marathon
v0.1.0Provider-agnostic email composition + delivery (SMTP, Mailgun) for March
$ forge add marathonMarathon
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
endThen 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:
| Adapter | cfg shape | Notes |
|---|---|---|
"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_responsereads 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.