diff --git a/apps/lifecycle/src/campaign/send.spec.ts b/apps/lifecycle/src/campaign/send.spec.ts index 41d777163..377540a82 100644 --- a/apps/lifecycle/src/campaign/send.spec.ts +++ b/apps/lifecycle/src/campaign/send.spec.ts @@ -26,7 +26,7 @@ import { type LifecycleJobDependencies, } from './send.js'; import { DeterministicLifecycleJobError } from '../job-errors.js'; -import { FOUNDER_BOOKING_URL } from './templates.js'; +import { BUSINESS_POSTAL_ADDRESS, FOUNDER_BOOKING_URL } from './templates.js'; const NOW = new Date('2026-09-01T12:03:00.000Z'); const CONTACT_ID = '00000000-0000-4000-8000-000000000002'; @@ -161,6 +161,13 @@ describe('prepareCampaignMessage', () => { if (prepared.status !== 'ready') throw new Error('expected ready'); expect(prepared.text).toContain(unsubscribeActionUrlValue(UNSUBSCRIBE)); expect(prepared.text).toContain('\n\n—\nBrian\n'); + expect( + prepared.text.endsWith( + `${unsubscribeActionUrlValue( + UNSUBSCRIBE + )}\n${BUSINESS_POSTAL_ADDRESS}` + ) + ).toBe(true); if (step === 3) expect(prepared.text).toContain('This is my last automated follow-up.'); } @@ -259,6 +266,13 @@ describe('prepareCampaignMessage', () => { '\n\n—\nBrian\n\nIs this email not relevant to you? Stop here: ' ); expect(prepared.text).toContain(unsubscribeActionUrlValue(UNSUBSCRIBE)); + expect( + prepared.text.endsWith( + `${unsubscribeActionUrlValue( + UNSUBSCRIBE + )}\n${BUSINESS_POSTAL_ADDRESS}` + ) + ).toBe(true); expect(prepared.text).not.toContain('ada@example.com'); } ); @@ -300,7 +314,7 @@ describe('prepareCampaignMessage', () => { expect(prepared.html).toContain('
—
Brian
Is this email not relevant to you? Click here.
` + `Is this email not relevant to you? Click here.
\n${BUSINESS_POSTAL_ADDRESS}
` ) ).toBe(true); expect(prepared.html.split(unsubscribeUrl)).toHaveLength(2); @@ -309,7 +323,7 @@ describe('prepareCampaignMessage', () => { ); expect(prepared.html).toContain(`href="${unsubscribeUrl}">here`); expect(prepared.text).toContain( - `Is this email not relevant to you? Stop here: ${unsubscribeUrl}` + `Is this email not relevant to you? Stop here: ${unsubscribeUrl}\n${BUSINESS_POSTAL_ADDRESS}` ); }); diff --git a/apps/lifecycle/src/campaign/send.ts b/apps/lifecycle/src/campaign/send.ts index 6aa934f7d..85051546c 100644 --- a/apps/lifecycle/src/campaign/send.ts +++ b/apps/lifecycle/src/campaign/send.ts @@ -48,6 +48,7 @@ import { renderFulfillmentTemplate } from '../fulfillment/templates.js'; import { renderInternalNotificationSummary } from '../notifications/templates.js'; import { DeterministicLifecycleJobError } from '../job-errors.js'; import { + BUSINESS_POSTAL_ADDRESS, renderCampaignTemplate, renderEvidenceCampaignTemplate, type CampaignDraft, @@ -279,7 +280,7 @@ function signedText( ): string { return `${body}\n\n—\nBrian\n\nIs this email not relevant to you? Stop here: ${unsubscribeActionUrlValue( unsubscribeUrl - )}`; + )}\n${BUSINESS_POSTAL_ADDRESS}`; } const HTML_ESCAPES: Record—
Brian
Is this email not relevant to you? Click here.
`, + `${escapeHtml(BUSINESS_POSTAL_ADDRESS)}
`, ].join('\n'); } diff --git a/apps/lifecycle/src/campaign/templates.ts b/apps/lifecycle/src/campaign/templates.ts index 76c7d0aa4..bc3a8bf2a 100644 --- a/apps/lifecycle/src/campaign/templates.ts +++ b/apps/lifecycle/src/campaign/templates.ts @@ -21,6 +21,16 @@ const CampaignStepSchema = z.enum(['immediate', 'day-3', 'day-8']); export const FOUNDER_BOOKING_URL = 'https://calendar.app.google/nK961tWHZd21izKR6'; +/** + * The business postal address every recipient-facing email prints after its + * unsubscribe line. CAN-SPAM requires a valid physical postal address on + * commercial mail, so the footer in `send.ts` renders this single constant in + * both the text and HTML parts. Keep it on one line; the HTML part escapes it + * into a bare ``. + */ +export const BUSINESS_POSTAL_ADDRESS = + '2843 NW Lolo Dr, Bend, OR 97703, United States'; + /** * Every link recipient copy may carry, across campaign steps and fulfillment * mail. The four PDFs are the whitepaper fulfillment deliverables. 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 0d1f70c28..d6f842420 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 @@ -674,6 +674,7 @@ apps/lifecycle should declare Dawn Core/CLI/LangGraph/Postgres Storage/SDK 0.8.2 Copy constraints: - 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. +- Every recipient-facing email (campaign steps and fulfillment) ends with the business postal address on a single plain line directly after the unsubscribe line, in both the text and HTML parts, because CAN-SPAM requires a valid physical postal address on commercial mail. The address is sourced from the single exported constant `BUSINESS_POSTAL_ADDRESS` in the campaign templates; the HTML part renders it as one escaped `
` so the sender's paragraphs-and-links-only check still passes. - Internal research and operational notifications are plain text as well. - Each campaign step is at most 120 words. - At most one question and at most one useful link.