Skip to content
Open
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
192 changes: 188 additions & 4 deletions emails/CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions emails/audiences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions emails/conversion-experiment.md
Original file line number Diff line number Diff line change
@@ -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 <private receipt> --team "Cap Software, Inc." --mailing-list <verified 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 <private directory>` 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.
3 changes: 3 additions & 0 deletions emails/delivery-safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
: []),
],
});

Expand Down
Loading
Loading