scaleway_tem-actionmailer
Send ActionMailer email through Scaleway Transactional Email (TEM), over its HTTP API rather than its SMTP relay.
Symfony and Laravel have had a TEM mailer for years. Ruby did not. This is it.
# Gemfile
gem "scaleway_tem-actionmailer"
# config/initializers/scaleway_tem.rb
ScalewayTem::ActionMailer.configure do |config|
config.secret_key = Rails.application.credentials.dig(:scaleway, :tem, :secret_key)
config.project_id = Rails.application.credentials.dig(:scaleway, :tem, :project_id)
end
# config/environments/production.rb
config.action_mailer.delivery_method = :scaleway_tem
That is the whole integration. Your mailers do not change.
Requirements: Ruby 3.2 or newer. Rails is optional, see Using it without Rails.
Why the API and not SMTP
Scaleway TEM offers both, and Rails can already speak SMTP with no gem at all. The HTTP API is worth a gem for one reason: it needs no outbound SMTP port. That matters in a container, and it matters on Scaleway Instances in particular, where outbound SMTP is blocked by default until you ask support to unblock it.
If port 587 is open where you deploy, you may not need this gem. Point
ActionMailer at smtp.tem.scaleway.com with your Project ID as the username
and an API secret key as the password, and you are done.
Installation
gem "scaleway_tem-actionmailer"
Configuration
# config/initializers/scaleway_tem.rb
ScalewayTem::ActionMailer.configure do |config|
config.secret_key = Rails.application.credentials.dig(:scaleway, :tem, :secret_key)
config.project_id = Rails.application.credentials.dig(:scaleway, :tem, :project_id)
config.region = "fr-par" # the default
end
# config/environments/production.rb
config.action_mailer.delivery_method = :scaleway_tem
The delivery method registers itself through a Railtie, so there is nothing else to wire up.
| Setting | Default | Notes |
|---|---|---|
secret_key |
none | A Scaleway API secret key with TEM permissions |
project_id |
none | The project UUID the sending domain belongs to |
region |
"fr-par" |
|
timeout / open_timeout |
15 |
Seconds |
api_host |
https://api.scaleway.com |
Override for testing |
Every setting can also be given per delivery through
config.action_mailer.scaleway_tem_settings, which takes precedence.
What gets mapped
| Scaleway | |
|---|---|
from (exactly one) |
from |
to, cc, bcc |
to, cc, bcc |
subject |
subject |
| text and HTML parts | text, html |
| attachments | attachments, base64 encoded |
Reply-To and any other header |
additional_headers |
Scaleway's payload has no reply_to field, so Reply-To is forwarded as an
additional header, which is what Scaleway's own documentation shows. Headers
that already have a payload field of their own are not duplicated.
Two things raise rather than degrade quietly: a message with no From, and a
message with more than one From, because Scaleway accepts exactly one sender
and dropping the extra would send from an address you did not choose.
Attachments are capped by Scaleway at 2MB for the whole message. The gem does not enforce this; the API will tell you.
Using it without Rails
The gem depends on faraday and mail, not on actionmailer. A delivery
method is only a duck type, two methods, and the Railtie is loaded only when
Rails is already present. So any Mail::Message works:
require "scaleway_tem/action_mailer"
require "mail"
ScalewayTem::ActionMailer.configure do |config|
config.secret_key = ENV.fetch("SCW_SECRET_KEY")
config.project_id = ENV.fetch("SCW_DEFAULT_PROJECT_ID")
end
mail = Mail.new do
from "Sender <sender@example.com>"
to "reader@example.com"
subject "Hello"
body "Plain body"
end
ScalewayTem::ActionMailer::DeliveryMethod.new.deliver!(mail)
Checking your credentials
ScalewayTem::ActionMailer::Client.new.list_domains
# => { success: true, error: nil, domains: [{ "name" => "example.com", ... }] }
This sends no email. It is the quickest way to tell a bad key from an unverified sending domain.
Errors
All inherit from ScalewayTem::ActionMailer::Error.
ConfigurationError: no secret key or no project id. Raised before any network call, because that is a deployment mistake and should not look like a delivery failure.DeliveryError: Scaleway answered and refused. Carries#statusand#api_messageso you can branch (retry a 429, do not retry a 403).ConnectionError: the request never completed. Nothing is known about whether the message was accepted, so a blind retry risks a duplicate.
deliver! raises, like the SMTP delivery method does. Set
config.action_mailer.raise_delivery_errors = false if you would rather a
failed email did not take down the request.
Before it will work
- Add and verify your sending domain in the TEM console.
- Publish the SPF, DKIM and DMARC records Scaleway gives you.
- Create an API key whose IAM policy includes TEM permissions.
Your from address must be on the verified domain, or every send is refused.
Development
bundle install
bundle exec rake test
bundle exec rubocop
The suite uses WebMock and asserts on the exact bytes on the wire. No test touches the network.
See CLAUDE.md for the design constraints worth knowing before changing
anything, particularly around the payload contract.
Troubleshooting
| What you see | What it usually means |
|---|---|
ConfigurationError |
The credentials never reached the gem. Check the initializer actually ran and that the values are not nil. |
HTTP 401, denied authentication |
Wrong secret key, or you used the access key instead of the secret key. |
| HTTP 403 | The key authenticates but its IAM policy lacks Transactional Email permissions. |
| HTTP 403 naming the sender or domain | The from domain is not verified in the TEM console, or you are sending from a different domain than the one you verified. |
| HTTP 404 on the endpoint | The region is wrong. Domains live in one region and the default here is fr-par. |
| Everything succeeds, no email arrives | Accepted is not delivered. Check Transactional Email, Activity in the console: bounces show up there. |
ConnectionError |
Outbound HTTPS to api.scaleway.com is blocked, or the timeout is too tight for your network. |
list_domains is the fastest way to separate a bad key from an unverified
domain, and it sends nothing.
Contributing
Issues and pull requests are welcome. Please keep the test suite green
(bundle exec rake test) and RuboCop clean (bundle exec rubocop), and add a
test with any behaviour change. The payload contract in MessageMapper is the
part most worth covering.
License
MIT. Copyright (c) 2026 Ludovic Frank.