From 1256506c0aa7361fe4181bd03c11411d6aee3a8e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 09:51:40 -0700 Subject: [PATCH 1/8] feat(lifecycle): founder session offer as step one, one-word unsubscribe link - Step one of every campaign is now Brian's engineer-to-engineer session offer, written in his register (no contractions, one thought per line) and linking only his Google Calendar booking page. Research angles shape steps two and three only, and the five-minute enrichment wait is gone. - Recipient mail gains a plain HTML alternative rendered from the text part (paragraphs and HTTPS anchors only) so the unsubscribe footer reads "click here" instead of the long signed URL. The Resend sender rejects any HTML part with images, scripts, styles, forms, event handlers, or a missing/duplicated unsubscribe anchor, before final authorization. - The draft checks approve exactly one booking URL; every other calendar host or path is still rejected. FOUNDER_BOOKING_URL is a placeholder until Brian's real booking link is pasted in. Co-Authored-By: Claude Fable 5.1 --- apps/lifecycle/README.md | 2 +- apps/lifecycle/src/campaign/send.spec.ts | 104 ++++++++---------- apps/lifecycle/src/campaign/send.ts | 97 ++++++++++------ apps/lifecycle/src/campaign/templates.spec.ts | 62 ++++++++--- apps/lifecycle/src/campaign/templates.ts | 14 ++- ...-threadplane-growth-lifecycle-v1-design.md | 16 +-- .../2026-09-02-growth-hard-cutover-design.md | 2 +- libs/growth/src/lib/resend.spec.ts | 78 +++++++++++++ libs/growth/src/lib/resend.ts | 60 +++++++++- .../development-install.mjs | 1 + 10 files changed, 322 insertions(+), 114 deletions(-) create mode 100644 unused/.install-collector/development-install.mjs diff --git a/apps/lifecycle/README.md b/apps/lifecycle/README.md index 0a830e3cf..231a08e19 100644 --- a/apps/lifecycle/README.md +++ b/apps/lifecycle/README.md @@ -15,7 +15,7 @@ Apply migrations 0004–0007 before deploying the backend: contact deletion and Deploy backend observation acceptance and bridge resolution with the rollout switch off. Verify the synthetic journey in preview with a controlled recipient and lifecycle's matching HMAC keys, then publish the matching collectors and enable production collection and activation gradually. Preserve the existing enrollment start timestamp, campaign, delivery, and cron controls. -A persisted `install_runtime` enrollment reason selects the existing generic founder sequence immediately, without waiting for an enrichment artifact. All three steps stay generic even if optional research later becomes available. Form and project-claim enrollments retain their existing behavior. The shared delivery authorization, reply/suppression stops, mailbox recovery guard, unsubscribe links, and once-per-contact three-step enrollment remain in force; install-derived eligibility does not verify identity or employment. +Step one of every campaign is the founder session offer and sends immediately without waiting for an enrichment artifact; research angles only shape steps two and three. A persisted `install_runtime` enrollment reason keeps all three steps generic even if optional research later becomes available. Form and project-claim enrollments retain their existing behavior. The shared delivery authorization, reply/suppression stops, mailbox recovery guard, unsubscribe links, and once-per-contact three-step enrollment remain in force; install-derived eligibility does not verify identity or employment. Recipient delivery also requires `GROWTH_PUBLIC_ACTION_ORIGIN`, a server-only bare HTTPS origin for the Website deployment that owns `/api/unsubscribe`. In preview, use a dedicated public custom-domain alias for the exact Website preview deployment while keeping generated preview URLs protected; the signed action token is the application-layer authorization. In production, use the canonical Website origin. Paths, query strings, fragments, credentials, and HTTP origins are rejected. The lifecycle service uses this value only to construct opaque, contact-bound unsubscribe action URLs; it never derives the origin from a request or hardcodes the production site. diff --git a/apps/lifecycle/src/campaign/send.spec.ts b/apps/lifecycle/src/campaign/send.spec.ts index 0de20f422..367baae80 100644 --- a/apps/lifecycle/src/campaign/send.spec.ts +++ b/apps/lifecycle/src/campaign/send.spec.ts @@ -25,6 +25,7 @@ import { type LifecycleJobDependencies, } from './send.js'; import { DeterministicLifecycleJobError } from '../job-errors.js'; +import { FOUNDER_BOOKING_URL } from './templates.js'; const NOW = new Date('2026-09-01T12:03:00.000Z'); const CONTACT_ID = '00000000-0000-4000-8000-000000000002'; @@ -135,10 +136,9 @@ describe('prepareCampaignMessage', () => { campaignEnrollmentReason: 'install_runtime', }, job: job('send_step', { campaign_version: 'v1', step: 1 }), - now: new Date('2026-09-01T12:00:00.000Z'), unsubscribeUrl: UNSUBSCRIBE, }) - ).toMatchObject({ status: 'ready', subject: 'A practical place to start' }); + ).toMatchObject({ status: 'ready', subject: 'Engineer to engineer' }); }); it.each([1, 2, 3] as const)( @@ -147,13 +147,12 @@ describe('prepareCampaignMessage', () => { const prepared = prepareCampaignMessage({ context: { ...context(), campaignEnrollmentReason: 'install_runtime' }, job: job('send_step', { campaign_version: 'v1', step }), - now: NOW, unsubscribeUrl: UNSUBSCRIBE, }); expect(prepared).toMatchObject({ status: 'ready', subject: [ - 'A practical place to start', + 'Engineer to engineer', 'One debugging shortcut', 'One last architecture note', ][step - 1], @@ -171,7 +170,6 @@ describe('prepareCampaignMessage', () => { prepareCampaignMessage({ context: { ...context(), campaignEnrollmentReason: 'install_runtime' }, job: job('send_step', { campaign_version: 'v1', step: 4 }), - now: NOW, unsubscribeUrl: UNSUBSCRIBE, }) ).toThrow(DeterministicLifecycleJobError); @@ -200,11 +198,10 @@ describe('prepareCampaignMessage', () => { expect( prepareCampaignMessage({ context: context({ enrichmentArtifact: cited }), - job: job('send_step', { campaign_version: 'v1', step: 1 }), - now: NOW, + job: job('send_step', { campaign_version: 'v1', step: 2 }), unsubscribeUrl: UNSUBSCRIBE, }) - ).toMatchObject({ status: 'ready', subject: 'A streaming foundation' }); + ).toMatchObject({ status: 'ready', subject: 'A debugging sequence' }); }); it.each([ @@ -224,13 +221,12 @@ describe('prepareCampaignMessage', () => { const prepared = prepareCampaignMessage({ context: context({ enrichmentArtifact: unsafe }), job: job('send_step', { campaign_version: 'v1', step: 1 }), - now: new Date('2026-09-01T12:05:00.000Z'), unsubscribeUrl: UNSUBSCRIBE, }); expect(prepared).toMatchObject({ status: 'ready', - subject: 'A practical place to start', + subject: 'Engineer to engineer', }); if (prepared.status === 'ready') { expect(prepared.text).not.toContain(inventedClaim); @@ -243,14 +239,13 @@ describe('prepareCampaignMessage', () => { const prepared = prepareCampaignMessage({ context: context(), job: job('send_step', { campaign_version: 'v1', step }), - now: NOW, unsubscribeUrl: UNSUBSCRIBE, }); expect(prepared).toMatchObject({ status: 'ready', subject: [ - 'A streaming foundation', + 'Engineer to engineer', 'A debugging sequence', 'One event-state boundary', ][step - 1], @@ -262,74 +257,70 @@ describe('prepareCampaignMessage', () => { } ); - it('uses a valid artifact immediately without imposing the five-minute wait', () => { + it('sends the founder offer as step one even when a research artifact exists', () => { expect( prepareCampaignMessage({ context: context(), job: job('send_step', { campaign_version: 'v1', step: 1 }), - now: new Date('2026-09-01T12:00:30.000Z'), unsubscribeUrl: UNSUBSCRIBE, }) - ).toMatchObject({ status: 'ready', subject: 'A streaming foundation' }); + ).toMatchObject({ status: 'ready', subject: 'Engineer to engineer' }); }); - it('defers step one only until enrollment plus five minutes when no valid artifact exists', () => { + it('sends step one immediately without waiting for a research artifact', () => { expect( prepareCampaignMessage({ - context: context({ enrichmentArtifact: null }), + context: context({ enrichmentArtifact: null, enrollmentAt: null }), job: job('send_step', { campaign_version: 'v1', step: 1 }), - now: NOW, unsubscribeUrl: UNSUBSCRIBE, }) - ).toEqual({ - status: 'deferred', - availableAt: new Date('2026-09-01T12:05:00.000Z'), - }); + ).toMatchObject({ status: 'ready', subject: 'Engineer to engineer' }); }); - it('uses the corresponding neutral template after the five-minute deadline', () => { + it('renders a plain HTML alternative with a one-word unsubscribe link', () => { + const prepared = prepareCampaignMessage({ + context: context(), + job: job('send_step', { campaign_version: 'v1', step: 1 }), + unsubscribeUrl: UNSUBSCRIBE, + }); + const unsubscribeUrl = unsubscribeActionUrlValue(UNSUBSCRIBE); + + expect(prepared.html).toContain( + `here:
${FOUNDER_BOOKING_URL}

` + ); + expect(prepared.html).toContain('


Brian

'); expect( - prepareCampaignMessage({ - context: context({ enrichmentArtifact: null }), - job: job('send_step', { campaign_version: 'v1', step: 1 }), - now: new Date('2026-09-01T12:05:00.000Z'), - unsubscribeUrl: UNSUBSCRIBE, - }) - ).toMatchObject({ status: 'ready', subject: 'A practical place to start' }); + prepared.html.endsWith( + `

To stop these emails, click here.

` + ) + ).toBe(true); + expect(prepared.html.split(unsubscribeUrl)).toHaveLength(2); + expect(prepared.html).not.toMatch( + /<(?:img|script|style|div|span|table)\b/iu + ); + expect(prepared.html.replace(/<[^>]+>/gu, '')).not.toContain('unsubscribe'); + expect(prepared.text).toContain(`To stop these emails: ${unsubscribeUrl}`); }); - it('closes the sequence on the final step even when evidence copy is selected', () => { - const cited = artifact({ - cited_signals: [ - { signal: 'Bounded source fact', source_ids: ['source-1'] }, - ], - sources: [ - { - id: 'source-1', - url: 'https://example.com/about', - retrieved_at: '2026-09-01T12:00:00.000Z', - content_hash: 'a'.repeat(64), - }, - ], + it('escapes body text and keeps only bare links as anchors in the HTML part', () => { + const invalid = artifact({ drafts: [ { angle_id: 'streaming_foundation', source_id: 'source-1' }, { angle_id: 'debugging_layers', source_id: 'source-1' }, { angle_id: 'event_state_boundary', source_id: 'source-1' }, ], }); - - const message = prepareCampaignMessage({ - context: context({ enrichmentArtifact: cited }), - job: job('send_step', { campaign_version: 'v1', step: 3 }), - now: new Date('2026-09-09T12:05:00.000Z'), + const prepared = prepareCampaignMessage({ + context: context({ enrichmentArtifact: invalid }), + job: job('send_step', { campaign_version: 'v1', step: 2 }), unsubscribeUrl: UNSUBSCRIBE, }); - expect(message).toMatchObject({ - status: 'ready', - subject: 'One event-state boundary', - }); - expect(JSON.stringify(message)).toContain('last automated follow-up'); + expect(prepared.html).toContain( + '

https://threadplane.ai/docs

' + ); + expect(prepared.html).toMatch(/^

[^<]+<\/p>\n

[^<]+<\/p>\n

{ @@ -345,10 +336,9 @@ describe('prepareCampaignMessage', () => { prepareCampaignMessage({ context: context({ enrichmentArtifact: invalid }), job: job('send_step', { campaign_version: 'v1', step: 1 }), - now: new Date('2026-09-01T12:05:00.000Z'), unsubscribeUrl: UNSUBSCRIBE, }) - ).toMatchObject({ status: 'ready', subject: 'A practical place to start' }); + ).toMatchObject({ status: 'ready', subject: 'Engineer to engineer' }); }); }); @@ -426,7 +416,9 @@ describe('dispatchLifecycleAppOwnedJob', () => { expect.objectContaining({ jobId: send.id, leaseToken: LEASE_TOKEN, - subject: 'A practical place to start', + subject: 'Engineer to engineer', + text: expect.stringContaining('To stop these emails: '), + html: expect.stringContaining('click ()"'“”‘’\]}]+/gu; + +function escapeHtml(value: string): string { + return value.replace( + /[&<>"']/gu, + (character) => HTML_ESCAPES[character] ?? character + ); +} + +function htmlLine(line: string): string { + let rendered = ''; + let cursor = 0; + for (const match of line.matchAll(BODY_LINK_PATTERN)) { + const start = match.index; + const trailing = /[.,;:!]+$/u.exec(match[0])?.[0] ?? ''; + const link = match[0].slice(0, match[0].length - trailing.length); + rendered += escapeHtml(line.slice(cursor, start)); + rendered += `${escapeHtml(link)}`; + rendered += escapeHtml(trailing); + cursor = start + match[0].length; + } + return rendered + escapeHtml(line.slice(cursor)); +} + +/** + * A plain HTML alternative for the text part: the same paragraphs, bare HTTPS + * links as anchors, and a one-word unsubscribe link instead of the long signed + * URL. No layout, images, styles, or tracking. + */ +function signedHtml( + body: string, + unsubscribeUrl: UnsubscribeActionUrl +): string { + const paragraphs = body + .split('\n\n') + .map((paragraph) => paragraph.split('\n').map(htmlLine).join('
')) + .map((paragraph) => `

${paragraph}

`); + const unsubscribe = escapeHtml(unsubscribeActionUrlValue(unsubscribeUrl)); + return [ + ...paragraphs, + '


Brian

', + `

To stop these emails, click here.

`, + ].join('\n'); +} + export function prepareCampaignMessage(input: { context: LifecycleJobContext; job: GrowthJob; - now: Date; unsubscribeUrl: UnsubscribeActionUrl; }): PreparedCampaignMessage { const step = campaignStep(input.job); @@ -271,24 +326,12 @@ export function prepareCampaignMessage(input: { const artifact = genericHello ? null : validArtifact(input.context.enrichmentArtifact, input.context.contactId); - if (step === 1 && !artifact && !genericHello) { - if (!input.context.enrollmentAt) { - throw new DeterministicLifecycleJobError( - 'Campaign enrollment timestamp is required' - ); - } - const availableAt = new Date( - input.context.enrollmentAt.getTime() + FIVE_MINUTES_MS - ); - if (input.now.getTime() < availableAt.getTime()) { - return { status: 'deferred', availableAt }; - } - } const draft = draftFor(step, artifact); return { status: 'ready', subject: draft.subject, text: signedText(draft.body, input.unsubscribeUrl), + html: signedHtml(draft.body, input.unsubscribeUrl), }; } @@ -341,6 +384,7 @@ async function dispatchRecipient( job: GrowthJob, subject: string, text: string, + html: string, unsubscribeUrl: UnsubscribeActionUrl, signal: AbortSignal, dependencies: LifecycleJobDependencies @@ -349,7 +393,7 @@ async function dispatchRecipient( signal.throwIfAborted(); const result = await dependencies.sendRecipient( executor, - { jobId: job.id, leaseToken, subject, text, unsubscribeUrl, signal }, + { jobId: job.id, leaseToken, subject, text, html, unsubscribeUrl, signal }, dependencies.recipientPolicy ); if (result.accepted) return 'completed'; @@ -425,6 +469,7 @@ export async function dispatchLifecycleAppOwnedJob( job, message.subject, signedText(message.body, unsubscribeUrl), + signedHtml(message.body, unsubscribeUrl), unsubscribeUrl, signal, dependencies @@ -439,24 +484,14 @@ export async function dispatchLifecycleAppOwnedJob( const message = prepareCampaignMessage({ context, job, - now, unsubscribeUrl, }); - if (message.status === 'deferred') { - await dependencies.deferJob(executor, { - jobId: job.id, - leaseToken, - now, - availableAt: message.availableAt, - errorCode: 'awaiting_enrichment_artifact', - }); - return 'deferred'; - } return dispatchRecipient( executor, job, message.subject, message.text, + message.html, unsubscribeUrl, signal, dependencies diff --git a/apps/lifecycle/src/campaign/templates.spec.ts b/apps/lifecycle/src/campaign/templates.spec.ts index 3818a5e43..75829985e 100644 --- a/apps/lifecycle/src/campaign/templates.spec.ts +++ b/apps/lifecycle/src/campaign/templates.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + FOUNDER_BOOKING_URL, campaignDraftViolations, normalizeCampaignDraft, renderCampaignTemplate, @@ -13,20 +14,53 @@ function wordCount(value: string): number { describe('renderCampaignTemplate', () => { it.each([ - ['immediate', 'A practical place to start'], - ['day-3', 'One debugging shortcut'], - ['day-8', 'One last architecture note'], - ] as const)('returns the fixed neutral %s template', (step, subject) => { - const message = renderCampaignTemplate(step); - - expect(message.subject).toBe(subject); - expect(wordCount(message.body)).toBeLessThanOrEqual(120); - expect(message.body.match(/\?/gu) ?? []).toHaveLength(1); - expect(message.body.match(/https:\/\/[^\s]+/gu) ?? []).toHaveLength( - step === 'day-8' ? 0 : 1 - ); - expect(campaignDraftViolations(message)).toEqual([]); - expect(message.body).not.toMatch(/\nBrian$/u); + ['immediate', 'Engineer to engineer', 0, 1], + ['day-3', 'One debugging shortcut', 1, 1], + ['day-8', 'One last architecture note', 1, 0], + ] as const)( + 'returns the fixed neutral %s template', + (step, subject, questions, links) => { + const message = renderCampaignTemplate(step); + + expect(message.subject).toBe(subject); + expect(wordCount(message.body)).toBeLessThanOrEqual(120); + expect(message.body.match(/\?/gu) ?? []).toHaveLength(questions); + expect(message.body.match(/https:\/\/[^\s]+/gu) ?? []).toHaveLength( + links + ); + expect(campaignDraftViolations(message)).toEqual([]); + expect(message.body).not.toMatch(/\nBrian$/u); + } + ); + + it('opens with a founder session offer that links only to the booking page', () => { + const message = renderCampaignTemplate('immediate'); + + expect( + message.body.endsWith(`You can grab a time here:\n${FOUNDER_BOOKING_URL}`) + ).toBe(true); + expect(message.body).toContain('No sales pitch.'); + expect(message.body).not.toMatch(/\b(?:I saw you|checked out)\b/iu); + expect(message.body).not.toMatch(/\b\w+'\w+\b/u); + }); + + it('accepts only the approved booking page as a scheduling link', () => { + expect( + campaignDraftViolations({ + subject: 'Hello', + body: `Grab a time here:\n${FOUNDER_BOOKING_URL}`, + }) + ).toEqual([]); + for (const link of [ + 'https://calendar.app.google/someone-else', + 'https://calendly.com/threadplane/demo', + 'https://cal.com/threadplane/demo', + 'https://calendar.google.com/calendar/appointments/schedules/abc', + ]) { + expect( + campaignDraftViolations({ subject: 'Hello', body: `Book at ${link}` }) + ).not.toEqual([]); + } }); it('marks day 8 as the last automated follow-up', () => { diff --git a/apps/lifecycle/src/campaign/templates.ts b/apps/lifecycle/src/campaign/templates.ts index e90f3b7ff..75208a6dd 100644 --- a/apps/lifecycle/src/campaign/templates.ts +++ b/apps/lifecycle/src/campaign/templates.ts @@ -12,9 +12,19 @@ export type CampaignEvidenceAngle = | 'event_state_boundary'; const CampaignStepSchema = z.enum(['immediate', 'day-3', 'day-8']); + +/** + * Brian's Google Calendar appointment-schedule booking page. This is the only + * scheduling link recipient copy may carry; every other calendar host or path + * is still rejected by the draft checks below. + */ +export const FOUNDER_BOOKING_URL = + 'https://calendar.app.google/REPLACE_WITH_BRIAN_BOOKING_LINK'; + const APPROVED_CAMPAIGN_LINKS = new Set([ 'https://threadplane.ai/docs', 'https://threadplane.ai/pilot-to-prod', + FOUNDER_BOOKING_URL, ]); const URL_PATTERN = /https?:\/\/[^\s<>()"'“”‘’\]}]+/giu; const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/iu; @@ -161,8 +171,8 @@ export function normalizeCampaignDraft(candidate: unknown): CampaignDraft { const CAMPAIGN_TEMPLATES: Record = { immediate: { - subject: 'A practical place to start', - body: 'Thanks for taking a look at Threadplane. One practical starting point is to get a streamed response working end to end, then add persistence and interrupts as the product needs them.\n\nWhat are you building?\n\nhttps://threadplane.ai/docs', + subject: 'Engineer to engineer', + body: `Thanks for taking a look at Threadplane.\n\nA lot of teams hit the same point.\nThe idea is clear.\nGetting it working cleanly in production is where things get messy.\n\nI am the founder, and I am offering short engineer-to-engineer sessions to think through implementation, unblock technical questions, and avoid the common mistakes.\n\nNo sales pitch.\nJust a practical conversation about your use case and what it would take to get it working.\n\nYou can grab a time here:\n${FOUNDER_BOOKING_URL}`, }, 'day-3': { subject: 'One debugging shortcut', diff --git a/docs/superpowers/specs/2026-08-31-threadplane-growth-lifecycle-v1-design.md b/docs/superpowers/specs/2026-08-31-threadplane-growth-lifecycle-v1-design.md index fc517e42b..e5567663b 100644 --- a/docs/superpowers/specs/2026-08-31-threadplane-growth-lifecycle-v1-design.md +++ b/docs/superpowers/specs/2026-08-31-threadplane-growth-lifecycle-v1-design.md @@ -665,21 +665,21 @@ apps/lifecycle should declare Dawn Core/CLI/LangGraph/Postgres Storage/SDK 0.8.2 ### Campaign -| Step | Due | Purpose | -| ---- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| 1 | As soon as artifact is ready; neutral fallback no later than five minutes | Acknowledge guide/product context, offer one useful observation, ask what they are building. | -| 2 | Day 3 | Help with one missing activation milestone and ask for the blocking detail. | -| 3 | Day 8 | Offer concise architecture help, ask one reply-oriented question, and state it is the last automated follow-up. | +| Step | Due | Purpose | +| ---- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Immediately on enrollment (research is not awaited) | Founder session offer: thank them, name the pilot-to-production gap, offer an engineer-to-engineer session, link the booking page. | +| 2 | Day 3 | Help with one missing activation milestone and ask for the blocking detail. | +| 3 | Day 8 | Offer concise architecture help, ask one reply-oriented question, and state it is the last automated follow-up. | Copy constraints: -- All recipient fulfillment, welcome, acknowledgment, and campaign mail uses Resend’s text field rather than an HTML template. +- All recipient fulfillment, welcome, acknowledgment, and campaign mail is authored as plain text. Since 2026-09-05 the sender also attaches a plain HTML alternative rendered from that text (paragraphs and HTTPS anchors only) so the unsubscribe footer can read “click here” instead of the long signed URL; the sender rejects any HTML part containing images, scripts, styles, forms, event handlers, or a missing unsubscribe anchor. - Internal research and operational notifications are plain text as well. - Each campaign step is at most 120 words. -- One question and at most one useful link. +- At most one question and at most one useful link. - No banner, button, HTML layout, tracking pixel, open tracking, or click rewriting. - Never say “I saw you…” based on telemetry. -- No calendar link in v1. +- The only scheduling link allowed is Brian’s Google Calendar appointment-schedule booking page (`FOUNDER_BOOKING_URL` in the campaign templates); every other calendar host or path is rejected. - From and Reply-To are Brian at Threadplane . - BCC Brian on every recipient-facing email that may begin a conversation. The email carries X-Threadplane-Job-ID so the Google poller can register the BCC seed’s actual RFC Message-ID; seed copies are never treated as recipient replies. diff --git a/docs/superpowers/specs/2026-09-02-growth-hard-cutover-design.md b/docs/superpowers/specs/2026-09-02-growth-hard-cutover-design.md index 4ec816857..4b964e5bc 100644 --- a/docs/superpowers/specs/2026-09-02-growth-hard-cutover-design.md +++ b/docs/superpowers/specs/2026-09-02-growth-hard-cutover-design.md @@ -70,7 +70,7 @@ The system remains intentionally lean: ### Delivery and reply policy -- Campaign and fulfillment email is plain text and should read as a direct message from Brian. +- Campaign and fulfillment email is authored as plain text and should read as a direct message from Brian. A plain HTML alternative (paragraphs and links only, no layout or tracking) rides alongside so the unsubscribe footer is a one-word link. - Resend is used for transport, provider message identifiers, and verified delivery webhooks. - New campaign jobs are held in Neon until due; Resend must not schedule the future campaign steps. - Google Workspace is the reply mailbox. A Google Apps Script poller sends only bounded message metadata and reply headers to the signed reply endpoint; it does not persist message bodies in Threadplane. diff --git a/libs/growth/src/lib/resend.spec.ts b/libs/growth/src/lib/resend.spec.ts index 6482b4e9d..3890e0e6c 100644 --- a/libs/growth/src/lib/resend.spec.ts +++ b/libs/growth/src/lib/resend.spec.ts @@ -199,6 +199,84 @@ describe('sendRecipientEmail', () => { expect(test.markProviderAcceptanceUnknown).not.toHaveBeenCalled(); }); + it('forwards a plain-paragraph HTML alternative that links the unsubscribe URL', async () => { + const test = harness(); + const html = `

Hi Sam,

Here is the note.

To stop these emails, click here.

`; + + await expect( + sendRecipientEmail( + test.database, + { ...message, html }, + productionPolicy(), + test.dependencies + ) + ).resolves.toEqual({ accepted: true, providerEmailId }); + const payload = test.send.mock.calls[0]?.[0] as Record; + expect(payload['html']).toBe(html); + expect(payload['text']).toBe(message.text); + }); + + it.each([ + [ + 'a tracking image', + '

Hi

', + ], + ['a script', '

Hi

'], + ['a style block', '

Hi

'], + ['an inline style', '

Hi

'], + [ + 'an event handler', + '

Docs

', + ], + ['a non-HTTPS anchor', '

Docs

'], + [ + 'an unquoted anchor', + '

Docs

', + ], + ['an unknown element', '

Hi

'], + ['an HTML comment', '

Hi

'], + [ + 'no unsubscribe link', + '

Hi

Docs

', + ], + ])( + 'rejects an HTML alternative containing %s before authorization', + async (_case, fragment) => { + const test = harness(); + const html = fragment.includes(unsubscribeUrl) + ? fragment + : _case === 'no unsubscribe link' + ? fragment + : `${fragment}

here

`; + + await expect( + sendRecipientEmail( + test.database, + { ...message, html }, + productionPolicy(), + test.dependencies + ) + ).rejects.toThrow(); + expect(test.authorizeLeasedJobForSubmission).not.toHaveBeenCalled(); + expect(test.send).not.toHaveBeenCalled(); + } + ); + + it('rejects an HTML alternative that repeats the unsubscribe link', async () => { + const test = harness(); + const html = `

here or here

`; + + await expect( + sendRecipientEmail( + test.database, + { ...message, html }, + productionPolicy(), + test.dependencies + ) + ).rejects.toThrow(/exactly once/u); + expect(test.send).not.toHaveBeenCalled(); + }); + it('uses a separate fulfillment tag contract without campaign tags', async () => { const test = harness({ job: job({ diff --git a/libs/growth/src/lib/resend.ts b/libs/growth/src/lib/resend.ts index d3ac9771c..e7957ea9f 100644 --- a/libs/growth/src/lib/resend.ts +++ b/libs/growth/src/lib/resend.ts @@ -58,6 +58,13 @@ export interface RecipientEmailInput { leaseToken: string; subject: string; text: string; + /** + * Optional HTML alternative for the same message. It must stay a plain + * rendering of the text part: paragraphs and HTTPS anchors only, no images, + * scripts, styles, forms, or event handlers, and it must carry the same + * unsubscribe link as the text part. + */ + html?: string; unsubscribeUrl: UnsubscribeActionUrl; signal?: AbortSignal; } @@ -73,6 +80,7 @@ export interface RecipientEmailProviderPayload { replyTo: typeof RECIPIENT_EMAIL_SENDER; subject: string; text: string; + html?: string; headers: { 'List-Unsubscribe': string; 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'; @@ -114,6 +122,51 @@ export type RecipientSendResult = | 'provider_outcome_unknown'; }; +const HTML_MAXIMUM = 40_000; +const FORBIDDEN_HTML_ELEMENT_PATTERN = + /<(?:img|picture|source|script|style|link|iframe|frame|object|embed|svg|video|audio|form|input|button|select|textarea|meta|base|template|math)\b/iu; +const HTML_EVENT_HANDLER_PATTERN = /\son[a-z]+\s*=/iu; +const HTML_RESOURCE_ATTRIBUTE_PATTERN = + /\s(?:src|srcset|style|background|poster|ping|formaction|action|data)\s*=/iu; +const HTML_TAG_PATTERN = /<\/?([a-z][a-z0-9]*)\b[^>]*>/giu; +const ALLOWED_HTML_ELEMENTS = new Set(['p', 'br', 'a']); +const HTML_ANCHOR_PATTERN = /]*)>/giu; +const HTML_HREF_PATTERN = /\bhref\s*=\s*"([^"]*)"/iu; + +function optionalPlainHtml( + field: string, + value: string | undefined, + unsubscribeUrl: string +): string | undefined { + if (value === undefined) return undefined; + const html = requiredBoundedText(field, value, HTML_MAXIMUM, true); + if ( + FORBIDDEN_HTML_ELEMENT_PATTERN.test(html) || + HTML_EVENT_HANDLER_PATTERN.test(html) || + HTML_RESOURCE_ATTRIBUTE_PATTERN.test(html) || + /