From c3be551430c5888abc9f0e7934fd05e09211bff0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 9 Sep 2026 10:24:19 -0700 Subject: [PATCH] feat(lifecycle): attach the whitepaper PDF to the fulfillment email The whitepaper fulfillment email pointed at a URL. It now carries the guide as an attachment, with the link kept underneath as the fallback for mail gateways that strip attachments from an unfamiliar sender. Resend fetches the bytes from the public threadplane.ai path itself, so no PDF flows through the lifecycle function and the attachment is always whatever generate-whitepaper.ts last deployed. Because Resend performs that fetch under the Threadplane sender, the attachment is a closed registry rather than a free-form field: only fulfill jobs may carry one, at most one, the filename must be a lowercase PDF name, and the path must be one of the four approved deliverables. Every guard is mutation-tested. dispatchRecipient took nine positional parameters and would have taken ten, so its message-shaped arguments collapse into one object. Co-Authored-By: Claude Opus 5 --- apps/lifecycle/src/campaign/send.spec.ts | 75 ++++++++ apps/lifecycle/src/campaign/send.ts | 52 ++++-- .../src/fulfillment/templates.spec.ts | 39 +++- apps/lifecycle/src/fulfillment/templates.ts | 27 ++- libs/growth/src/lib/resend.spec.ts | 174 ++++++++++++++++++ libs/growth/src/lib/resend.ts | 65 +++++++ 6 files changed, 406 insertions(+), 26 deletions(-) diff --git a/apps/lifecycle/src/campaign/send.spec.ts b/apps/lifecycle/src/campaign/send.spec.ts index f79743eb3..7d86163b5 100644 --- a/apps/lifecycle/src/campaign/send.spec.ts +++ b/apps/lifecycle/src/campaign/send.spec.ts @@ -567,6 +567,81 @@ describe('dispatchLifecycleAppOwnedJob', () => { expect(sent?.html).toContain( '' ); + expect(sent?.attachments).toEqual([ + { + filename: 'angular-chat-guide.pdf', + path: 'https://threadplane.ai/whitepapers/chat.pdf', + }, + ]); + }); + + it.each([ + ['overview', 'angular-agent-readiness-guide.pdf', 'whitepaper.pdf'], + ['angular', 'angular-streaming-guide.pdf', 'whitepapers/angular.pdf'], + ['render', 'angular-genui-guide.pdf', 'whitepapers/render.pdf'], + ['chat', 'angular-chat-guide.pdf', 'whitepapers/chat.pdf'], + ] as const)( + 'attaches the requested %s guide to the fulfillment message', + async (paper, filename, path) => { + const deps = dependencies(); + + await expect( + dispatchLifecycleAppOwnedJob( + {} as SqlExecutor, + job('fulfill', { + form_kind: 'whitepaper', + paper, + submission_id: '00000000-0000-4000-8000-000000000012', + }), + {}, + deps + ) + ).resolves.toBe('completed'); + + expect( + vi.mocked(deps.sendRecipient).mock.calls[0]?.[1].attachments + ).toEqual([{ filename, path: `https://threadplane.ai/${path}` }]); + } + ); + + it.each(['newsletter', 'contact', 'pricing'] as const)( + 'attaches no file to a %s fulfillment', + async (formKind) => { + const deps = dependencies(); + + await expect( + dispatchLifecycleAppOwnedJob( + {} as SqlExecutor, + job('fulfill', { + form_kind: formKind, + submission_id: '00000000-0000-4000-8000-000000000012', + }), + {}, + deps + ) + ).resolves.toBe('completed'); + + expect( + vi.mocked(deps.sendRecipient).mock.calls[0]?.[1] + ).not.toHaveProperty('attachments'); + } + ); + + it('never attaches a file to a campaign step', async () => { + const deps = dependencies(); + + await expect( + dispatchLifecycleAppOwnedJob( + {} as SqlExecutor, + job('send_step', { campaign_version: 'v1', step: 1 }), + {}, + deps + ) + ).resolves.toBe('completed'); + + expect(vi.mocked(deps.sendRecipient).mock.calls[0]?.[1]).not.toHaveProperty( + 'attachments' + ); }); it('falls back to the generic greeting on fulfillment when the display name is unusable', async () => { diff --git a/apps/lifecycle/src/campaign/send.ts b/apps/lifecycle/src/campaign/send.ts index 7b1ac7b5c..a92d2a183 100644 --- a/apps/lifecycle/src/campaign/send.ts +++ b/apps/lifecycle/src/campaign/send.ts @@ -26,6 +26,7 @@ import { type GrowthJob, type GrowthTokenKey, type RecipientDeliveryPolicy, + type RecipientAttachment, type RecipientEmailInput, type RecipientSendResult, type SqlExecutor, @@ -367,30 +368,34 @@ function enrichmentDrafts(context: LifecycleJobContext): CampaignDraft[] { }); } +interface RecipientMessage { + subject: string; + text: string; + html: string; + unsubscribeUrl: UnsubscribeActionUrl; + campaignTemplate?: CampaignTemplateId; + attachment?: RecipientAttachment; +} + async function dispatchRecipient( executor: SqlExecutor, job: GrowthJob, - subject: string, - text: string, - html: string, - unsubscribeUrl: UnsubscribeActionUrl, + message: RecipientMessage, signal: AbortSignal, - dependencies: LifecycleJobDependencies, - campaignTemplate?: CampaignTemplateId + dependencies: LifecycleJobDependencies ): Promise { const leaseToken = requireLease(job); signal.throwIfAborted(); + const { campaignTemplate, attachment, ...parts } = message; const result = await dependencies.sendRecipient( executor, { jobId: job.id, leaseToken, - subject, - text, - html, - unsubscribeUrl, + ...parts, signal, ...(campaignTemplate === undefined ? {} : { campaignTemplate }), + ...(attachment === undefined ? {} : { attachments: [attachment] }), }, dependencies.recipientPolicy ); @@ -469,10 +474,15 @@ export async function dispatchLifecycleAppOwnedJob( return dispatchRecipient( executor, job, - message.subject, - signedText(body, unsubscribeUrl), - signedHtml(body, unsubscribeUrl), - unsubscribeUrl, + { + subject: message.subject, + text: signedText(body, unsubscribeUrl), + html: signedHtml(body, unsubscribeUrl), + unsubscribeUrl, + ...(message.attachment === undefined + ? {} + : { attachment: message.attachment }), + }, signal, dependencies ); @@ -491,13 +501,15 @@ export async function dispatchLifecycleAppOwnedJob( return dispatchRecipient( executor, job, - message.subject, - message.text, - message.html, - unsubscribeUrl, + { + subject: message.subject, + text: message.text, + html: message.html, + unsubscribeUrl, + campaignTemplate: message.template, + }, signal, - dependencies, - message.template + dependencies ); } diff --git a/apps/lifecycle/src/fulfillment/templates.spec.ts b/apps/lifecycle/src/fulfillment/templates.spec.ts index 742c1617a..e7a5ee42c 100644 --- a/apps/lifecycle/src/fulfillment/templates.spec.ts +++ b/apps/lifecycle/src/fulfillment/templates.spec.ts @@ -1,12 +1,24 @@ import { describe, expect, it } from 'vitest'; import { campaignDraftViolations } from '../campaign/templates.js'; -import { renderFulfillmentTemplate } from './templates.js'; +import { + renderFulfillmentTemplate, + type RecipientTemplate, +} from './templates.js'; const URL_PATTERN = /https:\/\/[^\s]+/gu; const HTML_PATTERN = /<\/?[a-z][^>]*>/iu; const CONTRACTION_PATTERN = /\b\w+['’]\w+\b/u; +/** + * The shared copy checks reject unknown fields, so they see the copy only. + * The attachment is checked separately, and again against the closed path + * registry in libs/growth before submission. + */ +function copyOf(message: RecipientTemplate) { + return { subject: message.subject, body: message.body }; +} + function everyFulfillmentMessage() { return [ renderFulfillmentTemplate({ context: 'whitepaper', paper: 'overview' }), @@ -26,25 +38,29 @@ describe('renderFulfillmentTemplate', () => { 'overview', 'Your Angular agent readiness guide', 'https://threadplane.ai/whitepaper.pdf', + 'angular-agent-readiness-guide.pdf', ], [ 'angular', 'Your Angular streaming guide', 'https://threadplane.ai/whitepapers/angular.pdf', + 'angular-streaming-guide.pdf', ], [ 'render', 'Your Angular generative UI guide', 'https://threadplane.ai/whitepapers/render.pdf', + 'angular-genui-guide.pdf', ], [ 'chat', 'Your Angular agent chat guide', 'https://threadplane.ai/whitepapers/chat.pdf', + 'angular-chat-guide.pdf', ], ] as const)( 'fulfills the exact requested %s resource without broader state', - (paper, subject, url) => { + (paper, subject, url, filename) => { const message = renderFulfillmentTemplate({ context: 'whitepaper', paper, @@ -52,12 +68,25 @@ describe('renderFulfillmentTemplate', () => { expect(message.subject).toBe(subject); expect( - message.body.startsWith(`Here is the guide you requested:\n${url}\n\n`) + message.body.startsWith( + 'Here is the guide you requested, attached to this message.\n\n' + ) ).toBe(true); + // The link survives only as the stripped-attachment fallback. + expect(message.body).toContain( + `If the attachment does not come through, it is also here:\n${url}` + ); expect(message.body.match(URL_PATTERN)).toEqual([url]); + expect(message.attachment).toEqual({ filename, path: url }); } ); + it('attaches no file to any non-whitepaper fulfillment', () => { + for (const message of everyFulfillmentMessage().slice(1)) { + expect(message.attachment).toBeUndefined(); + } + }); + it('welcomes a newsletter signup without adding another request', () => { const message = renderFulfillmentTemplate({ context: 'newsletter' }); @@ -170,12 +199,12 @@ describe('renderFulfillmentTemplate', () => { it('stays inside the recipient-copy checks shared with the campaign', () => { for (const message of everyFulfillmentMessage()) { - expect(campaignDraftViolations(message)).toEqual([]); + expect(campaignDraftViolations(copyOf(message))).toEqual([]); } for (const paper of ['angular', 'render', 'chat'] as const) { expect( campaignDraftViolations( - renderFulfillmentTemplate({ context: 'whitepaper', paper }) + copyOf(renderFulfillmentTemplate({ context: 'whitepaper', paper })) ) ).toEqual([]); } diff --git a/apps/lifecycle/src/fulfillment/templates.ts b/apps/lifecycle/src/fulfillment/templates.ts index 0eb3dfc62..a5020f6a8 100644 --- a/apps/lifecycle/src/fulfillment/templates.ts +++ b/apps/lifecycle/src/fulfillment/templates.ts @@ -37,27 +37,49 @@ export type FulfillmentTemplateInput = z.infer< typeof FulfillmentTemplateInputSchema >; +/** + * A file the recipient message carries. `path` is the public URL Resend + * fetches the bytes from at send time, so the attachment is always the + * currently deployed PDF; `filename` is what the recipient sees. Both are + * re-checked against a closed registry in libs/growth before submission. + */ +export interface RecipientAttachment { + readonly filename: string; + readonly path: string; +} + export interface RecipientTemplate { readonly subject: string; readonly body: string; + /** Set only by the whitepaper context; every other context sends no file. */ + readonly attachment?: RecipientAttachment; } +/** + * The filenames match the ones apps/website WhitePaperForm.tsx puts on the + * on-page download, so the attachment and the direct download are the same + * name to a contact who takes both. + */ const WHITEPAPERS = { overview: { subject: 'Your Angular agent readiness guide', url: 'https://threadplane.ai/whitepaper.pdf', + filename: 'angular-agent-readiness-guide.pdf', }, angular: { subject: 'Your Angular streaming guide', url: 'https://threadplane.ai/whitepapers/angular.pdf', + filename: 'angular-streaming-guide.pdf', }, render: { subject: 'Your Angular generative UI guide', url: 'https://threadplane.ai/whitepapers/render.pdf', + filename: 'angular-genui-guide.pdf', }, chat: { subject: 'Your Angular agent chat guide', url: 'https://threadplane.ai/whitepapers/chat.pdf', + filename: 'angular-chat-guide.pdf', }, } as const; @@ -84,9 +106,12 @@ export function renderFulfillmentTemplate( switch (input.context) { case 'whitepaper': { const paper = WHITEPAPERS[input.paper]; + // The guide rides along as an attachment. The link stays as a fallback + // for mail gateways that strip attachments from an unfamiliar sender. return { subject: paper.subject, - body: `Here is the guide you requested:\n${paper.url}\n\nRead it when you have a quiet hour.\nIf something in it does not hold up in your own code, reply and tell me.`, + body: `Here is the guide you requested, attached to this message.\n\nIf the attachment does not come through, it is also here:\n${paper.url}\n\nRead it when you have a quiet hour.\nIf something in it does not hold up in your own code, reply and tell me.`, + attachment: { filename: paper.filename, path: paper.url }, }; } case 'newsletter': diff --git a/libs/growth/src/lib/resend.spec.ts b/libs/growth/src/lib/resend.spec.ts index 39ce4990f..fcee95bd0 100644 --- a/libs/growth/src/lib/resend.spec.ts +++ b/libs/growth/src/lib/resend.spec.ts @@ -9,6 +9,7 @@ import { unsubscribeActionUrlValue, } from './tokens.ts'; import { + APPROVED_ATTACHMENT_PATHS, RECIPIENT_EMAIL_SENDER, sendRecipientEmail, type RecipientDeliveryPolicy, @@ -136,6 +137,19 @@ function harness(overrides: { job?: GrowthJob; response?: unknown } = {}) { }; } +const APPROVED_GUIDE = { + filename: 'angular-streaming-guide.pdf', + path: 'https://threadplane.ai/whitepapers/angular.pdf', +}; + +function fulfillmentJob() { + return job({ + kind: 'fulfill', + idempotencyKey: 'fulfill:whitepaper:contact', + payload: { fulfillment_kind: 'whitepaper' }, + }); +} + const message = { jobId, leaseToken, @@ -370,6 +384,166 @@ describe('sendRecipientEmail', () => { }); }); + it('attaches an approved deliverable to fulfillment mail', async () => { + const test = harness({ job: fulfillmentJob() }); + + await sendRecipientEmail( + test.database, + { + ...message, + campaignTemplate: undefined, + attachments: [ + { + filename: 'angular-streaming-guide.pdf', + path: 'https://threadplane.ai/whitepapers/angular.pdf', + }, + ], + }, + productionPolicy(), + test.dependencies + ); + + expect(test.send.mock.calls[0]?.[0]).toMatchObject({ + attachments: [ + { + filename: 'angular-streaming-guide.pdf', + path: 'https://threadplane.ai/whitepapers/angular.pdf', + }, + ], + }); + }); + + it.each(APPROVED_ATTACHMENT_PATHS)( + 'submits the approved deliverable path %s unchanged', + async (path) => { + const test = harness({ job: fulfillmentJob() }); + + await sendRecipientEmail( + test.database, + { + ...message, + campaignTemplate: undefined, + attachments: [{ filename: 'guide.pdf', path }], + }, + productionPolicy(), + test.dependencies + ); + + expect(test.send.mock.calls[0]?.[0]).toMatchObject({ + attachments: [{ filename: 'guide.pdf', path }], + }); + } + ); + + it('sends no attachments key when fulfillment mail carries no file', async () => { + const test = harness({ job: fulfillmentJob() }); + + await sendRecipientEmail( + test.database, + { ...message, campaignTemplate: undefined }, + productionPolicy(), + test.dependencies + ); + + expect(test.send.mock.calls[0]?.[0]).not.toHaveProperty('attachments'); + }); + + it('rejects an attachment on a campaign step', async () => { + const test = harness(); + + await expect( + sendRecipientEmail( + test.database, + { + ...message, + attachments: [ + { + filename: 'angular-streaming-guide.pdf', + path: 'https://threadplane.ai/whitepapers/angular.pdf', + }, + ], + }, + productionPolicy(), + test.dependencies + ) + ).rejects.toThrow(/Only fulfill mail carries an attachment/u); + expect(test.send).not.toHaveBeenCalled(); + }); + + it.each([ + 'https://threadplane.ai/whitepapers/angular.pdf?x=1', + 'https://threadplane.ai/../etc/passwd', + 'https://evil.example/whitepapers/angular.pdf', + 'http://threadplane.ai/whitepaper.pdf', + 'https://threadplane.ai/whitepapers/Angular.pdf', + 'file:///etc/passwd', + '/whitepaper.pdf', + ])('rejects the off-registry attachment path %s', async (path) => { + const test = harness({ job: fulfillmentJob() }); + + await expect( + sendRecipientEmail( + test.database, + { + ...message, + campaignTemplate: undefined, + attachments: [{ filename: 'guide.pdf', path }], + }, + productionPolicy(), + test.dependencies + ) + ).rejects.toThrow(/attachment\.path/u); + expect(test.send).not.toHaveBeenCalled(); + }); + + it.each([ + '', + 'guide.exe', + 'guide.pdf.exe', + '../guide.pdf', + 'Guide.pdf', + 'guide report.pdf', + 'guide.pdf\nBcc: victim@example.com', + ])('rejects the unsafe attachment filename %j', async (filename) => { + const test = harness({ job: fulfillmentJob() }); + + await expect( + sendRecipientEmail( + test.database, + { + ...message, + campaignTemplate: undefined, + attachments: [ + { + filename, + path: 'https://threadplane.ai/whitepapers/angular.pdf', + }, + ], + }, + productionPolicy(), + test.dependencies + ) + ).rejects.toThrow(/attachment\.filename/u); + expect(test.send).not.toHaveBeenCalled(); + }); + + it.each([[[]], [[APPROVED_GUIDE, APPROVED_GUIDE]]])( + 'rejects an attachment list that is not exactly one file (%j)', + async (attachments) => { + const test = harness({ job: fulfillmentJob() }); + + await expect( + sendRecipientEmail( + test.database, + { ...message, campaignTemplate: undefined, attachments }, + productionPolicy(), + test.dependencies + ) + ).rejects.toThrow(/exactly one attachment/u); + expect(test.send).not.toHaveBeenCalled(); + } + ); + it('returns an explicit rejection for a resolved provider error without recording acceptance', async () => { const test = harness({ response: { diff --git a/libs/growth/src/lib/resend.ts b/libs/growth/src/lib/resend.ts index 9930ee767..c9b14667e 100644 --- a/libs/growth/src/lib/resend.ts +++ b/libs/growth/src/lib/resend.ts @@ -78,11 +78,39 @@ export function isCampaignTemplateId( return typeof value === 'string' && CAMPAIGN_TEMPLATE_ID_SET.has(value); } +/** + * Every file recipient mail may carry. Resend fetches the bytes from `path` + * itself, so the closed registry is the only thing standing between a caller + * and an arbitrary outbound fetch made under the Threadplane sender. Keep it + * to deployed threadplane.ai deliverables. + */ +export const APPROVED_ATTACHMENT_PATHS = [ + 'https://threadplane.ai/whitepaper.pdf', + 'https://threadplane.ai/whitepapers/angular.pdf', + 'https://threadplane.ai/whitepapers/render.pdf', + 'https://threadplane.ai/whitepapers/chat.pdf', +] as const; +const APPROVED_ATTACHMENT_PATH_SET: ReadonlySet = new Set( + APPROVED_ATTACHMENT_PATHS +); +const ATTACHMENT_FILENAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*\.pdf$/u; +const ATTACHMENT_JOB_KINDS = new Set(['fulfill']); + +export interface RecipientAttachment { + filename: string; + path: string; +} + export interface RecipientEmailInput { jobId: string; leaseToken: string; subject: string; text: string; + /** + * Files to attach. Only `fulfill` jobs may carry one, at most one, and only + * from the approved path registry above. + */ + attachments?: readonly RecipientAttachment[]; /** * Which campaign template rendered this message. Required for send_step * jobs and forbidden otherwise; it is emitted as the bounded @@ -112,6 +140,7 @@ export interface RecipientEmailProviderPayload { subject: string; text: string; html?: string; + attachments?: RecipientAttachment[]; headers: { 'List-Unsubscribe': string; 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'; @@ -354,6 +383,40 @@ function campaignTags( return tags; } +/** + * Resolve the attachments a leased job is allowed to submit. Like + * `campaignTags`, this runs after authorization because the rule depends on + * the authorized `job.kind`, and it throws rather than dropping a bad value + * so a mis-wired caller fails the job instead of silently sending nothing. + */ +function recipientAttachments( + kind: string, + attachments: readonly RecipientAttachment[] | undefined +): RecipientAttachment[] | undefined { + if (attachments === undefined) return undefined; + if (!ATTACHMENT_JOB_KINDS.has(kind)) { + throw new Error('Only fulfill mail carries an attachment'); + } + if (!Array.isArray(attachments) || attachments.length !== 1) { + throw new Error('Recipient mail carries exactly one attachment'); + } + return attachments.map((attachment) => { + const filename = requiredBoundedText( + 'attachment.filename', + attachment.filename, + 100 + ); + if (!ATTACHMENT_FILENAME_PATTERN.test(filename)) { + throw new Error('attachment.filename must be a lowercase PDF name'); + } + const path = requiredBoundedText('attachment.path', attachment.path, 200); + if (!APPROVED_ATTACHMENT_PATH_SET.has(path)) { + throw new Error('attachment.path must be an approved deliverable'); + } + return { filename, path }; + }); +} + export async function sendRecipientEmail( executor: SqlExecutor, input: RecipientEmailInput, @@ -428,6 +491,7 @@ export async function sendRecipientEmail( job.payload, input.campaignTemplate ); + const attachments = recipientAttachments(job.kind, input.attachments); input.signal?.throwIfAborted(); let response: ResendResponse; @@ -444,6 +508,7 @@ export async function sendRecipientEmail( subject, text, ...(html === undefined ? {} : { html }), + ...(attachments === undefined ? {} : { attachments }), headers: { 'List-Unsubscribe': `<${unsubscribeUrl}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',