← All documentation

Mail

You write mailers the ordinary Rails way. Nothing in app/mailers ever names an email company, so moving from one to another means changing one setting and pasting in a new key.


Choosing a provider

Six real ones: SendLayer, Postmark, SendGrid, Mailgun, Amazon SES and plain SMTP. Two more for development — a preview adapter that shows you the message instead of sending it, and a test adapter that pretends to send, so your test suite never emails anybody by accident.

A provider whose gem is not installed is still selectable — choosing it is how you find out which gem to add. The panel says so where the settings would be, and refuses to save until the gem is there.

Only the settings belonging to the provider you choose are saved. The panel asks for exactly what that provider needs, marks the boxes you can leave empty, and explains anything that is not obvious from its name.

Amazon SES is the one worth a note. It asks for a region — chosen from a list of the regions where SES is actually offered — and that must be the region your sending domain or address is verified in, because SES refuses a send from anywhere else. The access key and secret are optional, and blank is right when the machine already has AWS credentials from the environment, a profile or an instance role. With no key in either place, a send fails saying so, naming both places one could go.


Two rules you cannot switch off

A send is never retried. A send that timed out may well have arrived. Sending it again puts a second copy of a one-time code in somebody’s inbox and invalidates the first. The adapter is called exactly once, and you get an error rather than a silent second attempt.

deliver_later is a separate layer, and the default job makes one attempt too. A mailer can opt into retrying — see below — which is a decision you make per mailer, never one made for you.

Message bodies are never logged. Transactional mail carries live sign-in codes and invitation links, and a log is a place those outlive their expiry in a system with different access rules. Adapters are never handed a logger at all, so a new one cannot leak a body by forgetting a convention. Provider error messages are scrubbed too, because providers routinely quote the message they rejected back at you.


Keeping the important mail separate

The mail somebody is waiting for — a sign-in code, a receipt — should not share a sending reputation with mail they are not, like a newsletter. A spam complaint about the newsletter counts against the reputation carrying your sign-in codes, and some providers suspend an account over it.

That is the failure this exists to prevent: the newsletter being annoying is a nuisance, the sign-in code not arriving is an outage.

Everything sends on the transactional stream unless you say otherwise. It is what a message with no stream of its own uses, and one provider for everything is genuinely the simple case rather than a special one.

To separate a second kind of mail, declare a stream in the initializer:

# config/initializers/sparrow_mail.rb
config.stream :broadcast, settings: {message_stream: "broadcast"}

# or through a different provider entirely
config.stream :broadcast, adapter: :mailgun,
                          settings: {domain: "news.example.com"}

Providers that model this themselves take the first form. Providers that do not take the second, where the separation comes from a different provider, account or sending domain. Both produce real separation; only the mechanism differs.

Credentials are not inherited across providers. A key issued by one is meaningless to another, so a stream that names a different provider starts from nothing and must say what it needs. The alternative — merging everything into everything — means a stream that forgot to declare its own key quietly authenticating as something else instead of failing loudly.

If you deliberately want both kinds on one identity, say so with shared_identity: true. Declared, it stays deliberate; without it, a stream that cannot actually be separated is refused rather than quietly providing no separation at all.

Putting a message on the broadcast stream

The message says which stream it is on, with a header. Anything that does not say goes transactional:

class NewsletterMailer < ApplicationMailer
  def weekly_digest(subscriber)
    headers["X-Sparrow-Stream"] = "broadcast"

    mail(to: subscriber.email, subject: "This week")
  end
end

Postmark and Amazon SES separate the two themselves and need nothing further. A provider with no such notion needs a different sending identity instead — a different domain, account or provider — which is the second form above.

A stream nobody declared is refused rather than quietly sent as transactional, so a typo here is an error you see rather than a newsletter riding your sign-in reputation.

Test both of them. With two providers configured, the panel’s test send offers a choice: transactional, broadcast, or one message on each, which is what it does by default. Each is reported on its own, so a broadcast that fails beside a transactional that succeeds reads as exactly that rather than as one ambiguous failure. Each message names its stream, so you can tell which arrived where.


Retrying a deliver_later

Mail sent with deliver_later goes through a job, and a job can be retried. The default one is not — it makes a single attempt, like everything else here.

For mail where arriving twice is a much smaller problem than not arriving at all, a mailer can opt in:

class NewsletterMailer < ApplicationMailer
  self.delivery_job = SparrowMail::RetryableDeliveryJob
end

That retries only the three failures where trying again has a chance of working: the provider rate-limited you, the provider failed, or the network did. It deliberately does not retry a bad credential or a rejected address, because sending the same thing again cannot fix either — it would only delay a failure that was never going to resolve.

Reach for this on a newsletter, not on a sign-in code. The whole reason the ordinary path never retries is that a duplicate one-time code is worse than a failed send, and that reasoning does not stop applying because the send went through a queue.


The default sender

Who a message appears to be from, when a mailer does not name a sender of its own.

Two boxes, one stored value. A name without an address is not a sender, so the address is what actually gets stored:

Acme <hello@acme.com>     name and address
hello@acme.com            address alone

A name containing a comma is quoted for you. Unquoted, Acme, Inc <a@b> parses as a list of two recipients, and the mail goes to somebody called “Acme”.

This describes the message rather than an account, so it reaches every provider on the page.


Sending without a mailer

ActionMailer works exactly as normal and nothing about your mailers changes. You can also send directly:

SparrowMail.deliver!(mail)   # raises SparrowMail::DeliveryError
SparrowMail.deliver(mail)    # returns a result carrying the failure

Use deliver when a failed send is a value rather than an exception — a bulk send that must not stop at one bad address:

result = SparrowMail.deliver(mail)

unless result.success?
  case result.category
  when :invalid_recipient then subscriber.mark_undeliverable!
  when :rate_limited      then DeferredMailJob.set(wait: 5.minutes).perform_later(...)
  end
end

success? and delivered? are not the same thing: a message withheld in sandbox mode is a success that was not delivered.


What is not configured here

Templates and content. Mail is written as ordinary ActionMailer views.

Anything in the log. Covered above, and it is a rule rather than a default.


Saving changes the running application

Values are written to sparrow_mail: in your Rails encrypted credentials and re-read into the running process immediately. Switch provider and the next message goes through the new one — no restart.

You can edit the same keys by hand with bin/rails credentials:edit:

sparrow_mail:
  default_from: Acme <hello@acme.com>
  transactional:
    adapter: postmark
    api_key: ...
  broadcast:
    adapter: mailgun
    api_key: ...
    domain: mail.acme.com

transactional is the default stream; every other key is one you declared.

For a deployment that would rather set these outside the repository, sparrow_mail also reads SPARROW_MAIL_ADAPTER, SPARROW_MAIL_DEFAULT_FROM and SPARROW_MAIL_SANDBOX from the environment. A value set in the initializer wins over the environment, because an initializer is the more specific statement of intent.