feat(mail): first-week onboarding tip sequence (dark-shipped)#754
feat(mail): first-week onboarding tip sequence (dark-shipped)#754edwh wants to merge 12 commits into
Conversation
✅ Deploy Preview for golden-caramel-d2c3a7 canceled.
|
0427b0f to
3773bcc
Compare
e53e68e to
c246a6e
Compare
Adversarial review + UI review — re-engagement sequenceReviewed the core logic, mail rendering/security, tests, instrumentation, and experiment-readiness (four focused passes), and viewed the rendered stages in mailpit/headless-Chrome. The feature is well-built and correctly dark-shipped; the items below are what to fix before ramping, plus the effectiveness/experiment work now being added on top. 🔴 Blockers (before enabling for real users)
🟠 High
🟡 Medium
🖼️ UI review (mailpit, headless Chrome, GPU off)Stage 1 renders clean and on-brand: green hero + logo, warm "Hi Alex", localised "47 free items … near you", a 2-col item-card grid, one clear primary CTA, secondary "Give it away", correct footer with unsubscribe + charity reg. In preview the item images are the "?" placeholder tiles (sample data has no photos) — this is exactly the finding above: with real-but-photoless offers the grid would collapse, so the empty/photoless state needs a graceful fallback (hide the grid, keep the CTA). Stages 2/3 are visually consistent. 📊 Effectiveness instrumentation — currently a gap (being added)The mail is invisible to open/click tracking (no 🧪 Experiment / variants / journey (being added)Also on this branch: config-driven experiment arms with a real control holdout (recorded but unmailed, so lift is measurable), stable per-user assignment ( |
fa99160 to
b1ec903
Compare
…hipped)
A 3-stage win-back flow for lapsed users, graduated across the lapse (early
gentle local nudge -> community impact -> opt-in/sunset) and leading with local
content instead of generic "we miss you". Sits earlier than the existing
`engage` system and finishes before its sunset, so the two never double-mail
the same lapse.
Stages (MJML on shared partials, plain-text alternatives, RFC 8058
List-Unsubscribe), birthday email as the visual bar:
1. nearby - free items offered near the user this week + live cards
2. impact - items rehomed + weight kept from landfill in their local
Freegle groups, plus a couple of live items
3. preferences - opt-in confirmation + sunset notice, with an "email me less
often" downgrade before unsubscribe
Sequence state derives from `reengage` rows newer than users.lastaccess, so any
login resets it for free; suppressed after stage 3 with no re-engagement.
Reuses NearbyOffersService, the stats table, and the lat/lng waterfall; degrades
gracefully with no location. Cadence/messaging follow win-back research:
intervene early and graduate across the lifecycle, suppress non-responders.
Trigger is on login inactivity (lastaccess); defaults trigger=30 / gap=45 /
max=175 days, all env-tunable. NB login-gap overstates disengagement for
digest-readers - recommend refining to an email-engagement signal before ramping.
Dark by default: gated by FREEGLE_MAIL_ENABLED_TYPES (type "Reengage", added to
docker-compose) AND FREEGLE_REENGAGE_ALLOWLIST (empty = nobody). Daily
`mail:reengage` (15:30); `--preview=addr` renders to mailpit (screenshots in
plans/). Tests: render + escaping + MJML compile + plain-text; eligibility,
stage progression, suppression, dark-ship gates - all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Abstract counters (items rehomed / kg saved) don't engage. Replace stage 2's
stat badges with a collage of real neighbours — circular avatars + first names
of recent nearby posters who have a profile photo — alongside item photos of
what they've shared, plus a names sentence and a qualitative impact line. Same
public wall data freeglers already see in-app; avatar-uploaders only, first
names only; degrades to items-only / generic copy when faces or photos are
missing.
Subject becomes people-oriented ("Your neighbours near X have been freegling").
Drops the stats-table impact query. Render tests updated (collage + empty-faces
fallback); 25/25 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…aw location
The stage-1/3 place label came only from the user's picked/last location. For
most users lastlocation is a road/polygon they tapped, so locations.name is
often a fragment ("WV3", "Red", "10") — producing "free things near Red". The
parent areaid is no better (frequently a tiny wrong hamlet, e.g. "Peamore" for
someone near Exeter).
New waterfall: settings.mylocation (picked) -> the user's Freegle group town
(curated/recognizable, "Coventry Freegle" -> "Coventry") -> a guarded
locations.name (must contain real letters) -> "your area". Live data confirms
this turns WV3/S36/Jet into Wolverhampton/Barnsley/Middlesbrough.
Adds a group-town fallback test. 26/26 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The group-town strip used /\bfreegle\b/i, which only matches "Freegle" as a standalone word — so the nameshort fallback "CoventryFreegle" (no space) was left unstripped. Use /\s*freegle\s*/i so "Coventry Freegle", "Freegle Inverness" and "CoventryFreegle" all reduce to the town. Adds a nameshort test. 27/27 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… you"
Deriving a place name from the user's stored location or group was too error
-prone — road/postcode fragments, multi-group ambiguity — and confidently
printing the wrong town reads worse than saying nothing. Remove the place-name
derivation entirely (areaName/groupAreaName and the areaName/areaLabel keys)
and make all copy generic ("near you"). The localisation that matters — real
nearby items and neighbour avatars, fetched by lat/lng — is unchanged.
Drops the two place-label tests; updates render tests + screenshots. 25/25 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- push:daily-posts: multi-post title now "N new things near you" (was "N new freegles near you"); drop the unused image_count log field and its payload decode. Tests updated to match. - namegen: return the fillWord result directly (drop the redundant min-length guard that could never trip after the earlier length checks). - modtools std-message modal: fill-in hint uses text-muted, not text-danger. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXKgf7Xquudc6CwP2RN6jX
…n, effectiveness instrumentation & sysadmin graph
Adds, on top of the dark-shipped 3-stage sequence:
Experiment (dark by default; FREEGLE_REENGAGE_EXPERIMENT_ROLLOUT_PCT=0 == today):
- ExperimentBucket: stable crc32(userId|experiment)%100 assignment, same arm across all 3 stages
- config arms control/a/b with a REAL control holdout (recorded, not mailed → lift measurable)
- rollout_pct pilot knob layered on the existing allowlist gate
- arm 'b' subject-line variant (infra supports full body variants)
Journey segmentation:
- record first-action segment (offer/wanted/replier/other) per send; segment-aware subject framing
Effectiveness instrumentation → sysadmin:
- ReengageMail uses TrackableEmail (open pixel + tracked CTA), stores email_tracking_id per send
- mail:reengage-outcomes writes real reengaged_at/via + outcome='Reengaged' from logs/messages
- new columns: experiment, arm, bucket, segment, email_tracking_id, reengaged_at, reengaged_via
- Go GET /modtools/email/stats/reengage + Reengagement sysadmin tab (funnel by stage/arm/segment, lift vs control)
Review fixes (see PR review comment): keyed RFC 8058 List-Unsubscribe header; honour
relevantallowed; plain-text {!! !!} escaping; singular subject; Approved-only engagement
opt-out; select('id') candidate query.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXKgf7Xquudc6CwP2RN6jX
…rine Laravel 11 dropped getDoctrineSchemaManager(); calling it aborted the whole test DB setup. Check existing indexes with an information_schema.statistics query instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXKgf7Xquudc6CwP2RN6jX
SchedulerResilienceTest requires every withoutOverlapping() guard to expire well below Laravel's 24h default so a killed job self-heals. mail:reengage and mail:reengage-outcomes now use withoutOverlapping(60). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXKgf7Xquudc6CwP2RN6jX
The re-engagement compile test segfaulted the PHP process (exit 139) the moment master was merged in. Root cause: build() put $this (the Mailable) into the Blade view data so templates could call $mail->trackedUrl() and $mail->getTrackingPixelMjml(). Extracting a large, self-referential Mailable into the Blade scope segfaults the renderer under PHP 8.4 — the crash is in the Blade render, before MJML is even compiled. Rendering the same template without $mail in the data compiles fine. Fix: do the tracking in build() and pass only plain strings into the view. CTA links (find/give/browse/settings) and the item-card offer URLs are pre-wrapped with trackedUrl() here, and the open pixel is handed over as ready MJML in $trackingPixelMjml. Templates now use $findUrl directly and drop the pixel via @if(!empty($trackingPixelMjml)). No Mailable object ever reaches the view. Previews (userId 0) stay untracked, so CTAs render as plain URLs and the pixel section is omitted. Verified: full Laravel suite (4835 tests) passes; ReengageMailRender's compile test no longer segfaults. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VXKgf7Xquudc6CwP2RN6jX
Repurposes the (dark-shipped, not-yet-live) "reengage" sequence from a
lapsed-user win-back into a first-week ONBOARDING flow: one short, friendly
tip a day for a new member's first five days, following the welcome mail.
Kept under the reengage name (table, service, effectiveness dashboard) since
the sequencing/tracking/experiment plumbing is identical - only the trigger
and content changed.
The five tips (content from product):
1. Welcome + what makes a good offer post (sets the "a tip a day for 5 days"
expectation up front).
2. The stuff you think nobody wants - the neglected things in cupboards and
drawers; don't assume nobody would want it, on Freegle they nearly always do.
3. Need something? Post a wanted.
4. You can search for things already offered nearby.
5. You're a freegler now - recap + being a good neighbour.
One shared MJML `tip` template (+ plain-text) driven by per-day copy, with a
progress indicator and a single clear CTA.
Trigger & audience (ReengageService):
- new members only, keyed off account age (users.added); tip N lands on day
start_day + (N-1)*gap (days 1-5 by default), one a day, capped at `stages`;
- max_start_days stops a first-enable from back-blasting recent joiners;
- TrashNothing / LoveJunk proxy accounts excluded (tnuserid/ljuserid gate +
per-user isTN()/isLJ() belt-and-braces; emails live in users_emails so the
domain case is handled per-user, not in SQL);
- existing gates kept: approved Freegle membership, bounce/holiday/marketing
and per-group engagement opt-outs.
Volunteer sign-off (ReengageContentService::resolveVolunteer, mirrors
BirthdayService): every tip is signed by a real local volunteer - an active,
publicly-shown (users.settings.showmod) Moderator/Owner of a community the
member GENUINELY joined (collection Approved, rippled=0 so a Rippling-Out
auto-join can't appear to "come from" a group they never knowingly joined),
nearest first by haversine on the group centroid. Falls back to "The Freegle
team" when there's no such volunteer.
Instrumentation unchanged and still dark by default: per-send arm/segment +
email_tracking id, outcomes written by mail:reengage-outcomes, graphed per
stage/arm/segment in the sysadmin dashboard (Go groups by stage dynamically,
so 5 days just work; stale stage-count comments/labels updated).
Config: start_day / stage_gap_days / max_start_days replace the lapse-based
trigger_days / max_days. Tests rewritten for the tip template, the new-member
trigger, TN/LJ exclusion, the daily cadence and completion. Screenshots of all
five tips (headless Chrome, GPU off) under plans/onboarding-screenshots/.
Full iznik-batch Laravel suite (4835 tests) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXKgf7Xquudc6CwP2RN6jX
06bfd9a to
0f9e7e3
Compare
What
A first-week onboarding email sequence for new members: one short, friendly tip a day for the first five days, following the welcome mail. It sets the expectation up front ("one short tip a day for five days"), teaches the handful of things that make Freegle click, and is signed off by a real local volunteer wherever we can find one. Dark by default so it ships safely ahead of any rollout, and carries the tracking/experiment machinery so its effectiveness is measurable in the sysadmin dashboard.
The five tips
One shared MJML
tiptemplate (head/footer partials, plain-text alternative, RFC 8058 one-clickList-Unsubscribe, a single clear CTA and a progress indicator), driven by per-day copy inReengageContentService.Rendered (headless Chrome, GPU off - sample data)
Who gets it
users.added); tip N lands on daystart_day + (N-1)*gap(days 1-5 by default). A safety bound (max_start_days) means first-enabling the feature can't back-blast everyone who joined last month.tnuserid/ljuserid/domain gate plus a belt-and-bracesisTN()/isLJ()check).The volunteer sign-off
Every tip is signed by a real local volunteer where one exists, resolved exactly like the birthday-appeal sign-off so the two stay consistent:
collection = Approved,rippled = 0so a Rippling-Out auto-join can never appear to "come from" a group they never knowingly joined;users.settings.showmod) Moderator/Owner.No such volunteer? It falls back to the plain "The Freegle team" voice rather than inventing one.
Instrumentation & experiment
Unchanged from the existing plumbing and still dark by default (
rollout_pct = 0): every send records a stable arm (control/a/b), a journey segment, and anemail_trackingid, so opens/clicks and whether a tip drove a real action (login/reply/post in the outcome window) are graphable per tip/arm/segment in the sysadmin Reengage effectiveness panel.mail:reengage-outcomeswrites the outcomes; the control arm is a true holdout for measuring lift.Roll-out
Inert until both knobs are opened:
FREEGLE_REENGAGE_ALLOWLIST(empty = nobody;*= everyone; or specific pilot addresses) and"Reengage"inFREEGLE_MAIL_ENABLED_TYPES. Preview any/all tips into mailpit withphp artisan mail:reengage --preview=you@example.com(bypasses the gates).Tests
ReengageMailRenderTest(tip template + volunteer sign-off + fallback + escaping + full MJML compile),ReengageEmailsCommandTest(new-member trigger, TN/LJ exclusion, daily cadence, completion, dark-ship gates, dry run) andReengageExperimentTest(deterministic bucketing, per-day subjects). Fulliznik-batchLaravel suite green.🤖 Generated with Claude Code