Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.43% covered (success)
98.43%
125 / 127
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailSenderService
98.41% covered (success)
98.41%
124 / 126
88.89% covered (warning)
88.89%
8 / 9
30
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
 enqueueFromTemplate
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
5
 sendQueueItem
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 resolveTransport
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
4.04
 buildEmailMessage
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 attachEmailFiles
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 testConnection
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 testMailServerConnection
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
 testMailboxConnection
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\Security\Encryption\AesGcmEncryptionService;
12use App\Core\Security\Encryption\EncryptionException;
13use App\Core\Security\Encryption\EncryptionServiceInterface;
14use App\Modules\Mail\Application\Contract\MailSenderServiceInterface;
15use App\Modules\Mail\Domain\Model\ClientMailbox;
16use App\Modules\Mail\Domain\Model\MailQueueItem;
17use App\Modules\Mail\Domain\Model\MailServer;
18use App\Modules\Mail\Domain\Model\MailSmtp;
19use App\Modules\Mail\Domain\Repository\MailRepositoryInterface;
20use InvalidArgumentException;
21use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
22use Symfony\Component\Mailer\Transport\TransportInterface;
23use Symfony\Component\Mime\Address;
24use Symfony\Component\Mime\Email;
25use Throwable;
26
27/**
28 * Universal Mail Delivery & Queue Dispatching Service.
29 *
30 * @package App\Modules\Mail\Application\Service
31 */
32final readonly class MailSenderService implements MailSenderServiceInterface
33{
34    private EncryptionServiceInterface $encryption;
35
36    /**
37     * MailSenderService constructor.
38     *
39     * @param MailRepositoryInterface         $repository Mail repository.
40     * @param MailTemplateRenderer            $renderer   Template renderer.
41     * @param EncryptionServiceInterface|null $encryption Encryption service.
42     * @param TransportInterface|null         $transport  Optional custom transport.
43     */
44    public function __construct(
45        private MailRepositoryInterface $repository,
46        private MailTemplateRenderer $renderer,
47        ?EncryptionServiceInterface $encryption = null,
48        private ?TransportInterface $transport = null,
49    ) {
50        $this->encryption = $encryption ?? new AesGcmEncryptionService();
51    }
52
53    /**
54     * Enqueues an email for asynchronous delivery.
55     *
56     * @param string                $templateCode   Template identifier.
57     * @param string                $recipientEmail Recipient email.
58     * @param string|null           $recipientName  Recipient name.
59     * @param array<string, scalar> $params         Replacement parameters.
60     * @param string                $priority       Delivery priority (urgent, high, normal).
61     * @param string                $languageCode   Language code.
62     * @return int Enqueued queue record ID.
63     */
64    public function enqueueFromTemplate(
65        string $templateCode,
66        string $recipientEmail,
67        ?string $recipientName = null,
68        array $params = [],
69        string $priority = 'normal',
70        string $languageCode = 'en'
71    ): int {
72        if (filter_var($recipientEmail, FILTER_VALIDATE_EMAIL) === false) {
73            throw new InvalidArgumentException(
74                sprintf('Invalid recipient email address format: "%s".', $recipientEmail)
75            );
76        }
77
78        EmailHeaderSanitizer::assertSafeHeader($recipientEmail, 'recipient_email');
79        if ($recipientName !== null) {
80            EmailHeaderSanitizer::assertSafeHeader($recipientName, 'recipient_name');
81        }
82
83        $template = $this->repository->findTemplateByCode($templateCode, $languageCode);
84        $smtp = $template?->smtpId !== null
85            ? $this->repository->findSmtpById($template->smtpId)
86            : $this->repository->findDefaultSmtp();
87
88        $smtpId = $smtp?->id ?? 1;
89        $rawSubject = $this->renderer->render($template?->subject ?? 'Notification', $params);
90        $subject = EmailHeaderSanitizer::sanitizeHeader($rawSubject);
91        $bodyHtml = $this->renderer->render($template?->bodyHtml ?? '', $params);
92        $bodyText = $template?->bodyText !== null
93            ? $this->renderer->render($template->bodyText, $params)
94            : $this->renderer->htmlToPlainText($bodyHtml);
95
96        $queueItem = new MailQueueItem(
97            id: 0,
98            templateId: $template?->id,
99            smtpId: $smtpId,
100            recipientEmail: $recipientEmail,
101            recipientName: $recipientName,
102            subject: $subject,
103            bodyHtml: $bodyHtml,
104            bodyText: $bodyText,
105            priority: $priority,
106            dispatchMode: $template?->dispatchMode ?? 'automatic'
107        );
108
109        return $this->repository->enqueue($queueItem);
110    }
111
112    /**
113     * Directly delivers an email item via SMTP transport.
114     *
115     * @param MailQueueItem $item Queue record item.
116     * @param MailSmtp      $smtp SMTP configuration.
117     * @return bool True on successful transmission.
118     * @throws InvalidArgumentException When recipient email is malformed.
119     */
120    public function sendQueueItem(MailQueueItem $item, MailSmtp $smtp): bool
121    {
122        if (filter_var($item->recipientEmail, FILTER_VALIDATE_EMAIL) === false) {
123            throw new InvalidArgumentException(
124                sprintf('Invalid recipient email address format: "%s".', $item->recipientEmail)
125            );
126        }
127
128        $transport = $this->resolveTransport($smtp);
129        $email = $this->buildEmailMessage($item, $smtp);
130
131        $transport->send($email);
132        return true;
133    }
134
135    private function resolveTransport(MailSmtp $smtp): TransportInterface
136    {
137        if ($this->transport !== null) {
138            return $this->transport;
139        }
140
141        $tls = in_array(strtolower($smtp->security), ['tls', 'ssl', 'starttls'], true);
142        $transport = new EsmtpTransport(
143            host: $smtp->host,
144            port: $smtp->port,
145            tls: $tls
146        );
147
148        if ($smtp->username !== '') {
149            $transport->setUsername($smtp->username);
150            try {
151                $plainPass = $this->encryption->decrypt($smtp->password);
152            } catch (EncryptionException) {
153                $plainPass = '';
154            }
155            $transport->setPassword($plainPass);
156        }
157
158        return $transport;
159    }
160
161    private function buildEmailMessage(MailQueueItem $item, MailSmtp $smtp): Email
162    {
163        $email = (new Email())
164            ->from(new Address($smtp->fromEmail, $smtp->fromName))
165            ->to(new Address($item->recipientEmail, $item->recipientName ?? ''))
166            ->subject($item->subject)
167            ->html($item->bodyHtml);
168
169        if ($item->bodyText !== null && $item->bodyText !== '') {
170            $email->text($item->bodyText);
171        }
172
173        if ($smtp->replyToEmail !== null && $smtp->replyToEmail !== '') {
174            $email->replyTo(new Address($smtp->replyToEmail));
175        }
176
177        $this->attachEmailFiles($email, $item->attachments);
178
179        return $email;
180    }
181
182    /**
183     * @param list<string> $attachments
184     */
185    private function attachEmailFiles(Email $email, array $attachments): void
186    {
187        $hasCalendar = false;
188        foreach ($attachments as $attPath) {
189            if (!file_exists($attPath)) {
190                continue;
191            }
192            $isIcs = str_ends_with(strtolower($attPath), '.ics');
193            $method = str_contains(strtolower($attPath), 'reply') ? 'REPLY' : 'REQUEST';
194            $mimeType = $isIcs ? sprintf('text/calendar; charset=UTF-8; method=%s', $method) : null;
195            if ($isIcs) {
196                $hasCalendar = true;
197            }
198            $email->attachFromPath($attPath, basename($attPath), $mimeType);
199        }
200
201        if ($hasCalendar) {
202            $email->getHeaders()->addTextHeader('Content-Class', 'urn:content-classes:calendarmessage');
203        }
204    }
205
206    /**
207     * Tests live connectivity and credentials for an SMTP configuration.
208     *
209     * @param MailSmtp $smtp           SMTP configuration instance.
210     * @param string   $testRecipient  Target test recipient address.
211     * @return array{success: bool, message: string} Result descriptor.
212     */
213    public function testConnection(MailSmtp $smtp, string $testRecipient): array
214    {
215        try {
216            $item = new MailQueueItem(
217                id: 0,
218                templateId: null,
219                smtpId: $smtp->id,
220                recipientEmail: $testRecipient,
221                recipientName: 'Test Recipient',
222                subject: 'SMTP Connection Test - ' . $smtp->name,
223                bodyHtml: '<p>This is a verification test message sent from Ammonly App Admin.</p>',
224                bodyText: 'This is a verification test message sent from Ammonly App Admin.'
225            );
226
227            $this->sendQueueItem($item, $smtp);
228            return [
229                'success' => true,
230                'message' => "Successfully connected and transmitted test message to {$testRecipient}.",
231            ];
232        } catch (Throwable $e) {
233            return [
234                'success' => false,
235                'message' => 'SMTP Connection failed: ' . $e->getMessage(),
236            ];
237        }
238    }
239
240    /** {@inheritdoc} */
241    public function testMailServerConnection(MailServer $server): array
242    {
243        $smtpOk = @fsockopen($server->smtpHost, $server->smtpPort, $errno, $errstr, 4);
244        if ($smtpOk === false) {
245            return [
246                'success' => false,
247                'message' => sprintf(
248                    'Cannot connect to SMTP %s:%d (error: %s).',
249                    $server->smtpHost,
250                    $server->smtpPort,
251                    $errstr
252                ),
253            ];
254        }
255        fclose($smtpOk);
256
257        $imapOk = @fsockopen($server->imapHost, $server->imapPort, $errno, $errstr, 4);
258        if ($imapOk === false) {
259            return [
260                'success' => false,
261                'message' => sprintf(
262                    'Cannot connect to IMAP %s:%d (error: %s).',
263                    $server->imapHost,
264                    $server->imapPort,
265                    $errstr
266                ),
267            ];
268        }
269        fclose($imapOk);
270
271        return [
272            'success' => true,
273            'message' => sprintf('Successfully verified connectivity to %s (IMAP & SMTP).', $server->name),
274        ];
275    }
276
277    /** {@inheritdoc} */
278    public function testMailboxConnection(ClientMailbox $mailbox, MailServer $server): array
279    {
280        return $this->testMailServerConnection($server);
281    }
282}