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
- Create
app/mailers/outbound_email_safelist.rbwith aself.delivering_email(mail)class method. - Compare every
to/cc/bccaddress against a safelist — a list, so several developers can each get a copy. - If any address is not on the list, rewrite
toto the whole safelist, tag the subject, and stash the originals inX-Original-*headers. Reroute — never silently drop. - Append a notice to the email body saying who it would have gone to and why the developer is holding it instead.
- Register it in a
to_prepareblock:ActionMailer::Base.register_interceptor(...). - Default it to ON when unconfigured, with
EMAIL_SAFELIST=offas the escape hatch. - Show a banner in the app so testers know mail is in safe mode and where it went.
- Send one test email and read the printed
final_toto 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? / .recipients — never 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_tolists every address on your safelist,final_ccis empty, the subject carries the[SAFELIST -> stranger@example.com]tag, andnotice_in_bodyistrue. - Guard OFF:
final_tois["stranger@example.com"], the subject is clean, andnotice_in_bodyisfalse.
Three ways this check earns its keep:
- If
final_ccstill showsboss@realcompany.com, your interceptor only rewritesto. A real person is still being emailed. This exact hole was live on a production Leo box on 2026-08-04. - If
final_tohas 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_bodyisfalse, run the multipart check below — a real mailer sendstext/plainandtext/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:
ccandbccare 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/mailersfirst and be certain the answer is empty.- No subject tag. Everything piles into one inbox with its real subject line, so test mail and real mail look identical.
- 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. - 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
.envkeys, blank-means-off means a rebuild silently starts mailing real people. See "Fail safe" in the Gotchas. - 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. - 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.
- Substring or loose matching (
r.include?(safelist)in some versions) letsdev@you.com.attacker.netpass 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:
- Delete the registration block from the environment config.
- Delete
app/mailers/outbound_email_safelist.rb. - Delete the safe-mode banner partial and the render call in the layout.
- Recreate the container and re-run the verification above. Confirm
final_tois 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,ccANDbcc. The most common real-world bug in this pattern is an interceptor that only rewritesto. Mail addressed to a stranger gets redirected correctly while a real person onccreceives 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. Walkmail.partsand assign to each part instead. A message is multipart whenever the mailer has both a.text.erband a.html.erbtemplate — which is most of them. - Recurse into nested parts, and skip attachments. Add one attachment and the tree
becomes
multipart/mixedwrapping amultipart/alternativewrapping the two bodies. A singlemail.parts.eachthen annotates nothing (the one part it sees is itself multipart), and a version that does not checkpart.attachment?will append the notice into the bytes of the attached PDF, corrupting it. - Re-set
content_typeafter assigning a body. Assigningpart.body = stringcan drop the charset from the part header, and the email then renders with mangled accented characters. Capturepart.content_typefirst, put it back after. - Read with
part.body.decoded, notpart.body.to_s.to_shands 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-Guardheader 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 = falsewhen 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 writereturn 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.envkeys, so blank-means-off turns a rebuild into a live send to real customers. If you keep opt-in anyway, pin the value indevelopment.rbrather 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 inproduction.rbnever runs. config.action_mailer.interceptors =overwrites. If something else already registered an interceptor, use+=, or you will silently unregister it.register_interceptorinsideto_prepareappends 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 bareActionMailer::Base.register_interceptor(OutboundEmailSafelist)raisesNameErrorand fails the boot. Wrap it inRails.application.config.to_prepare. - On a Leo box,
config/is mounted ONE FILE AT A TIME.app/is a directory mount, so a newapp/services/*.rbappears in the container instantly. A new file underconfig/does not — only the exact paths listed indocker-compose.ymlexist inside the container, so a freshly writtenconfig/initializers/mail_safelist.rbis invisible to Rails and your guard never registers. Check what is actually mounted withdocker 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.rbis 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_lateris 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.commust still matchyou@example.com. mail.tocan be nil, a String, or an Array depending on how the mailer built it. Always wrap inArray(...)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
tocan see each other. That is normally fine — they are teammates. If it is not, deliver to one address andbccthe rest, but then remember your own guard will strip thebccon 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
.envedit — 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
- Replace
DEFAULT_SAFELISTwith addresses you control. This is the only edit most apps need. Put real inboxes there — a black hole defeats the purpose. - 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.compasses as a unit instead of listing every teammate. - 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. - 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.
- 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.
- 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.
- 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.