Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
224 / 240
28.57% covered (danger)
28.57%
4 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecordEmailLinkService
93.72% covered (success)
93.72%
224 / 239
28.57% covered (danger)
28.57%
4 / 14
75.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
 fetchLinkedEmailsForRecord
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 rescanAndLinkEmailsForRecord
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
6.01
 unlinkEmailFromRecord
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 linkEmailToRecord
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 fetchAvailableEmailsToLink
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
10
 fetchTicketEmailContext
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 fetchActiveMailboxes
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 rescanEmailsForContact
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
5
 rescanEmailsForCompany
92.11% covered (success)
92.11%
35 / 38
0.00% covered (danger)
0.00%
0 / 1
7.02
 rescanEmailsForTicket
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
5.01
 fetchContactEmailContext
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 fetchCompanyEmailContext
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 fetchTicketSpecificEmailContext
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
4.07
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\Core\Engine\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use PDO;
12
13/**
14 * Handles email association, scanning, and contextual linking for CRM records.
15 */
16final readonly class RecordEmailLinkService
17{
18    public function __construct(
19        private ?PDO $pdo = null,
20        private string $tablePrefix = 'a_'
21    ) {
22    }
23
24    /**
25     * Fetches email records linked to a specific record (tickets, contacts, companies).
26     *
27     * @param string $moduleName Module machine name.
28     * @param int    $recordId   Target record ID.
29     * @return array<int, array<string, mixed>> List of linked email records.
30     */
31    public function fetchLinkedEmailsForRecord(string $moduleName, int $recordId): array
32    {
33        if ($this->pdo === null) {
34            return [];
35        }
36
37        $table = $this->tablePrefix . 'mod_emails_records';
38        $filterCol = match ($moduleName) {
39            'contacts'  => 'contact_id',
40            'companies' => 'company_id',
41            default     => 'ticket_id',
42        };
43
44        $sql = 'SELECT id, subject, from_email, from_name, to_email, reply_to, body_html, body_text, '
45            . 'received_at, direction, email_status, mailbox_id, has_attachments '
46            . "FROM `{$table}"
47            . "WHERE `{$filterCol}` = :record_id AND `special_access` = 1 "
48            . 'ORDER BY `received_at` DESC, `id` DESC';
49        try {
50            $stmt = $this->pdo->prepare($sql);
51            $stmt->execute([':record_id' => $recordId]);
52            /** @var array<int, array<string, mixed>> */
53            return $stmt->fetchAll(PDO::FETCH_ASSOC);
54        } catch (\Throwable) {
55            return [];
56        }
57    }
58
59    /**
60     * Rescans existing unlinked emails and attaches matching messages to contact, company, or ticket.
61     *
62     * @param string $moduleName Module machine name ('contacts', 'companies', 'tickets').
63     * @param int    $recordId   Source record ID.
64     * @return array{matched_count: int, linked_emails: array<int, array<string, mixed>>} Rescan outcome.
65     */
66    public function rescanAndLinkEmailsForRecord(string $moduleName, int $recordId): array
67    {
68        if ($this->pdo === null) {
69            return ['matched_count' => 0, 'linked_emails' => []];
70        }
71
72        $emailsTable = $this->tablePrefix . 'mod_emails_records';
73        $matchedCount = match ($moduleName) {
74            'contacts'  => $this->rescanEmailsForContact($recordId, $emailsTable),
75            'companies' => $this->rescanEmailsForCompany($recordId, $emailsTable),
76            'tickets'   => $this->rescanEmailsForTicket($recordId, $emailsTable),
77            default     => 0,
78        };
79
80        $linkedEmails = $this->fetchLinkedEmailsForRecord($moduleName, $recordId);
81
82        return [
83            'matched_count' => $matchedCount,
84            'linked_emails' => $linkedEmails,
85        ];
86    }
87
88    /**
89     * Unlinks an email message from a record.
90     *
91     * @param string $moduleName Module machine name.
92     * @param int    $recordId   Target record ID.
93     * @param int    $emailId    Target email record ID to unlink.
94     * @return bool True if record was updated.
95     */
96    public function unlinkEmailFromRecord(string $moduleName, int $recordId, int $emailId): bool
97    {
98        if ($this->pdo === null) {
99            return false;
100        }
101
102        $table = $this->tablePrefix . 'mod_emails_records';
103        $filterCol = match ($moduleName) {
104            'contacts'  => 'contact_id',
105            'companies' => 'company_id',
106            default     => 'ticket_id',
107        };
108
109        $sql = "UPDATE `{$table}` SET `{$filterCol}` = NULL "
110            . "WHERE `id` = :email_id AND `{$filterCol}` = :record_id";
111        try {
112            $stmt = $this->pdo->prepare($sql);
113            return $stmt->execute([
114                ':email_id'  => $emailId,
115                ':record_id' => $recordId,
116            ]);
117        } catch (\Throwable) {
118            return false;
119        }
120    }
121
122    /**
123     * Links an email message to a record.
124     *
125     * @param string $moduleName Module machine name.
126     * @param int    $recordId   Target record ID.
127     * @param int    $emailId    Target email record ID to link.
128     * @return bool True if record was updated.
129     */
130    public function linkEmailToRecord(string $moduleName, int $recordId, int $emailId): bool
131    {
132        if ($this->pdo === null) {
133            return false;
134        }
135
136        $table = $this->tablePrefix . 'mod_emails_records';
137        $targetCol = match ($moduleName) {
138            'contacts'  => 'contact_id',
139            'companies' => 'company_id',
140            default     => 'ticket_id',
141        };
142
143        $sql = "UPDATE `{$table}` SET `{$targetCol}` = :record_id "
144            . "WHERE `id` = :email_id";
145        try {
146            $stmt = $this->pdo->prepare($sql);
147            return $stmt->execute([
148                ':record_id' => $recordId,
149                ':email_id'  => $emailId,
150            ]);
151        } catch (\Throwable) {
152            return false;
153        }
154    }
155
156    /**
157     * Fetches candidate email records available to link with a record.
158     *
159     * @param string|int  $moduleOrId Target module name or ticket ID.
160     * @param string|null $query      Optional text search filter.
161     * @param int         $limit      Maximum records to fetch.
162     * @return array<int, array<string, mixed>> List of available email candidate records.
163     */
164    public function fetchAvailableEmailsToLink(string|int $moduleOrId, ?string $query = null, int $limit = 20): array
165    {
166        if ($this->pdo === null) {
167            return [];
168        }
169
170        $moduleName = is_string($moduleOrId) && !is_numeric($moduleOrId) ? $moduleOrId : 'tickets';
171        $filterCol = match ($moduleName) {
172            'contacts'  => 'contact_id',
173            'companies' => 'company_id',
174            default     => 'ticket_id',
175        };
176
177        $table = $this->tablePrefix . 'mod_emails_records';
178        $sql = 'SELECT id, subject, from_email, from_name, to_email, received_at, direction '
179            . "FROM `{$table}"
180            . "WHERE (`{$filterCol}` IS NULL OR `{$filterCol}` = 0) AND `special_access` = 1 ";
181
182        $params = [];
183        if ($query !== null && trim($query) !== '') {
184            $sql .= 'AND (`subject` LIKE :q OR `from_email` LIKE :q OR `from_name` LIKE :q) ';
185            $params[':q'] = '%' . trim($query) . '%';
186        }
187
188        $sql .= 'ORDER BY `received_at` DESC, `id` DESC LIMIT ' . (int) $limit;
189
190        try {
191            $stmt = $this->pdo->prepare($sql);
192            $stmt->execute($params);
193            /** @var array<int, array<string, mixed>> */
194            return $stmt->fetchAll(PDO::FETCH_ASSOC);
195        } catch (\Throwable) {
196            return [];
197        }
198    }
199
200    /**
201     * Fetches email context (ticket_no, subject, linked contact email) for compose dialog.
202     *
203     * @param string|int $moduleOrId Target module name or ticket ID.
204     * @param int|null   $recordId   Record primary key.
205     * @return array<string, mixed>|null Context data or null.
206     */
207    public function fetchTicketEmailContext(string|int $moduleOrId, ?int $recordId = null): ?array
208    {
209        if ($this->pdo === null) {
210            return null;
211        }
212
213        $module = is_string($moduleOrId) && !is_numeric($moduleOrId) ? $moduleOrId : 'tickets';
214        $id = $recordId ?? (int) $moduleOrId;
215
216        return match ($module) {
217            'contacts'  => $this->fetchContactEmailContext($id),
218            'companies' => $this->fetchCompanyEmailContext($id),
219            default     => $this->fetchTicketSpecificEmailContext($id),
220        };
221    }
222
223    /**
224     * Fetches active client mailboxes for compose dialog sender selector.
225     *
226     * @return array<int, array<string, mixed>> List of active mailboxes.
227     */
228    public function fetchActiveMailboxes(): array
229    {
230        if ($this->pdo === null) {
231            return [];
232        }
233
234        $table = $this->tablePrefix . 'mod_client_mailboxes_records';
235        $sql = 'SELECT `id`, `name`, `email`, `from_name`, `is_default` '
236            . "FROM `{$table}"
237            . "WHERE `status` = 'active' AND `special_access` = 1 "
238            . 'ORDER BY `is_default` DESC, `sort_order` ASC, `id` ASC';
239
240        try {
241            $stmt = $this->pdo->query($sql);
242            /** @var array<int, array<string, mixed>>|false $rows */
243            $rows = $stmt !== false ? $stmt->fetchAll(PDO::FETCH_ASSOC) : false;
244            return $rows !== false ? $rows : [];
245        } catch (\Throwable) {
246            return [];
247        }
248    }
249
250    /**
251     * Rescans unlinked emails matching contact email addresses.
252     */
253    private function rescanEmailsForContact(int $recordId, string $emailsTable): int
254    {
255        if ($this->pdo === null) {
256            return 0;
257        }
258
259        $contactsTable = $this->tablePrefix . 'mod_contacts_records';
260        $stmt = $this->pdo->prepare(
261            "SELECT id, email, secondary_email, parent_id FROM `{$contactsTable}` WHERE id = :id LIMIT 1"
262        );
263        $stmt->execute([':id' => $recordId]);
264        $contact = $stmt->fetch(PDO::FETCH_ASSOC);
265        if ($contact === false) {
266            return 0;
267        }
268
269        $candidateEmails = array_values(array_filter([
270            strtolower(trim((string) ($contact['email'] ?? ''))),
271            strtolower(trim((string) ($contact['secondary_email'] ?? ''))),
272        ]));
273        $companyId = !empty($contact['parent_id']) ? (int) $contact['parent_id'] : null;
274
275        $matchedCount = 0;
276        foreach ($candidateEmails as $em) {
277            $updSql = "UPDATE `{$emailsTable}` SET contact_id = :cid, "
278                . 'company_id = COALESCE(company_id, :coid) '
279                . 'WHERE (`from_email` = :em OR `to_email` LIKE :em_like) '
280                . 'AND (`contact_id` IS NULL OR `contact_id` = 0)';
281            $updStmt = $this->pdo->prepare($updSql);
282            $updStmt->execute([
283                ':cid'     => $recordId,
284                ':coid'    => $companyId,
285                ':em'      => $em,
286                ':em_like' => '%' . $em . '%',
287            ]);
288            $matchedCount += $updStmt->rowCount();
289        }
290
291        return $matchedCount;
292    }
293
294    /**
295     * Rescans unlinked emails matching company and child contacts email addresses.
296     */
297    private function rescanEmailsForCompany(int $recordId, string $emailsTable): int
298    {
299        if ($this->pdo === null) {
300            return 0;
301        }
302
303        $companiesTable = $this->tablePrefix . 'mod_companies_records';
304        $stmt = $this->pdo->prepare(
305            "SELECT id, email, secondary_email FROM `{$companiesTable}` WHERE id = :id LIMIT 1"
306        );
307        $stmt->execute([':id' => $recordId]);
308        $company = $stmt->fetch(PDO::FETCH_ASSOC);
309        if ($company === false) {
310            return 0;
311        }
312
313        $candidateEmails = array_values(array_filter([
314            strtolower(trim((string) ($company['email'] ?? ''))),
315            strtolower(trim((string) ($company['secondary_email'] ?? ''))),
316        ]));
317
318        $contactsTable = $this->tablePrefix . 'mod_contacts_records';
319        $cSql = "SELECT email, secondary_email FROM `{$contactsTable}"
320            . 'WHERE parent_id = :cid';
321        $cStmt = $this->pdo->prepare($cSql);
322        $cStmt->execute([':cid' => $recordId]);
323        while ($cRow = $cStmt->fetch(PDO::FETCH_ASSOC)) {
324            if (!empty($cRow['email'])) {
325                $candidateEmails[] = strtolower(trim((string) $cRow['email']));
326            }
327            if (!empty($cRow['secondary_email'])) {
328                $candidateEmails[] = strtolower(trim((string) $cRow['secondary_email']));
329            }
330        }
331        $candidateEmails = array_values(array_unique($candidateEmails));
332
333        $matchedCount = 0;
334        foreach ($candidateEmails as $em) {
335            $updSql = "UPDATE `{$emailsTable}` SET company_id = :coid "
336                . 'WHERE (`from_email` = :em OR `to_email` LIKE :em_like) '
337                . 'AND (`company_id` IS NULL OR `company_id` = 0)';
338            $updStmt = $this->pdo->prepare($updSql);
339            $updStmt->execute([
340                ':coid'    => $recordId,
341                ':em'      => $em,
342                ':em_like' => '%' . $em . '%',
343            ]);
344            $matchedCount += $updStmt->rowCount();
345        }
346
347        return $matchedCount;
348    }
349
350    /**
351     * Rescans unlinked emails matching ticket number in subject or body.
352     */
353    private function rescanEmailsForTicket(int $recordId, string $emailsTable): int
354    {
355        if ($this->pdo === null) {
356            return 0;
357        }
358
359        $ticketsTable = $this->tablePrefix . 'mod_tickets_records';
360        $stmt = $this->pdo->prepare(
361            "SELECT id, ticket_no, company_id FROM `{$ticketsTable}` WHERE id = :id LIMIT 1"
362        );
363        $stmt->execute([':id' => $recordId]);
364        $ticket = $stmt->fetch(PDO::FETCH_ASSOC);
365        $ticketNo = is_array($ticket) ? trim((string) ($ticket['ticket_no'] ?? '')) : '';
366        if ($ticketNo === '') {
367            return 0;
368        }
369
370        $companyId = !empty($ticket['company_id']) ? (int) $ticket['company_id'] : null;
371
372        $updSql = "UPDATE `{$emailsTable}` SET ticket_id = :tid, "
373            . 'company_id = COALESCE(company_id, :coid) '
374            . 'WHERE (`subject` LIKE :tno_like1 OR `body_text` LIKE :tno_like2) '
375            . 'AND (`ticket_id` IS NULL OR `ticket_id` = 0)';
376        $updStmt = $this->pdo->prepare($updSql);
377        $updStmt->execute([
378            ':tid'       => $recordId,
379            ':coid'      => $companyId,
380            ':tno_like1' => '%' . $ticketNo . '%',
381            ':tno_like2' => '%' . $ticketNo . '%',
382        ]);
383
384        return $updStmt->rowCount();
385    }
386
387    /**
388     * Fetches contact email context for compose dialog.
389     */
390    private function fetchContactEmailContext(int $id): ?array
391    {
392        if ($this->pdo === null) {
393            return null;
394        }
395
396        $contactsTable = $this->tablePrefix . 'mod_contacts_records';
397        $cSql = 'SELECT id, email AS contact_email, '
398            . "CONCAT_WS(' ', first_name, last_name) AS contact_name "
399            . "FROM `{$contactsTable}` WHERE id = :id LIMIT 1";
400        $stmt = $this->pdo->prepare($cSql);
401        $stmt->execute([':id' => $id]);
402        $row = $stmt->fetch(PDO::FETCH_ASSOC);
403
404        return $row !== false ? array_merge($row, ['ticket_no' => '', 'subject' => '']) : null;
405    }
406
407    /**
408     * Fetches company email context for compose dialog.
409     */
410    private function fetchCompanyEmailContext(int $id): ?array
411    {
412        if ($this->pdo === null) {
413            return null;
414        }
415
416        $companiesTable = $this->tablePrefix . 'mod_companies_records';
417        $coSql = 'SELECT id, email AS contact_email, name AS contact_name '
418            . "FROM `{$companiesTable}` WHERE id = :id LIMIT 1";
419        $stmt = $this->pdo->prepare($coSql);
420        $stmt->execute([':id' => $id]);
421        $row = $stmt->fetch(PDO::FETCH_ASSOC);
422
423        return $row !== false ? array_merge($row, ['ticket_no' => '', 'subject' => '']) : null;
424    }
425
426    /**
427     * Fetches ticket-specific email context with linked contact for compose dialog.
428     */
429    private function fetchTicketSpecificEmailContext(int $id): ?array
430    {
431        if ($this->pdo === null) {
432            return null;
433        }
434
435        $ticketsTable = $this->tablePrefix . 'mod_tickets_records';
436        $relTable = $this->tablePrefix . 'rel_tickets_contacts_records';
437        $contactsTable = $this->tablePrefix . 'mod_contacts_records';
438
439        $sql = 'SELECT t.id, t.ticket_no, t.subject, t.owner, '
440            . "c.email AS contact_email, CONCAT_WS(' ', c.first_name, c.last_name) AS contact_name "
441            . "FROM `{$ticketsTable}` t "
442            . "LEFT JOIN `{$relTable}` r ON r.ticket_id = t.id "
443            . "LEFT JOIN `{$contactsTable}` c ON c.id = r.contact_id "
444            . 'WHERE t.id = :ticket_id '
445            . 'ORDER BY r.id ASC LIMIT 1';
446
447        try {
448            $stmt = $this->pdo->prepare($sql);
449            $stmt->execute([':ticket_id' => $id]);
450            /** @var array<string, mixed>|false $row */
451            $row = $stmt->fetch(PDO::FETCH_ASSOC);
452
453            return $row !== false ? $row : null;
454        } catch (\Throwable) {
455            return null;
456        }
457    }
458}