[13.x] Add support for Cloudflare Email Service#59735
[13.x] Add support for Cloudflare Email Service#59735dwightwatson wants to merge 8 commits intolaravel:13.xfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Symfony Mailer transport to send email via Cloudflare Email Service and wires it into Laravel’s MailManager, with accompanying PHPUnit coverage.
Changes:
- Introduces
CloudflareTransportthat posts email payloads to Cloudflare’s Email Service API using Symfony HTTP Client. - Registers a new
cloudflaretransport inMailManager. - Adds tests validating payload formatting (including named
from/reply_to), headers, attachments, and API failures.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/Illuminate/Mail/Transport/CloudflareTransport.php |
New transport implementation using Symfony HTTP Client and Cloudflare-specific payload formatting. |
src/Illuminate/Mail/MailManager.php |
Adds createCloudflareTransport factory method and imports the new transport. |
tests/Mail/MailCloudflareTransportTest.php |
New tests covering transport creation and request payload/headers behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $result = $response->toArray(false); | ||
| } catch (Exception $exception) { | ||
| throw new TransportException( | ||
| sprintf('Request to Cloudflare API failed. Reason: %s.', $exception->getMessage()), | ||
| is_int($exception->getCode()) ? $exception->getCode() : 0, | ||
| $exception, | ||
| ); | ||
| } | ||
|
|
||
| throw_if( | ||
| $response->getStatusCode() !== Response::HTTP_OK, | ||
| Exception::class, | ||
| $result['errors'][0]['message'] ?? 'Unknown error', | ||
| ); |
There was a problem hiding this comment.
throw_if(...) is executed outside the try/catch, so non-200 responses will throw a raw Exception rather than being wrapped as a TransportException (unlike other transports, e.g. Resend). Move the status/error handling inside the try/catch (or catch after) and rethrow as TransportException including the API error message and HTTP status code.
There was a problem hiding this comment.
Not great when debugging tbh, for the same reason as:
#59252 (comment)
There was a problem hiding this comment.
I think I did this to be consistent with the Resend transport. Are you suggesting that I just do a a regular throw exception rather than throw_if?
| $this->app['config']->get('services.cloudflare.account_id'), | ||
| $this->app['config']->get('services.cloudflare.token'), |
There was a problem hiding this comment.
This transport always reads credentials from services.cloudflare.* and ignores any credentials provided in the mailer config array. For consistency with other transports (e.g. Postmark/Mailgun/Resend), consider using values from $config when present (e.g. $config['account_id'], $config['token']) and falling back to services.cloudflare.* only when not set.
| $this->app['config']->get('services.cloudflare.account_id'), | |
| $this->app['config']->get('services.cloudflare.token'), | |
| $config['account_id'] ?? $this->app['config']->get('services.cloudflare.account_id'), | |
| $config['token'] ?? $this->app['config']->get('services.cloudflare.token'), |
| $this->expectExceptionMessage('invalid_request_schema'); | ||
|
|
||
| $transport->send($message); | ||
| } |
There was a problem hiding this comment.
The test currently expects a generic Exception on API failure, but transports typically surface failures as Symfony\Component\Mailer\Exception\TransportException. Once the transport wraps non-2xx responses in TransportException, update this assertion to expect that type (and it will also make the existing TransportException import meaningful).
| use Symfony\Component\HttpClient\Response\MockResponse; | ||
| use Symfony\Component\Mime\Address; | ||
| use Symfony\Component\Mime\Email; |
There was a problem hiding this comment.
Symfony\Component\Mailer\Exception\TransportException is imported but never used in this test file (and PHPUnit will flag unused imports in some setups). Either remove the import or update the failing-API test to assert TransportException once the transport consistently wraps failures.
| ) { | ||
| parent::__construct(); | ||
|
|
||
| $this->client = $client ?? HttpClient::create(); |
There was a problem hiding this comment.
question: any reason not to use Laravel's Illuminate\Http\Client\Factory instead? This will improve observability for things like Telescope (possibly Nightwatch, not sure)?
There was a problem hiding this comment.
For consistency with the other transports - sharing the same client configuration that the MailManager handles. I touched on this in the PR description. Open to tweaking this but wanted to get a sense of whether this transport would even be considered first.
There was a problem hiding this comment.
Shame on me for jumping right into the code. 😆 Cool, thanks!
| $result['errors'][0]['message'] ?? 'Unknown error', | ||
| $response->getStatusCode(), | ||
| ); | ||
| } |
There was a problem hiding this comment.
Before leaving, any chance to get a MessageID and add it to the original message header?
There was a problem hiding this comment.
Cloudflare doesn't seem to return a MessageID in the response. However there is one shown in the dashboard so perhaps it will be added to the response at some point.
This adds a new transport for the Cloudflare Email Service which was entered public beta today. I thought it was worth considering given the prominence of Cloudflare itself, that it's a cost effective option, and also that it's a provider for Laravel Cloud.
I fully appreciate that you may want to wait until this is out of beta, or to see if it pops up as a Symfony transport that we can leverage instead. Happy to look at PRing this to Symfony instead to see if they'd be happy to include it.
Similar to other transports it requires Symfony's HTTP Client so it can share the same implementation and setup in the
MailManager. This could easily be replaced with Laravel's HTTP interface if preferred but thought it was better to stick with convention.Then you can configure the transport and credentials in
config/services.php.This implementation was inspired by the existing
ResendTransport, as well as reviewing how some of the Symfony mailers Laravel uses under the hood (like Postmark) work.There are some quirks in the email formatting required by the API - it doesn't support the named syntax (
Taylor Otwell <taylor@example.com>). However it does support it as an object with name/address forfromandreply_to, though not for any of the recipients.