Outbox and previews
While you develop, you want to read the mail your app sends, not deliver it. The outbox keeps every message, and the admin area shows them as they arrive. Previews show each of your emails built from sample data, without running the flow that sends it.
The outbox
howdy/mail/outbox is an adapter that keeps messages instead of sending
them:
-
outbox.start()keeps them in memory, until the VM stops. -
outbox.start_in("tmp/mail")also writes each one to the directory, creating it if needed, and reads back what is already there when it starts.
With start_in, each message is an .eml file, which any mail client
opens, and a .json file the outbox reads after a restart. Add the
directory to .gitignore: it holds sign-in tokens and whatever else your
app sent.
Only the newest 500 messages are kept. Older ones, and their files, are removed.
Both start functions link the outbox to the caller. In an app with a
supervision tree, outbox.supervised() and
outbox.supervised_in(directory) return the handle and a child to add to
the supervisor instead, so a crashed outbox is restarted: one in memory
starts empty again, and one with a directory reads its files back. While
the outbox is not running, sending to it is mail.Unavailable, like any
provider that is away, and outbox.messages and outbox.clear return the
same error rather than crashing the caller.
In development
Start the outbox in your dev/ entry point, and give the app a mailer that
uses it. Register the outbox and your previews with the admin area from
howdy_admin to read them at /_howdy:
// dev/my_app_dev.gleam
import gleam/erlang/process
import howdy/admin
import howdy/dev
import howdy/mail
import howdy/mail/outbox
import my_app
pub fn main() {
let db = my_app.open_database()
let assert Ok(box) = outbox.start_in("tmp/mail")
let mailer =
mail.mailer(outbox.adapter(box)) |> mail.default_from(my_app.sender)
let assert Ok(_) =
dev.start(fn() {
my_app.app(db, mailer)
|> admin.mount(
admin.new()
|> admin.mail(box)
|> admin.mail_previews(my_app.previews(), send_with: mailer),
)
})
process.sleep_forever()
}
Start the outbox outside the function dev.start rebuilds the app from,
as here, so it survives reloads.
admin.mail(box) lists what the outbox keeps, and shows each message's
HTML, its plain text, its headers and its raw source. New messages appear
on their own.
The admin area is a dev dependency, so it never reaches a deployment:
[dev_dependencies]
howdy_dev = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.1.0", path = "howdy_dev" }
howdy_admin = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.1.0", path = "admin" }
In production, my_app.app gets a mailer with a real
adapter instead.
Previews
A preview is a name and a function that builds a message from sample data:
import howdy/mail/preview
pub fn previews() -> List(preview.Preview) {
[
preview.new("Welcome", fn() { emails.welcome(sample_user()) }),
preview.new("Invoice", fn() { emails.invoice(sample_invoice()) })
|> preview.in_group("Billing"),
]
}
Previews are listed under "Emails" unless preview.in_group gives another
heading. The admin shows each one as it would be sent, with the mailer's
default sender, and has a button that sends it through the mailer given as
send_with. That mailer really sends, so point it at the outbox or at a
local SMTP server such as Mailpit, never at production.
A preview is built each time it is shown, so with howdy dev an edited
template shows on the next refresh. A template that crashes on its sample
data, or a message send would refuse, shows why instead.
admin.mail_previews can be called more than once, and the previews are
shown together. Howdy Auth has a preview for
every email it sends:
admin.new()
|> admin.mail(box)
|> admin.mail_previews(my_app.previews(), send_with: mailer)
|> admin.mail_previews(emails.previews(auth_emails), send_with: mailer)
In tests
The outbox is also the adapter for tests. Start one in memory, run the flow, and read what it sent:
import howdy/mail
import howdy/mail/outbox
pub fn signup_sends_a_welcome_test() {
let box = outbox.start()
let mailer =
mail.mailer(outbox.adapter(box))
|> mail.default_from(mail.address("hello@acme.test"))
let assert Ok(_) = my_app.sign_up(mailer, "ada@example.com")
let assert Ok(sent) = outbox.latest_to(box, "ada@example.com")
assert sent.subject == "Welcome to Acme"
assert sent.tags == ["onboarding.welcome"]
}
-
outbox.latest_to(box, email)is the newest message with that address among its recipients. -
outbox.messages(box)is every message, newest first, asOk(messages). -
outbox.get(box, id)finds one by its id, which is theidof theReceiptthatsendreturned. -
outbox.clear(box)forgets every message and deletes their files.
messages and clear are Error(mail.Unavailable(_)) only when the
outbox is not running, and get and latest_to are Error(Nil) then.
What the outbox holds is the mail.Outgoing an adapter receives, so a test
sees the message after the checks and the mailer's defaults, exactly as a
provider would.
