Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.31% covered (warning)
79.31%
46 / 58
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
SesProvider
79.31% covered (warning)
79.31%
46 / 58
60.00% covered (warning)
60.00%
3 / 5
15.74
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
 send
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
4.00
 parseRecipients
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 extractEmailAddress
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getSesClient
37.50% covered (danger)
37.50%
6 / 16
0.00% covered (danger)
0.00%
0 / 1
7.91
1<?php
2
3/**
4 * Amazon SES mail provider.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-13
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Mail\Provider;
21
22use Aws\Ses\SesClient;
23use phpMyFAQ\Configuration;
24use phpMyFAQ\Core\Exception;
25use phpMyFAQ\Mail\MailProviderInterface;
26use Throwable;
27
28final class SesProvider implements MailProviderInterface
29{
30    private ?SesClient $sesClient;
31
32    public function __construct(
33        private readonly Configuration $configuration,
34        ?SesClient $sesClient = null,
35    ) {
36        $this->sesClient = $sesClient;
37    }
38
39    /**
40     * @param array<string, int|string|null> $headers
41     * @throws Exception
42     */
43    public function send(string $recipients, array $headers, string $body): int
44    {
45        $source = $this->extractEmailAddress((string) ($headers['From'] ?? ''));
46        if ($source === '') {
47            throw new Exception('Missing valid From header for SES provider.');
48        }
49
50        $toAddresses = $this->parseRecipients($recipients);
51        if ($toAddresses === []) {
52            throw new Exception('No valid recipients for SES provider.');
53        }
54
55        try {
56            $this->getSesClient()->sendEmail([
57                'Source' => $source,
58                'Destination' => [
59                    'ToAddresses' => $toAddresses,
60                ],
61                'Message' => [
62                    'Subject' => [
63                        'Data' => $headers['Subject'] ?? '',
64                        'Charset' => 'UTF-8',
65                    ],
66                    'Body' => [
67                        'Text' => [
68                            'Data' => $body,
69                            'Charset' => 'UTF-8',
70                        ],
71                        'Html' => [
72                            'Data' => $body,
73                            'Charset' => 'UTF-8',
74                        ],
75                    ],
76                ],
77            ]);
78        } catch (Throwable $throwable) {
79            throw new Exception('SES mail delivery failed: ' . $throwable->getMessage());
80        }
81
82        return count($toAddresses);
83    }
84
85    /**
86     * @return array<int, string>
87     */
88    private function parseRecipients(string $recipients): array
89    {
90        $addresses = [];
91        foreach (explode(',', $recipients) as $recipient) {
92            $address = $this->extractEmailAddress($recipient);
93            if ($address !== '') {
94                $addresses[] = $address;
95            }
96        }
97
98        return array_values(array_unique($addresses));
99    }
100
101    private function extractEmailAddress(string $rawAddress): string
102    {
103        $matches = [];
104        if (preg_match('/<([^>]+)>/', $rawAddress, $matches) === 1) {
105            return trim($matches[1]);
106        }
107
108        return trim($rawAddress);
109    }
110
111    /**
112     * @throws Exception
113     */
114    private function getSesClient(): SesClient
115    {
116        if ($this->sesClient instanceof SesClient) {
117            return $this->sesClient;
118        }
119
120        $accessKey = (string) ($this->configuration->get('mail.sesAccessKeyId') ?? '');
121        $secretKey = (string) ($this->configuration->get('mail.sesSecretAccessKey') ?? '');
122        $region = (string) ($this->configuration->get('mail.sesRegion') ?? 'us-east-1');
123
124        if ($accessKey === '' || $secretKey === '') {
125            throw new Exception('SES credentials are not configured.');
126        }
127
128        $this->sesClient = new SesClient([
129            'version' => 'latest',
130            'region' => $region,
131            'credentials' => [
132                'key' => $accessKey,
133                'secret' => $secretKey,
134            ],
135        ]);
136
137        return $this->sesClient;
138    }
139}