Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.17% covered (success)
98.17%
161 / 164
87.50% covered (warning)
87.50%
14 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlMailRepository
98.16% covered (success)
98.16%
160 / 163
87.50% covered (warning)
87.50%
14 / 16
43
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findMailServerById
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 findDefaultMailServer
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 findAllActiveServers
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
 findClientMailboxById
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 findSmtpById
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 findDefaultSmtp
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 findTemplateByCode
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 enqueue
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 findPendingQueueItems
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 resetStuckToFailed
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 markAsProcessing
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 markAsSent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 markAsFailed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 recordOutboundEmail
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
12
 resolveTicketRelations
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
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\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Modules\Mail\Domain\Model\ClientMailbox;
12use App\Modules\Mail\Domain\Model\MailQueueItem;
13use App\Modules\Mail\Domain\Model\MailServer;
14use App\Modules\Mail\Domain\Model\MailSmtp;
15use App\Modules\Mail\Domain\Model\MailTemplate;
16use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
17use PDO;
18
19/**
20 * SQL Implementation of Mail Repository Interface.
21 *
22 * Zero SELECT * - all columns explicitly listed. Adds zombie recovery
23 * methods for stuck processing queue items.
24 *
25 * @package App\Modules\Mail\Infrastructure\Repository
26 */
27final readonly class SqlMailRepository implements MailRepositoryInterface
28{
29    /** @var string Mail server columns for explicit column selection. */
30    private const MAIL_SERVER_COLUMNS = '`id`, `name`, `code`, `imap_host`, `imap_port`, `imap_encryption`, '
31        . '`smtp_host`, `smtp_port`, `smtp_encryption`, `require_tls`, `allow_self_signed`, `is_public_for_clients`, '
32        . '`is_default`, `spf_status`, `dkim_status`, `dmarc_status`, `description`, `is_active`';
33
34    /** @var string Client mailbox columns for explicit column selection. */
35    public const CLIENT_MAILBOX_COLUMNS = '`id`, `name`, `email`, `from_name`, `mail_server_id`, `protocol_type`, '
36        . '`username`, `password`, `smtp_host`, `smtp_port`, `smtp_encryption`, `smtp_auth_method`, '
37        . '`smtp_username`, `smtp_password`, `smtp_from_email`, `smtp_from_name`, `smtp_reply_to_email`, '
38        . '`folder_inbox`, `folder_sent`, `folder_drafts`, `folder_trash`, `folder_spam`, `folder_archive`, '
39        . '`signature_html`, `folder_order`, `cached_folders`, `highest_modseq`, `uid_next`, `uid_validity`, '
40        . '`is_shared`, `is_default`, `show_badge`, `notify_new_mail`, `sort_order`, `sync_status`, '
41        . '`last_sync_at`, `status`, `special_access`, `owner`';
42
43    /** @var string SMTP record columns for explicit column selection. */
44    private const SMTP_COLUMNS = '`id`, `mail_server_id`, `name`, `host`, `port`, `security`, `auth_method`, '
45        . '`username`, `password`, `from_email`, `from_name`, `reply_to_email`, `timeout_seconds`, `is_default`, '
46        . '`is_active`';
47
48    /** @var string Template record columns for explicit column selection. */
49    private const TEMPLATE_COLUMNS = '`id`, `code`, `name`, `subject`, `body_html`, `body_text`, '
50        . '`smtp_id`, `language_code`, `dispatch_mode`, `description`, `status`, `special_access`';
51
52    /** @var string Queue record columns for explicit column selection. */
53    private const QUEUE_COLUMNS = '`id`, `template_id`, `smtp_id`, `recipient_email`, `recipient_name`, '
54        . '`cc_emails`, `bcc_emails`, `subject`, `body_html`, `body_text`, `attachments_json`, '
55        . '`status`, `priority`, `dispatch_mode`, `attempts_count`, `max_attempts`, `last_error`, '
56        . '`scheduled_at`, `sent_at`, `created_at`';
57
58    /** @var string SQL SELECT keyword prefix. */
59    private const SQL_SELECT = 'SELECT ';
60
61    /** @var string Error message for zombie recovery. */
62    private const ZOMBIE_ERROR_MSG = 'Processing timed out. Automatically reset by zombie recovery.';
63
64    /**
65     * SqlMailRepository constructor.
66     *
67     * @param PDO    $pdo         Active PDO database handle.
68     * @param string $tablePrefix Optional database table prefix.
69     */
70    public function __construct(
71        private PDO $pdo,
72        private string $tablePrefix = 'a_'
73    ) {
74    }
75
76    /** {@inheritdoc} */
77    public function findMailServerById(int $id): ?MailServer
78    {
79        $table = $this->tablePrefix . 'mod_mail_servers_records';
80        $sql = self::SQL_SELECT . self::MAIL_SERVER_COLUMNS
81            . " FROM {$table} WHERE `id` = :id AND `is_active` = 1 LIMIT 1";
82        $stmt = $this->pdo->prepare($sql);
83        $stmt->execute([':id' => $id]);
84
85        /** @var array<string, mixed>|false $row */
86        $row = $stmt->fetch(PDO::FETCH_ASSOC);
87        return $row !== false ? MailServer::fromRow($row) : null;
88    }
89
90    /** {@inheritdoc} */
91    public function findDefaultMailServer(): ?MailServer
92    {
93        $table = $this->tablePrefix . 'mod_mail_servers_records';
94        $sql = self::SQL_SELECT . self::MAIL_SERVER_COLUMNS
95            . " FROM {$table} WHERE `is_active` = 1 ORDER BY `is_default` DESC, `id` ASC LIMIT 1";
96        $stmt = $this->pdo->query($sql);
97
98        /** @var array<string, mixed>|false $row */
99        $row = $stmt !== false ? $stmt->fetch(PDO::FETCH_ASSOC) : false;
100        return $row !== false ? MailServer::fromRow($row) : null;
101    }
102
103    /** {@inheritdoc} */
104    public function findAllActiveServers(): array
105    {
106        $table = $this->tablePrefix . 'mod_mail_servers_records';
107        $sql = self::SQL_SELECT . self::MAIL_SERVER_COLUMNS
108            . " FROM {$table} WHERE `is_active` = 1 AND `is_public_for_clients` = 1 "
109            . "ORDER BY `is_default` DESC, `name` ASC";
110        $stmt = $this->pdo->query($sql);
111        if ($stmt === false) {
112            return [];
113        }
114
115        $servers = [];
116        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
117            $servers[] = MailServer::fromRow($row);
118        }
119
120        return $servers;
121    }
122
123    /** {@inheritdoc} */
124    public function findClientMailboxById(int $id): ?ClientMailbox
125    {
126        $table = $this->tablePrefix . 'mod_client_mailboxes_records';
127        $sql = self::SQL_SELECT . self::CLIENT_MAILBOX_COLUMNS
128            . " FROM {$table} WHERE `id` = :id AND `status` = 'active' AND `special_access` = 1 LIMIT 1";
129        $stmt = $this->pdo->prepare($sql);
130        $stmt->execute([':id' => $id]);
131
132        /** @var array<string, mixed>|false $row */
133        $row = $stmt->fetch(PDO::FETCH_ASSOC);
134        return $row !== false ? ClientMailbox::fromRow($row) : null;
135    }
136
137    /** {@inheritdoc} */
138    public function findSmtpById(int $id): ?MailSmtp
139    {
140        $table = $this->tablePrefix . 'mod_mail_smtp_records';
141        $sql = self::SQL_SELECT . self::SMTP_COLUMNS
142            . " FROM {$table} WHERE `id` = :id AND `is_active` = 1 LIMIT 1";
143        $stmt = $this->pdo->prepare($sql);
144        $stmt->execute([':id' => $id]);
145
146        /** @var array<string, mixed>|false $row */
147        $row = $stmt->fetch(PDO::FETCH_ASSOC);
148        return $row !== false ? MailSmtp::fromRow($row) : null;
149    }
150
151    /** {@inheritdoc} */
152    public function findDefaultSmtp(): ?MailSmtp
153    {
154        $table = $this->tablePrefix . 'mod_mail_smtp_records';
155        $sql = self::SQL_SELECT . self::SMTP_COLUMNS
156            . " FROM {$table} WHERE `is_active` = 1 ORDER BY `is_default` DESC, `id` ASC LIMIT 1";
157        $stmt = $this->pdo->query($sql);
158
159        /** @var array<string, mixed>|false $row */
160        $row = $stmt !== false ? $stmt->fetch(PDO::FETCH_ASSOC) : false;
161        return $row !== false ? MailSmtp::fromRow($row) : null;
162    }
163
164    /** {@inheritdoc} */
165    public function findTemplateByCode(string $code, string $languageCode = 'en'): ?MailTemplate
166    {
167        $table = $this->tablePrefix . 'mod_mail_template_records';
168        $sql = self::SQL_SELECT . self::TEMPLATE_COLUMNS . " FROM {$table}
169                WHERE `code` = :code AND `status` = 'active' AND `special_access` = 1
170                ORDER BY CASE WHEN `language_code` = :lang THEN 0 ELSE 1 END, `id` ASC
171                LIMIT 1";
172        $stmt = $this->pdo->prepare($sql);
173        $stmt->execute([':code' => $code, ':lang' => $languageCode]);
174
175        /** @var array<string, mixed>|false $row */
176        $row = $stmt->fetch(PDO::FETCH_ASSOC);
177        return $row !== false ? MailTemplate::fromRow($row) : null;
178    }
179
180    /** {@inheritdoc} */
181    public function enqueue(MailQueueItem $item): int
182    {
183        $table = $this->tablePrefix . 'mod_mail_queue_records';
184        $sql = "INSERT INTO {$table} (
185                    `template_id`, `smtp_id`, `recipient_email`, `recipient_name`,
186                    `cc_emails`, `bcc_emails`, `subject`, `body_html`, `body_text`,
187                    `attachments_json`, `status`, `priority`, `dispatch_mode`,
188                    `attempts_count`, `max_attempts`, `scheduled_at`, `created_by`, `owner`
189                ) VALUES (
190                    :template_id, :smtp_id, :recipient_email, :recipient_name,
191                    :cc_emails, :bcc_emails, :subject, :body_html, :body_text,
192                    :attachments_json, :status, :priority, :dispatch_mode,
193                    :attempts_count, :max_attempts, :scheduled_at, 1, 1
194                )";
195
196        $stmt = $this->pdo->prepare($sql);
197        $stmt->execute([
198            ':template_id'      => $item->templateId,
199            ':smtp_id'          => $item->smtpId,
200            ':recipient_email'  => $item->recipientEmail,
201            ':recipient_name'   => $item->recipientName,
202            ':cc_emails'        => $item->ccEmails,
203            ':bcc_emails'       => $item->bccEmails,
204            ':subject'          => $item->subject,
205            ':body_html'        => $item->bodyHtml,
206            ':body_text'        => $item->bodyText,
207            ':attachments_json' => json_encode($item->attachments, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
208            ':status'           => $item->status,
209            ':priority'         => $item->priority,
210            ':dispatch_mode'    => $item->dispatchMode,
211            ':attempts_count'   => $item->attemptsCount,
212            ':max_attempts'     => $item->maxAttempts,
213            ':scheduled_at'     => $item->scheduledAt?->format('Y-m-d H:i:s.u'),
214        ]);
215
216        return (int)$this->pdo->lastInsertId();
217    }
218
219    /** {@inheritdoc} */
220    public function findPendingQueueItems(int $limit = 20): array
221    {
222        $table = $this->tablePrefix . 'mod_mail_queue_records';
223        $sql = self::SQL_SELECT . self::QUEUE_COLUMNS . " FROM {$table}
224                WHERE `status` IN ('pending', 'failed')
225                  AND `dispatch_mode` = 'automatic'
226                  AND `attempts_count` < `max_attempts`
227                  AND (`scheduled_at` IS NULL OR `scheduled_at` <= NOW(6))
228                ORDER BY
229                    CASE `priority`
230                        WHEN 'urgent' THEN 1
231                        WHEN 'high' THEN 2
232                        ELSE 3
233                    END ASC,
234                    `id` ASC
235                LIMIT :limit";
236
237        $stmt = $this->pdo->prepare($sql);
238        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
239        $stmt->execute();
240
241        /** @var array<int, array<string, mixed>> $rows */
242        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
243
244        $items = [];
245        foreach ($rows as $row) {
246            $items[] = MailQueueItem::fromRow($row);
247        }
248
249        return $items;
250    }
251
252    /** {@inheritdoc} */
253    public function resetStuckToFailed(int $timeoutSeconds = 300): int
254    {
255        $table = $this->tablePrefix . 'mod_mail_queue_records';
256        $sql = "UPDATE {$table}
257                SET `status` = 'failed', `last_error` = :err
258                WHERE `status` = 'processing'
259                  AND `updated_at` <= DATE_SUB(NOW(6), INTERVAL :timeout SECOND)";
260
261        $stmt = $this->pdo->prepare($sql);
262        $stmt->bindValue(':timeout', $timeoutSeconds, PDO::PARAM_INT);
263        $stmt->bindValue(':err', self::ZOMBIE_ERROR_MSG);
264        $stmt->execute();
265
266        return (int)$stmt->rowCount();
267    }
268
269    /** {@inheritdoc} */
270    public function markAsProcessing(int $id): bool
271    {
272        $table = $this->tablePrefix . 'mod_mail_queue_records';
273        $sql = "UPDATE {$table}
274                SET `status` = 'processing', `attempts_count` = `attempts_count` + 1
275                WHERE `id` = :id";
276        $stmt = $this->pdo->prepare($sql);
277        return $stmt->execute([':id' => $id]);
278    }
279
280    /** {@inheritdoc} */
281    public function markAsSent(int $id): bool
282    {
283        $table = $this->tablePrefix . 'mod_mail_queue_records';
284        $sql = "UPDATE {$table} SET `status` = 'sent', `sent_at` = NOW(6) WHERE `id` = :id";
285        $stmt = $this->pdo->prepare($sql);
286        return $stmt->execute([':id' => $id]);
287    }
288
289    /** {@inheritdoc} */
290    public function markAsFailed(int $id, string $error): bool
291    {
292        $table = $this->tablePrefix . 'mod_mail_queue_records';
293        $sql = "UPDATE {$table} SET `status` = 'failed', `last_error` = :err WHERE `id` = :id";
294        $stmt = $this->pdo->prepare($sql);
295        return $stmt->execute([':id' => $id, ':err' => mb_substr($error, 0, 2000)]);
296    }
297
298    /** {@inheritdoc} */
299    public function recordOutboundEmail(array $data): int
300    {
301        $table = $this->tablePrefix . 'mod_emails_records';
302        $companyId = !empty($data['company_id']) ? (int) $data['company_id'] : null;
303        $contactId = !empty($data['contact_id']) ? (int) $data['contact_id'] : null;
304        $ticketId  = !empty($data['ticket_id']) ? (int) $data['ticket_id'] : null;
305
306        if ($ticketId !== null && ($companyId === null || $contactId === null)) {
307            $resolved = $this->resolveTicketRelations($ticketId, $companyId, $contactId);
308            $companyId = $resolved['company_id'];
309            $contactId = $resolved['contact_id'];
310        }
311
312        $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
313        $sql = "INSERT INTO `{$table}` ("
314            . "`subject`, `from_email`, `from_name`, `to_email`, `body_html`, `body_text`, "
315            . "`message_id`, `mailbox_id`, `ticket_id`, `contact_id`, `company_id`, "
316            . "`email_status`, `direction`, `received_at`, `has_attachments`, `owner`, `created_by`"
317            . ") VALUES ("
318            . ":subject, :from_email, :from_name, :to_email, :body_html, :body_text, "
319            . ":message_id, :mailbox_id, :ticket_id, :contact_id, :company_id, "
320            . "'sent', 'outbound', :now, 0, :owner, :created_by"
321            . ")";
322
323        $stmt = $this->pdo->prepare($sql);
324        $stmt->execute([
325            ':subject'     => (string) ($data['subject'] ?? ''),
326            ':from_email'  => (string) ($data['from_email'] ?? ''),
327            ':from_name'   => !empty($data['from_name']) ? (string) $data['from_name'] : null,
328            ':to_email'    => (string) ($data['to_email'] ?? ''),
329            ':body_html'   => !empty($data['body_html']) ? (string) $data['body_html'] : null,
330            ':body_text'   => !empty($data['body_text']) ? (string) $data['body_text'] : null,
331            ':message_id'  => !empty($data['message_id']) ? (string) $data['message_id'] : null,
332            ':mailbox_id'  => (int) ($data['mailbox_id'] ?? 0),
333            ':ticket_id'   => $ticketId,
334            ':contact_id'  => $contactId,
335            ':company_id'  => $companyId,
336            ':now'         => $now,
337            ':owner'       => (int) ($data['owner'] ?? 1),
338            ':created_by'  => (int) ($data['created_by'] ?? 1),
339        ]);
340
341        $insertedId = (int) $this->pdo->lastInsertId();
342
343        if ($ticketId !== null) {
344            $ticketsTable = $this->tablePrefix . 'mod_tickets_records';
345            $updTicket = "UPDATE `{$ticketsTable}` SET `updated_at` = :now WHERE `id` = :tid";
346            $this->pdo->prepare($updTicket)->execute([':now' => $now, ':tid' => $ticketId]);
347        }
348
349        return $insertedId;
350    }
351
352    /**
353     * Resolves missing company and contact IDs from related ticket if available.
354     *
355     * @param int $ticketId Target ticket ID.
356     * @param int|null $companyId Current company ID.
357     * @param int|null $contactId Current contact ID.
358     * @return array{company_id: ?int, contact_id: ?int} Resolved relationship IDs.
359     */
360    private function resolveTicketRelations(int $ticketId, ?int $companyId, ?int $contactId): array
361    {
362        if ($companyId !== null && $contactId !== null) {
363            return ['company_id' => $companyId, 'contact_id' => $contactId];
364        }
365
366        $ticketsTable = $this->tablePrefix . 'mod_tickets_records';
367        $relTable = $this->tablePrefix . 'rel_tickets_contacts_records';
368        $sql = "SELECT t.company_id, r.contact_id FROM `{$ticketsTable}` t "
369            . "LEFT JOIN `{$relTable}` r ON r.ticket_id = t.id "
370            . "WHERE t.id = :tid LIMIT 1";
371        $stmt = $this->pdo->prepare($sql);
372        $stmt->execute([':tid' => $ticketId]);
373        /** @var array<string, mixed>|false $row */
374        $row = $stmt->fetch(PDO::FETCH_ASSOC);
375        if ($row === false) {
376            return ['company_id' => $companyId, 'contact_id' => $contactId];
377        }
378
379        $resolvedCompany = $companyId ?? (!empty($row['company_id']) ? (int) $row['company_id'] : null);
380        $resolvedContact = $contactId ?? (!empty($row['contact_id']) ? (int) $row['contact_id'] : null);
381
382        return ['company_id' => $resolvedCompany, 'contact_id' => $resolvedContact];
383    }
384}
385