Howdy Mail
howdy_mail is an optional package for sending email from a Howdy app. It
gives you:
- a builder for messages, written with smail templates or plain HTML and text
- checks that run before anything is sent, so user input cannot add headers
- adapters for SMTP, Resend and SendGrid, and a way to write your own
- a development outbox and previews that keep mail instead of sending it, and show it in the admin area
Howdy Auth builds its ready-made emails on it; see Sending the email.
Install
Add the package to gleam.toml, next to howdy and smail:
howdy_mail = { git = "https://github.com/mikeyjones/howdy.git", ref = "v2.0.0", path = "mail" }
smail = ">= 2.0.1 and < 3.0.0"
Writing a message
A message starts empty and is built up with a pipeline:
import howdy/mail
import smail/attribute
import smail/email
import smail/html
pub fn welcome(user: User) -> mail.Message {
mail.message()
|> mail.to([mail.named(user.name, user.email)])
|> mail.subject("Welcome to Acme")
|> mail.template(
email.html([], [
email.head([], []),
email.body([], [
email.preview("Your account is ready"),
email.container([], [
email.paragraph([], [html.text("Hi " <> user.name <> ",")]),
email.button([attribute.href("https://acme.test/start")], [
html.text("Get started"),
]),
]),
]),
]),
)
|> mail.tag("onboarding.welcome")
}
Keep functions like welcome apart from the code that sends them. The
previews in the admin area call them with sample
data.
The body
-
mail.template(message, element)renders a smail element to HTML, and to plain text for mail clients that do not show HTML. -
mail.text(message, text)sets the plain text yourself, and wins over the texttemplatemade. -
mail.html(message, html)takes HTML as it is, without smail.
Write the text with mail.text when it carries a link that matters. smail
can run the block after an email.button into the button's URL, which
breaks the link for anyone reading the text version. The admin's previews
show the text, so you can check.
Addresses
-
mail.address("ada@example.com")is an address on its own. -
mail.named("Ada Lovelace", "ada@example.com")shows the name too, asAda Lovelace <ada@example.com>. -
mail.parse_address("Ada <ada@example.com>")reads one a person wrote, and returnsError(Nil)when it is not an address.
Addresses must be plain ASCII local@domain mailboxes. Addresses with other
characters need a feature not every server offers, so they are refused for
now.
Everything else
-
mail.from(message, address)sends from this address instead of the mailer's default. -
mail.to,mail.ccandmail.bcctake a list of addresses.Bccrecipients are hidden from the others. -
mail.reply_to(message, address)sets where replies go. -
mail.header(message, name, value)adds a header, such asList-Unsubscribe. -
mail.tag(message, tag)labels the message. Providers that support tags or categories receive them, and the outbox shows them. -
mail.attach(message, attachment)adds a file.
Functions that take a list add to what was there, so
mail.to([a]) |> mail.to([b]) sends to both.
Attachments
let invoice =
mail.attachment(
filename: "invoice.pdf",
content_type: "application/pdf",
content: pdf,
)
let logo =
mail.attachment(filename: "logo.png", content_type: "image/png", content: png)
|> mail.inline("logo")
message
|> mail.attach(invoice)
|> mail.attach(logo)
mail.inline shows an image inside the HTML body instead of listing it as
a download. The HTML refers to it as <img src="cid:logo">.
Sending
A Mailer holds an adapter and the sender used by messages that do not
name their own:
import howdy/mail
import howdy/mail/smtp
pub fn mailer() -> mail.Mailer {
let assert Ok(config) = smtp.from_env()
mail.mailer(smtp.adapter(config))
|> mail.default_from(mail.named("Acme", "hello@acme.test"))
}
Build the mailer once when the app starts, and pass it to the services that send mail. Then send:
import logging
case mail.send(mailer, welcome(user)) {
Ok(_receipt) -> Nil
Error(error) ->
logging.log(logging.Warning, "welcome: " <> mail.error_to_string(error))
}
send waits for the provider to accept or refuse the message, so a refusal
comes back as an error rather than a bounce.
Sending is synchronous
send runs in the calling process. It opens the SMTP connection or makes
the HTTP request, and returns when the provider answers or the adapter's
timeout passes. A request handler that sends mail is held for that long,
and there is no queue in the package: an Unavailable error is returned to
the caller, not tried again later. That is fine for a sign-in link, where
the person is waiting for it anyway, and for a local Mailpit.
Where the wait matters, or a temporary failure must not lose the message, you have two choices:
-
The SMTP adapter can try again itself:
smtp.retries(config, 2)retries a temporary failure twice, waitingsmtp.backoff(500 ms by default, doubling each time) in between. The caller is held for every attempt, so this suits a job or a background process better than a request. -
Record the message in a table of your own and send it from a job that
runs outside the request, using
mail.retryable(error)to decide what to try again. The outbox is a sink for reading mail in development and tests, not that queue.
The Receipt holds the message's own id, which is also in its
Message-ID header, and the provider_id the provider gave it, if any.
Checks
Before any adapter sees a message, send checks that it has:
- a sender, its own or the mailer's default
- at least one recipient
- a subject
- a body: HTML, text or both
- addresses that are addresses
- no line break in the subject, a display name, a header or a tag
The last check means text from a form can go into a subject or a name
without letting anyone add headers. header also refuses the headers the
package writes itself, such as From, To, Subject, Date and
Content-Type: use their own functions instead.
A message that fails is Error(mail.Invalid(reason)), and nothing is sent.
mail.prepare(mailer, message) runs the same checks and applies the
mailer's defaults, without sending. It returns the mail.Outgoing an
adapter would receive, which is how previews show a message as it would go
out.
Errors
-
Invalid: the message is wrong, such as a missing sender, recipient, subject or body, or a bad address or header. Sending it again will not help. -
Unavailable: the provider could not be reached or asked to be tried later, such as a timeout, an SMTP4xx, or an HTTP429or5xx. Worth retrying. -
Refused: the provider refused it for good, such as bad credentials, an unverified sender, or an SMTP5xx. Do not retry.
mail.retryable(error) says whether trying again might work.
mail.error_to_string(error) describes the error for your logs.
In a service, turn the error into a service
error with mail.to_service_error:
import gleam/result
import howdy/mail
import howdy/service
pub fn invite(mailer, email: String) -> service.Result(Nil) {
use address <- result.try(
mail.parse_address(email)
|> result.replace_error(service.Invalid("not an email address")),
)
invitation(address)
|> mail.send(mailer, _)
|> result.map_error(mail.to_service_error)
|> result.replace(Nil)
}
Invalid becomes service.Invalid, a 400 that explains itself.
Unavailable and Refused become service.Internal, which is logged but
not shown, so provider details never reach the client.
