Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.00% covered (success)
95.00%
95 / 100
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
InboundEmailSyncService
94.95% covered (success)
94.95%
94 / 99
75.00% covered (warning)
75.00%
3 / 4
22.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 syncInboundMailboxes
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 findInboundMailboxes
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 syncSingleInboundMailbox
85.29% covered (warning)
85.29%
29 / 34
0.00% covered (danger)
0.00%
0 / 1
11.38
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\Engine\Infrastructure\Repository\SqlRecordCoOwnerRepository;
12use App\Core\Security\Encryption\EncryptionException;
13use App\Core\Security\Encryption\EncryptionServiceInterface;
14use App\Modules\Calendar\Application\Service\CalendarIcsGenerator;
15use App\Modules\Calendar\Application\Service\CalendarInvitationService;
16use App\Modules\Calendar\Application\Service\CalendarRsvpTokenService;
17use App\Modules\Calendar\Application\Service\InboundCalendarReplyProcessor;
18use App\Modules\Mail\Application\Contract\EmailScannerMatcherServiceInterface;
19use App\Modules\Mail\Application\Contract\InboundEmailSyncServiceInterface;
20use App\Modules\Mail\Domain\Contract\MailProtocolDriverRegistryInterface;
21use App\Modules\Mail\Domain\Exception\MailServerNotFoundException;
22use App\Modules\Mail\Domain\Exception\WebmailAuthException;
23use App\Modules\Mail\Domain\Model\ClientMailbox;
24use App\Modules\Mail\Domain\Model\MailSearchCriteriaDto;
25use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
26use App\Modules\Mail\Infrastructure\Repository\SqlMailRepository;
27use PDO;
28use Psr\EventDispatcher\EventDispatcherInterface;
29use Throwable;
30use Twig\Environment as TwigEnvironment;
31use Twig\Loader\FilesystemLoader;
32
33/**
34 * Inbound Email Synchronization Service.
35 *
36 * Polls active inbound/shared mailboxes over IMAP, extracts metadata,
37 * executes intelligent entity matching and multi-user deduplication,
38 * and dispatches domain events to trigger automated workflows.
39 *
40 * @package App\Modules\Mail\Application\Service
41 */
42final readonly class InboundEmailSyncService implements InboundEmailSyncServiceInterface
43{
44    private EmailScannerMatcherServiceInterface $matcherService;
45
46    /**
47     * InboundEmailSyncService constructor.
48     *
49     * @param PDO                                      $pdo            Database connection.
50     * @param MailRepositoryInterface                  $mailRepo       Mail repository for server details.
51     * @param EncryptionServiceInterface               $encryption     AES encryption service.
52     * @param MailProtocolDriverRegistryInterface      $driverRegistry Mail protocol driver registry.
53     * @param EventDispatcherInterface                 $dispatcher     System event dispatcher.
54     * @param string                                   $tablePrefix    Database table prefix.
55     * @param EmailScannerMatcherServiceInterface|null $matcherService Custom scanner matcher.
56     */
57    public function __construct(
58        private PDO $pdo,
59        private MailRepositoryInterface $mailRepo,
60        private EncryptionServiceInterface $encryption,
61        private MailProtocolDriverRegistryInterface $driverRegistry,
62        private EventDispatcherInterface $dispatcher,
63        private string $tablePrefix = 'a_',
64        ?EmailScannerMatcherServiceInterface $matcherService = null
65    ) {
66        if ($matcherService !== null) {
67            $this->matcherService = $matcherService;
68            return;
69        }
70
71        $templateDir = dirname(__DIR__, 4) . '/resources/templates';
72        $loader = is_dir($templateDir) ? new FilesystemLoader($templateDir) : new FilesystemLoader();
73        $twig = new TwigEnvironment($loader);
74        $icsGen = new CalendarIcsGenerator();
75        $tokenSvc = new CalendarRsvpTokenService();
76        $invSvc = new CalendarInvitationService(
77            $icsGen,
78            $tokenSvc,
79            $twig,
80            $this->mailRepo,
81            $this->pdo,
82            $this->tablePrefix
83        );
84        $calProcessor = new InboundCalendarReplyProcessor($invSvc, $this->pdo, $this->tablePrefix);
85
86        $this->matcherService = new EmailScannerMatcherService(
87            $this->pdo,
88            new SqlRecordCoOwnerRepository($this->pdo),
89            $this->dispatcher,
90            $this->tablePrefix,
91            null,
92            null,
93            $calProcessor
94        );
95    }
96
97    /**
98     * {@inheritdoc}
99     */
100    public function syncInboundMailboxes(
101        ?string $targetMailboxEmail = null,
102        int $limit = 50,
103        bool $dryRun = false,
104        int $days = 3
105    ): array {
106        $mailboxes = $this->findInboundMailboxes($targetMailboxEmail);
107        $importedCount = 0;
108        $mergedCount = 0;
109        $errors = [];
110
111        foreach ($mailboxes as $mailbox) {
112            try {
113                $counts = $this->syncSingleInboundMailbox($mailbox, $limit, $dryRun, $days);
114                $importedCount += $counts['imported'];
115                $mergedCount += $counts['merged'];
116            } catch (Throwable $e) {
117                $errors[] = sprintf(
118                    'Mailbox %s: %s (in %s:%d)',
119                    $mailbox->email,
120                    $e->getMessage(),
121                    $e->getFile(),
122                    $e->getLine()
123                );
124            }
125        }
126
127        return [
128            'mailboxes_checked' => count($mailboxes),
129            'emails_imported'   => $importedCount,
130            'duplicates_merged' => $mergedCount,
131            'errors'            => $errors,
132        ];
133    }
134
135    /**
136     * {@inheritdoc}
137     */
138    public function findInboundMailboxes(?string $filterEmail = null): array
139    {
140        $table = $this->tablePrefix . 'mod_client_mailboxes_records';
141        $columns = SqlMailRepository::CLIENT_MAILBOX_COLUMNS;
142
143        if ($filterEmail !== null && trim($filterEmail) !== '') {
144            $sql = "SELECT {$columns} FROM {$table} "
145                . "WHERE `status` = 'active' AND `special_access` = 1 AND `email` = :email LIMIT 1";
146            $stmt = $this->pdo->prepare($sql);
147            $stmt->execute([':email' => trim($filterEmail)]);
148        } else {
149            $sql = "SELECT {$columns} FROM {$table} "
150                . "WHERE `status` = 'active' AND `special_access` = 1 AND `is_shared` = 1 "
151                . "ORDER BY `id` ASC";
152            $stmt = $this->pdo->query($sql);
153        }
154
155        if ($stmt === false) {
156            return [];
157        }
158
159        $mailboxes = [];
160        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
161            $mailboxes[] = ClientMailbox::fromRow($row);
162        }
163
164        return $mailboxes;
165    }
166
167    /**
168     * Synchronizes a single inbound mailbox by fetching messages and processing ingestion.
169     *
170     * @param ClientMailbox $mailbox Mailbox to synchronize.
171     * @param int           $limit   Max messages to fetch.
172     * @param bool          $dryRun  Whether simulation mode.
173     * @param int           $days    Max age in days (0 for no limit).
174     * @return array{imported: int, merged: int} Counts of imported and merged messages.
175     */
176    private function syncSingleInboundMailbox(
177        ClientMailbox $mailbox,
178        int $limit,
179        bool $dryRun,
180        int $days = 3
181    ): array {
182        $server = $this->mailRepo->findMailServerById($mailbox->mailServerId);
183        if ($server === null) {
184            throw MailServerNotFoundException::forMailbox($mailbox->mailServerId, $mailbox->email);
185        }
186
187        $driver = $this->driverRegistry->getDriver($mailbox->protocolType);
188        try {
189            $plainPass = $this->encryption->decrypt($mailbox->password);
190        } catch (EncryptionException $e) {
191            throw new WebmailAuthException(
192                'Mailbox credentials could not be decrypted. Please re-enter password in settings.',
193                previous: $e
194            );
195        }
196        $activeMailbox = $mailbox->withDecryptedPassword($plainPass);
197
198        $driver->connect($activeMailbox, $server);
199
200        $sinceDate = $days > 0 ? date('d-M-Y', strtotime("-{$days} days")) : null;
201        $criteria = new MailSearchCriteriaDto(
202            folder: $mailbox->folderInbox,
203            isUnreadOnly: false,
204            sinceDate: $sinceDate
205        );
206        $searchResult = $driver->fetchMessages($mailbox->folderInbox, $criteria, 1, max(1, $limit));
207        $messages = $searchResult['messages'] ?? [];
208
209        $imported = 0;
210        $merged = 0;
211        $minTimestamp = $days > 0 ? time() - ($days * 86400) : 0;
212
213        foreach ($messages as $msgSummary) {
214            if ($minTimestamp > 0 && $msgSummary->dateTimestamp > 0 && $msgSummary->dateTimestamp < $minTimestamp) {
215                continue;
216            }
217
218            $detail = $driver->getMessageDetail($mailbox->folderInbox, $msgSummary->uid, false);
219            $result = $this->matcherService->processIngestion($mailbox, $detail, $dryRun);
220
221            if ($result['status'] === 'imported') {
222                $imported++;
223            } elseif ($result['status'] === 'duplicate_merged') {
224                $merged++;
225            }
226        }
227
228        $driver->disconnect();
229
230        return ['imported' => $imported, 'merged' => $merged];
231    }
232}