• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / Iznik / 8634

30 Apr 2026 09:36PM UTC coverage: 72.346% (+0.03%) from 72.312%
8634

push

circleci

invalid-email-address
fix(infra): make postfix→batch bridge resilient and self-monitoring

Hardcoding the batch hostname in the mail-handler script meant a stale
image build (or COMPOSE_PROJECT_NAME change) could silently break
incoming mail for hours, with the only symptom being deferred queue
entries in the postfix log.

- freegle-mail-handler now reads the endpoint from BATCH_URL, set in
  docker-compose to use the service alias (batch-prod) which compose
  guarantees on the shared network regardless of container_name.
- Postfix healthcheck now also probes http://batch-prod:8080/up, so a
  broken bridge marks the container unhealthy rather than silently
  deferring mail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

13565 of 20463 branches covered (66.29%)

Branch coverage included in aggregate %.

98342 of 134221 relevant lines covered (73.27%)

22.37 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.54
/iznik-batch/app/Console/Commands/Queue/ProcessBackgroundTasksCommand.php
1
<?php
2

3
namespace App\Console\Commands\Queue;
4

5
use App\Console\Concerns\PreventsOverlapping;
6
use App\Mail\Charity\CharitySignupMail;
7
use App\Mail\Chat\ReferToSupportMail;
8
use App\Mail\Donation\DonateExternalMail;
9
use App\Mail\Newsfeed\ChitchatReportMail;
10
use App\Mail\Session\ForgotPasswordMail;
11
use App\Mail\Session\MergeOfferMail;
12
use App\Mail\Session\UnsubscribeConfirmMail;
13
use App\Mail\Session\VerifyEmailMail;
14
use App\Mail\Message\ModStdMessageMail;
15
use App\Models\ChatRoom;
16
use App\Models\User;
17
use App\Services\EmailSpoolerService;
18
use App\Services\HousekeeperService;
19
use App\Services\PostcodeRemapService;
20
use App\Services\UserManagementService;
21
use App\Services\PushNotificationService;
22
use App\Traits\GracefulShutdown;
23
use Illuminate\Console\Command;
24
use Illuminate\Support\Facades\DB;
25
use Illuminate\Support\Facades\Log;
26
use Illuminate\Support\Facades\Mail;
27
use Illuminate\Support\Str;
28

29
/**
30
 * Processes background tasks queued by the Go API server.
31
 *
32
 * Go writes rows to the `background_tasks` table with a task_type and JSON data.
33
 * This command polls that table and dispatches each task to the appropriate handler.
34
 *
35
 * Runs as a daemon via supervisor or the Laravel scheduler.
36
 */
37
class ProcessBackgroundTasksCommand extends Command
38
{
39
    use GracefulShutdown;
40
    use PreventsOverlapping;
41

42
    protected $signature = 'queue:background-tasks
43
                            {--limit=50 : Maximum tasks to process per iteration}
44
                            {--max-iterations=60 : Maximum iterations before exiting (0 = infinite)}
45
                            {--sleep=5 : Seconds to sleep between iterations}
46
                            {--spool : Spool emails instead of sending directly}';
47

48
    protected $description = 'Process background tasks queued by the Go API server';
49

50
    private const MAX_ATTEMPTS = 3;
51

52
    public function handle(PushNotificationService $pushService, EmailSpoolerService $spooler): int
50✔
53
    {
54
        if (! $this->acquireLock()) {
50✔
55
            $this->info('Already running, exiting.');
×
56

57
            return Command::SUCCESS;
×
58
        }
59

60
        try {
61
            return $this->doHandle($pushService, $spooler);
50✔
62
        } finally {
63
            $this->releaseLock();
50✔
64
        }
65
    }
66

67
    protected function doHandle(PushNotificationService $pushService, EmailSpoolerService $spooler): int
50✔
68
    {
69
        $limit = (int) $this->option('limit');
50✔
70
        $maxIterations = (int) $this->option('max-iterations');
50✔
71
        $sleepSeconds = (int) $this->option('sleep');
50✔
72
        $shouldSpool = (bool) $this->option('spool');
50✔
73
        $iteration = 0;
50✔
74

75
        $this->registerShutdownHandlers();
50✔
76
        $this->registerFatalErrorHandler();
50✔
77
        $this->info("Processing background tasks (limit={$limit}, max-iterations={$maxIterations})");
50✔
78

79
        while (TRUE) {
50✔
80
            if ($this->shouldStop()) {
50✔
81
                $this->info('Shutdown signal received, stopping gracefully.');
×
82
                break;
×
83
            }
84

85
            $iteration++;
50✔
86
            if ($maxIterations > 0 && $iteration > $maxIterations) {
50✔
87
                $this->info("Reached max iterations ({$maxIterations}), exiting.");
50✔
88
                break;
50✔
89
            }
90

91
            $processed = $this->processIteration($limit, $pushService, $spooler, $shouldSpool);
50✔
92

93
            if ($processed === 0) {
50✔
94
                sleep($sleepSeconds);
8✔
95
            }
96
        }
97

98
        return Command::SUCCESS;
50✔
99
    }
100

101
    // Fatal errors bypass try/catch; this shutdown function is the only hook that runs after them.
102
    protected function registerFatalErrorHandler(): void
50✔
103
    {
104
        if ($this->isTestingEnvironment()) {
50✔
105
            return;
50✔
106
        }
107

108
        // 2 MB reserve freed at shutdown entry — ensures we have heap for Log/Sentry after OOM.
109
        $memoryReserve = str_repeat("\0", 2 * 1024 * 1024);
×
110

111
        register_shutdown_function(function () use (&$memoryReserve) {
×
112
            unset($memoryReserve);
×
113
            $this->handleFatalShutdown(error_get_last());
×
114
        });
×
115
    }
116

117
    public function handleFatalShutdown(?array $error): void
3✔
118
    {
119
        $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
3✔
120

121
        if ($error === null || ! in_array($error['type'], $fatalTypes, TRUE)) {
3✔
122
            return;
2✔
123
        }
124

125
        $message = sprintf(
1✔
126
            'Fatal error in queue:background-tasks: %s in %s:%d',
1✔
127
            $error['message'],
1✔
128
            $error['file'],
1✔
129
            $error['line']
1✔
130
        );
1✔
131

132
        Log::critical($message, ['error' => $error]);
1✔
133

134
        if (app()->bound('sentry')) {
1✔
135
            app('sentry')->captureMessage($message, \Sentry\Severity::fatal());
1✔
136
        }
137
    }
138

139
    /**
140
     * Process one iteration of pending tasks.
141
     */
142
    protected function processIteration(
50✔
143
        int $limit,
144
        PushNotificationService $pushService,
145
        EmailSpoolerService $spooler,
146
        bool $shouldSpool
147
    ): int {
148
        $tasks = DB::select(
50✔
149
            'SELECT * FROM background_tasks WHERE processed_at IS NULL AND failed_at IS NULL AND attempts < ? ORDER BY created_at ASC LIMIT ?',
50✔
150
            [self::MAX_ATTEMPTS, $limit]
50✔
151
        );
50✔
152

153
        $processed = 0;
50✔
154

155
        foreach ($tasks as $task) {
50✔
156
            if ($this->shouldStop()) {
48✔
157
                break;
×
158
            }
159

160
            try {
161
                DB::table('background_tasks')
48✔
162
                    ->where('id', $task->id)
48✔
163
                    ->increment('attempts');
48✔
164

165
                $data = json_decode($task->data, TRUE);
48✔
166

167
                $this->dispatchTask($task->task_type, $data, $pushService, $spooler, $shouldSpool);
48✔
168

169
                DB::table('background_tasks')
42✔
170
                    ->where('id', $task->id)
42✔
171
                    ->update(['processed_at' => now()]);
42✔
172

173
                $processed++;
42✔
174
            } catch (\Throwable $e) {
6✔
175
                Log::error('Background task failed', [
6✔
176
                    'task_id' => $task->id,
6✔
177
                    'task_type' => $task->task_type,
6✔
178
                    'error' => $e->getMessage(),
6✔
179
                    'attempts' => $task->attempts + 1,
6✔
180
                ]);
6✔
181

182
                // Report to Sentry so we get alerted to task failures.
183
                if (app()->bound('sentry')) {
6✔
184
                    app('sentry')->captureException($e);
6✔
185
                }
186

187
                $update = ['error_message' => substr($e->getMessage(), 0, 65535)];
6✔
188

189
                if ($task->attempts + 1 >= self::MAX_ATTEMPTS) {
6✔
190
                    $update['failed_at'] = now();
3✔
191
                }
192

193
                DB::table('background_tasks')
6✔
194
                    ->where('id', $task->id)
6✔
195
                    ->update($update);
6✔
196
            }
197
        }
198

199
        if ($processed > 0) {
50✔
200
            $this->info("Processed {$processed} task(s).");
42✔
201
        }
202

203
        return $processed;
50✔
204
    }
205

206
    /**
207
     * Dispatch a task to the appropriate handler.
208
     */
209
    protected function dispatchTask(
48✔
210
        string $taskType,
211
        array $data,
212
        PushNotificationService $pushService,
213
        EmailSpoolerService $spooler,
214
        bool $shouldSpool
215
    ): void {
216
        match ($taskType) {
48✔
217
            'push_notify_group_mods' => $this->handlePushNotifyGroupMods($data, $pushService),
2✔
218
            'email_chitchat_report' => $this->handleEmailChitchatReport($data, $spooler, $shouldSpool),
4✔
219
            'email_charity_signup' => $this->handleEmailCharitySignup($data, $spooler, $shouldSpool),
×
220
            'email_donate_external' => $this->handleEmailDonateExternal($data, $spooler, $shouldSpool),
4✔
221
            'email_forgot_password' => $this->handleEmailForgotPassword($data, $spooler, $shouldSpool),
2✔
222
            'email_unsubscribe' => $this->handleEmailUnsubscribe($data, $spooler, $shouldSpool),
2✔
223
            'email_message_approved', 'email_message_rejected', 'email_message_reply'
48✔
224
                => $this->handleModStdMessage($taskType, $data, $pushService, $spooler, $shouldSpool),
8✔
225
            'email_mod_stdmsg'
48✔
226
                => $this->handleModStdMessageForMember($taskType, $data, $spooler, $shouldSpool),
7✔
227
            'email_merge' => $this->handleEmailMerge($data, $spooler, $shouldSpool),
2✔
228
            'email_verify' => $this->handleEmailVerify($data, $spooler, $shouldSpool),
3✔
229
            'refer_to_support' => $this->handleReferToSupport($data, $spooler, $shouldSpool),
1✔
230
            'message_outcome' => $this->handleMessageOutcome($data),
3✔
231
            'freebie_alerts_add' => $this->handleFreebieAlertsAdd($data),
4✔
232
            'freebie_alerts_remove' => $this->handleFreebieAlertsRemove($data),
2✔
233
            'housekeeper_notify' => $this->handleHousekeeperNotify($data),
×
234
            'remap_postcodes' => $this->handleRemapPostcodes($data),
×
235
            'user_forget' => $this->handleUserForget($data),
2✔
236
            default => throw new \RuntimeException("Unknown task type: {$taskType}"),
3✔
237
        };
48✔
238
    }
239

240
    /**
241
     * Send push notifications to all moderators of a group.
242
     */
243
    protected function handlePushNotifyGroupMods(array $data, PushNotificationService $pushService): void
2✔
244
    {
245
        $groupId = $data['group_id'] ?? NULL;
2✔
246

247
        if (! $groupId) {
2✔
248
            throw new \RuntimeException('push_notify_group_mods requires group_id');
×
249
        }
250

251
        $count = $pushService->notifyGroupMods((int) $groupId);
2✔
252
        Log::info('Notified group mods', ['group_id' => $groupId, 'notified' => $count]);
2✔
253
    }
254

255
    /**
256
     * Send a ChitChat report email to support.
257
     */
258
    protected function handleEmailChitchatReport(
4✔
259
        array $data,
260
        EmailSpoolerService $spooler,
261
        bool $shouldSpool
262
    ): void {
263
        $required = ['user_id', 'user_email', 'newsfeed_id', 'reason'];
4✔
264
        foreach ($required as $field) {
4✔
265
            if (empty($data[$field])) {
4✔
266
                throw new \RuntimeException("email_chitchat_report requires {$field}");
1✔
267
            }
268
        }
269

270
        // user_name is cosmetic — reporters identified by id+email; fall back if blank.
271
        $reporterName = !empty($data['user_name']) ? $data['user_name'] : 'A Freegle user';
3✔
272

273
        $mail = new ChitchatReportMail(
3✔
274
            reporterName: $reporterName,
3✔
275
            reporterId: (int) $data['user_id'],
3✔
276
            reporterEmail: $data['user_email'],
3✔
277
            newsfeedId: (int) $data['newsfeed_id'],
3✔
278
            reason: $data['reason'],
3✔
279
        );
3✔
280

281
        if ($shouldSpool) {
3✔
282
            $recipients = array_map('trim', explode(',', config('freegle.mail.chitchat_support_addr')));
×
283
            $spooler->spool($mail, $recipients);
×
284
        } else {
285
            Mail::send($mail);
3✔
286
        }
287

288
        Log::info('Sent ChitChat report email', [
3✔
289
            'reporter_id' => $data['user_id'],
3✔
290
            'newsfeed_id' => $data['newsfeed_id'],
3✔
291
        ]);
3✔
292
    }
293

294
    /**
295
     * Send an external donation notification email to the info address.
296
     */
297
    protected function handleEmailDonateExternal(
4✔
298
        array $data,
299
        EmailSpoolerService $spooler,
300
        bool $shouldSpool
301
    ): void {
302
        $required = ['user_id', 'user_name', 'user_email', 'amount'];
4✔
303
        foreach ($required as $field) {
4✔
304
            if (empty($data[$field])) {
4✔
305
                throw new \RuntimeException("email_donate_external requires {$field}");
1✔
306
            }
307
        }
308

309
        $mail = new DonateExternalMail(
3✔
310
            userName: $data['user_name'],
3✔
311
            userId: (int) $data['user_id'],
3✔
312
            userEmail: $data['user_email'],
3✔
313
            amount: (float) $data['amount'],
3✔
314
            source: $data['source'] ?? DonateExternalMail::SOURCE_EXTERNAL,
3✔
315
        );
3✔
316

317
        if ($shouldSpool) {
3✔
318
            $spooler->spool($mail, config('freegle.mail.info_addr'));
×
319
        } else {
320
            Mail::send($mail);
3✔
321
        }
322

323
        Log::info('Sent external donation email', [
3✔
324
            'user_id' => $data['user_id'],
3✔
325
            'amount' => $data['amount'],
3✔
326
        ]);
3✔
327
    }
328

329
    /**
330
     * Send a charity partner signup notification to the partnerships team.
331
     */
332
    protected function handleEmailCharitySignup(
×
333
        array $data,
334
        EmailSpoolerService $spooler,
335
        bool $shouldSpool
336
    ): void {
337
        if (empty($data['orgname']) || empty($data['contactemail'])) {
×
338
            throw new \RuntimeException('email_charity_signup requires orgname and contactemail');
×
339
        }
340

341
        $mail = new CharitySignupMail(
×
342
            charityId: (int) $data['charity_id'],
×
343
            orgName: $data['orgname'],
×
344
            orgType: $data['orgtype'] ?? 'registered',
×
345
            charityNumber: $data['charitynumber'] ?? null,
×
346
            contactEmail: $data['contactemail'],
×
347
            contactName: $data['contactname'] ?? null,
×
348
            website: $data['website'] ?? null,
×
349
            description: $data['description'] ?? null,
×
350
        );
×
351

352
        if ($shouldSpool) {
×
353
            $spooler->spool($mail, config('freegle.mail.partnerships_addr'));
×
354
        } else {
355
            Mail::send($mail);
×
356
        }
357

358
        Log::info('Sent charity signup notification', [
×
359
            'charity_id' => $data['charity_id'],
×
360
            'orgname' => $data['orgname'],
×
361
        ]);
×
362
    }
363

364
    /**
365
     * Send a forgot-password email with auto-login link.
366
     */
367
    protected function handleEmailForgotPassword(
2✔
368
        array $data,
369
        EmailSpoolerService $spooler,
370
        bool $shouldSpool
371
    ): void {
372
        $required = ['user_id', 'email', 'reset_url'];
2✔
373
        foreach ($required as $field) {
2✔
374
            if (empty($data[$field])) {
2✔
375
                throw new \RuntimeException("email_forgot_password requires {$field}");
×
376
            }
377
        }
378

379
        $mail = new ForgotPasswordMail(
2✔
380
            userId: (int) $data['user_id'],
2✔
381
            email: $data['email'],
2✔
382
            resetUrl: $data['reset_url'],
2✔
383
        );
2✔
384

385
        if ($shouldSpool) {
2✔
386
            $spooler->spool($mail, $data['email']);
×
387
        } else {
388
            Mail::send($mail);
2✔
389
        }
390

391
        Log::info('Sent forgot password email', [
2✔
392
            'user_id' => $data['user_id'],
2✔
393
        ]);
2✔
394
    }
395

396
    /**
397
     * Send an unsubscribe confirmation email with auto-login link.
398
     */
399
    protected function handleEmailUnsubscribe(
2✔
400
        array $data,
401
        EmailSpoolerService $spooler,
402
        bool $shouldSpool
403
    ): void {
404
        $required = ['user_id', 'email', 'unsub_url'];
2✔
405
        foreach ($required as $field) {
2✔
406
            if (empty($data[$field])) {
2✔
407
                throw new \RuntimeException("email_unsubscribe requires {$field}");
×
408
            }
409
        }
410

411
        $mail = new UnsubscribeConfirmMail(
2✔
412
            userId: (int) $data['user_id'],
2✔
413
            email: $data['email'],
2✔
414
            unsubUrl: $data['unsub_url'],
2✔
415
        );
2✔
416

417
        if ($shouldSpool) {
2✔
418
            $spooler->spool($mail, $data['email']);
×
419
        } else {
420
            Mail::send($mail);
2✔
421
        }
422

423
        Log::info('Sent unsubscribe confirmation email', [
2✔
424
            'user_id' => $data['user_id'],
2✔
425
        ]);
2✔
426
    }
427

428
    /**
429
     * Handle mod standard message emails (approve, reject, reply).
430
     *
431
     * Looks up the message poster, group, and mod info, then:
432
     * 1. Sends the stdmsg email (if subject/body provided).
433
     * 2. Creates a User2Mod chat message for the mod log.
434
     * 3. Creates a mod log entry (always — even for plain approve with no stdmsg).
435
     * 4. Queues push notifications to group moderators.
436
     */
437
    protected function handleModStdMessage(
8✔
438
        string $taskType,
439
        array $data,
440
        PushNotificationService $pushService,
441
        EmailSpoolerService $spooler,
442
        bool $shouldSpool
443
    ): void {
444
        $msgId = (int) ($data['msgid'] ?? 0);
8✔
445
        $byUser = (int) ($data['byuser'] ?? 0);
8✔
446
        $groupId = (int) ($data['groupid'] ?? 0);
8✔
447
        $subject = $data['subject'] ?? '';
8✔
448
        $body = $data['body'] ?? '';
8✔
449
        $stdmsgId = (int) ($data['stdmsgid'] ?? 0);
8✔
450

451
        // Fall back to looking up group from messages_groups if not provided.
452
        if ($groupId === 0 && $msgId > 0) {
8✔
453
            $groupId = (int) (DB::table('messages_groups')->where('msgid', $msgId)->value('groupid') ?? 0);
1✔
454
        }
455

456
        if ($msgId === 0 || $byUser === 0) {
8✔
457
            throw new \RuntimeException("{$taskType} requires msgid and byuser");
×
458
        }
459

460
        // Look up the poster (needed for both log and email).
461
        $posterId = (int) DB::table('messages')->where('id', $msgId)->value('fromuser');
8✔
462

463
        // Determine the log subtype from the task type.
464
        // email_message_approved → Approved
465
        // email_message_rejected with subject → Rejected, without subject → Deleted
466
        // email_message_reply → Replied
467
        $subtype = match ($taskType) {
8✔
468
            'email_message_approved' => 'Approved',
2✔
469
            'email_message_rejected' => $subject !== '' ? 'Rejected' : 'Deleted',
5✔
470
            'email_message_reply' => 'Replied',
1✔
471
            default => 'Approved',
×
472
        };
8✔
473

474
        // Always create the mod log entry (even if no stdmsg content).
475
        DB::table('logs')->insert([
8✔
476
            'timestamp' => now(),
8✔
477
            'type' => 'Message',
8✔
478
            'subtype' => $subtype,
8✔
479
            'msgid' => $msgId,
8✔
480
            'user' => $posterId ?: null,
8✔
481
            'byuser' => $byUser,
8✔
482
            'groupid' => $groupId ?: null,
8✔
483
            'stdmsgid' => $stdmsgId ?: null,
8✔
484
            'text' => $subject,
8✔
485
        ]);
8✔
486

487
        // Queue push notifications to group moderators.
488
        if ($groupId > 0) {
8✔
489
            $pushService->notifyGroupMods($groupId);
8✔
490
        }
491

492
        // No subject/body means no stdmsg email to send (e.g. plain approve without message).
493
        if ($subject === '' && $body === '') {
8✔
494
            Log::info("Mod action {$taskType} without stdmsg content, skipping email", [
1✔
495
                'msgid' => $msgId,
1✔
496
                'byuser' => $byUser,
1✔
497
            ]);
1✔
498
            return;
1✔
499
        }
500

501
        if (! $posterId) {
7✔
502
            Log::warning("No poster found for message {$msgId}");
×
503
            return;
×
504
        }
505

506
        $poster = User::find($posterId);
7✔
507
        $posterEmail = $poster?->email_preferred;
7✔
508

509
        if (! $posterEmail) {
7✔
510
            Log::warning("No email found for poster of message {$msgId}");
×
511
            return;
×
512
        }
513

514
        // Look up the group info.
515
        $groupName = '';
7✔
516
        $groupNameShort = '';
7✔
517
        $groupContactMail = null;
7✔
518
        if ($groupId > 0) {
7✔
519
            $group = DB::table('groups')->where('id', $groupId)->first();
7✔
520
            if ($group) {
7✔
521
                $groupName = $group->namefull ?: $group->nameshort ?? '';
7✔
522
                $groupNameShort = $group->nameshort ?? '';
7✔
523
                $groupContactMail = $group->contactmail ?: null;
7✔
524
            }
525
        }
526

527
        // Look up the mod's display name.
528
        $modName = DB::table('users')->where('id', $byUser)->value('fullname') ?? 'A volunteer';
7✔
529

530
        // Look up the message subject for context.
531
        $messageSubject = DB::table('messages')->where('id', $msgId)->value('subject') ?? '';
7✔
532

533
        $mail = new ModStdMessageMail(
7✔
534
            modName: $modName,
7✔
535
            groupName: $groupName,
7✔
536
            groupNameShort: $groupNameShort,
7✔
537
            stdSubject: $subject,
7✔
538
            stdBody: $body,
7✔
539
            messageSubject: $messageSubject,
7✔
540
            msgId: $msgId,
7✔
541
            recipientUserId: $posterId,
7✔
542
            recipientEmail: $posterEmail,
7✔
543
            groupContactMail: $groupContactMail,
7✔
544
        );
7✔
545

546
        if ($shouldSpool) {
7✔
547
            $spooler->spool($mail, $posterEmail);
×
548
        } else {
549
            Mail::to($posterEmail)->send($mail);
7✔
550
        }
551

552
        // V1 parity: send BCC copy if configured in mod's ModConfig.
553
        $this->sendBccIfConfigured(
7✔
554
            data: $data,
7✔
555
            byUser: $byUser,
7✔
556
            groupId: $groupId,
7✔
557
            groupNameShort: $groupNameShort,
7✔
558
            groupName: $groupName,
7✔
559
            subject: $subject,
7✔
560
            body: $body,
7✔
561
            recipientUserId: $posterId,
7✔
562
            recipientEmail: $posterEmail,
7✔
563
            messageSubject: $messageSubject,
7✔
564
            msgId: $msgId,
7✔
565
            groupContactMail: $groupContactMail,
7✔
566
            modName: $modName,
7✔
567
            spooler: $spooler,
7✔
568
            shouldSpool: $shouldSpool,
7✔
569
        );
7✔
570

571
        // Create a User2Mod chat message so the conversation appears in modtools chats.
572
        if ($groupId > 0) {
7✔
573
            $chatRoom = ChatRoom::getOrCreateUser2Mod($posterId, $groupId);
7✔
574

575
            if ($chatRoom) {
7✔
576
                DB::table('chat_messages')->insert([
7✔
577
                    'chatid' => $chatRoom->id,
7✔
578
                    'userid' => $byUser,
7✔
579
                    'message' => "{$subject}\r\n\r\n{$body}",
7✔
580
                    'type' => 'ModMail',
7✔
581
                    'refmsgid' => $msgId,
7✔
582
                    'date' => now(),
7✔
583
                    'reviewrequired' => 0,
7✔
584
                    'processingrequired' => 0,
7✔
585
                    'processingsuccessful' => 1,
7✔
586
                ]);
7✔
587
            }
588
        }
589

590
        Log::info("Sent mod stdmsg email ({$taskType})", [
7✔
591
            'msgid' => $msgId,
7✔
592
            'byuser' => $byUser,
7✔
593
            'groupid' => $groupId,
7✔
594
            'recipient' => $posterEmail,
7✔
595
        ]);
7✔
596
    }
597

598
    /**
599
     * Handle mod standard message emails sent to a member (not related to a message).
600
     *
601
     * V1 parity with User::mail() + User::maybeMail():
602
     * 1. Send email to the member.
603
     * 2. Create a User2Mod chat message for the mod log.
604
     */
605
    protected function handleModStdMessageForMember(
7✔
606
        string $taskType,
607
        array $data,
608
        EmailSpoolerService $spooler,
609
        bool $shouldSpool
610
    ): void {
611
        $userId = (int) ($data['userid'] ?? 0);
7✔
612
        $byUser = (int) ($data['byuser'] ?? 0);
7✔
613
        $groupId = (int) ($data['groupid'] ?? 0);
7✔
614
        $subject = $data['subject'] ?? '';
7✔
615
        $body = $data['body'] ?? '';
7✔
616
        $stdmsgId = (int) ($data['stdmsgid'] ?? 0);
7✔
617

618
        if ($userId === 0 || $byUser === 0) {
7✔
619
            throw new \RuntimeException('email_mod_stdmsg requires userid and byuser');
×
620
        }
621

622
        if ($subject === '' && $body === '') {
7✔
623
            Log::info('Mod stdmsg for member without content, skipping email', [
1✔
624
                'userid' => $userId,
1✔
625
                'byuser' => $byUser,
1✔
626
            ]);
1✔
627
            return;
1✔
628
        }
629

630
        // Look up the member's preferred email.
631
        $member = User::find($userId);
6✔
632
        $memberEmail = $member?->email_preferred;
6✔
633

634
        if (! $memberEmail) {
6✔
635
            Log::warning("No email found for member {$userId}");
×
636
            return;
×
637
        }
638

639
        // Look up group info.
640
        $groupName = '';
6✔
641
        $groupNameShort = '';
6✔
642
        $groupContactMail = null;
6✔
643
        if ($groupId > 0) {
6✔
644
            $group = DB::table('groups')->where('id', $groupId)->first();
6✔
645
            if ($group) {
6✔
646
                $groupName = $group->namefull ?: $group->nameshort ?? '';
6✔
647
                $groupNameShort = $group->nameshort ?? '';
6✔
648
                $groupContactMail = $group->contactmail ?: null;
6✔
649
            }
650
        }
651

652
        // Look up the mod's display name.
653
        $modName = DB::table('users')->where('id', $byUser)->value('fullname') ?? 'A volunteer';
6✔
654

655
        $mail = new ModStdMessageMail(
6✔
656
            modName: $modName,
6✔
657
            groupName: $groupName,
6✔
658
            groupNameShort: $groupNameShort,
6✔
659
            stdSubject: $subject,
6✔
660
            stdBody: $body,
6✔
661
            messageSubject: '',
6✔
662
            msgId: 0,
6✔
663
            recipientUserId: $userId,
6✔
664
            recipientEmail: $memberEmail,
6✔
665
            groupContactMail: $groupContactMail,
6✔
666
        );
6✔
667

668
        if ($shouldSpool) {
6✔
669
            $spooler->spool($mail, $memberEmail);
×
670
        } else {
671
            Mail::to($memberEmail)->send($mail);
6✔
672
        }
673

674
        // V1 parity: send BCC copy if configured in mod's ModConfig.
675
        $this->sendBccIfConfigured(
6✔
676
            data: $data,
6✔
677
            byUser: $byUser,
6✔
678
            groupId: $groupId,
6✔
679
            groupNameShort: $groupNameShort,
6✔
680
            groupName: $groupName,
6✔
681
            subject: $subject,
6✔
682
            body: $body,
6✔
683
            recipientUserId: $userId,
6✔
684
            recipientEmail: $memberEmail,
6✔
685
            messageSubject: '',
6✔
686
            msgId: 0,
6✔
687
            groupContactMail: $groupContactMail,
6✔
688
            modName: $modName,
6✔
689
            spooler: $spooler,
6✔
690
            shouldSpool: $shouldSpool,
6✔
691
        );
6✔
692

693
        // Create a User2Mod chat message so the conversation appears in modtools chats.
694
        if ($groupId > 0) {
6✔
695
            $chatRoom = ChatRoom::getOrCreateUser2Mod($userId, $groupId);
6✔
696

697
            if ($chatRoom) {
6✔
698
                $chatMessageId = DB::table('chat_messages')->insertGetId([
6✔
699
                    'chatid' => $chatRoom->id,
6✔
700
                    'userid' => $byUser,
6✔
701
                    'message' => "{$subject}\r\n\r\n{$body}",
6✔
702
                    'type' => 'ModMail',
6✔
703
                    'date' => now(),
6✔
704
                    'reviewrequired' => 0,
6✔
705
                    'processingrequired' => 0,
6✔
706
                    'processingsuccessful' => 1,
6✔
707
                ]);
6✔
708

709
                // V1 parity: upToDate() — mark the chat message as already emailed to the member
710
                // so the notification daemon (NotifyUser2ModCommand) does not send a duplicate.
711
                // V1 calls $r->upToDate($fromuser) after the direct email send, which sets
712
                // lastmsgemailed = MAX(chat_messages.id) for the member's roster entry.
713
                DB::table('chat_roster')->upsert(
6✔
714
                    [
6✔
715
                        'chatid' => $chatRoom->id,
6✔
716
                        'userid' => $userId,
6✔
717
                        'lastmsgemailed' => $chatMessageId,
6✔
718
                        'lastemailed' => now(),
6✔
719
                    ],
6✔
720
                    ['chatid', 'userid'],
6✔
721
                    ['lastmsgemailed', 'lastemailed']
6✔
722
                );
6✔
723
            }
724

725
            // Only create the User/Mailed log for email_mod_stdmsg (direct mod message to member).
726
            // Membership approve/reject actions no longer route here — they use email_mod_stdmsg
727
            // directly (or create no task if no content), so we only log when it's a direct modmail.
728
            if ($taskType === 'email_mod_stdmsg') {
6✔
729
                DB::table('logs')->insert([
6✔
730
                    'timestamp' => now(),
6✔
731
                    'type' => 'User',
6✔
732
                    'subtype' => 'Mailed',
6✔
733
                    'byuser' => $byUser,
6✔
734
                    'user' => $userId,
6✔
735
                    'groupid' => $groupId,
6✔
736
                    'stdmsgid' => $stdmsgId ?: null,
6✔
737
                    'text' => $subject,
6✔
738
                ]);
6✔
739
                // Note: users_modmails is populated by the syncModMailCounts cron job
740
                // which scans the logs table — no direct insert needed here.
741
            }
742
        }
743

744
        Log::info('Sent mod stdmsg email to member', [
6✔
745
            'userid' => $userId,
6✔
746
            'byuser' => $byUser,
6✔
747
            'groupid' => $groupId,
6✔
748
            'recipient' => $memberEmail,
6✔
749
        ]);
6✔
750
    }
751

752
    /**
753
     * Handle message outcome background processing.
754
     *
755
     * V1 parity with Message::backgroundMark():
756
     * 1. Log the outcome to the logs table for each group the message is on.
757
     * 2. Notify interested users (who replied but didn't get the item) by creating
758
     *    TYPE_COMPLETED chat messages in their User2User chat rooms.
759
     */
760
    protected function handleMessageOutcome(array $data): void
3✔
761
    {
762
        $msgId = (int) ($data['msgid'] ?? 0);
3✔
763
        $byUser = (int) ($data['byuser'] ?? 0);
3✔
764
        $outcome = $data['outcome'] ?? '';
3✔
765
        $happiness = $data['happiness'] ?? '';
3✔
766
        $comment = $data['comment'] ?? '';
3✔
767
        $userid = (int) ($data['userid'] ?? 0);
3✔
768
        $messageForOthers = $data['message'] ?? '';
3✔
769

770
        if ($msgId === 0) {
3✔
771
            throw new \RuntimeException('message_outcome requires msgid');
×
772
        }
773

774
        // Get the message poster.
775
        $fromUser = (int) (DB::table('messages')->where('id', $msgId)->value('fromuser') ?? 0);
3✔
776

777
        // 1. Log the outcome for each group (V1: Log::TYPE_MESSAGE, Log::SUBTYPE_OUTCOME).
778
        $groups = DB::table('messages_groups')->where('msgid', $msgId)->pluck('groupid');
3✔
779

780
        foreach ($groups as $groupId) {
3✔
781
            DB::table('logs')->insert([
3✔
782
                'timestamp' => now(),
3✔
783
                'type' => 'Message',
3✔
784
                'subtype' => 'Outcome',
3✔
785
                'msgid' => $msgId,
3✔
786
                'user' => $fromUser ?: null,
3✔
787
                'byuser' => $byUser ?: null,
3✔
788
                'groupid' => $groupId,
3✔
789
                'text' => trim("{$outcome} {$comment}"),
3✔
790
            ]);
3✔
791
        }
792

793
        // 2. Notify interested users who replied but didn't get the item.
794
        // Find User2User chat rooms with INTERESTED messages referencing this message,
795
        // excluding users who are in messages_by (i.e. who got the item).
796
        $replies = DB::select(
3✔
797
            "SELECT DISTINCT chatid FROM chat_messages
3✔
798
             INNER JOIN chat_rooms ON chat_rooms.id = chat_messages.chatid AND chat_rooms.chattype = 'User2User'
799
             LEFT JOIN messages_by ON messages_by.msgid = chat_messages.refmsgid
800
                 AND messages_by.userid IN (chat_rooms.user1, chat_rooms.user2)
801
             WHERE refmsgid = ? AND chat_messages.type = 'Interested'
802
                 AND reviewrejected = 0 AND messages_by.id IS NULL",
3✔
803
            [$msgId]
3✔
804
        );
3✔
805

806
        foreach ($replies as $reply) {
3✔
807
            // Check if this message was unpromised in this chat (TYPE_RENEGED).
808
            // If so, don't send the generic message as it may not be appropriate.
809
            $unpromised = DB::table('chat_messages')
2✔
810
                ->where('chatid', $reply->chatid)
2✔
811
                ->where('refmsgid', $msgId)
2✔
812
                ->where('type', 'Reneged')
2✔
813
                ->exists();
2✔
814

815
            DB::table('chat_messages')->insert([
2✔
816
                'chatid' => $reply->chatid,
2✔
817
                'userid' => $fromUser,
2✔
818
                'message' => $unpromised ? null : ($messageForOthers ?: null),
2✔
819
                'type' => 'Completed',
2✔
820
                'refmsgid' => $msgId,
2✔
821
                'date' => now(),
2✔
822
                'reviewrequired' => 0,
2✔
823
                'processingrequired' => 0,
2✔
824
                'processingsuccessful' => 1,
2✔
825
            ]);
2✔
826

827
            // Mark the poster as up-to-date in this chat so it doesn't appear as unread to them.
828
            if ($fromUser) {
2✔
829
                DB::table('chat_roster')->updateOrInsert(
2✔
830
                    ['chatid' => $reply->chatid, 'userid' => $fromUser],
2✔
831
                    ['lastmsgseen' => DB::raw('(SELECT MAX(id) FROM chat_messages WHERE chatid = ' . (int) $reply->chatid . ')'), 'date' => now()]
2✔
832
                );
2✔
833
            }
834
        }
835

836
        Log::info('Processed message outcome', [
3✔
837
            'msgid' => $msgId,
3✔
838
            'outcome' => $outcome,
3✔
839
            'groups' => $groups->count(),
3✔
840
            'notified_chats' => count($replies),
3✔
841
        ]);
3✔
842
    }
843

844
    /**
845
     * Handle merge offer email — sends to both users involved in a merge.
846
     *
847
     * V1 parity: merge.php lines 149-211.
848
     */
849
    protected function handleEmailMerge(
2✔
850
        array $data,
851
        EmailSpoolerService $spooler,
852
        bool $shouldSpool
853
    ): void {
854
        $mergeId = (int) ($data['merge_id'] ?? 0);
2✔
855
        $uid = $data['uid'] ?? '';
2✔
856
        $user1Id = (int) ($data['user1'] ?? 0);
2✔
857
        $user2Id = (int) ($data['user2'] ?? 0);
2✔
858

859
        if ($mergeId === 0 || $uid === '' || $user1Id === 0 || $user2Id === 0) {
2✔
860
            throw new \RuntimeException('email_merge requires merge_id, uid, user1, user2');
×
861
        }
862

863
        $u1 = User::find($user1Id);
2✔
864
        $u2 = User::find($user2Id);
2✔
865

866
        if (! $u1 || ! $u2) {
2✔
867
            Log::warning('Merge user not found', ['user1' => $user1Id, 'user2' => $user2Id]);
×
868
            return;
×
869
        }
870

871
        $mergeUrl = config('freegle.sites.user') . '/merge?id=' . $mergeId . '&uid=' . $uid;
2✔
872
        $name1 = $u1->fullname ?: 'Freegle User';
2✔
873
        $name2 = $u2->fullname ?: 'Freegle User';
2✔
874
        $email1 = $this->obfuscateEmail($u1->email_preferred ?? '');
2✔
875
        $email2 = $this->obfuscateEmail($u2->email_preferred ?? '');
2✔
876

877
        // Send to both users.
878
        foreach ([$u1, $u2] as $recipient) {
2✔
879
            $recipientEmail = $recipient->email_preferred;
2✔
880

881
            if (! $recipientEmail) {
2✔
882
                continue;
×
883
            }
884

885
            $mail = new MergeOfferMail(
2✔
886
                recipientUserId: $recipient->id,
2✔
887
                recipientName: $recipient->fullname ?: 'Freegle User',
2✔
888
                recipientEmail: $recipientEmail,
2✔
889
                name1: $name1,
2✔
890
                email1: $email1,
2✔
891
                name2: $name2,
2✔
892
                email2: $email2,
2✔
893
                mergeUrl: $mergeUrl,
2✔
894
            );
2✔
895

896
            if ($shouldSpool) {
2✔
897
                $spooler->spool($mail, $recipientEmail);
×
898
            } else {
899
                Mail::to($recipientEmail)->send($mail);
2✔
900
            }
901
        }
902

903
        Log::info('Sent merge offer emails', [
2✔
904
            'merge_id' => $mergeId,
2✔
905
            'user1' => $user1Id,
2✔
906
            'user2' => $user2Id,
2✔
907
        ]);
2✔
908
    }
909

910
    /**
911
     * Handle email verification — generates a validate key and sends a confirmation link.
912
     *
913
     * V1 parity: User::verifyEmail() lines 3822-3896.
914
     */
915
    protected function handleEmailVerify(
3✔
916
        array $data,
917
        EmailSpoolerService $spooler,
918
        bool $shouldSpool
919
    ): void {
920
        $userId = (int) ($data['user_id'] ?? 0);
3✔
921
        $email = $data['email'] ?? '';
3✔
922

923
        if ($userId === 0 || $email === '') {
3✔
924
            throw new \RuntimeException('email_verify requires user_id and email');
×
925
        }
926

927
        $user = User::find($userId);
3✔
928

929
        if (! $user) {
3✔
930
            Log::warning("User not found for email verify: {$userId}");
×
931
            return;
×
932
        }
933

934
        // Check if this email is already one of the user's emails.
935
        $canon = strtolower(trim($email));
3✔
936
        $existing = DB::table('users_emails')
3✔
937
            ->where('userid', $userId)
3✔
938
            ->whereRaw('LOWER(email) = ?', [$canon])
3✔
939
            ->exists();
3✔
940

941
        if ($existing) {
3✔
942
            // Already the user's email — just make it primary.
943
            DB::table('users_emails')
1✔
944
                ->where('userid', $userId)
1✔
945
                ->whereRaw('LOWER(email) = ?', [$canon])
1✔
946
                ->update(['preferred' => 1]);
1✔
947

948
            DB::table('users_emails')
1✔
949
                ->where('userid', $userId)
1✔
950
                ->whereRaw('LOWER(email) != ?', [$canon])
1✔
951
                ->update(['preferred' => 0]);
1✔
952

953
            Log::info('Email already belongs to user, made primary', [
1✔
954
                'user_id' => $userId,
1✔
955
                'email' => $email,
1✔
956
            ]);
1✔
957
            return;
1✔
958
        }
959

960
        // Generate a validation key. Check if one was recently set (< 600s) to avoid confusion.
961
        $recentKey = DB::table('users_emails')
2✔
962
            ->where('canon', $canon)
2✔
963
            ->whereRaw('TIMESTAMPDIFF(SECOND, validatetime, NOW()) < 600')
2✔
964
            ->value('validatekey');
2✔
965

966
        $key = $recentKey;
2✔
967

968
        if (! $key) {
2✔
969
            $key = uniqid();
2✔
970
            DB::statement(
2✔
971
                'INSERT INTO users_emails (email, canon, validatekey, backwards) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE validatekey = ?',
2✔
972
                [$email, $canon, $key, strrev($canon), $key]
2✔
973
            );
2✔
974
        }
975

976
        // Generate the confirm URL with auto-login.
977
        $userKey = $user->getUserKey();
2✔
978
        $confirmPath = '/settings/confirmmail/' . urlencode($key);
2✔
979
        $userSite = config('freegle.sites.user');
2✔
980
        $confirmUrl = "{$userSite}{$confirmPath}?u={$userId}&k={$userKey}&src=changeemail";
2✔
981

982
        $mail = new VerifyEmailMail(
2✔
983
            userId: $userId,
2✔
984
            email: $email,
2✔
985
            confirmUrl: $confirmUrl,
2✔
986
        );
2✔
987

988
        if ($shouldSpool) {
2✔
989
            $spooler->spool($mail, $email);
×
990
        } else {
991
            Mail::to($email)->send($mail);
2✔
992
        }
993

994
        Log::info('Sent email verification', [
2✔
995
            'user_id' => $userId,
2✔
996
            'email' => $email,
2✔
997
        ]);
2✔
998
    }
999

1000
    /**
1001
     * Handle refer-to-support — sends a plain text email to the support team.
1002
     *
1003
     * V1 parity: ChatRoom::referToSupport() lines 2266-2284.
1004
     */
1005
    protected function handleReferToSupport(
1✔
1006
        array $data,
1007
        EmailSpoolerService $spooler,
1008
        bool $shouldSpool
1009
    ): void {
1010
        $chatId = (int) ($data['chatid'] ?? 0);
1✔
1011
        $userId = (int) ($data['userid'] ?? 0);
1✔
1012

1013
        if ($chatId === 0 || $userId === 0) {
1✔
1014
            throw new \RuntimeException('refer_to_support requires chatid and userid');
×
1015
        }
1016

1017
        $chat = DB::table('chat_rooms')->where('id', $chatId)->first();
1✔
1018

1019
        if (! $chat) {
1✔
1020
            Log::warning("Chat not found for refer_to_support: {$chatId}");
×
1021
            return;
×
1022
        }
1023

1024
        $user = User::find($userId);
1✔
1025

1026
        if (! $user) {
1✔
1027
            Log::warning("User not found for refer_to_support: {$userId}");
×
1028
            return;
×
1029
        }
1030

1031
        // The "other" user in the chat.
1032
        $otherUserId = $chat->user1 == $userId ? $chat->user2 : $chat->user1;
1✔
1033
        $otherUser = $otherUserId ? User::find($otherUserId) : null;
1✔
1034
        $otherUserName = $otherUser ? ($otherUser->fullname ?: 'Unknown') : 'Unknown';
1✔
1035

1036
        // Get group mods email for reply-to.
1037
        $groupId = $chat->groupid;
1✔
1038
        $replyToAddress = config('freegle.mail.noreply_addr');
1✔
1039
        $replyToName = config('freegle.branding.name');
1✔
1040

1041
        if ($groupId) {
1✔
1042
            $group = DB::table('groups')->where('id', $groupId)->first();
1✔
1043

1044
            if ($group) {
1✔
1045
                $groupNameShort = $group->nameshort ?? '';
1✔
1046
                $replyToAddress = $groupNameShort . '-volunteers@' . config('freegle.mail.group_domain', 'groups.ilovefreegle.org');
1✔
1047
                $replyToName = ($group->namefull ?: $groupNameShort) . ' Volunteers';
1✔
1048
            }
1049
        }
1050

1051
        $mail = new ReferToSupportMail(
1✔
1052
            userName: $user->fullname ?: 'Unknown',
1✔
1053
            userId: $userId,
1✔
1054
            chatId: $chatId,
1✔
1055
            otherUserName: $otherUserName,
1✔
1056
            otherUserId: (int) ($otherUserId ?? 0),
1✔
1057
            replyToAddress: $replyToAddress,
1✔
1058
            replyToName: $replyToName,
1✔
1059
        );
1✔
1060

1061
        $supportAddr = config('freegle.mail.support_addr', 'support@ilovefreegle.org');
1✔
1062
        $recipients = array_map('trim', explode(',', $supportAddr));
1✔
1063

1064
        if ($shouldSpool) {
1✔
1065
            $spooler->spool($mail, $recipients);
×
1066
        } else {
1067
            Mail::to($recipients)->send($mail);
1✔
1068
        }
1069

1070
        Log::info('Sent refer to support email', [
1✔
1071
            'chat_id' => $chatId,
1✔
1072
            'user_id' => $userId,
1✔
1073
        ]);
1✔
1074
    }
1075

1076
    /**
1077
     * Process a housekeeping notification from the Chrome extension.
1078
     */
1079
    protected function handleHousekeeperNotify(array $data): void
×
1080
    {
1081
        $service = app(HousekeeperService::class);
×
1082
        $service->process($data);
×
1083
    }
1084

1085
    /**
1086
     * Resolve the BCC address for a mod standard message action.
1087
     *
1088
     * V1 parity: ModConfig::getForGroup() + ModConfig::getBcc() + ModConfig::evalIt().
1089
     *
1090
     * @param int    $byUser  The moderator's user ID
1091
     * @param int    $groupId The group ID
1092
     * @param string $action  The action string (Approve, Reject, Leave Approved Member, etc.)
1093
     * @return string|null    The resolved BCC email address, or null if none configured
1094
     */
1095
    private function resolveBccAddress(int $byUser, int $groupId, string $action): ?string
7✔
1096
    {
1097
        if ($groupId === 0 || $action === '') {
7✔
1098
            return null;
×
1099
        }
1100

1101
        // Step 1: Find the mod's config for this group (V1: ModConfig::getForGroup).
1102
        $configId = DB::table('memberships')
7✔
1103
            ->where('userid', $byUser)
7✔
1104
            ->where('groupid', $groupId)
7✔
1105
            ->value('configid');
7✔
1106

1107
        if (! $configId) {
7✔
1108
            // Fall back to any other mod's config for this group.
1109
            $configId = DB::table('memberships')
3✔
1110
                ->where('groupid', $groupId)
3✔
1111
                ->whereIn('role', ['Moderator', 'Owner'])
3✔
1112
                ->whereNotNull('configid')
3✔
1113
                ->value('configid');
3✔
1114
        }
1115

1116
        if (! $configId) {
7✔
1117
            // Fall back to any config created by this mod.
1118
            $configId = DB::table('mod_configs')
2✔
1119
                ->where('createdby', $byUser)
2✔
1120
                ->value('id');
2✔
1121
        }
1122

1123
        if (! $configId) {
7✔
1124
            // Fall back to a default config.
1125
            $configId = DB::table('mod_configs')
2✔
1126
                ->where('default', 1)
2✔
1127
                ->value('id');
2✔
1128
        }
1129

1130
        if (! $configId) {
7✔
1131
            return null;
2✔
1132
        }
1133

1134
        // Step 2: Map action to CC column pair (V1: ModConfig::getBcc).
1135
        [$toColumn, $addrColumn] = match ($action) {
5✔
1136
            'Approve', 'Reject', 'Leave' => ['ccrejectto', 'ccrejectaddr'],
4✔
1137
            'Leave Member' => ['ccrejmembto', 'ccrejmembaddr'],
×
1138
            'Leave Approved Message', 'Delete Approved Message' => ['ccfollowupto', 'ccfollowupaddr'],
×
1139
            'Leave Approved Member', 'Delete Approved Member' => ['ccfollmembto', 'ccfollmembaddr'],
1✔
1140
            default => [null, null],
5✔
1141
        };
5✔
1142

1143
        if (! $toColumn) {
5✔
1144
            return null;
×
1145
        }
1146

1147
        // Step 3: Look up the config and evaluate (V1: ModConfig::evalIt).
1148
        $config = DB::table('mod_configs')
5✔
1149
            ->where('id', $configId)
5✔
1150
            ->first([$toColumn, $addrColumn]);
5✔
1151

1152
        if (! $config) {
5✔
1153
            return null;
×
1154
        }
1155

1156
        $to = $config->$toColumn;
5✔
1157
        $addr = $config->$addrColumn;
5✔
1158

1159
        if ($to === 'Me') {
5✔
1160
            $modUser = User::find($byUser);
1✔
1161

1162
            return $modUser?->email_preferred;
1✔
1163
        }
1164

1165
        if ($to === 'Specific') {
4✔
1166
            return $addr ?: null;
3✔
1167
        }
1168

1169
        return null;
1✔
1170
    }
1171

1172
    /**
1173
     * Send a BCC copy of a mod standard message if configured.
1174
     *
1175
     * V1 parity: both Message::mail() and User::maybeMail() send a BCC copy
1176
     * with body prefixed by "(This is a BCC of a message sent to Freegle user #...)".
1177
     */
1178
    private function sendBccIfConfigured(
13✔
1179
        array $data,
1180
        int $byUser,
1181
        int $groupId,
1182
        string $groupNameShort,
1183
        string $groupName,
1184
        string $subject,
1185
        string $body,
1186
        int $recipientUserId,
1187
        string $recipientEmail,
1188
        string $messageSubject,
1189
        int $msgId,
1190
        ?string $groupContactMail,
1191
        string $modName,
1192
        EmailSpoolerService $spooler,
1193
        bool $shouldSpool
1194
    ): void {
1195
        $action = $data['action'] ?? '';
13✔
1196
        if ($action === '' || $groupId === 0) {
13✔
1197
            return;
6✔
1198
        }
1199

1200
        $bccAddress = $this->resolveBccAddress($byUser, $groupId, $action);
7✔
1201
        if (! $bccAddress) {
7✔
1202
            return;
3✔
1203
        }
1204

1205
        // V1: replace $groupname in BCC address.
1206
        $bccAddress = str_replace('$groupname', $groupNameShort, $bccAddress);
4✔
1207

1208
        // V1: prefix BCC body with notice.
1209
        $bccBody = "(This is a BCC of a message sent to Freegle user #{$recipientUserId} {$recipientEmail})\n\n{$body}";
4✔
1210

1211
        $bccMail = new ModStdMessageMail(
4✔
1212
            modName: $modName,
4✔
1213
            groupName: $groupName,
4✔
1214
            groupNameShort: $groupNameShort,
4✔
1215
            stdSubject: $subject,
4✔
1216
            stdBody: $bccBody,
4✔
1217
            messageSubject: $messageSubject,
4✔
1218
            msgId: $msgId,
4✔
1219
            recipientUserId: 0,
4✔
1220
            recipientEmail: $bccAddress,
4✔
1221
            groupContactMail: $groupContactMail,
4✔
1222
        );
4✔
1223

1224
        if ($shouldSpool) {
4✔
1225
            $spooler->spool($bccMail, $bccAddress);
×
1226
        } else {
1227
            Mail::to($bccAddress)->send($bccMail);
4✔
1228
        }
1229

1230
        Log::info('Sent BCC copy of mod stdmsg', [
4✔
1231
            'action' => $action,
4✔
1232
            'bcc' => $bccAddress,
4✔
1233
            'byuser' => $byUser,
4✔
1234
            'groupid' => $groupId,
4✔
1235
        ]);
4✔
1236
    }
1237

1238
    /**
1239
     * Add a post to freebiealerts.app.
1240
     *
1241
     * V1 parity with FreebieAlerts::add() — only sends outstanding Offers with
1242
     * a location. TrashNothing messages are skipped (TN syncs directly).
1243
     */
1244
    protected function handleFreebieAlertsAdd(array $data): void
4✔
1245
    {
1246
        $msgId = (int) ($data['msgid'] ?? 0);
4✔
1247
        if ($msgId === 0) {
4✔
1248
            throw new \RuntimeException('freebie_alerts_add requires msgid');
×
1249
        }
1250

1251
        $apiKey = config('freegle.freebie_alerts.api_key');
4✔
1252
        if (empty($apiKey)) {
4✔
1253
            Log::debug('Freebie Alerts API key not configured, skipping add', ['msgid' => $msgId]);
1✔
1254
            return;
1✔
1255
        }
1256

1257
        // Only outstanding Offers (no outcome yet).
1258
        $msg = DB::table('messages')->where('id', $msgId)->first();
3✔
1259
        if (! $msg || $msg->type !== 'Offer') {
3✔
1260
            return;
1✔
1261
        }
1262

1263
        $hasOutcome = DB::table('messages_outcomes')->where('msgid', $msgId)->exists();
2✔
1264
        if ($hasOutcome) {
2✔
1265
            return;
1✔
1266
        }
1267

1268
        // Skip TrashNothing messages — TN syncs to freebiealerts directly.
1269
        if ($msg->sourceheader && str_starts_with($msg->sourceheader, 'TN-')) {
1✔
1270
            return;
×
1271
        }
1272

1273
        $group = DB::table('messages_groups')
1✔
1274
            ->where('msgid', $msgId)
1✔
1275
            ->where('collection', 'Approved')
1✔
1276
            ->first();
1✔
1277
        if (! $group) {
1✔
1278
            return;
×
1279
        }
1280

1281
        if (! $msg->lat || ! $msg->lng) {
1✔
1282
            return;
×
1283
        }
1284

1285
        // Build image list from message attachments.
1286
        // Same pattern as digest emails: externalurl if set, otherwise {images.domain}/timg_{id}.jpg.
1287
        $imagesDomain = config('freegle.images.domain', 'https://images.ilovefreegle.org');
1✔
1288
        $attachments = DB::table('messages_attachments')->where('msgid', $msgId)->get();
1✔
1289
        $images = $attachments->map(function ($att) use ($imagesDomain) {
1✔
1290
            return ! empty($att->externalurl)
×
1291
                ? $att->externalurl
×
1292
                : "{$imagesDomain}/timg_{$att->id}.jpg";
×
1293
        })->implode(',');
1✔
1294

1295
        $body = $msg->textbody ?: 'No description';
1✔
1296

1297
        $payload = [
1✔
1298
            'id' => $msgId,
1✔
1299
            'title' => $msg->subject,
1✔
1300
            'description' => $body,
1✔
1301
            'latitude' => $msg->lat,
1✔
1302
            'longitude' => $msg->lng,
1✔
1303
            'images' => $images,
1✔
1304
            'created_at' => $group->arrival,
1✔
1305
        ];
1✔
1306

1307
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1308

1309
        try {
1310
            $response = \Illuminate\Support\Facades\Http::withHeaders([
1✔
1311
                'Content-type' => 'application/json',
1✔
1312
                'Key' => $apiKey,
1✔
1313
            ])->timeout(60)->post("{$apiUrl}/freegle/post/create", $payload);
1✔
1314

1315
            if ($response->successful()) {
1✔
1316
                Log::info('Added post to Freebie Alerts', ['msgid' => $msgId]);
1✔
1317
            } else {
1318
                Log::warning('Freebie Alerts add failed', [
×
1319
                    'msgid' => $msgId,
×
1320
                    'status' => $response->status(),
×
1321
                    'body' => $response->body(),
×
1322
                ]);
×
1323
                if (app()->bound('sentry')) {
×
1324
                    app('sentry')->captureMessage("Freebie Alerts add failed for message {$msgId}: {$response->status()}");
1✔
1325
                }
1326
            }
1327
        } catch (\Throwable $e) {
×
1328
            Log::error('Freebie Alerts add exception', [
×
1329
                'msgid' => $msgId,
×
1330
                'error' => $e->getMessage(),
×
1331
            ]);
×
1332
            if (app()->bound('sentry')) {
×
1333
                app('sentry')->captureException($e);
×
1334
            }
1335
        }
1336
    }
1337

1338
    /**
1339
     * Remove a post from freebiealerts.app.
1340
     *
1341
     * V1 parity with FreebieAlerts::remove().
1342
     */
1343
    protected function handleFreebieAlertsRemove(array $data): void
2✔
1344
    {
1345
        $msgId = (int) ($data['msgid'] ?? 0);
2✔
1346
        if ($msgId === 0) {
2✔
1347
            throw new \RuntimeException('freebie_alerts_remove requires msgid');
×
1348
        }
1349

1350
        $apiKey = config('freegle.freebie_alerts.api_key');
2✔
1351
        if (empty($apiKey)) {
2✔
1352
            Log::debug('Freebie Alerts API key not configured, skipping remove', ['msgid' => $msgId]);
1✔
1353
            return;
1✔
1354
        }
1355

1356
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1357

1358
        try {
1359
            $response = \Illuminate\Support\Facades\Http::withHeaders([
1✔
1360
                'Content-type' => 'application/json',
1✔
1361
                'Key' => $apiKey,
1✔
1362
            ])->timeout(60)->post("{$apiUrl}/freegle/post/{$msgId}/delete");
1✔
1363

1364
            if ($response->successful()) {
1✔
1365
                Log::info('Removed post from Freebie Alerts', ['msgid' => $msgId]);
1✔
1366
            } else {
1367
                Log::warning('Freebie Alerts remove failed', [
1✔
1368
                    'msgid' => $msgId,
1✔
1369
                    'status' => $response->status(),
1✔
1370
                    'body' => $response->body(),
1✔
1371
                ]);
1✔
1372
            }
1373
        } catch (\Throwable $e) {
×
1374
            Log::error('Freebie Alerts remove exception', [
×
1375
                'msgid' => $msgId,
×
1376
                'error' => $e->getMessage(),
×
1377
            ]);
×
1378
            if (app()->bound('sentry')) {
×
1379
                app('sentry')->captureException($e);
×
1380
            }
1381
        }
1382
    }
1383

1384
    /**
1385
     * Obfuscate an email address for display (e.g. "j***@example.com").
1386
     */
1387
    private function obfuscateEmail(string $email): string
2✔
1388
    {
1389
        if (! $email || ! str_contains($email, '@')) {
2✔
1390
            return $email;
×
1391
        }
1392

1393
        [$local, $domain] = explode('@', $email, 2);
2✔
1394

1395
        if (strlen($local) <= 1) {
2✔
1396
            return '*@' . $domain;
×
1397
        }
1398

1399
        return $local[0] . str_repeat('*', strlen($local) - 1) . '@' . $domain;
2✔
1400
    }
1401

1402
    /**
1403
     * Remap postcodes to their nearest area after a location geometry change.
1404
     */
1405
    protected function handleRemapPostcodes(array $data): void
×
1406
    {
1407
        $locationId = isset($data['location_id']) ? (int) $data['location_id'] : NULL;
×
1408
        $polygon = $data['polygon'] ?? NULL;
×
1409

1410
        $service = app(PostcodeRemapService::class);
×
1411
        $updated = $service->remapPostcodes($locationId, $polygon);
×
1412

1413
        Log::info('Remapped postcodes', [
×
1414
            'location_id' => $locationId,
×
1415
            'updated' => $updated,
×
1416
        ]);
×
1417
    }
1418

1419
    protected function handleUserForget(array $data): void
2✔
1420
    {
1421
        $userId = isset($data['user_id']) ? (int) $data['user_id'] : NULL;
2✔
1422
        $reason = $data['reason'] ?? 'Support purge';
2✔
1423

1424
        if (! $userId) {
2✔
1425
            throw new \RuntimeException('user_forget requires user_id');
1✔
1426
        }
1427

1428
        $service = app(UserManagementService::class);
1✔
1429
        $service->forgetUser($userId, $reason);
1✔
1430

1431
        Log::info('User forgotten via background task', [
1✔
1432
            'user_id' => $userId,
1✔
1433
            'reason'  => $reason,
1✔
1434
        ]);
1✔
1435
    }
1436
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc