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
- Send transactional email through an injected sender, with SMTP or the optional Postmark adapter.
- Add magic-link or email-code verification to an existing application while keeping its account and session model.
- Test the application’s real email flow without a provider, or inspect captured mail in a development-only inbox.
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
- Typed address and message values with text or HTML bodies, attachments, recipient roles, headers, and a separate delivery envelope.
- MIME rendering and message admission before sending.
- Bounded SMTP submission with STARTTLS or implicit TLS, and an optional Postmark adapter.
- A common
delivery.Senderfunction and distinctAccepted,NotSent,Rejected, andOutcomeUnknownresults. - Bounded capture with checkpoints and timed matching helpers for testing application email flows.
- An optional read-only HTTP handler for inspecting a capture in a development environment.
- Magic-link and email-code challenges bound to an application subject, purpose, scope, and destination.
- Opaque verification evidence and recovery handles for uncertain challenge operations.
- In-memory challenge storage or an optional PostgreSQL adapter over a caller-owned pool.
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
correio_postmarksubmits through Postmark. Its usage test uses a local scripted peer and dummy token.correio_postgresprovides durable challenge storage through a caller-owned named Pog pool.correio_mailboxreads captured messages; the application owns the HTTP server and access control.passwordless_demois a local browser example, not a production authentication application.- Runnable usage examples cover delivery outcomes, capture, the development mailbox, and passwordless verification.
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
Acceptedmeans the provider accepted the submission; it does not confirm inbox delivery.OutcomeUnknownmeans submission may have occurred, so do not retry it automatically.- Captured messages can contain login links, codes, addresses, and blind recipients. Mount the mailbox only in development and put it behind application access control.
- Memory storage is bounded and disappears on restart. The PostgreSQL adapter requires explicit migration and cleanup; the application owns the pool.
- Keep a challenge operation’s recovery handle when its outcome is unknown and resolve that operation through
recover_issue,recover_verify, orrecover_revoke.
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.