Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.39% covered (warning)
85.39%
76 / 89
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
WebmailSenderService
85.23% covered (warning)
85.23%
75 / 88
55.56% covered (warning)
55.56%
5 / 9
46.42
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
 sendMessage
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
3.11
 resolveSmtpConfig
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
7.39
 buildSymfonyEmail
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
12.47
 createSmtpTransport
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 appendSentCopy
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
8.14
 saveDraft
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 assertMailboxAccess
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 connectDriver
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\EncryptionException;
13use App\Core\Security\Encryption\EncryptionServiceInterface;
14use App\Modules\Mail\Application\Service\Connection\WebmailConnectionManager;
15use App\Modules\Mail\Application\Service\Connection\WebmailConnectionManagerInterface;
16use App\Modules\Mail\Domain\Contract\MailProtocolDriverInterface;
17use App\Modules\Mail\Domain\Contract\MailProtocolDriverRegistryInterface;
18use App\Modules\Mail\Domain\Contract\WebmailSenderServiceInterface;
19use App\Modules\Mail\Domain\Exception\WebmailAuthException;
20use App\Modules\Mail\Domain\Model\ClientMailbox;
21use App\Modules\Mail\Domain\Model\MailOutgoingMessageDto;
22use App\Modules\Mail\Domain\Model\MailSendResultDto;
23use App\Modules\Mail\Domain\Model\MailServer;
24use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
25use InvalidArgumentException;
26use RuntimeException;
27use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
28use Symfony\Component\Mime\Address;
29use Symfony\Component\Mime\Email;
30use Throwable;
31
32/**
33 * Webmail Message Sending Application Service.
34 *
35 * Dispatches outbound email messages through dedicated per-mailbox SMTP transports,
36 * signs DKIM when configured, and appends sent copies to the designated Sent folder.
37 *
38 * @package App\Modules\Mail\Application\Service
39 */
40final readonly class WebmailSenderService implements WebmailSenderServiceInterface
41{
42    private const string MODULE_NAME = 'client_mailboxes';
43
44    private WebmailConnectionManagerInterface $connectionManager;
45
46    /**
47     * WebmailSenderService constructor.
48     *
49     * @param MailRepositoryInterface                 $mailRepo       Mail repository.
50     * @param RecordCoOwnerRepositoryInterface        $coOwnerRepo    Relational co-owner repository.
51     * @param EncryptionServiceInterface              $encryption     AES-256 encryption service.
52     * @param MailProtocolDriverRegistryInterface     $driverRegistry Protocol driver registry.
53     * @param WebmailConnectionManagerInterface|null  $connManager    Optional connection manager.
54     */
55    public function __construct(
56        private MailRepositoryInterface $mailRepo,
57        private RecordCoOwnerRepositoryInterface $coOwnerRepo,
58        private EncryptionServiceInterface $encryption,
59        private MailProtocolDriverRegistryInterface $driverRegistry,
60        ?WebmailConnectionManagerInterface $connManager = null
61    ) {
62        $this->connectionManager = $connManager ?? new WebmailConnectionManager(
63            $this->mailRepo,
64            $this->encryption,
65            $this->driverRegistry
66        );
67    }
68
69    /**
70     * Sends outgoing email message via dedicated mailbox SMTP configuration.
71     *
72     * @param int                    $userId  Current user ID.
73     * @param MailOutgoingMessageDto $message Outgoing message payload.
74     * @return MailSendResultDto Send outcome.
75     */
76    public function sendMessage(int $userId, MailOutgoingMessageDto $message): MailSendResultDto
77    {
78        $mailbox = $this->assertMailboxAccess($message->mailboxId, $userId);
79        $server  = $this->mailRepo->findMailServerById($mailbox->mailServerId);
80
81        [$smtpHost, $smtpPort, $smtpEncryption, $smtpUser, $smtpPass] = $this->resolveSmtpConfig($mailbox, $server);
82        if ($smtpHost === '') {
83            return MailSendResultDto::failure('No SMTP server configured for this mailbox.');
84        }
85
86        try {
87            $email = $this->buildSymfonyEmail($message);
88            $transport = $this->createSmtpTransport($smtpHost, $smtpPort, $smtpEncryption, $smtpUser, $smtpPass);
89
90            $sentMessage = $transport->send($email);
91            $messageId   = $sentMessage?->getMessageId() ?? sprintf('<%s@ammonly>', bin2hex(random_bytes(16)));
92            $sentUid     = $this->appendSentCopy($mailbox, $message, $email->toString());
93
94            return MailSendResultDto::success($messageId, $sentUid);
95        } catch (Throwable $e) {
96            return MailSendResultDto::failure('SMTP Send Error: ' . $e->getMessage());
97        }
98    }
99
100    /**
101     * Resolves effective SMTP configuration parameters.
102     *
103     * @return array{0: string, 1: int, 2: string, 3: string, 4: string}
104     */
105    private function resolveSmtpConfig(ClientMailbox $mailbox, ?MailServer $server): array
106    {
107        $smtpHost = $mailbox->smtpHost ?? ($server !== null ? $server->smtpHost : '');
108        $smtpPort = $mailbox->smtpPort ?? ($server !== null ? $server->smtpPort : 465);
109        $fallbackEncryption = $server !== null ? $server->smtpEncryption : 'ssl';
110        $smtpEncryption = $mailbox->smtpEncryption !== '' ? $mailbox->smtpEncryption : $fallbackEncryption;
111        $smtpUser = $mailbox->smtpUsername ?? $mailbox->username;
112        $rawSmtpPass = $mailbox->smtpPassword ?? $mailbox->password;
113        try {
114            $smtpPass = $rawSmtpPass !== '' ? $this->encryption->decrypt($rawSmtpPass) : '';
115        } catch (EncryptionException) {
116            $smtpPass = '';
117        }
118
119        return [$smtpHost, $smtpPort, $smtpEncryption, $smtpUser, $smtpPass];
120    }
121
122    /**
123     * Builds Symfony MIME Email from outgoing message DTO.
124     */
125    private function buildSymfonyEmail(MailOutgoingMessageDto $message): Email
126    {
127        $email = new Email();
128        $email->from(new Address($message->fromEmail, $message->fromName));
129        foreach ($message->to as $toAddr) {
130            $email->addTo(trim($toAddr));
131        }
132        foreach ($message->cc as $ccAddr) {
133            $email->addCc(trim($ccAddr));
134        }
135        foreach ($message->bcc as $bccAddr) {
136            $email->addBcc(trim($bccAddr));
137        }
138
139        $email->subject($message->subject);
140        if ($message->htmlBody !== '') {
141            $email->html($message->htmlBody);
142        }
143        if ($message->textBody !== '') {
144            $email->text($message->textBody);
145        }
146
147        $hasCalendar = false;
148        foreach ($message->attachments as $att) {
149            if (isset($att['content'], $att['filename'])) {
150                $filename = (string) $att['filename'];
151                $mimeType = (string) ($att['mimeType'] ?? 'application/octet-stream');
152                if (str_ends_with(strtolower($filename), '.ics')) {
153                    $hasCalendar = true;
154                    if (!str_contains($mimeType, 'method=')) {
155                        $method = str_contains(strtolower($filename), 'reply') ? 'REPLY' : 'REQUEST';
156                        $mimeType = sprintf('text/calendar; charset=UTF-8; method=%s', $method);
157                    }
158                }
159                $email->attach((string) $att['content'], $filename, $mimeType);
160            }
161        }
162
163        if ($hasCalendar) {
164            $email->getHeaders()->addTextHeader('Content-Class', 'urn:content-classes:calendarmessage');
165        }
166
167        return $email;
168    }
169
170    /**
171     * Creates configured ESMTP transport.
172     */
173    private function createSmtpTransport(
174        string $host,
175        int $port,
176        string $encryption,
177        string $user,
178        string $pass
179    ): EsmtpTransport {
180        $isTls = strtolower($encryption) === 'ssl' || $port === 465;
181        $transport = new EsmtpTransport($host, $port, $isTls);
182        if ($user !== '' && $pass !== '') {
183            $transport->setUsername($user);
184            $transport->setPassword($pass);
185        }
186        return $transport;
187    }
188
189    /**
190     * Appends copy of outgoing message to Sent folder and cleans up origin draft.
191     *
192     * @param ClientMailbox          $mailbox    Mailbox configuration.
193     * @param MailOutgoingMessageDto $message    Outgoing message payload.
194     * @param string|null            $rawPayload Raw RFC 822 MIME payload string.
195     * @return string|null Appended message UID or null on failure.
196     */
197    private function appendSentCopy(
198        ClientMailbox $mailbox,
199        MailOutgoingMessageDto $message,
200        ?string $rawPayload = null
201    ): ?string {
202        try {
203            $driver = $this->connectDriver($mailbox);
204            $sentFolder = $mailbox->folderSent ?? 'Sent';
205            $payload = ($rawPayload !== null && $rawPayload !== '')
206                ? $rawPayload
207                : $this->buildSymfonyEmail($message)->toString();
208
209            $sentUid = $driver->appendRawMessage($sentFolder, $payload, ['\\Seen']);
210
211            if ($message->existingDraftUid !== null && $message->existingDraftUid !== '') {
212                $draftFolder = $mailbox->folderDrafts ?? 'Drafts';
213                try {
214                    $driver->deleteMessages($draftFolder, [$message->existingDraftUid], true);
215                } catch (Throwable) {
216                    // Ignore draft removal errors on outgoing delivery
217                }
218            }
219
220            $driver->disconnect();
221            return $sentUid;
222        } catch (Throwable) {
223            return null;
224        }
225    }
226
227    /**
228     * Stores message draft in Drafts folder.
229     *
230     * @param int                    $userId  Current user ID.
231     * @param MailOutgoingMessageDto $message Outgoing message payload.
232     * @return string Stored draft message UID.
233     */
234    public function saveDraft(int $userId, MailOutgoingMessageDto $message): string
235    {
236        $mailbox = $this->assertMailboxAccess($message->mailboxId, $userId);
237        $driver  = $this->connectDriver($mailbox);
238
239        try {
240            return $driver->saveDraft($message, $message->existingDraftUid);
241        } finally {
242            $driver->disconnect();
243        }
244    }
245
246    /**
247     * Asserts user access to mailbox.
248     */
249    private function assertMailboxAccess(int $mailboxId, int $userId): ClientMailbox
250    {
251        $mailbox = $this->mailRepo->findClientMailboxById($mailboxId);
252        if ($mailbox === null) {
253            throw new InvalidArgumentException(sprintf('Mailbox %d not found.', $mailboxId));
254        }
255
256        $isOwner   = $mailbox->owner === $userId;
257        $isCoOwner = $this->coOwnerRepo->isCoOwner(self::MODULE_NAME, $mailboxId, $userId);
258
259        if (!$isOwner && !$isCoOwner && !$mailbox->isShared) {
260            throw new \App\Modules\Mail\Domain\Exception\WebmailSenderException('Access denied to mailbox.');
261        }
262
263        return $mailbox;
264    }
265
266    /**
267     * Connects driver instance.
268     */
269    private function connectDriver(ClientMailbox $mailbox): MailProtocolDriverInterface
270    {
271        return $this->connectionManager->connectDriver($mailbox);
272    }
273}