diff --git a/apps/lifecycle/README.md b/apps/lifecycle/README.md
index 0a830e3cf..437d5f523 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.
+All three campaign steps are founder session offers and send without waiting for an enrichment artifact; a cited research angle only selects an angle-flavored version of the same offer. 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..9bd5a879c 100644
--- a/apps/lifecycle/src/campaign/send.spec.ts
+++ b/apps/lifecycle/src/campaign/send.spec.ts
@@ -20,11 +20,13 @@ import {
dispatchLifecycleAppOwnedJob,
LIFECYCLE_SCORE_CONTENT_REGISTRY_V1,
loadLifecycleRuntimeConfiguration,
+ campaignGreeting,
prepareCampaignMessage,
type LifecycleJobContext,
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 +137,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,15 +148,14 @@ 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',
- 'One debugging shortcut',
- 'One last architecture note',
+ 'Engineer to engineer',
+ 'Get your agent UI into production',
+ 'Free engineering session with the Threadplane founder',
][step - 1],
});
if (prepared.status !== 'ready') throw new Error('expected ready');
@@ -171,13 +171,12 @@ describe('prepareCampaignMessage', () => {
prepareCampaignMessage({
context: { ...context(), campaignEnrollmentReason: 'install_runtime' },
job: job('send_step', { campaign_version: 'v1', step: 4 }),
- now: NOW,
unsubscribeUrl: UNSUBSCRIBE,
})
).toThrow(DeterministicLifecycleJobError);
});
- it('renders only a closed evidence-linked angle selection deterministically', () => {
+ it('renders the evidence-flavored final offer for a closed evidence-linked artifact', () => {
const cited = artifact({
cited_signals: [
{ signal: 'Bounded source fact', source_ids: ['source-1'] },
@@ -200,11 +199,13 @@ 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: 3 }),
unsubscribeUrl: UNSUBSCRIBE,
})
- ).toMatchObject({ status: 'ready', subject: 'A streaming foundation' });
+ ).toMatchObject({
+ status: 'ready',
+ subject: 'One boundary that makes agent UIs testable',
+ });
});
it.each([
@@ -224,13 +225,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);
@@ -238,98 +238,127 @@ describe('prepareCampaignMessage', () => {
});
it.each([1, 2, 3] as const)(
- 'maps the validated AI draft at index %i to only that fixed step',
+ 'maps the validated research angle at index %i to only that fixed step',
(step) => {
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',
- 'A debugging sequence',
- 'One event-state boundary',
+ 'Streaming first, then the rest',
+ 'Three checks when the UI stalls',
+ 'One boundary that makes agent UIs testable',
][step - 1],
});
if (prepared.status !== 'ready') throw new Error('expected ready');
- expect(prepared.text).toContain('\n\n—\nBrian\n\nTo stop these emails: ');
+ expect(prepared.text).toContain(
+ '\n\n—\nBrian\n\nIs this email not relevant to you? Stop here: '
+ );
expect(prepared.text).toContain(unsubscribeActionUrlValue(UNSUBSCRIBE));
expect(prepared.text).not.toContain('ada@example.com');
}
);
- it('uses a valid artifact immediately without imposing the five-minute wait', () => {
+ it('sends the streaming-flavored offer as step one when research is cited', () => {
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: 'Streaming first, then the rest',
+ });
});
- 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(
+ `Is this email not relevant to you? 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).toContain(`href="${unsubscribeUrl}">here`);
+ expect(prepared.text).toContain(
+ `Is this email not relevant to you? Stop here: ${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),
- },
- ],
- 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' },
- ],
+ it('escapes body text and keeps only bare links as anchors in the HTML part', () => {
+ const prepared = prepareCampaignMessage({
+ context: context({ enrichmentArtifact: null }),
+ job: job('send_step', { campaign_version: 'v1', step: 2 }),
+ unsubscribeUrl: UNSUBSCRIBE,
});
- 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'),
+ expect(prepared.html).toContain(
+ `me:
${FOUNDER_BOOKING_URL}`
+ );
+ expect(prepared.html).toMatch(
+ /^Hey Ada,<\/p>\n
[^<]+<\/p>\n
[^<]+<\/p>\n
[^<]+
/u
+ );
+ expect(prepared.html).not.toContain('<');
+ });
+
+ it.each([
+ ['Ada', 'Hey Ada,'],
+ ['Ada Lovelace', 'Hey Ada,'],
+ [" O'Brien ", "Hey O'Brien,"],
+ ['Anne-Marie Smith', 'Hey Anne-Marie,'],
+ [null, 'Hey there,'],
+ ['', 'Hey there,'],
+ ['Click https://evil.example', 'Hey there,'],
+ ['Ada', 'Hey there,'],
+ ['ada@example.com', 'Hey there,'],
+ ['Ada123', 'Hey there,'],
+ ['A'.repeat(31), 'Hey there,'],
+ ['Bcc: victim@example.com', 'Hey there,'],
+ ])('greets display name %j as %s', (displayName, greeting) => {
+ expect(campaignGreeting(displayName)).toBe(greeting);
+ });
+
+ it('opens every step with the greeting and escapes it in the HTML part', () => {
+ const prepared = prepareCampaignMessage({
+ context: context({ displayName: "O'Brien", enrichmentArtifact: null }),
+ 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.text.startsWith("Hey O'Brien,\n\nEven with agents")).toBe(
+ true
+ );
+ expect(
+ prepared.html.startsWith('
Hey O'Brien,
\nEven with')
+ ).toBe(true);
});
it('falls back per fixed step when an artifact draft violates copy checks', () => {
@@ -345,10 +374,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 +454,9 @@ describe('dispatchLifecycleAppOwnedJob', () => {
expect.objectContaining({
jobId: send.id,
leaseToken: LEASE_TOKEN,
- subject: 'A practical place to start',
+ subject: 'Engineer to engineer',
+ text: expect.stringContaining('Stop here: '),
+ html: expect.stringContaining('Click ," when the persisted display name is a plain name and its
+ * first word is a plain first name; otherwise "Hey there,". 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.
+ */
+export function campaignGreeting(
+ displayName: string | null | undefined
+): string {
+ const name = (displayName ?? '').trim();
+ if (name.length > 60 || !PLAIN_NAME_PATTERN.test(name)) return 'Hey there,';
+ const first = name.split(/\s+/u)[0] ?? '';
+ return FIRST_NAME_PATTERN.test(first) ? `Hey ${first},` : 'Hey there,';
+}
+
function signedText(
body: string,
unsubscribeUrl: UnsubscribeActionUrl
): string {
- return `${body}\n\n—\nBrian\n\nTo stop these emails: ${unsubscribeActionUrlValue(
+ return `${body}\n\n—\nBrian\n\nIs this email not relevant to you? Stop here: ${unsubscribeActionUrlValue(
unsubscribeUrl
)}`;
}
+const HTML_ESCAPES: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+};
+const BODY_LINK_PATTERN = /https:\/\/[^\s<>()"'“”‘’\]}]+/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
',
+ `Is this email not relevant to you? Click here.
`,
+ ].join('\n');
+}
+
export function prepareCampaignMessage(input: {
context: LifecycleJobContext;
job: GrowthJob;
- now: Date;
unsubscribeUrl: UnsubscribeActionUrl;
}): PreparedCampaignMessage {
- const step = campaignStep(input.job);
const genericHello =
input.context.campaignEnrollmentReason === 'install_runtime';
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);
+ const draft = draftFor(campaignStep(input.job), artifact);
+ const body = `${campaignGreeting(input.context.displayName)}\n\n${
+ draft.body
+ }`;
return {
status: 'ready',
subject: draft.subject,
- text: signedText(draft.body, input.unsubscribeUrl),
+ text: signedText(body, input.unsubscribeUrl),
+ html: signedHtml(body, input.unsubscribeUrl),
};
}
@@ -341,6 +404,7 @@ async function dispatchRecipient(
job: GrowthJob,
subject: string,
text: string,
+ html: string,
unsubscribeUrl: UnsubscribeActionUrl,
signal: AbortSignal,
dependencies: LifecycleJobDependencies
@@ -349,7 +413,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 +489,7 @@ export async function dispatchLifecycleAppOwnedJob(
job,
message.subject,
signedText(message.body, unsubscribeUrl),
+ signedHtml(message.body, unsubscribeUrl),
unsubscribeUrl,
signal,
dependencies
@@ -439,24 +504,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..2e1c1f403 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,73 @@ 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
+ ['immediate', 'Engineer to engineer', 0, 1],
+ ['day-3', 'Get your agent UI into production', 0, 1],
+ ['day-8', 'Free engineering session with the Threadplane founder', 0, 1],
+ ] 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('follows up with a hands-on session offer that links only to the booking page', () => {
+ const message = renderCampaignTemplate('day-3');
+
+ expect(
+ message.body.endsWith(`Book a time with me:\n${FOUNDER_BOOKING_URL}`)
+ ).toBe(true);
+ expect(message.body).toContain('No sales.');
+ expect(message.body).not.toMatch(/\b\w+'\w+\b/u);
+ });
+
+ it('closes with a final founder offer that links only to the booking page', () => {
+ const message = renderCampaignTemplate('day-8');
+
+ expect(message.body.startsWith('One last note.')).toBe(true);
+ expect(message.body).toContain(
+ `Grab some time on my calendar:\n${FOUNDER_BOOKING_URL}`
);
- expect(campaignDraftViolations(message)).toEqual([]);
- expect(message.body).not.toMatch(/\nBrian$/u);
+ 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..9c80583f8 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/nK961tWHZd21izKR6';
+
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,31 +171,31 @@ 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: `A 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 founding engineer, 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',
- body: 'If an agent UI stalls, I usually isolate transport, state updates, and rendering in that order. It turns a vague integration problem into three small checks.\n\nWhich layer is blocking you?\n\nhttps://threadplane.ai/docs',
+ subject: 'Get your agent UI into production',
+ body: `Even with agents writing much of our code today, teams still get stuck.\n\nI can help you work through any issue with Threadplane in a 30-minute hands-on session: fix the bug, and get your code working.\n\nNo sales.\nJust a founding engineer meeting with you and your engineering team.\n\nBook a time with me:\n${FOUNDER_BOOKING_URL}`,
},
'day-8': {
- subject: 'One last architecture note',
- body: 'A clean boundary between agent events and UI state makes streaming, retries, and tests much easier to reason about. If you reply with the rough shape of your stack, I can point to a relevant pattern.\n\nWould that be useful?\n\nThis is my last automated follow-up.',
+ subject: 'Free engineering session with the Threadplane founder',
+ body: `One last note.\n\nAs the founder of Threadplane, I want to get on a call with you and your team to help you fix any bugs and get your code working.\n\nNo sales.\nJust help, and some feedback for me to improve the product.\n\nGrab some time on my calendar:\n${FOUNDER_BOOKING_URL}\n\nThis is my last automated follow-up.`,
},
};
const EVIDENCE_TEMPLATES: Record = {
streaming_foundation: {
- subject: 'A streaming foundation',
- body: 'One useful starting pattern is to get a streamed response working end to end before layering in persistence and interrupts. It keeps the first integration boundary small.\n\nWould that sequence help?\n\nhttps://threadplane.ai/docs',
+ subject: 'Streaming first, then the rest',
+ body: `Most agent UIs go wrong before the first streamed response works end to end.\nGet that boundary solid first, then layer in persistence and interrupts.\n\nI am the founding engineer, and I am happy to work through that sequence with you and your team in a short hands-on session.\n\nNo sales.\nJust engineer to engineer.\n\nGrab a time here:\n${FOUNDER_BOOKING_URL}`,
},
debugging_layers: {
- subject: 'A debugging sequence',
- body: 'A practical debugging order is transport, state updates, then rendering. It turns an agent UI problem into three smaller checks.\n\nWhich layer would be most useful to isolate?\n\nhttps://threadplane.ai/docs',
+ subject: 'Three checks when the UI stalls',
+ body: `When an agent UI stalls, I isolate transport, state updates, and rendering, in that order.\nIt turns one vague problem into three small checks.\n\nIf you are stuck on any of them, I can walk through it with you and your team in a 30-minute hands-on session.\n\nNo sales.\nJust a founding engineer helping you get your code working.\n\nBook a time with me:\n${FOUNDER_BOOKING_URL}`,
},
event_state_boundary: {
- subject: 'One event-state boundary',
- body: 'A narrow boundary between agent events and UI state makes streaming, retries, and tests easier to reason about.\n\nWould a concrete pattern be useful?',
+ subject: 'One boundary that makes agent UIs testable',
+ body: `A narrow boundary between agent events and UI state makes streaming, retries, and tests much easier to reason about.\n\nAs the founding engineer of Threadplane, I want to get on a call with you and your team to look at that boundary in your code and fix anything in the way.\n\nNo sales.\nJust help, and some feedback for me to improve the product.\n\nGrab some time on my calendar:\n${FOUNDER_BOOKING_URL}`,
},
};
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..0d1f70c28 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; a cited angle only flavors the offer) | Founder session offer: thank them, name the pilot-to-production gap, offer an engineer-to-engineer session, link the booking page. |
+| 2 | Day 3 | Second founder offer: teams still get stuck, offer a 30-minute hands-on session with the founder, link the booking page. |
+| 3 | Day 8 | Final founder offer: one last note, a call with the founder to fix bugs and gather product feedback, booking link, and the last-automated-follow-up notice. |
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', ''],
+ ['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) ||
+ /