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

Freegle / Iznik / 5796

24 Apr 2026 08:50AM UTC coverage: 71.608% (-0.006%) from 71.614%
5796

Pull #249

circleci

edwh
fix(ci): use PORT_FREEGLE_PROD_LOCAL/PORT_MODTOOLS_PROD_LOCAL vars in health check

Hardcoded ports 3012/3013 never matched CI runner's actual ports (12012/12013),
causing the 'Wait for production containers to be healthy' step to always hang
until the 20-minute timeout on the self-hosted runner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pull Request #249: fix(batch): report PHP fatal errors (incl. OOM) to Sentry on shutdown

13398 of 20349 branches covered (65.84%)

Branch coverage included in aggregate %.

17 of 22 new or added lines in 1 file covered. (77.27%)

6 existing lines in 1 file now uncovered.

96354 of 132918 relevant lines covered (72.49%)

21.49 hits per line

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

85.3
/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\PushNotificationService;
21
use App\Traits\GracefulShutdown;
22
use Illuminate\Console\Command;
23
use Illuminate\Support\Facades\DB;
24
use Illuminate\Support\Facades\Log;
25
use Illuminate\Support\Facades\Mail;
26
use Illuminate\Support\Str;
27

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

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

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

49
    private const MAX_ATTEMPTS = 3;
50

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

56
            return Command::SUCCESS;
×
57
        }
58

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

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

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

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

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

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

92
            if ($processed === 0) {
47✔
93
                sleep($sleepSeconds);
7✔
94
            }
95
        }
96

97
        return Command::SUCCESS;
47✔
98
    }
99

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

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

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

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

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

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

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

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

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

152
        $processed = 0;
47✔
153

154
        foreach ($tasks as $task) {
47✔
155
            if ($this->shouldStop()) {
45✔
156
                break;
×
157
            }
158

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

164
                $data = json_decode($task->data, TRUE);
45✔
165

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

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

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

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

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

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

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

198
        if ($processed > 0) {
47✔
199
            $this->info("Processed {$processed} task(s).");
40✔
200
        }
201

202
        return $processed;
47✔
203
    }
204

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

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

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

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

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

268
        $mail = new ChitchatReportMail(
2✔
269
            reporterName: $data['user_name'],
2✔
270
            reporterId: (int) $data['user_id'],
2✔
271
            reporterEmail: $data['user_email'],
2✔
272
            newsfeedId: (int) $data['newsfeed_id'],
2✔
273
            reason: $data['reason'],
2✔
274
        );
2✔
275

276
        if ($shouldSpool) {
2✔
277
            $recipients = array_map('trim', explode(',', config('freegle.mail.chitchat_support_addr')));
×
278
            $spooler->spool($mail, $recipients);
×
279
        } else {
280
            Mail::send($mail);
2✔
281
        }
282

283
        Log::info('Sent ChitChat report email', [
2✔
284
            'reporter_id' => $data['user_id'],
2✔
285
            'newsfeed_id' => $data['newsfeed_id'],
2✔
286
        ]);
2✔
287
    }
288

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

304
        $mail = new DonateExternalMail(
3✔
305
            userName: $data['user_name'],
3✔
306
            userId: (int) $data['user_id'],
3✔
307
            userEmail: $data['user_email'],
3✔
308
            amount: (float) $data['amount'],
3✔
309
            source: $data['source'] ?? DonateExternalMail::SOURCE_EXTERNAL,
3✔
310
        );
3✔
311

312
        if ($shouldSpool) {
3✔
313
            $spooler->spool($mail, config('freegle.mail.info_addr'));
×
314
        } else {
315
            Mail::send($mail);
3✔
316
        }
317

318
        Log::info('Sent external donation email', [
3✔
319
            'user_id' => $data['user_id'],
3✔
320
            'amount' => $data['amount'],
3✔
321
        ]);
3✔
322
    }
323

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

336
        $mail = new CharitySignupMail(
×
337
            charityId: (int) $data['charity_id'],
×
338
            orgName: $data['orgname'],
×
339
            orgType: $data['orgtype'] ?? 'registered',
×
340
            charityNumber: $data['charitynumber'] ?? null,
×
341
            contactEmail: $data['contactemail'],
×
342
            contactName: $data['contactname'] ?? null,
×
343
            website: $data['website'] ?? null,
×
344
            description: $data['description'] ?? null,
×
345
        );
×
346

347
        if ($shouldSpool) {
×
348
            $spooler->spool($mail, config('freegle.mail.partnerships_addr'));
×
349
        } else {
350
            Mail::send($mail);
×
351
        }
352

353
        Log::info('Sent charity signup notification', [
×
354
            'charity_id' => $data['charity_id'],
×
355
            'orgname' => $data['orgname'],
×
356
        ]);
×
357
    }
358

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

374
        $mail = new ForgotPasswordMail(
2✔
375
            userId: (int) $data['user_id'],
2✔
376
            email: $data['email'],
2✔
377
            resetUrl: $data['reset_url'],
2✔
378
        );
2✔
379

380
        if ($shouldSpool) {
2✔
381
            $spooler->spool($mail, $data['email']);
×
382
        } else {
383
            Mail::send($mail);
2✔
384
        }
385

386
        Log::info('Sent forgot password email', [
2✔
387
            'user_id' => $data['user_id'],
2✔
388
        ]);
2✔
389
    }
390

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

406
        $mail = new UnsubscribeConfirmMail(
2✔
407
            userId: (int) $data['user_id'],
2✔
408
            email: $data['email'],
2✔
409
            unsubUrl: $data['unsub_url'],
2✔
410
        );
2✔
411

412
        if ($shouldSpool) {
2✔
413
            $spooler->spool($mail, $data['email']);
×
414
        } else {
415
            Mail::send($mail);
2✔
416
        }
417

418
        Log::info('Sent unsubscribe confirmation email', [
2✔
419
            'user_id' => $data['user_id'],
2✔
420
        ]);
2✔
421
    }
422

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

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

451
        if ($msgId === 0 || $byUser === 0) {
8✔
452
            throw new \RuntimeException("{$taskType} requires msgid and byuser");
×
453
        }
454

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

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

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

482
        // Queue push notifications to group moderators.
483
        if ($groupId > 0) {
8✔
484
            $pushService->notifyGroupMods($groupId);
8✔
485
        }
486

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

496
        if (! $posterId) {
7✔
497
            Log::warning("No poster found for message {$msgId}");
×
498
            return;
×
499
        }
500

501
        $poster = User::find($posterId);
7✔
502
        $posterEmail = $poster?->email_preferred;
7✔
503

504
        if (! $posterEmail) {
7✔
505
            Log::warning("No email found for poster of message {$msgId}");
×
506
            return;
×
507
        }
508

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

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

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

528
        $mail = new ModStdMessageMail(
7✔
529
            modName: $modName,
7✔
530
            groupName: $groupName,
7✔
531
            groupNameShort: $groupNameShort,
7✔
532
            stdSubject: $subject,
7✔
533
            stdBody: $body,
7✔
534
            messageSubject: $messageSubject,
7✔
535
            msgId: $msgId,
7✔
536
            recipientUserId: $posterId,
7✔
537
            recipientEmail: $posterEmail,
7✔
538
            groupContactMail: $groupContactMail,
7✔
539
        );
7✔
540

541
        if ($shouldSpool) {
7✔
542
            $spooler->spool($mail, $posterEmail);
×
543
        } else {
544
            Mail::to($posterEmail)->send($mail);
7✔
545
        }
546

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

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

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

585
        Log::info("Sent mod stdmsg email ({$taskType})", [
7✔
586
            'msgid' => $msgId,
7✔
587
            'byuser' => $byUser,
7✔
588
            'groupid' => $groupId,
7✔
589
            'recipient' => $posterEmail,
7✔
590
        ]);
7✔
591
    }
592

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

613
        if ($userId === 0 || $byUser === 0) {
7✔
614
            throw new \RuntimeException('email_mod_stdmsg requires userid and byuser');
×
615
        }
616

617
        if ($subject === '' && $body === '') {
7✔
618
            Log::info('Mod stdmsg for member without content, skipping email', [
1✔
619
                'userid' => $userId,
1✔
620
                'byuser' => $byUser,
1✔
621
            ]);
1✔
622
            return;
1✔
623
        }
624

625
        // Look up the member's preferred email.
626
        $member = User::find($userId);
6✔
627
        $memberEmail = $member?->email_preferred;
6✔
628

629
        if (! $memberEmail) {
6✔
630
            Log::warning("No email found for member {$userId}");
×
631
            return;
×
632
        }
633

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

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

650
        $mail = new ModStdMessageMail(
6✔
651
            modName: $modName,
6✔
652
            groupName: $groupName,
6✔
653
            groupNameShort: $groupNameShort,
6✔
654
            stdSubject: $subject,
6✔
655
            stdBody: $body,
6✔
656
            messageSubject: '',
6✔
657
            msgId: 0,
6✔
658
            recipientUserId: $userId,
6✔
659
            recipientEmail: $memberEmail,
6✔
660
            groupContactMail: $groupContactMail,
6✔
661
        );
6✔
662

663
        if ($shouldSpool) {
6✔
664
            $spooler->spool($mail, $memberEmail);
×
665
        } else {
666
            Mail::to($memberEmail)->send($mail);
6✔
667
        }
668

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

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

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

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

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

739
        Log::info('Sent mod stdmsg email to member', [
6✔
740
            'userid' => $userId,
6✔
741
            'byuser' => $byUser,
6✔
742
            'groupid' => $groupId,
6✔
743
            'recipient' => $memberEmail,
6✔
744
        ]);
6✔
745
    }
746

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

765
        if ($msgId === 0) {
3✔
766
            throw new \RuntimeException('message_outcome requires msgid');
×
767
        }
768

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

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

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

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

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

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

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

831
        Log::info('Processed message outcome', [
3✔
832
            'msgid' => $msgId,
3✔
833
            'outcome' => $outcome,
3✔
834
            'groups' => $groups->count(),
3✔
835
            'notified_chats' => count($replies),
3✔
836
        ]);
3✔
837
    }
838

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

854
        if ($mergeId === 0 || $uid === '' || $user1Id === 0 || $user2Id === 0) {
2✔
855
            throw new \RuntimeException('email_merge requires merge_id, uid, user1, user2');
×
856
        }
857

858
        $u1 = User::find($user1Id);
2✔
859
        $u2 = User::find($user2Id);
2✔
860

861
        if (! $u1 || ! $u2) {
2✔
862
            Log::warning('Merge user not found', ['user1' => $user1Id, 'user2' => $user2Id]);
×
863
            return;
×
864
        }
865

866
        $mergeUrl = config('freegle.sites.user') . '/merge?id=' . $mergeId . '&uid=' . $uid;
2✔
867
        $name1 = $u1->fullname ?: 'Freegle User';
2✔
868
        $name2 = $u2->fullname ?: 'Freegle User';
2✔
869
        $email1 = $this->obfuscateEmail($u1->email_preferred ?? '');
2✔
870
        $email2 = $this->obfuscateEmail($u2->email_preferred ?? '');
2✔
871

872
        // Send to both users.
873
        foreach ([$u1, $u2] as $recipient) {
2✔
874
            $recipientEmail = $recipient->email_preferred;
2✔
875

876
            if (! $recipientEmail) {
2✔
877
                continue;
×
878
            }
879

880
            $mail = new MergeOfferMail(
2✔
881
                recipientUserId: $recipient->id,
2✔
882
                recipientName: $recipient->fullname ?: 'Freegle User',
2✔
883
                recipientEmail: $recipientEmail,
2✔
884
                name1: $name1,
2✔
885
                email1: $email1,
2✔
886
                name2: $name2,
2✔
887
                email2: $email2,
2✔
888
                mergeUrl: $mergeUrl,
2✔
889
            );
2✔
890

891
            if ($shouldSpool) {
2✔
892
                $spooler->spool($mail, $recipientEmail);
×
893
            } else {
894
                Mail::to($recipientEmail)->send($mail);
2✔
895
            }
896
        }
897

898
        Log::info('Sent merge offer emails', [
2✔
899
            'merge_id' => $mergeId,
2✔
900
            'user1' => $user1Id,
2✔
901
            'user2' => $user2Id,
2✔
902
        ]);
2✔
903
    }
904

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

918
        if ($userId === 0 || $email === '') {
3✔
919
            throw new \RuntimeException('email_verify requires user_id and email');
×
920
        }
921

922
        $user = User::find($userId);
3✔
923

924
        if (! $user) {
3✔
925
            Log::warning("User not found for email verify: {$userId}");
×
926
            return;
×
927
        }
928

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

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

943
            DB::table('users_emails')
1✔
944
                ->where('userid', $userId)
1✔
945
                ->whereRaw('LOWER(email) != ?', [$canon])
1✔
946
                ->update(['preferred' => 0]);
1✔
947

948
            Log::info('Email already belongs to user, made primary', [
1✔
949
                'user_id' => $userId,
1✔
950
                'email' => $email,
1✔
951
            ]);
1✔
952
            return;
1✔
953
        }
954

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

961
        $key = $recentKey;
2✔
962

963
        if (! $key) {
2✔
964
            $key = uniqid();
2✔
965
            DB::statement(
2✔
966
                'INSERT INTO users_emails (email, canon, validatekey, backwards) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE validatekey = ?',
2✔
967
                [$email, $canon, $key, strrev($canon), $key]
2✔
968
            );
2✔
969
        }
970

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

977
        $mail = new VerifyEmailMail(
2✔
978
            userId: $userId,
2✔
979
            email: $email,
2✔
980
            confirmUrl: $confirmUrl,
2✔
981
        );
2✔
982

983
        if ($shouldSpool) {
2✔
984
            $spooler->spool($mail, $email);
×
985
        } else {
986
            Mail::to($email)->send($mail);
2✔
987
        }
988

989
        Log::info('Sent email verification', [
2✔
990
            'user_id' => $userId,
2✔
991
            'email' => $email,
2✔
992
        ]);
2✔
993
    }
994

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

1008
        if ($chatId === 0 || $userId === 0) {
1✔
1009
            throw new \RuntimeException('refer_to_support requires chatid and userid');
×
1010
        }
1011

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

1014
        if (! $chat) {
1✔
1015
            Log::warning("Chat not found for refer_to_support: {$chatId}");
×
1016
            return;
×
1017
        }
1018

1019
        $user = User::find($userId);
1✔
1020

1021
        if (! $user) {
1✔
1022
            Log::warning("User not found for refer_to_support: {$userId}");
×
1023
            return;
×
1024
        }
1025

1026
        // The "other" user in the chat.
1027
        $otherUserId = $chat->user1 == $userId ? $chat->user2 : $chat->user1;
1✔
1028
        $otherUser = $otherUserId ? User::find($otherUserId) : null;
1✔
1029
        $otherUserName = $otherUser ? ($otherUser->fullname ?: 'Unknown') : 'Unknown';
1✔
1030

1031
        // Get group mods email for reply-to.
1032
        $groupId = $chat->groupid;
1✔
1033
        $replyToAddress = config('freegle.mail.noreply_addr');
1✔
1034
        $replyToName = config('freegle.branding.name');
1✔
1035

1036
        if ($groupId) {
1✔
1037
            $group = DB::table('groups')->where('id', $groupId)->first();
1✔
1038

1039
            if ($group) {
1✔
1040
                $groupNameShort = $group->nameshort ?? '';
1✔
1041
                $replyToAddress = $groupNameShort . '-volunteers@' . config('freegle.mail.group_domain', 'groups.ilovefreegle.org');
1✔
1042
                $replyToName = ($group->namefull ?: $groupNameShort) . ' Volunteers';
1✔
1043
            }
1044
        }
1045

1046
        $mail = new ReferToSupportMail(
1✔
1047
            userName: $user->fullname ?: 'Unknown',
1✔
1048
            userId: $userId,
1✔
1049
            chatId: $chatId,
1✔
1050
            otherUserName: $otherUserName,
1✔
1051
            otherUserId: (int) ($otherUserId ?? 0),
1✔
1052
            replyToAddress: $replyToAddress,
1✔
1053
            replyToName: $replyToName,
1✔
1054
        );
1✔
1055

1056
        $supportAddr = config('freegle.mail.support_addr', 'support@ilovefreegle.org');
1✔
1057
        $recipients = array_map('trim', explode(',', $supportAddr));
1✔
1058

1059
        if ($shouldSpool) {
1✔
1060
            $spooler->spool($mail, $recipients);
×
1061
        } else {
1062
            Mail::to($recipients)->send($mail);
1✔
1063
        }
1064

1065
        Log::info('Sent refer to support email', [
1✔
1066
            'chat_id' => $chatId,
1✔
1067
            'user_id' => $userId,
1✔
1068
        ]);
1✔
1069
    }
1070

1071
    /**
1072
     * Process a housekeeping notification from the Chrome extension.
1073
     */
1074
    protected function handleHousekeeperNotify(array $data): void
×
1075
    {
1076
        $service = app(HousekeeperService::class);
×
1077
        $service->process($data);
×
1078
    }
1079

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

1096
        // Step 1: Find the mod's config for this group (V1: ModConfig::getForGroup).
1097
        $configId = DB::table('memberships')
7✔
1098
            ->where('userid', $byUser)
7✔
1099
            ->where('groupid', $groupId)
7✔
1100
            ->value('configid');
7✔
1101

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

1111
        if (! $configId) {
7✔
1112
            // Fall back to any config created by this mod.
1113
            $configId = DB::table('mod_configs')
2✔
1114
                ->where('createdby', $byUser)
2✔
1115
                ->value('id');
2✔
1116
        }
1117

1118
        if (! $configId) {
7✔
1119
            // Fall back to a default config.
1120
            $configId = DB::table('mod_configs')
2✔
1121
                ->where('default', 1)
2✔
1122
                ->value('id');
2✔
1123
        }
1124

1125
        if (! $configId) {
7✔
1126
            return null;
2✔
1127
        }
1128

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

1138
        if (! $toColumn) {
5✔
1139
            return null;
×
1140
        }
1141

1142
        // Step 3: Look up the config and evaluate (V1: ModConfig::evalIt).
1143
        $config = DB::table('mod_configs')
5✔
1144
            ->where('id', $configId)
5✔
1145
            ->first([$toColumn, $addrColumn]);
5✔
1146

1147
        if (! $config) {
5✔
1148
            return null;
×
1149
        }
1150

1151
        $to = $config->$toColumn;
5✔
1152
        $addr = $config->$addrColumn;
5✔
1153

1154
        if ($to === 'Me') {
5✔
1155
            $modUser = User::find($byUser);
1✔
1156

1157
            return $modUser?->email_preferred;
1✔
1158
        }
1159

1160
        if ($to === 'Specific') {
4✔
1161
            return $addr ?: null;
3✔
1162
        }
1163

1164
        return null;
1✔
1165
    }
1166

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

1195
        $bccAddress = $this->resolveBccAddress($byUser, $groupId, $action);
7✔
1196
        if (! $bccAddress) {
7✔
1197
            return;
3✔
1198
        }
1199

1200
        // V1: replace $groupname in BCC address.
1201
        $bccAddress = str_replace('$groupname', $groupNameShort, $bccAddress);
4✔
1202

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

1206
        $bccMail = new ModStdMessageMail(
4✔
1207
            modName: $modName,
4✔
1208
            groupName: $groupName,
4✔
1209
            groupNameShort: $groupNameShort,
4✔
1210
            stdSubject: $subject,
4✔
1211
            stdBody: $bccBody,
4✔
1212
            messageSubject: $messageSubject,
4✔
1213
            msgId: $msgId,
4✔
1214
            recipientUserId: 0,
4✔
1215
            recipientEmail: $bccAddress,
4✔
1216
            groupContactMail: $groupContactMail,
4✔
1217
        );
4✔
1218

1219
        if ($shouldSpool) {
4✔
1220
            $spooler->spool($bccMail, $bccAddress);
×
1221
        } else {
1222
            Mail::to($bccAddress)->send($bccMail);
4✔
1223
        }
1224

1225
        Log::info('Sent BCC copy of mod stdmsg', [
4✔
1226
            'action' => $action,
4✔
1227
            'bcc' => $bccAddress,
4✔
1228
            'byuser' => $byUser,
4✔
1229
            'groupid' => $groupId,
4✔
1230
        ]);
4✔
1231
    }
1232

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

1246
        $apiKey = config('freegle.freebie_alerts.api_key');
4✔
1247
        if (empty($apiKey)) {
4✔
1248
            Log::debug('Freebie Alerts API key not configured, skipping add', ['msgid' => $msgId]);
1✔
1249
            return;
1✔
1250
        }
1251

1252
        // Only outstanding Offers (no outcome yet).
1253
        $msg = DB::table('messages')->where('id', $msgId)->first();
3✔
1254
        if (! $msg || $msg->type !== 'Offer') {
3✔
1255
            return;
1✔
1256
        }
1257

1258
        $hasOutcome = DB::table('messages_outcomes')->where('msgid', $msgId)->exists();
2✔
1259
        if ($hasOutcome) {
2✔
1260
            return;
1✔
1261
        }
1262

1263
        // Skip TrashNothing messages — TN syncs to freebiealerts directly.
1264
        if ($msg->sourceheader && str_starts_with($msg->sourceheader, 'TN-')) {
1✔
1265
            return;
×
1266
        }
1267

1268
        $group = DB::table('messages_groups')
1✔
1269
            ->where('msgid', $msgId)
1✔
1270
            ->where('collection', 'Approved')
1✔
1271
            ->first();
1✔
1272
        if (! $group) {
1✔
1273
            return;
×
1274
        }
1275

1276
        if (! $msg->lat || ! $msg->lng) {
1✔
1277
            return;
×
1278
        }
1279

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

1290
        $body = $msg->textbody ?: 'No description';
1✔
1291

1292
        $payload = [
1✔
1293
            'id' => $msgId,
1✔
1294
            'title' => $msg->subject,
1✔
1295
            'description' => $body,
1✔
1296
            'latitude' => $msg->lat,
1✔
1297
            'longitude' => $msg->lng,
1✔
1298
            'images' => $images,
1✔
1299
            'created_at' => $group->arrival,
1✔
1300
        ];
1✔
1301

1302
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1303

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

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

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

1345
        $apiKey = config('freegle.freebie_alerts.api_key');
2✔
1346
        if (empty($apiKey)) {
2✔
1347
            Log::debug('Freebie Alerts API key not configured, skipping remove', ['msgid' => $msgId]);
1✔
1348
            return;
1✔
1349
        }
1350

1351
        $apiUrl = config('freegle.freebie_alerts.api_url');
1✔
1352

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

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

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

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

1390
        if (strlen($local) <= 1) {
2✔
1391
            return '*@' . $domain;
×
1392
        }
1393

1394
        return $local[0] . str_repeat('*', strlen($local) - 1) . '@' . $domain;
2✔
1395
    }
1396

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

1405
        $service = app(PostcodeRemapService::class);
×
1406
        $updated = $service->remapPostcodes($locationId, $polygon);
×
1407

1408
        Log::info('Remapped postcodes', [
×
1409
            'location_id' => $locationId,
×
1410
            'updated' => $updated,
×
1411
        ]);
×
1412
    }
1413
}
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