Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.43% |
125 / 127 |
|
88.89% |
8 / 9 |
CRAP | |
0.00% |
0 / 1 |
| MailSenderService | |
98.41% |
124 / 126 |
|
88.89% |
8 / 9 |
30 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| enqueueFromTemplate | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
5 | |||
| sendQueueItem | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| resolveTransport | |
86.67% |
13 / 15 |
|
0.00% |
0 / 1 |
4.04 | |||
| buildEmailMessage | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| attachEmailFiles | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
7 | |||
| testConnection | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
2 | |||
| testMailServerConnection | |
100.00% |
28 / 28 |
|
100.00% |
1 / 1 |
3 | |||
| testMailboxConnection | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Mail\Application\Service; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Security\Encryption\AesGcmEncryptionService; |
| 12 | use App\Core\Security\Encryption\EncryptionException; |
| 13 | use App\Core\Security\Encryption\EncryptionServiceInterface; |
| 14 | use App\Modules\Mail\Application\Contract\MailSenderServiceInterface; |
| 15 | use App\Modules\Mail\Domain\Model\ClientMailbox; |
| 16 | use App\Modules\Mail\Domain\Model\MailQueueItem; |
| 17 | use App\Modules\Mail\Domain\Model\MailServer; |
| 18 | use App\Modules\Mail\Domain\Model\MailSmtp; |
| 19 | use App\Modules\Mail\Domain\Repository\MailRepositoryInterface; |
| 20 | use InvalidArgumentException; |
| 21 | use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; |
| 22 | use Symfony\Component\Mailer\Transport\TransportInterface; |
| 23 | use Symfony\Component\Mime\Address; |
| 24 | use Symfony\Component\Mime\Email; |
| 25 | use Throwable; |
| 26 | |
| 27 | /** |
| 28 | * Universal Mail Delivery & Queue Dispatching Service. |
| 29 | * |
| 30 | * @package App\Modules\Mail\Application\Service |
| 31 | */ |
| 32 | final 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 | } |