Framework guide
Send email from Ruby on Rails
Why use this
There is no Ruby gem, and you do not need one: sending is a single JSON POST to https://api.yourmail.dev/v1/emails, and there is no SMTP server to configure. This guide wraps that call in a small Net::HTTP client from the standard library, then sends from an Active Job.
For example
Someone signs up on your Rails app and an Active Job sends their welcome email in the background.
1. Keep the key on the server
Read the key from the YOURMAIL_API_KEY environment variable. It is a server-side secret: call the API only from controllers, jobs and the console, never from JavaScript you ship to the browser.
# Server-side only: your host's secret store, or .env with dotenv-rails.
YOURMAIL_API_KEY=yourmail_...2. A small client
Authentication is a Bearer API key. Every error comes back as {"error": {"type", "message", "docs_url"}}, so the client raises one of two exception classes, transient or permanent, which is the only distinction a job needs. The explicit timeouts stop a stalled connection from holding a worker.
# app/services/yourmail_client.rb
require "net/http"
require "json"
class YourmailClient
ENDPOINT = URI("https://api.yourmail.dev/v1/emails")
class Error < StandardError
attr_reader :status, :type, :retry_after
def initialize(message, status:, type:, retry_after: nil)
super(message)
@status = status
@type = type
@retry_after = retry_after
end
end
# 5xx, or a rate limit with a short Retry-After: worth another attempt.
class TransientError < Error; end
# Invalid field, unverified domain, suppressed recipient, quota or a
# day-long cap: the same request fails the same way. Surface it.
class PermanentError < Error; end
def initialize(api_key: ENV.fetch("YOURMAIL_API_KEY"))
@api_key = api_key
end
def send_email(**email)
request = Net::HTTP::Post.new(ENDPOINT)
request["Authorization"] = "Bearer #{@api_key}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(email)
response = Net::HTTP.start(
ENDPOINT.host, ENDPOINT.port,
use_ssl: ENDPOINT.scheme == "https", open_timeout: 5, read_timeout: 10
) { |http| http.request(request) }
body = JSON.parse(response.body) rescue {}
return body.fetch("id") if response.is_a?(Net::HTTPSuccess)
error = body.fetch("error", {})
status = response.code.to_i
retry_after = response["Retry-After"]&.to_i
# The daily caps answer 429 with the seconds until UTC midnight, so a
# rate_limited is only transient when the wait is short.
transient = status >= 500 ||
(error["type"] == "rate_limited" && retry_after && retry_after <= 60)
raise (transient ? TransientError : PermanentError).new(
error["message"] || response.message,
status: status, type: error["type"], retry_after: retry_after
)
end
end3. The shortest working send
Supplying both html and text is worth the extra line: some clients prefer the plain-text part, and its absence is a mild spam signal.
id = YourmailClient.new.send_email(
from: "Acme <hello@mail.acme.com>",
to: "customer@example.com",
subject: "Welcome aboard",
html: "<p>Thanks for signing up.</p>",
text: "Thanks for signing up."
)
puts "queued #{id}"A success returns {"id": "…"}. The from address must be on a domain you have verified; until you have one, send from no-reply@yourmail.dev instead, which delivers to your own inbox in test mode.
4. An Active Job
# app/jobs/welcome_email_job.rb
class WelcomeEmailJob < ApplicationJob
queue_as :default
# Retry what can succeed later: 5xx, short rate limits, timeouts.
retry_on YourmailClient::TransientError, wait: :polynomially_longer, attempts: 5
retry_on Net::OpenTimeout, Net::ReadTimeout, wait: :polynomially_longer, attempts: 5
# Permanent: the same request will fail the same way. Record it and stop.
discard_on YourmailClient::PermanentError do |job, error|
Rails.logger.warn("YourMail rejected #{job.class.name}: #{error.type}: #{error.message}")
end
def perform(user)
YourmailClient.new.send_email(
from: "Acme <hello@mail.acme.com>",
to: user.email,
subject: "Welcome aboard",
html: "<p>Thanks for signing up.</p>",
text: "Thanks for signing up.",
# Derived from the user, not generated per attempt: a retry after a lost
# response returns the original id instead of sending twice.
idempotency_key: "welcome:#{user.id}",
tags: [{ name: "type", value: "welcome" }]
)
end
end# After the user is created, e.g. in UsersController#create:
WelcomeEmailJob.perform_later(@user)The keyword arguments go out as JSON keys, which is why idempotency_key works: the API accepts the snake_case spellings reply_to, idempotency_key and scheduled_at alongside the camelCase ones. tags is an array of { name:, value: } hashes, not a single hash; a hash is a 400. Tags show up when you filter your message history later.
5. Retry by error type, not attempt count
A 400, 401, 403 or 422 is configuration to fix: an invalid field, a bad key, an unverified domain, a suppressed recipient. It will fail the same way every time, which is why the job discards it with a log line instead of retrying. A 5xx, a timeout, or a 429 rate_limited with a short Retry-After is transient, and those are the only errors retry_on names. A 429 quota_exceeded carries no Retry-After at all: it is a ceiling, so surface it rather than waiting on it.
The other half of a safe retry is the idempotency key. A send is only safe to retry with the same key: without one, a retry after a lost response sends twice. Derive it from something stable, such as the user or order id plus the template name, never a fresh random value per attempt. A repeat with a key already used returns the original message's id instead of sending a second copy.
# Don't do this.
class WelcomeEmailJob < ApplicationJob
# Retries every failure: a suppressed recipient fails all five times, and
# a quota error is a ceiling for the rest of the day, not backpressure.
retry_on StandardError, attempts: 5
endThe full list of error types, and which are safe to retry, is in the errors reference.
Sending a lot at once
Enqueuing one job per recipient for a few hundred people works but is slow and wasteful. Use the batch endpoint instead: up to 100 messages per request, each with its own recipients and body.
Switching from another provider?
- AWS SES: YourMail vs AWS SES
- Resend: migration guide · YourMail vs Resend
- Postmark: migration guide · YourMail vs Postmark
- SendGrid: migration guide · YourMail vs SendGrid
Last updated