DOCS LLMs

How to get a 10/10 deliverability score for your emails

RailsFast can achieve a 10/10 email deliverability out of the box, as tested by mail-tester.com

RailsFast Email Deliverability Guide

To achieve that, when configuring AWS SES for sending email, make sure to strictly follow these steps:

  • Navigate to your Amazon SES dashboard
  • Add your project domain name as a SES identity
    • To do that, on the sidebar, navigate to Configuration > Identities and click [ Create Identity ]
    • In the new page, select "Domain" as the new identity type
    • Enter your domain
    • Activate the "Use a custom MAIL FROM domain" checkbox
      • In the MAIL FROM field, input something like mail.<yourdomain.com>
    • Scroll down and select "Easy DKIM"
      • Select RSA_2048_BIT
      • Leave "DKIM Signatures" active
    • Click on the [ Crete identity ] button
  • On the new identiy page, you'll see three main sections: DKIM, MAIL FROM, and DMARC. For each section, there's a "Publish DNS records" unfoldable item. If you click it, you'll see the DNS records you need to add to your domain's DNS to clear each section. It's a somewhat tedious job, but make sure you add all records right.
  • Once you're done adding all records, scroll up in the indentity page in SES and click the refresh button. If everything went right, you should see your "Identity status: Verified", and the DKIM and MAIL FROM sections marked as successful. If that did not happen, give your DNS time to propagate (anywhere from some minutes to a few hours) and try again.
  • RailsFast is configured with best email practices so that when you send a transactional email from Rails you should get a perfect 10/10 score (or pretty close to it) in deliverability: you can test it at mail-tester.com
TIP

Terminal / agent lane: check verification status without the dashboard — aws sesv2 get-email-identity --email-identity yourdomain.com --query '{Verified:VerifiedForSendingStatus, Dkim:DkimAttributes.Status, MailFrom:MailFromAttributes.MailFromDomainStatus}' (all three should say verified/SUCCESS — with Cloudflare DNS this typically takes ~2 minutes, not hours). Then prove the whole chain with a real email from production:

bin/kamal app exec --reuse 'bin/rails runner "Goodmail.compose(to: %q([email protected]), from: %q([email protected]), subject: %q(SES test)) { h1 %q(It works!); text %q(Sent from production via SES.); sign }.deliver_now"'

The received message should show SPF, DKIM and DMARC all PASS in its headers.

After launch: the email lifecycle

Deliverability isn't a setup step, it's a lifecycle. Three things keep your 10/10 honest over time:

  1. Turn on Cloudflare's DMARC Management for the zone (Email → DMARC Management in the dashboard — free). It rewrites your DMARC record with a rua reporting address and dashboards who is sending as your domain. Without reports, you're graduating policy blind.
  2. Graduate your DMARC policy. The quickstart starts you at p=none (monitor only) — that's correct for day one and wrong forever. After 2–4 weeks of clean reports (all mail SPF/DKIM-aligned, no legitimate source failing), move to p=quarantine, and later p=reject. It's a one-line DNS TXT edit — fully agent-executable via the same Cloudflare API lane as the quickstart.
  3. Turn on bounce and complaint handling. Reputation damage is silent by default. A rising complaint rate you don't see is how sending privileges die. RailsFast ships the whole loop: see Bounces, complaints and the suppression list below. SES's account-level suppression list is on by default — leave it on as well.

Bounces, complaints and the suppression list

SES judges the reputation of the whole AWS account. An address that bounced hard, or a recipient who marked your mail as spam, must get no mail from you again. Every app on the same account pays for the one that keeps mailing them.

RailsFast does this in the app. It is off until you set two keys in config/railsfast/railsfast.yml. While both are blank, the app does not read the suppression list either, so blanking them later sends mail to suppressed addresses again. Then:

  • Every mail names your SES configuration set. A Mail interceptor (config/initializers/ses_events.rb) adds the X-SES-CONFIGURATION-SET header to each mail of each mailer: yours, Devise, Pay, the organizations gem, Goodmail.compose. SES reads the header and removes it before delivery (AWS).
  • The configuration set publishes the events to an SNS topic, and the topic posts them to /webhooks/ses. Webhooks::SesController accepts a message only when it names your topic and its signature verifies with the AWS SDK's Aws::SNS::MessageVerifier. It confirms the topic's subscription by itself. When that confirmation request fails, it answers 503, and SNS sends the confirmation again.
  • A permanent bounce or a complaint puts the address on the suppression list (EmailSuppression). The table stores a keyed digest of the address, never the address. A transient bounce and a "not-spam" report change nothing.
  • No mail goes to a suppressed address. The suppressed addresses leave To, Cc and Bcc, and the other recipients still get the mail. A mail with no recipient left is not sent: an Action Mailer before_deliver callback stops deliver_now, deliver_now!, deliver_later and deliver_later! alike. One log line says what left, without the addresses: [ses] removed suppressed recipients from a DeviseGoodmailer message (1 from To); it is not sent.

Set it up

You need the AWS CLI, signed in to the account that sends the app's mail. The examples use the app name myapp, the domain example.com and the region us-east-1. The script reads the defaults from railsfast.yml, and bin/ses-events-setup --help lists the overrides.

  1. Make the AWS resources. Look first, then run it for real:

    bin/ses-events-setup prepare --dry-run
    bin/ses-events-setup prepare

    It makes the configuration set myapp-mail and the SNS topic myapp-ses-events. The topic gets signature version 2, a policy that lets SES publish to it only for this account and this configuration set, and an HTTPS retry policy (10 retries, 5 to 300 seconds), so a deploy loses no event. Last, it adds the set's event destination to the topic for the BOUNCE and COMPLAINT events. Each step reads AWS first, so a second run changes nothing. It prints the two values for the next step.

  2. Set both keys in config/railsfast/railsfast.yml, under production: or under default::

    ses_configuration_set: "myapp-mail"
    ses_events_topic_arn: "arn:aws:sns:us-east-1:111122223333:myapp-ses-events"

    railsfast.yml is merge-guarded, so an app made from an older template does not get these keys from a template update. Add them by hand. bin/railsfast doctor tells you when they are missing, and when only one is set.

  3. Allow the configuration set to the IAM user that sends the mail. ses:SendRawEmail checks the configuration set as a resource when the mail names one (AWS). A policy that allows only your domain identity (as in One SES user per app) makes SES refuse every mail as soon as step 2 is deployed. Add the set to the statement:

    {
      "Effect": "Allow",
      "Action": ["ses:SendRawEmail", "ses:SendEmail"],
      "Resource": [
        "arn:aws:ses:us-east-1:111122223333:identity/example.com",
        "arn:aws:ses:us-east-1:111122223333:configuration-set/myapp-mail"
      ]
    }
  4. Deploy. Then subscribe the endpoint. SNS posts a confirmation at once, the app confirms it, and the script waits for that:

    bin/ses-events-setup subscribe

    Subscribe only after the deploy. A release without the keys answers 404, and the subscription stays "pending confirmation". Then subscribe stops with an error after SUBSCRIBE_TIMEOUT seconds (120 by default). To fix it, deploy the keys and run subscribe again: it sends the confirmation again.

  5. Prove the path with the SES mailbox simulator. Mail to the simulator costs no reputation, and the simulator never adds its addresses to the account-level suppression list (AWS):

    for to in success bounce complaint; do
      aws sesv2 send-email --from-email-address [email protected] --destination "ToAddresses=$to@simulator.amazonses.com" \
        --configuration-set-name myapp-mail --content 'Simple={Subject={Data=SES events test},Body={Text={Data=Test}}}' \
        --region us-east-1
    done
    bin/kamal app exec --roles=web --reuse 'bin/rails runner "p EmailSuppression.group(:reason).count"'
    # {"bounce"=>1, "complaint"=>1}

    The bounce and the complaint each make one suppression; the success mail makes none. If nothing arrives, look in the app's log for [ses] refused an SNS message (it names the cause class), [ses] the SNS subscription is not confirmed or [ses] confirmed the SNS subscription.

CAUTION

The gate: send no mail to third parties before the SES account is out of the sandbox. A new SES account sends only to verified addresses and the simulator (AWS). Check it with aws sesv2 get-account --query ProductionAccessEnabled --region us-east-1; both script commands print it too. Ask for production access with the bounce and complaint handling above in place. Until AWS says yes, keep any feature that mails people who are not you behind a flag that is off in production.

Your own mail and the events

  • A sender that checks the list itself. A mail that must never race a suppression (an invoice, say) checks EmailSuppression.suppressed?(address) in the transaction that sends it, after EmailSuppression.hold(address). That shared advisory lock makes a suppression of the same address wait until the send is recorded. Such a mail never reaches the interceptor with a suppressed address, so the two never both act on it. The interceptor records nothing: a sender that must record a refusal checks the list itself like this.

  • A mail with its own configuration set keeps it: the interceptor adds the header only when the mail has none.

  • Delivery states. The template acts on bounces and complaints only. To follow your own mails, add DELIVERY (or SEND) with SES_EVENT_TYPES="BOUNCE COMPLAINT DELIVERY" bin/ses-events-setup prepare, and subscribe to each event:

    # config/initializers/mail_delivery_states.rb
    ActiveSupport::Notifications.subscribe(SesEvent::NOTIFICATION) do |notification|
      ses_event = notification.payload[:ses_event]
      next unless ses_event.type == "Delivery"
    
      SentMail.find_by(ses_message_id: ses_event.message_id)&.delivered!(at: ses_event.time_of("delivery"))
    end

    SES and SNS repeat and reorder events, so make each step safe to run again. When a subscriber raises, the endpoint answers 500 and SNS posts the message again.

NOTE

The proxy limit is the first line of defence for /webhooks/ses. The endpoint is public: its authentication is the SNS signature, checked after the body is read. kamal-proxy refuses request bodies above proxy.buffering.max_request_body (1 GB by default; see Kamal's proxy configuration). The app then cuts the body of this path at 300 KB before Rails reads it (SNS posts 256 KB at most). Lower the proxy limit if your app takes no large uploads.

WARNING

The digests use a key derived from secret_key_base. A new secret_key_base makes every stored digest unmatchable: the list is empty in effect, and suppressed addresses get mail again. Keep the account-level suppression list on, so SES still stops them.