Send email with Rails
A service object plus an ActiveJob — reads like a mailer, retries like a job.
Facts verified 2026-07-17 — corrections: hello@emailfast.dev
Replace the mailer, keep the shape: a small service object owns the HTTP call, an ActiveJob owns the retry semantics, and WelcomeEmailJob.perform_later(user) reads exactly like deliver_later did. Subject, recipient, and variables travel as plain arguments; the HTML template lives with Email Fast, not in app/views.
# app/services/email_fast.rb — stdlib Net::HTTP, no gem
require "json"
require "net/http"
class EmailFast
ENDPOINT = URI("https://api.emailfast.dev/v1/emails")
def self.send_email(to:, subject:, html:, data: {}, idempotency_key:)
req = Net::HTTP::Post.new(ENDPOINT)
req["Authorization"] = "Bearer #{Rails.application.credentials.emailfast_api_key}"
req["Content-Type"] = "application/json"
req.body = { to:, subject:, html:, data:, idempotency_key: }.to_json
res = Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, read_timeout: 10) do |http|
http.request(req)
end
raise "Send rejected (#{res.code}): #{res.body}" unless res.is_a?(Net::HTTPSuccess)
JSON.parse(res.body)
end
end# app/jobs/welcome_email_job.rb — where a mailer used to be
class WelcomeEmailJob < ApplicationJob
queue_as :default
def perform(user)
EmailFast.send_email(
to: user.email,
subject: "Welcome aboard",
html: "<p>Hi {{name}}, your account is ready.</p>",
data: { name: user.name },
idempotency_key: "welcome-#{user.id}" # a retried job replays, never re-sends
)
end
end
# wherever you called UserMailer.welcome(user).deliver_later:
WelcomeEmailJob.perform_later(user)a 202 from the API means the send is committed to a durable, partitioned outbox before we answer — a crash can't lose it, and a retry with the same idempotency key can't double-send.
Gotchas
- Enqueue from
after_commit, notafter_create— with a fast queue the job can run before your transaction commits and find no row. - ActiveJob retries are exactly why the idempotency key matters:
welcome-#{user.id}turns a retried job into a replay instead of a duplicate email. - Store the key in
Rails.application.credentials(orENVvia your host); with a sandbox key (ef_sandbox_...) the whole flow runs dry in development. - One message per job. Fan-out belongs in
/v1/emails/batch(up to 500 per POST), not in afind_eachloop of jobs.
Next
- Ruby SDK quickstart — the typed client
- The email API — batch, scheduling, message timelines
- Plain Ruby recipe
- All docs