Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,81 @@ describe('dispatchLifecycleAppOwnedJob', () => {
expect(sent?.html).toContain(
'<a href="https://threadplane.ai/whitepapers/chat.pdf">'
);
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 () => {
Expand Down
52 changes: 32 additions & 20 deletions apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type GrowthJob,
type GrowthTokenKey,
type RecipientDeliveryPolicy,
type RecipientAttachment,
type RecipientEmailInput,
type RecipientSendResult,
type SqlExecutor,
Expand Down Expand Up @@ -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<GrowthDispatchResult> {
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
);
Expand Down Expand Up @@ -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
);
Expand All @@ -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
);
}

Expand Down
39 changes: 34 additions & 5 deletions apps/lifecycle/src/fulfillment/templates.spec.ts
Original file line number Diff line number Diff line change
@@ -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' }),
Expand All @@ -26,38 +38,55 @@ 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,
});

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' });

Expand Down Expand Up @@ -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([]);
}
Expand Down
27 changes: 26 additions & 1 deletion apps/lifecycle/src/fulfillment/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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':
Expand Down
Loading
Loading