{"slug":"outbound-email-safelist","meta":{"title":"Outbound Email Safelist — Stop Test Emails Reaching Real People","slug":"outbound-email-safelist","category":"Integrations","summary":"An Action Mailer interceptor that reroutes every outbound email to a safelist while the app is in testing, so a stray notification can never reach a real customer or supplier. Covers the reroute-don't-drop rule, the EMAIL_SAFELIST env switch, and how to remove it safely on go-live.","tags":["email","action-mailer","interceptor","testing","safety","env","smtp"],"status":"stable","visibility":"public","source_project":"leo-mezuli.leo.llamapress.ai","layers":["model"],"related":[{"title":"Twilio — SMS Sending \u0026 Phone Verification","url":"/cookbook/twilio-sms-and-phone-verification","summary":"Sibling recipe for the other outbound channel — same .env-key + container-recreate pattern, and it needs the same \"don't text real people during testing\" guard."},{"title":"Action Mailer interceptors — Rails Guides","url":"https://guides.rubyonrails.org/action_mailer_basics.html#intercepting-and-observing-emails","summary":"Official reference for the delivering_email hook this recipe uses."}]},"body":"# Outbound Email Safelist — Stop Test Emails Reaching Real People\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nYou are building a feature that emails real people — a quote request to a supplier, an\ninvoice reminder to a client, a follow-up sweep that fires on a timer. The app is not\nlive yet. You still have to click the button to see if it works. The moment you do, a\nreal supplier gets a half-finished email from a half-finished app, and you cannot take\nit back.\n\nA **safelist interceptor** removes that risk. It is one small class that Action Mailer\nruns on every outbound message, just before delivery. If a recipient is not on your\nsafelist, the message is **rerouted to you** instead — subject tagged, original\nrecipients preserved in the headers. Nothing reaches a stranger, and nothing disappears.\n\nBecause it sits below the mailers, you do not have to remember it. Every path that sends\nmail goes through it: a controller action, a background job, a `rails console` one-liner,\na timer thread, a seed script.\n\n\u003e **When to use:** any app that sends mail to addresses you do not own, at any point\n\u003e before go-live. Also useful on a staging or demo copy of a live app, which is the\n\u003e classic way real customers get emailed twice.\n\u003e **When not to:** a live production app (remove it — see \"Turning it off for real\"), or\n\u003e mail that never leaves your team.\n\n---\n\n## The 80/20 in one breath\n\n1. Create `app/mailers/outbound_email_safelist.rb` with a `self.delivering_email(mail)`\n   class method.\n2. Compare every `to` / `cc` / `bcc` address against a safelist.\n3. If any address is **not** on the list, rewrite `to` to the safelist, tag the subject,\n   and stash the originals in `X-Original-*` headers. **Reroute — never silently drop.**\n4. Register it: `config.action_mailer.interceptors = %w[OutboundEmailSafelist]` in the\n   environment config your app actually boots in.\n5. Default it to **ON** when unconfigured, with `EMAIL_SAFELIST=off` as the escape hatch.\n6. Send one test email and read the log line to confirm the reroute fired.\n\n---\n\n## Layer 1 — The interceptor\n\n```ruby\n# app/mailers/outbound_email_safelist.rb\n#\n# Reroutes outbound mail to a safelist while the app is in testing, so a stray\n# email can never reach a real customer or supplier.\n#\n# Control with the EMAIL_SAFELIST env var:\n#   unset                       -\u003e DEFAULT_SAFELIST (fail safe: the guard is ON)\n#   \"a@you.com, b@you.com\"      -\u003e that list instead\n#   \"off\"                       -\u003e disabled, mail goes wherever the app addressed it\nclass OutboundEmailSafelist\n  # Change these to addresses YOU control. Never leave a customer address here.\n  DEFAULT_SAFELIST = %w[you@example.com teammate@example.com].freeze\n\n  def self.delivering_email(mail)\n    setting = ENV[\"EMAIL_SAFELIST\"].to_s.strip\n    return if setting.casecmp(\"off\").zero?\n\n    allowed = if setting.present?\n                setting.split(\",\").map { |a| a.strip.downcase }.reject(\u0026:empty?)\n              else\n                DEFAULT_SAFELIST\n              end\n    return if allowed.empty? # misconfigured: better to send than to swallow\n\n    originals = { to: Array(mail.to), cc: Array(mail.cc), bcc: Array(mail.bcc) }\n    blocked   = originals.values.flatten.reject { |a| allowed.include?(a.to_s.downcase) }\n    return if blocked.empty? # everyone was already safelisted — send it untouched\n\n    # Keep a record of what the app MEANT to do. This is what makes the guard\n    # debuggable instead of spooky.\n    originals.each do |field, addresses|\n      next if addresses.empty?\n      mail.header[\"X-Original-#{field.to_s.capitalize}\"] = addresses.join(\", \")\n    end\n\n    mail.subject = \"[SAFELIST -\u003e #{blocked.join(', ')}] #{mail.subject}\"\n    mail.to  = allowed\n    mail.cc  = nil\n    mail.bcc = nil\n\n    Rails.logger.warn(\n      \"[OutboundEmailSafelist] rerouted #{mail.subject.inspect} \" \\\n      \"away from #{blocked.join(', ')} to #{allowed.join(', ')}\"\n    )\n  end\nend\n```\n\n## Layer 2 — Registering it\n\nRegister the interceptor **as a String**, not as the constant. Rails resolves the name\nlazily at delivery time, which keeps Zeitwerk's autoloader happy across reloads.\n\n```ruby\n# config/environments/development.rb\n#\n# ⚠️ On a Leo instance the app boots in the DEVELOPMENT environment, so app config\n# overrides belong in this file — NOT in application.rb or production.rb. Putting it in\n# the wrong file is the #1 reason the guard appears to do nothing.\n\n# TESTING SAFELIST: outbound mail is rerouted to the safelist until go-live.\n# Set EMAIL_SAFELIST=off in .env to lift it, or to a comma-separated list to change\n# who may receive mail.\nconfig.action_mailer.interceptors = %w[OutboundEmailSafelist]\n```\n\n## Layer 3 — The env switch\n\n```bash\n# .env  (project root, next to docker-compose.yml)\n\n# Leave this line OUT entirely while building. Unset means the guard is ON with\n# DEFAULT_SAFELIST — that is deliberate (see Gotchas: fail safe).\n\n# Send only to specific addresses during a client demo:\n# EMAIL_SAFELIST=you@example.com, client@theircompany.com\n\n# Go live — mail goes wherever the app addressed it:\n# EMAIL_SAFELIST=off\n```\n\n**A `.env` edit needs a container recreate, not a restart.** A plain `restart` does not\nreload `.env`:\n\n```bash\ncd ~/Leonardo\ndocker compose up -d --force-recreate llamapress\n```\n\n---\n\n## Verifying it actually works\n\nDo not assume. Send one message and read what came back:\n\n```bash\ncd ~/Leonardo\ndocker compose exec llamapress bin/rails runner '\n  m = ActionMailer::Base.mail(\n    to:      \"stranger@example.com\",\n    from:    ENV.fetch(\"MAILER_FROM_EMAIL\", \"noreply@example.com\"),\n    subject: \"safelist check\",\n    body:    \"If you can read this, the reroute worked.\"\n  )\n  m.deliver_now\n  puts \"final_to=#{Array(m.to).inspect}\"\n  puts \"subject=#{m.subject.inspect}\"\n  puts \"x_original_to=#{m.header[\"X-Original-To\"]}\"\n' \u003c/dev/null\n```\n\nThe interceptor mutates the message in place, so the printed values are the real\ndelivered ones.\n\n- Guard **ON**: `final_to` is your safelist and the subject carries the\n  `[SAFELIST -\u003e stranger@example.com]` tag.\n- Guard **OFF**: `final_to` is `[\"stranger@example.com\"]` and the subject is clean.\n\n---\n\n## Turning it off for real (go-live)\n\n`EMAIL_SAFELIST=off` is the right switch for a quick test. It is the **wrong** thing to\ndepend on permanently, for one reason specific to Leo instances: **hand-added `.env` keys\nare dropped on relaunch or restore.** The box comes back, the key is gone, the guard\nfalls back to ON, and the app goes quiet again — with no error, because a rerouted email\nstill looks like a successful send.\n\nSo when the app genuinely goes live, make the change in **code**, which is tracked in git\nand survives a rebuild:\n\n1. Delete the `config.action_mailer.interceptors` line from the environment config.\n2. Delete `app/mailers/outbound_email_safelist.rb`.\n3. Recreate the container and re-run the verification above. Confirm `final_to` is the\n   stranger address.\n\nDeleting beats commenting out. A commented-out guard is one careless uncomment away from\nswallowing production mail.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Reroute, never drop.** The tempting version sets `mail.perform_deliveries = false`\n  when no recipient survives. Do not. The app still reports success, so \"we blocked this\n  on purpose\" and \"the customer never got their email\" look identical from outside — and\n  the only trace is one log line nobody reads. Rerouting keeps every message visible.\n  This is the single most important line in this guide.\n- **Fail safe: unset means ON.** Read the env var as an opt-*out*. If a restore wipes\n  `.env`, you want the app protected, not blasting a supplier list. Never write\n  `return unless ENV[\"EMAIL_SAFELIST\"] == \"on\"`.\n- **Register in the environment your app actually boots in.** Leo instances run\n  `RAILS_ENV=development`. Config placed in `production.rb` never runs.\n- **`config.action_mailer.interceptors = ` overwrites.** If something else already\n  registered an interceptor, use `+=`, or you will silently unregister it.\n- **It only catches Action Mailer.** Mail sent through a vendor HTTP API — a SendGrid,\n  Postmark, Resend, or raw AWS SES SDK call from a service object — never touches this\n  hook. If your app has one of those paths, guard it separately, at the service.\n- **`deliver_later` is covered.** The interceptor runs at delivery time inside the job,\n  not at enqueue time. Background sweeps and timer threads are protected.\n- **Compare downcased.** Email addresses are case-insensitive in practice. A safelist\n  entry of `You@Example.com` must still match `you@example.com`.\n- **`mail.to` can be nil, a String, or an Array** depending on how the mailer built it.\n  Always wrap in `Array(...)` before iterating, or you will crash on the one mailer that\n  sets a bare string.\n- **The subject tag is load-bearing.** Without it, your inbox fills with rerouted mail\n  you cannot tell apart from real mail. The tag also makes a Gmail filter trivial.\n- **Recreate, don't restart, after a `.env` edit** — and remember that on a Leo box the\n  env var itself is not durable. See \"Turning it off for real\".\n\n---\n\n## Files this pattern touches\n\n```\napp/mailers/outbound_email_safelist.rb      # the interceptor (new)\nconfig/environments/development.rb          # one registration line\n.env                                        # optional EMAIL_SAFELIST override\n```\n\n## How to adapt to your schema\n\n1. **Replace `DEFAULT_SAFELIST`** with addresses you control. This is the only edit most\n   apps need. Put a real inbox there — a black hole defeats the purpose.\n2. **Add domain matching** if your whole team should receive rerouted mail. Swap the\n   membership test for one that also accepts a domain suffix, so `@yourcompany.com`\n   passes as a unit instead of listing every teammate.\n3. **Drop the `X-Original-*` headers** if you find them noisy. Keep the subject tag —\n   that is the part you read every day.\n4. **Skip the env var entirely** for a short-lived build. A hardcoded constant plus a\n   deliberate deletion at go-live is simpler, and it cannot silently revert.\n5. **Add a second guard at the service layer** if the app also sends through a vendor\n   HTTP API, since the interceptor cannot see those calls.\n"}