Phase 22D + 22E: streak re-engagement email + mobile SharePage polish#4
Merged
Phase 22D + 22E: streak re-engagement email + mobile SharePage polish#4
Conversation
Daily 18:00 UTC cron — sends one short "picking up where you left off"
email to users whose streak slipped yesterday (last_active_date =
yesterday-UTC AND current_streak ≥ 1 AND email_opt_in = true AND not
already mailed today). Backend-only: no scheduler infra, just a
setTimeout-to-target driver in the same in-process pattern as the
Phase 22A budgetWatcher.
Email template: subject "Picking up where you left off", preheader
"A short lesson, when you've got the time.", deep link via
course_progress.last_lesson_id with /start fallback. Plaintext is
canonical; HTML is dark-surface, single column, gradient-pill CTA.
List-Unsubscribe + List-Unsubscribe-Post headers so Gmail/Apple Mail
render an inbox one-click button.
Sender: CodeTutor <noreply@mail.codetutor.msrivas.com> (verified ACS
domain). Reply-To: support@codetutor.msrivas.com (Wix → ImprovMX
forward to operator gmail; setup deferred to deploy time).
Unsubscribe: GET /api/email/unsubscribe?token=<HMAC-signed userId>.
Tokens never expire; rotating EMAIL_UNSUBSCRIBE_SECRET invalidates
all outstanding links. Per-IP rate limit (60/min). 401 on tampered
or missing tokens; 200 + branded HTML on success.
CAN-SPAM floor: digestSweeper refuses to send if either ACS or the
unsubscribe secret is unconfigured (logs + skips). Per-user send
failure does NOT mark last_streak_email_sent_at, so tomorrow's cron
retries. Re-entrancy guard prevents a delayed boot-time catch-up
from racing the on-time scheduled fire.
Frontend:
- usePreferencesStore + setEmailOptIn helpers (optimistic patch +
rollback on 4xx/5xx)
- SettingsPanel Notifications section: switch toggle + copy
- api/client UserPreferences interface + UserPreferencesPatch
Files:
- migration 20260429020000: email_opt_in BOOLEAN DEFAULT TRUE +
last_streak_email_sent_at TIMESTAMPTZ + index on
user_streak(last_active_date) for the cron sweep
- 4 new email service modules (acsClient extended, streakNudge,
unsubscribeTokens, digestSweeper) + 3 vitest files (47 new tests)
- routes/email.ts (unsubscribe handler)
- cloud-init.yaml: refresh-env wires EMAIL-UNSUBSCRIBE-SECRET from KV
- e2e/specs/email-unsubscribe.spec.ts (5 tests, auto-skips when
secret unset so dev/CI without 22D deployed don't false-fail)
Verification: backend vitest 697 pass / 1 skip; backend + frontend +
e2e typechecks clean. Migration application + KV secret + smoke
verify via shortened cron window all deferred to deploy time.
Open ops:
- DNS: Wix MX records → ImprovMX → support@codetutor.msrivas.com
forwards to msrivas4017@gmail.com
- az keyvault secret set --vault-name codetutor-ai-kv-* \
--name EMAIL-UNSUBSCRIBE-SECRET --value "$(openssl rand -base64 48)"
- npx supabase migration up against dev + prod (per memory rule:
both envs before merge)
iPhone 13 portrait (390×844) is the dominant viewport for share-link
clicks pasted into Twitter / WhatsApp / iMessage. The cinematic page
landed clean on desktop in Phase 21C but had layout headroom issues
at narrow widths.
Polish:
- Header: smaller wordmark on narrow (text-xl sm:text-2xl), URL
row hidden via `hidden sm:block` (the address bar carries the
link; competing for ~340px of usable width with a 12-char-token
URL was the original pinch point)
- Body padding: px-5 sm:px-10 + pt-8 sm:pt-10 + pb-12 sm:pb-16 —
every pixel of usable width handed back to the code panel
- Code panel padding: p-4 sm:p-6 md:p-8 (was p-6 sm:p-8) — inner
width on iPhone 13 is now ~310px instead of ~286px
- CodeTypewriter mobile font: 13.5px / 1.5 leading (was 15px /
1.55) — ~38 chars of mono fit before horizontal scroll on
typical lessons; sm/md unchanged
- Code wrapped in overflow-x-auto so the rare 50+ char line
scrolls horizontally rather than wrapping mid-token (which
would break 4-color tokenization across visual lines)
Save-image button (NEW):
- Renders below the primary CTA when ogStoryImageUrl is non-null
(the 9:16 Story image; the 1200×630 OG card is the wrong aspect
ratio for camera-roll save / IG-Stories re-share)
- On touch with navigator.canShare({ files }): fetches the PNG as
Blob, wraps in File, hands to the native share sheet → iOS users
tap "Save to Photos", Android gets standard share UI with media
targets
- Fallback (desktop, in-app browsers, older Safari): opens the bare
PNG in a new tab → iOS Safari long-press → Save Image; desktop
right-click → Save
- AbortError (user tapped Cancel on share sheet) silently swallowed
E2E (e2e/specs/share-mobile.spec.ts) — 3 tests at iPhone 13 portrait:
- layout + no horizontal overflow + author/ring/CTA all visible
- URL row hidden on narrow (only wordmark in header)
- reduced-motion path renders the final state from t=0 (no
waiting on the typewriter; CTA visible immediately)
All 3 pass in ~10s locally.
Stack: backend + frontend + e2e typechecks clean; backend vitest
697/697.
The earlier 22D draft routed Reply-To through ImprovMX on support@codetutor.msrivas.com, which would have required adding MX records to a CNAME-bearing subdomain — Wix DNS UI blocks that and the domain isn't moveable to a more flexible provider without a transfer. iCloud Custom Email Domain is already live on msrivas.com (MX, SPF, sig1 DKIM all verified). Pointing Reply-To at support@msrivas.com routes operator replies to the existing iCloud inbox with zero DNS surgery. Also plumbs EMAIL_UNSUBSCRIBE_SECRET through docker-compose.yml (was defined in config.ts but never passed from .env into the backend container) and fixes one e2e assertion that expected a literal apostrophe but the response renders it as ' via escapeHtml.
|
Azure Static Web Apps: Your stage site is ready! Visit it here: https://gentle-flower-093ba7e0f-4.eastus2.7.azurestaticapps.net |
msrivas-7
added a commit
that referenced
this pull request
Apr 29, 2026
Refresh-env was a write_files inline in cloud-init.yaml — frozen at first boot, never re-synced when cloud-init gained new fetch_optional lines. Adding EMAIL_UNSUBSCRIBE_SECRET in PR #4 hit exactly that: KV secret was set + cloud-init.yaml updated, but the live VM kept the old script and the digestSweeper would have seen empty value. Caught only because we manually patched the VM with sed during 22D rollout. Refactor: • Extract /usr/local/bin/refresh-env into infra/scripts/refresh-env.sh as the canonical source. Reads KV_NAME + identity vars from /etc/codetutor/env.conf (cloud-init still writes that on first boot via {{KV_NAME}}-style template substitution). • cloud-init.yaml drops the 140-line inline. write_files now seeds only env.conf; runcmd installs refresh-env from the cloned repo after `git clone {{REPO_URL}}` and before the first refresh-env run. • vm-deploy-backend.sh installs the script from the repo on every deploy (idempotent), so adding a fetch_optional line lands without a VM reprovision or manual sed patch. • ci.yml drops the now-unneeded YAML-extraction-then-shellcheck dance against the inline; lints the standalone file directly. Drift warning text updated to reflect that refresh-env is no longer first-boot-frozen (other cloud-init contents still are). Also bundles the small follow-ups from the 22D rollout post-mortem: • SWA workflow concurrency: split push and pull_request:closed into separate groups so the prod deploy run no longer races against the PR-cleanup run on merge (PR #4 lost that race; the SettingsPanel email toggle never deployed until a manual workflow_dispatch). • pythonHarness.test.ts: bump describe-level testTimeout to 30s for the python3-spawning integration tests. Default 5s flaked under Windows CI cold-start. • auth.spec.ts Phase 20-P1: bump toBeVisible from 5s to 10s for the magic-link panel transition. Same pattern; flaked twice in CI runs against PR #4 / the 22D hotfix. Operator state already in place on prod VM: • /etc/codetutor/env.conf seeded (KV_NAME + 4 identity vars) • New refresh-env.sh smoke-tested (output identical to current .env) Net -90 lines.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
msrivas.com(no ImprovMX/DNS work needed)Ops state
20260429020000_email_opt_in_and_index.sqlapplied to dev + prodEMAIL-UNSUBSCRIBE-SECRETwritten to prod KV (codetutor-ai-kv-ma4jdfos)Test plan