Skip to content
YourMail

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
end

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

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
end

The 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?

Last updated