Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.48% covered (success)
97.48%
155 / 159
91.67% covered (success)
91.67%
11 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailSyncService
97.47% covered (success)
97.47%
154 / 158
91.67% covered (success)
91.67%
11 / 12
52
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
 checkMailboxChanges
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
8
 syncMailboxes
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
9
 syncSingleMailbox
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
4
 extractCachedInboxUnseen
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
16.67
 isSyncThrottled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 hasNewMessages
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 fetchNewMessageHeaders
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 updateMailboxSyncState
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 markSyncError
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 collectBadgeCount
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 collectNewMessages
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
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\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Encryption\EncryptionServiceInterface;
12use App\Modules\Mail\Domain\Contract\MailProtocolDriverInterface;
13use App\Modules\Mail\Domain\Contract\MailProtocolDriverRegistryInterface;
14use App\Modules\Mail\Domain\Model\ClientMailbox;
15use App\Modules\Mail\Domain\Model\MailFolderStatusDto;
16use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto;
17use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
18use PDO;
19use Throwable;
20
21/**
22 * Webmail High-Scale Lightweight Synchronization Service.
23 *
24 * Designed for 10,000+ mailboxes: executes micro-payload IMAP STATUS checks
25 * (CONDSTORE HIGHESTMODSEQ / UIDNEXT) transferring under 150 bytes per query
26 * without downloading heavy message payloads when mailbox state is unchanged.
27 *
28 * @package App\Modules\Mail\Application\Service
29 */
30final readonly class WebmailSyncService
31{
32    /**
33     * WebmailSyncService constructor.
34     *
35     * @param PDO                                 $pdo            Database connection.
36     * @param MailRepositoryInterface             $mailRepo       Mail repository.
37     * @param EncryptionServiceInterface          $encryption     AES-256 encryption service.
38     * @param MailProtocolDriverRegistryInterface $driverRegistry Protocol driver registry.
39     */
40    public function __construct(
41        private PDO $pdo,
42        private MailRepositoryInterface $mailRepo,
43        private EncryptionServiceInterface $encryption,
44        private MailProtocolDriverRegistryInterface $driverRegistry
45    ) {
46    }
47
48    /**
49     * Executes ultra-lightweight change detection query for a mailbox inbox folder.
50     *
51     * @param ClientMailbox $mailbox Target mailbox.
52     * @return array{hasChanged: bool, unseenCount: int, highestModseq: ?int, uidNext: ?int, error: ?string}
53     */
54    public function checkMailboxChanges(ClientMailbox $mailbox): array
55    {
56        try {
57            $server = $this->mailRepo->findMailServerById($mailbox->mailServerId);
58            $driver = $this->driverRegistry->getDriver($mailbox->protocolType);
59            $plainPass = $this->encryption->decrypt($mailbox->password);
60            $activeMailbox = $mailbox->withDecryptedPassword($plainPass);
61
62            $driver->connect($activeMailbox, $server);
63            $status = $driver->getFolderStatus($mailbox->folderInbox);
64            $driver->disconnect();
65
66            $modseqChanged = $status->highestModseq !== null
67                && $mailbox->highestModseq !== null
68                && $status->highestModseq !== $mailbox->highestModseq;
69
70            $uidNextChanged = $status->uidNext !== null
71                && $mailbox->uidNext !== null
72                && $status->uidNext !== $mailbox->uidNext;
73
74            $hasChanged = $modseqChanged || $uidNextChanged || ($mailbox->highestModseq === null);
75
76            $this->updateMailboxSyncState(
77                $mailbox->id,
78                $status->highestModseq,
79                $status->uidNext,
80                $status->uidValidity
81            );
82
83            return [
84                'hasChanged'    => $hasChanged,
85                'unseenCount'   => $status->unseenMessages,
86                'highestModseq' => $status->highestModseq,
87                'uidNext'       => $status->uidNext,
88                'error'         => null,
89            ];
90        } catch (Throwable $e) {
91            $this->markSyncError($mailbox->id, $e->getMessage());
92            return [
93                'hasChanged'    => false,
94                'unseenCount'   => 0,
95                'highestModseq' => null,
96                'uidNext'       => null,
97                'error'         => $e->getMessage(),
98            ];
99        }
100    }
101
102    /**
103     * Synchronizes mailboxes and retrieves newly arrived unread messages for notifications.
104     *
105     * @param array<ClientMailbox> $mailboxes Active user mailboxes.
106     * @param bool                 $force     Whether to bypass last sync throttle.
107     * @return array{
108     *     has_new: bool,
109     *     new_count: int,
110     *     total_unseen: int,
111     *     badge_text: string,
112     *     mailbox_counts: array<array{mailbox_id: int, email: string, name: string, unseen: int}>,
113     *     messages: array<array{
114     *         uid: string,
115     *         subject: string,
116     *         from_name: string,
117     *         from_email: string,
118     *         date: string,
119     *         mailbox_id: int,
120     *         mailbox_email: string
121     *     }>
122     * } Aggregated synchronization payload.
123     */
124    public function syncMailboxes(array $mailboxes, bool $force = false): array
125    {
126        $newMessages = [];
127        $mailboxCounts = [];
128        $defaultMailboxUnseen = null;
129
130        foreach ($mailboxes as $mailbox) {
131            if (!$mailbox->isActive) {
132                continue;
133            }
134
135            $mailboxResult = $this->syncSingleMailbox($mailbox, $force);
136            $mailboxCounts = $this->collectBadgeCount($mailbox, $mailboxResult['unseen'], $mailboxCounts);
137
138            if ($mailbox->showBadge && $mailbox->isDefault && $defaultMailboxUnseen === null) {
139                $defaultMailboxUnseen = $mailboxResult['unseen'];
140            }
141
142            $newMessages = $this->collectNewMessages($mailbox, $mailboxResult['messages'], $newMessages);
143        }
144
145        if ($defaultMailboxUnseen === null && $mailboxCounts !== []) {
146            $defaultMailboxUnseen = (int) $mailboxCounts[0]['unseen'];
147        }
148
149        $badgeTotal = $defaultMailboxUnseen ?? 0;
150        $badgeText = $badgeTotal > 99 ? '99+' : (string) $badgeTotal;
151
152        return [
153            'has_new'        => $newMessages !== [],
154            'new_count'      => count($newMessages),
155            'total_unseen'   => $badgeTotal,
156            'badge_text'     => $badgeText,
157            'mailbox_counts' => $mailboxCounts,
158            'messages'       => $newMessages,
159        ];
160    }
161
162    /**
163     * Synchronizes a single mailbox and checks for new unread messages.
164     *
165     * @param ClientMailbox $mailbox Target mailbox.
166     * @param bool          $force   Bypass throttling.
167     * @return array{
168     *     unseen: int,
169     *     messages: array<int, array{
170     *         uid: string,
171     *         subject: string,
172     *         from_name: string,
173     *         from_email: string,
174     *         date: string,
175     *         mailbox_id: int,
176     *         mailbox_email: string
177     *     }>
178     * }
179     */
180    private function syncSingleMailbox(ClientMailbox $mailbox, bool $force): array
181    {
182        if ($this->isSyncThrottled($mailbox, $force)) {
183            return [
184                'unseen'   => $this->extractCachedInboxUnseen($mailbox),
185                'messages' => [],
186            ];
187        }
188
189        try {
190            $server = $this->mailRepo->findMailServerById($mailbox->mailServerId);
191            $driver = $this->driverRegistry->getDriver($mailbox->protocolType);
192            $plainPass = $this->encryption->decrypt($mailbox->password);
193            $activeMailbox = $mailbox->withDecryptedPassword($plainPass);
194
195            $driver->connect($activeMailbox, $server);
196            $status = $driver->getFolderStatus($mailbox->folderInbox);
197
198            $messages = $this->hasNewMessages($mailbox, $status)
199                ? $this->fetchNewMessageHeaders($driver, $mailbox)
200                : [];
201
202            $driver->disconnect();
203
204            $this->updateMailboxSyncState(
205                $mailbox->id,
206                $status->highestModseq,
207                $status->uidNext,
208                $status->uidValidity
209            );
210
211            return [
212                'unseen'   => $status->unseenMessages,
213                'messages' => $messages,
214            ];
215        } catch (Throwable $e) {
216            $this->markSyncError($mailbox->id, $e->getMessage());
217            return [
218                'unseen'   => $this->extractCachedInboxUnseen($mailbox),
219                'messages' => [],
220            ];
221        }
222    }
223
224    /**
225     * Extracts cached unseen count for the inbox folder from mailbox metadata.
226     *
227     * @param ClientMailbox $mailbox Mailbox aggregate.
228     * @return int Cached unseen count.
229     */
230    private function extractCachedInboxUnseen(ClientMailbox $mailbox): int
231    {
232        if (empty($mailbox->cachedFolders) || !is_array($mailbox->cachedFolders)) {
233            return 0;
234        }
235
236        foreach ($mailbox->cachedFolders as $folder) {
237            if (is_array($folder) && ($folder['name'] ?? '') === $mailbox->folderInbox) {
238                return (int) ($folder['unseenMessages'] ?? 0);
239            }
240        }
241
242        return 0;
243    }
244
245    /**
246     * Checks whether mailbox synchronization should be skipped due to throttling.
247     */
248    private function isSyncThrottled(ClientMailbox $mailbox, bool $force): bool
249    {
250        if ($force || $mailbox->syncStatus !== 'ok' || $mailbox->lastSyncAt === null) {
251            return false;
252        }
253
254        $timeSinceSync = time() - (int) strtotime($mailbox->lastSyncAt);
255        return $timeSinceSync < 15;
256    }
257
258    /**
259     * Determines whether new messages have arrived in the mailbox inbox.
260     */
261    private function hasNewMessages(ClientMailbox $mailbox, MailFolderStatusDto $status): bool
262    {
263        if ($mailbox->highestModseq === null && $mailbox->uidNext === null) {
264            return false;
265        }
266
267        $uidNextIncreased = $status->uidNext !== null
268            && $mailbox->uidNext !== null
269            && $status->uidNext > $mailbox->uidNext;
270
271        $modseqIncreased = $status->highestModseq !== null
272            && $mailbox->highestModseq !== null
273            && $status->highestModseq > $mailbox->highestModseq;
274
275        return $uidNextIncreased || ($modseqIncreased && $status->unseenMessages > 0);
276    }
277
278    /**
279     * Fetches headers of newly arrived unread messages.
280     *
281     * @return array<int, array{
282     *     uid: string,
283     *     subject: string,
284     *     from_name: string,
285     *     from_email: string,
286     *     date: string,
287     *     mailbox_id: int,
288     *     mailbox_email: string
289     * }>
290     */
291    private function fetchNewMessageHeaders(
292        MailProtocolDriverInterface $driver,
293        ClientMailbox $mailbox
294    ): array {
295        $criteria = new MailSearchCriteriaDto(
296            folder: $mailbox->folderInbox,
297            isUnreadOnly: true,
298            sortDirection: 'desc'
299        );
300        $fetchResult = $driver->fetchMessages($mailbox->folderInbox, $criteria, 1, 10);
301
302        $messages = [];
303        foreach ($fetchResult['messages'] as $msg) {
304            $messages[] = [
305                'uid'           => (string) $msg->uid,
306                'subject'       => $msg->subject !== '' ? $msg->subject : '(No subject)',
307                'from_name'     => $msg->fromName !== '' ? $msg->fromName : $msg->fromEmail,
308                'from_email'    => $msg->fromEmail,
309                'date'          => $msg->date,
310                'mailbox_id'    => $mailbox->id,
311                'mailbox_email' => $mailbox->email,
312            ];
313        }
314
315        return $messages;
316    }
317
318    /**
319     * Updates synchronization metadata timestamps and sequence identifiers in database.
320     */
321    private function updateMailboxSyncState(
322        int $mailboxId,
323        ?int $highestModseq,
324        ?int $uidNext,
325        ?int $uidValidity
326    ): void {
327        $sql = 'UPDATE `a_mod_client_mailboxes_records` SET '
328            . '`highest_modseq` = :modseq, `uid_next` = :uid_next, `uid_validity` = :validity, '
329            . '`sync_status` = "ok", `last_sync_at` = CURRENT_TIMESTAMP(6) '
330            . 'WHERE `id` = :id';
331
332        $stmt = $this->pdo->prepare($sql);
333        $stmt->execute([
334            ':modseq'   => $highestModseq,
335            ':uid_next' => $uidNext,
336            ':validity' => $uidValidity,
337            ':id'       => $mailboxId,
338        ]);
339    }
340
341    /**
342     * Records sync error status.
343     */
344    private function markSyncError(int $mailboxId, string $error): void
345    {
346        $status = str_contains(strtolower($error), 'auth') ? 'auth_error' : 'connection_error';
347        $sql = 'UPDATE `a_mod_client_mailboxes_records` SET `sync_status` = :status WHERE `id` = :id';
348        $stmt = $this->pdo->prepare($sql);
349        $stmt->execute([
350            ':status' => $status,
351            ':id'     => $mailboxId,
352        ]);
353    }
354
355    /**
356     * Appends mailbox badge count if badge display is enabled.
357     *
358     * @param ClientMailbox $mailbox Mailbox instance.
359     * @param int $unseen Unread message count.
360     * @param array<int, array<string, mixed>> $counts Existing badge count list.
361     * @return array<int, array<string, mixed>> Updated badge counts.
362     */
363    private function collectBadgeCount(ClientMailbox $mailbox, int $unseen, array $counts): array
364    {
365        if (!$mailbox->showBadge) {
366            return $counts;
367        }
368
369        $counts[] = [
370            'mailbox_id' => $mailbox->id,
371            'email'      => $mailbox->email,
372            'name'       => $mailbox->name,
373            'unseen'     => $unseen,
374            'is_default' => $mailbox->isDefault,
375        ];
376
377        return $counts;
378    }
379
380    /**
381     * Appends new incoming messages if mailbox notification is enabled.
382     *
383     * @param ClientMailbox $mailbox Mailbox instance.
384     * @param array<int, array<string, mixed>> $messages Retrieved messages.
385     * @param array<int, array<string, mixed>> $accumulator Accumulated message list.
386     * @return array<int, array<string, mixed>> Updated message list.
387     */
388    private function collectNewMessages(ClientMailbox $mailbox, array $messages, array $accumulator): array
389    {
390        if (!$mailbox->notifyNewMail) {
391            return $accumulator;
392        }
393
394        return array_merge($accumulator, $messages);
395    }
396}