From 7960d93c474aa5c3b4c81ab3fc8f03e39afe6e8d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 6 Jul 2026 17:09:26 -0500 Subject: [PATCH] feat(apify): deterministic digest template with direct links to each new post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the per-send LLM email body (nondeterministic branding, vendor jargon reaching customers, no reliable post links) with a shared deterministic renderer: stable subject/branding, one section per platform, a direct link to every genuinely-new post, chat CTA secondary. Used by both the batch digest and the legacy solo Instagram alert, which now also requires new-post URLs and is BCC-only (recoupable/chat#1855, PR #5 of 6; supersedes the interim body from PR #4 and, for this file, the standalone BCC fix in api#758 — same invariant, tests included). Co-Authored-By: Claude Fable 5 --- .../__tests__/sendApifyWebhookEmail.test.ts | 62 +++++++++++++++++++ .../__tests__/renderScrapeDigestHtml.test.ts | 39 ++++++++++++ lib/apify/digest/renderScrapeDigestHtml.ts | 53 ++++++++++++++++ lib/apify/digest/sendScrapeDigestEmail.ts | 1 + ...ndleInstagramProfileScraperResults.test.ts | 2 +- .../handleInstagramProfileScraperResults.ts | 1 + lib/apify/sendApifyWebhookEmail.ts | 55 ++++++---------- 7 files changed, 177 insertions(+), 36 deletions(-) create mode 100644 lib/apify/__tests__/sendApifyWebhookEmail.test.ts create mode 100644 lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts create mode 100644 lib/apify/digest/renderScrapeDigestHtml.ts diff --git a/lib/apify/__tests__/sendApifyWebhookEmail.test.ts b/lib/apify/__tests__/sendApifyWebhookEmail.test.ts new file mode 100644 index 00000000..65d60d36 --- /dev/null +++ b/lib/apify/__tests__/sendApifyWebhookEmail.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sendApifyWebhookEmail } from "@/lib/apify/sendApifyWebhookEmail"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; + +const sendEmailWithResend = vi.fn(); +vi.mock("@/lib/emails/sendEmail", () => ({ + sendEmailWithResend: (...a: unknown[]) => sendEmailWithResend(...a), +})); +vi.mock("@/lib/composio/getFrontendBaseUrl", () => ({ + getFrontendBaseUrl: () => "https://chat.recoupable.dev", +})); + +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; +const NEW_URLS = ["https://instagram.com/p/new1"]; + +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"]; + await sendApifyWebhookEmail(PROFILE, recipients, NEW_URLS); + 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, NEW_URLS); + const payload = sendEmailWithResend.mock.calls[0][0] as Record; + expect([payload.to, payload.cc].flat().filter(Boolean)).toEqual([RECOUP_FROM_EMAIL]); + }); + + it("links the new posts in the deterministic body — no vendor jargon", async () => { + await sendApifyWebhookEmail(PROFILE, ["a@b.com"], NEW_URLS); + const payload = sendEmailWithResend.mock.calls[0][0] as Record; + expect(payload.html).toContain('href="https://instagram.com/p/new1"'); + expect((payload.html + payload.subject).toLowerCase()).not.toContain("apify"); + }); + + it("returns null and sends nothing when there are no recipients", async () => { + expect(await sendApifyWebhookEmail(PROFILE, [], NEW_URLS)).toBeNull(); + expect(sendEmailWithResend).not.toHaveBeenCalled(); + }); + + it("returns null and sends nothing when no post is genuinely new", async () => { + expect(await sendApifyWebhookEmail(PROFILE, ["a@b.com"], [])).toBeNull(); + expect(sendEmailWithResend).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts b/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts new file mode 100644 index 00000000..3562f731 --- /dev/null +++ b/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml"; + +const SECTIONS = [ + { + platform: "instagram", + postUrls: ["https://instagram.com/p/abc", "https://instagram.com/p/def"], + }, + { platform: "tiktok", postUrls: ["https://tiktok.com/@a/video/1"] }, +]; + +describe("renderScrapeDigestHtml", () => { + it("links every new post, grouped under its platform", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + for (const s of SECTIONS) for (const u of s.postUrls) expect(html).toContain(`href="${u}"`); + expect(html.toLowerCase()).toContain("instagram"); + expect(html.toLowerCase()).toContain("tiktok"); + }); + + it("is deterministic — identical input renders identical output", () => { + const a = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + const b = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(a).toEqual(b); + }); + + it("never leaks internal vendor jargon to customers", () => { + const { html, subject } = renderScrapeDigestHtml({ + sections: SECTIONS, + artistName: "Ashnikko", + }); + expect((html + subject).toLowerCase()).not.toContain("apify"); + expect((html + subject).toLowerCase()).not.toContain("dataset"); + }); + + it("counts posts and platforms in the subject", () => { + const { subject } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(subject).toBe("Ashnikko: 3 new posts across 2 platforms"); + }); +}); diff --git a/lib/apify/digest/renderScrapeDigestHtml.ts b/lib/apify/digest/renderScrapeDigestHtml.ts new file mode 100644 index 00000000..f0583a3a --- /dev/null +++ b/lib/apify/digest/renderScrapeDigestHtml.ts @@ -0,0 +1,53 @@ +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; +import type { ScrapeDigestSection } from "@/lib/apify/digest/sendScrapeDigestEmail"; + +const PLATFORM_LABELS: Record = { + instagram: "Instagram", + tiktok: "TikTok", + x: "X", + twitter: "X", + youtube: "YouTube", + facebook: "Facebook", + threads: "Threads", +}; + +/** + * Deterministic house-style renderer for the new-posts digest (chat#1855). + * Replaces the per-send LLM body: identical input renders identical output, + * every genuinely-new post is linked directly, and internal vendor jargon + * never reaches customers. + */ +export function renderScrapeDigestHtml({ + sections, + artistName, +}: { + sections: ScrapeDigestSection[]; + artistName?: string | null; +}): { subject: string; html: string } { + const who = artistName || "Your artist"; + const total = sections.reduce((n, s) => n + s.postUrls.length, 0); + const subject = `${who}: ${total} new post${total === 1 ? "" : "s"} across ${sections.length} platform${sections.length === 1 ? "" : "s"}`; + + const sectionHtml = sections + .map(section => { + const label = PLATFORM_LABELS[section.platform.toLowerCase()] ?? section.platform; + const items = section.postUrls + .map(url => { + const href = url.startsWith("http") ? url : `https://${url}`; + return `
  • ${href}
  • `; + }) + .join(""); + return `

    ${label}

      ${items}
    `; + }) + .join(""); + + const chatUrl = `${getFrontendBaseUrl()}/?q=${encodeURIComponent("tell me about my artist's latest posts")}`; + const html = `
    +

    New posts from ${who}

    +

    We found ${total} new post${total === 1 ? "" : "s"} since we last checked:

    +${sectionHtml} +

    Ask Recoup about these posts →

    +
    `; + + return { subject, html }; +} diff --git a/lib/apify/digest/sendScrapeDigestEmail.ts b/lib/apify/digest/sendScrapeDigestEmail.ts index c75df694..3ae915bb 100644 --- a/lib/apify/digest/sendScrapeDigestEmail.ts +++ b/lib/apify/digest/sendScrapeDigestEmail.ts @@ -1,5 +1,6 @@ import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; +import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml"; export type ScrapeDigestSection = { platform: string; postUrls: string[] }; export type ScrapeDigestInput = { diff --git a/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts b/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts index 2a985014..d8310dbd 100644 --- a/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts +++ b/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts @@ -97,7 +97,7 @@ describe("handleInstagramProfileScraperResults", () => { expect(upsertPosts).toHaveBeenCalledOnce(); expect(upsertSocialPosts).toHaveBeenCalledOnce(); - expect(sendApifyWebhookEmail).toHaveBeenCalledWith(expect.any(Object), ["x@y.com"]); + expect(sendApifyWebhookEmail).toHaveBeenCalledWith(expect.any(Object), ["x@y.com"], ["u1"]); expect(handleInstagramProfileFollowUpRuns).toHaveBeenCalledOnce(); expect(result.social).toEqual({ id: "s1" }); expect(result.posts).toEqual(posts); diff --git a/lib/apify/instagram/handleInstagramProfileScraperResults.ts b/lib/apify/instagram/handleInstagramProfileScraperResults.ts index 3244e8eb..de38d4c4 100644 --- a/lib/apify/instagram/handleInstagramProfileScraperResults.ts +++ b/lib/apify/instagram/handleInstagramProfileScraperResults.ts @@ -112,6 +112,7 @@ export async function handleInstagramProfileScraperResults(parsed: ApifyWebhookP sentEmails = await sendApifyWebhookEmail( firstResult, accountEmails.map(e => e.email).filter(Boolean), + newPostUrls, ); } } catch (error) { diff --git a/lib/apify/sendApifyWebhookEmail.ts b/lib/apify/sendApifyWebhookEmail.ts index 051db149..5503b361 100644 --- a/lib/apify/sendApifyWebhookEmail.ts +++ b/lib/apify/sendApifyWebhookEmail.ts @@ -1,54 +1,39 @@ -import generateText from "@/lib/ai/generateText"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; -import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; +import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml"; import type { ApifyInstagramProfileResult } from "@/lib/apify/types"; /** - * Sends an Apify-webhook summary email to the given recipients using - * Resend. Generates the email body via an LLM based on the first - * profile result. + * Sends the legacy single-platform Instagram alert (non-batch scrape runs). + * Batch runs get the consolidated digest instead. Body comes from the shared + * deterministic renderer — the per-send LLM body is gone (chat#1855): stable + * branding, direct links to the new posts, no vendor jargon. * * @param profile - First dataset result from the profile scraper. - * @param emails - Recipient email addresses. - * @returns The Resend response, or `null` when there are no recipients. + * @param emails - Recipient email addresses (cross-tenant — BCC only). + * @param newPostUrls - Post URLs genuinely new to the platform. + * @returns The Resend response, or `null` when there is nothing to send. */ export async function sendApifyWebhookEmail( profile: ApifyInstagramProfileResult, emails: string[], + newPostUrls: string[] = [], ) { - if (!emails?.length) return null; + if (!emails?.length || !newPostUrls.length) return null; - const prompt = `You have a new Apify dataset update. Here is the data: - -Key Data -Full Name: ${profile.fullName} -Username: ${profile.username} -Profile URL: ${profile.url} -Profile Picture: ${profile.profilePicUrl} -Biography: ${profile.biography} -Followers: ${profile.followersCount} -Following: ${profile.followsCount} -Latest Posts: ${(Array.isArray(profile.latestPosts) ? profile.latestPosts : []).map(p => JSON.stringify(p)).join(", ")} -`; - - const { text } = await generateText({ - system: `you are a record label services manager for Recoup. - write beautiful html email. - subject: New Apify Dataset Notification. you're notifying music managers about new posts being available for one of their roster musician's Instagram profile. - include a link to view the instagram profile. - call to action is to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents): ${getFrontendBaseUrl()}/?q=tell%20me%20about%20my%20latest%20Ig%20posts - You'll be passed a dataset summary for a musician profile and their latest posts on instagram. - your goal is to get the recipient to click a cta link to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents). - only include the email body html. - no headers or subject`, - prompt, + const { subject, html } = renderScrapeDigestHtml({ + sections: [{ platform: "instagram", postUrls: newPostUrls }], + artistName: profile.fullName ?? profile.username ?? null, }); + // 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, - subject: `${profile.fullName ?? profile.username ?? "Your artist"} has new posts on Instagram`, - html: text, + to: [RECOUP_FROM_EMAIL], + bcc: emails, + subject, + html, }); }