Lines 79.31% 46 / 58
Methods 60.00% 3 / 5
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 send 93.54% 29 / 31 0.00% 0 / 1 4.00
 parseRecipients 100.00% 6 / 6 100.00% 1 / 1 3
 extractEmailAddress 100.00% 4 / 4 100.00% 1 / 1 2
 getSesClient 37.50% 6 / 16 0.00% 0 / 1 7.91
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}