Skip to content

Commit d0993ea

Browse files
bloveclaude
andcommitted
feat(lifecycle): print the business postal address in every email footer
CAN-SPAM requires a valid physical postal address on commercial email. Every recipient-facing message (campaign steps and fulfillment) now ends with the business postal address on a single plain line directly after the unsubscribe line, in both the text and HTML parts. The address is sourced from one exported constant, BUSINESS_POSTAL_ADDRESS, next to the founder booking URL; the HTML part renders it as one escaped <p> so the sender's paragraphs-and-links-only check still passes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 3f941d4 commit d0993ea

4 files changed

Lines changed: 34 additions & 6 deletions

File tree

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
type LifecycleJobDependencies,
2727
} from './send.js';
2828
import { DeterministicLifecycleJobError } from '../job-errors.js';
29-
import { FOUNDER_BOOKING_URL } from './templates.js';
29+
import { BUSINESS_POSTAL_ADDRESS, FOUNDER_BOOKING_URL } from './templates.js';
3030

3131
const NOW = new Date('2026-09-01T12:03:00.000Z');
3232
const CONTACT_ID = '00000000-0000-4000-8000-000000000002';
@@ -161,6 +161,13 @@ describe('prepareCampaignMessage', () => {
161161
if (prepared.status !== 'ready') throw new Error('expected ready');
162162
expect(prepared.text).toContain(unsubscribeActionUrlValue(UNSUBSCRIBE));
163163
expect(prepared.text).toContain('\n\n—\nBrian\n');
164+
expect(
165+
prepared.text.endsWith(
166+
`${unsubscribeActionUrlValue(
167+
UNSUBSCRIBE
168+
)}\n${BUSINESS_POSTAL_ADDRESS}`
169+
)
170+
).toBe(true);
164171
if (step === 3)
165172
expect(prepared.text).toContain('This is my last automated follow-up.');
166173
}
@@ -259,6 +266,13 @@ describe('prepareCampaignMessage', () => {
259266
'\n\n—\nBrian\n\nIs this email not relevant to you? Stop here: '
260267
);
261268
expect(prepared.text).toContain(unsubscribeActionUrlValue(UNSUBSCRIBE));
269+
expect(
270+
prepared.text.endsWith(
271+
`${unsubscribeActionUrlValue(
272+
UNSUBSCRIBE
273+
)}\n${BUSINESS_POSTAL_ADDRESS}`
274+
)
275+
).toBe(true);
262276
expect(prepared.text).not.toContain('ada@example.com');
263277
}
264278
);
@@ -300,7 +314,7 @@ describe('prepareCampaignMessage', () => {
300314
expect(prepared.html).toContain('<p>—<br>Brian</p>');
301315
expect(
302316
prepared.html.endsWith(
303-
`<p>Is this email not relevant to you? Click <a href="${unsubscribeUrl}">here</a>.</p>`
317+
`<p>Is this email not relevant to you? Click <a href="${unsubscribeUrl}">here</a>.</p>\n<p>${BUSINESS_POSTAL_ADDRESS}</p>`
304318
)
305319
).toBe(true);
306320
expect(prepared.html.split(unsubscribeUrl)).toHaveLength(2);
@@ -309,7 +323,7 @@ describe('prepareCampaignMessage', () => {
309323
);
310324
expect(prepared.html).toContain(`href="${unsubscribeUrl}">here</a>`);
311325
expect(prepared.text).toContain(
312-
`Is this email not relevant to you? Stop here: ${unsubscribeUrl}`
326+
`Is this email not relevant to you? Stop here: ${unsubscribeUrl}\n${BUSINESS_POSTAL_ADDRESS}`
313327
);
314328
});
315329

apps/lifecycle/src/campaign/send.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { renderFulfillmentTemplate } from '../fulfillment/templates.js';
4848
import { renderInternalNotificationSummary } from '../notifications/templates.js';
4949
import { DeterministicLifecycleJobError } from '../job-errors.js';
5050
import {
51+
BUSINESS_POSTAL_ADDRESS,
5152
renderCampaignTemplate,
5253
renderEvidenceCampaignTemplate,
5354
type CampaignDraft,
@@ -279,7 +280,7 @@ function signedText(
279280
): string {
280281
return `${body}\n\n—\nBrian\n\nIs this email not relevant to you? Stop here: ${unsubscribeActionUrlValue(
281282
unsubscribeUrl
282-
)}`;
283+
)}\n${BUSINESS_POSTAL_ADDRESS}`;
283284
}
284285

285286
const HTML_ESCAPES: Record<string, string> = {
@@ -315,8 +316,9 @@ function htmlLine(line: string): string {
315316

316317
/**
317318
* A plain HTML alternative for the text part: the same paragraphs, bare HTTPS
318-
* links as anchors, and a one-word unsubscribe link instead of the long signed
319-
* URL. No layout, images, styles, or tracking.
319+
* links as anchors, a one-word unsubscribe link instead of the long signed
320+
* URL, and the business postal address as the final line. No layout, images,
321+
* styles, or tracking.
320322
*/
321323
function signedHtml(
322324
body: string,
@@ -331,6 +333,7 @@ function signedHtml(
331333
...paragraphs,
332334
'<p>—<br>Brian</p>',
333335
`<p>Is this email not relevant to you? Click <a href="${unsubscribe}">here</a>.</p>`,
336+
`<p>${escapeHtml(BUSINESS_POSTAL_ADDRESS)}</p>`,
334337
].join('\n');
335338
}
336339

apps/lifecycle/src/campaign/templates.ts

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

24+
/**
25+
* The business postal address every recipient-facing email prints after its
26+
* unsubscribe line. CAN-SPAM requires a valid physical postal address on
27+
* commercial mail, so the footer in `send.ts` renders this single constant in
28+
* both the text and HTML parts. Keep it on one line; the HTML part escapes it
29+
* into a bare `<p>`.
30+
*/
31+
export const BUSINESS_POSTAL_ADDRESS =
32+
'2843 NW Lolo Dr, Bend, OR 97703, United States';
33+
2434
/**
2535
* Every link recipient copy may carry, across campaign steps and fulfillment
2636
* mail. The four PDFs are the whitepaper fulfillment deliverables.

docs/superpowers/specs/2026-08-31-threadplane-growth-lifecycle-v1-design.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,7 @@ apps/lifecycle should declare Dawn Core/CLI/LangGraph/Postgres Storage/SDK 0.8.2
674674
Copy constraints:
675675

676676
- 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.
677+
- 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 `<p>` so the sender's paragraphs-and-links-only check still passes.
677678
- Internal research and operational notifications are plain text as well.
678679
- Each campaign step is at most 120 words.
679680
- At most one question and at most one useful link.

0 commit comments

Comments
 (0)