Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.24% covered (success)
94.24%
131 / 139
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SystemMailSenderService
94.20% covered (success)
94.20%
130 / 138
75.00% covered (warning)
75.00%
6 / 8
28.15
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
 sendTemplateEmail
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 sendRawEmail
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 resolveTemplate
77.42% covered (warning)
77.42%
24 / 31
0.00% covered (danger)
0.00%
0 / 1
7.56
 replaceVariables
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 loadSystemMailboxConfig
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
5
 createSmtpTransport
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 getHardcodedFallbackTemplate
100.00% covered (success)
100.00%
18 / 18
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;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Security\Encryption\AesGcmEncryptionService;
12use App\Core\Security\Encryption\EncryptionServiceInterface;
13use InvalidArgumentException;
14use PDO;
15use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
16use Symfony\Component\Mailer\Transport\TransportInterface;
17use Symfony\Component\Mime\Address;
18use Symfony\Component\Mime\Email;
19use Throwable;
20
21/**
22 * Transactional System Email Sender with Multi-Language Template Resolution.
23 *
24 * Transmits mission-critical notifications (password reset, MFA enrollment, security alerts)
25 * exclusively through system@ammonly.com with language-aware template selection.
26 *
27 * @package App\Modules\Mail\Application\Service
28 */
29final readonly class SystemMailSenderService implements SystemMailSenderServiceInterface
30{
31    /** @var string System mailbox email address. */
32    private const SYSTEM_EMAIL = 'system@ammonly.com';
33
34    /** @var string Default sender display name. */
35    private const DEFAULT_FROM_NAME = 'Ammonly System';
36
37    /** @var EncryptionServiceInterface Encryption service for credential decryption. */
38    private EncryptionServiceInterface $encryption;
39
40    /**
41     * SystemMailSenderService constructor.
42     *
43     * @param PDO $pdo Database PDO connection.
44     * @param EncryptionServiceInterface|null $encryption Encryption service.
45     * @param TransportInterface|null $transport Optional custom transport (e.g. for testing).
46     */
47    public function __construct(
48        private PDO $pdo,
49        ?EncryptionServiceInterface $encryption = null,
50        private ?TransportInterface $transport = null
51    ) {
52        $this->encryption = $encryption ?? new AesGcmEncryptionService();
53    }
54
55    /** {@inheritdoc} */
56    public function sendTemplateEmail(
57        string $recipientEmail,
58        string $recipientName,
59        string $templateCode,
60        array $variables,
61        string $locale = 'pl'
62    ): bool {
63        $cleanLocale = strtolower(trim($locale)) === 'en' ? 'en' : 'pl';
64        $template = $this->resolveTemplate($templateCode, $cleanLocale);
65
66        $subject = $this->replaceVariables($template['subject'], $variables);
67        $bodyHtml = $this->replaceVariables($template['body_html'], $variables);
68        $bodyText = isset($template['body_text']) && $template['body_text'] !== ''
69            ? $this->replaceVariables((string) $template['body_text'], $variables)
70            : strip_tags($bodyHtml);
71
72        return $this->sendRawEmail($recipientEmail, $recipientName, $subject, $bodyHtml, $bodyText);
73    }
74
75    /** {@inheritdoc} */
76    public function sendRawEmail(
77        string $recipientEmail,
78        string $recipientName,
79        string $subject,
80        string $htmlBody,
81        ?string $textBody = null
82    ): bool {
83        if (filter_var($recipientEmail, FILTER_VALIDATE_EMAIL) === false) {
84            throw new InvalidArgumentException(sprintf('Invalid recipient email address: "%s".', $recipientEmail));
85        }
86
87        EmailHeaderSanitizer::assertSafeHeader($recipientEmail, 'recipient_email');
88        EmailHeaderSanitizer::assertSafeHeader($recipientName, 'recipient_name');
89
90        $cleanSubject = EmailHeaderSanitizer::sanitizeHeader($subject);
91        $config = $this->loadSystemMailboxConfig();
92
93        $transport = $this->transport ?? $this->createSmtpTransport($config);
94        $fromEmail = (string) ($config['from_email'] ?? self::SYSTEM_EMAIL);
95        $fromName = (string) ($config['from_name'] ?? self::DEFAULT_FROM_NAME);
96
97        $email = (new Email())
98            ->from(new Address($fromEmail, $fromName))
99            ->to(new Address($recipientEmail, $recipientName))
100            ->subject($cleanSubject)
101            ->html($htmlBody);
102
103        if ($textBody !== null && $textBody !== '') {
104            $email->text($textBody);
105        }
106
107        try {
108            $transport->send($email);
109            return true;
110        } catch (Throwable) {
111            return false;
112        }
113    }
114
115    /**
116     * Resolves matching template from database by code and language with fallback.
117     *
118     * @param string $code Template code.
119     * @param string $locale Preferred language code (pl, en).
120     * @return array{subject: string, body_html: string, body_text: ?string} Template data.
121     */
122    private function resolveTemplate(string $code, string $locale): array
123    {
124        $candidateTables = ['a_mod_mail_template_records', 'a_mod_client_mail_templates_records'];
125
126        foreach ($candidateTables as $table) {
127            try {
128                $stmt = $this->pdo->prepare(
129                    "SELECT subject, body_html, body_text FROM `{$table}" .
130                    "WHERE `code` = :code AND `language_code` = :locale " .
131                    "AND `status` = 'active' AND `special_access` = 1 LIMIT 1"
132                );
133                $stmt->execute([':code' => $code, ':locale' => $locale]);
134                $row = $stmt->fetch(PDO::FETCH_ASSOC);
135                if (is_array($row)) {
136                    return [
137                        'subject'   => (string) $row['subject'],
138                        'body_html' => (string) $row['body_html'],
139                        'body_text' => isset($row['body_text']) ? (string) $row['body_text'] : null,
140                    ];
141                }
142
143                // Fallback to any language for this code
144                $fbStmt = $this->pdo->prepare(
145                    "SELECT subject, body_html, body_text FROM `{$table}" .
146                    "WHERE `code` = :code AND `status` = 'active' AND `special_access` = 1 " .
147                    "ORDER BY (language_code = 'en') DESC LIMIT 1"
148                );
149                $fbStmt->execute([':code' => $code]);
150                $fbRow = $fbStmt->fetch(PDO::FETCH_ASSOC);
151                if (is_array($fbRow)) {
152                    return [
153                        'subject'   => (string) $fbRow['subject'],
154                        'body_html' => (string) $fbRow['body_html'],
155                        'body_text' => isset($fbRow['body_text']) ? (string) $fbRow['body_text'] : null,
156                    ];
157                }
158            } catch (Throwable) {
159                // Table might not exist in this database profile
160                continue;
161            }
162        }
163
164        return $this->getHardcodedFallbackTemplate($code);
165    }
166
167    /**
168     * Replaces {{ variable }} placeholders in template string.
169     *
170     * @param string $template Raw template text.
171     * @param array<string, scalar> $variables Key-value pairs.
172     * @return string Substituted text.
173     */
174    private function replaceVariables(string $template, array $variables): string
175    {
176        $patterns = [];
177        $replacements = [];
178
179        foreach ($variables as $key => $value) {
180            $patterns[] = '/\{\{\s*' . preg_quote($key, '/') . '\s*\}\}/';
181            $replacements[] = (string) $value;
182        }
183
184        return preg_replace($patterns, $replacements, $template) ?? $template;
185    }
186
187    /**
188     * Loads mailbox credentials and connection parameters for system@ammonly.com.
189     *
190     * @return array<string, mixed> Mailbox configuration parameters.
191     */
192    private function loadSystemMailboxConfig(): array
193    {
194        $clientCols = '`id`, `email`, `from_name`, `username`, `password`, `smtp_host`, `smtp_port`, '
195            . '`smtp_encryption`, `smtp_username`, `smtp_password`, `smtp_from_email`, `smtp_from_name`';
196
197        $queries = [
198            [
199                'tbl'   => 'a_mod_client_mailboxes_records',
200                'cols'  => $clientCols,
201                'where' => '`email` = :email AND `status` = "active"',
202            ],
203            [
204                'tbl'   => 'c_mod_client_mailboxes_records',
205                'cols'  => $clientCols,
206                'where' => '`email` = :email AND `status` = "active"',
207            ],
208        ];
209
210        foreach ($queries as $q) {
211            try {
212                $sql = "SELECT {$q['cols']} FROM `{$q['tbl']}` WHERE {$q['where']} LIMIT 1";
213                $stmt = $this->pdo->prepare($sql);
214                $stmt->execute([':email' => self::SYSTEM_EMAIL]);
215                $row = $stmt->fetch(PDO::FETCH_ASSOC);
216
217                if (is_array($row)) {
218                    $rawEncPwd = (string) ($row['smtp_password'] ?? $row['password'] ?? '');
219                    $decryptedPwd = $rawEncPwd !== '' ? $this->encryption->decrypt($rawEncPwd) : '';
220
221                    return [
222                        'host'       => (string) ($row['smtp_host'] ?? 'mail.ammonly.com'),
223                        'port'       => (int) ($row['smtp_port'] ?? 465),
224                        'encryption' => (string) ($row['smtp_encryption'] ?? 'ssl'),
225                        'username'   => (string) ($row['smtp_username'] ?? self::SYSTEM_EMAIL),
226                        'password'   => $decryptedPwd,
227                        'from_email' => (string) ($row['smtp_from_email'] ?? $row['email'] ?? self::SYSTEM_EMAIL),
228                        'from_name'  => (string) (
229                            $row['smtp_from_name'] ?? $row['from_name'] ?? self::DEFAULT_FROM_NAME
230                        ),
231                    ];
232                }
233            } catch (Throwable) {
234                continue;
235            }
236        }
237
238        return [
239            'host'       => 'mail.ammonly.com',
240            'port'       => 465,
241            'encryption' => 'ssl',
242            'username'   => self::SYSTEM_EMAIL,
243            'password'   => 'AmmonlySystemPass2026Secure!',
244            'from_email' => self::SYSTEM_EMAIL,
245            'from_name'  => self::DEFAULT_FROM_NAME,
246        ];
247    }
248
249    /**
250     * Creates configured EsmtpTransport instance.
251     *
252     * @param array<string, mixed> $config Mailbox connection configuration.
253     * @return TransportInterface Configured transport.
254     */
255    private function createSmtpTransport(array $config): TransportInterface
256    {
257        $host = (string) $config['host'];
258        $port = (int) $config['port'];
259        $isTls = strtolower((string) $config['encryption']) === 'ssl';
260
261        $transport = new EsmtpTransport($host, $port, $isTls);
262        $username = (string) $config['username'];
263        $password = (string) $config['password'];
264
265        if ($username !== '') {
266            $transport->setUsername($username);
267            $transport->setPassword($password);
268        }
269
270        return $transport;
271    }
272
273    /**
274     * Returns built-in fallback template if not present in database.
275     *
276     * @param string $code Template code.
277     * @return array{subject: string, body_html: string, body_text: ?string} Fallback template.
278     */
279    private function getHardcodedFallbackTemplate(string $code): array
280    {
281        if ($code === 'PASSWORD_RESET') {
282            return [
283                'subject'   => 'Password Reset Request for {{ app_name }}',
284                'body_html' => '<p>Hello {{ user_name }},</p>' .
285                    '<p>We received a request to reset your password for {{ app_name }}.</p>' .
286                    '<p><a href="{{ reset_url }}">Click here to reset your password</a></p>' .
287                    '<p>This link will expire in {{ token_lifetime_minutes }} minutes ' .
288                    '(valid until {{ expires_at }} UTC).</p>' .
289                    '<p>If you did not request this, please ignore this email.</p>',
290                'body_text' => "Hello {{ user_name }},\n\n" .
291                    "Password reset link for {{ app_name }}:\n{{ reset_url }}\n\n" .
292                    "This link expires in {{ token_lifetime_minutes }} minutes.",
293            ];
294        }
295
296        return [
297            'subject'   => 'Security Alert: {{ alert_title }}',
298            'body_html' => '<p>Hello {{ user_name }},</p><p>{{ alert_details }}</p>',
299            'body_text' => "Hello {{ user_name }},\n\n{{ alert_details }}",
300        ];
301    }
302}