---
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 & 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.
---

# Outbound Email Safelist — Stop Test Emails Reaching 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 **rerouted to you** instead — subject tagged, original
recipients preserved in the headers. 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.

> **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.
3. If any address is **not** on the list, rewrite `to` to the safelist, tag the subject,
   and stash the originals in `X-Original-*` headers. **Reroute — never silently drop.**
4. Register it: `config.action_mailer.interceptors = %w[OutboundEmailSafelist]` in the
   environment config your app actually boots in.
5. Default it to **ON** when unconfigured, with `EMAIL_SAFELIST=off` as the escape hatch.
6. Send one test email and read the log line to confirm the reroute fired.

---

## Layer 1 — The interceptor

```ruby
# 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)
#   "a@you.com, b@you.com"      -> that list instead
#   "off"                       -> disabled, mail goes wherever the app addressed it
class OutboundEmailSafelist
  # Change these to addresses YOU control. Never leave a customer address here.
  DEFAULT_SAFELIST = %w[you@example.com teammate@example.com].freeze

  def self.delivering_email(mail)
    setting = ENV["EMAIL_SAFELIST"].to_s.strip
    return if setting.casecmp("off").zero?

    allowed = if setting.present?
                setting.split(",").map { |a| a.strip.downcase }.reject(&:empty?)
              else
                DEFAULT_SAFELIST
              end
    return if allowed.empty? # misconfigured: better to send than to swallow

    originals = { to: Array(mail.to), cc: Array(mail.cc), bcc: Array(mail.bcc) }
    blocked   = originals.values.flatten.reject { |a| allowed.include?(a.to_s.downcase) }
    return if blocked.empty? # everyone was already safelisted — send it untouched

    # 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.subject = "[SAFELIST -> #{blocked.join(', ')}] #{mail.subject}"
    mail.to  = allowed
    mail.cc  = nil
    mail.bcc = nil

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

## Layer 2 — Registering it

Register the interceptor **as a String**, not as the constant. Rails resolves the name
lazily at delivery time, which keeps Zeitwerk's autoloader happy across reloads.

```ruby
# 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 rerouted 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]
```

## Layer 3 — The env switch

```bash
# .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).

# Send only to specific addresses during a client demo:
# EMAIL_SAFELIST=you@example.com, client@theircompany.com

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

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

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

---

## Verifying it actually works

Do not assume. Send one message and read what came back:

```bash
cd ~/Leonardo
docker compose exec llamapress bin/rails runner '
  m = ActionMailer::Base.mail(
    to:      "stranger@example.com",
    from:    ENV.fetch("MAILER_FROM_EMAIL", "noreply@example.com"),
    subject: "safelist check",
    body:    "If you can read this, the reroute worked."
  )
  m.deliver_now
  puts "final_to=#{Array(m.to).inspect}"
  puts "subject=#{m.subject.inspect}"
  puts "x_original_to=#{m.header["X-Original-To"]}"
' </dev/null
```

The interceptor mutates the message in place, so the printed values are the real
delivered ones.

- Guard **ON**: `final_to` is your safelist and the subject carries the
  `[SAFELIST -> stranger@example.com]` tag.
- Guard **OFF**: `final_to` is `["stranger@example.com"]` and the subject is clean.

---

## 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 `config.action_mailer.interceptors` line from the environment config.
2. Delete `app/mailers/outbound_email_safelist.rb`.
3. Recreate the container and re-run the verification above. Confirm `final_to` is the
   stranger address.

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

---

## Gotchas (the hard-won stuff)

- **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"`.
- **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.
- **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.
- **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)
config/environments/development.rb          # one registration line
.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 a real inbox 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 —
   that is the part you read every day.
4. **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.
5. **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.
