# Letterbox

![Letterbox — Inbound email for Phoenix](https://raw.githubusercontent.com/hattoriai/letterbox/main/media/banner.png)

**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

```sh
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:

```elixir
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`:

```elixir
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.

```elixir
# 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")]
  ]
```

* **Resend** (`Letterbox.Ingress.Resend`): its webhook, signed, and the
  raw email fetched from its API out of the request.
* **A relay** (`Letterbox.Ingress.Relay`): the raw email posted as it is,
  with a token, from Postfix, a Cloudflare Email Worker or a script.
* **Your own** (`Letterbox.Ingress`), or `Letterbox.ingest/2` from your
  code.

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

```elixir
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:

```elixir
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

```elixir
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.1 | [mailroom](https://hex.pm/packages/mailroom) 0.7 | [gen_smtp](https://hex.pm/packages/gen_smtp) 1.3 | [mail](https://hex.pm/packages/mail) 0.5 |
|---|---|---|---|---|
| Webhooks of providers | Yes | No | No | No |
| A relay of raw emails | Yes | No | No | No |
| Polling a mailbox (IMAP, POP3) | Later | Yes | No | No |
| An SMTP server | Later | No | Yes | No |
| Routing to the app's code | Yes | No | No | No |
| Kept, with a status, retried | Yes | No | No | No |
| Charsets beyond UTF-8 and Latin-1 | All of WHATWG's | With a handler of yours | With iconv installed | No |
| RFC 2231 file names, read | Yes | No (mail's parser) | No (written only) | No |
| Attachments streamed to the disk | Yes | No | No | No |
| Senders verified | Yes | No | No | No |
| A development page | Yes | No | No | No |

## 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

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

## License

MIT. See [LICENSE](https://github.com/hattoriai/letterbox/blob/main/LICENSE).
