Skip to content

[13.x] Add support for Cloudflare Email Service#59735

Open
dwightwatson wants to merge 8 commits intolaravel:13.xfrom
dwightwatson:cloudflare-transport
Open

[13.x] Add support for Cloudflare Email Service#59735
dwightwatson wants to merge 8 commits intolaravel:13.xfrom
dwightwatson:cloudflare-transport

Conversation

@dwightwatson
Copy link
Copy Markdown
Contributor

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.

composer require symfony/http-client

Then you can configure the transport and credentials in config/services.php.

'cloudflare' => [
    'account_id' => env('CLOUDFLARE_ACCOUNT_ID'),
    'token' => env('CLOUDFLARE_TOKEN'),
],

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 for from and reply_to, though not for any of the recipients.

Copilot AI review requested due to automatic review settings April 17, 2026 04:22
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CloudflareTransport that posts email payloads to Cloudflare’s Email Service API using Symfony HTTP Client.
  • Registers a new cloudflare transport in MailManager.
  • 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.

Comment on lines +61 to +74
$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',
);
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not great when debugging tbh, for the same reason as:
#59252 (comment)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread src/Illuminate/Mail/MailManager.php Outdated
Comment on lines +195 to +196
$this->app['config']->get('services.cloudflare.account_id'),
$this->app['config']->get('services.cloudflare.token'),
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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'),

Copilot uses AI. Check for mistakes.
Comment on lines +196 to +199
$this->expectExceptionMessage('invalid_request_schema');

$transport->send($message);
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +14
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@dwightwatson dwightwatson changed the title Add support for Cloudflare Email Service [13.x] Add support for Cloudflare Email Service Apr 17, 2026
) {
parent::__construct();

$this->client = $client ?? HttpClient::create();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shame on me for jumping right into the code. 😆 Cool, thanks!

$result['errors'][0]['message'] ?? 'Unknown error',
$response->getStatusCode(),
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before leaving, any chance to get a MessageID and add it to the original message header?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants