diff --git a/docker-compose.yml b/docker-compose.yml index 485ef16dd3..eee12a9e0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1590,7 +1590,7 @@ services: - VECTOR_SEARCH_DEFAULT=${VECTOR_SEARCH_DEFAULT:-} - FREEGLE_AVATAR_SERVER_URL=http://apiv2.localhost:${PORT_TRAEFIK_HTTP:-80}/api/avatar - SPATIAL_KNN_URL=http://spatial-knn:8194 - - FREEGLE_MAIL_ENABLED_TYPES=Welcome,ChatNotification,ChatNotificationUser2Mod,ChatNotificationMod2Mod,Digest,UnifiedDigest,DonationThank,DonationAsk,ChaseUp,AutoRepost,MessageExpiry,NotificationChaseUp,StoriesNewsletter + - FREEGLE_MAIL_ENABLED_TYPES=Welcome,ChatNotification,ChatNotificationUser2Mod,ChatNotificationMod2Mod,Digest,UnifiedDigest,DonationThank,DonationAsk,ChaseUp,AutoRepost,MessageExpiry,NotificationChaseUp,StoriesNewsletter,Reengage # CI=true skips supervisor startup to prevent Laravel bootstrap race conditions. # Multiple supervisor workers bootstrapping Laravel simultaneously can corrupt services.php. - CI=${CI:-false} @@ -1710,7 +1710,7 @@ services: # Freegle Mail Configuration - FREEGLE_NOREPLY_ADDR=noreply@ilovefreegle.org - FREEGLE_USER_DOMAIN=users.ilovefreegle.org - - FREEGLE_MAIL_ENABLED_TYPES=Welcome,ChatNotification,ChatNotificationUser2Mod,ChatNotificationMod2Mod,Admin,ChaseUp,AutoRepost,MessageExpiry,AIImageReviewDigest,NotificationChaseUp,UnifiedDigest + - FREEGLE_MAIL_ENABLED_TYPES=Welcome,ChatNotification,ChatNotificationUser2Mod,ChatNotificationMod2Mod,Admin,ChaseUp,AutoRepost,MessageExpiry,AIImageReviewDigest,NotificationChaseUp,UnifiedDigest,Reengage # Freegle Images - FREEGLE_IMAGES_DOMAIN=https://images.ilovefreegle.org - FREEGLE_DELIVERY_URL=https://delivery.ilovefreegle.org diff --git a/iznik-batch/app/Console/Commands/Mail/SendReengageEmailsCommand.php b/iznik-batch/app/Console/Commands/Mail/SendReengageEmailsCommand.php new file mode 100644 index 0000000000..8c673490a9 --- /dev/null +++ b/iznik-batch/app/Console/Commands/Mail/SendReengageEmailsCommand.php @@ -0,0 +1,38 @@ +option('preview'); + if ($preview) { + $count = $service->sendPreview((string) $preview); + $this->info("Sent {$count} preview tip email(s) to {$preview}"); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + $prefix = $dryRun ? '[DRY RUN] ' : ''; + + $result = $service->processReengageEmails($dryRun); + + $this->info("{$prefix}Tips sent: {$result['sent']}"); + $this->info("{$prefix}Control (holdout, recorded not sent): {$result['control']}"); + $this->info("{$prefix}Sequences completed: {$result['completed']}"); + $this->info("{$prefix}Not yet due / skipped: {$result['skipped']}"); + + return self::SUCCESS; + } +} diff --git a/iznik-batch/app/Console/Commands/Mail/UpdateReengageOutcomesCommand.php b/iznik-batch/app/Console/Commands/Mail/UpdateReengageOutcomesCommand.php new file mode 100644 index 0000000000..15bca9f443 --- /dev/null +++ b/iznik-batch/app/Console/Commands/Mail/UpdateReengageOutcomesCommand.php @@ -0,0 +1,25 @@ +option('window'); + $result = $service->updateOutcomes($window !== null ? (int) $window : null); + + $this->info("Checked: {$result['checked']}"); + $this->info("Newly re-engaged: {$result['reengaged']}"); + + return self::SUCCESS; + } +} diff --git a/iznik-batch/app/Console/Commands/Push/SendDailyPostsPushCommand.php b/iznik-batch/app/Console/Commands/Push/SendDailyPostsPushCommand.php index 4d9a9e4c85..e62f1e1041 100644 --- a/iznik-batch/app/Console/Commands/Push/SendDailyPostsPushCommand.php +++ b/iznik-batch/app/Console/Commands/Push/SendDailyPostsPushCommand.php @@ -276,12 +276,10 @@ private function processUser( // Advance the cursor regardless of send count (the posts were processed). $this->advanceCursor($tracker, $allPosts); - $payloadImages = json_decode($pushService->buildDailyNewPostsPayload($user->id, $postsArray)['images'] ?? '[]', TRUE); Log::info('push:daily-posts: sent', [ - 'user_id' => $user->id, - 'post_count' => count($postsArray), - 'image_count' => is_array($payloadImages) ? count($payloadImages) : 0, - 'tokens_hit' => $sent, + 'user_id' => $user->id, + 'post_count' => count($postsArray), + 'tokens_hit' => $sent, ]); return 'pushes_sent'; diff --git a/iznik-batch/app/Mail/Reengage/ReengageMail.php b/iznik-batch/app/Mail/Reengage/ReengageMail.php new file mode 100644 index 0000000000..d5a7db9d10 --- /dev/null +++ b/iznik-batch/app/Mail/Reengage/ReengageMail.php @@ -0,0 +1,157 @@ + 0) are tracked; previews (userId 0) are not, to + // keep sample renders out of the effectiveness stats. + if ($this->userId > 0) { + $this->initTracking( + 'Reengage', + $this->recipientEmail, + $this->userId, + null, + $this->emailSubject, + ['template' => $this->template], + ); + } + } + + /** Tracking row id, so the reengage row can join to opens/clicks. */ + public function getTrackingId(): ?int + { + return $this->tracking?->id; + } + + protected function getSubject(): string + { + return $this->emailSubject; + } + + public function envelope(): Envelope + { + return new Envelope( + from: new Address( + config('freegle.mail.noreply_addr', 'noreply@ilovefreegle.org'), + config('freegle.branding.name', 'Freegle') + ), + to: [new Address($this->recipientEmail)], + subject: $this->getSubject(), + ); + } + + public function getEmailType(): string + { + return 'Reengage'; + } + + protected function getRecipientUserId(): ?int + { + return $this->userId > 0 ? $this->userId : null; + } + + /** + * Use the keyed one-click unsubscribe URL for the RFC 8058 header so a + * mail-client one-click POST (no session) actually works. The parent builds + * a session-only /unsubscribe/{id} URL which silently no-ops for those POSTs. + */ + protected function addListUnsubscribeHeaders(Email $message): void + { + $keyedUrl = $this->content['unsubscribeUrl'] ?? null; + + if (! $keyedUrl) { + // No keyed URL available — fall back to the parent behaviour rather + // than emit a header at all. + parent::addListUnsubscribeHeaders($message); + + return; + } + + $unsubscribeEmail = config('freegle.mail.noreply_addr', 'noreply@ilovefreegle.org'); + $headers = $message->getHeaders(); + + $headers->addTextHeader( + 'List-Unsubscribe', + ", <{$keyedUrl}>" + ); + $headers->addTextHeader('List-Unsubscribe-Post', 'List-Unsubscribe=One-Click'); + } + + public function build(): static + { + // Do the click/open tracking here and pass it into the view as plain + // strings. NEVER put $this (the Mailable) into the view data: a Mailable + // is a large, self-referential object and extracting it into the Blade + // scope segfaults the renderer under PHP 8.4. So pre-wrap the CTA links + // with tracked redirects and hand the open pixel over as ready MJML. + $content = $this->content; + + $ctas = [ + 'findUrl' => 'find', + 'giveUrl' => 'give', + 'browseUrl' => 'browse', + 'settingsUrl' => 'settings', + ]; + foreach ($ctas as $key => $action) { + if (! empty($content[$key]) && is_string($content[$key])) { + $content[$key] = $this->trackedUrl($content[$key], 'cta', $action); + } + } + + // Track click-through on the individual item cards too. + if (! empty($content['offers']) && is_array($content['offers'])) { + foreach ($content['offers'] as $i => $offer) { + if (! empty($offer['url']) && is_string($offer['url'])) { + $content['offers'][$i]['url'] = $this->trackedUrl($offer['url'], 'offer', 'offer'); + } + } + } + + $data = array_merge([ + 'name' => $this->recipientName, + 'email' => $this->recipientEmail, + 'trackingPixelMjml' => $this->getTrackingPixelMjml(), + ], $content); + + $this->mjmlView( + "emails.mjml.reengage.{$this->template}", + $data, + "emails.text.reengage.{$this->template}", + ); + + return $this->configureMessage(); + } +} diff --git a/iznik-batch/app/Services/PushNotificationService.php b/iznik-batch/app/Services/PushNotificationService.php index 76b66c9eb0..4682f9a350 100644 --- a/iznik-batch/app/Services/PushNotificationService.php +++ b/iznik-batch/app/Services/PushNotificationService.php @@ -1305,7 +1305,7 @@ public function buildDailyNewPostsPayload(int $userId, array $posts): array // Single post: title is the item name itself (BigPictureStyle). $title = $this->nameWithBulk($posts[0]['message']); } else { - $title = $count . ' new freegles near you'; + $title = $count . ' new things near you'; } // ---- Photo URLs ---- diff --git a/iznik-batch/app/Services/ReengageContentService.php b/iznik-batch/app/Services/ReengageContentService.php new file mode 100644 index 0000000000..09d2eda1f6 --- /dev/null +++ b/iznik-batch/app/Services/ReengageContentService.php @@ -0,0 +1,283 @@ + Plain strings/ints/arrays, safe to serialise + * onto a queued Mailable. + */ + public function buildContent(User $user, int $day): array + { + $userSite = rtrim((string) config('freegle.sites.user', 'https://www.ilovefreegle.org'), '/'); + $src = 'onboard-' . $day; + + $volunteer = $this->resolveVolunteer($user); + + $base = [ + 'name' => $this->firstName($user), + 'email' => $user->email_preferred, + 'userSite' => $userSite, + // /give = offer something, /find = post a wanted, /browse = search + // and see what's already been offered nearby. + 'giveUrl' => $user->loginLink('/give', $src), + 'findUrl' => $user->loginLink('/find', $src), + 'browseUrl' => $user->loginLink('/browse', $src), + 'settingsUrl' => $user->loginLink('/settings', $src), + 'unsubscribeUrl' => $user->listUnsubscribeUrl(), + 'volunteerName' => $volunteer['name'] ?? null, + 'volunteerGroup' => $volunteer['group'] ?? null, + ]; + + return array_merge($base, $this->tip($day, $base)); + } + + /** + * Sample content for the operator preview (`mail:reengage --preview=`), so + * each day's tip can be eyeballed in mailpit without a real DB user. Renders + * with a sample local-volunteer sign-off. + * + * @return array + */ + public function previewContent(int $day, string $email): array + { + $userSite = rtrim((string) config('freegle.sites.user', 'https://www.ilovefreegle.org'), '/'); + + $base = [ + 'name' => 'Alex', + 'email' => $email, + 'userSite' => $userSite, + 'giveUrl' => $userSite . '/give', + 'findUrl' => $userSite . '/find', + 'browseUrl' => $userSite . '/browse', + 'settingsUrl' => $userSite . '/settings', + 'unsubscribeUrl' => $userSite . '/unsubscribe', + 'volunteerName' => 'Priya', + 'volunteerGroup' => 'Edinburgh Freegle', + ]; + + return array_merge($base, $this->tip($day, $base)); + } + + /** + * The tip copy for a given day. Each returns a heading, a short intro, one or + * more body paragraphs, an optional highlight box (title + bullet list), a + * preheader (inbox preview line) and a single clear call to action. + * + * @param array $base + * @return array + */ + private function tip(int $day, array $base): array + { + $day = max(1, min(self::TIPS, $day)); + $total = self::TIPS; + + $tips = [ + 1 => [ + 'preheader' => "One short tip a day for your first five days - here's the first.", + 'heading' => 'Welcome to Freegle!', + 'intro' => "Over the next five days we'll send you one short tip a day to help you get the hang of things. Here's the first.", + 'body' => [ + "The heart of Freegle is offering things you no longer need to neighbours who do. A good post gets snapped up fast, and it only takes a moment.", + "When someone wants it, you'll arrange a quick doorstep handover - sort the details out in Freegle chat rather than in a public post, and a little common sense keeps it lovely.", + ], + 'highlight' => [ + 'title' => 'What makes a good offer', + 'items' => [ + 'A clear title - just say what it is ("Single duvet", "Pine bookshelf").', + 'A photo - even a quick phone snap makes all the difference.', + "The condition - honest is perfect. Well-loved and working is very welcome.", + ], + ], + 'ctaLabel' => 'Offer something', + 'ctaUrl' => $base['giveUrl'], + ], + 2 => [ + 'preheader' => "Don't assume nobody wants it - on Freegle, they nearly always do.", + 'heading' => 'The stuff you think nobody wants', + 'intro' => "Today's tip is the one that surprises people most.", + 'body' => [ + "Have a look in that drawer you never open, the back of the cupboard, the shed. The half-tin of paint, the odd cables, the jam jars, the fabric offcuts, the clothes the kids grew out of.", + "It's tempting to think \"nobody would want this\". On Freegle, they nearly always do. The things sitting neglected are exactly what someone nearby is hunting for - and it's genuinely surprising what gets claimed within the hour.", + ], + 'highlight' => [ + 'title' => 'Often snapped up in no time', + 'items' => [ + 'Half-used craft, hobby and DIY bits', + 'Spare cables, chargers, pots and jars', + 'Curtains, offcuts and odd balls of wool', + "Kids' clothes and toys they've outgrown", + ], + ], + 'ctaLabel' => 'Offer something', + 'ctaUrl' => $base['giveUrl'], + ], + 3 => [ + 'preheader' => "Freegle isn't only for giving - you can ask for what you need, too.", + 'heading' => 'Need something? Just ask', + 'intro' => "Freegle isn't only for giving - it's for getting, too.", + 'body' => [ + "If there's something you're after, post a wanted and let your neighbours know. People love helping out, and it saves usable things from heading to landfill.", + ], + 'highlight' => [ + 'title' => 'What makes a good wanted', + 'items' => [ + "Say clearly what you're after.", + 'Add a line of why - it helps someone think "I\'ve got just the thing".', + "Stay open on brand and condition - you'll get more offers.", + ], + ], + 'ctaLabel' => 'Post a wanted', + 'ctaUrl' => $base['findUrl'], + ], + 4 => [ + 'preheader' => 'You can search for things that are already being offered nearby.', + 'heading' => 'You can search for things', + 'intro' => "Waiting and hoping isn't the only option.", + 'body' => [ + "Freegle has a search. Type in what you need - a desk, a travel cot, a particular book - and see what's already been offered near you right now.", + "New things are posted all the time, so it's worth a look, and worth saving a search so the right one doesn't pass you by.", + ], + 'ctaLabel' => 'Search Freegle', + 'ctaUrl' => $base['browseUrl'], + ], + 5 => [ + 'preheader' => "That's the lot - you're a freegler now.", + 'heading' => "You're a freegler now", + 'intro' => "That's the lot - you've got the hang of it.", + 'body' => [ + "A quick recap: offer the things you're not using, ask for what you need, and search for anything specific. Then keep an eye on what's popping up nearby.", + "One last thing. Freegle works because of neighbours being kind to each other - reply promptly, arrange an easy doorstep pickup, and say thanks. That's really all there is to it.", + ], + 'highlight' => [ + 'title' => 'Your Freegle in a nutshell', + 'items' => [ + 'Offer the things you no longer need', + 'Ask for the things you do', + 'Search for anything specific', + 'Be a good neighbour', + ], + ], + 'ctaLabel' => 'See what\'s near you', + 'ctaUrl' => $base['browseUrl'], + ], + ]; + + return array_merge($tips[$day], ['day' => $day, 'totalDays' => $total]); + } + + /** + * Find a real local volunteer to sign the tip off. Mirrors BirthdayService's + * "local volunteer" rule so the two stay consistent: + * - only communities the member GENUINELY joined (collection Approved, + * rippled = 0 - never a Rippling-Out auto-join); + * - nearest to the member first (haversine on the group centroid), so the + * sign-off feels local rather than from the far side of the country; + * - the volunteer must be an active (accessed within a year), publicly + * shown (users.settings.showmod, default true) Moderator/Owner. + * + * Returns null when there's no such volunteer, so the caller falls back to + * the plain Freegle voice. + * + * @return array{name: string, group: string}|null + */ + public function resolveVolunteer(User $user): ?array + { + [$lat, $lng] = $this->resolveLatLng($user); + + $groupsQuery = DB::table('memberships') + ->join('groups', 'groups.id', '=', 'memberships.groupid') + ->where('memberships.userid', $user->id) + ->where('memberships.collection', Membership::COLLECTION_APPROVED) + ->where('memberships.rippled', 0) + ->where('groups.type', Group::TYPE_FREEGLE) + ->select('groups.id', 'groups.nameshort', 'groups.namefull'); + + if ($lat !== null && $lng !== null) { + // Nearest genuine community first; groups with no centroid sort last. + $groupsQuery->orderByRaw( + 'groups.lat IS NULL, haversine(?, ?, groups.lat, groups.lng) ASC', + [$lat, $lng] + ); + } + + $groups = $groupsQuery->limit(8)->get(); + $oneYearAgo = now()->subYear()->toDateTimeString(); + + foreach ($groups as $group) { + $mod = DB::table('memberships') + ->join('users', 'users.id', '=', 'memberships.userid') + ->where('memberships.groupid', $group->id) + ->where('memberships.collection', Membership::COLLECTION_APPROVED) + ->whereIn('memberships.role', ['Moderator', 'Owner']) + ->whereNull('users.deleted') + ->where('users.lastaccess', '>=', $oneYearAgo) + ->whereRaw("(JSON_EXTRACT(users.settings, '$.showmod') IS NULL OR JSON_EXTRACT(users.settings, '$.showmod') = TRUE)") + ->inRandomOrder() + ->select('users.fullname', 'users.firstname') + ->first(); + + if ($mod) { + $display = trim((string) ($mod->fullname ?: $mod->firstname ?: 'Volunteer')); + $first = $display !== '' ? explode(' ', $display)[0] : 'Volunteer'; + + return [ + 'name' => $first, + 'group' => (string) ($group->namefull ?: $group->nameshort), + ]; + } + } + + return null; + } + + /** + * Lat/lng waterfall matching NewsfeedDigestService::resolveLatLng: the saved + * 'mylocation' setting first, then the user's lastlocation. + * + * @return array{0: float|null, 1: float|null} + */ + private function resolveLatLng(User $user): array + { + $settings = $user->settings ?? []; + if (isset($settings['mylocation']['lat'], $settings['mylocation']['lng'])) { + return [(float) $settings['mylocation']['lat'], (float) $settings['mylocation']['lng']]; + } + + return $user->getLatLng(); + } + + private function firstName(User $user): string + { + $first = $user->firstname ?: null; + if ($first) { + return $first; + } + + $display = trim((string) $user->display_name); + + return $display !== '' ? explode(' ', $display)[0] : 'there'; + } +} diff --git a/iznik-batch/app/Services/ReengageService.php b/iznik-batch/app/Services/ReengageService.php new file mode 100644 index 0000000000..144f36e916 --- /dev/null +++ b/iznik-batch/app/Services/ReengageService.php @@ -0,0 +1,468 @@ += start_day old and + * while it is young enough to still be in (or start) the sequence; + * - tip N is sent when the account reaches (start_day + (N-1)*gap) days old, + * one tip per day, up to `stages` tips; + * - a fresh member gets tip 1 only if the account is <= max_start_days old, so + * first enabling the feature can't back-blast everyone who joined last month. + * + * Excluded: TrashNothing and LoveJunk proxy accounts (they onboard through their + * own platform), plus the usual bounce / holiday / marketing-opt-out gates. + * + * Personalised: every tip is signed off by a real local volunteer from a + * community the member genuinely joined (see ReengageContentService), or the + * plain Freegle voice when there's no such volunteer. + * + * Experiment/instrumentation layer (dark until rollout_pct > 0): each member + * gets a stable arm ('control'|'a'|'b') and a journey segment, recorded per send + * so tip effectiveness (opens/clicks/actions) is measurable in the sysadmin + * dashboard, joined back via the email_tracking id. + * + * Dark by default: gated by BOTH the FREEGLE_MAIL_ENABLED_TYPES kill-switch + * (type "Reengage") and FREEGLE_REENGAGE_ALLOWLIST (empty = nobody). + */ +class ReengageService +{ + use FeatureFlags; + + public const EMAIL_TYPE = 'Reengage'; + + /** Number of tips in the onboarding sequence (day 1..N). */ + public const TIPS = 5; + + /** + * Stage (day) number → arm → template basename. Every day shares the one + * `tip` template, whose per-day copy comes from ReengageContentService. The + * 'a'/'b' arms exist for future copy experiments; today both render `tip`. + */ + private const STAGE_TEMPLATE = [ + 1 => ['a' => 'tip', 'b' => 'tip'], + 2 => ['a' => 'tip', 'b' => 'tip'], + 3 => ['a' => 'tip', 'b' => 'tip'], + 4 => ['a' => 'tip', 'b' => 'tip'], + 5 => ['a' => 'tip', 'b' => 'tip'], + ]; + + public function __construct( + private readonly ReengageContentService $content = new ReengageContentService(), + ) { + } + + /** + * Process the whole candidate cohort of new members. + * + * @return array{sent: int, completed: int, control: int, skipped: int} + */ + public function processReengageEmails(bool $dryRun = false): array + { + $counts = ['sent' => 0, 'completed' => 0, 'control' => 0, 'skipped' => 0]; + + $allowlist = $this->allowlist(); + + // Dark unless an operator has both enabled the type and opted at least + // one recipient in. Keeps the feature inert until deliberately rolled out. + if ($allowlist === [] || ! static::isEmailTypeEnabled(self::EMAIL_TYPE)) { + return $counts; + } + + $startDay = (int) config('freegle.reengage.start_day', 1); + $gapDays = max(1, (int) config('freegle.reengage.stage_gap_days', 1)); + $maxStartDays = (int) config('freegle.reengage.max_start_days', 7); + + // The window of account ages that can still be in the sequence: young + // enough to have started (>= start_day) up to old enough to be finishing + // the last tip (or a late first-start capped by max_start_days). + $windowDays = $maxStartDays + (self::TIPS - 1) * $gapDays; + $oldest = now()->subDays($windowDays)->toDateTimeString(); + $newest = now()->subDays($startDay)->toDateTimeString(); + + $candidateIds = DB::table('users') + ->whereNull('deleted') + ->where('bouncing', 0) + // Honour the marketing opt-out, exactly as the sibling flows do. + ->where('relevantallowed', 1) + // New members only: keyed off account creation date. + ->whereBetween('added', [$oldest, $newest]) + // TrashNothing / LoveJunk proxy accounts onboard through their own + // platform - never send them Freegle onboarding tips. (Emails live in + // users_emails, not users.email, so the domain case for a TN account + // without tnuserid set is caught per-user by isTN() in processUser.) + ->whereNull('tnuserid') + ->whereNull('ljuserid') + ->where(function ($q) { + $q->whereNull('onholidaytill')->orWhere('onholidaytill', '<', now()); + }) + ->where(function ($q) { + $q->whereRaw("JSON_EXTRACT(users.settings, '$.simplemail') IS NULL") + ->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(users.settings, '$.simplemail')) != ?", [User::SIMPLE_MAIL_NONE]); + }) + ->select('id') + ->orderBy('id') + ->lazyById(1000) + ->pluck('id'); + + $lowerAllowlist = $allowlist === ['*'] ? ['*'] : array_map('strtolower', $allowlist); + + foreach ($candidateIds as $userId) { + $result = $this->processUser((int) $userId, $lowerAllowlist, $startDay, $gapDays, $maxStartDays, $dryRun); + if ($result !== null && isset($counts[$result])) { + $counts[$result]++; + } + } + + return $counts; + } + + /** + * Decide and (unless dry-run) send the next due tip for one new member. + * + * @return string|null 'sent'|'completed'|'control'|'skipped', or null if not a candidate. + */ + private function processUser(int $userId, array $lowerAllowlist, int $startDay, int $gapDays, int $maxStartDays, bool $dryRun): ?string + { + $user = User::find($userId); + if (! $user) { + return null; + } + + $email = $user->email_preferred; + if (! $email) { + return null; + } + + if ($lowerAllowlist !== ['*'] && ! in_array(strtolower($email), $lowerAllowlist, true)) { + return null; + } + + // Belt-and-braces: skip TN/LJ even if they slipped through the SQL gate + // (e.g. a domain the query didn't catch). + if ($user->isTN() || $user->isLJ()) { + return null; + } + + // Must have genuinely joined a real Freegle group (approved member). + $hasMembership = DB::table('memberships') + ->join('groups', 'groups.id', '=', 'memberships.groupid') + ->where('memberships.userid', $userId) + ->where('memberships.collection', Membership::COLLECTION_APPROVED) + ->where('groups.type', Group::TYPE_FREEGLE) + ->exists(); + + if (! $hasMembership) { + return null; + } + + // Respect the per-group engagement opt-out (same key the engage flow uses). + $engagementEnabled = DB::table('memberships') + ->join('groups', 'groups.id', '=', 'memberships.groupid') + ->where('memberships.userid', $userId) + ->where('memberships.collection', Membership::COLLECTION_APPROVED) + ->where('groups.type', Group::TYPE_FREEGLE) + ->selectRaw('MAX(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(memberships.settings, "$.engagement")), 1)) AS enabled') + ->value('enabled'); + + if ($engagementEnabled === '0' || $engagementEnabled === 0) { + return null; + } + + // How far through the sequence this member is: one reengage row per tip. + $rows = DB::table('reengage') + ->where('userid', $userId) + ->orderBy('sentat') + ->get(); + + $stagesSent = $rows->count(); + + // Sequence already ran its course → record the terminal state once. + if ($stagesSent >= self::TIPS) { + $latest = $rows->last(); + if ($latest && $latest->outcome === null && ! $dryRun) { + DB::table('reengage')->where('id', $latest->id)->update(['outcome' => 'Suppressed']); + } + + return 'completed'; + } + + $stage = $stagesSent + 1; + $ageDays = $user->added ? now()->diffInDays($user->added, false) * -1 : 0; + + // Not yet due for this tip (account too young for its day threshold). + $requiredAge = $startDay + ($stage - 1) * $gapDays; + if ($ageDays < $requiredAge) { + return 'skipped'; + } + + // Don't start the sequence for an account that's already too old. + if ($stage === 1 && $ageDays > $maxStartDays) { + return 'skipped'; + } + + // Never send two tips the same day. + $lastSentAt = $rows->max('sentat'); + if ($lastSentAt && now()->diffInDays($lastSentAt, false) > -$gapDays) { + return 'skipped'; + } + + // Stable experiment assignment + journey segment, recorded on every row. + [$experiment, $arm, $bucket] = $this->assignArm($userId); + $segment = $this->journeySegment($userId); + + // Control arm: record the send position but mail nothing (holdout). + if ($arm === 'control') { + if (! $dryRun) { + DB::table('reengage')->insert([ + 'userid' => $userId, + 'stage' => $stage, + 'template' => null, + 'experiment' => $experiment, + 'arm' => 'control', + 'bucket' => $bucket, + 'segment' => $segment, + 'sentat' => now(), + ]); + } + + return 'control'; + } + + $template = self::STAGE_TEMPLATE[$stage][$arm] ?? self::STAGE_TEMPLATE[$stage]['a']; + + $content = $this->content->buildContent($user, $stage); + $content['arm'] = $arm; + $content['segment'] = $segment; + $subject = $this->subjectFor($stage, $content, $arm, $segment); + + if (! $dryRun) { + $mail = new ReengageMail( + recipientName: $user->display_name, + recipientEmail: $email, + emailSubject: $subject, + template: $template, + userId: $userId, + content: $content, + ); + + $trackingId = $mail->getTrackingId(); + + app(EmailSpoolerService::class)->spool($mail, $email, 'reengage'); + + DB::table('reengage')->insert([ + 'userid' => $userId, + 'stage' => $stage, + 'template' => $template, + 'experiment' => $experiment, + 'arm' => $arm, + 'bucket' => $bucket, + 'segment' => $segment, + 'email_tracking_id' => $trackingId, + 'sentat' => now(), + ]); + } + + return 'sent'; + } + + /** + * Resolve the stable experiment arm for a user. Two independent gates, both + * dark by default: rollout_pct decides whether the user is in the experiment + * at all (outside it they get arm 'a', no holdout); within it a second stable + * bucket picks control/a/b. + * + * @return array{0: string, 1: string, 2: int} [experimentName, arm, bucket] + */ + private function assignArm(int $userId): array + { + $name = (string) config('freegle.reengage.experiment.name', 'reengage-v1'); + $rolloutPct = (int) config('freegle.reengage.experiment.rollout_pct', 0); + $arms = (array) config('freegle.reengage.experiment.arms', []); + + if ($rolloutPct <= 0 || $arms === []) { + return ['', 'a', ExperimentBucket::bucket($userId, $name)]; + } + + $inBucket = ExperimentBucket::bucket($userId, $name); + if ($inBucket >= $rolloutPct) { + return ['', 'a', $inBucket]; + } + + $resolved = ExperimentBucket::resolveArm($userId, $name . ':arm', $arms); + $arm = $resolved['arm'] !== '' ? $resolved['arm'] : 'a'; + + return [$name, $arm, $resolved['bucket']]; + } + + /** + * Classify the member by their FIRST action on Freegle (to cut effectiveness + * by journey, and available to tailor copy later). Cheap indexed lookups. + * - first post was an Offer → 'offer' + * - first post was a Wanted → 'wanted' + * - first action was a chat reply (no earlier post) → 'replier' + * - nothing yet (common for brand-new members) → 'other' + */ + public function journeySegment(int $userId): string + { + $firstPost = DB::table('messages') + ->where('fromuser', $userId) + ->whereIn('type', ['Offer', 'Wanted']) + ->whereNull('deleted') + ->orderBy('arrival') + ->limit(1) + ->first(['type', 'arrival']); + + $firstChatAt = DB::table('chat_messages') + ->where('userid', $userId) + ->where('type', 'Default') + ->min('date'); + + if ($firstPost && (! $firstChatAt || $firstPost->arrival <= $firstChatAt)) { + return $firstPost->type === 'Offer' ? 'offer' : 'wanted'; + } + + if ($firstChatAt !== null) { + return 'replier'; + } + + return 'other'; + } + + /** + * Detect and record whether a tip drove a real ACTION (login/reply/post) + * within the outcome window, for any reengage row (treatment OR control + * holdout). Writes reengaged_at / reengaged_via and outcome='Reengaged'. + * Idempotent. This is what makes per-tip/arm/segment effectiveness (and the + * control lift) measurable. + * + * @return array{checked: int, reengaged: int} + */ + public function updateOutcomes(?int $windowDays = null): array + { + $windowDays = $windowDays ?? (int) config('freegle.reengage.experiment.outcome_window_days', 14); + $stats = ['checked' => 0, 'reengaged' => 0]; + + $rows = DB::table('reengage') + ->whereNull('reengaged_at') + ->where(function ($q) { + $q->whereNull('outcome')->orWhere('outcome', '!=', 'Reengaged'); + }) + ->where('sentat', '>=', now()->subDays($windowDays + 60)->toDateTimeString()) + ->select('id', 'userid', 'sentat') + ->orderBy('id') + ->lazyById(1000); + + foreach ($rows as $row) { + $stats['checked']++; + $windowEnd = \Illuminate\Support\Carbon::parse($row->sentat)->addDays($windowDays)->toDateTimeString(); + + $candidates = array_filter([ + 'login' => DB::table('logs')->where('user', $row->userid)->where('subtype', 'Login') + ->whereBetween('timestamp', [$row->sentat, $windowEnd])->min('timestamp'), + 'reply' => DB::table('logs')->where('user', $row->userid)->where('subtype', 'Replied') + ->whereBetween('timestamp', [$row->sentat, $windowEnd])->min('timestamp'), + 'post' => DB::table('messages')->where('fromuser', $row->userid) + ->whereIn('type', ['Offer', 'Wanted'])->whereNull('deleted') + ->whereBetween('arrival', [$row->sentat, $windowEnd])->min('arrival'), + ]); + + if ($candidates === []) { + continue; + } + + asort($candidates); + $via = (string) array_key_first($candidates); + + DB::table('reengage')->where('id', $row->id)->update([ + 'reengaged_at' => $candidates[$via], + 'reengaged_via' => $via, + 'outcome' => 'Reengaged', + ]); + $stats['reengaged']++; + } + + return $stats; + } + + /** + * Send every tip in the sequence to a single address with sample data, for + * visual review in mailpit. Bypasses the dark-ship gates (operator-triggered). + */ + public function sendPreview(string $email): int + { + $sent = 0; + + for ($day = 1; $day <= self::TIPS; $day++) { + $content = $this->content->previewContent($day, $email); + $subject = '[Preview] ' . $this->subjectFor($day, $content, 'a', 'other'); + + Mail::to($email)->send(new ReengageMail( + recipientName: $content['name'] ?? 'there', + recipientEmail: $email, + emailSubject: $subject, + template: self::STAGE_TEMPLATE[$day]['a'], + userId: 0, + content: $content, + )); + + $sent++; + } + + return $sent; + } + + /** + * Subject line for a given day's tip. Mirrors the tip heading so the inbox + * and the email agree. + */ + public function subjectFor(int $stage, array $c, string $arm = 'a', string $segment = 'other'): string + { + return match ($stage) { + 1 => 'Welcome to Freegle - your first tip', + 2 => 'The stuff you think nobody wants', + 3 => 'Need something? Just ask', + 4 => 'Did you know you can search Freegle?', + 5 => "You're a freegler now 🎉", + default => 'Getting started on Freegle', + }; + } + + /** + * Parse FREEGLE_REENGAGE_ALLOWLIST (mirrors the digest daily allowlist): + * [] = nobody, ['*'] = everyone, else the opted-in addresses. + * + * @return array + */ + private function allowlist(): array + { + $raw = trim((string) config('freegle.reengage.allowlist', '')); + if ($raw === '') { + return []; + } + + $parts = array_values(array_filter(array_map('trim', explode(',', $raw)), fn ($s) => $s !== '')); + + if (in_array('*', $parts, true)) { + return ['*']; + } + + return $parts; + } +} diff --git a/iznik-batch/app/Support/ExperimentBucket.php b/iznik-batch/app/Support/ExperimentBucket.php new file mode 100644 index 0000000000..15f2809376 --- /dev/null +++ b/iznik-batch/app/Support/ExperimentBucket.php @@ -0,0 +1,54 @@ + ['from' => 0, 'to' => 19], 'a' => ['from' => 20, 'to' => 59], ...] + * where from/to are inclusive percentile bounds over 0..99. + * + * @param array $arms + * @return array{arm: string, bucket: int} + */ + public static function resolveArm(int $userId, string $experiment, array $arms): array + { + $bucket = self::bucket($userId, $experiment, 100); + + foreach ($arms as $arm => $range) { + $from = (int) ($range['from'] ?? 0); + $to = (int) ($range['to'] ?? -1); + if ($bucket >= $from && $bucket <= $to) { + return ['arm' => (string) $arm, 'bucket' => $bucket]; + } + } + + // Bucket fell outside every configured range (arms don't cover 0-99). + // Fail safe: no arm (caller treats as "not in experiment"). + return ['arm' => '', 'bucket' => $bucket]; + } +} diff --git a/iznik-batch/config/freegle.php b/iznik-batch/config/freegle.php index e89fd047d7..5d6a67626a 100644 --- a/iznik-batch/config/freegle.php +++ b/iznik-batch/config/freegle.php @@ -158,6 +158,53 @@ 'zip_url' => env('DOOGAL_ZIP_URL', 'https://www.doogal.co.uk/files/postcodes.zip'), ], + // First-week ONBOARDING tip sequence (mail:reengage). One short tip a day + // for a new member's first five days, after the welcome mail. Kept under the + // "reengage" key/table/dashboard name; the trigger and content are onboarding. + 'reengage' => [ + // Dark-ship gate (mirrors FREEGLE_DIGEST_DAILY_ALLOWLIST): + // '' → send to nobody (default — feature is dark) + // '*' → send to everyone eligible + // 'a@x,b@y' → only these recipient addresses (rollout pilot) + 'allowlist' => env('FREEGLE_REENGAGE_ALLOWLIST', ''), + // Day the FIRST tip lands (account age in days). 1 leaves day 0 for the + // welcome mail, so tip 1 arrives the day after joining. + 'start_day' => (int) env('FREEGLE_ONBOARD_START_DAY', 1), + // Gap between tips — one a day. Tip N lands on day start_day+(N-1)*gap, + // i.e. days 1..5 with the defaults. + 'stage_gap_days' => (int) env('FREEGLE_ONBOARD_STAGE_GAP_DAYS', 1), + // Never START the sequence for an account already older than this. Stops + // a first enable from back-blasting everyone who joined recently; a member + // mid-sequence keeps getting the rest regardless. + 'max_start_days' => (int) env('FREEGLE_ONBOARD_MAX_START_DAYS', 7), + + // A/B experiment layer. Stays inert by default: rollout_pct=0 means + // nobody is in the experiment, everyone eligible falls through to the + // single current 'a' template with NO control holdout — i.e. exactly the + // pre-experiment behaviour. Ramp with FREEGLE_REENGAGE_EXPERIMENT_ROLLOUT_PCT + // (0 → 10 → 50 → 100) *on top of* the allowlist gate above, so two + // independent knobs must both be opened. + 'experiment' => [ + // Salts the deterministic per-user bucket; bump to reshuffle arms + // for a brand-new experiment (do NOT bump mid-experiment). + 'name' => env('FREEGLE_REENGAGE_EXPERIMENT', 'reengage-v1'), + // Fraction of eligible users pulled into the experiment (0-100). + // Those outside it get the default 'a' arm with no holdout. + 'rollout_pct' => (int) env('FREEGLE_REENGAGE_EXPERIMENT_ROLLOUT_PCT', 0), + // Arm split over the 0-99 bucket space (inclusive, must tile 0-99). + // 'control' is a REAL holdout: recorded but never mailed, so lift is + // measurable. 'a' = current copy, 'b' = alternate copy variant. + 'arms' => [ + 'control' => ['from' => 0, 'to' => 19], // 20% holdout + 'a' => ['from' => 20, 'to' => 59], // 40% current copy + 'b' => ['from' => 60, 'to' => 99], // 40% alternate copy + ], + // Window (days after a send) in which a login/reply/post counts as + // re-engagement caused by the mail, written by mail:reengage-outcomes. + 'outcome_window_days' => (int) env('FREEGLE_REENGAGE_OUTCOME_WINDOW_DAYS', 14), + ], + ], + 'digest' => [ // Safety gate for the unified-digest IMMEDIATE mode. // '' or '*' → allow all users (the normal production state) diff --git a/iznik-batch/database/migrations/2026_06_14_120000_create_reengage_table.php b/iznik-batch/database/migrations/2026_06_14_120000_create_reengage_table.php new file mode 100644 index 0000000000..ad76021662 --- /dev/null +++ b/iznik-batch/database/migrations/2026_06_14_120000_create_reengage_table.php @@ -0,0 +1,45 @@ +comment('First-week onboarding tip sends (one row per tip, day 1-5)'); + $table->bigIncrements('id'); + $table->unsignedBigInteger('userid')->index('userid'); + // The tip/day number (1..5) in the onboarding sequence. + $table->unsignedTinyInteger('stage'); + $table->string('template', 32)->nullable(); + $table->timestamp('sentat')->useCurrent()->index('sentat'); + // Terminal/analytics state: 'Reengaged' once a tip drove a real action + // (login/reply/post) in the outcome window; 'Suppressed' once the + // sequence completes with no such action. + $table->enum('outcome', ['Reengaged', 'Suppressed'])->nullable(); + + $table->index(['userid', 'sentat'], 'userid_sentat'); + }); + } + + public function down(): void + { + Schema::dropIfExists('reengage'); + } +}; diff --git a/iznik-batch/database/migrations/2026_07_12_000000_add_experiment_and_tracking_to_reengage_table.php b/iznik-batch/database/migrations/2026_07_12_000000_add_experiment_and_tracking_to_reengage_table.php new file mode 100644 index 0000000000..98288732b2 --- /dev/null +++ b/iznik-batch/database/migrations/2026_07_12_000000_add_experiment_and_tracking_to_reengage_table.php @@ -0,0 +1,101 @@ +string('experiment', 32)->nullable()->after('template'); + } + if (! Schema::hasColumn('reengage', 'arm')) { + // 'control' (held out, no mail) | 'a' | 'b' | ... + $table->string('arm', 16)->nullable()->after('experiment'); + } + if (! Schema::hasColumn('reengage', 'bucket')) { + // Raw deterministic bucket 0-99, kept for audit/debugging. + $table->unsignedTinyInteger('bucket')->nullable()->after('arm'); + } + // User-journey segment captured AT SEND time (users.* mutates later). + if (! Schema::hasColumn('reengage', 'segment')) { + // 'offer' | 'wanted' | 'replier' | 'other' + $table->string('segment', 16)->nullable()->after('bucket'); + } + // Join key into the shared email_tracking table for opens/clicks. + if (! Schema::hasColumn('reengage', 'email_tracking_id')) { + $table->unsignedBigInteger('email_tracking_id')->nullable()->after('segment'); + } + // Actual re-engagement outcome — written by mail:reengage-outcomes from + // the precise `logs`/`messages` event trail (not the mutable lastaccess). + if (! Schema::hasColumn('reengage', 'reengaged_at')) { + $table->timestamp('reengaged_at')->nullable()->after('outcome'); + } + if (! Schema::hasColumn('reengage', 'reengaged_via')) { + // 'login' | 'reply' | 'post' + $table->string('reengaged_via', 16)->nullable()->after('reengaged_at'); + } + }); + + // Indexes (guarded via information_schema — MySQL has no IF NOT EXISTS + // for ADD INDEX pre-8.0.29, and Laravel 11 dropped the Doctrine schema + // manager we'd otherwise use to introspect them). + $hasIndex = static function (string $index): bool { + return DB::table('information_schema.statistics') + ->whereRaw('table_schema = DATABASE()') + ->where('table_name', 'reengage') + ->where('index_name', $index) + ->exists(); + }; + + Schema::table('reengage', function (Blueprint $table) use ($hasIndex) { + if (! $hasIndex('experiment_arm_segment')) { + $table->index(['experiment', 'arm', 'segment'], 'experiment_arm_segment'); + } + if (! $hasIndex('reengage_email_tracking_id')) { + $table->index('email_tracking_id', 'reengage_email_tracking_id'); + } + }); + } + + public function down(): void + { + if (! Schema::hasTable('reengage')) { + return; + } + + Schema::table('reengage', function (Blueprint $table) { + foreach (['experiment_arm_segment', 'reengage_email_tracking_id'] as $idx) { + try { + $table->dropIndex($idx); + } catch (\Throwable $e) { + // best effort + } + } + foreach (['experiment', 'arm', 'bucket', 'segment', 'email_tracking_id', 'reengaged_at', 'reengaged_via'] as $col) { + if (Schema::hasColumn('reengage', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/iznik-batch/resources/views/emails/mjml/reengage/tip.blade.php b/iznik-batch/resources/views/emails/mjml/reengage/tip.blade.php new file mode 100644 index 0000000000..4244c8e637 --- /dev/null +++ b/iznik-batch/resources/views/emails/mjml/reengage/tip.blade.php @@ -0,0 +1,105 @@ +{{-- + First-week onboarding — one tip a day (day {{ $day ?? 1 }} of {{ $totalDays ?? 5 }}). + Warm, short, one clear call to action, signed off by a real local volunteer + where we have one. Shared template driven by ReengageContentService::tip(). +--}} + + @include('emails.mjml.partials.head', ['preview' => $preheader ?? '']) + + + {{-- Header band: logo + "Getting started · Day N of 5" --}} + + + + Getting started · Day {{ $day }} of {{ $totalDays }} + + + {{ $heading }} + + + + + + + + {{-- Progress dots --}} + + + + @for($i = 1; $i <= $totalDays; $i++){!! $i <= $day ? '' : '•' !!}@endfor + + + + + {{-- Intro + body --}} + + + + Hi {{ $name }}, + + @if(!empty($intro)) + + {{ $intro }} + + @endif + @foreach(($body ?? []) as $para) + + {!! $para !!} + + @endforeach + + + + {{-- Optional highlight box --}} + @if(!empty($highlight)) + + + + {{ $highlight['title'] }} + + @foreach($highlight['items'] as $item) + +   {!! $item !!} + + @endforeach + + + @endif + + {{-- Primary CTA --}} + + + + {{ $ctaLabel }} + + + + + {{-- Volunteer sign-off (real local volunteer, or the Freegle team) --}} + + + @if(!empty($volunteerName)) + + Happy freegling,
+ {{ $volunteerName }}
+ Your local Freegle volunteer{{ !empty($volunteerGroup) ? ', ' . $volunteerGroup : '' }} +
+ @else + + Happy freegling,
+ The Freegle team +
+ @endif +
+
+ + @if(!empty($trackingPixelMjml)){!! $trackingPixelMjml !!}@endif + + @include('emails.mjml.partials.footer', [ + 'email' => $email, + 'settingsUrl' => $settingsUrl, + 'unsubscribeUrl' => $unsubscribeUrl, + ]) + +
+
diff --git a/iznik-batch/resources/views/emails/text/reengage/tip.blade.php b/iznik-batch/resources/views/emails/text/reengage/tip.blade.php new file mode 100644 index 0000000000..b6070a9b34 --- /dev/null +++ b/iznik-batch/resources/views/emails/text/reengage/tip.blade.php @@ -0,0 +1,30 @@ +Hi {!! $name !!}, + +Getting started on Freegle - tip {{ $day }} of {{ $totalDays }}: {!! $heading !!} + +@if(!empty($intro)){!! $intro !!} + +@endif +@foreach(($body ?? []) as $para){!! strip_tags($para) !!} + +@endforeach +@if(!empty($highlight)){!! $highlight['title'] !!}: +@foreach($highlight['items'] as $item)- {!! strip_tags($item) !!} +@endforeach + +@endif +{!! $ctaLabel !!}: {!! $ctaUrl !!} + +@if(!empty($volunteerName))Happy freegling, +{!! $volunteerName !!} +Your local Freegle volunteer{{ !empty($volunteerGroup) ? ', ' . $volunteerGroup : '' }} +@else +Happy freegling, +The Freegle team +@endif + +— +This email was sent to {!! $email !!}. +Change your email settings: {!! $settingsUrl !!} +Unsubscribe: {!! $unsubscribeUrl !!} +{{ config('freegle.branding.name') }} is a charity run by volunteers. diff --git a/iznik-batch/routes/console.php b/iznik-batch/routes/console.php index 883deae89d..ffbb5252a4 100644 --- a/iznik-batch/routes/console.php +++ b/iznik-batch/routes/console.php @@ -741,6 +741,27 @@ function cronLog(string $command): string ->sendOutputTo(cronLog('mail:engage')) ->runInBackground(); +// First-week onboarding tip sequence for new members: one short tip a day for +// the first five days, after the welcome mail. Dark by default: inert unless +// FREEGLE_REENGAGE_ALLOWLIST is set AND "Reengage" is in +// FREEGLE_MAIL_ENABLED_TYPES. Runs daily so members get the next due tip as they +// cross each day threshold; one-a-day spacing is enforced in the service, and +// TrashNothing/LoveJunk accounts are excluded there. +Schedule::command('mail:reengage') + ->dailyAt('15:30') + ->withoutOverlapping(60) + ->sendOutputTo(cronLog('mail:reengage')) + ->runInBackground(); + +// Record whether a tip drove a real action (login/reply/post within the window) +// for onboarding sends, so per-tip/arm/segment effectiveness and control-arm +// lift can be graphed in the sysadmin dashboard. Runs after the day's sends. +Schedule::command('mail:reengage-outcomes') + ->dailyAt('16:30') + ->withoutOverlapping(60) + ->sendOutputTo(cronLog('mail:reengage-outcomes')) + ->runInBackground(); + // Ask eligible users with outcomes/offers to share their Freegle story. // V1: cron/stories.php (weekly Saturday 11:00) Schedule::command('stories:ask') diff --git a/iznik-batch/tests/Feature/Mail/ReengageEmailsCommandTest.php b/iznik-batch/tests/Feature/Mail/ReengageEmailsCommandTest.php new file mode 100644 index 0000000000..72f72b8f62 --- /dev/null +++ b/iznik-batch/tests/Feature/Mail/ReengageEmailsCommandTest.php @@ -0,0 +1,276 @@ +setUpIsolatedSpoolDirectory(); + Mail::fake(); + } + + protected function tearDown(): void + { + $this->tearDownIsolatedSpoolDirectory(); + parent::tearDown(); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private function enable(string $allowlist = '*'): void + { + config([ + 'freegle.reengage.allowlist' => $allowlist, + 'freegle.mail.enabled_types' => 'Reengage', + 'freegle.reengage.start_day' => 1, + 'freegle.reengage.stage_gap_days' => 1, + 'freegle.reengage.max_start_days' => 7, + ]); + } + + private function createFreegleGroup(): Group + { + return Group::create([ + 'nameshort' => 'TestOnb_' . uniqid(), + 'type' => Group::TYPE_FREEGLE, + 'publish' => 1, + 'onmap' => 1, + 'onhere' => 1, + 'lat' => 51.5074, + 'lng' => -0.1278, + ]); + } + + /** + * A new member: joined a Freegle group, account created $ageDays ago, active. + */ + private function createNewMember(int $ageDays = 2, array $attrs = []): object + { + $user = $this->createTestUser($attrs); + $group = $this->createFreegleGroup(); + DB::table('memberships')->insertOrIgnore([ + 'userid' => $user->id, + 'groupid' => $group->id, + 'collection' => Membership::COLLECTION_APPROVED, + 'role' => Membership::ROLE_MEMBER, + 'added' => now()->subDays($ageDays), + ]); + DB::table('users')->where('id', $user->id)->update([ + 'added' => now()->subDays($ageDays), + 'lastaccess' => now(), + 'relevantallowed' => 1, + ]); + + return $user->fresh(); + } + + private function insertTip(int $userId, int $stage, $sentat): void + { + DB::table('reengage')->insert([ + 'userid' => $userId, + 'stage' => $stage, + 'template' => 'tip', + 'sentat' => $sentat, + ]); + } + + // ── Dark-ship gates ────────────────────────────────────────────────────── + + public function test_dark_when_allowlist_empty(): void + { + $this->createNewMember(2); + $this->enable(''); + config(['freegle.mail.enabled_types' => 'Reengage']); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + Mail::assertNothingSent(); + } + + public function test_dark_when_type_not_enabled(): void + { + $this->createNewMember(2); + config(['freegle.reengage.allowlist' => '*', 'freegle.mail.enabled_types' => '']); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + public function test_skips_user_not_on_allowlist(): void + { + $this->createNewMember(2); + $this->enable('someoneelse@example.com'); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + // ── Tip 1 ──────────────────────────────────────────────────────────────── + + public function test_sends_first_tip_to_new_member(): void + { + $user = $this->createNewMember(2); + $this->enable('*'); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(1, $result['sent']); + $this->assertDatabaseHas('reengage', ['userid' => $user->id, 'stage' => 1, 'template' => 'tip']); + + $stats = app(EmailSpoolerService::class)->processSpool(); + $this->assertSame(1, $stats['sent']); + } + + public function test_does_not_send_to_account_too_young(): void + { + $this->createNewMember(0); // joined today — welcome mail's day + $this->enable('*'); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + public function test_does_not_start_sequence_for_account_too_old(): void + { + $this->createNewMember(10); // older than max_start_days, no prior tips + $this->enable('*'); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + public function test_excludes_trashnothing_member(): void + { + $user = $this->createNewMember(2); + DB::table('users')->where('id', $user->id)->update(['tnuserid' => 987654]); + $this->enable('*'); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + public function test_excludes_lovejunk_member(): void + { + $user = $this->createNewMember(2); + DB::table('users')->where('id', $user->id)->update(['ljuserid' => 987655]); + $this->enable('*'); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + public function test_skips_user_without_freegle_membership(): void + { + $user = $this->createTestUser(); + DB::table('users')->where('id', $user->id)->update([ + 'added' => now()->subDays(2), 'lastaccess' => now(), 'relevantallowed' => 1, + ]); + $this->enable('*'); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + public function test_skips_user_on_holiday(): void + { + $user = $this->createNewMember(2); + DB::table('users')->where('id', $user->id)->update(['onholidaytill' => now()->addDays(5)]); + $this->enable('*'); + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + // ── Daily cadence ───────────────────────────────────────────────────────── + + public function test_does_not_send_two_tips_same_day(): void + { + $user = $this->createNewMember(3); + $this->enable('*'); + $this->insertTip($user->id, 1, now()); // tip 1 already sent today + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(0, $result['sent']); + } + + public function test_advances_to_next_tip_the_following_day(): void + { + $user = $this->createNewMember(3); + $this->enable('*'); + $this->insertTip($user->id, 1, now()->subHours(25)); // tip 1 sent > a day ago + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(1, $result['sent']); + $this->assertDatabaseHas('reengage', ['userid' => $user->id, 'stage' => 2]); + } + + public function test_completes_after_five_tips(): void + { + $user = $this->createNewMember(7); + $this->enable('*'); + for ($s = 1; $s <= 5; $s++) { + $this->insertTip($user->id, $s, now()->subDays(6 - $s)); + } + + $result = (new ReengageService())->processReengageEmails(false); + + $this->assertSame(1, $result['completed']); + $this->assertSame(0, $result['sent']); + $this->assertDatabaseHas('reengage', ['userid' => $user->id, 'stage' => 5, 'outcome' => 'Suppressed']); + } + + // ── Dry run ────────────────────────────────────────────────────────────── + + public function test_dry_run_does_not_send_or_record(): void + { + $user = $this->createNewMember(2); + $this->enable('*'); + + $result = (new ReengageService())->processReengageEmails(true); + + $this->assertSame(1, $result['sent']); + $this->assertDatabaseMissing('reengage', ['userid' => $user->id]); + Mail::assertNothingSent(); + } + + // ── Command smoke ──────────────────────────────────────────────────────── + + public function test_command_runs(): void + { + $this->artisan('mail:reengage')->assertExitCode(0); + } + + public function test_command_dry_run_shows_prefix(): void + { + $this->artisan('mail:reengage', ['--dry-run' => true]) + ->expectsOutputToContain('[DRY RUN]') + ->assertExitCode(0); + } +} diff --git a/iznik-batch/tests/Unit/Mail/ReengageMailRenderTest.php b/iznik-batch/tests/Unit/Mail/ReengageMailRenderTest.php new file mode 100644 index 0000000000..b5962f31a3 --- /dev/null +++ b/iznik-batch/tests/Unit/Mail/ReengageMailRenderTest.php @@ -0,0 +1,143 @@ +previewContent($day, 'alex@example.com'); + } + + // ── Blade/MJML source rendering (cheap; no MJML compile) ───────────────── + + public function test_day1_sets_expectation_and_shows_good_offer_tip(): void + { + $html = view('emails.mjml.reengage.tip', $this->data(1))->render(); + + $this->assertStringContainsString('Welcome to Freegle', $html); + $this->assertStringContainsString('one short tip a day', $html); // expectation-setting + $this->assertStringContainsString('Day 1 of 5', $html); // progress + $this->assertStringContainsString('What makes a good offer', $html); + $this->assertStringContainsString('Offer something', $html); // CTA + $this->assertStringContainsString('alex@example.com', $html); // footer recipient + + // We don't tell people to state where they are: postcode-based posting + // fills in the area automatically, and personal details belong in chat, + // not a public post. + $this->assertStringNotContainsString('Roughly where you are', $html); + $this->assertStringNotContainsString('so people know if they can collect', $html); + } + + public function test_day2_challenges_the_nobody_wants_it_instinct(): void + { + $html = view('emails.mjml.reengage.tip', $this->data(2))->render(); + + $this->assertStringContainsString('nobody', $html); + $this->assertStringContainsString('cupboard', $html); + $this->assertStringContainsString('Offer something', $html); + } + + public function test_day3_encourages_a_wanted_post(): void + { + $html = view('emails.mjml.reengage.tip', $this->data(3))->render(); + + $this->assertStringContainsString('Post a wanted', $html); + $this->assertStringContainsString('/find', $html); // wanted CTA target + + // Same as the offer tip: no "state your location" advice - the area comes + // from the poster's postcode. + $this->assertStringNotContainsString('Roughly where you are', $html); + } + + public function test_day4_promotes_search(): void + { + $html = view('emails.mjml.reengage.tip', $this->data(4))->render(); + + $this->assertStringContainsString('search', $html); + $this->assertStringContainsString('Search Freegle', $html); + } + + public function test_day1_gives_safe_neighbourly_collection_guidance(): void + { + // The safety/etiquette message that hundreds of groups put in their own + // welcome mails - arrange privately in chat (not a public post), keep to a + // doorstep handover, use common sense - lands on day 1: a new member can be + // arranging their first pickup within the hour of their first offer, so it + // must not wait until the day-5 wrap-up. + $html = view('emails.mjml.reengage.tip', $this->data(1))->render(); + + $this->assertStringContainsString('doorstep handover', $html); + $this->assertStringContainsString('in Freegle chat rather than in a public post', $html); + $this->assertStringContainsString('common sense', $html); + } + + public function test_every_tip_carries_a_volunteer_signoff(): void + { + for ($day = 1; $day <= ReengageContentService::TIPS; $day++) { + $html = view('emails.mjml.reengage.tip', $this->data($day))->render(); + + $this->assertStringContainsString('Priya', $html, "day {$day} volunteer name"); + $this->assertStringContainsString('Your local Freegle volunteer', $html, "day {$day} sign-off"); + $this->assertStringContainsString('Edinburgh Freegle', $html, "day {$day} group"); + } + } + + public function test_signoff_falls_back_to_freegle_team_without_a_volunteer(): void + { + $data = array_merge($this->data(1), ['volunteerName' => null, 'volunteerGroup' => null]); + + $html = view('emails.mjml.reengage.tip', $data)->render(); + + $this->assertStringContainsString('The Freegle team', $html); + $this->assertStringNotContainsString('Your local Freegle volunteer', $html); + } + + public function test_template_escapes_user_supplied_content(): void + { + $data = array_merge($this->data(1), ['name' => '']); + + $html = view('emails.mjml.reengage.tip', $data)->render(); + + $this->assertStringContainsString('<script>', $html); + $this->assertStringNotContainsString('', $html); + } + + // ── Full MJML compile + Mailable wiring ────────────────────────────────── + + public function test_tip_mailable_compiles_to_html(): void + { + $mail = new ReengageMail('Alex', 'alex@example.com', 'Subject', 'tip', 0, $this->data(1)); + + $html = $mail->render(); + + $this->assertStringContainsString('Offer something', $html); + $this->assertStringContainsString('assertStringNotContainsString('', strtolower($html)); + } + + public function test_email_type_is_reengage(): void + { + $mail = new ReengageMail('Alex', 'alex@example.com', 'Subject', 'tip', 5, $this->data(1)); + + $this->assertSame('Reengage', $mail->getEmailType()); + } + + public function test_plain_text_alternative_renders(): void + { + $text = view('emails.text.reengage.tip', $this->data(1))->render(); + + $this->assertStringContainsString('Welcome to Freegle', $text); + $this->assertStringContainsString('Offer something', $text); + $this->assertStringContainsString('Your local Freegle volunteer', $text); + $this->assertStringNotContainsString('buildPostsArray($msgs); $payload = $this->pushService->buildDailyNewPostsPayload($user->id, $posts); - $this->assertSame('2 new freegles near you', $payload['title']); + $this->assertSame('2 new things near you', $payload['title']); $this->assertSame('2', $payload['count']); } @@ -158,7 +158,7 @@ public function test_seven_posts_lines_capped_at_five_and_more_count(): void $posts = $this->buildPostsArray($msgs); $payload = $this->pushService->buildDailyNewPostsPayload($user->id, $posts); - $this->assertSame('7 new freegles near you', $payload['title']); + $this->assertSame('7 new things near you', $payload['title']); $this->assertSame('7', $payload['count']); $lines = json_decode($payload['lines'], TRUE); diff --git a/iznik-batch/tests/Unit/Services/ReengageExperimentTest.php b/iznik-batch/tests/Unit/Services/ReengageExperimentTest.php new file mode 100644 index 0000000000..4dfc67d33a --- /dev/null +++ b/iznik-batch/tests/Unit/Services/ReengageExperimentTest.php @@ -0,0 +1,70 @@ +assertSame($a, $b, 'Same user+experiment must always yield the same bucket'); + $this->assertGreaterThanOrEqual(0, $a); + $this->assertLessThan(100, $a); + } + + public function test_bucket_is_salted_by_experiment_name(): void + { + // Different experiment salts should (almost always) decorrelate the same user. + $differ = 0; + for ($u = 1; $u <= 200; $u++) { + if (ExperimentBucket::bucket($u, 'exp-one') !== ExperimentBucket::bucket($u, 'exp-two')) { + $differ++; + } + } + $this->assertGreaterThan(150, $differ, 'Experiment salt should decorrelate arms across experiments'); + } + + public function test_arm_resolution_covers_configured_ranges(): void + { + $arms = [ + 'control' => ['from' => 0, 'to' => 19], + 'a' => ['from' => 20, 'to' => 59], + 'b' => ['from' => 60, 'to' => 99], + ]; + + $seen = []; + for ($u = 1; $u <= 3000; $u++) { + $r = ExperimentBucket::resolveArm($u, 'reengage-v1:arm', $arms); + $this->assertContains($r['arm'], ['control', 'a', 'b']); + $seen[$r['arm']] = ($seen[$r['arm']] ?? 0) + 1; + } + // All three arms should be populated, control ~half the size of a/b. + $this->assertArrayHasKey('control', $seen); + $this->assertArrayHasKey('a', $seen); + $this->assertArrayHasKey('b', $seen); + $this->assertGreaterThan(0, $seen['control']); + } + + public function test_subject_matches_each_tip_day(): void + { + $service = new ReengageService(); + + $this->assertStringContainsString('Welcome to Freegle', $service->subjectFor(1, [])); + $this->assertStringContainsString('nobody wants', $service->subjectFor(2, [])); + $this->assertStringContainsString('Just ask', $service->subjectFor(3, [])); + $this->assertStringContainsString('search Freegle', $service->subjectFor(4, [])); + $this->assertStringContainsString('freegler now', $service->subjectFor(5, [])); + } + + public function test_subjects_are_distinct_per_day(): void + { + $service = new ReengageService(); + $subjects = array_map(fn ($d) => $service->subjectFor($d, []), range(1, 5)); + $this->assertCount(5, array_unique($subjects), 'Each day should have its own subject line'); + } +} diff --git a/iznik-nuxt3/api/EmailTrackingAPI.js b/iznik-nuxt3/api/EmailTrackingAPI.js index b82758362b..a8fcd31a69 100644 --- a/iznik-nuxt3/api/EmailTrackingAPI.js +++ b/iznik-nuxt3/api/EmailTrackingAPI.js @@ -73,4 +73,15 @@ export default class EmailTrackingAPI extends BaseAPI { async fetchDigestPositions(params = {}) { return await this.$getv2('/modtools/email/stats/digestpositions', params) } + + /** + * Fetch reengagement effectiveness stats: send/open/click/reengage funnel, + * broken down by reengagement stage, experiment arm, and post segment. + * @param {Object} params - Query parameters + * @param {string} [params.start] - Start date (YYYY-MM-DD) + * @param {string} [params.end] - End date (YYYY-MM-DD) + */ + async fetchReengageEffectiveness(params = {}) { + return await this.$getv2('/modtools/email/stats/reengage', params) + } } diff --git a/iznik-nuxt3/modtools/components/ModStdMessageModal.vue b/iznik-nuxt3/modtools/components/ModStdMessageModal.vue index b18983740e..cda2e7fc11 100644 --- a/iznik-nuxt3/modtools/components/ModStdMessageModal.vue +++ b/iznik-nuxt3/modtools/components/ModStdMessageModal.vue @@ -67,7 +67,7 @@ and a keep/remove control per , filled in order so you never have to look back at the template. -->
-

+

Fill in the highlighted boxes and choose Keep or Remove for any optional parts. You can edit any of the rest of the wording too. This is the message the member will receive. diff --git a/iznik-nuxt3/modtools/components/ModSysAdminReengageEffectiveness.vue b/iznik-nuxt3/modtools/components/ModSysAdminReengageEffectiveness.vue new file mode 100644 index 0000000000..1d5af7d24c --- /dev/null +++ b/iznik-nuxt3/modtools/components/ModSysAdminReengageEffectiveness.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/iznik-nuxt3/modtools/pages/sysadmin/index.vue b/iznik-nuxt3/modtools/pages/sysadmin/index.vue index 73bf37dd14..7b94a92b97 100644 --- a/iznik-nuxt3/modtools/pages/sysadmin/index.vue +++ b/iznik-nuxt3/modtools/pages/sysadmin/index.vue @@ -109,6 +109,17 @@ :key="'recommendations-' + recommendationsBump" /> + + + + + +

@@ -142,6 +153,8 @@ const showRippling = ref(false) const ripplingBump = ref(0) const showRecommendations = ref(false) const recommendationsBump = ref(0) +const showReengage = ref(false) +const reengageBump = ref(0) const topTabMap = { housekeeping: 0, @@ -151,6 +164,7 @@ const topTabMap = { incoming: 4, rippling: 5, recommendations: 6, + reengagement: 7, } function onHousekeepingTab() { @@ -188,6 +202,11 @@ function onRecommendationsTab() { recommendationsBump.value = Date.now() } +function onReengageTab() { + showReengage.value = true + reengageBump.value = Date.now() +} + onMounted(() => { const tab = route.query.tab if (tab && topTabMap[tab] !== undefined) { @@ -200,6 +219,7 @@ onMounted(() => { else if (tab === 'incoming') onIncomingEmailTab() else if (tab === 'rippling') onRipplingTab() else if (tab === 'recommendations') onRecommendationsTab() + else if (tab === 'reengagement') onReengageTab() } else { // Default to showing housekeeping onHousekeepingTab() diff --git a/iznik-nuxt3/modtools/stores/emailtracking.js b/iznik-nuxt3/modtools/stores/emailtracking.js index 98e607108b..5d81692f1b 100644 --- a/iznik-nuxt3/modtools/stores/emailtracking.js +++ b/iznik-nuxt3/modtools/stores/emailtracking.js @@ -73,6 +73,12 @@ export const useEmailTrackingStore = defineStore({ digestPositions: [], digestPositionsLoading: false, digestPositionsError: null, + + // Reengagement effectiveness: send/open/click/reengage funnel, plus + // breakdowns by stage, experiment arm, and post segment. + reengageStats: null, + reengageLoading: false, + reengageError: null, }), actions: { init(config) { @@ -94,6 +100,8 @@ export const useEmailTrackingStore = defineStore({ this.aggregateClickedLinks = true this.digestPositions = [] this.digestPositionsError = null + this.reengageStats = null + this.reengageError = null this.userEmails = [] this.userEmailsTotal = 0 this.userEmailsError = null @@ -231,6 +239,32 @@ export const useEmailTrackingStore = defineStore({ } }, + async fetchReengageEffectiveness({ start, end } = {}) { + this.reengageLoading = true + this.reengageError = null + + try { + const params = {} + if (start) { + params.start = start + } + if (end) { + params.end = end + } + + const response = await api( + this.config + ).emailtracking.fetchReengageEffectiveness(params) + this.reengageStats = response || null + } catch (e) { + this.reengageError = + e.message || 'Failed to fetch reengagement effectiveness stats' + console.error('Reengagement effectiveness fetch error:', e) + } finally { + this.reengageLoading = false + } + }, + async fetchClickedLinks(showAll = false) { this.clickedLinksLoading = true this.clickedLinksError = null @@ -728,6 +762,8 @@ export const useEmailTrackingStore = defineStore({ hasDigestPositions: (state) => state.digestPositions.length > 0, + hasReengageStats: (state) => state.reengageStats !== null, + // Digest position data formatted for Google Charts (ComboChart). Bars show // the click-through rate at each position; the line shows the sample size // (how many digests displayed a post at that position) for context. diff --git a/iznik-server-go/emailtracking/emailtracking.go b/iznik-server-go/emailtracking/emailtracking.go index b2f11a7085..bbbe904b31 100755 --- a/iznik-server-go/emailtracking/emailtracking.go +++ b/iznik-server-go/emailtracking/emailtracking.go @@ -1554,3 +1554,172 @@ func DigestClickPositions(c *fiber.Ctx) error { }, }) } + +// ReengageFunnel represents overall funnel counts for the localised +// re-engagement email sequence within the requested period. +type ReengageFunnel struct { + Sent int64 `json:"sent"` + Opened int64 `json:"opened"` + Clicked int64 `json:"clicked"` + Reengaged int64 `json:"reengaged"` +} + +// ReengageStageStat represents funnel counts for a single stage (day 1-5) of +// the re-engagement sequence. +type ReengageStageStat struct { + Stage uint8 `json:"stage"` + Sent int64 `json:"sent"` + Opened int64 `json:"opened"` + Clicked int64 `json:"clicked"` + Reengaged int64 `json:"reengaged"` +} + +// ReengageArmStat represents funnel counts for a single experiment arm +// ('control', 'a', 'b', ...). The control arm is a holdout that receives +// no mail at all, so its opened/clicked counts are always zero - it still +// has a sent count and a reengaged count, which together form the baseline +// used to compute lift for the mailed arms. +type ReengageArmStat struct { + Arm string `json:"arm"` + Sent int64 `json:"sent"` + Opened int64 `json:"opened"` + Clicked int64 `json:"clicked"` + Reengaged int64 `json:"reengaged"` +} + +// ReengageSegmentStat represents send/reengagement counts for a single +// user-journey segment ('offer', 'wanted', 'replier', 'other') captured at +// send time. +type ReengageSegmentStat struct { + Segment string `json:"segment"` + Sent int64 `json:"sent"` + Reengaged int64 `json:"reengaged"` +} + +// ReengageEffectiveness returns funnel/effectiveness statistics for the +// localised re-engagement email sequence (requires authentication). +// +// Sends live in the `reengage` table (one row per stage sent to a user). +// Opens/clicks come from a LEFT JOIN to email_tracking via +// reengage.email_tracking_id, which is NULL for the experiment's control +// arm (a holdout that receives no mail) - the LEFT JOIN keeps those rows +// counted towards "sent" while their opened/clicked always resolve to +// zero. Actual re-engagement is reengage.reengaged_at IS NOT NULL, written +// separately from the mutable email open/click signals by the +// mail:reengage-outcomes batch job. +// +// @Router /modtools/email/stats/reengage [get] +// @Summary Get re-engagement email effectiveness +// @Description Returns funnel (sent/opened/clicked/reengaged) counts overall and broken down by stage, experiment arm and journey segment (Support/Admin only) +// @Tags emailtracking +// @Produce json +// @Security BearerAuth +// @Param start query string false "Start date (YYYY-MM-DD)" +// @Param end query string false "End date (YYYY-MM-DD)" +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} fiber.Error "Unauthorized" +// @Failure 403 {object} fiber.Error "Forbidden" +func ReengageEffectiveness(c *fiber.Ctx) error { + db := database.DBConn + + myid := user.WhoAmI(c) + if myid == 0 { + return fiber.NewError(fiber.StatusUnauthorized, "Not logged in") + } + + // Check if user has support/admin role + if !auth.IsAdminOrSupport(myid) { + return fiber.NewError(fiber.StatusForbidden, "Support or Admin role required") + } + + startDate := c.Query("start", "") + endDate := c.Query("end", "") + + // Default to the last 90 days when no range is supplied. + if startDate == "" || endDate == "" { + now := time.Now() + endDate = now.Format("2006-01-02") + startDate = now.AddDate(0, 0, -90).Format("2006-01-02") + } + + // If endDate doesn't include a time component, extend it to end of day. + endDateTime := endDate + if !strings.Contains(endDate, " ") && !strings.Contains(endDate, "T") { + endDateTime = endDate + " 23:59:59" + } + + // Overall funnel. LEFT JOIN so control-arm rows (email_tracking_id IS + // NULL, since no mail was sent) still count towards "sent" - they just + // never contribute to opened/clicked. + var funnel ReengageFunnel + db.Raw(` + SELECT + COUNT(*) AS sent, + SUM(CASE WHEN et.opened_at IS NOT NULL THEN 1 ELSE 0 END) AS opened, + SUM(CASE WHEN et.clicked_at IS NOT NULL THEN 1 ELSE 0 END) AS clicked, + SUM(CASE WHEN r.reengaged_at IS NOT NULL THEN 1 ELSE 0 END) AS reengaged + FROM reengage r + LEFT JOIN email_tracking et ON r.email_tracking_id = et.id + WHERE r.sentat BETWEEN ? AND ? + `, startDate, endDateTime).Scan(&funnel) + + // Funnel broken down by stage (day 1-5). + byStage := make([]ReengageStageStat, 0) + db.Raw(` + SELECT + r.stage AS stage, + COUNT(*) AS sent, + SUM(CASE WHEN et.opened_at IS NOT NULL THEN 1 ELSE 0 END) AS opened, + SUM(CASE WHEN et.clicked_at IS NOT NULL THEN 1 ELSE 0 END) AS clicked, + SUM(CASE WHEN r.reengaged_at IS NOT NULL THEN 1 ELSE 0 END) AS reengaged + FROM reengage r + LEFT JOIN email_tracking et ON r.email_tracking_id = et.id + WHERE r.sentat BETWEEN ? AND ? + GROUP BY r.stage + ORDER BY r.stage ASC + `, startDate, endDateTime).Scan(&byStage) + + // Funnel broken down by experiment arm. Rows predating the experiment + // (or sent outside of one) have arm = NULL and are excluded here - they + // are still reflected in the overall funnel above. + byArm := make([]ReengageArmStat, 0) + db.Raw(` + SELECT + r.arm AS arm, + COUNT(*) AS sent, + SUM(CASE WHEN et.opened_at IS NOT NULL THEN 1 ELSE 0 END) AS opened, + SUM(CASE WHEN et.clicked_at IS NOT NULL THEN 1 ELSE 0 END) AS clicked, + SUM(CASE WHEN r.reengaged_at IS NOT NULL THEN 1 ELSE 0 END) AS reengaged + FROM reengage r + LEFT JOIN email_tracking et ON r.email_tracking_id = et.id + WHERE r.sentat BETWEEN ? AND ? AND r.arm IS NOT NULL + GROUP BY r.arm + ORDER BY r.arm ASC + `, startDate, endDateTime).Scan(&byArm) + + // Sent/reengaged broken down by the user-journey segment captured at + // send time. Segment has no bearing on opens/clicks so it isn't joined + // to email_tracking. + bySegment := make([]ReengageSegmentStat, 0) + db.Raw(` + SELECT + r.segment AS segment, + COUNT(*) AS sent, + SUM(CASE WHEN r.reengaged_at IS NOT NULL THEN 1 ELSE 0 END) AS reengaged + FROM reengage r + WHERE r.sentat BETWEEN ? AND ? AND r.segment IS NOT NULL + GROUP BY r.segment + ORDER BY r.segment ASC + `, startDate, endDateTime).Scan(&bySegment) + + return c.JSON(fiber.Map{ + "funnel": funnel, + "byStage": byStage, + "byArm": byArm, + "bySegment": bySegment, + "period": fiber.Map{ + "start": startDate, + "end": endDate, + }, + }) +} diff --git a/iznik-server-go/router/routes.go b/iznik-server-go/router/routes.go index 3cfb4dd53c..eb7be62824 100644 --- a/iznik-server-go/router/routes.go +++ b/iznik-server-go/router/routes.go @@ -1428,6 +1428,20 @@ func SetupRoutes(app *fiber.App) { // @Failure 403 {object} fiber.Error "Forbidden" rg.Get("/modtools/email/stats/digestpositions", emailtracking.DigestClickPositions) + // Re-engagement Email Effectiveness (authenticated, admin only) + // @Router /email/stats/reengage [get] + // @Summary Get re-engagement email effectiveness + // @Description Returns funnel (sent/opened/clicked/reengaged) counts overall and broken down by stage, experiment arm and journey segment + // @Tags emailtracking + // @Produce json + // @Security BearerAuth + // @Param start query string false "Start date (YYYY-MM-DD)" + // @Param end query string false "End date (YYYY-MM-DD)" + // @Success 200 {object} map[string]interface{} + // @Failure 401 {object} fiber.Error "Unauthorized" + // @Failure 403 {object} fiber.Error "Forbidden" + rg.Get("/modtools/email/stats/reengage", emailtracking.ReengageEffectiveness) + // Browse-feed scroll-depth curve for the sysadmin "Scrolling" tab (Support/Admin). // @Router /modtools/scroll/depth [get] // @Summary Browse-feed scroll-depth curve diff --git a/iznik-server-go/utils/namegen.go b/iznik-server-go/utils/namegen.go index 375c5824db..d9f7c63d8b 100644 --- a/iznik-server-go/utils/namegen.go +++ b/iznik-server-go/utils/namegen.go @@ -124,12 +124,5 @@ func GenerateName() string { return "A freegler" } - // fillWord stops early if the trigram chain dead-ends, so the result can be - // shorter than minLen (down to the 2-char start). Guard the length contract: - // fall back rather than return a too-short stub. - name := fillWord(start, length, namegenTrigrams) - if len(name) < minLen { - return "A freegler" - } - return name + return fillWord(start, length, namegenTrigrams) } diff --git a/plans/2026-06-14-reengagement-emails.md b/plans/2026-06-14-reengagement-emails.md new file mode 100644 index 0000000000..371d01b9d3 --- /dev/null +++ b/plans/2026-06-14-reengagement-emails.md @@ -0,0 +1,164 @@ +# Localised re-engagement emails + +**Branch:** `feature/reengagement-mails` · **Status:** built, dark by default · 2026-06-14 + +## Why + +Freegle already has a re-engagement flow — the `engage` system (`mail:engage`, +`EngageEmailService`, templates `missing` / `inactive`). But it is **late and +generic**: + +- It only fires at the very end of a user's life: "AtRisk" ~7 days before the + 182.5-day inactivity cutoff, and "Inactive" once they're past it. +- The copy is "We miss you" / "We'll stop emailing you soon" — no local content, + no nearby activity, no sense of the community the user is drifting away from. + +Industry + charity-sector research (see sources below) is consistent that the +biggest re-engagement wins come **early**, from **local/personalised content** +("what's happening near you") rather than guilt, with a short **sequence** +ending in a preference choice, then suppression. + +This change adds that earlier, localised sequence **alongside** the existing +engage system, graduated across the lapse. After the upper bound the existing +engage AtRisk/Inactive sunset takes over, so the two never double-mail the same +lapse. + +## Research summary + +**Timing — intervene early and graduate, don't wait months.** A single late +trigger misses the point: most of the drop-off happens in the first few weeks, +and re-engaging while users are *on the way out* reactivates markedly better than +reviving long-dormant ones. An early, gentle nudge recovers a meaningful share +because many early lapses are accidental, whereas very late attempts barely +register and risk spam traps. Modern practice uses **behavioural / lifecycle +triggers** (declining engagement, stage-based), not one static window; episodic +products warrant an early first touch. We therefore **graduate** the three stages +across the lapse (early → mid → late) rather than bunching them, ending before +the engage sunset. + +**Sequence/deliverability** (Mailchimp, Klaviyo, Litmus, Spamhaus, Mailgun, +beehiiv, Charity Digital): a **3-email sequence**, **suppress** non-responders +after to protect deliverability (Gmail/Yahoo bulk-sender spam-rate caps), exclude +lapsed users from the regular digest while the sequence runs, and measure +clicks/logins not opens (Apple MPP inflates opens). + +**Messaging** (Nonprofit Marketing Guide, Bloomerang, Campaign Monitor, Litmus): +lead with concrete **local impact** ("items offered near you this week", "weight +kept out of landfill near you"), not "we miss you". One clear CTA. Offer a +**frequency downgrade** before unsubscribe. Plain, accessible, mobile-first +design for a broad demographic. RFC 8058 one-click unsubscribe. + +## Design + +A new **`reengage`** email family — kept separate from the live `engage` system +so it can ship dark without touching production sending. + +**The sequence** (templates under `resources/views/emails/mjml/reengage/`): + +1. **`nearby`** — "what's been freegled near you": a count of free items offered + near the user this week + live item cards. CTA *See what's near you*. +2. **`impact`** — "your community has been busy": a **collage** of real + neighbours (avatars + first names) and the things they've shared (item + photos), instead of abstract stat counters — concrete and human. CTA *Join + back in*. +3. **`preferences`** — "shall we stay in touch?": honest opt-in confirmation + + gentle sunset notice. Primary *Keep me freegling* (auto-login), secondary + *Email me less often*, clear unsubscribe. Doubles as a GDPR consent refresh. + +All three use the shared MJML head/footer partials and brand tokens (clean, +attractive, the birthday email as the visual bar), have plain-text alternatives, +and set RFC 8058 `List-Unsubscribe` headers. + +**Cadence / state.** Candidate once `lastaccess` is ≥ `trigger_days` (default +**30**) and < `max_days` (175). Stages are spaced ≥ `stage_gap_days` (default +**45**), so a clean lapse receives the three stages at roughly **30 / 75 / 120 +days** inactive — early gentle nudge → community pull → opt-in/sunset. A user's +position is derived from `reengage` rows newer than their `lastaccess`, so **any +login resets the sequence for free** (their lastaccess jumps past every send). +After stage 3 with no re-engagement the sequence is marked `Suppressed`. + +The trigger is on **login** inactivity (`users.lastaccess`). Many freeglers read +digests without logging in, so login-gap overstates disengagement; the first +touch is deliberately a soft, value-led "what's near you" (low annoyance risk), +and the trigger is env-tunable. Refining to an **email-engagement** signal +(non-opens/clicks of recent digests) is the recommended pre-ramp improvement. + +**Localisation.** The localisation is the *content*, not a place name. We +deliberately do **not** print a derived place label ("near Edinburgh") — the +user's stored location/group is too unreliable for that (road/postcode +fragments, multi-group ambiguity), so confidently naming the wrong town reads +worse than saying nothing. Copy stays generic ("near you"). What's genuinely +local is the data: nearby OFFERs (item photos) via the existing +`NearbyOffersService`, fetched by the `settings.mylocation` → `lastlocation` +lat/lng waterfall; the stage-2 collage uses recent nearby +posters who have uploaded an avatar (first name + profile photo — the same +public wall data shown in-app). Everything degrades gracefully — no location, no +avatars, or no photos still yields a sensible email. + +**Eligibility / suppression** mirrors the engage flow: approved Freegle +membership, per-group engagement opt-out honoured, `deleted`/`bouncing`/ +`onholidaytill`/`simplemail=None` all respected. + +## Dark-ship gating + +Two gates, both off by default: + +- `FREEGLE_MAIL_ENABLED_TYPES` must contain `Reengage` (added to docker-compose; + kill-switch). +- `FREEGLE_REENGAGE_ALLOWLIST` — `''` = nobody (default), `'*'` = everyone, + else a comma-separated pilot list of recipient addresses. + +Tunables: `FREEGLE_REENGAGE_TRIGGER_DAYS` (30), `_STAGE_GAP_DAYS` (45), +`_MAX_DAYS` (175). + +## Files + +- Migration `2026_06_14_120000_create_reengage_table.php` +- `config/freegle.php` — `reengage` block +- `app/Services/ReengageService.php` — orchestration + sequence logic +- `app/Services/ReengageContentService.php` — localised content +- `app/Mail/Reengage/ReengageMail.php` +- `resources/views/emails/mjml/reengage/{nearby,impact,preferences}.blade.php` +- `resources/views/emails/text/reengage/{nearby,impact,preferences}.blade.php` +- `app/Console/Commands/Mail/SendReengageEmailsCommand.php` — `mail:reengage` +- `routes/console.php` — daily 15:30 schedule +- Tests: `tests/Unit/Mail/ReengageMailRenderTest.php`, + `tests/Feature/Mail/ReengageEmailsCommandTest.php` + +## Visual review + +`php artisan mail:reengage --preview=you@example.org` sends one of each stage +(sample data) to mailpit, bypassing the gates. Rendered via headless Chrome +(GPU off) against the mailpit HTML: + +| Stage 1 — nearby | Stage 2 — impact | Stage 3 — preferences | +|---|---|---| +| ![Stage 1 — nearby](reengage-screenshots/stage1-nearby.png) | ![Stage 2 — impact](reengage-screenshots/stage2-impact.png) | ![Stage 3 — preferences](reengage-screenshots/stage3-preferences.png) | + +(Sample data shown — counts, avatars and items are illustrative.) + +## Rollout + +1. Merge (dark — nothing sends). +2. **Before enabling: exclude in-sequence users from the regular unified digest** + (deliverability best practice — do not double-mail). Not done here to keep the + live digest untouched; this is the #1 prerequisite for going live. +3. Pilot: set `FREEGLE_REENGAGE_ALLOWLIST` to a couple of staff addresses; watch. +4. Ramp the allowlist; monitor unsubscribe/complaint rates. +5. Consider click/login-based success metrics on the `reengage` table. + +The trigger and window were sanity-checked against live data (kept out of this +public doc): the lapse rate is steepest in the first weeks and the early window +holds a substantial, reachable cohort — confirming an early, graduated cadence +over a single late trigger. + +## Key sources + +**Timing/early intervention:** DesignRush 2026 behavioral-triggers benchmark; +Bloomreach reengagement evolution; Memberlytic lapsed-member playbook; Mailflow +Authority (re-engagement timing & sunset); Braze win-back. +**Sequence/deliverability/messaging:** Mailchimp win-back; Klaviyo list-cleaning; +Litmus deliverability + 2025 State of Email (MPP); Spamhaus sunset policy; +Mailgun frequency-indexed thresholds; beehiiv re-engagement framework; +Charity Digital + Nonprofit Marketing Guide (charity framing); Campaign Monitor +(button CTA uplift); Digioh (preference-centre unsubscribe reduction). diff --git a/plans/2026-07-12-reengagement-experiment-and-instrumentation.md b/plans/2026-07-12-reengagement-experiment-and-instrumentation.md new file mode 100644 index 0000000000..992b687d1e --- /dev/null +++ b/plans/2026-07-12-reengagement-experiment-and-instrumentation.md @@ -0,0 +1,80 @@ +# Re-engagement: experiment, journey segmentation & effectiveness instrumentation + +Builds on `2026-06-14-reengagement-emails.md` (the 3-stage nearby → impact → preferences +sequence). Adds the ability to **experiment between copy variants on a subset of users with a +real control holdout**, **tailor by the user's first-action journey**, and **graph +effectiveness in the sysadmin dashboard**. Still fully dark by default. + +## Adversarial + UI review outcome (see PR #754 review comment) + +Fixed here: +- **List-Unsubscribe blocker** — `ReengageMail` now emits the RFC 8058 header with the *keyed* + one-click URL (`/one-click-unsubscribe/{id}/{key}`), not the session-only `/unsubscribe/{id}` + that silently no-ops for a mail-client one-click POST. +- **`relevantallowed` opt-out** now honoured in candidate selection (parity with `engage`). +- **Plain-text escaping** — user content and URLs in the text parts use `{!! !!}` (were `{{ }}`, + which mangled apostrophes/ampersands and would break `&`-carrying tracked URLs). +- **Subject singular** — "1 free thing" not "1 free things". +- **Perf** — candidate query `select('id')` only; `lastaccess` index recommended (below). +- **Engagement opt-out** scoped to Approved memberships (a stale row can't override a `0`). + +Still **go-live prerequisites** (not code-fixable here): +1. **Exclude in-reengage-sequence users from `mail:engage`** (its Inactive mailer fires from day 14 + weekly with no upper bound → double-mails the same lapse) **and from the unified digest**. +2. Add a `lastaccess` index (or `(deleted, lastaccess)`) before opening the allowlist wide. +3. Product/privacy sign-off on the stage-2 "impact" collage (third-party names/avatars to lapsed users). + +## Experiment layer + +Config-driven, deterministic, dark by default (`config/freegle.php` → `reengage.experiment`): + +- **Stable assignment**: `ExperimentBucket::bucket($userId, $experiment)` = `crc32(userId|experiment) % 100` + (the CRC32 choice `UnifiedDigestService` already uses; salted by experiment name so experiments + don't correlate). Held constant across all three stages so a user keeps the same arm. +- **Arms** (`control`/`a`/`b`, config ratios over 0-99): **`control` is a true holdout** — a + `reengage` row is recorded (so it progresses through the stages and is provably drawn from the + same eligible pool) but **no mail is sent**, making treatment lift measurable. Fixed ratios, not + a bandit — a bandit would erode the holdout. +- **Limited pilot**: two independent knobs both dark by default — `FREEGLE_REENGAGE_ALLOWLIST` + (who is eligible at all, unchanged) and `FREEGLE_REENGAGE_EXPERIMENT_ROLLOUT_PCT` (0→10→50→100, + what fraction of eligible users enter the experiment). `rollout_pct=0` = today's exact behaviour + (everyone eligible gets arm `a`, no holdout). +- **Variants**: arm `b` currently ships a different **subject line** per stage (a classic, high-signal + A/B), plus segment-aware framing. Full body variants drop in as `nearby-b`/… templates pointed at + by `STAGE_TEMPLATE` — the plumbing (assignment, recording, graphs) already supports N arms. + +## Journey segmentation + +Each send records the user's **first action when they joined** — `offer` / `wanted` / `replier` / +`other` — from two cheap indexed queries (`messages.type` first Offer/Wanted vs first `chat_messages`). +Recorded per row so copy and effectiveness can be cut by it; the subject framing already varies by segment. + +## Effectiveness instrumentation → sysadmin graph + +- `ReengageMail` now `use`s `TrackableEmail`: an **open pixel** and **tracked CTA** land opens/clicks in + the shared `email_tracking` table; the per-send `email_tracking_id` is stored on the `reengage` row. +- **Real outcome**, not inferred: `mail:reengage-outcomes` (daily 16:30) writes `reengaged_at` / + `reengaged_via` (`login`/`reply`/`post`) and `outcome='Reengaged'` from the precise `logs`/`messages` + event trail within `outcome_window_days` — for treatment **and** control rows, so lift is real. +- New columns (migration `2026_07_12_000000_add_experiment_and_tracking_to_reengage_table`): + `experiment, arm, bucket, segment, email_tracking_id, reengaged_at, reengaged_via`. +- **Sysadmin dashboard**: new **Reengagement** tab (`ModSysAdminReengageEffectiveness.vue`) backed by + `GET /modtools/email/stats/reengage` (`emailtracking.ReengageEffectiveness`): the sent → opened → + clicked → reengaged funnel, broken down by stage, by arm (with **lift vs control** highlighted), and + by journey segment, over a date range. + +## Files + +- Batch: `app/Support/ExperimentBucket.php`, `app/Services/ReengageService.php`, + `app/Mail/Reengage/ReengageMail.php`, `app/Console/Commands/Mail/UpdateReengageOutcomesCommand.php`, + `config/freegle.php`, `routes/console.php`, the 6 reengage templates, migration above, + `tests/Unit/Services/ReengageExperimentTest.php`. +- API: `iznik-server-go/emailtracking/emailtracking.go`, `router/routes.go`. +- Frontend: `iznik-nuxt3/api/EmailTrackingAPI.js`, `modtools/stores/emailtracking.js`, + `modtools/components/ModSysAdminReengageEffectiveness.vue`, `modtools/pages/sysadmin/index.vue`. + +## Rollout + +Merge (dark) → set a small `FREEGLE_REENGAGE_ALLOWLIST` pilot → after the go-live prerequisites above, +`FREEGLE_REENGAGE_EXPERIMENT_ROLLOUT_PCT=10` → watch the Reengagement tab (control-lift, unsubscribe/ +complaint rates) → ramp. diff --git a/plans/onboarding-screenshots/tip-day1.png b/plans/onboarding-screenshots/tip-day1.png new file mode 100644 index 0000000000..9d8acfb5b5 Binary files /dev/null and b/plans/onboarding-screenshots/tip-day1.png differ diff --git a/plans/onboarding-screenshots/tip-day2.png b/plans/onboarding-screenshots/tip-day2.png new file mode 100644 index 0000000000..21891b27f6 Binary files /dev/null and b/plans/onboarding-screenshots/tip-day2.png differ diff --git a/plans/onboarding-screenshots/tip-day3.png b/plans/onboarding-screenshots/tip-day3.png new file mode 100644 index 0000000000..fed588dcca Binary files /dev/null and b/plans/onboarding-screenshots/tip-day3.png differ diff --git a/plans/onboarding-screenshots/tip-day4.png b/plans/onboarding-screenshots/tip-day4.png new file mode 100644 index 0000000000..33de82d209 Binary files /dev/null and b/plans/onboarding-screenshots/tip-day4.png differ diff --git a/plans/onboarding-screenshots/tip-day5.png b/plans/onboarding-screenshots/tip-day5.png new file mode 100644 index 0000000000..f21e9db07e Binary files /dev/null and b/plans/onboarding-screenshots/tip-day5.png differ