Adapters
An adapter is what delivers a message. howdy_mail comes with four:
-
howdy/mail/smtpdelivers over SMTP, to your provider's relay or a local Mailpit. Configure it withsmtp.from_env(), which readsSMTP_URL, orsmtp.from_url(url). -
howdy/mail/resenddelivers with Resend's HTTP API. Configure it withresend.from_env(), which readsRESEND_API_KEY, orresend.new(key). -
howdy/mail/sendgriddelivers with SendGrid's v3 Mail Send API. Configure it withsendgrid.from_env(), which readsSENDGRID_API_KEY, orsendgrid.new(key). -
howdy/mail/outboxdelivers nowhere: it keeps the message. See Outbox and previews.
Each one gives you an adapter to put in a mailer:
import howdy/mail
import howdy/mail/resend
pub fn mailer() -> mail.Mailer {
let assert Ok(config) = resend.from_env()
mail.mailer(resend.adapter(config))
|> mail.default_from(mail.named("Acme", "hello@acme.test"))
}
The rest of your app only sees the Mailer, so changing provider changes
one function.
SMTP
import howdy/mail/smtp
let assert Ok(config) = smtp.from_env()
let adapter = smtp.adapter(config)
SMTP_URL looks like smtp://user:password@smtp.example.com:587.
Percent-encode an @ in the user name as %40. from_env returns
Error(smtp.MissingUrl(variable: "SMTP_URL")) when it is unset, and
Error(smtp.InvalidUrl) when it cannot be read. The URL is not repeated in
the error, because it may hold a password.
The scheme chooses the encryption:
-
smtps://is TLS from the first byte, on port 465 unless the URL gives another. -
smtp://connects in plain text and requires an upgrade withSTARTTLS, on port 587 unless the URL gives another. A server that does not offer the upgrade is refused rather than used unencrypted. -
smtp://to a loopback address, such aslocalhost, or to a name without a dot, such as a Compose service calledmailpit, has TLS off.
Certificates are checked against the system's CAs and the host name. Add
?tls=none, ?tls=starttls or ?tls=implicit to the URL to choose
yourself.
The configuration can also be built in code:
smtp.new("smtp.example.com")
|> smtp.port(2525)
|> smtp.credentials(username: "apikey", password: password)
|> smtp.tls(smtp.StartTls(verify: True))
|> smtp.timeout(5000)
smtp.tls takes smtp.NoTls, smtp.StartTls(verify:) or
smtp.ImplicitTls(verify:). Only turn verify off for a server you
control. Without credentials, no authentication is tried. timeout is
how long to wait for the connection to open, 10 seconds by default (once
connected, each reply is waited for up to gen_smtp's own limit of twenty
minutes), and helo sets the name this host gives in EHLO.
Each send opens a connection, delivers one message and closes it, in the
process that called mail.send. An SMTP 4xx reply is Unavailable, and
a 5xx is Refused.
A temporary failure, which is an Unavailable one, can be tried again
before it is returned:
smtp.from_env()
|> result.map(smtp.retries(_, 2))
|> result.map(smtp.backoff(_, 1000))
retries is how many more attempts to make, 0 by default, and backoff
is how long to wait before the first retry in milliseconds, 500 by default;
each retry after it waits twice as long. A Refused message is never tried
again. Every attempt happens in the calling process, so a send can take up
to retries + 1 times timeout, plus the waits in between: see
"Sending is synchronous" in the overview.
Resend
import howdy/mail/resend
let assert Ok(config) = resend.from_env()
let adapter = resend.adapter(config)
The sender's domain must be verified with Resend. Each message's id is sent
as the Idempotency-Key, so retrying the same message after a timeout does
not send it twice.
Resend only allows letters, digits, _ and - in tags, so other
characters in a mail.tag become _: auth.sign_in arrives as
auth_sign_in.
SendGrid
import howdy/mail/sendgrid
let assert Ok(config) = sendgrid.from_env()
let adapter = sendgrid.adapter(config)
SendGrid needs at least one To recipient, and refuses an address that
appears twice, so an address already in To is dropped from Cc and
Bcc. Tags are sent as categories, of which SendGrid keeps the first ten.
The message's id is sent as the custom argument howdy_id.
For the EU region, change the base URL:
sendgrid.new(api_key)
|> sendgrid.base_url("https://api.eu.sendgrid.com")
Both HTTP adapters wait 15 seconds by default; change it with
resend.timeout or sendgrid.timeout, in milliseconds. A 408, 429 or 5xx
response is Unavailable, and any other failure is Refused.
Staging and fallbacks
Adapters wrap other adapters.
mail.redirect_all sends everything to one inbox, for a staging server
that must not mail real people:
smtp.adapter(config)
|> mail.redirect_all(to: mail.address("staging@acme.test"))
Cc and Bcc are dropped, and the intended recipients are kept in an
X-Original-To header.
mail.fallback tries a second adapter when the first is Unavailable:
mail.fallback(resend.adapter(resend_config), smtp.adapter(smtp_config))
A message the first adapter Refused is not tried again, since the second
would most likely refuse it too.
Writing your own
An adapter is a name and a function from a checked mail.Outgoing to a
mail.Receipt:
import gleam/option.{Some}
import howdy/mail
import howdy/mail/mime
pub fn adapter(config: Config) -> mail.Adapter {
mail.adapter(named: "Postmark", send: fn(outgoing: mail.Outgoing) {
// outgoing.from, .to, .cc, .bcc, .reply_to, .subject, .html, .text,
// .headers, .attachments, .tags, .id and .date are all there.
// mime.encode(outgoing) is the raw message, for APIs that take one.
case post(config, outgoing) {
Ok(provider_id) -> Ok(mail.Receipt(outgoing.id, Some(provider_id)))
Error(Timeout) -> Error(mail.Unavailable("Postmark timed out"))
Error(Rejected(reason)) -> Error(mail.Refused(reason))
}
})
}
The Outgoing has already passed every check, and has the mailer's
default sender. Return Unavailable for anything worth retrying, and
Refused for the rest, so mail.retryable and mail.fallback work.
howdy/mail/mime writes the message as the text an SMTP server receives:
mime.encode(outgoing) gives the whole of it, headers and body, which is
also what providers such as Amazon SES take as a raw message. Bcc
recipients are left out of it, since they belong only in the envelope;
mail.recipients(outgoing) lists every recipient for that.
mail.deliver(adapter, outgoing) hands an Outgoing to another adapter,
for adapters that wrap one.
Testing a provider
resend.request and sendgrid.request build the HTTP request the adapter
would send, and resend.receipt and sendgrid.receipt read a response, so
you can test what your messages look like to a provider without the
network:
import gleam/http/request
import howdy/mail
import howdy/mail/resend
pub fn welcome_request_test() {
let assert Ok(outgoing) = mail.prepare(mailer(), welcome(user()))
let assert Ok(req) = resend.request(resend.new("re_test"), outgoing)
assert request.get_header(req, "idempotency-key") == Ok(outgoing.id)
}
To test the flows that send mail, use the outbox instead.
