Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1551,7 +1551,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}
Expand Down Expand Up @@ -1671,7 +1671,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
Expand Down
Binary file modified docs/members/assets/browse.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace App\Console\Commands\Mail;

use App\Services\ReengageService;
use Illuminate\Console\Command;

class SendReengageEmailsCommand extends Command
{
protected $signature = 'mail:reengage
{--dry-run : Report counts without sending emails or recording sends}
{--preview= : Send the whole tip sequence with sample data to this address (for mailpit review); bypasses the dark-ship gates}';

protected $description = 'Send the first-week onboarding tip sequence to new members (dark unless FREEGLE_REENGAGE_ALLOWLIST + type gate are set)';

public function handle(ReengageService $service): int
{
$preview = $this->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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Console\Commands\Mail;

use App\Services\ReengageService;
use Illuminate\Console\Command;

class UpdateReengageOutcomesCommand extends Command
{
protected $signature = 'mail:reengage-outcomes
{--window= : Override the outcome window in days (default from config)}';

protected $description = 'Record real re-engagement outcomes (login/reply/post within the window) for reengage sends, so effectiveness and control-arm lift can be graphed';

public function handle(ReengageService $service): int
{
$window = $this->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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
157 changes: 157 additions & 0 deletions iznik-batch/app/Mail/Reengage/ReengageMail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<?php

namespace App\Mail\Reengage;

use App\Mail\MjmlMailable;
use App\Mail\Traits\TrackableEmail;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Envelope;
use Symfony\Component\Mime\Email;

/**
* One email in the first-week onboarding tip sequence. Every day shares the one
* 'tip' template; the per-day copy (and the local-volunteer sign-off) is resolved
* up front by ReengageContentService and passed in as $content, so the Mailable
* itself stays dumb and serialisable.
*
* Tracked: participates in the shared email_tracking system (open pixel + wrapped
* links) so the re-engagement flow's opens/clicks/effectiveness are visible in
* the sysadmin dashboard, joined back to the reengage row via the tracking id.
*
* Sets the RFC 8058 List-Unsubscribe headers using the KEYED one-click URL (not
* the session-only /unsubscribe/{id} form), so a mail-client one-click POST from
* Gmail/Yahoo — which carries no Freegle session — actually unsubscribes the user.
*/
class ReengageMail extends MjmlMailable
{
use TrackableEmail;

public function __construct(
public readonly string $recipientName,
public readonly string $recipientEmail,
public readonly string $emailSubject,
public readonly string $template,
public readonly int $userId,
public readonly array $content = [],
) {
parent::__construct();

// Real sends (userId > 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',
"<mailto:{$unsubscribeEmail}?subject=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();
}
}
2 changes: 1 addition & 1 deletion iznik-batch/app/Services/PushNotificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----
Expand Down
Loading