← Packages/marathon

marathon

v0.1.0

Provider-agnostic email composition + delivery (SMTP, Mailgun) for March

$ forge add marathon
ReadmeVersionsDependencies

Marathon

Provider-agnostic email composition and delivery for March. Build an Email with a small pipe-based builder, then hand it to one of three adapters — local (dev/test, no network), mailgun (Mailgun's HTTP API), or smtp (a real RFC 5321 client) — through a single uniform Mailer.deliver call.

import Marathon.Email
import Marathon.Mailer

let email =
  Marathon.Email.new()
  |> Marathon.Email.from({ name: "Acme", address: "[email protected]" })
  |> Marathon.Email.to({ name: "", address: "[email protected]" })
  |> Marathon.Email.subject("Confirm your account")
  |> Marathon.Email.text_body("Click here to confirm: ...")

Marathon.Mailer.deliver(email, "local", { base: "", domain: "", key: "", test_mode: false })
-- Ok("local")

Installing into a Bastion app

Marathon is a plain forge.toml path (or, once published, registry) dependency — no Bastion-specific packaging is needed. Add it to your app's forge.toml:

[deps]
bastion  = { path = "/path/to/bastion" }
marathon = { path = "/path/to/marathon" }

Marathon deliberately does not read environment variables itself — Mailer.deliver(email, adapter_name, cfg) takes the adapter name and config explicitly, so your app owns config/env-var resolution. The recommended pattern (used by forgepm's own integration) is a thin glue module in your app, e.g. lib/my_app/mailer.march:

-- Reads MAIL_ADAPTER / MAILGUN_* / SMTP_* from the environment and hands
-- off to Marathon.Mailer.deliver. Swapping providers is a config change,
-- never a call-site change in the rest of the app.
mod MyApp.Mailer do

  import Marathon.Mailer

fn deliver(email) do
  let adapter_name = Env.get("MAIL_ADAPTER", "local")
  Marathon.Mailer.deliver(email, adapter_name, cfg_for(adapter_name))
end

pfn cfg_for(name) do
  -- One superset record shape across every branch — March requires a
  -- function's match arms to construct the same concrete record type,
  -- so build one shape with every adapter's fields, defaulting the ones
  -- the chosen adapter doesn't use.
  match name do
  "mailgun" -> {
      base: Env.get("MAILGUN_BASE", "https://api.mailgun.net"),
      domain: Env.get("MAILGUN_DOMAIN", ""),
      key: Env.get("MAILGUN_KEY", ""),
      test_mode: Env.get("MAIL_TEST_MODE", "false") == "true",
      host: "", port: 0, user: "", pass: "", ca_file: None
    }
  "smtp" -> {
      base: "", domain: "", key: "", test_mode: false,
      host: Env.get("SMTP_HOST", ""),
      port: Env.get_int("SMTP_PORT", 587),
      user: Env.get("SMTP_USER", ""),
      pass: Env.get("SMTP_PASS", ""),
      -- Set to Some("/path/to/ca.pem") if your relay uses a private/
      -- self-signed CA. None (the default here) trusts the system CA store.
      ca_file: None
    }
  _ -> {
      base: "", domain: "", key: "", test_mode: false,
      host: "", port: 0, user: "", pass: "", ca_file: None
    }
  end
end

end

Then call MyApp.Mailer.deliver(email) from wherever your app sends mail — a Bastion controller action, a Conduit background job, a signup flow, etc. Marathon has no dependency on Bastion or any web-framework concept; it's a plain library call.

Usage

Building an `Email`

import Marathon.Email

let email =
  Marathon.Email.new()
  |> Marathon.Email.from({ name: "Acme", address: "[email protected]" })
  |> Marathon.Email.to({ name: "Ada", address: "[email protected]" })
  |> Marathon.Email.cc({ name: "", address: "[email protected]" })
  |> Marathon.Email.bcc({ name: "", address: "[email protected]" })
  |> Marathon.Email.subject("Welcome!")
  |> Marathon.Email.text_body("Plain-text body")
  |> Marathon.Email.html_body("<p>HTML body</p>")
  |> Marathon.Email.reply_to({ name: "Support", address: "[email protected]" })
  |> Marathon.Email.header("X-Campaign", "onboarding")

to/cc/bcc append (call them more than once for multiple recipients). CR/LF is stripped from every name/address/subject/header value at build time, so user-supplied input can never inject extra headers.

Adapters and their `cfg` shapes

Each adapter's deliver(email, cfg) expects a different cfg record — Mailer.deliver just forwards whatever you pass through to the adapter named by adapter_name:

Adaptercfg shapeNotes
"local"any record (ignored)Logs a one-line summary instead of sending. Always Ok("local"). Use for dev/test.
"mailgun"{ base, domain, key, test_mode }base e.g. "https://api.mailgun.net", domain your sending domain, key your API key. test_mode: true has Mailgun validate + return an id without actually delivering.
"smtp"{ host, port, user, pass, ca_file }Real RFC 5321 client: connect, EHLO, STARTTLS, AUTH LOGIN, MAIL FROM/RCPT TO/DATA. ca_file : Option(String)None trusts the system CA store (normal case); Some(path) trusts a specific CA/self-signed cert instead (private relays, or tests against a fake server).
-- Mailgun
Marathon.Mailer.deliver(email, "mailgun", {
  base: "https://api.mailgun.net", domain: "mail.acme.example",
  key: "key-xxxxxxxx", test_mode: false
})

-- SMTP (STARTTLS on 587)
Marathon.Mailer.deliver(email, "smtp", {
  host: "smtp.acme.example", port: 587,
  user: "[email protected]", pass: "s3cret", ca_file: None
})

An unrecognized adapter_name is a hard error (Err("Mailer: unrecognized adapter: <name>")) rather than a silent fallback — a typo'd adapter name surfaces immediately instead of quietly dropping mail.

Known limitations

  • Compiled SMTP delivery currently crashes. Marathon.Adapters.Smtp.deliver's
real-transport path (`Socket.connect` → `STARTTLS` → `Tls.connect` →
`Tls.write`) SIGSEGVs deterministically when run from a `--compile`d
binary, due to a March compiler bug: `lib/tir/llvm_emit.ml` misdeclares
`Tls.write`'s return type. It works correctly under the plain interpreter
(`march file.march`, or `march test`). **Do not rely on the `"smtp"`
adapter from a compiled app until this upstream bug is fixed.** The
`"local"` and `"mailgun"` adapters are unaffected (Mailgun goes over
`Marathon.HttpClient`, an internal HTTP/1.1-over-TLS client, not `Tls.write`
directly).
  • Two SMTP code paths have no automated coverage: deliver_over_fd (the
thin transport-wrapping glue: `Socket.connect` → plaintext `EHLO`/`STARTTLS`
→ `Tls.client_ctx`/`Tls.connect`) and `client_tls_config`'s `Some(ca_file)`
branch. The substantial protocol logic (`EHLO`/`AUTH LOGIN`/`MAIL FROM`/
`RCPT TO`/`DATA`, status-code matching, error propagation) IS covered, via
an `io`-abstraction seam (`{read, write}` closures) that tests drive with
an in-memory scripted fake instead of a real socket — this pivot was
forced by the same compiler bug above (a real-socket-and-TLS test setup
hit it deterministically) and is the recommended long-term design, not a
stopgap: keep it even after the compiler bug is fixed, and add a real
end-to-end smoke test alongside the fake-`io` tests once `Tls.write` is
fixed upstream, rather than collapsing the abstraction back to direct
`Tls.read`/`Tls.write` calls.
  • check_response reads a single response chunk and assumes the full
SMTP status line arrives in it, rather than looping until a true
multi-line-response terminator is seen. Correct for real mail submission
servers in practice (they send each response in one TCP segment); revisit
if delivery flakes against an unusual server.
  • forge test --coverage's reported percentage is not meaningful for this
package.** It has a file-scoping bug (the denominator excludes test-body
expressions; the numerator doesn't), so it reports nonsensical numbers
(e.g. 843%) for a project structured like this one, and cannot report on
`lib/` files at all when invoked via `forge test`. Coverage here was
verified by manual per-function/branch inspection instead — see the
comments in `test/marathon_test.march` for what's covered and why.

License

Not yet decided — set license in forge.toml before publishing.

Downloads0total
0this week
Details
Published bychase
UpdatedJul 07, 2026