Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.32% covered (success)
90.32%
84 / 93
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailEntityMatcher
90.22% covered (success)
90.22%
83 / 92
70.00% covered (warning)
70.00%
7 / 10
39.35
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
 resolveEntities
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 lookupTicketByPrefix
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 loadConfiguredTicketPrefix
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 lookupTicketCompanyId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 lookupTicketContactId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 lookupContactByEmailAddresses
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
6.05
 lookupCompanyForContact
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 lookupCompanyByEmailAddresses
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 cleanEmail
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\Scanner;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use PDO;
12
13/**
14 * Enterprise Scanner Entity Matcher.
15 *
16 * Matches incoming email metadata against CRM tickets, contacts, and companies
17 * using configured ticket number prefixes and email address candidates.
18 *
19 * @package App\Modules\Mail\Application\Service\Scanner
20 */
21final readonly class EmailEntityMatcher
22{
23    /**
24     * EmailEntityMatcher constructor.
25     *
26     * @param PDO    $pdo         Database connection handle.
27     * @param string $tablePrefix Database table prefix.
28     */
29    public function __construct(
30        private PDO $pdo,
31        private string $tablePrefix = 'a_'
32    ) {
33    }
34
35    /**
36     * Resolves CRM entity references (ticket, company, contact) from email subject, body, and addresses.
37     *
38     * @param string        $subject   Email subject line.
39     * @param string        $bodyText  Email plaintext body content.
40     * @param string        $fromEmail Sender email address.
41     * @param array<string> $toEmails  List of recipient email addresses.
42     * @return array{ticket_id: ?int, contact_id: ?int, company_id: ?int, module_prefix: ?string}
43     */
44    public function resolveEntities(
45        string $subject,
46        string $bodyText,
47        string $fromEmail,
48        array $toEmails
49    ): array {
50        $ticketId = $this->lookupTicketByPrefix($subject, $bodyText);
51        $companyId = null;
52        $contactId = null;
53        $modulePrefix = null;
54
55        if ($ticketId !== null) {
56            $modulePrefix = 'TICK';
57            $companyId = $this->lookupTicketCompanyId($ticketId);
58            $contactId = $this->lookupTicketContactId($ticketId);
59        }
60
61        if ($contactId === null) {
62            $contactInfo = $this->lookupContactByEmailAddresses([$fromEmail, ...$toEmails]);
63            if ($contactInfo !== null) {
64                $contactId = $contactInfo['id'];
65                if ($companyId === null && $contactInfo['company_id'] !== null) {
66                    $companyId = $contactInfo['company_id'];
67                }
68            }
69        }
70
71        if ($companyId === null) {
72            $companyId = $this->lookupCompanyByEmailAddresses([$fromEmail, ...$toEmails]);
73        }
74
75        return [
76            'ticket_id'     => $ticketId,
77            'contact_id'    => $contactId,
78            'company_id'    => $companyId,
79            'module_prefix' => $modulePrefix,
80        ];
81    }
82
83    /**
84     * Looks up ticket ID using configured prefix pattern in subject or body text.
85     *
86     * @param string $subject  Email subject.
87     * @param string $bodyText Plaintext body.
88     * @return int|null Ticket ID or null if not found.
89     */
90    public function lookupTicketByPrefix(string $subject, string $bodyText): ?int
91    {
92        $prefix = $this->loadConfiguredTicketPrefix();
93        $pattern = '/(?:\[#|\b)(' . preg_quote($prefix, '/') . '-\d{4}-\d+)(?:\]|\b)/i';
94
95        if (!preg_match($pattern, $subject, $m) && !preg_match($pattern, $bodyText, $m)) {
96            return null;
97        }
98
99        $ticketNo = strtoupper($m[1]);
100        $table = $this->tablePrefix . 'mod_tickets_records';
101        $sql = "SELECT `id` FROM {$table} WHERE `ticket_no` = :no LIMIT 1";
102        $stmt = $this->pdo->prepare($sql);
103        $stmt->execute([':no' => $ticketNo]);
104        $id = $stmt->fetchColumn();
105
106        return $id !== false ? (int) $id : null;
107    }
108
109    /**
110     * Retrieves configured ticket prefix from core_prefix_records.
111     */
112    public function loadConfiguredTicketPrefix(): string
113    {
114        $table = $this->tablePrefix . 'core_prefix_records';
115        $sql = "SELECT `prefix` FROM {$table} WHERE `module_id` = 50 AND `is_active` = 1 LIMIT 1";
116        $stmt = $this->pdo->query($sql);
117        $val = $stmt !== false ? $stmt->fetchColumn() : false;
118
119        return is_string($val) && $val !== '' ? $val : 'TICK';
120    }
121
122    /**
123     * Looks up company_id linked to ticket.
124     */
125    public function lookupTicketCompanyId(int $ticketId): ?int
126    {
127        $table = $this->tablePrefix . 'mod_tickets_records';
128        $sql = "SELECT `company_id` FROM {$table} WHERE `id` = :id LIMIT 1";
129        $stmt = $this->pdo->prepare($sql);
130        $stmt->execute([':id' => $ticketId]);
131        $val = $stmt->fetchColumn();
132
133        return $val !== false && (int) $val > 0 ? (int) $val : null;
134    }
135
136    /**
137     * Looks up contact_id associated with ticket via intermediate relation table.
138     */
139    public function lookupTicketContactId(int $ticketId): ?int
140    {
141        $table = $this->tablePrefix . 'rel_tickets_contacts_records';
142        $sql = "SELECT `contact_id` FROM {$table} WHERE `ticket_id` = :id LIMIT 1";
143        $stmt = $this->pdo->prepare($sql);
144        $stmt->execute([':id' => $ticketId]);
145        $val = $stmt->fetchColumn();
146
147        return $val !== false && (int) $val > 0 ? (int) $val : null;
148    }
149
150    /**
151     * Matches contact record by checking candidate email addresses.
152     *
153     * @param array<string> $addresses Candidate emails.
154     * @return array{id: int, company_id: ?int}|null Contact ID and linked company ID.
155     */
156    public function lookupContactByEmailAddresses(array $addresses): ?array
157    {
158        $table = $this->tablePrefix . 'mod_contacts_records';
159        $sql = "SELECT `id`, `parent_id` FROM {$table} "
160            . "WHERE (`email` = :em1 OR `secondary_email` = :em2) "
161            . "LIMIT 1";
162        $stmt = $this->pdo->prepare($sql);
163
164        foreach ($addresses as $rawEmail) {
165            $email = $this->cleanEmail($rawEmail);
166            if ($email === '') {
167                continue;
168            }
169            $stmt->execute([':em1' => $email, ':em2' => $email]);
170            $row = $stmt->fetch(PDO::FETCH_ASSOC);
171            if ($row !== false) {
172                $contactId = (int) $row['id'];
173                $companyId = !empty($row['parent_id']) ? (int) $row['parent_id'] : null;
174
175                if ($companyId === null) {
176                    $companyId = $this->lookupCompanyForContact($contactId);
177                }
178
179                return ['id' => $contactId, 'company_id' => $companyId];
180            }
181        }
182
183        return null;
184    }
185
186    /**
187     * Finds linked company via M:N relation table if contact parent_id was null.
188     */
189    public function lookupCompanyForContact(int $contactId): ?int
190    {
191        $table = $this->tablePrefix . 'rel_companies_contacts_records';
192        $sql = "SELECT `company_id` FROM {$table} WHERE `contact_id` = :cid LIMIT 1";
193        $stmt = $this->pdo->prepare($sql);
194        $stmt->execute([':cid' => $contactId]);
195        $val = $stmt->fetchColumn();
196
197        return $val !== false && (int) $val > 0 ? (int) $val : null;
198    }
199
200    /**
201     * Matches company record by checking candidate email addresses.
202     *
203     * @param array<string> $addresses Candidate emails.
204     * @return int|null Company ID or null if not found.
205     */
206    public function lookupCompanyByEmailAddresses(array $addresses): ?int
207    {
208        $table = $this->tablePrefix . 'mod_companies_records';
209        $sql = "SELECT `id` FROM {$table} "
210            . "WHERE (`email` = :em1 OR `secondary_email` = :em2) "
211            . "LIMIT 1";
212        $stmt = $this->pdo->prepare($sql);
213
214        foreach ($addresses as $rawEmail) {
215            $email = $this->cleanEmail($rawEmail);
216            if ($email === '') {
217                continue;
218            }
219            $stmt->execute([':em1' => $email, ':em2' => $email]);
220            $val = $stmt->fetchColumn();
221            if ($val !== false && (int) $val > 0) {
222                return (int) $val;
223            }
224        }
225
226        return null;
227    }
228
229    /**
230     * Normalizes email address.
231     */
232    private function cleanEmail(string $value): string
233    {
234        if (preg_match('/<([^>]+)>/', $value, $m)) {
235            $value = $m[1];
236        }
237
238        return strtolower(trim($value));
239    }
240}