Skip to content

feat(mail): first-week onboarding tip sequence (dark-shipped)#754

Open
edwh wants to merge 12 commits into
masterfrom
feature/reengagement-mails
Open

feat(mail): first-week onboarding tip sequence (dark-shipped)#754
edwh wants to merge 12 commits into
masterfrom
feature/reengagement-mails

Conversation

@edwh

@edwh edwh commented Jun 14, 2026

Copy link
Copy Markdown
Member

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.

Internally this still lives under the reengage name (table, service, effectiveness dashboard) - the sequencing, tracking and experiment plumbing are identical; only the trigger and content changed from the earlier lapsed-user idea.

The five tips

Day Tip CTA
1 Welcome - and what makes a good offer. Sets the expectation, then: clear title, a photo, honest condition, roughly where you are. Offer something
2 The stuff you think nobody wants. The half-tin of paint, odd cables, outgrown clothes languishing in cupboards and drawers - don't assume nobody would want it. On Freegle they nearly always do. Offer something
3 Need something? Just ask. Freegle is for getting too - post a wanted; say what you're after, a line of why, roughly where; stay open on brand/condition. Post a wanted
4 You can search for things. Type in what you need and see what's already been offered nearby; save a search so the right one doesn't pass you by. Search Freegle
5 You're a freegler now. Quick recap + being a good neighbour (reply promptly, easy doorstep pickup, say thanks). See what's near you

One shared MJML tip template (head/footer partials, plain-text alternative, RFC 8058 one-click List-Unsubscribe, a single clear CTA and a progress indicator), driven by per-day copy in ReengageContentService.

Rendered (headless Chrome, GPU off - sample data)

Day 1 Day 2 Day 3 Day 4 Day 5
Day 1 Day 2 Day 3 Day 4 Day 5

Who gets it

  • New members only - keyed off account age (users.added); tip N lands on day start_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.
  • TrashNothing and LoveJunk accounts are excluded - they onboard through their own platform (SQL tnuserid/ljuserid/domain gate plus a belt-and-braces isTN()/isLJ() check).
  • Plus the usual gates: genuine approved Freegle membership, not bouncing, not on holiday, marketing opt-out and per-group engagement opt-out honoured.

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:

  • only communities the member genuinely joined - collection = Approved, rippled = 0 so a Rippling-Out auto-join can never appear to "come from" a group they never knowingly joined;
  • nearest first (haversine on the group centroid vs the member's location), so it feels local rather than from the far side of the country;
  • the volunteer must be an active, publicly-shown (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 an email_tracking id, 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-outcomes writes 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" in FREEGLE_MAIL_ENABLED_TYPES. Preview any/all tips into mailpit with php 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) and ReengageExperimentTest (deterministic bucketing, per-day subjects). Full iznik-batch Laravel suite green.

🤖 Generated with Claude Code

@netlify

netlify Bot commented Jun 14, 2026

Copy link
Copy Markdown

Deploy Preview for golden-caramel-d2c3a7 canceled.

Name Link
🔨 Latest commit 2bec8ed
🔍 Latest deploy log https://app.netlify.com/projects/golden-caramel-d2c3a7/deploys/6a556610b79dcc00088e3aaa

@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Running CI build is currently running labels Jun 14, 2026
@edwh edwh force-pushed the feature/reengagement-mails branch 2 times, most recently from 0427b0f to 3773bcc Compare June 14, 2026 13:51
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Passed CI build passed CI: Running CI build is currently running labels Jun 14, 2026
@edwh edwh force-pushed the feature/reengagement-mails branch from e53e68e to c246a6e Compare June 17, 2026 16:47
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Passed CI build passed CI: Running CI build is currently running labels Jun 17, 2026
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running and removed CI: Passed CI build passed labels Jul 11, 2026
@edwh

edwh commented Jul 12, 2026

Copy link
Copy Markdown
Member Author

Adversarial review + UI review — re-engagement sequence

Reviewed 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)

  1. Double-mails the same lapse as mail:engage. The design says the sequence "finishes before the engage sunset so the two never double-mail." That's not what engage does: EngageUpdateService flips New/Occasional users to engagement='Inactive' after 14 days (RECENT_ACCESS_DAYS=14) and EngageEmailService then mails every Inactive user ~weekly (RESEND_INTERVAL_DAYS=7) with no lastaccess upper bound — right through the whole 30/75/120-day reengage window. A lapsed user would get both campaigns. The rollout checklist only excludes in-sequence users from the digest, never from engage. Go-live prerequisite: exclude in-reengage-sequence users from engage (and the digest).

  2. List-Unsubscribe HTTP header can't actually unsubscribe. MjmlMailable::addListUnsubscribeHeaders() builds "{site}/unsubscribe/{userId}"no auth key. The mail-client one-click POST (RFC 8058) arrives with no session, and /unsubscribe/[id] only acts when the browser session already matches id, so it no-ops. The in-body link correctly uses the keyed /one-click-unsubscribe/{id}/{key}. Fix: override addListUnsubscribeHeaders() in ReengageMail to use the keyed URL (the ChatNotification pattern). Gmail/Yahoo require a working one-click header for bulk mail.

🟠 High

  1. relevantallowed opt-out is ignored. Candidate selection checks deleted/bouncing/onholidaytill/simplemail but never users.relevantallowed, which EngageEmailService explicitly honours. A user opted out of win-back/marketing mail still gets all three nudges.
  2. Daily candidate query full-scans users. whereBetween('lastaccess', …) has no usable index (only the composite (added, lastaccess)), the JSON_EXTRACT predicate is unindexable, and there's no ->select('id') before lazyById, so it pulls every column of every row daily at 15:30 (just before the already-slow engage). Add a lastaccess index and select('id').
  3. Plain-text templates HTML-escape user content. text/reengage/nearby.blade.php and impact.blade.php use {{ }} in a text/plain part, so "Kids' bikes & scooters" becomes Kids' bikes & scooters. The codebase documents this exact trap in text/digest/unified.blade.php and uses {!! !!} — the reengage text templates got it backwards. (And the nearby/impact text parts are never rendered by any test.)

🟡 Medium

  • Per-group engagement opt-out uses MAX(...) over all Freegle memberships (not just Approved), so '1' from any stale row overrides a deliberate '0'.
  • Stage-1 promises "N items … up for grabs" from offerCount (no photo requirement) but the cards require whereHas('attachments') → promise with an empty grid when nearby offers lack photos.
  • Subject line: "1 free things near you this week" (no singular; body pluralises correctly).
  • Stage-2 mixes lookback windows: neighbour faces use 30 days, item photos use 7 days.
  • HTML/text parity: avatars capped at 4, names at 3 (HTML) vs 5 (text).
  • Send-then-record isn't atomic and there's no unique (userid, stage) — a failed insert after a successful spool resends the stage; a manual run overlapping the cron can double-send.

🖼️ 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 TrackableEmail, no pixel, no wrapped links → zero rows in email_tracking), and outcome='Reengaged' is declared but never written (re-engagement is only inferred from mutable lastaccess). So today nothing can graph effectiveness. Being added on this branch: wire TrackableEmail (opens/clicks via the existing email_tracking infra), record a per-send email_tracking_id, a daily outcome job that writes real reengaged_at/via from the logs/messages event trail, and a Reengagement sysadmin tab (sent → opened → clicked → reengaged funnel per stage/arm/segment).

🧪 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 (crc32(userId|experiment) % 100, same arm across all 3 stages), a rollout_pct knob layered on the existing allowlist for a limited pilot, alternate copy variants, and journey segmentation by first action when joining (offer / wanted / replier / other) so copy can differ by how the user first used Freegle.

@FreegleGeeks FreegleGeeks added CI: Passed CI build passed and removed CI: Running CI build is currently running labels Jul 12, 2026
@edwh edwh changed the title feat(mail): localised re-engagement email sequence (dark-shipped) feat(mail): first-week onboarding tip sequence (dark-shipped) Jul 12, 2026
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Passed CI build passed CI: Running CI build is currently running labels Jul 12, 2026
@edwh edwh force-pushed the feature/reengagement-mails branch 2 times, most recently from fa99160 to b1ec903 Compare July 12, 2026 18:19
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running CI: Passed CI build passed and removed CI: Passed CI build passed CI: Running CI build is currently running labels Jul 12, 2026
edwh and others added 11 commits July 13, 2026 23:01
…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
@edwh edwh force-pushed the feature/reengagement-mails branch from 06bfd9a to 0f9e7e3 Compare July 13, 2026 22:01
@FreegleGeeks FreegleGeeks added CI: Running CI build is currently running and removed CI: Passed CI build passed labels Jul 13, 2026
@FreegleGeeks FreegleGeeks added CI: Passed CI build passed and removed CI: Running CI build is currently running labels Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CI: Passed CI build passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants