Skip to content

feat(conversion): pre-expiry trial reminder email (R1)#939

Merged
FagnerMartinsBrack merged 1 commit into
mainfrom
conversion/trial-reminder-email
Jul 6, 2026
Merged

feat(conversion): pre-expiry trial reminder email (R1)#939
FagnerMartinsBrack merged 1 commit into
mainfrom
conversion/trial-reminder-email

Conversation

@FagnerMartinsBrack

Copy link
Copy Markdown
Member

feat(conversion): pre-expiry trial reminder email (R1)

Hypothesis

Card-free trials expire silently: users get zero contact between signup and the moment their account flips read-only, so the trial ends by default rather than by decision. A founder-voice email 2 days before trialEndsAt, with a direct subscribe CTA, converts some of that silent expiry into deliberate subscribe decisions (and honest churn signal on replies).

Evidence (30d, Jun 4 – Jul 4 2026)

  • 28 of 28 trials that reached end-of-trial emitted charge_failed reason=no_card_on_file → auto-cancelled ~60 min later. Trial→paid = 0%.
  • The only lifecycle email today fires 3 days AFTER cancellation (feedback email: 28 scheduled, 22 sent). Nothing fires before expiry.
  • The upgrade surface works but nobody is pushed to it in time: header trial-countdown 54 clicks/40 visitors → /account 22 views → subscribe button 4 clicks → 2 checkout successes (one of them after the user had already been cancelled — proof that a post-hoc nudge can convert, just far too late).
  • 6 of 17 sampled churned trialists had 0 saves — the reminder copy therefore never fabricates usage (saved-N-articles clause is omitted at zero).

What this changes

  • SendTrialFeedbackEmailCommand gains optional kind: "feedback" | "reminder" (absent = feedback → fully backward compatible with in-flight schedules).
  • New deterministic EventBridge Scheduler one-shot trial-reminder-<userId> (same target DetailType, Input {userId, kind:"reminder"}, ActionAfterCompletion: DELETE) created at all three trial-signup sites (email, Google, Apple) at trialEndsAt − 2d, and recreated on trial reactivation when >2d remain.
  • Deleted alongside every existing deleteTrialEndSchedule call: checkout success and the cancel-subscription trialing branch.
  • The existing send-trial-feedback-email Lambda branches on kind. Reminder path re-checks status === "trialing" && trialEndsAt in the future, and stamps a new trialReminderEmailSentAt row flag (markTrialReminderEmailSent) so at-least-once delivery can't double-send.
  • New founder-voice template (trial-reminder-email), CTA → /account?utm_source=trial-reminder&utm_medium=email&utm_campaign=trial-preexpiry.
  • Infra delta is exactly one env var: APP_ORIGIN on the send-trial-feedback-email Lambda. No new Lambda, no new queue, no new event rule, no IAM change (the scheduler-manage policy already wildcards the schedule group).

Estimated result

~28 reminder emails/month reaching 100% of expiring card-free trials. At even 1-in-10 recipients clicking through and the observed 2/4 subscribe-click→checkout-success rate, this is the difference between 0%% and a measurable trial→paid baseline; secondarily, replies give pre-churn feedback while users are still active (the post-churn email pulls 0-save ghosts).

How to measure

Reminder sends (CloudWatch Logs Insights on /aws/lambda/…send-trial-feedback-email log group):

fields @timestamp, @message
| filter @message like /\[send-trial-feedback-email\] reminder sent/
| stats count() as reminders_sent by bin(1d)

Reminder noops (stale schedules, races): filter reminder: for the noop reasons.
Arrivals: hutch analytics pageviews where the query string carries utm_source=trial-reminder (click-attribution middleware already persists utm_source/medium/campaign), joined against subsequent subscribe clicks and /auth/checkout/success hits. Success criterion after 30d: reminders_sent ≈ trials expiring, >0 checkout successes attributed trial-reminder.

Merge note

This PR adds no analytics events, streams, or log groups, so analytics-dashboard.test.ts (toHaveLength(24)) is untouched. Sibling PRs #916#918 may bump that widget count; if one lands first, a rebase here is conflict-free — if this lands first, they re-count from whatever main asserts. Resolve by keeping the assertion equal to the actual widget array length on the merged main, never by deleting widgets.

https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude CI has FAILED. Use your Read tool to read the local file .github/workflows/claude-PR-CI-failure-fixer.md for instructions. The file is in the checked-out repository.

Context

Failed Jobs

  • web-tests: Run pnpm check

@claude

claude Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 8m 50s —— View job


CI Failure Fix — Attempt 1 of 5

  • Read fixer instructions
  • Understand PR intent (single commit — trial-reminder email only; no rendering code)
  • Read failed CI logs (web-tests: Run pnpm check)
  • Identify root cause
  • Determine correct action

Root cause: pre-existing failure on main, not caused by this PR

The failing step is not a type/coverage/lint error — it's a Playwright visual-regression test that CI runs as part of pnpm check:

[chromium] › src/e2e/crawl-bookmark-visual.e2e-local.ts:39 › … rounded-left capsule (light)
[chromium] › src/e2e/crawl-bookmark-visual.e2e-local.ts:46 › … rounded-left capsule (dark)
  Expected an image 145px by 54px, received 153px by 54px. 990 pixels (ratio 0.12) are different.

That test does not exist on this branch. It was added by PR #9365b993302 "render the crawl bookmark as one seamless capsule", which is on main (my branch forked one commit earlier, at 86fdbbdb). CI builds the PR merge commit, so it inherits main's test.

Evidence it's pre-existing and unrelated to #939:

Check Result
main push-CI for 5b993302 (run 28744076544) byte-identical failure (same 2 tests, 145→153px, 990px, ratio 0.12)
#936's own PR check (web-tests, commit c32904bf) ❌ was already failure#936 merged into main with a red check
This PR's rendering changes none — full diff is trial-reminder only; the one "bundle"-named file (web-test-harness/src/bundle.types.ts) is types-only
Baseline snapshot history committed once, by 5b993302; never matched the CI renderer

The capsule is 8px wider because the fixed, timezone-pinned label "26 Mar '26, 14:32" occupies more horizontal space on the CI chromium-linux renderer than on whatever machine generated the committed 145×54 baseline. .crawl-bookmark__time sets no explicit font-family, so this is a classic web-font-fallback / off-platform baseline mismatch.

Why I did not push a change here

There is no in-scope fix for this PR. Correcting #936's binary snapshot baseline from inside an unrelated trial-reminder PR would:

  • violate "do not make unrelated changes" (the PR touches no rendering code, and the snapshot files aren't even on this branch — they arrive via the merge);
  • risk masking a real bug — if a font genuinely fails to load in the reader, the correct fix is to make it load, not to bless the 153px fallback as the new baseline (I can't verify which rendering is intended from here);
  • collide with the proper fix once it lands on main.

main is currently red for every PR, so no amount of re-running this fixer on #939 will make it green — the fix must land on main.

Recommended fix (on main, in #936's context)

Reproduce on the CI runner, then either:

  1. If a web font is failing to load (most likely) → fix the font loading for the crawl bookmark; or
  2. If the baseline was captured off-platform → regenerate both baselines with playwright --update-snapshots on the CI-equivalent chromium-linux runner.

Then rebase this branch onto the fixed main#939 goes green with zero changes here. Fix this →


No commit pushed: the failure originates entirely in main (#936) and this PR contains no rendering changes. Pushing an unrelated snapshot edit would mix concerns and could mask a real font-loading regression.
· failing PR CI · branch conversion/trial-reminder-email

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 6m 20s —— View job


Code Review — PR #939 (pre-expiry trial reminder email)

  • Read reviewer instructions
  • Gather PR diff & commits
  • Review scheduler + handler logic
  • Review the three signup sites (email/Google/Apple) + reactivation
  • Review DynamoDB writes + provider contracts + row schema
  • Review email template + web conventions
  • Review infra delta (env var)
  • Review tests & coverage

Review complete — 0 high / 0 medium / 2 low. The full review is emitted to the code-review automation, which posts it as the review comment (kept out of this tracking comment to avoid a duplicate).

TL;DR: Strong, additive PR. Perfect create/delete parity across all schedule sites, backward-compatible optional kind, DST-safe date math, comprehensive noop-branch and best-effort-failure coverage, and it follows the existing feedback-email conventions precisely. Two minor (low) notes: a singular subject-verb nit in the saved-articles clause, and the "can't double-send" guarantee being a read-then-write rather than an atomic conditional write (matches the existing feedback email, so not a regression).
· branch conversion/trial-reminder-email

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. Singular subject-verb disagreement in the saved-articles clause

usageClause in projects/hutch/src/runtime/web/auth/trial-reminder-email.ts:29-33 produces, for a user who saved exactly one article:

the 1 article you've saved stay readable either way

"the 1 article … stay" reads as a subject-verb disagreement (should be "stays"). It's a deliberate choice — trial-reminder-email.test.ts:55 explicitly asserts this exact string — but in otherwise-polished founder-voice copy a recipient may notice it. Optional: special-case the verb, e.g. const verb = count === 1 ? "stays" : "stay"; (and update the two singular assertions). Purely cosmetic; not blocking.

2. "At most one reminder" is a read-then-write guard, not an atomic conditional write

The single-send property in processReminder (send-trial-feedback-email-handler.ts) comes from reading row.trialReminderEmailSentAt and noop-ing if set, then sending, then calling markTrialReminderEmailSent. The write itself (dynamodb-subscription-writes.ts) only conditions on attribute_exists(userId), not on the flag being unset. Sequential SQS retries are safe (the redelivery reads the now-stamped flag), but a genuinely concurrent duplicate delivery could pass the read on both invocations and double-send — so the PR body's "at-least-once delivery can't double-send" is marginally stronger than the mechanism guarantees.

This mirrors the existing markTrialFeedbackEmailSent / feedback path exactly, so it is not a regression and is consistent with the established convention. Optional hardening (would apply equally to the feedback email): make the write the single-send guard via ConditionExpression: "attribute_exists(userId) AND attribute_not_exists(trialReminderEmailSentAt)" and treat ConditionalCheckFailedException as a benign noop. Fine to leave as-is for consistency with the sibling email.

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Resolve conflicts. Use your Read tool to read the local file .github/workflows/claude-PR-conflict-fixer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 13m 40s —— View job


Conflicts resolved ✅

Rebased conversion/trial-reminder-email onto the latest origin/main (37505e10) and pushed with --force-with-lease.

  • Read conflict-fixer instructions
  • Understand PR intent from commit history
  • Fetch origin/main and identify conflicts
  • Rebase onto origin/main and resolve conflicts
  • Run pnpm check to verify
  • Push with --force-with-lease

What conflicted

One file: projects/hutch/src/runtime/server.ts — the initAppleAuthRoutes(...) wiring. Two intentional changes collided:

Side Intent
main (#933 23bada88, "release Sign in with Apple to all users") Removed the if (deps.appleAuth) guard — Apple auth is now unconditional (appleAuth became a required dep, vs googleAuth?).
this PR (3fed3c09) Added createTrialReminderSchedule: deps.trialScheduler.createTrialReminderSchedule to the call.

Resolution: kept both — main's unconditional call structure and the feature's new createTrialReminderSchedule dependency. Neither intentional change was reverted. (createTrialReminderSchedule is a required param on initAppleAuthRoutes, so omitting it would not have type-checked.)

The other overlapping files — auth.page.ts, auth.route.test.ts, bundle.types.tsauto-merged cleanly; verified the reminder-schedule wiring survived across all three signup sites (email/Google/Apple).

History cleanup

The pre-existing merge commit (da96a30b) and the redundant crawl-bookmark snapshot commit (59d3328b, already on main) flattened away. The branch is now clean linear history: f1227b0b (feature) directly on origin/main — still 38 files, +1040 / −22, identical to the reviewed PR. The earlier visual-regression CI failure was fixed on main by 59d3328b, which the rebase inherits, so it should no longer block CI.

Verification

pnpm checkgreen: 37 projects / 48 tasks succeeded, hutch 2910 unit + 11 integration + 8 E2E tests pass, coverage thresholds met, biome + knip clean.
· branch conversion/trial-reminder-email

Add a founder-voice reminder email that fires two days before trialEndsAt
for card-free trials, reusing the existing send-trial-feedback-email Lambda
and EventBridge Scheduler pipeline (no new Lambda, queue, event rule, or IAM).

SendTrialFeedbackEmailCommand gains an optional kind ("feedback"|"reminder",
absent=feedback for backward compatibility). A deterministic one-shot
trial-reminder-<userId> targets the same DetailType and carries
Input {userId, kind:"reminder"}. Schedules are created at all three
trial-signup sites (email/Google/Apple) and recreated on /account
reactivation when >2d remain; deleted at checkout success and the
cancel-subscription trialing branch. Idempotence via a new
trialReminderEmailSentAt row flag; the reminder path re-checks
status==="trialing" with a future trialEndsAt before sending. CTA links to
/account with utm_source=trial-reminder for attribution. Only infra delta is
APP_ORIGIN on the send-trial-feedback-email Lambda.

Claude-Session: https://claude.ai/code/session_01En2uUiiwQoRQKWenCnkPi9
@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

@claude Review this PR. Use your Read tool to read the local file .github/workflows/claude-PR-code-reviewer.md for instructions. The file is in the checked-out repository.

Context

@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @FagnerMartinsBrack's task in 6m 2s —— View job


Code Review — PR #939 (pre-expiry trial reminder email)

  • Read reviewer instructions
  • Gather PR diff & commits
  • Review scheduler + handler logic
  • Review the three signup sites (email/Google/Apple) + reactivation
  • Review DynamoDB writes + provider contracts + row schema
  • Review email template + web conventions
  • Review infra delta (env var)
  • Review tests & coverage
  • Post review

Re-reviewed the rebased single-commit version (f1227b0b). Findings unchanged from the prior pass: 0 high / 0 medium / 2 low. The full review is emitted to the code-review automation (which posts it as the review comment) to avoid a duplicate here.

TL;DR: Strong, additive PR. Verified independently: clean create/delete schedule parity across all four create sites (email/Google/Apple signup + guarded reactivation) and both delete sites (checkout success + cancel trialing branch); backward-compatible optional kind (absent → feedback, matching in-flight {userId}-only schedules); the reminder create uses the identical CreateScheduleCommand shape as its three siblings; APP_ORIGIN is defined at infra/index.ts:215 and wired to the Lambda at :1345; subject/body/lead-time all derive from one TRIAL_REMINDER_LEAD_DAYS constant so they can't drift; DST-safe UTC math; comprehensive noop-branch + best-effort-failure coverage. Two low notes carried over — a singular subject-verb nit ("1 article … stay") and the "can't double-send" guarantee being a read-then-write rather than an atomic conditional write (identical to the existing feedback email, so not a regression).
· branch conversion/trial-reminder-email

@FagnerMartinsBrack

Copy link
Copy Markdown
Member Author

High Priority Issues (Must Fix)

None found

Medium Priority Issues

None found

Low Priority Suggestions

1. Singular subject-verb disagreement in the saved-articles clause

usageClause in projects/hutch/src/runtime/web/auth/trial-reminder-email.ts builds, for a user who saved exactly one article:

the 1 article you've saved stay readable either way

"the 1 article … stay" is a subject-verb disagreement (should be "stays"). It's deliberate — trial-reminder-email.test.ts asserts this exact string for the singular case — but in otherwise-polished founder-voice copy, and given the evidence that some expiring trialists have very low save counts (a single save is reachable), a recipient may notice it. Optional: special-case the verb, e.g. const verb = count === 1 ? "stays" : "stay";, and update the two singular assertions. Purely cosmetic; not blocking.

Fix this →

2. "At most one reminder" is a read-then-write guard, not an atomic conditional write

The single-send property in processReminder (send-trial-feedback-email-handler.ts) comes from reading row.trialReminderEmailSentAt and noop-ing if set, then sending, then calling markTrialReminderEmailSent. The write itself (dynamodb-subscription-writes.ts) conditions only on attribute_exists(userId), not on the flag being unset. Sequential SQS retries are safe (redelivery reads the now-stamped flag), and the scheduler emits a single one-shot, so in practice this is fine — but a genuinely concurrent duplicate delivery could pass the read on both invocations and double-send. The PR body's "at-least-once delivery can't double-send" is therefore marginally stronger than the mechanism guarantees.

This mirrors the existing markTrialFeedbackEmailSent / feedback path exactly, so it is not a regression and is consistent with the established convention. Optional hardening (would apply equally to the feedback email): make the write the single-send guard via ConditionExpression: "attribute_exists(userId) AND attribute_not_exists(trialReminderEmailSentAt)" and treat ConditionalCheckFailedException as a benign noop. Fine to leave as-is for consistency with the sibling email.

@FagnerMartinsBrack FagnerMartinsBrack merged commit 7302f6c into main Jul 6, 2026
9 checks passed
@FagnerMartinsBrack FagnerMartinsBrack deleted the conversion/trial-reminder-email branch July 6, 2026 23:20
FagnerMartinsBrack added a commit that referenced this pull request Jul 7, 2026
…on-app-store-rasc6i

Reconcile with #939 (pre-expiry trial reminder email), which extended the same subscription-providers store this PR extended for account-deletion scrubbing. Kept both sides' additions across the write provider, its unit test, and the in-memory fixture + test:
- markTrialReminderEmailSent (#939) and deleteSubscription (this PR)
- split the interleaved describe/it blocks back into complete blocks
- corrected the auto-merged "seven"->"eight mutations" doc comment

Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FagnerMartinsBrack added a commit that referenced this pull request Jul 7, 2026
The #939 merge added a fourth per-user EventBridge schedule
(trial-reminder-<userId>), created for every trialing user at
signup/trial-start, but the delete-account worker still deleted only
three (trial-end, deferred-cancellation, trial-feedback). An account
deleted mid-trial therefore left an orphaned, user-keyed reminder
schedule live at AWS until its natural fire time — the exact kind of
user-owned remnant this compliance feature exists to erase — and the
handler's own "all three" comment misstated the count.

Wire deleteTrialReminderSchedule into the worker alongside the other
three deletes (it is ResourceNotFound-idempotent, so a redrive
converges), correct the comment to "all four", and cover it in the
trialing and founding-member branch tests plus the raw-email redrive
test (which exercises the schedule delete on both the failed first
delivery and the retry). No IAM change: the worker's
scheduler:DeleteSchedule grant is a group-level wildcard that already
permits the reminder schedule name. The sibling cancel-subscription
worker already deletes this same schedule for a trialing cancel.

Co-authored-by: Fayner Brack <FagnerMartinsBrack@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant