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
12 changes: 12 additions & 0 deletions apps/lifecycle/src/campaign/send.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ describe('prepareCampaignMessage', () => {
'Get your agent UI into production',
'Free engineering session with the Threadplane founder',
][step - 1],
template: ['immediate', 'day-3', 'day-8'][step - 1],
});
if (prepared.status !== 'ready') throw new Error('expected ready');
expect(prepared.text).toContain(unsubscribeActionUrlValue(UNSUBSCRIBE));
Expand Down Expand Up @@ -253,6 +254,11 @@ describe('prepareCampaignMessage', () => {
'Three checks when the UI stalls',
'One boundary that makes agent UIs testable',
][step - 1],
template: [
'streaming_foundation',
'debugging_layers',
'event_state_boundary',
][step - 1],
});
if (prepared.status !== 'ready') throw new Error('expected ready');
expect(prepared.text).toContain(
Expand Down Expand Up @@ -458,6 +464,7 @@ describe('dispatchLifecycleAppOwnedJob', () => {
text: expect.stringContaining('Stop here: '),
html: expect.stringContaining('Click <a href="'),
unsubscribeUrl: UNSUBSCRIBE,
campaignTemplate: 'immediate',
}),
deps.recipientPolicy
);
Expand Down Expand Up @@ -548,6 +555,11 @@ describe('dispatchLifecycleAppOwnedJob', () => {
}),
deps.recipientPolicy
);
expect(deps.sendRecipient).toHaveBeenCalledWith(
expect.anything(),
expect.not.objectContaining({ campaignTemplate: expect.anything() }),
deps.recipientPolicy
);
const sent = vi.mocked(deps.sendRecipient).mock.calls[0]?.[1];
expect(sent?.text.startsWith('Hey Ada,\n\nHere is the guide')).toBe(true);
expect(
Expand Down
46 changes: 37 additions & 9 deletions apps/lifecycle/src/campaign/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
type RecipientEmailInput,
type RecipientSendResult,
type SqlExecutor,
type CampaignTemplateId,
type UnsubscribeActionUrl,
} from '../growth.js';
import { Resend } from 'resend';
Expand Down Expand Up @@ -188,6 +189,12 @@ export type PreparedCampaignMessage = {
subject: string;
text: string;
html: string;
/** The template id that rendered this message, attributed as a provider tag. */
template: CampaignTemplateId;
};

type SelectedCampaignDraft = CampaignDraft & {
readonly template: CampaignTemplateId;
};

function campaignStep(job: GrowthJob): 1 | 2 | 3 {
Expand Down Expand Up @@ -235,7 +242,7 @@ function validArtifact(
function draftFor(
step: 1 | 2 | 3,
artifact: EnrichmentArtifact | null
): CampaignDraft {
): SelectedCampaignDraft {
// Every step is the founder session offer. A cited research angle only
// changes which flavor of that offer goes out.
if (artifact) {
Expand All @@ -246,12 +253,18 @@ function draftFor(
source_ids.includes(selection.source_id)
);
if (selection !== null && cited) {
return renderEvidenceCampaignTemplate(selection.angle_id, {
finalStep: step === 3,
});
return {
...renderEvidenceCampaignTemplate(selection.angle_id, {
finalStep: step === 3,
}),
template: selection.angle_id,
};
}
}
return renderCampaignTemplate(STEP_NAMES[step]);
return {
...renderCampaignTemplate(STEP_NAMES[step]),
template: STEP_NAMES[step],
};
}

const FIRST_NAME_PATTERN = /^[A-Za-z][A-Za-z'’-]{0,29}$/u;
Expand Down Expand Up @@ -353,6 +366,7 @@ export function prepareCampaignMessage(input: {
subject: draft.subject,
text: signedText(body, input.unsubscribeUrl),
html: signedHtml(body, input.unsubscribeUrl),
template: draft.template,
};
}

Expand Down Expand Up @@ -397,7 +411,10 @@ function formSource(

function enrichmentDrafts(context: LifecycleJobContext): CampaignDraft[] {
const artifact = validArtifact(context.enrichmentArtifact, context.contactId);
return ([1, 2, 3] as const).map((step) => draftFor(step, artifact));
return ([1, 2, 3] as const).map((step) => {
const { subject, body } = draftFor(step, artifact);
return { subject, body };
});
}

async function dispatchRecipient(
Expand All @@ -408,13 +425,23 @@ async function dispatchRecipient(
html: string,
unsubscribeUrl: UnsubscribeActionUrl,
signal: AbortSignal,
dependencies: LifecycleJobDependencies
dependencies: LifecycleJobDependencies,
campaignTemplate?: CampaignTemplateId
): Promise<GrowthDispatchResult> {
const leaseToken = requireLease(job);
signal.throwIfAborted();
const result = await dependencies.sendRecipient(
executor,
{ jobId: job.id, leaseToken, subject, text, html, unsubscribeUrl, signal },
{
jobId: job.id,
leaseToken,
subject,
text,
html,
unsubscribeUrl,
signal,
...(campaignTemplate === undefined ? {} : { campaignTemplate }),
},
dependencies.recipientPolicy
);
if (result.accepted) return 'completed';
Expand Down Expand Up @@ -518,7 +545,8 @@ export async function dispatchLifecycleAppOwnedJob(
message.html,
unsubscribeUrl,
signal,
dependencies
dependencies,
message.template
);
}

Expand Down
71 changes: 70 additions & 1 deletion libs/growth/src/lib/resend.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ const message = {
subject: 'A Threadplane architecture note',
text: 'Hi Sam,\n\nHere is the architecture note.\n\nBrian',
unsubscribeUrl: unsubscribeActionUrl,
campaignTemplate: 'immediate' as const,
};

describe('sendRecipientEmail', () => {
Expand Down Expand Up @@ -178,6 +179,7 @@ describe('sendRecipientEmail', () => {
{ name: 'job_kind', value: 'send_step' },
{ name: 'campaign_version', value: 'v1' },
{ name: 'campaign_step', value: '1' },
{ name: 'campaign_template', value: 'immediate' },
],
},
{ idempotencyKey: 'campaign:v1:contact:step:1' }
Expand Down Expand Up @@ -277,6 +279,73 @@ describe('sendRecipientEmail', () => {
expect(test.send).not.toHaveBeenCalled();
});

it.each([
'immediate',
'day-3',
'day-8',
'streaming_foundation',
'debugging_layers',
'event_state_boundary',
] as const)('tags a send_step with the %s template', async (template) => {
const test = harness();

await sendRecipientEmail(
test.database,
{ ...message, campaignTemplate: template },
productionPolicy(),
test.dependencies
);

expect(test.send.mock.calls[0]?.[0]).toMatchObject({
tags: expect.arrayContaining([
{ name: 'campaign_template', value: template },
]),
});
});

it.each([
undefined,
'',
'day-4',
'immediate; developer@example.com',
'IMMEDIATE',
])(
'rejects a send_step whose template is outside the closed allowlist (%j)',
async (template) => {
const test = harness();

await expect(
sendRecipientEmail(
test.database,
{ ...message, campaignTemplate: template as never },
productionPolicy(),
test.dependencies
)
).rejects.toThrow(/campaign template/u);
expect(test.send).not.toHaveBeenCalled();
}
);

it('rejects a campaign template on a non-campaign job', async () => {
const test = harness({
job: job({
kind: 'fulfill',
idempotencyKey: 'fulfill:whitepaper:contact',
payload: { fulfillment_kind: 'whitepaper' },
}),
});

await expect(
sendRecipientEmail(
test.database,
message,
productionPolicy(),
test.dependencies
)
).rejects.toThrow(/campaign template/u);
expect(test.send).not.toHaveBeenCalled();
});

it('uses a separate fulfillment tag contract without campaign tags', async () => {
const test = harness({
job: job({
Expand All @@ -288,7 +357,7 @@ describe('sendRecipientEmail', () => {

await sendRecipientEmail(
test.database,
message,
{ ...message, campaignTemplate: undefined },
productionPolicy(),
test.dependencies
);
Expand Down
53 changes: 49 additions & 4 deletions libs/growth/src/lib/resend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,41 @@ export interface RecipientDeliveryPolicy {
nonProductionRedirectTo?: string;
}

/**
* The closed set of campaign template ids a send_step may be attributed to.
* Provider tags are the only per-template signal webhooks and reply handling
* can see, so every value here is a fixed identifier and never contact data.
*/
export const CAMPAIGN_TEMPLATE_IDS = [
'immediate',
'day-3',
'day-8',
'streaming_foundation',
'debugging_layers',
'event_state_boundary',
] as const;
export type CampaignTemplateId = (typeof CAMPAIGN_TEMPLATE_IDS)[number];
const CAMPAIGN_TEMPLATE_ID_SET: ReadonlySet<string> = new Set(
CAMPAIGN_TEMPLATE_IDS
);

export function isCampaignTemplateId(
value: unknown
): value is CampaignTemplateId {
return typeof value === 'string' && CAMPAIGN_TEMPLATE_ID_SET.has(value);
}

export interface RecipientEmailInput {
jobId: string;
leaseToken: string;
subject: string;
text: string;
/**
* Which campaign template rendered this message. Required for send_step
* jobs and forbidden otherwise; it is emitted as the bounded
* `campaign_template` provider tag.
*/
campaignTemplate?: CampaignTemplateId;
/**
* Optional HTML alternative for the same message. It must stay a plain
* rendering of the text part: paragraphs and HTTPS anchors only, no images,
Expand Down Expand Up @@ -291,23 +321,33 @@ function effectiveRecipient(
function campaignTags(
environment: DeliveryEnvironment,
kind: string,
payload: Record<string, unknown>
payload: Record<string, unknown>,
campaignTemplate: unknown
): { name: string; value: string }[] {
const tags = [
{ name: 'environment', value: environment },
{ name: 'job_kind', value: kind },
];
if (kind !== 'send_step') return tags;
if (kind !== 'send_step') {
if (campaignTemplate !== undefined) {
throw new Error('Only send_step carries a campaign template');
}
return tags;
}
const campaignVersion = payload['campaign_version'];
const step = payload['step'];
if (campaignVersion !== 'v1' || (step !== 1 && step !== 2 && step !== 3)) {
throw new Error(
'send_step requires a registered campaign version and step'
);
}
if (!isCampaignTemplateId(campaignTemplate)) {
throw new Error('send_step requires a registered campaign template');
}
tags.push(
{ name: 'campaign_version', value: campaignVersion },
{ name: 'campaign_step', value: String(step) }
{ name: 'campaign_step', value: String(step) },
{ name: 'campaign_template', value: campaignTemplate }
);
return tags;
}
Expand Down Expand Up @@ -379,7 +419,12 @@ export async function sendRecipientEmail(
job.idempotencyKey,
256
);
const tags = campaignTags(policy.environment, job.kind, job.payload);
const tags = campaignTags(
policy.environment,
job.kind,
job.payload,
input.campaignTemplate
);

input.signal?.throwIfAborted();
let response: ResendResponse;
Expand Down
Loading
Loading