Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.02% covered (success)
93.02%
40 / 43
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProcessMailQueueTask
92.86% covered (success)
92.86%
39 / 42
33.33% covered (danger)
33.33%
1 / 3
12.05
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 run
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
6
 resolvePdo
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Mail\Task;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Cron\CronTaskInterface;
12use App\Core\Database\MultiDbRouter;
13use App\Modules\Mail\Application\Contract\MailSenderServiceInterface;
14use App\Modules\Mail\Application\Service\MailSenderService;
15use App\Modules\Mail\Application\Service\MailTemplateRenderer;
16use App\Modules\Mail\Domain\Exception\SmtpConfigurationNotFoundException;
17use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
18use App\Modules\Mail\Infrastructure\Repository\SqlMailRepository;
19use App\Shared\Infrastructure\Config\InstallerConfigLoader;
20use PDO;
21use Throwable;
22
23/**
24 * Background Task Worker for Processing Outgoing Email Queue.
25 *
26 * Processes pending mail queue items in batches and performs zombie
27 * recovery for stuck processing items before each batch run.
28 *
29 * @package App\Modules\Mail\Task
30 */
31final class ProcessMailQueueTask implements CronTaskInterface
32{
33    /** @var int Default maximum emails processed per CRON run batch. */
34    private const DEFAULT_BATCH_SIZE = 25;
35
36    /** @var int Seconds before a stuck processing item is considered zombie. */
37    private const ZOMBIE_TIMEOUT_SECONDS = 600;
38
39    private MailRepositoryInterface $repository;
40    private MailSenderServiceInterface $sender;
41
42    /**
43     * ProcessMailQueueTask constructor.
44     *
45     * @param MailRepositoryInterface|null    $repository Optional repository.
46     * @param MailSenderServiceInterface|null $sender     Optional sender service.
47     * @param PDO|null                        $pdo        Optional PDO connection.
48     */
49    public function __construct(
50        ?MailRepositoryInterface $repository = null,
51        ?MailSenderServiceInterface $sender = null,
52        ?PDO $pdo = null
53    ) {
54        if ($repository !== null && $sender !== null) {
55            $this->repository = $repository;
56            $this->sender = $sender;
57            return;
58        }
59
60        $resolvedPdo = $pdo ?? $this->resolvePdo();
61        $this->repository = $repository ?? new SqlMailRepository($resolvedPdo);
62        $this->sender = $sender ?? new MailSenderService($this->repository, new MailTemplateRenderer());
63    }
64
65    /**
66     * Executes zombie recovery then batch processes pending emails.
67     *
68     * @return string Execution summary log message.
69     */
70    public function run(): string
71    {
72        $recovered = $this->repository->resetStuckToFailed(self::ZOMBIE_TIMEOUT_SECONDS);
73        $pendingItems = $this->repository->findPendingQueueItems(self::DEFAULT_BATCH_SIZE);
74
75        if (empty($pendingItems)) {
76            return sprintf(
77                'Mail queue worker: 0 processed. %d zombie items recovered.',
78                $recovered
79            );
80        }
81
82        $sentCount = 0;
83        $failedCount = 0;
84
85        foreach ($pendingItems as $item) {
86            $this->repository->markAsProcessing($item->id);
87
88            try {
89                $smtp = $item->smtpId !== null
90                    ? $this->repository->findSmtpById($item->smtpId)
91                    : $this->repository->findDefaultSmtp();
92
93                if ($smtp === null) {
94                    throw SmtpConfigurationNotFoundException::forMissingConfiguration($item->smtpId);
95                }
96
97                $this->sender->sendQueueItem($item, $smtp);
98                $this->repository->markAsSent($item->id);
99                $sentCount++;
100            } catch (Throwable $e) {
101                $failedCount++;
102                $this->repository->markAsFailed($item->id, $e->getMessage());
103            }
104        }
105
106        return sprintf(
107            'Mail queue worker: %d processed (%d sent, %d failed). %d zombie items recovered.',
108            count($pendingItems),
109            $sentCount,
110            $failedCount,
111            $recovered
112        );
113    }
114
115    /**
116     * Resolves default PDO database connection handle.
117     *
118     * @return PDO Database handle.
119     */
120    private function resolvePdo(): PDO
121    {
122        try {
123            $config = InstallerConfigLoader::load();
124            $dbParams = is_array($config) ? ($config['db'] ?? []) : [];
125
126            $router = new MultiDbRouter(['default' => $dbParams]);
127            return $router->getConnection('default');
128        } catch (Throwable) {
129            return new PDO('sqlite::memory:');
130        }
131    }
132}