From a2bb5108e40ccda246286cbfd4f44ce345fce747 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 14:14:33 -0700 Subject: [PATCH] feat(lifecycle): fulfillment mail in the campaign register 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 ," 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 --- apps/lifecycle/src/campaign/send.spec.ts | 31 +++++++ apps/lifecycle/src/campaign/send.ts | 10 ++- apps/lifecycle/src/campaign/templates.ts | 8 ++ .../src/fulfillment/templates.spec.ts | 83 ++++++++++++++----- apps/lifecycle/src/fulfillment/templates.ts | 16 ++-- 5 files changed, 118 insertions(+), 30 deletions(-) diff --git a/apps/lifecycle/src/campaign/send.spec.ts b/apps/lifecycle/src/campaign/send.spec.ts index 9bd5a879c..41d777163 100644 --- a/apps/lifecycle/src/campaign/send.spec.ts +++ b/apps/lifecycle/src/campaign/send.spec.ts @@ -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('

Hey Ada,

\n

Here is the guide') + ).toBe(true); + expect(sent?.html).toContain( + '' + ); + }); + + 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 () => { diff --git a/apps/lifecycle/src/campaign/send.ts b/apps/lifecycle/src/campaign/send.ts index 6bfb24951..6aa934f7d 100644 --- a/apps/lifecycle/src/campaign/send.ts +++ b/apps/lifecycle/src/campaign/send.ts @@ -259,7 +259,8 @@ const PLAIN_NAME_PATTERN = /^[A-Za-z'’.-]+(?:\s[A-Za-z'’.-]+){0,5}$/u; /** * "Hey ," 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. */ @@ -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 diff --git a/apps/lifecycle/src/campaign/templates.ts b/apps/lifecycle/src/campaign/templates.ts index 9c80583f8..76c7d0aa4 100644 --- a/apps/lifecycle/src/campaign/templates.ts +++ b/apps/lifecycle/src/campaign/templates.ts @@ -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; diff --git a/apps/lifecycle/src/fulfillment/templates.spec.ts b/apps/lifecycle/src/fulfillment/templates.spec.ts index cf5c04bd6..742c1617a 100644 --- a/apps/lifecycle/src/fulfillment/templates.spec.ts +++ b/apps/lifecycle/src/fulfillment/templates.spec.ts @@ -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([ @@ -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)( @@ -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 ); @@ -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 ," 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); diff --git a/apps/lifecycle/src/fulfillment/templates.ts b/apps/lifecycle/src/fulfillment/templates.ts index bee08e9d8..0eb3dfc62 100644 --- a/apps/lifecycle/src/fulfillment/templates.ts +++ b/apps/lifecycle/src/fulfillment/templates.ts @@ -70,6 +70,12 @@ const PROJECT_FACTS: Record, 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 ," + * 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 { @@ -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]); @@ -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.`, }; } }