Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.41% covered (success)
98.41%
62 / 63
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailOwnershipResolver
98.39% covered (success)
98.39%
61 / 62
83.33% covered (warning)
83.33%
5 / 6
20
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
 resolveOwnership
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
5
 loadActiveUserMap
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 extractCoOwnersFromAddresses
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 resolveAllBccAddresses
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 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 App\Modules\Mail\Domain\Model\MailMessageDetailDto;
12use PDO;
13
14/**
15 * Enterprise Email Ownership Resolver.
16 *
17 * Resolves record owner, co-owners, and direction (inbound/outbound) based on
18 * internal active user email accounts and RFC message recipient headers.
19 *
20 * @package App\Modules\Mail\Application\Service\Scanner
21 */
22final readonly class EmailOwnershipResolver
23{
24    public const string DIRECTION_INBOUND = 'inbound';
25    public const string DIRECTION_OUTBOUND = 'outbound';
26
27    /**
28     * EmailOwnershipResolver constructor.
29     *
30     * @param PDO    $pdo         Database connection handle.
31     * @param string $tablePrefix Database table prefix.
32     */
33    public function __construct(
34        private PDO $pdo,
35        private string $tablePrefix = 'a_'
36    ) {
37    }
38
39    /**
40     * Resolves record owner, co-owners, and direction from RFC message headers and internal user accounts.
41     *
42     * @param string        $fromEmail    Sender email address.
43     * @param array<string> $toEmails     To recipient addresses.
44     * @param array<string> $ccEmails     Cc recipient addresses.
45     * @param array<string> $bccEmails    Bcc recipient addresses.
46     * @param int           $defaultOwner Fallback owner ID from mailbox configuration.
47     * @return array{owner: int, co_owners: array<int>, direction: string, created_by: int}
48     */
49    public function resolveOwnership(
50        string $fromEmail,
51        array $toEmails,
52        array $ccEmails,
53        array $bccEmails,
54        int $defaultOwner
55    ): array {
56        $userMap = $this->loadActiveUserMap();
57        $normalizedFrom = $this->cleanEmail($fromEmail);
58
59        if (isset($userMap[$normalizedFrom])) {
60            $ownerId = $userMap[$normalizedFrom];
61            $coOwnerIds = $this->extractCoOwnersFromAddresses(
62                [...$toEmails, ...$ccEmails, ...$bccEmails],
63                $userMap,
64                $ownerId
65            );
66
67            return [
68                'owner'      => $ownerId,
69                'co_owners'  => $coOwnerIds,
70                'direction'  => self::DIRECTION_OUTBOUND,
71                'created_by' => $ownerId,
72            ];
73        }
74
75        $allRecipients = [...$toEmails, ...$ccEmails, ...$bccEmails];
76        $primaryOwnerId = null;
77
78        foreach ($allRecipients as $recipient) {
79            $clean = $this->cleanEmail($recipient);
80            if (isset($userMap[$clean])) {
81                $primaryOwnerId = $userMap[$clean];
82                break;
83            }
84        }
85
86        $ownerId = $primaryOwnerId ?? ($defaultOwner > 0 ? $defaultOwner : 1);
87        $coOwnerIds = $this->extractCoOwnersFromAddresses($allRecipients, $userMap, $ownerId);
88
89        return [
90            'owner'      => $ownerId,
91            'co_owners'  => $coOwnerIds,
92            'direction'  => self::DIRECTION_INBOUND,
93            'created_by' => 1,
94        ];
95    }
96
97    /**
98     * Loads map of lowercase internal user emails to user primary key IDs.
99     *
100     * @return array<string, int> Map of email => user_id.
101     */
102    public function loadActiveUserMap(): array
103    {
104        $table = $this->tablePrefix . 'mod_users_records';
105        $sql = "SELECT `id`, `email` FROM {$table} WHERE `status` = 'active' AND `special_access` = 1";
106        $stmt = $this->pdo->query($sql);
107        if ($stmt === false) {
108            return [];
109        }
110
111        $map = [];
112        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
113            $email = $this->cleanEmail((string) ($row['email'] ?? ''));
114            if ($email !== '') {
115                $map[$email] = (int) $row['id'];
116            }
117        }
118
119        return $map;
120    }
121
122    /**
123     * Extracts distinct internal user IDs from recipient addresses excluding the primary owner.
124     *
125     * @param array<string>      $addresses Recipient email list.
126     * @param array<string, int> $userMap   Active user mapping.
127     * @param int                $ownerId   Primary owner ID to exclude.
128     * @return array<int> Sorted distinct co-owner user IDs.
129     */
130    public function extractCoOwnersFromAddresses(array $addresses, array $userMap, int $ownerId): array
131    {
132        $coOwners = [];
133        foreach ($addresses as $addr) {
134            $clean = $this->cleanEmail($addr);
135            if (isset($userMap[$clean])) {
136                $uid = $userMap[$clean];
137                if ($uid !== $ownerId) {
138                    $coOwners[$uid] = $uid;
139                }
140            }
141        }
142
143        return array_values($coOwners);
144    }
145
146    /**
147     * Resolves all possible BCC / hidden recipient addresses from headers.
148     *
149     * @param MailMessageDetailDto $detail Message detail payload.
150     * @return array<string> List of detected BCC addresses.
151     */
152    public function resolveAllBccAddresses(MailMessageDetailDto $detail): array
153    {
154        $bcc = $detail->bcc;
155        $headers = $detail->headers;
156
157        $checkKeys = ['delivered-to', 'x-original-to', 'envelope-to', 'x-envelope-to'];
158        foreach ($checkKeys as $key) {
159            if (!empty($headers[$key])) {
160                $clean = $this->cleanEmail((string) $headers[$key]);
161                if ($clean !== '') {
162                    $bcc[] = $clean;
163                }
164            }
165        }
166
167        return array_values(array_unique($bcc));
168    }
169
170    /**
171     * Normalizes and extracts pure lowercase email address without display name brackets.
172     */
173    public function cleanEmail(string $value): string
174    {
175        if (preg_match('/<([^>]+)>/', $value, $m)) {
176            $value = $m[1];
177        }
178
179        return strtolower(trim($value));
180    }
181}