{"slug":"outbound-email-safelist","meta":{"title":"Outbound Email Safelist — Send Every Test Email to the Developer, Not Real People","slug":"outbound-email-safelist","category":"Integrations","summary":"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 \u003ccustomer\u003e\" 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.","tags":["email","action-mailer","interceptor","testing","safety","env","smtp","dev-mode","multipart"],"status":"stable","visibility":"public","source_project":"leo-moozu.leo.llamapress.ai, leo-mezuli.leo.llamapress.ai","layers":["model","controller","view"],"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 — Send Every Test Email to the Developer, Not 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 **auto-redirected to the developers' inboxes** instead — subject\ntagged, original recipients preserved in the headers, and a plain-English notice appended\nto the body explaining who it was really for. Nothing reaches a stranger, and nothing\ndisappears.\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 **Say this out loud to whoever is testing the app:** while safe dev mode is on, **no\n\u003e email reaches a real customer, supplier, or user**. Every message — invitations,\n\u003e password resets, notifications, quote requests — is delivered to **the inboxes you\n\u003e control** (every developer on the safelist gets a copy). The app's screens still say\n\u003e \"email sent\", because from the app's point of view it *was* sent. It was just sent to\n\u003e you. Open one and the bottom of the message tells you exactly who it would have gone to.\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 — **a list, so several\n   developers can each get a copy.**\n3. If any address is **not** on the list, rewrite `to` to the whole safelist, tag the\n   subject, and stash the originals in `X-Original-*` headers. **Reroute — never silently\n   drop.**\n4. **Append a notice to the email body** saying who it would have gone to and why the\n   developer is holding it instead.\n5. Register it in a `to_prepare` block: `ActionMailer::Base.register_interceptor(...)`.\n6. Default it to **ON** when unconfigured, with `EMAIL_SAFELIST=off` as the escape hatch.\n7. Show a banner in the app so testers know mail is in safe mode and where it went.\n8. Send one test email and read the printed `final_to` to confirm the redirect 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#   \"dev1@you.com, dev2@you.com\"     -\u003e that list instead; EVERY name on it gets a copy\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  # A list, not a single address — put every developer who should see test mail on it.\n  DEFAULT_SAFELIST = %w[dev1@example.com dev2@example.com].freeze\n\n  # The single source of truth for \"who receives redirected mail right now\".\n  # The in-app banner and the flash messages read this too, so they can never\n  # disagree with what the interceptor actually does.\n  def self.recipients\n    setting = ENV[\"EMAIL_SAFELIST\"].to_s.strip\n    return [] if setting.casecmp(\"off\").zero?\n\n    list = setting.present? ? setting.split(\",\") : DEFAULT_SAFELIST\n    list.map { |address| address.to_s.strip.downcase }.reject(\u0026:empty?).uniq\n  end\n\n  def self.active?\n    recipients.any?\n  end\n\n  def self.delivering_email(mail)\n    allowed = recipients\n    return if allowed.empty? # off, or 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.map(\u0026:to_s)\n                          .reject { |address| allowed.include?(address.downcase) }\n    return if blocked.empty? # everyone was already safelisted — send it untouched\n    return if mail.header[\"X-Email-Guard\"] # already annotated; don't double-append\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    mail.header[\"X-Email-Guard\"] = \"redirected\"\n\n    mail.subject = \"[SAFELIST -\u003e #{blocked.join(', ')}] #{mail.subject}\"\n    mail.to  = allowed # every developer on the list gets their own copy\n    mail.cc  = nil\n    mail.bcc = nil\n\n    annotate_body!(mail, originals)\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### The notice appended to the body (so the developer is not confused)\n\nThe subject tag tells you a message was redirected. It does not tell you **who it was\nmeant for**, and a developer opening an invitation addressed to a customer they have never\nheard of will reasonably wonder whether the app is broken. Append the explanation to the\nbody itself, in both the plain-text and HTML parts.\n\n```ruby\n# app/mailers/outbound_email_safelist.rb  (continued — same class)\n\n  # Walks the real body parts and appends the notice to each one. Attachments and\n  # inline images are skipped; nested multipart trees are recursed into.\n  def self.annotate_body!(mail, originals)\n    each_body_part(mail) do |part|\n      original_content_type = part.content_type # assigning a body can drop the charset\n      decoded = part.body.decoded\n\n      part.body = if original_content_type.to_s.include?(\"text/html\")\n                    insert_before_closing_body(decoded, notice_html(originals))\n                  else\n                    \"#{decoded}\\n\\n#{notice_text(originals)}\\n\"\n                  end\n\n      part.content_type = original_content_type\n    end\n  end\n\n  def self.each_body_part(mail, \u0026block)\n    return block.call(mail) unless mail.multipart?\n\n    mail.parts.each do |part|\n      next if part.attachment?\n      part.multipart? ? each_body_part(part, \u0026block) : block.call(part)\n    end\n  end\n\n  def self.notice_text(originals)\n    lines = [\"\", \"-\" * 64, \"SAFE EMAIL MODE — this message was redirected to you.\"]\n    originals.each do |field, addresses|\n      next if addresses.empty?\n      lines \u003c\u003c \"This would have been sent to (#{field}): #{addresses.join(', ')}\"\n    end\n    lines \u003c\u003c \"The app is still in development and the email guard is turned on, so\"\n    lines \u003c\u003c \"we redirected it to you. Nobody at the address(es) above received it.\"\n    lines \u003c\u003c \"-\" * 64\n    lines.join(\"\\n\")\n  end\n\n  def self.notice_html(originals)\n    rows = originals.reject { |_field, addresses| addresses.empty? }.map do |field, addresses|\n      \"\u003cdiv\u003eThis would have been sent to (#{field}): \" \\\n      \"\u003cstrong\u003e#{ERB::Util.html_escape(addresses.join(', '))}\u003c/strong\u003e\u003c/div\u003e\"\n    end.join\n\n    \u003c\u003c~HTML\n      \u003cdiv style=\"margin-top:24px;padding:12px 16px;border:1px solid #f59e0b;\n                  background:#fffbeb;color:#78350f;font:14px/1.5 sans-serif;\"\u003e\n        \u003cdiv style=\"font-weight:700;margin-bottom:4px;\"\u003e\n          Safe email mode — this message was redirected to you.\n        \u003c/div\u003e\n        #{rows}\n        \u003cdiv style=\"margin-top:4px;\"\u003e\n          The app is still in development and the email guard is turned on, so we\n          redirected it to you. Nobody at the address(es) above received it.\n        \u003c/div\u003e\n      \u003c/div\u003e\n    HTML\n  end\n\n  # Put the notice inside \u003cbody\u003e if there is one, so it renders instead of being\n  # dropped by mail clients that ignore content after \u003c/html\u003e.\n  def self.insert_before_closing_body(html, notice)\n    index = html.rindex(%r{\u003c/body\u003e}i)\n    index ? html.dup.insert(index, notice) : html + notice\n  end\n```\n\nThis code was exercised against a `multipart/alternative` message carrying a PDF\nattachment on 2026-08-04: both body parts got the notice, the attachment came back\nbyte-identical, the HTML part kept its `charset=UTF-8`, and a second pass added nothing.\nThose four are the things that break when people write this from memory.\n\nThe notice lands at the **bottom** of the message, which keeps the email looking like the\nreal thing when you are checking layout and copy. If your team would rather see it first,\nchange `insert_before_closing_body` to insert after the opening `\u003cbody\u003e` tag and switch\nthe text version to a prepend — the subject tag already carries the top-of-inbox signal\neither way.\n\n## Layer 2 — Registering it\n\nYou cannot write `config.action_mailer.interceptors = [OutboundEmailSafelist]` at the top\nof an initializer. The class lives in `app/`, so Zeitwerk has not defined the constant yet\nwhile the initializer file is loading, and you get a `NameError` at boot. There are two\nways around that. **Prefer the first.**\n\n### Preferred — `to_prepare` + `register_interceptor` (additive, survives reloads)\n\n```ruby\n# config/environments/development.rb   (inside the Rails.application.configure block)\n#\n# Registered inside to_prepare because autoloaded app/ constants are not resolvable\n# while config files are being loaded at boot. to_prepare also re-runs on each code\n# reload in development, so the interceptor never points at a stale class object.\nconfig.to_prepare do\n  ActionMailer::Base.register_interceptor(OutboundEmailSafelist)\nend\n```\n\n`register_interceptor` **appends**. Any interceptor another part of the app registered\nstays registered. That is the main reason to prefer this form.\n\n**Put it in `development.rb`, not a new initializer file** — on a Leo instance,\n`config/` is bind-mounted one file at a time, so a brand-new\n`config/initializers/mail_safelist.rb` written on the host never appears inside the\ncontainer and never runs. `development.rb` is already mounted. (Outside a Leo box, a\ndedicated initializer with `Rails.application.config.to_prepare do ... end` is the\ncleaner home.)\n\n### Alternative — the String form in the environment config\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 redirected 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\nWritten **as a String**, not as the constant, so Rails resolves the name lazily at\ndelivery time. The trade-off: `=` **replaces** the whole list and silently unregisters\nanything already there. Use `+=` if you keep this form.\n\nWhichever you pick, prove it took effect before you trust it:\n\n```bash\ncd ~/Leonardo\ndocker compose exec llamapress bin/rails runner \\\n  'puts Mail.class_variable_get(:@@delivery_interceptors).inspect' \u003c/dev/null\n# =\u003e [OutboundEmailSafelist]\n```\n\nAn empty array means the registration never ran. That is a silent failure — the app keeps\nsending, straight to real people.\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# Several developers on the build? Comma-separate them. EVERY address on the list\n# receives its own copy of every redirected email:\n# EMAIL_SAFELIST=dev1@example.com,dev2@example.com,dev3@example.com\n\n# Spaces around the commas are fine — the parser strips them:\n# EMAIL_SAFELIST=dev1@example.com, dev2@example.com\n\n# Add the client for a demo, so they see the emails their app produces without a\n# single one leaving the building:\n# EMAIL_SAFELIST=dev1@example.com, client@theircompany.com\n\n# Go live — mail goes wherever the app addressed it:\n# EMAIL_SAFELIST=off\n```\n\nNothing else changes when you add a developer. The interceptor already assigns the whole\nlist to `to`, and the in-app banner reads the same list, so one `.env` edit plus a\ncontainer recreate is the entire operation.\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## Layer 4 — Tell the humans (the part everyone skips)\n\nThe interceptor is invisible. A tester clicks \"Send invitation\", the app says \"Invitation\nsent\", and nothing about that screen reveals that the invitation went to the developer\ninstead of the person named on it. That ambiguity is how someone concludes the email\nfeature is broken — or worse, assumes a real customer was contacted when they were not.\n\nMake the mode visible in four places. All four are cheap.\n\n**1. A banner on every page, driven by the same list the interceptor uses:**\n\n```erb\n\u003c%# app/views/layouts/_email_safe_mode_banner.html.erb %\u003e\n\u003c% if OutboundEmailSafelist.active? %\u003e\n  \u003cdiv class=\"bg-amber-50 border-b border-amber-300 px-4 py-2 text-sm text-amber-900\"\u003e\n    \u003ci class=\"fa-solid fa-flask mr-1\" aria-hidden=\"true\"\u003e\u003c/i\u003e\n    \u003cstrong\u003eSafe email mode is on.\u003c/strong\u003e\n    No email leaves this app to a real recipient. Every message is delivered to\n    \u003cspan class=\"font-mono\"\u003e\u003c%= OutboundEmailSafelist.recipients.join(\", \") %\u003e\u003c/span\u003e\n    instead. Turn it off before go-live.\n  \u003c/div\u003e\n\u003c% end %\u003e\n```\n\nRender it from the app layout, above the content. Call\n`OutboundEmailSafelist.active?` / `.recipients` — **never re-read `ENV[\"EMAIL_SAFELIST\"]`\nin the view.** A second copy of the parsing rules is a second thing to get wrong, and the\nbanner would eventually claim something the interceptor does not do.\n\n**2. The notice appended to the body** (Layer 1). This is the one that answers the\ndeveloper's actual question — *\"why am I holding an invitation addressed to someone\nelse?\"* — at the moment they are looking at the email.\n\n**3. The subject tag** (also Layer 1). It is what makes the developer's inbox readable,\nand it makes a Gmail filter trivial.\n\n**4. A line in the flash message** on any screen whose main job is sending mail:\n\n```ruby\n# app/controllers/invitations_controller.rb\nnotice = \"Invitation sent.\"\nif OutboundEmailSafelist.active?\n  notice += \" (Safe email mode: delivered to \" \\\n            \"#{OutboundEmailSafelist.recipients.join(', ')}, not #{@user.email}.)\"\nend\nredirect_to invitations_path, notice: notice\n```\n\nFont Awesome is available on most Leo boxes but not guaranteed — drop the `\u003ci\u003e` tag or\nswap in an inline SVG if the icon does not render.\n\n---\n\n## Verifying it actually works\n\nDo not assume. Send one message — **with a `cc`, because that is the field people forget\nto guard** — 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    cc:      \"boss@realcompany.com\",\n    from:    ENV.fetch(\"MAILER_FROM_EMAIL\", \"noreply@example.com\"),\n    subject: \"safelist check\",\n    body:    \"If you can read this, the redirect worked.\"\n  )\n  m.delivery_method :test   # exercises interceptors WITHOUT hitting SMTP\n  m.deliver\n  puts \"final_to=#{Array(m.to).inspect}\"\n  puts \"final_cc=#{Array(m.cc).inspect}\"\n  puts \"subject=#{m.subject.inspect}\"\n  puts \"x_original_to=#{m.header[\"X-Original-To\"]}\"\n  puts \"notice_in_body=#{m.body.decoded.include?(\"would have been sent to\")}\"\n' \u003c/dev/null\n```\n\nInterceptors run for the `:test` delivery method too, and the interceptor mutates the\nmessage in place — so the printed values are exactly what a real send would deliver, with\nno email actually leaving the box. Drop the `delivery_method :test` line and use\n`deliver_now` when you want the message to land in the developers' inboxes for real.\n\n- Guard **ON**: `final_to` lists **every** address on your safelist, `final_cc` is empty,\n  the subject carries the `[SAFELIST -\u003e stranger@example.com]` tag, and `notice_in_body`\n  is `true`.\n- Guard **OFF**: `final_to` is `[\"stranger@example.com\"]`, the subject is clean, and\n  `notice_in_body` is `false`.\n\nThree ways this check earns its keep:\n\n- If `final_cc` still shows `boss@realcompany.com`, your interceptor only rewrites `to`.\n  A real person is still being emailed. This exact hole was live on a production Leo box\n  on 2026-08-04.\n- If `final_to` has one address when your safelist has three, you are parsing the env var\n  as a single string instead of splitting on commas.\n- If `notice_in_body` is `false`, run the multipart check below — a real mailer sends\n  `text/plain` **and** `text/html`, and this one-liner probe only builds a plain-text body.\n\n**Then check a real multipart mailer**, because that is where body rewriting actually\nbreaks:\n\n```bash\ndocker compose exec llamapress bin/rails runner '\n  m = UserMailer.invitation(User.first)   # a real mailer with both parts\n  m.delivery_method :test\n  m.deliver\n  puts \"multipart=#{m.multipart?} parts=#{m.parts.map(\u0026:content_type).inspect}\"\n  m.parts.each do |p|\n    puts \"#{p.content_type} notice=#{p.body.decoded.include?(\"would have been sent to\")}\"\n  end\n' \u003c/dev/null\n```\n\nEvery non-attachment part must report `notice=true` and must still list its original\ncontent type. A part that comes back as `text/plain` when it started as `text/html`, or a\n`parts` array that shrank, means the body assignment flattened the message — see the\nmultipart gotchas below.\n\n---\n\n## The 12-line version — and exactly what it costs you\n\nThis is the shape most agents write first. It works, it is proven in production, and it is\nworth understanding *because of its holes*, not despite them:\n\n```ruby\n# app/services/outbound_email_safelist.rb  (the minimal variant)\n#\n# When EMAIL_SAFELIST is set, redirect every outbound email to that one address so\n# real users are never emailed. Blank = no-op.\nclass OutboundEmailSafelist\n  def self.delivering_email(message)\n    safelist = ENV[\"EMAIL_SAFELIST\"]\n    return if safelist.blank?\n    return if Array(message.to).map { |r| r.to_s.downcase }.all? { |r| r == safelist.downcase }\n\n    message.to = [safelist]\n  end\nend\n```\n\nWhat you give up, in order of how much it will hurt:\n\n1. **`cc` and `bcc` are untouched.** A single mailer that CCs an office manager still\n   emails that office manager, every time. The minimal version guards one field out of\n   three. If you ship this, `grep -rn \"cc:\" app/mailers` first and be certain the answer\n   is empty.\n2. **No subject tag.** Everything piles into one inbox with its real subject line, so\n   test mail and real mail look identical.\n3. **No `X-Original-To`.** You cannot tell who the message was *meant* for, which is the\n   first thing you want to know when debugging a mailer.\n4. **Opt-in, not fail-safe.** Blank means OFF. That is correct if the same code also runs\n   in a real production environment — but on a Leo box, where a relaunch or restore drops\n   hand-added `.env` keys, blank-means-off means a rebuild silently starts mailing real\n   people. See \"Fail safe\" in the Gotchas.\n5. **One recipient only.** `ENV[\"EMAIL_SAFELIST\"]` is used as a single address, so a\n   second developer joining the build cannot be added without a code change. Splitting on\n   commas is a one-line fix and everything downstream already works.\n6. **No explanation in the body.** The developer opens a message addressed to a stranger\n   with no indication of why they have it. That is the confusion this whole guide exists\n   to prevent.\n7. **Substring or loose matching** (`r.include?(safelist)` in some versions) lets\n   `dev@you.com.attacker.net` pass as safelisted. Compare whole addresses, downcased.\n\nStart here if you must, then grow it into Layer 1. The `cc`/`bcc` rewrite, the comma\nsplit, the subject tag and the body notice together are about forty more lines and remove\nevery item on this list except #4.\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 registration block from the environment config.\n2. Delete `app/mailers/outbound_email_safelist.rb`.\n3. Delete the safe-mode banner partial and the render call in the layout.\n4. Recreate the container and re-run the verification above. Confirm `final_to` is the\n   stranger address and the registered-interceptor list is empty.\n\nDeleting beats commenting out. A commented-out guard is one careless uncomment away from\nswallowing production mail.\n\n**Then tell the customer, in writing, that email is now live.** They have been told for\nweeks that nothing reaches real people. The first real invitation going out is a change\nthey need to know about before they click.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **Guard `to`, `cc` AND `bcc`.** The most common real-world bug in this pattern is an\n  interceptor that only rewrites `to`. Mail addressed to a stranger gets redirected\n  correctly while a real person on `cc` receives the message untouched, so the guard looks\n  like it works right up until it does not. Verified live on a Leo box, 2026-08-04.\n- **Register it, then prove it registered.** `Mail.class_variable_get(:@@delivery_interceptors)`\n  must include your class. An unregistered interceptor produces no error, no log line, and\n  no clue — the app simply mails real people at full speed.\n- **Never write `mail.body = ...` on a multipart message.** It is the obvious way to append\n  the notice and it destroys the message: Mail replaces the whole part tree with one body,\n  so your HTML email arrives as a wall of raw markup or as plain text with the styling\n  gone. Walk `mail.parts` and assign to each part instead. A message is multipart whenever\n  the mailer has both a `.text.erb` and a `.html.erb` template — which is most of them.\n- **Recurse into nested parts, and skip attachments.** Add one attachment and the tree\n  becomes `multipart/mixed` wrapping a `multipart/alternative` wrapping the two bodies.\n  A single `mail.parts.each` then annotates nothing (the one part it sees is itself\n  multipart), and a version that does not check `part.attachment?` will append the notice\n  **into the bytes of the attached PDF**, corrupting it.\n- **Re-set `content_type` after assigning a body.** Assigning `part.body = string` can drop\n  the charset from the part header, and the email then renders with mangled accented\n  characters. Capture `part.content_type` first, put it back after.\n- **Read with `part.body.decoded`, not `part.body.to_s`.** `to_s` hands you the\n  quoted-printable or base64 encoded form, so appending to it produces garbage that the\n  mail client renders literally.\n- **Make annotation idempotent.** Anything that delivers a message twice — a retry, a test\n  helper, a preview — will append the notice twice. A `X-Email-Guard` header checked at\n  the top costs one line and makes the guard safe to run over the same message repeatedly.\n- **The notice contains real customer email addresses.** That is the point when it lands in\n  your own inbox. It is a small data leak the moment you add an outside party to the\n  safelist for a demo, so drop the address line from the HTML notice when the safelist\n  includes anyone who is not on your team.\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\"`. The opposite convention — blank means\n  off, set means on — reads well (\"production has no safelist, so it just sends\") and is\n  what most first drafts do, but on a Leo box a relaunch drops hand-added `.env` keys, so\n  blank-means-off turns a rebuild into a live send to real customers. If you keep\n  opt-in anyway, pin the value in `development.rb` rather than `.env`, so it is tracked in\n  git and cannot vanish.\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  `register_interceptor` inside `to_prepare` appends instead, which is why it is the\n  preferred form.\n- **Do not reference the class at the top level of an initializer.** `app/` constants are\n  not autoloadable while initializers load, so a bare\n  `ActionMailer::Base.register_interceptor(OutboundEmailSafelist)` raises `NameError` and\n  fails the boot. Wrap it in `Rails.application.config.to_prepare`.\n- **On a Leo box, `config/` is mounted ONE FILE AT A TIME.** `app/` is a directory mount,\n  so a new `app/services/*.rb` appears in the container instantly. A **new** file under\n  `config/` does not — only the exact paths listed in `docker-compose.yml` exist inside\n  the container, so a freshly written `config/initializers/mail_safelist.rb` is invisible\n  to Rails and your guard never registers. Check what is actually mounted with\n  `docker compose exec llamapress ls -la /rails/config/initializers/`, then register from\n  a file that is already there.\n- **Don't hide the registration in `devise.rb`.** It is the initializer most likely to\n  already be mounted, so it is a tempting place to park the two lines. Resist it: nobody\n  hunting for the mail guard opens the Devise config, and the next agent to regenerate\n  that file deletes your guard without noticing. `development.rb` is mounted too and is\n  where every other app-level config override on a Leo box lives.\n- **Editing a single-file-mounted config can silently no-op.** Some editors write a file\n  by replacing it, which swaps the host inode and detaches it from the container mount —\n  the host file changes, the running app keeps reading the old one. After editing\n  `development.rb`, always confirm the change is really inside the container:\n  `docker compose exec llamapress grep -n interceptor /rails/config/environments/development.rb`.\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- **Several developers on `to` can see each other.** That is normally fine — they are\n  teammates. If it is not, deliver to one address and `bcc` the rest, but then remember\n  your own guard will strip the `bcc` on the next pass unless you set it after the\n  rewrite.\n- **One list, one reader.** Parse the env var in exactly one method and let the banner,\n  the flash message and the interceptor all call it. Two copies of \"is the guard on?\"\n  drift, and the version that drifts is always the one on the screen telling a human\n  something reassuring and false.\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)\n                                                  # app/services/ is equally fine — pick one\nconfig/environments/development.rb                # the to_prepare registration block\napp/views/layouts/_email_safe_mode_banner.html.erb # \"no mail reaches real people\" banner (new)\napp/views/layouts/application.html.erb            # one render call for the banner\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 real inboxes 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 and\n   the body notice — those are the parts you read every day.\n4. **Reword the notice** to match how your team talks. The three facts it must carry are:\n   who it would have gone to, that nobody at that address received it, and that this is\n   the development guard rather than a bug.\n5. **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.\n6. **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.\n7. **Point the safelist at the people doing the testing**, not at a shared alias. On a\n   team build that is every developer's own address. During a client demo, add the\n   client's address too, so they can see the emails their app produces without a single\n   one leaving the building.\n"}