diff --git a/emails/CATALOG.md b/emails/CATALOG.md index fb2635c1c7..f292faec9f 100644 --- a/emails/CATALOG.md +++ b/emails/CATALOG.md @@ -4,22 +4,23 @@ Generated from the email library with `bun run emails:catalog`. Edit the source ## Lifecycle flows -These are the locally configured draft journeys. This document is not a live status check. Imports remain held; production enrollment is not deployed. Run `bun run emails:check-loops` to verify the actual Loops drafts. +These are the locally configured journeys. This document is not a live status check. Imports remain held. Run `bun run emails:check-loops --structure-only --allow-live` to verify the remote workflow structure and status; review custom MJML content in the browser. | Flow | Audience | Schedule after entry | Emails | | --- | --- | --- | --- | | [Independent free-user onboarding](https://app.loops.so/workflows/cmtvpih6201fp0jznodf8fpwz) | free | Day 0, Day 2, Day 5, Day 9 | 4 | | [Customer onboarding](https://app.loops.so/workflows/cmtvpilm601f40jzwx3474cko) | customer | Day 0, Day 3, Day 7 | 3 | +| [Free activation and Pro conversion v2](https://app.loops.so/workflows/cmtxlhkcw0dta0jzkd6an7xz0) | free | Day 0, Day 1, Day 3, Day 8, Day 10, Day 12 | 6 | | [Teammate onboarding](https://app.loops.so/workflows/cmtvpm0ag01it0j37w02o6wav) | teammate | Day 0, Day 3 | 2 | | [Former customer follow-up](https://app.loops.so/workflows/cmtvpm8zm01ii0j01cz7d15qr) | former | Day 14 | 1 | -Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. The integration is not deployed. Historical imports stay held; a new accepted invitation can start teammate help only. +Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. Historical imports stay held; a new accepted invitation can start teammate help only. -Current draft journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility. +Journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility. Teammate history takes priority over paid/free classification. Ambiguous contacts receive no journey. [Audience classification and consent](../scripts/loops/README.md#audience-rules). -An independent watchdog can block all four automatic journeys by adding an impossible subscription condition to their downstream guards. Recovery never resumes delivery automatically. This does not cancel manually scheduled campaigns; check sync health before every campaign send. See the [outage and resume procedure](../scripts/loops/README.md#outage-protection). +The independent watchdog checks every registered journey and alerts when an active flow requires a manual pause. It can hold paused/draft journeys with an impossible subscription condition. Recovery never resumes delivery automatically. Check sync health before campaign sends. See the [outage and resume procedure](../scripts/loops/README.md#outage-protection) and [conversion experiment](conversion-experiment.md). ### Independent free-user onboarding @@ -76,6 +77,53 @@ flowchart TD | 3 | [One less thing to explain twice](../emails/marketing/customer-workflow.ts) | Journey guards | | 7 | [How is Cap working for you?](../emails/marketing/customer-feedback.ts) | Journey guards | +### Free activation and Pro conversion v2 + +Entry: capLifecycleStage changes into free-v2. Re-entry is disabled. Delays below are relative to the previous step; day numbers are cumulative from entry. + +Downstream conditions: subscribed isTrue; capConsent equals subscribed; capAudience equals free; capTeammate isFalse; capPromotionalEligible isTrue; capLifecycleEnabled isTrue; capOnboardingEligible isTrue; capLifecycleStage equals free-v2. + +```mermaid +flowchart TD + entry["Stage becomes free-v2"] --> guard["Consent and audience guards"] + guard --> email0["Day 0: welcome"] + email0 --> wait1["Wait 1 days"] + wait1 --> branch1{"capNeedsRecordingHelp is true?"} + branch1 -->|Yes| email1["Day 1: record"] + branch1 -->|No: skip| next1((Continue)) + email1 --> next1 + next1 --> wait2["Wait 2 days"] + wait2 --> branch2{"capReadyForPro is true?"} + branch2 -->|Yes| email2["Day 3: plans"] + branch2 -->|No: skip| next2((Continue)) + email2 --> next2 + next2 --> wait3["Wait 5 days"] + wait3 --> branch3{"capNeedsSharingHelp is true?"} + branch3 -->|Yes| email3["Day 8: share"] + branch3 -->|No: skip| next3((Continue)) + email3 --> next3 + next3 --> wait4["Wait 2 days"] + wait4 --> branch4{"capReadyForPro is true?"} + branch4 -->|Yes| email4["Day 10: ai"] + branch4 -->|No: skip| next4((Continue)) + email4 --> next4 + next4 --> wait5["Wait 2 days"] + wait5 --> branch5{"capNeedsRecordingHelp is true?"} + branch5 -->|Yes| email5["Day 12: help"] + branch5 -->|No: skip| next5((Continue)) + email5 --> next5 + next5 --> exit["End"] +``` + +| Day | Subject and source | Send condition | +| --- | --- | --- | +| 0 | [Your first Cap only needs 30 seconds](../emails/marketing/free-v2-welcome.ts) | Journey guards | +| 1 | [One small thing to record today](../emails/marketing/free-v2-record.ts) | `capNeedsRecordingHelp=true` | +| 3 | [Some explanations need more than five minutes](../emails/marketing/free-v2-plans.ts) | `capReadyForPro=true` | +| 8 | [Put your recording to work](../emails/marketing/free-v2-share.ts) | `capNeedsSharingHelp=true` | +| 10 | [Record the walkthrough. Skip the extra write-up.](../emails/marketing/free-v2-ai.ts) | `capReadyForPro=true` | +| 12 | [Anything getting in the way?](../emails/marketing/free-v2-help.ts) | `capNeedsRecordingHelp=true` | + ### Teammate onboarding Entry: capLifecycleStage changes into teammate. Re-entry is disabled. Delays below are relative to the previous step; day numbers are cumulative from entry. @@ -300,6 +348,142 @@ Just reply here. I read every reply, and hearing how people actually use Cap hel Thanks again for backing us :) +### free-v2-welcome + +Help a new independent free user record and share one useful explanation. + +**Subject:** Your first Cap only needs 30 seconds + +**Preview:** Record one thing, send the link, and you're off. + +**Variables:** `contact.capGreeting` (fallback: Hey,) + +**Edit:** [emails/marketing/free-v2-welcome.ts](../emails/marketing/free-v2-welcome.ts) + +{contact.capGreeting} + +Richie here, founder of Cap. Thanks for giving it a go :) + +For your first recording, pick something you'd normally explain in a long message. Open Cap, choose Instant Mode, and spend 30 seconds showing it on screen. + +Once it's ready, send the link to someone who needs that explanation. No polished presentation needed. + +Download Cap and make your first recording + +If you get stuck, reply here and I'll help. + +### free-v2-record + +Offer a small first cloud recording task when no completed video is visible. + +**Subject:** One small thing to record today + +**Preview:** Try explaining something you already know. + +**Variables:** `contact.capGreeting` (fallback: Hey,) + +**Edit:** [emails/marketing/free-v2-record.ts](../emails/marketing/free-v2-record.ts) + +{contact.capGreeting} + +If you're still finding your feet with Cap, try recording a quick walkthrough of something you already know: a setting, a page, or a problem you want to show someone. + +Choose Instant Mode, keep it short, then send the link when it's ready. + +Get started with your first recording + +If screen or microphone permissions are getting in the way, reply with what you're seeing and I'll help you sort it. + +### free-v2-plans + +Offer Pro to active free users through a specific cloud-sharing benefit. + +**Subject:** Some explanations need more than five minutes + +**Preview:** Share the full walkthrough with Cap Pro. + +**Variables:** `contact.capGreeting` (fallback: Hey,) + +**Edit:** [emails/marketing/free-v2-plans.ts](../emails/marketing/free-v2-plans.ts) + +{contact.capGreeting} + +Five minutes works for a quick question. A full walkthrough sometimes needs longer. + +Cap Pro removes the five-minute limit on cloud recordings and gives you unlimited shareable links, so you can send the whole explanation in one video. + +It also includes the desktop commercial license for work recordings. + +Pro is US$12 per user, billed monthly. Annual billing is also available. + +Upgrade to Cap Pro + +### free-v2-share + +Help an active user put a completed recording into a real conversation. + +**Subject:** Put your recording to work + +**Preview:** Send the link with one sentence about what you need. + +**Variables:** `contact.capGreeting` (fallback: Hey,) + +**Edit:** [emails/marketing/free-v2-share.ts](../emails/marketing/free-v2-share.ts) + +{contact.capGreeting} + +A useful way to share a Cap is to add one sentence telling the other person what you need from them. + +"Here's the bit I'm stuck on. Can you take a look?" + +Or: "Here's how to change that setting. Does that solve it?" + +Open your recordings + +Send the link wherever you're already having the conversation. + +### free-v2-ai + +Show active free users how Pro reduces the work around a recording. + +**Subject:** Record the walkthrough. Skip the extra write-up. + +**Preview:** Give people a summary and chapters alongside your video. + +**Variables:** `contact.capGreeting` (fallback: Hey,) + +**Edit:** [emails/marketing/free-v2-ai.ts](../emails/marketing/free-v2-ai.ts) + +{contact.capGreeting} + +A recording saves you typing everything out. Writing a summary afterwards can feel like doing the job twice. + +Cap Pro generates a title, summary, transcript and clickable chapters for your recordings. The person watching can get the context, then jump to the part they need. + +That's especially useful for walkthroughs people come back to later. + +See Cap Pro + +### free-v2-help + +Invite a reply from users who have not reached a completed cloud recording. + +**Subject:** Anything getting in the way? + +**Preview:** Reply and tell me where you're getting stuck. + +**Variables:** `contact.capGreeting` (fallback: Hey,) + +**Edit:** [emails/marketing/free-v2-help.ts](../emails/marketing/free-v2-help.ts) + +{contact.capGreeting} + +If you haven't found a useful way to fit Cap into your day yet, is anything getting in the way? + +Maybe you're not sure what to record, something isn't working, or it isn't quite what you expected. + +Reply and let me know. If I can help you get a useful first recording out of it, I'd like to. + ### teammate-welcome Help an invited teammate find their workspace without promoting upgrades. diff --git a/emails/audiences.ts b/emails/audiences.ts index 12381da937..c0e38d1274 100644 --- a/emails/audiences.ts +++ b/emails/audiences.ts @@ -13,6 +13,12 @@ export const contactProperties = { capLifecycleStage: "string", capHasVideo: "boolean", capHasSharedVideo: "boolean", + capReadyForPro: "boolean", + capNeedsSharingHelp: "boolean", + capNeedsRecordingHelp: "boolean", + capFreeOnboardingVariant: "string", + capFreeOnboardingExperiment: "string", + capFreeOnboardingAssignedAt: "date", capVerifiedAt: "date", capImportedAt: "date", capSignupAt: "date", diff --git a/emails/conversion-experiment.md b/emails/conversion-experiment.md new file mode 100644 index 0000000000..0b4fe4a1f3 --- /dev/null +++ b/emails/conversion-experiment.md @@ -0,0 +1,48 @@ +# New free-user conversion experiment + +The hypothesis is that a simpler first recording task and earlier, relevant Pro offers increase first paid Pro purchases. The six `free-v2-*` templates form one treatment, compared with the existing four-email `free` journey. This tests the combined journey, not individual subject lines. No conversion lift has been established. + +## Enrollment and delivery + +`LOOPS_FREE_EXPERIMENT_AFTER` is the earliest eligible signup date. `LOOPS_FREE_EXPERIMENT_ENABLED=true` enables new assignments. Start with a future date after the code, contact properties, workflow and custom email uploads are verified. A SHA-256 bucket of the user ID and experiment ID assigns independent free accounts 50/50. Loops stores `capFreeOnboardingExperiment`, `capFreeOnboardingVariant` and `capFreeOnboardingAssignedAt` in the same update that first enters the journey. Both arms retain their assignment after purchase or unsubscribe; subsequent updates do not replace it. + +Existing enrolled users, imports, customers, invitees, teammate history, incomplete signups and opted-out/list-unsubscribed accounts cannot enter the test. No export/import or historical replay is needed. The treatment's downstream guard also requires `capLifecycleStage=free-v2`. All normal consent, list, audience and paid/teammate exclusions remain in force. Product and billing changes reach Loops through the existing durable queue. + +| Day from entry | Email | Eligibility at the branch | +| -------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| 0 | Your first Cap only needs 30 seconds | Eligible new free treatment account | +| 1 | One small thing to record today | No completed cloud video and no recent activation notification signal | +| 3 | Some explanations need more than five minutes | Completed cloud video, no pending upload/processing, no recent activation notification signal | +| 8 | Put your recording to work | Same readiness gate, without observed external-view notification evidence | +| 10 | Record the walkthrough. Skip the extra write-up. | Same Pro readiness gate; also reaches users who became active after day 3 | +| 12 | Anything getting in the way? | Same recording-help gate as day 1 | + +The schedule allows at most three lifecycle emails in any rolling seven days and separates planned sends by at least 24 hours. Pro and sharing follow-ups are suppressed when the latest synchronized first-link creation or first-view notification signal is less than 24 hours old. This is a conservative suppression rule, not an atomic frequency lock across Loops and Resend. Existing application notifications remain unchanged. Profile refreshes are hourly for new accounts; races between a profile refresh and delivery remain possible. + +## Activation evidence + +For accounts created from September 12, 2026 UTC, both arms use the new readiness query. Historical accounts retain their existing fingerprint and queries, avoiding a rewrite of the imported audience. The fixed activation date is independent of stopping experiment enrollment. + +A cloud video must have positive duration, not be a screenshot, have no upload row, and have either a verified processing job or an explicit published output/legacy completion marker with no processing job. Queued jobs whose upload row has been removed do not count. The query uses the owner index and primary-key joins. Pending or failed uploads/processing suppress sales even if an older video is complete. + +`capHasSharedVideo` for these new accounts means a completed video with `firstViewEmailSentAt` evidence. This is a conservative proxy for a non-owner page visit, including private videos. It is not proof of playback, and notification preferences can make it undercount. Do not use it as the primary conversion metric or claim it detects local Studio use. Imported/uploaded videos can establish cloud readiness; novelty of recording is not inferred. Copy avoids telling recipients that they have never recorded or shared. + +The Pro links use the existing login `next` mechanism to reach organization billing, where the independent owner can open the monthly-first Pro offer. No checkout or payment occurs merely by opening an email link. UTMs survive the login destination. Teammates and existing buyers are excluded from sales upstream. + +## Measurement contract + +Keep a private, timestamped Loops export containing all assigned accounts, including later unsubscribers and customers. Deduplicate by Cap `userId`; do not filter by current audience, delivery, opens, clicks, activation or continued subscription. Match `users.stripeCustomerId` and the paying account to Stripe invoices/payments for the verified Pro product. Retain assignment timestamp and variant for every denominator row. Never interpret a seat grant, trial, license purchase or `capCustomer` flag as a new paid Pro purchase. + +Primary outcome: the proportion of all assigned eligible accounts with a first successfully collected, nonzero Pro purchase within 30 days of assignment. Require a live paid invoice/payment, exclude out-of-band/uncollected payments and zero-price grants, and verify Pro line items. Report refunds separately. Analyze only assignment cohorts whose full 30-day window has elapsed, keeping both arms' acquisition windows identical. + +Secondary outcomes: actual Pro collections per assigned account over 30 and 60 days, successful cloud readiness at 24 hours/7 days, repeat use, and external viewing separately. Keep currencies separate or declare a fixed conversion method. Report annual cash collections separately from monthly subscriptions. Guardrails: opt-outs, complaints, bounces, duplicate sends, support problems and Desktop-license revenue displacement. Snapshot retention at equal cohort age; annual renewal is not observable after 60 days. + +Before choosing a winner, refresh the eligible signup rate and mature baseline, define the minimum useful lift and sample size, then allow the full outcome window. Early opens/clicks are diagnostic only. Preserve invoice IDs and payment timestamps in the private analysis for auditability. No scheduled statistical readout is implied by this file. + +## Operation and recovery + +Create a new workflow only with `scripts/loops/provision.ts --new-journey free-v2 --apply --state --team "Cap Software, Inc." --mailing-list `. This mode refuses unrelated existing workflows and initializes new draft emails before custom upload. It does not edit existing MJML. Export with `bun run emails:export --output ` and upload the six archives in Code. Review metadata, fallbacks, body, loaded logo and the single footer for every email. Reconcile resource IDs into `resources.json` and include the new workflow in watchdog checks. + +Verify with `bun run emails:check-loops --structure-only --allow-live`; omit `--allow-live` when only drafts should exist. API checks do not prove custom MJML parity. The source tests include opt-outs, immutable assignment, purchases, delayed notification suppression, SQL nulls, screenshots, incomplete processing and known completion. SQL fixture tests use CTEs with synthetic rows and perform no database writes. + +To stop new experiment assignments, set `LOOPS_FREE_EXPERIMENT_ENABLED=false` and deploy. Existing assignments continue their own journeys. Do not change the fixed activation signal date or reset contact assignment properties. Pause the treatment in Loops if delivery itself must stop. Before reverting to code without variant-aware routing, pause both free workflows and reconcile enrolled contacts, since older code would route treatment contacts back to `free`. Never bulk resubscribe, re-enroll, or clear imported holds as a recovery step. diff --git a/emails/delivery-safety.ts b/emails/delivery-safety.ts index 10c2b37c39..8a3ed9c84a 100644 --- a/emails/delivery-safety.ts +++ b/emails/delivery-safety.ts @@ -7,6 +7,9 @@ export const workflowAudience = (journey: Journey) => ({ ...audienceFilter(journey.audience, journey.promotional).conditions, condition("capLifecycleEnabled", true), condition("capOnboardingEligible", true), + ...(journey.key === "free-v2" + ? [condition("capLifecycleStage", "free-v2")] + : []), ], }); diff --git a/emails/flows.ts b/emails/flows.ts index 6fd19fd0a5..710e823d62 100644 --- a/emails/flows.ts +++ b/emails/flows.ts @@ -5,6 +5,12 @@ import { formerFeedback } from "./marketing/former-feedback"; import { freePlans } from "./marketing/free-plans"; import { freeRecord } from "./marketing/free-record"; import { freeShare } from "./marketing/free-share"; +import { freeV2Ai } from "./marketing/free-v2-ai"; +import { freeV2Help } from "./marketing/free-v2-help"; +import { freeV2Plans } from "./marketing/free-v2-plans"; +import { freeV2Record } from "./marketing/free-v2-record"; +import { freeV2Share } from "./marketing/free-v2-share"; +import { freeV2Welcome } from "./marketing/free-v2-welcome"; import { freeWelcome } from "./marketing/free-welcome"; import { teammateHandoff } from "./marketing/teammate-handoff"; import { teammateWelcome } from "./marketing/teammate-welcome"; @@ -42,6 +48,40 @@ export const journeys: Journey[] = [ { ...customerFeedback, delayDays: 4 }, ], }, + { + key: "free-v2", + name: "Cap | Free activation and Pro conversion v2", + audience: "free", + promotional: true, + messages: [ + { ...freeV2Welcome, delayDays: 0 }, + { + ...freeV2Record, + delayDays: 1, + onlyIf: { property: "capNeedsRecordingHelp", value: true }, + }, + { + ...freeV2Plans, + delayDays: 2, + onlyIf: { property: "capReadyForPro", value: true }, + }, + { + ...freeV2Share, + delayDays: 5, + onlyIf: { property: "capNeedsSharingHelp", value: true }, + }, + { + ...freeV2Ai, + delayDays: 2, + onlyIf: { property: "capReadyForPro", value: true }, + }, + { + ...freeV2Help, + delayDays: 2, + onlyIf: { property: "capNeedsRecordingHelp", value: true }, + }, + ], + }, { key: "teammate", name: "Cap | Teammate onboarding", diff --git a/emails/marketing/free-v2-ai.ts b/emails/marketing/free-v2-ai.ts new file mode 100644 index 0000000000..c75e1a8dca --- /dev/null +++ b/emails/marketing/free-v2-ai.ts @@ -0,0 +1,18 @@ +import type { EmailDefinition } from "../types"; + +export const freeV2Ai = { + id: "free-v2-ai", + key: "ai", + purpose: + "Show active free users how Pro reduces the work around a recording.", + subject: "Record the walkthrough. Skip the extra write-up.", + previewText: "Give people a summary and chapters alongside your video.", + variables: ["capGreeting"], + body: [ + "{contact.capGreeting}", + "A recording saves you typing everything out. Writing a summary afterwards can feel like doing the job twice.", + "Cap Pro generates a title, summary, transcript and clickable chapters for your recordings. The person watching can get the context, then jump to the part they need.", + "That's especially useful for walkthroughs people come back to later.", + 'See Cap Pro', + ].join(""), +} satisfies EmailDefinition; diff --git a/emails/marketing/free-v2-help.ts b/emails/marketing/free-v2-help.ts new file mode 100644 index 0000000000..bda06ad141 --- /dev/null +++ b/emails/marketing/free-v2-help.ts @@ -0,0 +1,17 @@ +import type { EmailDefinition } from "../types"; + +export const freeV2Help = { + id: "free-v2-help", + key: "help", + purpose: + "Invite a reply from users who have not reached a completed cloud recording.", + subject: "Anything getting in the way?", + previewText: "Reply and tell me where you're getting stuck.", + variables: ["capGreeting"], + body: [ + "{contact.capGreeting}", + "If you haven't found a useful way to fit Cap into your day yet, is anything getting in the way?", + "Maybe you're not sure what to record, something isn't working, or it isn't quite what you expected.", + "Reply and let me know. If I can help you get a useful first recording out of it, I'd like to.", + ].join(""), +} satisfies EmailDefinition; diff --git a/emails/marketing/free-v2-plans.ts b/emails/marketing/free-v2-plans.ts new file mode 100644 index 0000000000..c3ca23de57 --- /dev/null +++ b/emails/marketing/free-v2-plans.ts @@ -0,0 +1,19 @@ +import type { EmailDefinition } from "../types"; + +export const freeV2Plans = { + id: "free-v2-plans", + key: "plans", + purpose: + "Offer Pro to active free users through a specific cloud-sharing benefit.", + subject: "Some explanations need more than five minutes", + previewText: "Share the full walkthrough with Cap Pro.", + variables: ["capGreeting"], + body: [ + "{contact.capGreeting}", + "Five minutes works for a quick question. A full walkthrough sometimes needs longer.", + "Cap Pro removes the five-minute limit on cloud recordings and gives you unlimited shareable links, so you can send the whole explanation in one video.", + "It also includes the desktop commercial license for work recordings.", + "Pro is US$12 per user, billed monthly. Annual billing is also available.", + 'Upgrade to Cap Pro', + ].join(""), +} satisfies EmailDefinition; diff --git a/emails/marketing/free-v2-record.ts b/emails/marketing/free-v2-record.ts new file mode 100644 index 0000000000..0a304ba803 --- /dev/null +++ b/emails/marketing/free-v2-record.ts @@ -0,0 +1,18 @@ +import type { EmailDefinition } from "../types"; + +export const freeV2Record = { + id: "free-v2-record", + key: "record", + purpose: + "Offer a small first cloud recording task when no completed video is visible.", + subject: "One small thing to record today", + previewText: "Try explaining something you already know.", + variables: ["capGreeting"], + body: [ + "{contact.capGreeting}", + "If you're still finding your feet with Cap, try recording a quick walkthrough of something you already know: a setting, a page, or a problem you want to show someone.", + "Choose Instant Mode, keep it short, then send the link when it's ready.", + 'Get started with your first recording', + "If screen or microphone permissions are getting in the way, reply with what you're seeing and I'll help you sort it.", + ].join(""), +} satisfies EmailDefinition; diff --git a/emails/marketing/free-v2-share.ts b/emails/marketing/free-v2-share.ts new file mode 100644 index 0000000000..fef7f28927 --- /dev/null +++ b/emails/marketing/free-v2-share.ts @@ -0,0 +1,19 @@ +import type { EmailDefinition } from "../types"; + +export const freeV2Share = { + id: "free-v2-share", + key: "share", + purpose: + "Help an active user put a completed recording into a real conversation.", + subject: "Put your recording to work", + previewText: "Send the link with one sentence about what you need.", + variables: ["capGreeting"], + body: [ + "{contact.capGreeting}", + "A useful way to share a Cap is to add one sentence telling the other person what you need from them.", + "\"Here's the bit I'm stuck on. Can you take a look?\"", + 'Or: "Here\'s how to change that setting. Does that solve it?"', + 'Open your recordings', + "Send the link wherever you're already having the conversation.", + ].join(""), +} satisfies EmailDefinition; diff --git a/emails/marketing/free-v2-welcome.ts b/emails/marketing/free-v2-welcome.ts new file mode 100644 index 0000000000..6df0654126 --- /dev/null +++ b/emails/marketing/free-v2-welcome.ts @@ -0,0 +1,19 @@ +import type { EmailDefinition } from "../types"; + +export const freeV2Welcome = { + id: "free-v2-welcome", + key: "welcome", + purpose: + "Help a new independent free user record and share one useful explanation.", + subject: "Your first Cap only needs 30 seconds", + previewText: "Record one thing, send the link, and you're off.", + variables: ["capGreeting"], + body: [ + "{contact.capGreeting}", + "Richie here, founder of Cap. Thanks for giving it a go :)", + "For your first recording, pick something you'd normally explain in a long message. Open Cap, choose Instant Mode, and spend 30 seconds showing it on screen.", + "Once it's ready, send the link to someone who needs that explanation. No polished presentation needed.", + 'Download Cap and make your first recording', + "If you get stuck, reply here and I'll help.", + ].join(""), +} satisfies EmailDefinition; diff --git a/emails/resources.json b/emails/resources.json index 971e9e613e..0dbbdfce52 100644 --- a/emails/resources.json +++ b/emails/resources.json @@ -23,7 +23,8 @@ "Cap lifecycle header v5": "cmtwx1d1q0oys0jyw6yq91naz", "Cap lifecycle signature v5": "cmtwx4qyk0odg0j03ibll1cad", "Cap lifecycle header v6": "cmtwxickf0d570jzvtpz1dyyh", - "Cap lifecycle signature v6": "cmtwxid5g17tp0jyc2mu1hgwj" + "Cap lifecycle signature v6": "cmtwxid5g17tp0jyc2mu1hgwj", + "free-v2": "cmtxlhkcw0dta0jzkd6an7xz0" }, "operations": { "free:guard": "n3", @@ -48,6 +49,23 @@ "teammate:handoff:email": "n6", "former:guard": "n3", "former:feedback:timer": "n4", - "former:feedback:email": "n5" + "former:feedback:email": "n5", + "free-v2:guard": "n3", + "free-v2:welcome:email": "n4", + "free-v2:record:timer": "n5", + "free-v2:record:branch": "n6", + "free-v2:record:email": "n9", + "free-v2:plans:timer": "n10", + "free-v2:plans:branch": "n11", + "free-v2:plans:email": "n14", + "free-v2:share:timer": "n15", + "free-v2:share:branch": "n16", + "free-v2:share:email": "n19", + "free-v2:ai:timer": "n20", + "free-v2:ai:branch": "n21", + "free-v2:ai:email": "n24", + "free-v2:help:timer": "n25", + "free-v2:help:filter": "n27", + "free-v2:help:email": "n29" } } diff --git a/packages/database/loops/activation.ts b/packages/database/loops/activation.ts new file mode 100644 index 0000000000..04c6423b58 --- /dev/null +++ b/packages/database/loops/activation.ts @@ -0,0 +1,23 @@ +export const activationQuery = ` + SELECT + MAX(CASE WHEN u.video_id IS NOT NULL OR (j.video_id IS NOT NULL AND j.state <> 'verified') THEN 1 ELSE 0 END) AS hasPendingUpload, + MAX(CASE WHEN u.video_id IS NULL + AND v.duration > 0 + AND (j.state = 'verified' OR (j.video_id IS NULL AND ( + (JSON_TYPE(JSON_EXTRACT(v.source, '$.outputKey')) = 'STRING' + AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v.source, '$.outputKey')), '') IS NOT NULL) + OR v.jobStatus = 'COMPLETE' + ))) THEN 1 ELSE 0 END) AS hasVideo, + MAX(CASE WHEN u.video_id IS NULL + AND v.duration > 0 + AND (j.state = 'verified' OR (j.video_id IS NULL AND ( + (JSON_TYPE(JSON_EXTRACT(v.source, '$.outputKey')) = 'STRING' + AND NULLIF(JSON_UNQUOTE(JSON_EXTRACT(v.source, '$.outputKey')), '') IS NOT NULL) + OR v.jobStatus = 'COMPLETE' + ))) AND v.firstViewEmailSentAt IS NOT NULL THEN 1 ELSE 0 END) AS hasSharedVideo, + MAX(GREATEST(DATE_ADD(v.createdAt, INTERVAL 5 MINUTE), COALESCE(v.firstViewEmailSentAt, v.createdAt))) AS lastActivationNotificationAt + FROM videos v + LEFT JOIN video_uploads u ON u.video_id = v.id + LEFT JOIN video_processing_jobs j ON j.video_id = v.id + WHERE v.ownerId = ? AND v.isScreenshot = 0 +`; diff --git a/packages/database/loops/experiment.ts b/packages/database/loops/experiment.ts new file mode 100644 index 0000000000..1733ab6e4e --- /dev/null +++ b/packages/database/loops/experiment.ts @@ -0,0 +1,19 @@ +import { createHash } from "node:crypto"; + +export const freeOnboardingExperiment = "free-pro-v2-2026-09"; +export const activationSignalsAfter = new Date("2026-09-12T00:00:00Z"); + +export function freeOnboardingVariant(userId: string) { + return createHash("sha256") + .update(`${freeOnboardingExperiment}:${userId}`) + .digest() + .readUInt32BE(0) % + 2 === + 0 + ? "control" + : "pro-v2"; +} + +export function inFreeExperiment(signupAt: string, after?: Date) { + return Boolean(after && Date.parse(signupAt) >= after.getTime()); +} diff --git a/packages/database/loops/lifecycle.ts b/packages/database/loops/lifecycle.ts index 5e08a99975..01f8755662 100644 --- a/packages/database/loops/lifecycle.ts +++ b/packages/database/loops/lifecycle.ts @@ -1,3 +1,9 @@ +import { + activationSignalsAfter, + freeOnboardingExperiment, + freeOnboardingVariant, + inFreeExperiment, +} from "./experiment"; import { type ContactProfile, type CustomerCopy, @@ -12,6 +18,9 @@ export type LifecycleContact = RemoteContact & { capConsent?: string; capLifecycleEnabled?: boolean; capSignupAt?: string; + capFreeOnboardingVariant?: string; + capFreeOnboardingExperiment?: string; + capFreeOnboardingAssignedAt?: string; }; export type LoopsRuntimeConfig = { @@ -20,6 +29,8 @@ export type LoopsRuntimeConfig = { enrollmentEnabled: boolean; allowedEmails: Set | null; teammateJoinedAt?: string | null; + freeExperimentAfter?: Date; + freeExperimentEnrollmentEnabled?: boolean; }; export function enrollmentWindow(signupAt: string, config: LoopsRuntimeConfig) { @@ -35,6 +46,21 @@ export function enrollmentWindow(signupAt: string, config: LoopsRuntimeConfig) { }; } +export function activationFollowUps(input: LoopsProfileSource["input"]) { + const recentlyNotified = Boolean( + input.lastActivationNotificationAt && + input.now.getTime() - + Date.parse(isoDate(input.lastActivationNotificationAt)) < + 24 * 60 * 60_000, + ); + const ready = input.hasVideo && !input.hasPendingUpload && !recentlyNotified; + return { + capReadyForPro: ready, + capNeedsSharingHelp: ready && !input.hasSharedVideo, + capNeedsRecordingHelp: !input.hasVideo && !recentlyNotified, + }; +} + export function lifecycleUpdate( source: LoopsProfileSource, remote: LifecycleContact, @@ -73,8 +99,40 @@ export function lifecycleUpdate( capSourceGroup: _group, ...attributes } = profile; + const assigned = + remote.capFreeOnboardingExperiment === freeOnboardingExperiment; + const canAssign = + config.freeExperimentEnrollmentEnabled === true && + eligible && + audience === "free" && + !teammate && + !remote.capFreeOnboardingExperiment && + (!remote.capLifecycleStage || remote.capLifecycleStage === "idle") && + inFreeExperiment(profile.capSignupAt, config.freeExperimentAfter); + const assignment = canAssign + ? { + capFreeOnboardingVariant: freeOnboardingVariant(profile.userId), + capFreeOnboardingExperiment: freeOnboardingExperiment, + capFreeOnboardingAssignedAt: source.input.now.toISOString(), + } + : {}; + const variant = canAssign + ? assignment.capFreeOnboardingVariant + : assigned + ? remote.capFreeOnboardingVariant + : undefined; + if ( + assigned && + (!["control", "pro-v2"].includes(remote.capFreeOnboardingVariant ?? "") || + !Number.isFinite(Date.parse(remote.capFreeOnboardingAssignedAt ?? ""))) + ) + throw new Error("invalid_free_experiment_assignment"); return { ...attributes, + ...(inFreeExperiment(profile.capSignupAt, activationSignalsAfter) + ? activationFollowUps(source.input) + : {}), + ...assignment, capTeammate: teammate, capAudience: audience, capOrigin: teammate ? "teammate" : profile.capOrigin, @@ -87,7 +145,11 @@ export function lifecycleUpdate( subscribed && !teammate && profile.capPromotionalEligible, capLifecycleEnabled: eligible, capOnboardingEligible: eligible, - capLifecycleStage: eligible ? audience : "idle", + capLifecycleStage: eligible + ? audience === "free" && variant === "pro-v2" + ? "free-v2" + : audience + : "idle", }; } diff --git a/packages/database/loops/profile.ts b/packages/database/loops/profile.ts index 5adf8bbfb3..72600d2e97 100644 --- a/packages/database/loops/profile.ts +++ b/packages/database/loops/profile.ts @@ -42,6 +42,8 @@ export type ProfileInput = { teammateLatch?: boolean; hasVideo: boolean; hasSharedVideo: boolean; + lastActivationNotificationAt?: string | null; + hasPendingUpload?: boolean; now: Date; }; diff --git a/packages/database/loops/sources.ts b/packages/database/loops/sources.ts index b552ca56aa..9e886410ac 100644 --- a/packages/database/loops/sources.ts +++ b/packages/database/loops/sources.ts @@ -1,5 +1,13 @@ import type { Connection, RowDataPacket } from "mysql2/promise"; -import type { CapUser, License, Membership, ProfileInput } from "./profile"; +import { activationQuery } from "./activation"; +import { activationSignalsAfter, inFreeExperiment } from "./experiment"; +import { + type CapUser, + isoDate, + type License, + type Membership, + type ProfileInput, +} from "./profile"; type RuntimeUser = CapUser & { emailVerified: string | null; @@ -44,16 +52,30 @@ export async function readLoopsProfile( "SELECT status FROM organization_invites WHERE invitedEmail=? AND status IN ('pending','accepted')", [user.email], ); - const [video] = await rows<{ id: string }>( - cap, - "SELECT id FROM videos WHERE ownerId=? LIMIT 1", - [userId], - ); - const [shared] = await rows<{ id: string }>( - cap, - "SELECT id FROM videos WHERE ownerId=? AND public=1 LIMIT 1", - [userId], - ); + let activation: { + hasVideo: number | boolean | null; + hasSharedVideo: number | boolean | null; + hasPendingUpload?: number | null; + lastActivationNotificationAt?: string | null; + }; + if (inFreeExperiment(isoDate(user.created_at), activationSignalsAfter)) { + const [result] = await rows(cap, activationQuery, [ + userId, + ]); + activation = result; + } else { + const [video] = await rows<{ id: string }>( + cap, + "SELECT id FROM videos WHERE ownerId=? LIMIT 1", + [userId], + ); + const [shared] = await rows<{ id: string }>( + cap, + "SELECT id FROM videos WHERE ownerId=? AND public=1 LIMIT 1", + [userId], + ); + activation = { hasVideo: Boolean(video), hasSharedVideo: Boolean(shared) }; + } const entitlement = await rows( licenses, "SELECT u.email,l.subscriptionActive,l.nextRenewalDate,'desktop' AS kind FROM commercialLicenses l JOIN user u ON u.id=l.userId WHERE u.email=? UNION ALL SELECT u.email,l.subscriptionActive,l.nextRenewalDate,'selfhosted' AS kind FROM selfHostedLicenses l JOIN user u ON u.id=l.userId WHERE u.email=?", @@ -67,8 +89,10 @@ export async function readLoopsProfile( licenses: entitlement, invited: invites.length > 0, sso: accounts.some((account) => account.provider === "workos"), - hasVideo: Boolean(video), - hasSharedVideo: Boolean(shared), + hasVideo: Boolean(activation?.hasVideo), + hasSharedVideo: Boolean(activation?.hasSharedVideo), + lastActivationNotificationAt: activation?.lastActivationNotificationAt, + hasPendingUpload: Boolean(activation?.hasPendingUpload), now: new Date(), }, signedUp: Boolean(user.emailVerified || accounts.length), diff --git a/packages/database/loops/worker.ts b/packages/database/loops/worker.ts index 0f8864ef90..541a0e52a9 100644 --- a/packages/database/loops/worker.ts +++ b/packages/database/loops/worker.ts @@ -11,7 +11,9 @@ import { type LoopsClient, LoopsRequestError, } from "./client"; +import { activationSignalsAfter, inFreeExperiment } from "./experiment"; import { + activationFollowUps, enrollmentWindow, type LifecycleContact, type LoopsRuntimeConfig, @@ -176,11 +178,23 @@ export function loopsRuntimeConfig(env: NodeJS.ProcessEnv): LoopsRuntimeConfig { ); if (allowedEmails && !allowedEmails.size) throw new Error("Test mode requires LOOPS_TEST_EMAILS"); + const freeExperimentAfter = env.LOOPS_FREE_EXPERIMENT_AFTER + ? new Date(env.LOOPS_FREE_EXPERIMENT_AFTER) + : undefined; + if (freeExperimentAfter && !Number.isFinite(freeExperimentAfter.getTime())) + throw new Error("Invalid free onboarding experiment date"); + if (freeExperimentAfter && freeExperimentAfter < activationSignalsAfter) + throw new Error("Experiment date predates activation signal rollout"); + if (env.LOOPS_FREE_EXPERIMENT_ENABLED === "true" && !freeExperimentAfter) + throw new Error("Enabled free experiment requires a start date"); return { listId, enrollmentAfter, allowedEmails, enrollmentEnabled: env.LOOPS_ENROLLMENT_ENABLED === "true", + freeExperimentAfter, + freeExperimentEnrollmentEnabled: + env.LOOPS_FREE_EXPERIMENT_ENABLED === "true", }; } @@ -270,6 +284,20 @@ export async function runLoopsSync(customerCopy: CustomerCopy) { ), listId: config.listId, teammateJoinedAt: job.teammateJoinedAt, + ...(inFreeExperiment( + localProfile.capSignupAt, + activationSignalsAfter, + ) + ? { + freeExperiment: activationFollowUps(source.input), + freeExperimentEnrollmentEnabled: + config.freeExperimentEnrollmentEnabled && + inFreeExperiment( + localProfile.capSignupAt, + config.freeExperimentAfter, + ), + } + : {}), }), ) .digest("hex"); diff --git a/scripts/emails/catalog.ts b/scripts/emails/catalog.ts index 278f9e2b4d..776d10c36a 100644 --- a/scripts/emails/catalog.ts +++ b/scripts/emails/catalog.ts @@ -188,7 +188,7 @@ export const renderCatalog = () => { "", "## Lifecycle flows", "", - "These are the locally configured draft journeys. This document is not a live status check. Imports remain held; production enrollment is not deployed. Run `bun run emails:check-loops` to verify the actual Loops drafts.", + "These are the locally configured journeys. This document is not a live status check. Imports remain held. Run `bun run emails:check-loops --structure-only --allow-live` to verify the remote workflow structure and status; review custom MJML content in the browser.", "", "| Flow | Audience | Schedule after entry | Emails |", "| --- | --- | --- | --- |", @@ -201,13 +201,13 @@ export const renderCatalog = () => { .join(", ")} | ${journey.messages.length} |`, ), "", - "Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. The integration is not deployed. Historical imports stay held; a new accepted invitation can start teammate help only.", + "Completed signups reach Loops through Stripe; SSO uses a small direct fallback. Cap supplies targeting through a durable sync queue, without a separate marketing opt-in step. Existing opt-outs and suppressions take precedence. Historical imports stay held; a new accepted invitation can start teammate help only.", "", - "Current draft journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility.", + "Journeys require global subscription, capConsent=subscribed, the exact audience, lifecycle enabled and onboarding eligible. capConsent is a legacy migration guard, not a separate consent-capture requirement for new signups. These filters continue to apply downstream. Free/former promotional flows additionally exclude teammates and require promotional eligibility.", "", "Teammate history takes priority over paid/free classification. Ambiguous contacts receive no journey. [Audience classification and consent](../scripts/loops/README.md#audience-rules).", "", - "An independent watchdog can block all four automatic journeys by adding an impossible subscription condition to their downstream guards. Recovery never resumes delivery automatically. This does not cancel manually scheduled campaigns; check sync health before every campaign send. See the [outage and resume procedure](../scripts/loops/README.md#outage-protection).", + "The independent watchdog checks every registered journey and alerts when an active flow requires a manual pause. It can hold paused/draft journeys with an impossible subscription condition. Recovery never resumes delivery automatically. Check sync health before campaign sends. See the [outage and resume procedure](../scripts/loops/README.md#outage-protection) and [conversion experiment](conversion-experiment.md).", "", ]; for (const journey of journeys) { diff --git a/scripts/loops/activation.test.ts b/scripts/loops/activation.test.ts new file mode 100644 index 0000000000..cea6721633 --- /dev/null +++ b/scripts/loops/activation.test.ts @@ -0,0 +1,89 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import mysql, { type Connection, type RowDataPacket } from "mysql2/promise"; +import { activationQuery } from "../../packages/database/loops/activation"; + +const url = process.env.LOOPS_ACTIVATION_READONLY_TEST_URL; + +describe.skipIf(!url)( + "activation SQL against synthetic rows without database writes", + () => { + let database: Connection; + beforeAll(async () => { + if (!url) throw new Error("Missing read-only test connection"); + database = await mysql.createConnection({ + uri: url, + dateStrings: true, + timezone: "Z", + }); + }); + afterAll(async () => { + await database?.end(); + }); + + async function signals( + options: { + screenshot?: boolean; + upload?: boolean; + state?: string; + output?: string | null; + duration?: number; + viewed?: boolean; + otherOwner?: boolean; + legacyStatus?: string; + } = {}, + ) { + const query = `WITH + videos AS (SELECT 'video' AS id, ? AS ownerId, ? AS isScreenshot, + ? AS duration, JSON_OBJECT('type', 'desktopMP4', 'outputKey', ?) AS source, + ? AS jobStatus, CAST('2026-09-12 01:00:00' AS DATETIME) AS createdAt, + CAST(? AS DATETIME) AS firstViewEmailSentAt), + video_uploads AS (SELECT 'video' AS video_id WHERE ?), + video_processing_jobs AS (SELECT 'video' AS video_id, ? AS state WHERE ?) + ${activationQuery}`; + const [rows] = await database.query(query, [ + options.otherOwner ? "other" : "owner", + options.screenshot ? 1 : 0, + options.duration ?? 30, + options.output === undefined + ? "owner/video/result.mp4" + : options.output, + options.legacyStatus ?? null, + options.viewed ? "2026-09-12 02:00:00" : null, + options.upload ? 1 : 0, + options.state ?? null, + options.state ? 1 : 0, + "owner", + ]); + return rows[0]; + } + + for (const [name, options] of [ + ["screenshot", { screenshot: true }], + ["another owner", { otherOwner: true }], + ["active upload", { upload: true }], + ["queued job with upload row already removed", { state: "queued" }], + ["failed processing", { state: "source-blocked" }], + ["empty output", { output: "" }], + ["null output", { output: null }], + ["zero duration", { duration: 0 }], + ] as const) { + test(`${name} is not activation`, async () => { + expect(Boolean((await signals(options)).hasVideo)).toBe(false); + }); + } + test("published MP4 and verified processing count as completed videos", async () => { + expect((await signals()).hasVideo).toBe(1); + expect((await signals({ state: "verified" })).hasVideo).toBe(1); + expect( + (await signals({ output: null, legacyStatus: "COMPLETE" })).hasVideo, + ).toBe(1); + }); + test("public-by-default is insufficient sharing evidence", async () => { + expect((await signals()).hasSharedVideo).toBe(0); + expect((await signals({ viewed: true })).hasSharedVideo).toBe(1); + expect( + (await signals({ viewed: true, upload: true })).hasSharedVideo, + ).toBe(0); + }); + }, +); diff --git a/scripts/loops/lifecycle.test.ts b/scripts/loops/lifecycle.test.ts index 371dd80521..6cdc7c6a32 100644 --- a/scripts/loops/lifecycle.test.ts +++ b/scripts/loops/lifecycle.test.ts @@ -2,6 +2,11 @@ import { describe, expect, test } from "bun:test"; import { customerCopy } from "../../emails/customer-copy"; import type { LoopsClient } from "../../packages/database/loops/client"; import { + freeOnboardingExperiment, + freeOnboardingVariant, +} from "../../packages/database/loops/experiment"; +import { + activationFollowUps, enrollmentWindow, type LifecycleContact, type LoopsRuntimeConfig, @@ -108,6 +113,155 @@ function remote(): LifecycleContact { }; } +describe("free conversion experiment", () => { + const experimentConfig = { + ...config, + freeExperimentAfter: new Date("2026-09-12T01:00:00Z"), + freeExperimentEnrollmentEnabled: true, + }; + function freshSource() { + const source = fixture(); + source.input.user.created_at = "2026-09-12T01:00:00Z"; + source.input.now = new Date("2026-09-12T01:01:00Z"); + return source; + } + test("both variants receive one immutable assignment at eligible entry", () => { + const variants = new Set(); + for (let index = 0; index < 20; index++) { + const source = freshSource(); + source.input.user.id = `new-user-${index}`; + const update = lifecycleUpdate( + source, + remote(), + experimentConfig, + customerCopy, + ); + expect(update.capFreeOnboardingExperiment).toBe(freeOnboardingExperiment); + expect(update.capFreeOnboardingAssignedAt).toBe( + source.input.now.toISOString(), + ); + expect(update.capFreeOnboardingVariant).toBe( + freeOnboardingVariant(source.input.user.id), + ); + variants.add(update.capFreeOnboardingVariant ?? ""); + expect(update.capLifecycleStage).toBe( + update.capFreeOnboardingVariant === "pro-v2" ? "free-v2" : "free", + ); + const contact = { ...remote(), ...update }; + source.input.now = new Date("2026-09-14T01:00:00Z"); + const repeated = lifecycleUpdate( + source, + contact, + { ...experimentConfig, freeExperimentEnrollmentEnabled: false }, + customerCopy, + ); + expect(repeated.capLifecycleStage).toBe(update.capLifecycleStage); + expect(repeated).not.toHaveProperty("capFreeOnboardingAssignedAt"); + } + expect([...variants].sort()).toEqual(["control", "pro-v2"]); + }); + test("historical and already enrolled free contacts stay on their original flow", () => { + for (const [source, contact] of [ + [fixture(), remote()], + [freshSource(), { ...remote(), capLifecycleStage: "free" }], + ] as const) { + const update = lifecycleUpdate( + source, + contact, + experimentConfig, + customerCopy, + ); + expect(update.capLifecycleStage).toBe("free"); + expect(update).not.toHaveProperty("capFreeOnboardingVariant"); + } + }); + test("opt-outs, imports, customers, pending invites and teammate history cannot enter the experiment", () => { + for (const kind of [ + "unsubscribe", + "list", + "import", + "customer", + "invite", + "teammate", + "unsigned", + ] as const) { + const source = freshSource(); + const contact = remote(); + if (kind === "unsubscribe") contact.subscribed = false; + if (kind === "list") contact.mailingLists[config.listId] = false; + if (kind === "import") contact.capImportedAt = "2026-09-11T00:00:00Z"; + if (kind === "customer") + source.input.user.stripeSubscriptionStatus = "active"; + if (kind === "invite") source.pendingInvite = true; + if (kind === "teammate") contact.capTeammate = true; + if (kind === "unsigned") source.signedUp = false; + const update = lifecycleUpdate( + source, + contact, + experimentConfig, + customerCopy, + ); + expect(update).not.toHaveProperty("capFreeOnboardingVariant"); + expect(update.capLifecycleStage).not.toBe("free-v2"); + } + }); + test("purchase and unsubscribe exit sales without losing the recorded assignment", () => { + const source = freshSource(); + const contact = { + ...remote(), + capFreeOnboardingExperiment: freeOnboardingExperiment, + capFreeOnboardingVariant: "pro-v2", + capFreeOnboardingAssignedAt: source.input.now.toISOString(), + capLifecycleStage: "free-v2", + }; + source.input.user.stripeSubscriptionStatus = "active"; + const paid = lifecycleUpdate( + source, + contact, + experimentConfig, + customerCopy, + ); + expect(paid.capLifecycleStage).toBe("customer"); + expect(paid.capPromotionalEligible).toBe(false); + expect(paid).not.toHaveProperty("capFreeOnboardingVariant"); + contact.subscribed = false; + expect( + lifecycleUpdate(source, contact, experimentConfig, customerCopy) + .capLifecycleStage, + ).toBe("idle"); + }); + test("malformed assignments stop synchronization instead of silently changing arms", () => { + expect(() => + lifecycleUpdate( + freshSource(), + { + ...remote(), + capFreeOnboardingExperiment: freeOnboardingExperiment, + capFreeOnboardingVariant: "unknown", + }, + experimentConfig, + customerCopy, + ), + ).toThrow("invalid_free_experiment_assignment"); + }); + test("follow-ups wait 24 hours after application notification evidence and avoid pending uploads", () => { + const source = freshSource(); + source.input.hasVideo = true; + source.input.lastActivationNotificationAt = "2026-09-11T01:01:01Z"; + expect(activationFollowUps(source.input).capReadyForPro).toBe(false); + source.input.lastActivationNotificationAt = "2026-09-11T01:01:00Z"; + expect(activationFollowUps(source.input)).toEqual({ + capReadyForPro: true, + capNeedsSharingHelp: true, + capNeedsRecordingHelp: false, + }); + source.input.hasSharedVideo = true; + expect(activationFollowUps(source.input).capNeedsSharingHelp).toBe(false); + source.input.hasPendingUpload = true; + expect(activationFollowUps(source.input).capReadyForPro).toBe(false); + }); +}); + function fakeApi(initial: LifecycleContact | undefined) { let contact = initial; const calls: { path: string; method: string; body: unknown }[] = []; diff --git a/scripts/loops/profile.test.ts b/scripts/loops/profile.test.ts index 2b0a58ee07..dc2968c8af 100644 --- a/scripts/loops/profile.test.ts +++ b/scripts/loops/profile.test.ts @@ -291,7 +291,7 @@ test("verification timestamps do not churn the sync fingerprint", () => { test("every journey requires subscription consent and promotional journeys exclude teammates", () => { assert.equal( journeys.reduce((count, journey) => count + journey.messages.length, 0), - 10, + 16, ); for (const journey of journeys) { const filter = audienceFilter(journey.audience, journey.promotional); diff --git a/scripts/loops/provision.ts b/scripts/loops/provision.ts index 397e640bc6..807f327390 100644 --- a/scripts/loops/provision.ts +++ b/scripts/loops/provision.ts @@ -54,6 +54,7 @@ const { values } = parseArgs({ state: { type: "string" }, team: { type: "string" }, "mailing-list": { type: "string" }, + "new-journey": { type: "string" }, }, }); @@ -63,7 +64,12 @@ if (!values.team) throw new Error("Pass --team with the expected Loops team name"); if (values.apply && !values.state) throw new Error("--apply requires a private --state file"); -if (values.apply && deliveryFormat === "mjml") +const selectedJourneys = values["new-journey"] + ? journeys.filter((journey) => journey.key === values["new-journey"]) + : journeys; +if (!selectedJourneys.length) throw new Error("Unknown journey"); +const selectedCampaigns = values["new-journey"] ? [] : campaignTemplates; +if (values.apply && deliveryFormat === "mjml" && !values["new-journey"]) throw new Error( "Managed emails use custom MJML. Run emails:export and upload reviewed archives in the Loops editor; the API cannot edit this format.", ); @@ -85,7 +91,10 @@ let receipt: Receipt = { }; if (values.state) { try { - receipt = JSON.parse(await readFile(values.state, "utf8")); + receipt = { + ...receipt, + ...JSON.parse(await readFile(values.state, "utf8")), + }; } catch (error) { if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; @@ -119,6 +128,20 @@ try { ); const lists = await api.request("lists"); const workflows = await api.list("workflows"); + if (values["new-journey"]) { + const match = named(workflows, selectedJourneys[0].name); + const existing = match + ? await api.request(`workflows/${match.id}`) + : undefined; + if ( + existing && + (receipt.resources[values["new-journey"]] !== existing.id || + !existing.description.includes("created-for-mjml-upload")) + ) + throw new Error( + "New-journey mode cannot edit an existing managed workflow", + ); + } const themes = await api.list("themes"); const sharedComponents = await api.list("components"); const segments = await api.list("audience-segments"); @@ -133,12 +156,12 @@ try { JSON.stringify({ team: identity.teamName, mode: values.apply ? "apply drafts" : "dry run", - workflows: journeys.length, - emails: journeys.reduce( + workflows: selectedJourneys.length, + emails: selectedJourneys.reduce( (count, journey) => count + journey.messages.length, 0, ), - campaignTemplates: campaignTemplates.length, + campaignTemplates: selectedCampaigns.length, mailingListId: listId ?? "Create Product updates and tips in the Loops dashboard", }), @@ -199,11 +222,21 @@ try { await save(); return resource.id; }; - const themeId = await ensure("themes", themes, theme, "theme"); + const themeId = values["new-journey"] + ? receipt.resources.theme + : await ensure("themes", themes, theme, "theme"); + assert(themeId, "New MJML drafts require the existing brand registry"); const componentIds: string[] = []; for (const component of components) { componentIds.push( - await ensure("components", sharedComponents, component, component.name), + values["new-journey"] + ? receipt.resources[component.name] + : await ensure( + "components", + sharedComponents, + component, + component.name, + ), ); } const editEmail = async ( @@ -258,13 +291,13 @@ try { ); }; - for (const journey of journeys) { + for (const journey of selectedJourneys) { const existing = named(workflows, journey.name); let workflow = existing ? await api.request(`workflows/${existing.id}`) : await api.request("workflows", "POST", { name: journey.name, - description: `${programVersion}; managed by scripts/loops/provision.ts. Draft only. ${journey.messages.map((m) => m.delayDays).join(", ")} day relative delays. Imports never enroll.`, + description: `${programVersion}; ${values["new-journey"] ? "created-for-mjml-upload; " : ""}managed by scripts/loops/provision.ts. ${journey.messages.map((m) => m.delayDays).join(", ")} day relative delays. Imports never enroll.`, mailingListId: listId, }); if (workflow.status !== "Draft") @@ -367,7 +400,23 @@ try { ); } let fromNodeId: string | undefined; - if (message.onlyIf) { + if (message.onlyIf && message === journey.messages.at(-1)) { + const filter = await insert("AudienceFilter", `${operation}:filter`); + await update( + filter.id, + { + audienceFilter: { + match: "all", + conditions: [ + condition(message.onlyIf.property, message.onlyIf.value), + ], + }, + appliesDownstream: false, + }, + `${operation}:filter-config`, + ); + fromNodeId = filter.id; + } else if (message.onlyIf) { const branch = await insert("BranchNode", `${operation}:branch`); const children = workflow.nodes[branch.id].nextNodeIds; if (children.length !== 2) @@ -413,7 +462,7 @@ try { ); } - for (const template of campaignTemplates) { + for (const template of selectedCampaigns) { const segmentName = `${template.name} audience`; const segmentId = await ensure( "audience-segments", diff --git a/scripts/loops/verify.ts b/scripts/loops/verify.ts index 8533ab9aba..a3dee025a6 100644 --- a/scripts/loops/verify.ts +++ b/scripts/loops/verify.ts @@ -57,6 +57,8 @@ const { values } = parseArgs({ "mailing-list": { type: "string", default: registry.resources.mailingList }, "structure-only": { type: "boolean", default: false }, "require-held": { type: "boolean", default: false }, + "allow-live": { type: "boolean", default: false }, + journey: { type: "string" }, }, }); assert(values.state && values.team && values["mailing-list"]); @@ -145,10 +147,21 @@ const verifyEmail = async ( emails++; }; -for (const journey of journeys) { +const selectedJourneys = values.journey + ? journeys.filter((journey) => journey.key === values.journey) + : journeys; +assert(selectedJourneys.length, "Unknown journey"); +const selectedCampaigns = values.journey ? [] : campaignTemplates; +for (const journey of selectedJourneys) { const id = receipt.resources[journey.key]; const workflow = await api.request(`workflows/${id}`); - assert.equal(workflow.status, "Draft"); + assert( + (values["allow-live"] + ? ["Draft", "Sending", "Paused", "PausedAndQueueing"] + : ["Draft"] + ).includes(workflow.status), + `Unexpected workflow status: ${workflow.status}`, + ); assert.equal(workflow.name, journey.name); assert.equal(workflow.mailingListId, values["mailing-list"]); const visited = new Set(); @@ -195,7 +208,19 @@ for (const journey of journeys) { assert.equal(timer.unit, "d"); } let skipped: string | undefined; - if (message.onlyIf) { + if (message.onlyIf && message === journey.messages.at(-1)) { + const filterId = receipt.operations[`${operation}:filter`]; + connect(filterId); + node(filterId, "AudienceFilter"); + const filter = await api.request( + `workflows/${id}/nodes/${filterId}`, + ); + assert.deepEqual(filter.audienceFilter, { + match: "all", + conditions: [condition(message.onlyIf.property, message.onlyIf.value)], + }); + assert.equal(filter.appliesDownstream, false); + } else if (message.onlyIf) { const branchId = receipt.operations[`${operation}:branch`]; connect(branchId); const branch = node(branchId, "BranchNode"); @@ -236,14 +261,14 @@ for (const journey of journeys) { console.log( JSON.stringify({ workflow: journey.name, - status: "Draft", + status: workflow.status, graphVerified: true, deliveryHeld: held, }), ); } -for (const template of campaignTemplates) { +for (const template of selectedCampaigns) { const campaign = await api.request<{ status: string; mailingListId: string; @@ -267,13 +292,13 @@ for (const template of campaignTemplates) { } console.log( JSON.stringify({ - workflows: journeys.length, - campaigns: campaignTemplates.length, + workflows: selectedJourneys.length, + campaigns: selectedCampaigns.length, apiEmailContentVerified: emails, customEmailContentRequiresBrowserReview: customEmails, guardianVerifiedEmails: emails, deliveryHeldWorkflows: heldWorkflows, - allDraft: true, + liveStatesAllowed: values["allow-live"], }), ); if (customEmails && !values["structure-only"]) diff --git a/scripts/loops/watchdog.test.ts b/scripts/loops/watchdog.test.ts index 4350caf7c3..ca34b8b544 100644 --- a/scripts/loops/watchdog.test.ts +++ b/scripts/loops/watchdog.test.ts @@ -124,7 +124,7 @@ describe("independent delivery safety", () => { apply: true, }); expect(result[0].action).toBe("manual-pause-required"); - expect(provider.attemptedWrites).toHaveLength(4); + expect(provider.attemptedWrites).toHaveLength(safetyTargets.length); } }); @@ -159,7 +159,7 @@ describe("independent delivery safety", () => { expect(recovered.every((item) => item.action === "already-held")).toBe( true, ); - expect(provider.writes).toHaveLength(4); + expect(provider.writes).toHaveLength(safetyTargets.length); }); test("an unhealthy running journey requires manual pause without attempting forbidden edits", async () => { @@ -278,7 +278,9 @@ describe("independent delivery safety", () => { apply: true, }); expect(result.filter((item) => item.action === "error")).toHaveLength(1); - expect(result.filter((item) => item.action === "held")).toHaveLength(3); + expect(result.filter((item) => item.action === "held")).toHaveLength( + safetyTargets.length - 1, + ); }); test("audience drift closes delivery and cannot be silently overwritten on resume", async () => {