Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.39% covered (success)
97.39%
149 / 153
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailAuthService
97.37% covered (success)
97.37%
148 / 152
76.92% covered (warning)
76.92%
10 / 13
34
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
 getUserMailboxes
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 getMailboxForUser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 updateMailboxSettings
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 reorderMailboxes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 moveMailbox
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 connectAndLinkMailbox
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
9.02
 reauthenticateMailbox
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 unassignMailbox
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 findMailboxByEmail
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 updateMailboxPassword
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 createMailboxRecord
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 getMailboxById
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
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\Repository\RecordCoOwnerRepositoryInterface;
12use App\Core\Security\Encryption\EncryptionServiceInterface;
13use App\Modules\Mail\Application\Service\Auth\MailboxSettingsManager;
14use App\Modules\Mail\Domain\Contract\MailProtocolDriverRegistryInterface;
15use App\Modules\Mail\Domain\Model\ClientMailbox;
16use App\Modules\Mail\Domain\Model\MailServer;
17use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
18use App\Modules\Mail\Domain\Exception\WebmailAuthException;
19use InvalidArgumentException;
20use PDO;
21
22/**
23 * Webmail Authentication and Mailbox Association Service.
24 *
25 * Manages user mailbox discovery, credential verification against incoming mail servers,
26 * AES-256 password encryption, and relational co-ownership assignments in a_core_record_co_owners.
27 *
28 * @package App\Modules\Mail\Application\Service
29 */
30final readonly class WebmailAuthService
31{
32    private const string MODULE_NAME = 'client_mailboxes';
33
34    /** @var string Explicit columns list for client mailboxes query. */
35    private const string MAILBOX_COLUMNS = '`m`.`id`, `m`.`name`, `m`.`email`, `m`.`from_name`, '
36        . '`m`.`mail_server_id`, `m`.`protocol_type`, `m`.`username`, `m`.`password`, `m`.`smtp_host`, '
37        . '`m`.`smtp_port`, `m`.`smtp_encryption`, `m`.`smtp_auth_method`, `m`.`smtp_username`, '
38        . '`m`.`smtp_password`, `m`.`smtp_from_email`, `m`.`smtp_from_name`, `m`.`smtp_reply_to_email`, '
39        . '`m`.`folder_inbox`, `m`.`folder_sent`, `m`.`folder_drafts`, `m`.`folder_trash`, `m`.`folder_spam`, '
40        . '`m`.`folder_archive`, `m`.`signature_html`, `m`.`folder_order`, `m`.`highest_modseq`, `m`.`uid_next`, '
41        . '`m`.`uid_validity`, `m`.`is_shared`, `m`.`is_default`, `m`.`show_badge`, `m`.`notify_new_mail`, '
42        . '`m`.`sort_order`, `m`.`sync_status`, `m`.`last_sync_at`, `m`.`status`, `m`.`special_access`, `m`.`owner`, '
43        . '`m`.`default_for_users`, `m`.`cached_folders`';
44
45    private const string SELECT_MAILBOXES_FROM = 'SELECT ' . self::MAILBOX_COLUMNS
46        . ' FROM `a_mod_client_mailboxes_records` AS `m` ';
47
48    private MailboxSettingsManager $settingsManager;
49
50    /**
51     * WebmailAuthService constructor.
52     *
53     * @param PDO                                 $pdo             Database connection.
54     * @param MailRepositoryInterface             $mailRepo        Mail repository.
55     * @param RecordCoOwnerRepositoryInterface    $coOwnerRepo     Relational co-owner repository.
56     * @param EncryptionServiceInterface          $encryption      AES encryption service.
57     * @param MailProtocolDriverRegistryInterface $driverRegistry  Mail protocol driver registry.
58     * @param MailboxSettingsManager|null         $settingsManager Mailbox settings manager helper.
59     */
60    public function __construct(
61        private PDO $pdo,
62        private MailRepositoryInterface $mailRepo,
63        private RecordCoOwnerRepositoryInterface $coOwnerRepo,
64        private EncryptionServiceInterface $encryption,
65        private MailProtocolDriverRegistryInterface $driverRegistry,
66        ?MailboxSettingsManager $settingsManager = null
67    ) {
68        $this->settingsManager = $settingsManager ?? new MailboxSettingsManager($this->pdo);
69    }
70
71    /**
72     * Finds all active mailboxes accessible by the given user (as owner or co-owner).
73     *
74     * @param int $userId User ID.
75     * @return array<ClientMailbox> List of populated client mailboxes.
76     */
77    public function getUserMailboxes(int $userId): array
78    {
79        $sql = self::SELECT_MAILBOXES_FROM
80            . 'WHERE `m`.`status` = "active" AND `m`.`special_access` = 1 AND ('
81            . '`m`.`owner` = :owner_id OR `m`.`is_shared` = 1 OR EXISTS ('
82            . 'SELECT 1 FROM `a_core_record_co_owners` AS `co` '
83            . 'WHERE `co`.`module_name` = :mod AND `co`.`record_id` = `m`.`id` AND `co`.`user_id` = :co_user_id'
84            . ')) ORDER BY `m`.`sort_order` ASC, `m`.`is_default` DESC, `m`.`id` ASC';
85
86        $stmt = $this->pdo->prepare($sql);
87        $stmt->execute([
88            ':owner_id'   => $userId,
89            ':co_user_id' => $userId,
90            ':mod'        => self::MODULE_NAME,
91        ]);
92
93        $mailboxes = [];
94        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
95            $mboxId    = (int) $row['id'];
96            $coOwners  = $this->coOwnerRepo->findCoOwnerIds(self::MODULE_NAME, $mboxId);
97            $mailboxes[] = ClientMailbox::fromRow($row, $coOwners);
98        }
99
100        return $this->settingsManager->resolveDefaultMailboxesForUser($mailboxes, $userId);
101    }
102
103    /**
104     * Retrieves specific mailbox for the user or throws exception if unauthorized.
105     *
106     * @param int $mailboxId Mailbox primary key.
107     * @param int $userId    Authenticated user ID.
108     * @return ClientMailbox Mailbox instance.
109     */
110    public function getMailboxForUser(int $mailboxId, int $userId): ClientMailbox
111    {
112        $mailboxes = $this->getUserMailboxes($userId);
113        foreach ($mailboxes as $mbox) {
114            if ($mbox->id === $mailboxId) {
115                return $mbox;
116            }
117        }
118        throw new WebmailAuthException('Access to mailbox is forbidden or mailbox does not exist.');
119    }
120
121    /**
122     * Updates mailbox identity, special folders mapping, and signature.
123     *
124     * @param int                  $mailboxId Mailbox ID.
125     * @param int                  $userId    Current user ID for authorization check.
126     * @param array<string, mixed> $data      Settings payload.
127     * @return bool True if updated successfully.
128     */
129    public function updateMailboxSettings(int $mailboxId, int $userId, array $data): bool
130    {
131        $mailboxes = $this->getUserMailboxes($userId);
132        return $this->settingsManager->updateMailboxSettings($mailboxes, $mailboxId, $userId, $data);
133    }
134
135    /**
136     * Updates ordering sequences for user mailboxes.
137     *
138     * @param int        $userId     Authenticated user ID.
139     * @param array<int> $mailboxIds Ordered list of mailbox IDs.
140     * @return bool True if reorder succeeded.
141     */
142    public function reorderMailboxes(int $userId, array $mailboxIds): bool
143    {
144        $userMailboxes = $this->getUserMailboxes($userId);
145        return $this->settingsManager->reorderMailboxes($userMailboxes, $mailboxIds);
146    }
147
148    /**
149     * Shifts a mailbox up or down in sort order.
150     *
151     * @param int    $userId    Authenticated user ID.
152     * @param int    $mailboxId Target mailbox ID.
153     * @param string $direction Direction ('up' or 'down').
154     * @return bool True if moved.
155     */
156    public function moveMailbox(int $userId, int $mailboxId, string $direction): bool
157    {
158        $userMailboxes = $this->getUserMailboxes($userId);
159        $newIds = $this->settingsManager->computeMovedMailboxIds($userMailboxes, $mailboxId, $direction);
160        if ($newIds === null) {
161            return false;
162        }
163
164        return $this->reorderMailboxes($userId, $newIds);
165    }
166
167    /**
168     * Authenticates user against mail server and links or creates mailbox account.
169     *
170     * @param int         $userId              Current user ID.
171     * @param string      $email               Mailbox email address.
172     * @param string      $password            Raw plaintext password.
173     * @param int         $mailServerId        Selected MailServer ID.
174     * @param bool        $rememberCredentials Whether to encrypt and persist credentials.
175     * @return ClientMailbox Populated active mailbox instance.
176     */
177    public function connectAndLinkMailbox(
178        int $userId,
179        string $email,
180        string $password,
181        int $mailServerId,
182        bool $rememberCredentials = true
183    ): ClientMailbox {
184        $cleanEmail = strtolower(trim($email));
185        if (filter_var($cleanEmail, FILTER_VALIDATE_EMAIL) === false) {
186            throw new InvalidArgumentException('Invalid email address format.');
187        }
188
189        $server = $this->mailRepo->findMailServerById($mailServerId);
190        if ($server === null) {
191            throw new InvalidArgumentException(sprintf('Mail server with ID %d not found.', $mailServerId));
192        }
193
194        // 1. Verify connection against mail server protocol
195        $driver = $this->driverRegistry->getDriver('imap');
196        $probeMailbox = new ClientMailbox(
197            id: 0,
198            name: $cleanEmail,
199            email: $cleanEmail,
200            fromName: $cleanEmail,
201            mailServerId: $server->id,
202            username: $cleanEmail,
203            password: $password
204        );
205
206        $driver->connect($probeMailbox, $server);
207        $driver->disconnect();
208
209        // 2. Check if mailbox already exists in database
210        $existing = $this->findMailboxByEmail($cleanEmail);
211        $encryptedPass = $rememberCredentials ? $this->encryption->encrypt($password) : '';
212
213        if ($existing !== null) {
214            // If user is neither owner nor co-owner, register as co-owner
215            $isCoOwner = $this->coOwnerRepo->isCoOwner(self::MODULE_NAME, $existing->id, $userId);
216            if ($existing->owner !== $userId && !$isCoOwner) {
217                $this->coOwnerRepo->addCoOwner(self::MODULE_NAME, $existing->id, $userId);
218            }
219
220            if ($rememberCredentials && $encryptedPass !== '') {
221                $this->updateMailboxPassword($existing->id, $encryptedPass);
222            }
223
224            $coOwners = $this->coOwnerRepo->findCoOwnerIds(self::MODULE_NAME, $existing->id);
225            return $this->getMailboxById($existing->id, $coOwners);
226        }
227
228        // 3. Create new mailbox with current user as owner
229        $newId = $this->createMailboxRecord($userId, $cleanEmail, $encryptedPass, $server);
230        return $this->getMailboxById($newId, []);
231    }
232
233    /**
234     * Updates expired or changed credentials after successful re-authentication.
235     *
236     * @param int    $mailboxId   Target mailbox ID.
237     * @param int    $userId      Current user ID.
238     * @param string $newPassword New plaintext password.
239     */
240    public function reauthenticateMailbox(int $mailboxId, int $userId, string $newPassword): void
241    {
242        $mailbox = $this->mailRepo->findClientMailboxById($mailboxId);
243        if ($mailbox === null) {
244            throw new InvalidArgumentException(sprintf('Mailbox %d not found.', $mailboxId));
245        }
246
247        if ($mailbox->owner !== $userId && !$this->coOwnerRepo->isCoOwner(self::MODULE_NAME, $mailboxId, $userId)) {
248            throw new WebmailAuthException('Access denied to mailbox.');
249        }
250
251        $server = $this->mailRepo->findMailServerById($mailbox->mailServerId);
252        $driver = $this->driverRegistry->getDriver($mailbox->protocolType);
253
254        $probeMailbox = new ClientMailbox(
255            id: $mailbox->id,
256            name: $mailbox->name,
257            email: $mailbox->email,
258            fromName: $mailbox->fromName,
259            mailServerId: $mailbox->mailServerId,
260            username: $mailbox->username,
261            password: $newPassword
262        );
263
264        $driver->connect($probeMailbox, $server);
265        $driver->disconnect();
266
267        $encryptedPass = $this->encryption->encrypt($newPassword);
268        $this->updateMailboxPassword($mailboxId, $encryptedPass);
269    }
270
271    /**
272     * Unassigns and detaches mailbox access from the specified user.
273     *
274     * @param int $mailboxId Target mailbox ID.
275     * @param int $userId    Target user ID.
276     * @return bool True if unassigned successfully.
277     */
278    public function unassignMailbox(int $mailboxId, int $userId): bool
279    {
280        $mailbox = $this->mailRepo->findClientMailboxById($mailboxId);
281        if ($mailbox === null) {
282            return false;
283        }
284
285        if ($this->coOwnerRepo->isCoOwner(self::MODULE_NAME, $mailboxId, $userId)) {
286            $this->coOwnerRepo->removeCoOwner(self::MODULE_NAME, $mailboxId, $userId);
287        }
288
289        if ($mailbox->owner === $userId) {
290            $coOwners = $this->coOwnerRepo->findCoOwnerIds(self::MODULE_NAME, $mailboxId);
291            if ($coOwners !== []) {
292                $newOwner = $coOwners[0];
293                $sql = 'UPDATE `a_mod_client_mailboxes_records` SET `owner` = :new_owner WHERE `id` = :id';
294                $stmt = $this->pdo->prepare($sql);
295                $stmt->execute([
296                    ':new_owner' => $newOwner,
297                    ':id'        => $mailboxId,
298                ]);
299                $this->coOwnerRepo->removeCoOwner(self::MODULE_NAME, $mailboxId, $newOwner);
300            } else {
301                $sql = 'UPDATE `a_mod_client_mailboxes_records` SET `owner` = 0, `status` = "inactive" '
302                    . 'WHERE `id` = :id';
303                $stmt = $this->pdo->prepare($sql);
304                $stmt->execute([':id' => $mailboxId]);
305            }
306        }
307
308        $this->settingsManager->resetOtherDefaultMailboxes([$mailbox], 0, $userId);
309
310        return true;
311    }
312
313    /**
314     * Finds mailbox by email address.
315     */
316    private function findMailboxByEmail(string $email): ?ClientMailbox
317    {
318        $sql = self::SELECT_MAILBOXES_FROM . 'WHERE `m`.`email` = :email LIMIT 1';
319        $stmt = $this->pdo->prepare($sql);
320        $stmt->execute([':email' => $email]);
321        $row = $stmt->fetch(PDO::FETCH_ASSOC);
322
323        if (!$row) {
324            return null;
325        }
326
327        $coOwners = $this->coOwnerRepo->findCoOwnerIds(self::MODULE_NAME, (int) $row['id']);
328        return ClientMailbox::fromRow($row, $coOwners);
329    }
330
331    /**
332     * Updates encrypted password and resets sync status.
333     */
334    private function updateMailboxPassword(int $mailboxId, string $encryptedPassword): void
335    {
336        $sql = 'UPDATE `a_mod_client_mailboxes_records` SET `password` = :pwd, `sync_status` = "ok" WHERE `id` = :id';
337        $stmt = $this->pdo->prepare($sql);
338        $stmt->execute([
339            ':pwd' => $encryptedPassword,
340            ':id'  => $mailboxId,
341        ]);
342    }
343
344    /**
345     * Inserts new mailbox record into database.
346     */
347    private function createMailboxRecord(int $userId, string $email, string $encryptedPass, MailServer $server): int
348    {
349        $sql = 'INSERT INTO `a_mod_client_mailboxes_records` ('
350            . '`name`, `email`, `from_name`, `mail_server_id`, `protocol_type`, `username`, `password`, '
351            . '`smtp_host`, `smtp_port`, `smtp_encryption`, `smtp_auth_method`, `smtp_username`, `smtp_password`, '
352            . '`folder_inbox`, `is_shared`, `sync_status`, `status`, `special_access`, `created_by`, `owner`'
353            . ') VALUES ('
354            . ':name, :email, :from_name, :mail_server_id, "imap", :username, :password, '
355            . ':smtp_host, :smtp_port, :smtp_encryption, "login", :smtp_username, :smtp_password, '
356            . '"INBOX", 0, "ok", "active", 1, :created_by, :owner'
357            . ')';
358
359        $stmt = $this->pdo->prepare($sql);
360        $stmt->execute([
361            ':name'             => $email,
362            ':email'            => $email,
363            ':from_name'        => $email,
364            ':mail_server_id'   => $server->id,
365            ':username'         => $email,
366            ':password'         => $encryptedPass,
367            ':smtp_host'        => $server->smtpHost,
368            ':smtp_port'        => $server->smtpPort,
369            ':smtp_encryption'  => $server->smtpEncryption,
370            ':smtp_username'    => $email,
371            ':smtp_password'    => $encryptedPass,
372            ':created_by'       => $userId,
373            ':owner'            => $userId,
374        ]);
375
376        return (int) $this->pdo->lastInsertId();
377    }
378
379    /**
380     * Resolves single mailbox by ID.
381     *
382     * @param int        $id       Mailbox ID.
383     * @param array<int> $coOwners Co-owners array.
384     * @return ClientMailbox Mailbox instance.
385     */
386    private function getMailboxById(int $id, array $coOwners): ClientMailbox
387    {
388        $sql = self::SELECT_MAILBOXES_FROM . 'WHERE `m`.`id` = :id LIMIT 1';
389        $stmt = $this->pdo->prepare($sql);
390        $stmt->execute([':id' => $id]);
391        $row = $stmt->fetch(PDO::FETCH_ASSOC);
392
393        if (!$row) {
394            throw new WebmailAuthException(sprintf('Failed to retrieve mailbox %d after save.', $id));
395        }
396
397        return ClientMailbox::fromRow($row, $coOwners);
398    }
399}