From b951bdd927928472079655f663daa085f5aa048f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 14:44:02 -0700 Subject: [PATCH] feat(growth): attribute campaign sends per template with a bounded Resend tag Every send_step now carries a campaign_template tag alongside campaign_version and campaign_step, so replies, bookings, and unsubscribes can be attributed to the exact template that rendered the message rather than only its step. - draftFor returns the chosen template id (immediate, day-3, day-8, or the cited evidence angle) and prepareCampaignMessage exposes it. - RecipientEmailInput accepts campaignTemplate; resend.ts validates it against a closed allowlist, requires it for send_step, and rejects it on any other job kind. The value is a fixed id, never contact data. - The webhook receiver's canonical-send check counted send_step tags exactly, which would have downgraded every new send's early webhook from retryable to dropped. It now accepts the legacy 4-tag shape for in-flight sends and the 5-tag shape when the template is allowlisted. Internal analytics stays step-only by decision; the Resend dashboard is the v1 surface for per-template numbers. Co-Authored-By: Claude Fable 5.1 --- apps/lifecycle/src/campaign/send.spec.ts | 12 ++++ apps/lifecycle/src/campaign/send.ts | 46 ++++++++++++--- libs/growth/src/lib/resend.spec.ts | 71 +++++++++++++++++++++++- libs/growth/src/lib/resend.ts | 53 ++++++++++++++++-- libs/growth/src/lib/webhooks.spec.ts | 53 ++++++++++++++++++ libs/growth/src/lib/webhooks.ts | 11 +++- 6 files changed, 229 insertions(+), 17 deletions(-) diff --git a/apps/lifecycle/src/campaign/send.spec.ts b/apps/lifecycle/src/campaign/send.spec.ts index 41d777163..4cb7833ee 100644 --- a/apps/lifecycle/src/campaign/send.spec.ts +++ b/apps/lifecycle/src/campaign/send.spec.ts @@ -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)); @@ -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( @@ -458,6 +464,7 @@ describe('dispatchLifecycleAppOwnedJob', () => { text: expect.stringContaining('Stop here: '), html: expect.stringContaining('Click