howdy/mail
Email for Howdy apps. Build a Message, then send it with a Mailer,
which hands it to an Adapter: SMTP, an HTTP provider, or the
development outbox.
import howdy/mail
import howdy/mail/smtp
import smail/email
import smail/html
let assert Ok(config) = smtp.from_env()
let mailer =
mail.mailer(smtp.adapter(config))
|> mail.default_from(mail.named("Acme", "hello@acme.test"))
mail.message()
|> mail.to([mail.address(user.email)])
|> mail.subject("Welcome to Acme")
|> mail.template(
email.html([], [
email.head([], []),
email.body([], [email.paragraph([], [html.text("Hi!")])]),
]),
)
|> mail.send(mailer, _)
A message is checked before any adapter sees it: it needs a sender (its own or the mailer’s default), at least one recipient, a subject, and an HTML or text body. Addresses, the subject and headers may not contain line breaks, so user input placed in them cannot add headers.
Sending is synchronous
send delivers in the calling process and returns when the adapter has
an answer: it opens the SMTP connection or makes the HTTP request, and
waits for the provider to accept or refuse the message. A request
handler that sends mail is held for that long, up to the adapter’s
timeout, and an Unavailable error is returned rather than tried again.
There is no queue in this package. Where the wait matters, or a
temporary failure must not lose the message, either:
- let the SMTP adapter try again itself, with
smtp.retriesandsmtp.backoff, which still holds the caller for the whole time; or - send it from a background job with
howdy_jobs, queued
in the same transaction as the change the email is about, using
retryableto decide what to try again.message_to_jsonandmessage_decoderstore the message as the job’s arguments:
pub fn send_email() -> jobs.Job(mail.Message) {
jobs.define("send_email",
encode: mail.message_to_json,
decoder: mail.message_decoder(),
)
|> jobs.on_queue("mail")
}
// Queued: jobs.new(send_email(), message) |> jobs.insert(conn)
// Done, by the app's runner:
jobs.handle(runner, send_email(), fn(message, _) {
case mail.send(mailer, message) {
Ok(_) -> jobs.Done
Error(error) ->
case mail.retryable(error) {
True -> jobs.Retry(mail.error_to_string(error))
False -> jobs.GiveUp(mail.error_to_string(error))
}
}
})
The development outbox is only a sink for reading mail in
development and tests, not a queue.
Types
Something that delivers mail: howdy/mail/smtp, howdy/mail/resend,
howdy/mail/sendgrid, howdy/mail/outbox, or one of your own.
pub opaque type Adapter
A mailbox, with an optional display name.
pub type Address {
Address(name: option.Option(String), email: String)
}
Constructors
-
Address(name: option.Option(String), email: String)
A file sent with a message.
pub type Attachment {
Attachment(
filename: String,
content_type: String,
content: BitArray,
content_id: option.Option(String),
)
}
Constructors
-
Attachment( filename: String, content_type: String, content: BitArray, content_id: option.Option(String), )Arguments
- content_type
-
A MIME type such as
application/pdf. - content_id
-
Set by
inline: the id an HTML body refers to ascid:<id>.
Why a message was not sent.
pub type Error {
Invalid(String)
Unavailable(String)
Refused(String)
}
Constructors
-
Invalid(String)The message itself is wrong: a missing sender, recipient or subject, an address that is not one, or a line break in a header. Sending it again will not help.
-
Unavailable(String)The provider could not be reached or asked to be tried later: a timeout, an SMTP
4xx, or an HTTP429or5xx. Worth retrying. -
Refused(String)The provider refused it for good: bad credentials, an unverified sender, or an SMTP
5xx.
An email being written. Every function that takes a list adds to what was there.
pub opaque type Message
A message that passed the checks, with the mailer’s defaults applied. This is what an adapter sends.
pub type Outgoing {
Outgoing(
id: String,
date: timestamp.Timestamp,
from: Address,
to: List(Address),
cc: List(Address),
bcc: List(Address),
reply_to: option.Option(Address),
subject: String,
html: option.Option(String),
text: option.Option(String),
headers: List(#(String, String)),
attachments: List(Attachment),
tags: List(String),
)
}
Constructors
-
Outgoing( id: String, date: timestamp.Timestamp, from: Address, to: List(Address), cc: List(Address), bcc: List(Address), reply_to: option.Option(Address), subject: String, html: option.Option(String), text: option.Option(String), headers: List(#(String, String)), attachments: List(Attachment), tags: List(String), )Arguments
- id
-
Unique to this send, and used in the
Message-IDheader. Providers that take an idempotency key receive it, so a retry of the sameOutgoingis not delivered twice.
What happened to a sent message.
pub type Receipt {
Receipt(id: String, provider_id: option.Option(String))
}
Constructors
-
Receipt(id: String, provider_id: option.Option(String))Arguments
- id
-
The
Outgoingid. - provider_id
-
The provider’s own id for it, if it gave one.
Values
pub fn adapter(
named name: String,
send send: fn(Outgoing) -> Result(Receipt, Error),
) -> Adapter
An adapter for a provider this package does not cover. send receives a
checked Outgoing, and returns Receipt(outgoing.id, provider_id).
howdy/mail/mime builds the raw message for providers that take one.
pub fn adapter_name(adapter: Adapter) -> String
pub fn address_to_string(address: Address) -> String
The address as a person would write it.
pub fn attach(
message: Message,
attachment: Attachment,
) -> Message
pub fn attachment(
filename filename: String,
content_type content_type: String,
content content: BitArray,
) -> Attachment
pub fn default_from(mailer: Mailer, address: Address) -> Mailer
The sender of messages that do not name their own.
pub fn deliver(
adapter: Adapter,
outgoing: Outgoing,
) -> Result(Receipt, Error)
Hand an Outgoing to an adapter directly, as a wrapping adapter does.
pub fn error_to_string(error: Error) -> String
pub fn fallback(primary: Adapter, secondary: Adapter) -> Adapter
Try secondary when primary is Unavailable. A message primary
refused is not retried, since the second provider would refuse it too.
pub fn from(message: Message, address: Address) -> Message
Send from this address instead of the mailer’s default.
pub fn header(
message: Message,
name: String,
value: String,
) -> Message
Add a header, such as List-Unsubscribe. The headers this module writes
itself (From, To, Subject, Content-Type and so on) are refused
when the message is sent: use their functions instead.
pub fn inline(
attachment: Attachment,
content_id: String,
) -> Attachment
Show an attachment inside the HTML body, where <img src="cid:logo">
refers to it, instead of listing it as a download.
pub fn message_decoder() -> decode.Decoder(Message)
Read a message message_to_json wrote.
pub fn message_to_json(message: Message) -> json.Json
The message as JSON, to keep it somewhere durable and send it later,
such as in a background job’s arguments. message_decoder reads it
back. Attachments are included, base64-encoded, which makes the JSON a
third larger than the files: for big files, store them elsewhere and
attach them when the message is sent.
pub fn named(name: String, email: String) -> Address
An address shown with a name, such as Acme <hello@acme.test>.
pub fn parse_address(text: String) -> Result(Address, Nil)
Read someone@example.com, Someone <someone@example.com> or
"Someone, Esq." <someone@example.com>.
pub fn prepare(
mailer: Mailer,
message: Message,
) -> Result(Outgoing, Error)
Check a message and apply the mailer’s defaults, without sending it.
send does this first; previews use it to show a message as it would
go out.
pub fn redirect_all(
adapter: Adapter,
to address: Address,
) -> Adapter
Send everything to one address instead, for a staging server that must
not mail real people. Cc and Bcc are dropped, and the intended
recipients are kept in an X-Original-To header.
pub fn send(
mailer: Mailer,
message: Message,
) -> Result(Receipt, Error)
Check the message, then give it to the mailer’s adapter. Waits for the provider to accept or refuse it.
Sending is a mail.send span, with the adapter, the message id, its
tags and how many recipients it has, but not their addresses or the
subject.
pub fn tag(message: Message, tag: String) -> Message
Label the message, such as auth.sign_in. Providers that support tags
or categories receive them, and the development outbox shows them.
pub fn template(
message: Message,
element: html.Element,
) -> Message
Render a smail email to HTML, and to plain
text for mail clients that do not show HTML. text overrides the
generated text.
Check the generated text in the admin’s previews: smail can run the
block after an email.button into the button’s URL, which breaks the
link for text readers. Write the text with text when it carries a link
that matters, as howdy/auth/emails does.
pub fn to_service_error(error: Error) -> service.Error
A service error for a failed send. Only Invalid explains itself to
the client; provider failures become Internal, which is logged and
not shown.
pub fn valid_email(email: String) -> Bool
Whether email looks like an ASCII local@domain mailbox. It does not
check that the mailbox exists. Addresses with non-ASCII characters need
SMTPUTF8, which not every server offers, so they are refused for now.