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',