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
31 changes: 31 additions & 0 deletions apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,37 @@ describe('dispatchLifecycleAppOwnedJob', () => {
}),
deps.recipientPolicy
);
const sent = vi.mocked(deps.sendRecipient).mock.calls[0]?.[1];
expect(sent?.text.startsWith('Hey Ada,\n\nHere is the guide')).toBe(true);
expect(
sent?.html?.startsWith('<p>Hey Ada,</p>\n<p>Here is the guide')
).toBe(true);
expect(sent?.html).toContain(
'<a href="https://threadplane.ai/whitepapers/chat.pdf">'
);
});

it('falls back to the generic greeting on fulfillment when the display name is unusable', async () => {
const deps = dependencies({
readJobContext: vi
.fn()
.mockResolvedValue(
context({ displayName: 'Click https://evil.example' })
),
});
const fulfill = job('fulfill', {
form_kind: 'newsletter',
submission_id: '00000000-0000-4000-8000-000000000012',
});

await expect(
dispatchLifecycleAppOwnedJob({} as SqlExecutor, fulfill, {}, deps)
).resolves.toBe('completed');
const sent = vi.mocked(deps.sendRecipient).mock.calls[0]?.[1];
expect(sent?.text.startsWith('Hey there,\n\nYou are on the list.')).toBe(
true
);
expect(sent?.text).not.toContain('evil.example');
});

it('builds one bounded enrichment artifact and persists it once', async () => {
Expand Down
10 changes: 7 additions & 3 deletions apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@ const PLAIN_NAME_PATTERN = /^[A-Za-z'’.-]+(?:\s[A-Za-z'’.-]+){0,5}$/u;

/**
* "Hey <first name>," when the persisted display name is a plain name and its
* first word is a plain first name; otherwise "Hey there,". Display names are
* first word is a plain first name; otherwise "Hey there,". Campaign steps and
* fulfillment mail both open with it. Display names are
* free-text form input, so a name carrying digits, punctuation, or a URL
* anywhere is discarded as a whole and never reaches the email.
*/
Expand Down Expand Up @@ -484,12 +485,15 @@ export async function dispatchLifecycleAppOwnedJob(
{ contactId: context.contactId, issuedAt: now, eventNonce: job.id },
dependencies.tokenKey
);
// Fulfillment mail is the first message a contact gets from Brian, so it
// opens the same way every campaign step does.
const body = `${campaignGreeting(context.displayName)}\n\n${message.body}`;
return dispatchRecipient(
executor,
job,
message.subject,
signedText(message.body, unsubscribeUrl),
signedHtml(message.body, unsubscribeUrl),
signedText(body, unsubscribeUrl),
signedHtml(body, unsubscribeUrl),
unsubscribeUrl,
signal,
dependencies
Expand Down
8 changes: 8 additions & 0 deletions apps/lifecycle/src/campaign/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,17 @@ const CampaignStepSchema = z.enum(['immediate', 'day-3', 'day-8']);
export const FOUNDER_BOOKING_URL =
'https://calendar.app.google/nK961tWHZd21izKR6';

/**
* Every link recipient copy may carry, across campaign steps and fulfillment
* mail. The four PDFs are the whitepaper fulfillment deliverables.
*/
const APPROVED_CAMPAIGN_LINKS = new Set([
'https://threadplane.ai/docs',
'https://threadplane.ai/pilot-to-prod',
'https://threadplane.ai/whitepaper.pdf',
'https://threadplane.ai/whitepapers/angular.pdf',
'https://threadplane.ai/whitepapers/render.pdf',
'https://threadplane.ai/whitepapers/chat.pdf',
FOUNDER_BOOKING_URL,
]);
const URL_PATTERN = /https?:\/\/[^\s<>()"'“”‘’\]}]+/giu;
Expand Down
83 changes: 61 additions & 22 deletions apps/lifecycle/src/fulfillment/templates.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { describe, expect, it } from 'vitest';

import { campaignDraftViolations } from '../campaign/templates.js';
import { renderFulfillmentTemplate } from './templates.js';

const URL_PATTERN = /https:\/\/[^\s]+/gu;
const HTML_PATTERN = /<\/?[a-z][^>]*>/iu;
const CONTRACTION_PATTERN = /\b\w+['’]\w+\b/u;

function everyFulfillmentMessage() {
return [
renderFulfillmentTemplate({ context: 'whitepaper', paper: 'overview' }),
renderFulfillmentTemplate({ context: 'newsletter' }),
renderFulfillmentTemplate({ context: 'contact' }),
renderFulfillmentTemplate({ context: 'pricing' }),
renderFulfillmentTemplate({
context: 'project-connect',
claimedSignals: ['thread.persisted'],
}),
];
}

describe('renderFulfillmentTemplate', () => {
it.each([
Expand Down Expand Up @@ -35,20 +50,24 @@ describe('renderFulfillmentTemplate', () => {
paper,
});

expect(message).toEqual({
subject,
body: `Here is the guide you requested:\n\n${url}`,
});
expect(message.subject).toBe(subject);
expect(
message.body.startsWith(`Here is the guide you requested:\n${url}\n\n`)
).toBe(true);
expect(message.body.match(URL_PATTERN)).toEqual([url]);
}
);

it('welcomes a newsletter signup without adding another request', () => {
expect(renderFulfillmentTemplate({ context: 'newsletter' })).toEqual({
subject: 'Welcome to Threadplane',
body: expect.stringMatching(
/^Thanks for signing up\. I’ll keep these notes focused on practical engineering work with agent interfaces\.$/u
),
});
const message = renderFulfillmentTemplate({ context: 'newsletter' });

expect(message.subject).toBe('Welcome to Threadplane');
expect(message.body.startsWith('You are on the list.')).toBe(true);
expect(message.body).toContain(
'practical engineering work with agent interfaces'
);
expect(message.body).not.toMatch(URL_PATTERN);
expect(message.body).not.toContain('?');
});

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

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

it('keeps every recipient message plain and compact', () => {
const messages = [
renderFulfillmentTemplate({ context: 'whitepaper', paper: 'overview' }),
renderFulfillmentTemplate({ context: 'newsletter' }),
renderFulfillmentTemplate({ context: 'contact' }),
renderFulfillmentTemplate({ context: 'pricing' }),
renderFulfillmentTemplate({
context: 'project-connect',
claimedSignals: ['thread.persisted'],
}),
];
it('writes every recipient message in the campaign register', () => {
for (const message of everyFulfillmentMessage()) {
// No contractions, no "thanks for" openers, no greeting baked into the
// body (send.ts adds "Hey <name>," at send time), and every line short.
expect(message.body).not.toMatch(CONTRACTION_PATTERN);
expect(message.body).not.toMatch(/^thanks/iu);
expect(message.body).not.toMatch(/^hey\b/iu);
expect(message.body).not.toMatch(/\blet['’]?s\b/iu);
for (const line of message.body.split('\n')) {
expect(line.trim().split(/\s+/u).filter(Boolean).length).toBeLessThan(
25
);
}
}
});

it('stays inside the recipient-copy checks shared with the campaign', () => {
for (const message of everyFulfillmentMessage()) {
expect(campaignDraftViolations(message)).toEqual([]);
}
for (const paper of ['angular', 'render', 'chat'] as const) {
expect(
campaignDraftViolations(
renderFulfillmentTemplate({ context: 'whitepaper', paper })
)
).toEqual([]);
}
});

for (const message of messages) {
it('keeps every recipient message plain and compact', () => {
for (const message of everyFulfillmentMessage()) {
expect(typeof message.subject).toBe('string');
expect(typeof message.body).toBe('string');
expect(message.subject).not.toMatch(/[\r\n]/u);
Expand Down
16 changes: 11 additions & 5 deletions apps/lifecycle/src/fulfillment/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ const PROJECT_FACTS: Record<z.infer<typeof ProjectSignalSchema>, string> = {
'project.returned_7d': 'returned to the project within a week',
};

/**
* Recipient copy in Brian's register: no contractions, no "thanks for"
* openers, one thought per line, no marketing tone. The "Hey <name>,"
* greeting is added at send time; every body here stays inside the
* recipient-copy checks in campaign/templates.ts.
*/
export function renderFulfillmentTemplate(
candidate: unknown
): RecipientTemplate {
Expand All @@ -80,23 +86,23 @@ export function renderFulfillmentTemplate(
const paper = WHITEPAPERS[input.paper];
return {
subject: paper.subject,
body: `Here is the guide you requested:\n\n${paper.url}`,
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.`,
};
}
case 'newsletter':
return {
subject: 'Welcome to Threadplane',
body: 'Thanks for signing up. I’ll keep these notes focused on practical engineering work with agent interfaces.',
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.',
};
case 'contact':
return {
subject: 'Your contact request',
body: 'Thanks for reaching out. I’ll reply to the contact request you submitted.',
body: 'I have your contact request.\nI will read it and reply myself.',
};
case 'pricing':
return {
subject: 'Your pricing request',
body: 'Thanks for reaching out. I’ll reply to the pricing request you submitted.',
body: 'I have your pricing request.\nI will read it and reply myself.',
};
case 'project-connect': {
const facts = input.claimedSignals.map((signal) => PROJECT_FACTS[signal]);
Expand All @@ -106,7 +112,7 @@ export function renderFulfillmentTemplate(
: `${facts.slice(0, -1).join(', ')}, and ${facts.at(-1)}`;
return {
subject: 'Your connected Threadplane project',
body: `Thanks for explicitly connecting your project. In that connection, you shared that you ${joinedFacts}. I’ll keep any follow-up to that context.`,
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.`,
};
}
}
Expand Down
Loading