# Send email with Rails

> Send email from Rails with Email Fast: a mailer-shaped service object plus an ActiveJob, Net::HTTP against the REST API, credentials, and idempotency.

Canonical: https://emailfast.dev/send-email-with/rails

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

```ruby
# 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
```

```ruby
# 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`, not `after_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` (or `ENV` via 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 a `find_each` loop of jobs.

## Next

- [Ruby SDK quickstart](/docs/quickstart-ruby) — the typed client
- [The email API](/features/email-api) — batch, scheduling, message timelines
- [Plain Ruby recipe](/send-email-with/ruby)
- [All docs](/docs)
