Skip to content

Commit 3f941d4

Browse files
bloveclaude
andauthored
feat(lifecycle): fulfillment mail in the campaign register (#1018)
Rewrite the whitepaper, newsletter, contact, pricing, and project-connect fulfillment templates in Brian's voice: no contractions, no "thanks for" openers, one thought per line, no marketing tone. Every factual element stays: the four PDF links, the newsletter promise, the reply commitment, and the claimed project signals. Fulfillment mail now opens with the same "Hey <first name>," greeting as campaign steps, read from the job context the fulfill branch already loads. The four whitepaper PDFs join the approved recipient-copy links so every fulfillment body passes campaignDraftViolations. Specs assert the register (no contractions, no greeting baked into the body, short lines), run every fulfillment message through the shared draft checks, and cover the greeting plus its "Hey there," fallback on the fulfill path. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 56e8875 commit 3f941d4

5 files changed

Lines changed: 118 additions & 30 deletions

File tree

apps/lifecycle/src/campaign/send.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,37 @@ describe('dispatchLifecycleAppOwnedJob', () => {
548548
}),
549549
deps.recipientPolicy
550550
);
551+
const sent = vi.mocked(deps.sendRecipient).mock.calls[0]?.[1];
552+
expect(sent?.text.startsWith('Hey Ada,\n\nHere is the guide')).toBe(true);
553+
expect(
554+
sent?.html?.startsWith('<p>Hey Ada,</p>\n<p>Here is the guide')
555+
).toBe(true);
556+
expect(sent?.html).toContain(
557+
'<a href="https://threadplane.ai/whitepapers/chat.pdf">'
558+
);
559+
});
560+
561+
it('falls back to the generic greeting on fulfillment when the display name is unusable', async () => {
562+
const deps = dependencies({
563+
readJobContext: vi
564+
.fn()
565+
.mockResolvedValue(
566+
context({ displayName: 'Click https://evil.example' })
567+
),
568+
});
569+
const fulfill = job('fulfill', {
570+
form_kind: 'newsletter',
571+
submission_id: '00000000-0000-4000-8000-000000000012',
572+
});
573+
574+
await expect(
575+
dispatchLifecycleAppOwnedJob({} as SqlExecutor, fulfill, {}, deps)
576+
).resolves.toBe('completed');
577+
const sent = vi.mocked(deps.sendRecipient).mock.calls[0]?.[1];
578+
expect(sent?.text.startsWith('Hey there,\n\nYou are on the list.')).toBe(
579+
true
580+
);
581+
expect(sent?.text).not.toContain('evil.example');
551582
});
552583

553584
it('builds one bounded enrichment artifact and persists it once', async () => {

apps/lifecycle/src/campaign/send.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,8 @@ const PLAIN_NAME_PATTERN = /^[A-Za-z'’.-]+(?:\s[A-Za-z'’.-]+){0,5}$/u;
259259

260260
/**
261261
* "Hey <first name>," when the persisted display name is a plain name and its
262-
* first word is a plain first name; otherwise "Hey there,". Display names are
262+
* first word is a plain first name; otherwise "Hey there,". Campaign steps and
263+
* fulfillment mail both open with it. Display names are
263264
* free-text form input, so a name carrying digits, punctuation, or a URL
264265
* anywhere is discarded as a whole and never reaches the email.
265266
*/
@@ -484,12 +485,15 @@ export async function dispatchLifecycleAppOwnedJob(
484485
{ contactId: context.contactId, issuedAt: now, eventNonce: job.id },
485486
dependencies.tokenKey
486487
);
488+
// Fulfillment mail is the first message a contact gets from Brian, so it
489+
// opens the same way every campaign step does.
490+
const body = `${campaignGreeting(context.displayName)}\n\n${message.body}`;
487491
return dispatchRecipient(
488492
executor,
489493
job,
490494
message.subject,
491-
signedText(message.body, unsubscribeUrl),
492-
signedHtml(message.body, unsubscribeUrl),
495+
signedText(body, unsubscribeUrl),
496+
signedHtml(body, unsubscribeUrl),
493497
unsubscribeUrl,
494498
signal,
495499
dependencies

apps/lifecycle/src/campaign/templates.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,17 @@ const CampaignStepSchema = z.enum(['immediate', 'day-3', 'day-8']);
2121
export const FOUNDER_BOOKING_URL =
2222
'https://calendar.app.google/nK961tWHZd21izKR6';
2323

24+
/**
25+
* Every link recipient copy may carry, across campaign steps and fulfillment
26+
* mail. The four PDFs are the whitepaper fulfillment deliverables.
27+
*/
2428
const APPROVED_CAMPAIGN_LINKS = new Set([
2529
'https://threadplane.ai/docs',
2630
'https://threadplane.ai/pilot-to-prod',
31+
'https://threadplane.ai/whitepaper.pdf',
32+
'https://threadplane.ai/whitepapers/angular.pdf',
33+
'https://threadplane.ai/whitepapers/render.pdf',
34+
'https://threadplane.ai/whitepapers/chat.pdf',
2735
FOUNDER_BOOKING_URL,
2836
]);
2937
const URL_PATTERN = /https?:\/\/[^\s<>()"'\]}]+/giu;

apps/lifecycle/src/fulfillment/templates.spec.ts

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,24 @@
11
import { describe, expect, it } from 'vitest';
22

3+
import { campaignDraftViolations } from '../campaign/templates.js';
34
import { renderFulfillmentTemplate } from './templates.js';
45

56
const URL_PATTERN = /https:\/\/[^\s]+/gu;
67
const HTML_PATTERN = /<\/?[a-z][^>]*>/iu;
8+
const CONTRACTION_PATTERN = /\b\w+[']\w+\b/u;
9+
10+
function everyFulfillmentMessage() {
11+
return [
12+
renderFulfillmentTemplate({ context: 'whitepaper', paper: 'overview' }),
13+
renderFulfillmentTemplate({ context: 'newsletter' }),
14+
renderFulfillmentTemplate({ context: 'contact' }),
15+
renderFulfillmentTemplate({ context: 'pricing' }),
16+
renderFulfillmentTemplate({
17+
context: 'project-connect',
18+
claimedSignals: ['thread.persisted'],
19+
}),
20+
];
21+
}
722

823
describe('renderFulfillmentTemplate', () => {
924
it.each([
@@ -35,20 +50,24 @@ describe('renderFulfillmentTemplate', () => {
3550
paper,
3651
});
3752

38-
expect(message).toEqual({
39-
subject,
40-
body: `Here is the guide you requested:\n\n${url}`,
41-
});
53+
expect(message.subject).toBe(subject);
54+
expect(
55+
message.body.startsWith(`Here is the guide you requested:\n${url}\n\n`)
56+
).toBe(true);
57+
expect(message.body.match(URL_PATTERN)).toEqual([url]);
4258
}
4359
);
4460

4561
it('welcomes a newsletter signup without adding another request', () => {
46-
expect(renderFulfillmentTemplate({ context: 'newsletter' })).toEqual({
47-
subject: 'Welcome to Threadplane',
48-
body: expect.stringMatching(
49-
/^Thanks for signing up\. Ill keep these notes focused on practical engineering work with agent interfaces\.$/u
50-
),
51-
});
62+
const message = renderFulfillmentTemplate({ context: 'newsletter' });
63+
64+
expect(message.subject).toBe('Welcome to Threadplane');
65+
expect(message.body.startsWith('You are on the list.')).toBe(true);
66+
expect(message.body).toContain(
67+
'practical engineering work with agent interfaces'
68+
);
69+
expect(message.body).not.toMatch(URL_PATTERN);
70+
expect(message.body).not.toContain('?');
5271
});
5372

5473
it.each(['contact', 'pricing'] as const)(
@@ -82,8 +101,10 @@ describe('renderFulfillmentTemplate', () => {
82101
claimedSignals: [claim],
83102
});
84103

104+
expect(message.body.startsWith('You connected your project.')).toBe(true);
85105
expect(message.body).toContain(expectedFact);
86106
expect(message.body).toContain('you shared');
107+
expect(message.body).toContain('keep any follow-up to that context');
87108
expect(message.body).not.toMatch(
88109
/I saw you|we noticed|based on your activity|tracking|telemetry/iu
89110
);
@@ -131,19 +152,37 @@ describe('renderFulfillmentTemplate', () => {
131152
expect(() => renderFulfillmentTemplate(input as never)).toThrow();
132153
});
133154

134-
it('keeps every recipient message plain and compact', () => {
135-
const messages = [
136-
renderFulfillmentTemplate({ context: 'whitepaper', paper: 'overview' }),
137-
renderFulfillmentTemplate({ context: 'newsletter' }),
138-
renderFulfillmentTemplate({ context: 'contact' }),
139-
renderFulfillmentTemplate({ context: 'pricing' }),
140-
renderFulfillmentTemplate({
141-
context: 'project-connect',
142-
claimedSignals: ['thread.persisted'],
143-
}),
144-
];
155+
it('writes every recipient message in the campaign register', () => {
156+
for (const message of everyFulfillmentMessage()) {
157+
// No contractions, no "thanks for" openers, no greeting baked into the
158+
// body (send.ts adds "Hey <name>," at send time), and every line short.
159+
expect(message.body).not.toMatch(CONTRACTION_PATTERN);
160+
expect(message.body).not.toMatch(/^thanks/iu);
161+
expect(message.body).not.toMatch(/^hey\b/iu);
162+
expect(message.body).not.toMatch(/\blet[']?s\b/iu);
163+
for (const line of message.body.split('\n')) {
164+
expect(line.trim().split(/\s+/u).filter(Boolean).length).toBeLessThan(
165+
25
166+
);
167+
}
168+
}
169+
});
170+
171+
it('stays inside the recipient-copy checks shared with the campaign', () => {
172+
for (const message of everyFulfillmentMessage()) {
173+
expect(campaignDraftViolations(message)).toEqual([]);
174+
}
175+
for (const paper of ['angular', 'render', 'chat'] as const) {
176+
expect(
177+
campaignDraftViolations(
178+
renderFulfillmentTemplate({ context: 'whitepaper', paper })
179+
)
180+
).toEqual([]);
181+
}
182+
});
145183

146-
for (const message of messages) {
184+
it('keeps every recipient message plain and compact', () => {
185+
for (const message of everyFulfillmentMessage()) {
147186
expect(typeof message.subject).toBe('string');
148187
expect(typeof message.body).toBe('string');
149188
expect(message.subject).not.toMatch(/[\r\n]/u);

apps/lifecycle/src/fulfillment/templates.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ const PROJECT_FACTS: Record<z.infer<typeof ProjectSignalSchema>, string> = {
7070
'project.returned_7d': 'returned to the project within a week',
7171
};
7272

73+
/**
74+
* Recipient copy in Brian's register: no contractions, no "thanks for"
75+
* openers, one thought per line, no marketing tone. The "Hey <name>,"
76+
* greeting is added at send time; every body here stays inside the
77+
* recipient-copy checks in campaign/templates.ts.
78+
*/
7379
export function renderFulfillmentTemplate(
7480
candidate: unknown
7581
): RecipientTemplate {
@@ -80,23 +86,23 @@ export function renderFulfillmentTemplate(
8086
const paper = WHITEPAPERS[input.paper];
8187
return {
8288
subject: paper.subject,
83-
body: `Here is the guide you requested:\n\n${paper.url}`,
89+
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.`,
8490
};
8591
}
8692
case 'newsletter':
8793
return {
8894
subject: 'Welcome to Threadplane',
89-
body: 'Thanks for signing up. I’ll keep these notes focused on practical engineering work with agent interfaces.',
95+
body: 'You are on the list.\n\nI write these notes about practical engineering work with agent interfaces.\nStreaming, state, interrupts, and the boundaries that make them testable.\nNo hype.\n\nIf one of them misses the mark, reply and tell me.',
9096
};
9197
case 'contact':
9298
return {
9399
subject: 'Your contact request',
94-
body: 'Thanks for reaching out. I’ll reply to the contact request you submitted.',
100+
body: 'I have your contact request.\nI will read it and reply myself.',
95101
};
96102
case 'pricing':
97103
return {
98104
subject: 'Your pricing request',
99-
body: 'Thanks for reaching out. I’ll reply to the pricing request you submitted.',
105+
body: 'I have your pricing request.\nI will read it and reply myself.',
100106
};
101107
case 'project-connect': {
102108
const facts = input.claimedSignals.map((signal) => PROJECT_FACTS[signal]);
@@ -106,7 +112,7 @@ export function renderFulfillmentTemplate(
106112
: `${facts.slice(0, -1).join(', ')}, and ${facts.at(-1)}`;
107113
return {
108114
subject: 'Your connected Threadplane project',
109-
body: `Thanks for explicitly connecting your project. In that connection, you shared that you ${joinedFacts}. I’ll keep any follow-up to that context.`,
115+
body: `You connected your project.\nIn that connection, you shared that you ${joinedFacts}.\n\nI will keep any follow-up to that context.\nNothing else.`,
110116
};
111117
}
112118
}

0 commit comments

Comments
 (0)