Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
SendGridProvider
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
4 / 4
12
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 send
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
6
 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
1<?php
2
3/**
4 * SendGrid 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 phpMyFAQ\Configuration;
23use phpMyFAQ\Core\Exception;
24use phpMyFAQ\Mail\MailProviderInterface;
25use Symfony\Component\HttpClient\HttpClient;
26use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
27use Symfony\Contracts\HttpClient\HttpClientInterface;
28
29final readonly class SendGridProvider implements MailProviderInterface
30{
31    private HttpClientInterface $httpClient;
32
33    public function __construct(
34        private Configuration $configuration,
35        ?HttpClientInterface $httpClient = null,
36    ) {
37        $this->httpClient = $httpClient ?? HttpClient::create();
38    }
39
40    /**
41     * @param array<string, int|string|null> $headers
42     * @throws Exception
43     * @throws TransportExceptionInterface
44     */
45    public function send(string $recipients, array $headers, string $body): int
46    {
47        $apiKey = (string) ($this->configuration->get('mail.sendgridApiKey') ?? '');
48        if ($apiKey === '') {
49            throw new Exception('SendGrid API key is not configured.');
50        }
51
52        $fromAddress = $this->extractEmailAddress((string) ($headers['From'] ?? ''));
53        if ($fromAddress === '') {
54            throw new Exception('Missing valid From header for SendGrid provider.');
55        }
56
57        $toAddresses = $this->parseRecipients($recipients);
58        if ($toAddresses === []) {
59            throw new Exception('No valid recipients for SendGrid provider.');
60        }
61
62        $payload = [
63            'personalizations' => [
64                [
65                    'to' => array_map(static fn(string $address): array => ['email' => $address], $toAddresses),
66                    'subject' => $headers['Subject'] ?? '',
67                ],
68            ],
69            'from' => [
70                'email' => $fromAddress,
71            ],
72            'content' => [
73                ['type' => 'text/plain', 'value' => $body],
74                ['type' => 'text/html', 'value' => $body],
75            ],
76        ];
77
78        $response = $this->httpClient->request('POST', 'https://api.sendgrid.com/v3/mail/send', [
79            'headers' => [
80                'Authorization' => 'Bearer ' . $apiKey,
81                'Content-Type' => 'application/json',
82            ],
83            'json' => $payload,
84        ]);
85
86        $statusCode = $response->getStatusCode();
87        if ($statusCode < 200 || $statusCode >= 300) {
88            throw new Exception(sprintf(
89                'SendGrid request failed with status %d: %s',
90                $statusCode,
91                $response->getContent(false),
92            ));
93        }
94
95        return count($toAddresses);
96    }
97
98    /**
99     * @return array<int, string>
100     */
101    private function parseRecipients(string $recipients): array
102    {
103        $addresses = [];
104        foreach (explode(',', $recipients) as $recipient) {
105            $address = $this->extractEmailAddress($recipient);
106            if ($address !== '') {
107                $addresses[] = $address;
108            }
109        }
110
111        return array_values(array_unique($addresses));
112    }
113
114    private function extractEmailAddress(string $rawAddress): string
115    {
116        $matches = [];
117        if (preg_match('/<([^>]+)>/', $rawAddress, $matches) === 1) {
118            return trim($matches[1]);
119        }
120
121        return trim($rawAddress);
122    }
123}