Send email at scale for free with Cloudflare workers
written by daniel in
devops
on 30 Nov 2022
I was looking for a cheap/free way to send email for a hobby project. At first I turned to Amazon SES, but after I tried to enable production access and received an extremely generic rejection, I got discouraged and put the project on hold. Recently I discovered an interesting way to send email for free using Cloudflare workers and MailChannels API.
The only requirement to use this is to have a domain name verified in Cloudflare. MailChannels API is able to authorize requests coming from workers based on Cloudflare verification. No accounts other than Cloudflare are needed.
The worker
The worker receives a JSON payload representing a single email and forwards it to the MailChannels API.
Deploy it and configure TOKEN
and optionally DKIM_PRIVATE_KEY
(see below) in worker settings.
export default {
async fetch(request, env) {
if (request.method !== 'POST') {
return new Response('Method not supported', { status: 405 })
}
const token = request.headers.get('Authorization')?.replace('Bearer ', '')
if (token !== env.TOKEN) {
return new Response('Unauthorized', { status: 403 })
}
const body = await request.json()
const email_body = {
personalizations: [{
to: body.to,
// dkim_domain: 'example.com',
// dkim_selector: 'mail1',
// dkim_private_key: env.DKIM_PRIVATE_KEY,
}],
from: body.from,
subject: body.subject,
content: body.content,
// headers: {
// 'List-Unsubscribe': '<mailto:daniel@example.com?subject=unsubscribe>',
// },
}
const email_request = new Request('https://api.mailchannels.net/tx/v1/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(email_body),
})
const res = await fetch(email_request)
return new Response(`${res.status} ${res.statusText}`, { status: res.status })
},
}
To test it, deploy the worker and call it in production.
Requests sent through Cloudflare’s Quick edit interface would not work with MailChannels when I wrote this article.
MailChannels API will return 403
if the worker is called through Quick edit.
TOKEN=<token from worker env>
WORKER_URL=<URL of deployed worker>
curl -H 'application/json' -H "Authorization: Bearer $TOKEN" -d "{\"to\":[{\"email\":\"test@example.com\"}],\"from\":[{\"email\":\"sender@example.com\"}],\"subject\":\"Test subject\",\"content\":[{\"type\":\"text/plain\",\"value\":\"Test email\\r\\n\"}]}" $WORKER_URL
Deliverability tuning
It’s a good idea to configure SPF, DKIM, DMARC and an MX record to prevent emails getting caught in spam filters. SPF, DMARC and receiving emails (I used Cloudflare Email Routing) are configured through DNS records.
DKIM
Generate a DKIM private key and DNS record:
openssl genrsa 2048 | tee private_key.pem | openssl rsa -outform der | openssl base64 -A > private_key.txt
echo -n "v=DKIM1;p=" > dkim_record.txt && openssl rsa -in private_key.pem -pubout -outform der | openssl base64 -A >> dkim_record.txt
Save private key in DKIM_PRIVATE_KEY
worker environment variable.
Configure DKIM record with the right selector in Cloudflare DNS and uncomment DKIM config in the worker:
dkim_domain: 'example.com',
dkim_selector: 'mail1',
dkim_private_key: env.DKIM_PRIVATE_KEY,
List-Unsubscribe header
The List-Unsubscribe header should be present in mass emails (emails that are not transactional). Email clients that support it will show the user an unsubscribe button. Configure it in email headers like this:
headers: {
'List-Unsubscribe': '<mailto:daniel@example.com?subject=unsubscribe>',
},
Mail tester is useful for testing deliverability. You can test ~5 emails per day for free.
Sending emails from Rails
To send emails from Rails using ActionMailer, add a delivery method that calls the worker:
lib/mailchannels_integration/worker_delivery_method.rb
:
module MailchannelsIntegration
class WorkerDeliveryMethod
WORKER_EP = '<worker URL>'.freeze
attr_accessor :settings
class WorkerDeliveryError < StandardError; end
def initialize(settings)
self.settings = settings
end
def deliver!(mail)
Rails.logger.info('[MailChannels] Attempting to send email')
res = Typhoeus.post(WORKER_EP, { headers: headers, body: body(mail) })
raise WorkerDeliveryError, res.body unless res.success?
Rails.logger.info('[MailChannels] Email sent')
end
def headers
token = Rails.application.credentials.dig(:mailchannels_worker, :token)
{
Authorization: "Bearer #{token}",
'Content-Type': 'application/json',
}
end
def body(mail)
{
to: mail.to.map { |email| { email: email} },
from: { email: mail.from.first },
subject: mail.subject,
content: [{ type: 'text/plain', value: mail.body.raw_source }],
}.to_json
end
end
end
credentials.yml
:
mailchannels_worker:
token: <token from worker env>
config/application.rb
:
require "mailchannels_integration/worker_delivery_method"
ActionMailer::Base.add_delivery_method :mailchannels_worker, MailchannelsIntegration::WorkerDeliveryMethod
config/environments/(development|production).rb
config.action_mailer.raise_delivery_errors = true
config.action_mailer.delivery_method = :mailchannels_worker
With this configuration, you can send emails as usual using mail()
.
Scaling
As far as I know, MailChannels imposes no limits on how many emails you can send this way. Cloudflare workers are free up to 100k requests per day and limited to 10ms of execution time per invocation. You could batch emails before calling the worker to squeeze out a few extra emails before having to upgrade.
Useful links
- MailChannels Cloudflare integration docs
- MailChannels API docs
- MailChannels Cloudflare Pages plugin for simple contact forms for static pages