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:

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

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 as cid:<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 HTTP 429 or 5xx. Worth retrying.

  • Refused(String)

    The provider refused it for good: bad credentials, an unverified sender, or an SMTP 5xx.

An adapter and the defaults applied to every message.

pub opaque type Mailer

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

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 Outgoing id.

    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(email: String) -> Address

An address without a display name.

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 bcc(message: Message, addresses: List(Address)) -> Message

Recipients the others do not see.

pub fn cc(message: Message, addresses: List(Address)) -> Message
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 html(message: Message, html: String) -> Message

Use this HTML as it is, without smail.

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 mailer(adapter: Adapter) -> Mailer
pub fn mailer_adapter(mailer: Mailer) -> Adapter
pub fn message() -> Message

An empty message.

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 recipients(outgoing: Outgoing) -> List(Address)

Every recipient, for the SMTP envelope.

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 reply_to(message: Message, address: Address) -> Message
pub fn retryable(error: Error) -> Bool

Whether trying again later might work.

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 subject(message: Message, subject: String) -> Message
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 text(message: Message, text: String) -> Message

The plain text body.

pub fn to(message: Message, addresses: List(Address)) -> Message
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.

✨ Search Document