Outbound Email Safelist for Test Sends | LlamaPress Cookbook
LlamaPress
Cookbook
Integrations Stable

Outbound Email Safelist — Send Every Test Email to the Developer, Not Real People

An Action Mailer interceptor that auto-redirects every outbound email to one or more developer inboxes while the app is still being built, and appends a "this would have gone to <customer>" notice to the body so nobody is confused. Covers the reroute-don't-drop rule, multipart body rewriting, both registration methods, the EMAIL_SAFELIST env switch, and how to remove it on go-live.

Proven on leo-moozu.leo.llamapress.ai, leo-mezuli.leo.llamapress.ai model · controller · view

Outbound Email Safelist — Send Every Test Email to the Developer, Not Real People

⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.

You are building a feature that emails real people — a quote request to a supplier, an invoice reminder to a client, a follow-up sweep that fires on a timer. The app is not live yet. You still have to click the button to see if it works. The moment you do, a real supplier gets a half-finished email from a half-finished app, and you cannot take it back.

A safelist interceptor removes that risk. It is one small class that Action Mailer runs on every outbound message, just before delivery. If a recipient is not on your safelist, the message is auto-redirected to the developers' inboxes instead — subject tagged, original recipients preserved in the headers, and a plain-English notice appended to the body explaining who it was really for. Nothing reaches a stranger, and nothing disappears.

Because it sits below the mailers, you do not have to remember it. Every path that sends mail goes through it: a controller action, a background job, a rails console one-liner, a timer thread, a seed script.

Say this out loud to whoever is testing the app: while safe dev mode is on, no email reaches a real customer, supplier, or user. Every message — invitations, password resets, notifications, quote requests — is delivered to the inboxes you control (every developer on the safelist gets a copy). The app's screens still say "email sent", because from the app's point of view it was sent. It was just sent to you. Open one and the bottom of the message tells you exactly who it would have gone to.

When to use: any app that sends mail to addresses you do not own, at any point before go-live. Also useful on a staging or demo copy of a live app, which is the classic way real customers get emailed twice. When not to: a live production app (remove it — see "Turning it off for real"), or mail that never leaves your team.


The 80/20 in one breath

  1. Create app/mailers/outbound_email_safelist.rb with a self.delivering_email(mail) class method.
  2. Compare every to / cc / bcc address against a safelist — a list, so several developers can each get a copy.
  3. If any address is not on the list, rewrite to to the whole safelist, tag the subject, and stash the originals in X-Original-* headers. Reroute — never silently drop.
  4. Append a notice to the email body saying who it would have gone to and why the developer is holding it instead.
  5. Register it in a to_prepare block: ActionMailer::Base.register_interceptor(...).
  6. Default it to ON when unconfigured, with EMAIL_SAFELIST=off as the escape hatch.
  7. Show a banner in the app so testers know mail is in safe mode and where it went.
  8. Send one test email and read the printed final_to to confirm the redirect fired.

Layer 1 — The interceptor

# app/mailers/outbound_email_safelist.rb
#
# Reroutes outbound mail to a safelist while the app is in testing, so a stray
# email can never reach a real customer or supplier.
#
# Control with the EMAIL_SAFELIST env var:
#   unset                            -> DEFAULT_SAFELIST (fail safe: the guard is ON)
#   "dev1@you.com, dev2@you.com"     -> that list instead; EVERY name on it gets a copy
#   "off"                            -> disabled, mail goes wherever the app addressed it
class OutboundEmailSafelist
  # Change these to addresses YOU control. Never leave a customer address here.
  # A list, not a single address — put every developer who should see test mail on it.
  DEFAULT_SAFELIST = %w[dev1@example.com dev2@example.com].freeze

  # The single source of truth for "who receives redirected mail right now".
  # The in-app banner and the flash messages read this too, so they can never
  # disagree with what the interceptor actually does.
  def self.recipients
    setting = ENV["EMAIL_SAFELIST"].to_s.strip
    return [] if setting.casecmp("off").zero?

    list = setting.present? ? setting.split(",") : DEFAULT_SAFELIST
    list.map { |address| address.to_s.strip.downcase }.reject(&:empty?).uniq
  end

  def self.active?
    recipients.any?
  end

  def self.delivering_email(mail)
    allowed = recipients
    return if allowed.empty? # off, or misconfigured: better to send than to swallow

    originals = { to: Array(mail.to), cc: Array(mail.cc), bcc: Array(mail.bcc) }
    blocked   = originals.values.flatten.map(&:to_s)
                          .reject { |address| allowed.include?(address.downcase) }
    return if blocked.empty? # everyone was already safelisted — send it untouched
    return if mail.header["X-Email-Guard"] # already annotated; don't double-append

    # Keep a record of what the app MEANT to do. This is what makes the guard
    # debuggable instead of spooky.
    originals.each do |field, addresses|
      next if addresses.empty?
      mail.header["X-Original-#{field.to_s.capitalize}"] = addresses.join(", ")
    end
    mail.header["X-Email-Guard"] = "redirected"

    mail.subject = "[SAFELIST -> #{blocked.join(', ')}] #{mail.subject}"
    mail.to  = allowed # every developer on the list gets their own copy
    mail.cc  = nil
    mail.bcc = nil

    annotate_body!(mail, originals)

    Rails.logger.warn(
      "[OutboundEmailSafelist] rerouted #{mail.subject.inspect} " \
      "away from #{blocked.join(', ')} to #{allowed.join(', ')}"
    )
  end
end

The notice appended to the body (so the developer is not confused)

The subject tag tells you a message was redirected. It does not tell you who it was meant for, and a developer opening an invitation addressed to a customer they have never heard of will reasonably wonder whether the app is broken. Append the explanation to the body itself, in both the plain-text and HTML parts.

# app/mailers/outbound_email_safelist.rb  (continued — same class)

  # Walks the real body parts and appends the notice to each one. Attachments and
  # inline images are skipped; nested multipart trees are recursed into.
  def self.annotate_body!(mail, originals)
    each_body_part(mail) do |part|
      original_content_type = part.content_type # assigning a body can drop the charset
      decoded = part.body.decoded

      part.body = if original_content_type.to_s.include?("text/html")
                    insert_before_closing_body(decoded, notice_html(originals))
                  else
                    "#{decoded}\n\n#{notice_text(originals)}\n"
                  end

      part.content_type = original_content_type
    end
  end

  def self.each_body_part(mail, &block)
    return block.call(mail) unless mail.multipart?

    mail.parts.each do |part|
      next if part.attachment?
      part.multipart? ? each_body_part(part, &block) : block.call(part)
    end
  end

  def self.notice_text(originals)
    lines = ["", "-" * 64, "SAFE EMAIL MODE — this message was redirected to you."]
    originals.each do |field, addresses|
      next if addresses.empty?
      lines << "This would have been sent to (#{field}): #{addresses.join(', ')}"
    end
    lines << "The app is still in development and the email guard is turned on, so"
    lines << "we redirected it to you. Nobody at the address(es) above received it."
    lines << "-" * 64
    lines.join("\n")
  end

  def self.notice_html(originals)
    rows = originals.reject { |_field, addresses| addresses.empty? }.map do |field, addresses|
      "<div>This would have been sent to (#{field}): " \
      "<strong>#{ERB::Util.html_escape(addresses.join(', '))}</strong></div>"
    end.join

    <<~HTML
      <div style="margin-top:24px;padding:12px 16px;border:1px solid #f59e0b;
                  background:#fffbeb;color:#78350f;font:14px/1.5 sans-serif;">
        <div style="font-weight:700;margin-bottom:4px;">
          Safe email mode — this message was redirected to you.
        </div>
        #{rows}
        <div style="margin-top:4px;">
          The app is still in development and the email guard is turned on, so we
          redirected it to you. Nobody at the address(es) above received it.
        </div>
      </div>
    HTML
  end

  # Put the notice inside <body> if there is one, so it renders instead of being
  # dropped by mail clients that ignore content after </html>.
  def self.insert_before_closing_body(html, notice)
    index = html.rindex(%r{</body>}i)
    index ? html.dup.insert(index, notice) : html + notice
  end

This code was exercised against a multipart/alternative message carrying a PDF attachment on 2026-08-04: both body parts got the notice, the attachment came back byte-identical, the HTML part kept its charset=UTF-8, and a second pass added nothing. Those four are the things that break when people write this from memory.

The notice lands at the bottom of the message, which keeps the email looking like the real thing when you are checking layout and copy. If your team would rather see it first, change insert_before_closing_body to insert after the opening <body> tag and switch the text version to a prepend — the subject tag already carries the top-of-inbox signal either way.

Layer 2 — Registering it

You cannot write config.action_mailer.interceptors = [OutboundEmailSafelist] at the top of an initializer. The class lives in app/, so Zeitwerk has not defined the constant yet while the initializer file is loading, and you get a NameError at boot. There are two ways around that. Prefer the first.

Preferred — to_prepare + register_interceptor (additive, survives reloads)

# config/environments/development.rb   (inside the Rails.application.configure block)
#
# Registered inside to_prepare because autoloaded app/ constants are not resolvable
# while config files are being loaded at boot. to_prepare also re-runs on each code
# reload in development, so the interceptor never points at a stale class object.
config.to_prepare do
  ActionMailer::Base.register_interceptor(OutboundEmailSafelist)
end

register_interceptor appends. Any interceptor another part of the app registered stays registered. That is the main reason to prefer this form.

Put it in development.rb, not a new initializer file — on a Leo instance, config/ is bind-mounted one file at a time, so a brand-new config/initializers/mail_safelist.rb written on the host never appears inside the container and never runs. development.rb is already mounted. (Outside a Leo box, a dedicated initializer with Rails.application.config.to_prepare do ... end is the cleaner home.)

Alternative — the String form in the environment config

# config/environments/development.rb
#
# ⚠️ On a Leo instance the app boots in the DEVELOPMENT environment, so app config
# overrides belong in this file — NOT in application.rb or production.rb. Putting it in
# the wrong file is the #1 reason the guard appears to do nothing.

# TESTING SAFELIST: outbound mail is redirected to the safelist until go-live.
# Set EMAIL_SAFELIST=off in .env to lift it, or to a comma-separated list to change
# who may receive mail.
config.action_mailer.interceptors = %w[OutboundEmailSafelist]

Written as a String, not as the constant, so Rails resolves the name lazily at delivery time. The trade-off: = replaces the whole list and silently unregisters anything already there. Use += if you keep this form.

Whichever you pick, prove it took effect before you trust it:

cd ~/Leonardo
docker compose exec llamapress bin/rails runner \
  'puts Mail.class_variable_get(:@@delivery_interceptors).inspect' </dev/null
# => [OutboundEmailSafelist]

An empty array means the registration never ran. That is a silent failure — the app keeps sending, straight to real people.

Layer 3 — The env switch

# .env  (project root, next to docker-compose.yml)

# Leave this line OUT entirely while building. Unset means the guard is ON with
# DEFAULT_SAFELIST — that is deliberate (see Gotchas: fail safe).

# Several developers on the build? Comma-separate them. EVERY address on the list
# receives its own copy of every redirected email:
# EMAIL_SAFELIST=dev1@example.com,dev2@example.com,dev3@example.com

# Spaces around the commas are fine — the parser strips them:
# EMAIL_SAFELIST=dev1@example.com, dev2@example.com

# Add the client for a demo, so they see the emails their app produces without a
# single one leaving the building:
# EMAIL_SAFELIST=dev1@example.com, client@theircompany.com

# Go live — mail goes wherever the app addressed it:
# EMAIL_SAFELIST=off

Nothing else changes when you add a developer. The interceptor already assigns the whole list to to, and the in-app banner reads the same list, so one .env edit plus a container recreate is the entire operation.

A .env edit needs a container recreate, not a restart. A plain restart does not reload .env:

cd ~/Leonardo
docker compose up -d --force-recreate llamapress

Layer 4 — Tell the humans (the part everyone skips)

The interceptor is invisible. A tester clicks "Send invitation", the app says "Invitation sent", and nothing about that screen reveals that the invitation went to the developer instead of the person named on it. That ambiguity is how someone concludes the email feature is broken — or worse, assumes a real customer was contacted when they were not.

Make the mode visible in four places. All four are cheap.

1. A banner on every page, driven by the same list the interceptor uses:

<%# app/views/layouts/_email_safe_mode_banner.html.erb %>
<% if OutboundEmailSafelist.active? %>
  <div class="bg-amber-50 border-b border-amber-300 px-4 py-2 text-sm text-amber-900">
    <i class="fa-solid fa-flask mr-1" aria-hidden="true"></i>
    <strong>Safe email mode is on.</strong>
    No email leaves this app to a real recipient. Every message is delivered to
    <span class="font-mono"><%= OutboundEmailSafelist.recipients.join(", ") %></span>
    instead. Turn it off before go-live.
  </div>
<% end %>

Render it from the app layout, above the content. Call OutboundEmailSafelist.active? / .recipientsnever re-read ENV["EMAIL_SAFELIST"] in the view. A second copy of the parsing rules is a second thing to get wrong, and the banner would eventually claim something the interceptor does not do.

2. The notice appended to the body (Layer 1). This is the one that answers the developer's actual question — "why am I holding an invitation addressed to someone else?" — at the moment they are looking at the email.

3. The subject tag (also Layer 1). It is what makes the developer's inbox readable, and it makes a Gmail filter trivial.

4. A line in the flash message on any screen whose main job is sending mail:

# app/controllers/invitations_controller.rb
notice = "Invitation sent."
if OutboundEmailSafelist.active?
  notice += " (Safe email mode: delivered to " \
            "#{OutboundEmailSafelist.recipients.join(', ')}, not #{@user.email}.)"
end
redirect_to invitations_path, notice: notice

Font Awesome is available on most Leo boxes but not guaranteed — drop the <i> tag or swap in an inline SVG if the icon does not render.


Verifying it actually works

Do not assume. Send one message — with a cc, because that is the field people forget to guard — and read what came back:

cd ~/Leonardo
docker compose exec llamapress bin/rails runner '
  m = ActionMailer::Base.mail(
    to:      "stranger@example.com",
    cc:      "boss@realcompany.com",
    from:    ENV.fetch("MAILER_FROM_EMAIL", "noreply@example.com"),
    subject: "safelist check",
    body:    "If you can read this, the redirect worked."
  )
  m.delivery_method :test   # exercises interceptors WITHOUT hitting SMTP
  m.deliver
  puts "final_to=#{Array(m.to).inspect}"
  puts "final_cc=#{Array(m.cc).inspect}"
  puts "subject=#{m.subject.inspect}"
  puts "x_original_to=#{m.header["X-Original-To"]}"
  puts "notice_in_body=#{m.body.decoded.include?("would have been sent to")}"
' </dev/null

Interceptors run for the :test delivery method too, and the interceptor mutates the message in place — so the printed values are exactly what a real send would deliver, with no email actually leaving the box. Drop the delivery_method :test line and use deliver_now when you want the message to land in the developers' inboxes for real.

  • Guard ON: final_to lists every address on your safelist, final_cc is empty, the subject carries the [SAFELIST -> stranger@example.com] tag, and notice_in_body is true.
  • Guard OFF: final_to is ["stranger@example.com"], the subject is clean, and notice_in_body is false.

Three ways this check earns its keep:

  • If final_cc still shows boss@realcompany.com, your interceptor only rewrites to. A real person is still being emailed. This exact hole was live on a production Leo box on 2026-08-04.
  • If final_to has one address when your safelist has three, you are parsing the env var as a single string instead of splitting on commas.
  • If notice_in_body is false, run the multipart check below — a real mailer sends text/plain and text/html, and this one-liner probe only builds a plain-text body.

Then check a real multipart mailer, because that is where body rewriting actually breaks:

docker compose exec llamapress bin/rails runner '
  m = UserMailer.invitation(User.first)   # a real mailer with both parts
  m.delivery_method :test
  m.deliver
  puts "multipart=#{m.multipart?} parts=#{m.parts.map(&:content_type).inspect}"
  m.parts.each do |p|
    puts "#{p.content_type} notice=#{p.body.decoded.include?("would have been sent to")}"
  end
' </dev/null

Every non-attachment part must report notice=true and must still list its original content type. A part that comes back as text/plain when it started as text/html, or a parts array that shrank, means the body assignment flattened the message — see the multipart gotchas below.


The 12-line version — and exactly what it costs you

This is the shape most agents write first. It works, it is proven in production, and it is worth understanding because of its holes, not despite them:

# app/services/outbound_email_safelist.rb  (the minimal variant)
#
# When EMAIL_SAFELIST is set, redirect every outbound email to that one address so
# real users are never emailed. Blank = no-op.
class OutboundEmailSafelist
  def self.delivering_email(message)
    safelist = ENV["EMAIL_SAFELIST"]
    return if safelist.blank?
    return if Array(message.to).map { |r| r.to_s.downcase }.all? { |r| r == safelist.downcase }

    message.to = [safelist]
  end
end

What you give up, in order of how much it will hurt:

  1. cc and bcc are untouched. A single mailer that CCs an office manager still emails that office manager, every time. The minimal version guards one field out of three. If you ship this, grep -rn "cc:" app/mailers first and be certain the answer is empty.
  2. No subject tag. Everything piles into one inbox with its real subject line, so test mail and real mail look identical.
  3. No X-Original-To. You cannot tell who the message was meant for, which is the first thing you want to know when debugging a mailer.
  4. Opt-in, not fail-safe. Blank means OFF. That is correct if the same code also runs in a real production environment — but on a Leo box, where a relaunch or restore drops hand-added .env keys, blank-means-off means a rebuild silently starts mailing real people. See "Fail safe" in the Gotchas.
  5. One recipient only. ENV["EMAIL_SAFELIST"] is used as a single address, so a second developer joining the build cannot be added without a code change. Splitting on commas is a one-line fix and everything downstream already works.
  6. No explanation in the body. The developer opens a message addressed to a stranger with no indication of why they have it. That is the confusion this whole guide exists to prevent.
  7. Substring or loose matching (r.include?(safelist) in some versions) lets dev@you.com.attacker.net pass as safelisted. Compare whole addresses, downcased.

Start here if you must, then grow it into Layer 1. The cc/bcc rewrite, the comma split, the subject tag and the body notice together are about forty more lines and remove every item on this list except #4.


Turning it off for real (go-live)

EMAIL_SAFELIST=off is the right switch for a quick test. It is the wrong thing to depend on permanently, for one reason specific to Leo instances: hand-added .env keys are dropped on relaunch or restore. The box comes back, the key is gone, the guard falls back to ON, and the app goes quiet again — with no error, because a rerouted email still looks like a successful send.

So when the app genuinely goes live, make the change in code, which is tracked in git and survives a rebuild:

  1. Delete the registration block from the environment config.
  2. Delete app/mailers/outbound_email_safelist.rb.
  3. Delete the safe-mode banner partial and the render call in the layout.
  4. Recreate the container and re-run the verification above. Confirm final_to is the stranger address and the registered-interceptor list is empty.

Deleting beats commenting out. A commented-out guard is one careless uncomment away from swallowing production mail.

Then tell the customer, in writing, that email is now live. They have been told for weeks that nothing reaches real people. The first real invitation going out is a change they need to know about before they click.


Gotchas (the hard-won stuff)

  • Guard to, cc AND bcc. The most common real-world bug in this pattern is an interceptor that only rewrites to. Mail addressed to a stranger gets redirected correctly while a real person on cc receives the message untouched, so the guard looks like it works right up until it does not. Verified live on a Leo box, 2026-08-04.
  • Register it, then prove it registered. Mail.class_variable_get(:@@delivery_interceptors) must include your class. An unregistered interceptor produces no error, no log line, and no clue — the app simply mails real people at full speed.
  • Never write mail.body = ... on a multipart message. It is the obvious way to append the notice and it destroys the message: Mail replaces the whole part tree with one body, so your HTML email arrives as a wall of raw markup or as plain text with the styling gone. Walk mail.parts and assign to each part instead. A message is multipart whenever the mailer has both a .text.erb and a .html.erb template — which is most of them.
  • Recurse into nested parts, and skip attachments. Add one attachment and the tree becomes multipart/mixed wrapping a multipart/alternative wrapping the two bodies. A single mail.parts.each then annotates nothing (the one part it sees is itself multipart), and a version that does not check part.attachment? will append the notice into the bytes of the attached PDF, corrupting it.
  • Re-set content_type after assigning a body. Assigning part.body = string can drop the charset from the part header, and the email then renders with mangled accented characters. Capture part.content_type first, put it back after.
  • Read with part.body.decoded, not part.body.to_s. to_s hands you the quoted-printable or base64 encoded form, so appending to it produces garbage that the mail client renders literally.
  • Make annotation idempotent. Anything that delivers a message twice — a retry, a test helper, a preview — will append the notice twice. A X-Email-Guard header checked at the top costs one line and makes the guard safe to run over the same message repeatedly.
  • The notice contains real customer email addresses. That is the point when it lands in your own inbox. It is a small data leak the moment you add an outside party to the safelist for a demo, so drop the address line from the HTML notice when the safelist includes anyone who is not on your team.
  • Reroute, never drop. The tempting version sets mail.perform_deliveries = false when no recipient survives. Do not. The app still reports success, so "we blocked this on purpose" and "the customer never got their email" look identical from outside — and the only trace is one log line nobody reads. Rerouting keeps every message visible. This is the single most important line in this guide.
  • Fail safe: unset means ON. Read the env var as an opt-out. If a restore wipes .env, you want the app protected, not blasting a supplier list. Never write return unless ENV["EMAIL_SAFELIST"] == "on". The opposite convention — blank means off, set means on — reads well ("production has no safelist, so it just sends") and is what most first drafts do, but on a Leo box a relaunch drops hand-added .env keys, so blank-means-off turns a rebuild into a live send to real customers. If you keep opt-in anyway, pin the value in development.rb rather than .env, so it is tracked in git and cannot vanish.
  • Register in the environment your app actually boots in. Leo instances run RAILS_ENV=development. Config placed in production.rb never runs.
  • config.action_mailer.interceptors = overwrites. If something else already registered an interceptor, use +=, or you will silently unregister it. register_interceptor inside to_prepare appends instead, which is why it is the preferred form.
  • Do not reference the class at the top level of an initializer. app/ constants are not autoloadable while initializers load, so a bare ActionMailer::Base.register_interceptor(OutboundEmailSafelist) raises NameError and fails the boot. Wrap it in Rails.application.config.to_prepare.
  • On a Leo box, config/ is mounted ONE FILE AT A TIME. app/ is a directory mount, so a new app/services/*.rb appears in the container instantly. A new file under config/ does not — only the exact paths listed in docker-compose.yml exist inside the container, so a freshly written config/initializers/mail_safelist.rb is invisible to Rails and your guard never registers. Check what is actually mounted with docker compose exec llamapress ls -la /rails/config/initializers/, then register from a file that is already there.
  • Don't hide the registration in devise.rb. It is the initializer most likely to already be mounted, so it is a tempting place to park the two lines. Resist it: nobody hunting for the mail guard opens the Devise config, and the next agent to regenerate that file deletes your guard without noticing. development.rb is mounted too and is where every other app-level config override on a Leo box lives.
  • Editing a single-file-mounted config can silently no-op. Some editors write a file by replacing it, which swaps the host inode and detaches it from the container mount — the host file changes, the running app keeps reading the old one. After editing development.rb, always confirm the change is really inside the container: docker compose exec llamapress grep -n interceptor /rails/config/environments/development.rb.
  • It only catches Action Mailer. Mail sent through a vendor HTTP API — a SendGrid, Postmark, Resend, or raw AWS SES SDK call from a service object — never touches this hook. If your app has one of those paths, guard it separately, at the service.
  • deliver_later is covered. The interceptor runs at delivery time inside the job, not at enqueue time. Background sweeps and timer threads are protected.
  • Compare downcased. Email addresses are case-insensitive in practice. A safelist entry of You@Example.com must still match you@example.com.
  • mail.to can be nil, a String, or an Array depending on how the mailer built it. Always wrap in Array(...) before iterating, or you will crash on the one mailer that sets a bare string.
  • The subject tag is load-bearing. Without it, your inbox fills with rerouted mail you cannot tell apart from real mail. The tag also makes a Gmail filter trivial.
  • Several developers on to can see each other. That is normally fine — they are teammates. If it is not, deliver to one address and bcc the rest, but then remember your own guard will strip the bcc on the next pass unless you set it after the rewrite.
  • One list, one reader. Parse the env var in exactly one method and let the banner, the flash message and the interceptor all call it. Two copies of "is the guard on?" drift, and the version that drifts is always the one on the screen telling a human something reassuring and false.
  • Recreate, don't restart, after a .env edit — and remember that on a Leo box the env var itself is not durable. See "Turning it off for real".

Files this pattern touches

app/mailers/outbound_email_safelist.rb            # the interceptor (new)
                                                  # app/services/ is equally fine — pick one
config/environments/development.rb                # the to_prepare registration block
app/views/layouts/_email_safe_mode_banner.html.erb # "no mail reaches real people" banner (new)
app/views/layouts/application.html.erb            # one render call for the banner
.env                                              # optional EMAIL_SAFELIST override

How to adapt to your schema

  1. Replace DEFAULT_SAFELIST with addresses you control. This is the only edit most apps need. Put real inboxes there — a black hole defeats the purpose.
  2. Add domain matching if your whole team should receive rerouted mail. Swap the membership test for one that also accepts a domain suffix, so @yourcompany.com passes as a unit instead of listing every teammate.
  3. Drop the X-Original-* headers if you find them noisy. Keep the subject tag and the body notice — those are the parts you read every day.
  4. Reword the notice to match how your team talks. The three facts it must carry are: who it would have gone to, that nobody at that address received it, and that this is the development guard rather than a bug.
  5. Skip the env var entirely for a short-lived build. A hardcoded constant plus a deliberate deletion at go-live is simpler, and it cannot silently revert.
  6. Add a second guard at the service layer if the app also sends through a vendor HTTP API, since the interceptor cannot see those calls.
  7. Point the safelist at the people doing the testing, not at a shared alias. On a team build that is every developer's own address. During a client demo, add the client's address too, so they can see the emails their app produces without a single one leaving the building.

Related