Correio

Correio provides transactional email and independent passwordless challenge verification for Gleam applications on Erlang. Applications own templates, accounts, permission decisions, browser policy, and sessions. Delivery and challenge verification remain separate; the application connects them explicitly. It requires Gleam 1.18 or later.

When to use it

Correio is a fit for application-owned email and verification flows. It does not provide an outbox, delivery scheduler, bounce processing, inbound mail, account management, or an authentication server.

Capabilities

Install

Add Correio from Hex:

gleam add correio

Commit gleam.toml and manifest.toml so builds use the same resolved versions. The optional mailbox, PostgreSQL, and Postmark adapters install from Git subdirectories.

How it fits

Message, injected delivery.Sender, and passwordless.Service(a) over a Store(a) are the main contracts. Capture and in-memory state run in explicitly owned, bounded OTP processes; PostgreSQL borrows the application’s named pool. The application chooses when to send a challenge and what to do after verification.

flowchart LR
  app[Application]
  app -->|compose email| message[Validated message]
  message --> sender{delivery.Sender}
  sender --> smtp[SMTP]
  sender --> postmark[Postmark]
  sender --> capture[Capture for tests]
  app -->|issue and verify| auth[Passwordless service]
  auth --> store[Memory or PostgreSQL store]
  auth -->|challenge or verified evidence| app

Message construction does not send mail. This shortened excerpt makes a text and HTML alternative; the complete example adds Cc/Bcc, an inline attachment, a custom header, and an envelope sender. The complete message example supplies imports from correio/address, correio/message and gleam/result.

Both snippets below come from example modules that the repository gate runs.

// Parse addresses with ordinary `Result` handling. A display name is optional.
let assert Ok(from) =
  address.parse("hello@example.com")
  |> result.try(address.named(_, "Correio Store"))
let assert Ok(to) = address.parse("reader@example.com")
let assert Ok(copy) = address.parse("copy@example.com")
let assert Ok(audit) = address.parse("audit@example.com")
let assert Ok(support) = address.parse("support@example.com")
let assert Ok(bounce) = address.parse("bounces@example.com")

let assert Ok(mail) =
  message.new(
    from,
    to,
    "Your order",
    message.Alternative(
      "Your order is ready.",
      "<p>Your order is ready.</p><img src=\"cid:logo\">",
    ),
  )

An injected sender keeps application code independent of its transport. The capture example supplies the capture, delivery and testing imports and lets a test inspect the message produced by the same application flow.

let assert Ok(recipient) = address.parse("reader@example.com")
let assert Ok(other) = address.parse("someone-else@example.com")

// Give each test its own capture so parallel tests cannot see each other.
let assert Ok(outbox) = capture.start(100)
let sender = capture.sender(outbox)

// Mail sent before the checkpoint can never satisfy the helper.
let assert delivery.Accepted(_) = welcome(sender, other)
let assert Ok(checkpoint) = capture.checkpoint(outbox)

let assert delivery.Accepted(_) = welcome(sender, recipient)
let assert Ok(mail) =
  testing.await(checkpoint, matching: testing.to(recipient), timeout_ms: 1000)
assert message.view(mail).subject == "Welcome"
assert message.view(mail).body
  == message.Text("Open https://app.example.test/start to begin.")

// Awaiting mail that never arrives is a bounded, typed failure.
assert testing.await(checkpoint, matching: testing.to(other), timeout_ms: 50)
  == Error(testing.TimedOut)
assert testing.await(checkpoint, matching: testing.to(other), timeout_ms: -1)
  == Error(testing.InvalidTimeout)

// Inspection never consumes: since() returns the post-checkpoint messages.
let assert Ok(after) = capture.since(checkpoint)
assert after != []
let assert Ok(Nil) = capture.stop(outbox)

The welcome function in the runnable example is application code that accepts a delivery.Sender and constructs its message.

Passwordless verification

The application supplies a stable secret of 32–4096 cryptographically random bytes across restarts and a store for its native subject type. passwordless.issue returns a challenge and answer; it does not send email. The application delivers the answer, verifies it with the current trusted destination, applies its own account policy, and publishes its own session. See the memory example for issue, verify, refusal, revocation, and recovery, or the PostgreSQL example for explicit migration and cleanup.

Packages and examples

For Postmark, add token redaction before starting the caller-owned HTTP client. Recordings also retain message bodies and addresses unless you enable body redaction; see the adapter README for details.

Important behavior

Development

Run the repository gate with a local scripted SMTP/HTTP peer and disposable PostgreSQL:

nix develop -c scripts/check

The design defines contracts and ownership. Benchmarks are historical single-host observations without performance thresholds.

Code is licensed under Apache 2.0.

✨ Search Document