From f178c949a03243366d9edb5f43ed758716fc2ffe Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 6 Jul 2026 16:52:42 -0500 Subject: [PATCH] =?UTF-8?q?fix(emails):=20BCC=20scrape-alert=20recipients?= =?UTF-8?q?=20=E2=80=94=20never=20a=20shared=20cross-tenant=20To:=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendApifyWebhookEmail put every watcher of a social (resolved across all customer accounts) into one visible To: header, disclosing customer identities and monitoring relationships to each other (observed live: a manager, an internal account, and a label contact in one To: line). Hard-coded: to is always ourselves, recipients ride bcc; a unit test asserts to/cc never carry more than our own address (recoupable/chat#1855 PR #1 of 6). Co-Authored-By: Claude Fable 5 --- .../__tests__/sendApifyWebhookEmail.test.ts | 54 +++++++++++++++++++ lib/apify/sendApifyWebhookEmail.ts | 6 ++- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 lib/apify/__tests__/sendApifyWebhookEmail.test.ts diff --git a/lib/apify/__tests__/sendApifyWebhookEmail.test.ts b/lib/apify/__tests__/sendApifyWebhookEmail.test.ts new file mode 100644 index 00000000..a2044f63 --- /dev/null +++ b/lib/apify/__tests__/sendApifyWebhookEmail.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sendApifyWebhookEmail } from "@/lib/apify/sendApifyWebhookEmail"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; + +vi.mock("@/lib/ai/generateText", () => ({ + default: vi.fn(async () => ({ text: "

body

" })), +})); +const sendEmailWithResend = vi.fn(); +vi.mock("@/lib/emails/sendEmail", () => ({ + sendEmailWithResend: (...a: unknown[]) => sendEmailWithResend(...a), +})); + +const PROFILE = { + fullName: "Ashnikko", + username: "ashnikko", + url: "https://instagram.com/ashnikko", + profilePicUrl: "https://example.com/pic.jpg", + biography: "bio", + followersCount: 100, + followsCount: 10, + latestPosts: [], +} as never; + +beforeEach(() => { + vi.clearAllMocks(); + sendEmailWithResend.mockResolvedValue({ id: "email-1" }); +}); + +describe("sendApifyWebhookEmail", () => { + it("BCCs recipients so no account can see another's address (chat#1855)", async () => { + const recipients = [ + "manager@customer-a.com", + "label@customer-b.com", + "internal@recoupable.com", + ]; + await sendApifyWebhookEmail(PROFILE, recipients); + const payload = sendEmailWithResend.mock.calls[0][0] as Record; + expect(payload.bcc).toEqual(recipients); + expect(payload.to).toEqual([RECOUP_FROM_EMAIL]); + }); + + it("never carries more than our own address in to/cc, regardless of recipient count", async () => { + const recipients = Array.from({ length: 25 }, (_, i) => `user${i}@example.com`); + await sendApifyWebhookEmail(PROFILE, recipients); + const payload = sendEmailWithResend.mock.calls[0][0] as Record; + const visible = [payload.to, payload.cc].flat().filter(Boolean); + expect(visible).toEqual([RECOUP_FROM_EMAIL]); + }); + + it("returns null and sends nothing when there are no recipients", async () => { + expect(await sendApifyWebhookEmail(PROFILE, [])).toBeNull(); + expect(sendEmailWithResend).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/apify/sendApifyWebhookEmail.ts b/lib/apify/sendApifyWebhookEmail.ts index 051db149..3510dccd 100644 --- a/lib/apify/sendApifyWebhookEmail.ts +++ b/lib/apify/sendApifyWebhookEmail.ts @@ -45,9 +45,13 @@ Latest Posts: ${(Array.isArray(profile.latestPosts) ? profile.latestPosts : []). prompt, }); + // Recipients span multiple customer accounts (the social's watchers are + // resolved cross-tenant), so they must NEVER share a visible To: line — + // BCC only, with ourselves as the required To: (chat#1855). return await sendEmailWithResend({ from: RECOUP_FROM_EMAIL, - to: emails, + to: [RECOUP_FROM_EMAIL], + bcc: emails, subject: `${profile.fullName ?? profile.username ?? "Your artist"} has new posts on Instagram`, html: text, });