Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.91% covered (success)
90.91%
150 / 165
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailScannerMatcherService
90.85% covered (success)
90.85%
149 / 164
62.50% covered (warning)
62.50%
5 / 8
35.94
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 resolveEntities
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveOwnership
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 processIngestion
84.62% covered (warning)
84.62%
44 / 52
0.00% covered (danger)
0.00%
0 / 1
13.62
 findExistingEmailRecord
82.14% covered (warning)
82.14%
23 / 28
0.00% covered (danger)
0.00%
0 / 1
7.28
 handleExistingDuplicate
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 insertEmailRecord
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
7.01
 dispatchCreatedEvent
100.00% covered (success)
100.00%
22 / 22
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\Engine\Domain\Event\RecordCreatedEvent;
12use App\Core\Engine\Domain\Model\PermissionContext;
13use App\Core\Engine\Domain\Repository\RecordCoOwnerRepositoryInterface;
14use App\Modules\Calendar\Application\Service\InboundCalendarReplyProcessor;
15use App\Modules\Mail\Application\Contract\EmailScannerMatcherServiceInterface;
16use App\Modules\Mail\Application\Service\Scanner\EmailEntityMatcher;
17use App\Modules\Mail\Application\Service\Scanner\EmailOwnershipResolver;
18use App\Modules\Mail\Domain\Model\ClientMailbox;
19use App\Modules\Mail\Domain\Model\MailMessageDetailDto;
20use PDO;
21use Psr\EventDispatcher\EventDispatcherInterface;
22
23/**
24 * Enterprise Email Scanner & Metadata Matcher Service.
25 *
26 * Coordinates CRM prefix matching, customer/company linking, metadata-driven ownership resolution,
27 * co-ownership deduplication, and workflow trigger dispatching during email ingestion.
28 *
29 * @package App\Modules\Mail\Application\Service
30 */
31final readonly class EmailScannerMatcherService implements EmailScannerMatcherServiceInterface
32{
33    private const string MODULE_EMAILS = 'emails';
34    private const string STATUS_RECEIVED = 'received';
35
36    private EmailEntityMatcher $entityMatcher;
37    private EmailOwnershipResolver $ownershipResolver;
38
39    /**
40     * EmailScannerMatcherService constructor.
41     *
42     * @param PDO                             $pdo               Database connection handle.
43     * @param RecordCoOwnerRepositoryInterface $coOwnerRepo       Relational co-owner repository.
44     * @param EventDispatcherInterface        $dispatcher        PSR-14 event dispatcher.
45     * @param string                          $tablePrefix       Database table prefix.
46     * @param EmailEntityMatcher|null         $entityMatcher     Optional entity matcher helper.
47     * @param EmailOwnershipResolver|null     $ownershipResolver Optional ownership resolver helper.
48     * @param InboundCalendarReplyProcessor|null $calendarReplyProcessor Optional calendar reply processor.
49     */
50    public function __construct(
51        private PDO $pdo,
52        private RecordCoOwnerRepositoryInterface $coOwnerRepo,
53        private EventDispatcherInterface $dispatcher,
54        private string $tablePrefix = 'a_',
55        ?EmailEntityMatcher $entityMatcher = null,
56        ?EmailOwnershipResolver $ownershipResolver = null,
57        private ?InboundCalendarReplyProcessor $calendarReplyProcessor = null
58    ) {
59        $this->entityMatcher = $entityMatcher ?? new EmailEntityMatcher($this->pdo, $this->tablePrefix);
60        $this->ownershipResolver = $ownershipResolver ?? new EmailOwnershipResolver(
61            $this->pdo,
62            $this->tablePrefix
63        );
64    }
65
66    /**
67     * {@inheritdoc}
68     */
69    public function resolveEntities(
70        string $subject,
71        string $bodyText,
72        string $fromEmail,
73        array $toEmails
74    ): array {
75        return $this->entityMatcher->resolveEntities($subject, $bodyText, $fromEmail, $toEmails);
76    }
77
78    /**
79     * {@inheritdoc}
80     */
81    public function resolveOwnership(
82        string $fromEmail,
83        array $toEmails,
84        array $ccEmails,
85        array $bccEmails,
86        int $defaultOwner
87    ): array {
88        return $this->ownershipResolver->resolveOwnership($fromEmail, $toEmails, $ccEmails, $bccEmails, $defaultOwner);
89    }
90
91    /**
92     * {@inheritdoc}
93     */
94    public function processIngestion(
95        ClientMailbox $mailbox,
96        MailMessageDetailDto $detail,
97        bool $dryRun = false
98    ): array {
99        $summary = $detail->summary;
100        $subject = trim($summary->subject) !== '' ? $summary->subject : '(No Subject)';
101        $fromEmail = $this->ownershipResolver->cleanEmail($summary->fromEmail);
102        $fromName = trim($summary->fromName) !== '' ? $summary->fromName : $fromEmail;
103        $toEmails = $summary->to;
104        $ccEmails = $summary->cc;
105        $bccEmails = $this->ownershipResolver->resolveAllBccAddresses($detail);
106
107        $primaryTo = !empty($toEmails)
108            ? $this->ownershipResolver->cleanEmail((string) $toEmails[0])
109            : $mailbox->email;
110        $replyTo = !empty($detail->replyTo)
111            ? $this->ownershipResolver->cleanEmail((string) $detail->replyTo[0])
112            : null;
113
114        $ownership = $this->resolveOwnership($fromEmail, $toEmails, $ccEmails, $bccEmails, $mailbox->owner);
115        $entities = $this->resolveEntities($subject, $detail->textBody, $fromEmail, $toEmails);
116
117        $existingEmail = $this->findExistingEmailRecord($mailbox->id, $summary->messageId, $fromEmail, $subject);
118        if ($existingEmail !== null) {
119            return $this->handleExistingDuplicate($existingEmail, $ownership['co_owners'], $dryRun);
120        }
121
122        if ($dryRun) {
123            return [
124                'status'       => 'dry_run',
125                'email_id'     => null,
126                'owner'        => $ownership['owner'],
127                'co_owners'    => $ownership['co_owners'],
128                'is_duplicate' => false,
129            ];
130        }
131
132        $emailId = $this->insertEmailRecord(
133            $mailbox,
134            $detail,
135            $ownership,
136            $entities,
137            $primaryTo,
138            $replyTo,
139            $fromName
140        );
141
142        if (!empty($ownership['co_owners'])) {
143            $this->coOwnerRepo->syncCoOwners(self::MODULE_EMAILS, $emailId, $ownership['co_owners']);
144        }
145
146        if ($this->calendarReplyProcessor !== null) {
147            $icsList = [];
148            foreach ($detail->attachments as $att) {
149                $isIcs = str_ends_with(strtolower($att->fileName), '.ics');
150                if (($isIcs || str_contains($att->mimeType, 'calendar')) && $att->content !== null) {
151                    $icsList[] = $att->content;
152                }
153            }
154            $this->calendarReplyProcessor->processInboundReply($fromEmail, $subject, $icsList);
155        }
156
157        $this->dispatchCreatedEvent($mailbox, $emailId, $detail, $ownership, $entities, $primaryTo);
158
159        return [
160            'status'       => 'imported',
161            'email_id'     => $emailId,
162            'owner'        => $ownership['owner'],
163            'co_owners'    => $ownership['co_owners'],
164            'is_duplicate' => false,
165        ];
166    }
167
168    /**
169     * Checks if email was already imported using RFC 822 Message-ID or composite hash.
170     *
171     * @return array{id: int, owner: int, co_owners: string|null}|null Existing record or null.
172     */
173    private function findExistingEmailRecord(
174        int $mailboxId,
175        ?string $messageId,
176        string $fromEmail,
177        string $subject
178    ): ?array {
179        $table = $this->tablePrefix . 'mod_emails_records';
180
181        if ($messageId !== null && trim($messageId) !== '') {
182            $sql = "SELECT `id`, `owner`, `co_owners` FROM {$table} WHERE `message_id` = :mid LIMIT 1";
183            $stmt = $this->pdo->prepare($sql);
184            $stmt->execute([':mid' => trim($messageId)]);
185            $row = $stmt->fetch(PDO::FETCH_ASSOC);
186            if ($row !== false) {
187                return [
188                    'id'        => (int) $row['id'],
189                    'owner'     => (int) $row['owner'],
190                    'co_owners' => isset($row['co_owners']) ? (string) $row['co_owners'] : null,
191                ];
192            }
193        }
194
195        $sqlFb = "SELECT `id`, `owner`, `co_owners` FROM {$table} "
196            . "WHERE `mailbox_id` = :mbid AND `from_email` = :from AND `subject` = :subj LIMIT 1";
197        $stmtFb = $this->pdo->prepare($sqlFb);
198        $stmtFb->execute([
199            ':mbid' => $mailboxId,
200            ':from' => $fromEmail,
201            ':subj' => $subject,
202        ]);
203        $rowFb = $stmtFb->fetch(PDO::FETCH_ASSOC);
204
205        if ($rowFb !== false) {
206            return [
207                'id'        => (int) $rowFb['id'],
208                'owner'     => (int) $rowFb['owner'],
209                'co_owners' => isset($rowFb['co_owners']) ? (string) $rowFb['co_owners'] : null,
210            ];
211        }
212
213        return null;
214    }
215
216    /**
217     * Merges newly identified co-owners into existing duplicate record without duplicating the email.
218     *
219     * @param array{id: int, owner: int, co_owners: string|null} $existing   Existing record info.
220     * @param array<int>                                         $newCoOwners Newly found co-owners.
221     * @param bool                                               $dryRun      Whether this is a dry run.
222     * @return array{status: string, email_id: int, owner: int, co_owners: array<int>, is_duplicate: bool}
223     */
224    private function handleExistingDuplicate(array $existing, array $newCoOwners, bool $dryRun): array
225    {
226        $emailId = $existing['id'];
227        $currentCoOwners = $this->coOwnerRepo->findCoOwnerIds(self::MODULE_EMAILS, $emailId);
228        $mergedCoOwners = array_values(array_unique([...$currentCoOwners, ...$newCoOwners]));
229
230        if (!$dryRun && count($mergedCoOwners) > count($currentCoOwners)) {
231            $this->coOwnerRepo->syncCoOwners(self::MODULE_EMAILS, $emailId, $mergedCoOwners);
232            $table = $this->tablePrefix . 'mod_emails_records';
233            $sql = "UPDATE {$table} SET `co_owners` = :co WHERE `id` = :id";
234            $stmt = $this->pdo->prepare($sql);
235            $stmt->execute([
236                ':co' => json_encode($mergedCoOwners),
237                ':id' => $emailId,
238            ]);
239        }
240
241        return [
242            'status'       => 'duplicate_merged',
243            'email_id'     => $emailId,
244            'owner'        => $existing['owner'],
245            'co_owners'    => $mergedCoOwners,
246            'is_duplicate' => true,
247        ];
248    }
249
250    /**
251     * Persists fresh email row into mod_emails_records.
252     */
253    private function insertEmailRecord(
254        ClientMailbox $mailbox,
255        MailMessageDetailDto $detail,
256        array $ownership,
257        array $entities,
258        string $primaryTo,
259        ?string $replyTo,
260        string $fromName
261    ): int {
262        $table = $this->tablePrefix . 'mod_emails_records';
263        $summary = $detail->summary;
264
265        $bodyText = $detail->textBody;
266        $bodyHtml = $detail->htmlBody !== ''
267            ? $detail->htmlBody
268            : nl2br(htmlspecialchars($bodyText, ENT_QUOTES, 'UTF-8'));
269
270        $receivedAt = $summary->date !== '' && strtotime($summary->date) !== false
271            ? date('Y-m-d H:i:s', strtotime($summary->date))
272            : date('Y-m-d H:i:s');
273
274        $hasAttachments = count($detail->attachments) > 0 ? 1 : 0;
275        $coOwnersJson = !empty($ownership['co_owners']) ? json_encode($ownership['co_owners']) : null;
276
277        $sql = "INSERT INTO {$table} (
278            `mailbox_id`, `direction`, `email_status`, `subject`, `from_email`, `from_name`,
279            `to_email`, `reply_to`, `body_html`, `body_text`, `message_id`, `received_at`,
280            `has_attachments`, `special_access`, `owner`, `created_by`, `co_owners`,
281            `ticket_id`, `contact_id`, `company_id`
282        ) VALUES (
283            :mailbox_id, :direction, :email_status, :subject, :from_email, :from_name,
284            :to_email, :reply_to, :body_html, :body_text, :message_id, :received_at,
285            :has_attachments, 1, :owner, :created_by, :co_owners,
286            :ticket_id, :contact_id, :company_id
287        )";
288
289        $stmt = $this->pdo->prepare($sql);
290        $stmt->execute([
291            ':mailbox_id'      => $mailbox->id,
292            ':direction'       => $ownership['direction'],
293            ':email_status'    => self::STATUS_RECEIVED,
294            ':subject'         => trim($summary->subject) !== '' ? $summary->subject : '(No Subject)',
295            ':from_email'      => $this->ownershipResolver->cleanEmail($summary->fromEmail),
296            ':from_name'       => $fromName,
297            ':to_email'        => $primaryTo,
298            ':reply_to'        => $replyTo,
299            ':body_html'       => $bodyHtml,
300            ':body_text'       => $bodyText,
301            ':message_id'      => $summary->messageId,
302            ':received_at'     => $receivedAt,
303            ':has_attachments' => $hasAttachments,
304            ':owner'           => $ownership['owner'],
305            ':created_by'      => $ownership['created_by'],
306            ':co_owners'       => $coOwnersJson,
307            ':ticket_id'       => $entities['ticket_id'],
308            ':contact_id'      => $entities['contact_id'],
309            ':company_id'      => $entities['company_id'],
310        ]);
311
312        return (int) $this->pdo->lastInsertId();
313    }
314
315    /**
316     * Dispatches RecordCreatedEvent to invoke DAG workflows.
317     */
318    private function dispatchCreatedEvent(
319        ClientMailbox $mailbox,
320        int $emailId,
321        MailMessageDetailDto $detail,
322        array $ownership,
323        array $entities,
324        string $primaryTo
325    ): void {
326        $context = new PermissionContext($ownership['owner'], '127.0.0.1', true, false);
327
328        $payload = [
329            'id'              => $emailId,
330            'mailbox_id'      => $mailbox->id,
331            'direction'       => $ownership['direction'],
332            'email_status'    => self::STATUS_RECEIVED,
333            'subject'         => $detail->summary->subject,
334            'from_email'      => $detail->summary->fromEmail,
335            'from_name'       => $detail->summary->fromName,
336            'to_email'        => $primaryTo,
337            'body_text'       => $detail->textBody,
338            'body_html'       => $detail->htmlBody,
339            'message_id'      => $detail->summary->messageId,
340            'ticket_id'       => $entities['ticket_id'],
341            'contact_id'      => $entities['contact_id'],
342            'company_id'      => $entities['company_id'],
343            'owner'           => $ownership['owner'],
344            'co_owners'       => $ownership['co_owners'],
345            'has_attachments' => count($detail->attachments) > 0 ? 1 : 0,
346        ];
347
348        $event = new RecordCreatedEvent(self::MODULE_EMAILS, $emailId, $payload, $context);
349        $this->dispatcher->dispatch($event);
350    }
351}