Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
MailgunProvider
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
4 / 4
14
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%
32 / 32
100.00% covered (success)
100.00%
1 / 1
8
 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 * Mailgun 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-14
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 MailgunProvider 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.mailgunApiKey') ?? '');
48        if ($apiKey === '') {
49            throw new Exception('Mailgun API key is not configured.');
50        }
51
52        $domain = (string) ($this->configuration->get('mail.mailgunDomain') ?? '');
53        if ($domain === '') {
54            throw new Exception('Mailgun domain is not configured.');
55        }
56
57        $fromAddress = $this->extractEmailAddress((string) ($headers['From'] ?? ''));
58        if ($fromAddress === '') {
59            throw new Exception('Missing valid From header for Mailgun provider.');
60        }
61
62        $toAddresses = $this->parseRecipients($recipients);
63        if ($toAddresses === []) {
64            throw new Exception('No valid recipients for Mailgun provider.');
65        }
66
67        $region = strtolower((string) ($this->configuration->get('mail.mailgunRegion') ?? 'us'));
68        $baseUrl = $region === 'eu' ? 'https://api.eu.mailgun.net' : 'https://api.mailgun.net';
69
70        $response = $this->httpClient->request('POST', $baseUrl . '/v3/' . $domain . '/messages', [
71            'auth_basic' => ['api', $apiKey],
72            'body' => [
73                'from' => $fromAddress,
74                'to' => implode(',', $toAddresses),
75                'subject' => $headers['Subject'] ?? '',
76                'text' => $body,
77                'html' => $body,
78            ],
79        ]);
80
81        $statusCode = $response->getStatusCode();
82        if ($statusCode < 200 || $statusCode >= 300) {
83            throw new Exception(sprintf(
84                'Mailgun request failed with status %d: %s',
85                $statusCode,
86                $response->getContent(false),
87            ));
88        }
89
90        return count($toAddresses);
91    }
92
93    /**
94     * @return array<int, string>
95     */
96    private function parseRecipients(string $recipients): array
97    {
98        $addresses = [];
99        foreach (explode(',', $recipients) as $recipient) {
100            $address = $this->extractEmailAddress($recipient);
101            if ($address !== '') {
102                $addresses[] = $address;
103            }
104        }
105
106        return array_values(array_unique($addresses));
107    }
108
109    private function extractEmailAddress(string $rawAddress): string
110    {
111        $matches = [];
112        if (preg_match('/<([^>]+)>/', $rawAddress, $matches) === 1) {
113            return trim($matches[1]);
114        }
115
116        return trim($rawAddress);
117    }
118}