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
20 changes: 17 additions & 3 deletions apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.');
}
Expand Down Expand 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');
}
);
Expand Down Expand Up @@ -300,7 +314,7 @@ describe('prepareCampaignMessage', () => {
expect(prepared.html).toContain('<p>—<br>Brian</p>');
expect(
prepared.html.endsWith(
`<p>Is this email not relevant to you? Click <a href="${unsubscribeUrl}">here</a>.</p>`
`<p>Is this email not relevant to you? Click <a href="${unsubscribeUrl}">here</a>.</p>\n<p>${BUSINESS_POSTAL_ADDRESS}</p>`
)
).toBe(true);
expect(prepared.html.split(unsubscribeUrl)).toHaveLength(2);
Expand All @@ -309,7 +323,7 @@ describe('prepareCampaignMessage', () => {
);
expect(prepared.html).toContain(`href="${unsubscribeUrl}">here</a>`);
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}`
);
});

Expand Down
9 changes: 6 additions & 3 deletions apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -315,8 +316,9 @@ function htmlLine(line: string): string {

/**
* 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.
* links as anchors, a one-word unsubscribe link instead of the long signed
* URL, and the business postal address as the final line. No layout, images,
* styles, or tracking.
*/
function signedHtml(
body: string,
Expand All @@ -331,6 +333,7 @@ function signedHtml(
...paragraphs,
'<p>—<br>Brian</p>',
`<p>Is this email not relevant to you? Click <a href="${unsubscribe}">here</a>.</p>`,
`<p>${escapeHtml(BUSINESS_POSTAL_ADDRESS)}</p>`,
].join('\n');
}

Expand Down
10 changes: 10 additions & 0 deletions apps/lifecycle/src/campaign/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p>`.
*/
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<p>` 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.
Expand Down
Loading