Letterbox — Inbound email for Phoenix

Inbound email for Phoenix: emails to your app, kept, read and given to your code.

Replies to the notifications your app sends, a support address, receipts forwarded to be filed, photographs emailed to a library. An email comes in through your provider or a relay, Letterbox keeps it, reads it (its text, its HTML, its attachments, who sent it) and gives it to the mailbox your routes name.

Early release. Before 1.0, a minor version may change the API.

Install

mix igniter.install letterbox

or add {:letterbox, "~> 0.1"} and run mix letterbox.install. It writes the migration of Letterbox's table, the webhooks' route, the config, your routes and a first mailbox, and shows each change before it writes (see Mix.Tasks.Letterbox.Install). Letterbox needs Elixir 1.18+, Phoenix 1.7.14+ and Storehouse, which keeps the raw emails in your store.

Your first mailbox

Routes say which mailbox takes which message:

defmodule MyApp.Mailboxes do
  use Letterbox.Routing

  route "support@", to: MyApp.SupportMailbox
  route ~r/\Areply\+/, to: MyApp.RepliesMailbox
  route "@in.example.com", to: MyApp.SitesMailbox
  route :all, to: MyApp.Mailboxes.Inbox
end

A mailbox gets a Letterbox.Message:

defmodule MyApp.SupportMailbox do
  use Letterbox.Mailbox

  @impl true
  def process(message) do
    MyApp.Tickets.open(message.from, message.subject, message.text)
  end
end

:ok delivers it; {:bounce, reason} refuses it for good; an error or an exception tries it again, then marks it failed. Every message has a row (Letterbox.Inbound) with its status, and its raw email in your store, so a failed one can be retried once the bug is fixed.

How emails reach your app

Your domain's mail goes to a provider, or a server of yours, which hands it to Letterbox: that is an ingress. Point a domain's MX records at it once (in.example.com), and every address there reaches your routes.

# config/runtime.exs
config :letterbox,
  ingresses: [
    resend: [secret: System.fetch_env!("RESEND_WEBHOOK_SECRET"), api_key: System.fetch_env!("RESEND_API_KEY")],
    relay: [token: System.fetch_env!("LETTERBOX_RELAY_TOKEN")]
  ]

The webhooks are a plain route of your router, yours to move and rename (Letterbox.Plug):

scope "/inbound" do
  pipe_through :webhooks
  post "/webhooks/:ingress", Letterbox.Plug, []
end

A signed webhook needs its body as it was sent: the installer gives your endpoint's Plug.Parsers body_reader: {PhoenixMountable.RawBody, :read_body, [MyAppWeb.Router]}, which keeps it wherever you put the route.

Attachments

Nothing is decoded until you ask, and a large file is written to the disk as it is decoded, never whole in memory:

for attachment <- message.attachments, String.starts_with?(attachment.content_type, "image/") do
  Letterbox.Attachment.store(attachment,
    accept: Storehouse.Check.images(),
    owner: "sites/#{site.id}",
    room: Media.room(site),
    existing: &Media.by_checksum(site, &1)
  )
end

store/2 keeps it with Storehouse, checked by its bytes, under your limits, and gives back a file you have already (:existing) rather than a copy. :inline? says whether the HTML shows it in its text, as a phone does with the photographs of its body, and message.file_links the files sent as links (Mail Drop, Google Drive, …).

The development page

import Letterbox.Dev.Router

if Application.compile_env(:my_app, :dev_routes) do
  scope "/dev" do
    pipe_through :browser
    letterbox_dev "/letterbox"
  end
end

At /dev/letterbox: write an email to your app, with attachments, and see what became of it, its mailbox, its status, its text and HTML (in a frame that runs nothing and loads nothing from elsewhere), its files and its raw email; retry it, delete it.

What you get

  • Email as mail clients write it. A MIME parser of Letterbox's own: every charset of the WHATWG Encoding Standard (Latin-1 read as Windows-1252, as browsers do), encoded words split across lines, file names of RFC 2231, LF as well as CRLF, multiparts that never end. It copies nothing: each part is a sub-binary of the email.
  • Who sent it. message.from is what the email says; verified_from is the sender a mail server you trust checked (DMARC, DKIM, SPF in its Authentication-Results).
  • Where it went. The envelope's recipients, a Bcc included, and the one the route matched, site+abc123@, whose sub-address (Letterbox.Address.tag/1) can say whose it is.
  • Work out of the request. On Oban when your app runs it, else a process of Letterbox's, with retries; waiting messages are resumed after a restart, and old ones deleted (Letterbox.Sweeper).
  • Tests without a provider. Letterbox.Test.deliver/2 goes the whole way in the test's process; message/2 and mailbox_for/2 test a mailbox and your routes on their own.
  • Telemetry of each message kept and processed.

How Letterbox compares

The Elixir libraries that receive email, as their documentation and code describe them in October 2026. A correction is welcome as an issue.

Letterbox 0.1mailroom 0.7gen_smtp 1.3mail 0.5
Webhooks of providersYesNoNoNo
A relay of raw emailsYesNoNoNo
Polling a mailbox (IMAP, POP3)LaterYesNoNo
An SMTP serverLaterNoYesNo
Routing to the app's codeYesNoNoNo
Kept, with a status, retriedYesNoNoNo
Charsets beyond UTF-8 and Latin-1All of WHATWG'sWith a handler of yoursWith iconv installedNo
RFC 2231 file names, readYesNo (mail's parser)No (written only)No
Attachments streamed to the diskYesNoNoNo
Senders verifiedYesNoNoNo
A development pageYesNoNoNo

Roadmap

  • Replies: the new words of a reply without the quoted email, in many languages and mail apps; auto-replies told apart; signed reply addresses; threads; answers sent with Swoosh that come back.
  • More ways in: Postmark, Amazon SES, Mailgun and Cloudflare; bounces matched to what was sent; forwarded emails and their first sender.
  • An SMTP server in the app (gen_smtp), and an email shown safely as a component of your own pages.
  • People's own mailboxes over IMAP, Gmail and Microsoft Graph.
  • Japanese, Chinese and Korean charsets of their own; until then, Letterbox.Charset takes a module of yours.

Development

mix deps.get
DATABASE_URL=postgres://postgres:postgres@localhost/letterbox_test mix test
mix credo --strict

License

MIT. See LICENSE.