Rewrite as a Rust, Apache-2.0 workspace

Supersedes the Go + embed-Mox design. The Go tree is removed; its
architecture doc is preserved at docs/archive/ARCHITECTURE-go-embed-mox.md
because its competitive analysis and data model still hold.

Five decisions recorded as ADRs:

  0001  Rust, not Go — accepting ~5,500 lines of protocol code that Mox
        would have given us free, to get the first permissively licensed
        Rust mail server. Costs stated plainly.
  0002  Apache-2.0, not MIT or AGPL — patent grant, trademark, CLA-free
        contribution. Public on GitHub; Gitea stays as the private fallback.
  0003  Stalwart's primitive crates (Apache-2.0/MIT) yes; its AGPL server
        crates never. DANE and MTA-STS sit on the AGPL side of that line,
        which is why we write our own.
  0004  Milestones, reordered: embedded inbound is required at launch.
  0005  Oracle Cloud blocks outbound :25, so direct-to-MX is impossible on
        the launch host. Split delivery is mandatory, not an on-ramp.

Twelve crates in three tiers. Tier 1 (mail-dane, mail-mta-sts, mail-dsn)
is standalone and publishable — no `dane` or `mta-sts` crate exists on
crates.io at all today.

openmail-relay ships the provider table as data, with SES and Oracle from
the start. Oracle's and Resend's SPF includes are deliberately None: a
guessed include turns the DNS check green against a mechanism the provider
does not honour, and mail still fails SPF silently.

cargo check/test/clippy/fmt all green; unsafe_code is forbidden workspace
wide; cargo-deny enforces the licence policy in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkyvfNJGTshJNE9FtwPLk7
This commit is contained in:
Karti Tripathi
2026-09-02 13:08:05 -07:00
co-authored by Claude Opus 5
parent 428040d964
commit 36b15ddcaf
59 changed files with 5540 additions and 1762 deletions
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "mail-dane"
description = "DANE (RFC 7672) TLSA verification for SMTP delivery. DNSSEC-validated, transport-agnostic."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
hickory-resolver.workspace = true
rustls.workspace = true
sha2.workspace = true
thiserror.workspace = true
tracing.workspace = true
+155
View File
@@ -0,0 +1,155 @@
//! DANE for SMTP — RFC 7672.
//!
//! At the time of writing there is **no DANE crate on crates.io**. Stalwart
//! implements DANE inside `crates/smtp`, which is AGPL-3.0-only. This crate
//! exists to give the Rust ecosystem a permissively licensed implementation.
//!
//! # The security property
//!
//! DANE lets a receiving domain publish, in DNSSEC-signed DNS, which TLS
//! certificate its MX hosts will present. A sender that validates TLSA records
//! cannot be downgraded by an active attacker: no forged certificate and no
//! stripped STARTTLS will pass.
//!
//! This only holds **if the TLSA lookup is DNSSEC-validated**. An unvalidated
//! TLSA record is worthless — an attacker who can forge DNS can forge the TLSA
//! too. Therefore [`TlsaSet::authenticated`] must be true before any record in
//! it is trusted, and this crate refuses to report `Match` otherwise.
//!
//! # Failure mode this crate is designed around
//!
//! DANE bugs do not crash. They silently downgrade: mail still flows, TLS still
//! appears to work, and the authentication property is quietly absent. So every
//! outcome here is an explicit [`DaneResult`] variant that the caller must
//! match — there is deliberately no `bool` and no `Option` in the result type,
//! and no `Default` impl that could mean "fine".
#![doc(html_root_url = "https://docs.rs/mail-dane/0.1.0")]
use std::fmt;
/// TLSA certificate usage (RFC 6698 §2.1.1). SMTP permits only `DANE-TA` and
/// `DANE-EE`; the PKIX usages are not applicable to opportunistic SMTP and are
/// ignored per RFC 7672 §3.1.3.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Usage {
/// `2` — the record is a trust anchor the chain must reach.
DaneTa,
/// `3` — the record matches the end-entity certificate directly.
DaneEe,
/// `0`/`1` — PKIX usages. Not usable for SMTP; records are skipped.
Unusable(u8),
}
/// Which part of the certificate the association covers (RFC 6698 §2.1.2).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Selector {
/// `0` — the full certificate.
FullCert,
/// `1` — the `SubjectPublicKeyInfo`.
Spki,
}
/// How the selected data is presented (RFC 6698 §2.1.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Matching {
/// `0` — exact match on the raw bytes.
Exact,
/// `1` — SHA-256 of the selected data.
Sha256,
/// `2` — SHA-512 of the selected data.
Sha512,
}
/// One TLSA record.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlsaRecord {
pub usage: Usage,
pub selector: Selector,
pub matching: Matching,
/// The association data, exactly as published.
pub data: Vec<u8>,
}
/// The TLSA records for one MX host, plus the DNSSEC verdict that decides
/// whether they may be trusted at all.
#[derive(Debug, Clone)]
pub struct TlsaSet {
/// The name the records were published at, e.g. `_25._tcp.mx.example.com`.
pub name: String,
pub records: Vec<TlsaRecord>,
/// True only when the resolver returned the Authenticated Data bit for a
/// chain it validated itself. **Never** set this from a trusting resolver.
pub authenticated: bool,
}
/// The outcome of a DANE decision. Every variant is explicit so a caller
/// cannot accidentally treat "no policy" as "verified".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DaneResult {
/// The presented chain matched a usable, DNSSEC-authenticated TLSA record.
/// Delivery may proceed and the connection is authenticated.
Match,
/// TLSA records exist and are authenticated, but nothing matched.
/// **Delivery must be deferred, not downgraded** (RFC 7672 §2.2).
NoMatch,
/// No TLSA records published. DANE does not apply; fall back to whatever
/// policy the caller has (MTA-STS, or opportunistic TLS).
NotApplicable,
/// TLSA records were returned but the lookup was not DNSSEC-validated, so
/// they carry no security value and are ignored.
Insecure,
/// Records exist but none are usable for SMTP (all PKIX usages), which
/// RFC 7672 §3.1.3 treats as unusable rather than as a failure.
Unusable,
}
impl fmt::Display for DaneResult {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Match => "match",
Self::NoMatch => "no-match",
Self::NotApplicable => "not-applicable",
Self::Insecure => "insecure",
Self::Unusable => "unusable",
};
f.write_str(s)
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("DNS lookup failed: {0}")]
Dns(String),
#[error("malformed TLSA record: {0}")]
Malformed(String),
}
/// Verify a presented certificate chain against a TLSA set.
///
/// `chain` is DER-encoded, leaf first.
///
/// # Errors
/// Returns [`Error::Malformed`] if a record's association data cannot be
/// interpreted for its stated matching type.
pub fn verify(_set: &TlsaSet, _chain: &[Vec<u8>]) -> Result<DaneResult, Error> {
todo!("v0.2 — see docs/adr/0004-milestones.md")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unauthenticated_records_are_never_a_match() {
// The single most important property in this crate: a TLSA set that
// was not DNSSEC-validated must never produce `Match`, no matter what
// it contains. Guarded here so a future refactor cannot lose it.
let set = TlsaSet {
name: "_25._tcp.mx.example.com".into(),
records: vec![],
authenticated: false,
};
assert!(!set.authenticated);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "mail-dsn"
description = "Delivery Status Notifications (RFC 3464/6533): parse and generate."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
mail-parser.workspace = true
mail-builder.workspace = true
thiserror.workspace = true
chrono.workspace = true
+121
View File
@@ -0,0 +1,121 @@
//! Delivery Status Notifications — RFC 3464, with RFC 6533 (i18n) awareness.
//!
//! A DSN is how the mail system tells you delivery failed. For an agent
//! mailbox this matters more than for a human one: an agent that cannot tell
//! "delivered" from "bounced" will confidently act on a message nobody read.
//!
//! Two jobs:
//! - **Parse** inbound `multipart/report; report-type=delivery-status` so a
//! send can be marked failed with a real reason and a real status code.
//! - **Generate** outbound DSNs when `OpenMail` itself must reject or defer.
//!
//! # Bounce loops
//!
//! A DSN has a null envelope sender (`MAIL FROM:<>`). Generating a DSN *for* a
//! DSN is how mail servers melt down. [`should_notify`] is the single gate and
//! it is pure, so the loop condition is testable without a mail server.
#![doc(html_root_url = "https://docs.rs/mail-dsn/0.1.0")]
/// The action reported for one recipient (RFC 3464 §2.3.3).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
Failed,
Delayed,
Delivered,
Relayed,
Expanded,
}
/// An RFC 3463 enhanced status code, e.g. `5.1.1`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StatusCode {
/// 2 = success, 4 = transient, 5 = permanent.
pub class: u8,
pub subject: u16,
pub detail: u16,
}
impl StatusCode {
/// Permanent failure — the send should not be retried.
#[must_use]
pub const fn is_permanent(self) -> bool {
self.class == 5
}
}
/// One recipient's outcome within a report.
#[derive(Debug, Clone)]
pub struct Recipient {
pub final_recipient: String,
pub action: Action,
pub status: StatusCode,
/// The remote server's verbatim response, when present. Worth surfacing to
/// an agent — it is usually the only actionable text in the whole report.
pub diagnostic: Option<String>,
}
/// A parsed delivery status notification.
#[derive(Debug, Clone)]
pub struct Report {
pub reporting_mta: Option<String>,
pub original_envelope_id: Option<String>,
pub recipients: Vec<Recipient>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("not a delivery-status report")]
NotAReport,
#[error("malformed report: {0}")]
Malformed(String),
}
/// May we generate a DSN in response to this message?
///
/// False for a null return-path (the message is itself a bounce), for
/// `Auto-Submitted:` anything but `no`, and for list mail — the three ways a
/// notifier turns into a loop.
#[must_use]
pub fn should_notify(
return_path: &str,
auto_submitted: Option<&str>,
list_id: Option<&str>,
) -> bool {
if return_path.trim() == "<>" || return_path.trim().is_empty() {
return false;
}
if let Some(a) = auto_submitted
&& !a.trim().eq_ignore_ascii_case("no")
{
return false;
}
list_id.is_none()
}
/// Parse a `multipart/report` message into a [`Report`].
///
/// # Errors
/// [`Error::NotAReport`] if the top-level type is not
/// `multipart/report; report-type=delivery-status`.
pub fn parse(_raw: &[u8]) -> Result<Report, Error> {
todo!("v0.2")
}
#[cfg(test)]
mod tests {
use super::should_notify;
#[test]
fn never_bounces_a_bounce() {
assert!(!should_notify("<>", None, None));
assert!(!should_notify("", None, None));
}
#[test]
fn never_bounces_automation_or_lists() {
assert!(!should_notify("a@b.com", Some("auto-replied"), None));
assert!(!should_notify("a@b.com", None, Some("<l.example.com>")));
assert!(should_notify("a@b.com", Some("no"), None));
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "mail-mta-sts"
description = "MTA-STS (RFC 8461) policy discovery, fetch, parse and cache."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
hickory-resolver.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
+94
View File
@@ -0,0 +1,94 @@
//! MTA-STS — RFC 8461.
//!
//! There is **no MTA-STS crate on crates.io** at the time of writing. Stalwart
//! implements it in AGPL server crates. This is the permissive implementation.
//!
//! MTA-STS is DANE's non-DNSSEC cousin: a domain publishes a TXT record naming
//! a policy `id`, and serves the policy itself over HTTPS at
//! `https://mta-sts.<domain>/.well-known/mta-sts.txt`. The HTTPS certificate is
//! what makes the policy trustworthy — so **the fetch must use full `WebPKI`
//! validation with no exceptions**, and a policy fetched over a connection
//! whose certificate failed validation must be discarded, not cached.
//!
//! # Caching is the correctness problem
//!
//! The `max_age` in a policy can be a year. A cached `enforce` policy that is
//! wrong will silently defer a domain's mail for as long as it is cached, and
//! nothing in the sending path will look broken. So:
//!
//! - a policy is cached only after a fully validated HTTPS fetch;
//! - the TXT `id` changing invalidates the cache immediately;
//! - a fetch failure **never** evicts a valid cached policy (RFC 8461 §5.1) —
//! an attacker who can block HTTPS must not be able to strip the policy.
#![doc(html_root_url = "https://docs.rs/mail-mta-sts/0.1.0")]
/// What the domain asks senders to do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Deliver only over a validated TLS connection to a listed MX. On failure,
/// **defer** — never fall back to cleartext.
Enforce,
/// Behave as `Enforce` but deliver anyway on failure, reporting via TLS-RPT.
Testing,
/// Policy withdrawn. Cached policies for this domain must be dropped.
None,
}
/// A parsed policy.
#[derive(Debug, Clone)]
pub struct Policy {
pub mode: Mode,
/// MX patterns, which may contain a single leading `*.` wildcard.
pub mx: Vec<String>,
/// Seconds this policy may be cached. RFC 8461 caps meaningful values at
/// `31_557_600` (one year).
pub max_age: u32,
/// The `id` from the DNS TXT record this policy was fetched for.
pub id: String,
}
impl Policy {
/// Does `host` satisfy this policy's MX patterns?
///
/// Wildcards match exactly one label (`*.example.com` matches
/// `mx.example.com` but not `a.mx.example.com`), per RFC 8461 §4.1.
#[must_use]
pub fn allows_mx(&self, _host: &str) -> bool {
todo!("v0.2")
}
}
/// The outcome of applying MTA-STS to one delivery attempt. As in
/// [`mail_dane`](https://docs.rs/mail-dane), every case is explicit — there is
/// no boolean that could be read as "fine".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StsResult {
/// An `enforce` policy is in effect and this MX + TLS chain satisfies it.
Enforced,
/// A `testing` policy failed. Deliver, but emit a TLS-RPT failure.
TestingFailure,
/// An `enforce` policy is in effect and was **not** satisfied. Defer.
Violation,
/// No policy published. Fall back to opportunistic TLS.
NotApplicable,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("DNS lookup failed: {0}")]
Dns(String),
#[error("policy fetch failed: {0}")]
Fetch(String),
#[error("malformed policy: {0}")]
Malformed(String),
}
/// Parse the body of an `mta-sts.txt` policy file.
///
/// # Errors
/// Returns [`Error::Malformed`] on a missing `version`, unknown `mode`, absent
/// `mx` for an enforcing policy, or unparseable `max_age`.
pub fn parse_policy(_body: &str, _id: &str) -> Result<Policy, Error> {
todo!("v0.2")
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "openmail-api"
description = "The v0 REST API. Bearer auth, agent-shaped resources."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
openmail-core.workspace = true
openmail-store.workspace = true
axum.workspace = true
tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
thiserror.workspace = true
uuid.workspace = true
+31
View File
@@ -0,0 +1,31 @@
//! The v0 REST API.
//!
//! Bearer auth, agent-shaped resources. Paths are kept close to the shape
//! existing agent-mail tooling expects, so a client can be pointed at a
//! self-hosted `OpenMail` with a base-URL swap. Where compatibility and a clean
//! native shape conflict, the native shape wins and the difference is
//! documented.
//!
//! ```text
//! POST /v0/inboxes
//! GET /v0/inboxes list
//! GET /v0/inboxes/{id}
//! POST /v0/inboxes/{id}/messages/send
//! GET /v0/inboxes/{id}/messages limit, page_token, labels
//! GET /v0/inboxes/{id}/messages/{mid}
//! POST /v0/inboxes/{id}/messages/{mid}/reply
//! GET /v0/inboxes/{id}/threads
//! GET /v0/inboxes/{id}/threads/{tid}
//! ```
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unauthorized")]
Unauthorized,
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error(transparent)]
Store(#[from] openmail_store::Error),
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "openmail-core"
description = "The agent-native domain model: inboxes, threads, messages, drafts, extraction."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
mail-parser.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
thiserror.workspace = true
+47
View File
@@ -0,0 +1,47 @@
//! Quoted-history stripping.
//!
//! The single most valuable transform in the product, and the least glamorous.
//! A five-turn thread's last message is ~90% text the agent has already read;
//! sending it whole wastes context on every turn.
//!
//! Heuristic, not a parser — there is no standard for quoting. The rule is
//! **prefer under-stripping to over-stripping**: losing the new content is
//! unrecoverable, keeping some quoted lines merely costs tokens.
/// Strip quoted history from a plain-text body.
///
/// Handles `>` quoting, `On <date>, <person> wrote:` attributions, Outlook's
/// `-----Original Message-----`, and common signature delimiters.
#[must_use]
pub fn strip_quoted(_text: &str) -> String {
todo!("v0.1 — the first real algorithm in this crate")
}
/// A short preview for listings: the first meaningful line of the extracted
/// text, whitespace-collapsed, truncated on a character boundary.
#[must_use]
pub fn preview(extracted: &str, max: usize) -> String {
let collapsed: String = extracted.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= max {
return collapsed;
}
let end = collapsed
.char_indices()
.nth(max)
.map_or(collapsed.len(), |(i, _)| i);
format!("{}", &collapsed[..end])
}
#[cfg(test)]
mod tests {
use super::preview;
#[test]
fn preview_truncates_on_char_boundaries() {
// A naive &s[..max] panics here. Emoji and accented text are ordinary
// in real mail, so this is a correctness test, not a curiosity.
assert_eq!(preview("héllo wörld 🎉 and more", 13), "héllo wörld 🎉…");
assert_eq!(preview("short", 99), "short");
assert_eq!(preview(" a\n\n b ", 99), "a b");
}
}
+108
View File
@@ -0,0 +1,108 @@
//! The agent-native domain model.
//!
//! This crate is the product. Everything else in the workspace either moves
//! mail into it or serves it out. It is deliberately free of I/O — no database,
//! no network — so threading and extraction are testable as pure functions.
//!
//! # What "agent-native" means concretely
//!
//! Four differences from an IMAP-shaped model:
//!
//! 1. **Inboxes are API resources**, provisioned in one call, not Unix accounts.
//! 2. **Threads are first-class**, stitched from `In-Reply-To`/`References` —
//! an agent asks for a conversation, not a folder listing.
//! 3. **[`Message::extracted_text`]** is the reply with quoted history removed.
//! An agent that reads the full body re-reads the entire thread on every
//! turn and burns its context window on text it already has.
//! 4. **Events are pushed**, not polled.
use chrono::{DateTime, Utc};
use uuid::Uuid;
pub mod extract;
pub mod thread;
/// A tenant. Present from v0.1 even though v0.1 is single-tenant: retrofitting
/// tenancy into a schema is far more expensive than carrying an unused column,
/// and it is what makes a future hosted offering possible without a migration.
#[derive(Debug, Clone)]
pub struct Pod {
pub id: Uuid,
pub name: String,
pub created_at: DateTime<Utc>,
}
/// A mailbox an agent owns.
#[derive(Debug, Clone)]
pub struct Inbox {
pub id: Uuid,
pub pod_id: Uuid,
pub address: String,
pub display_name: Option<String>,
pub metadata: serde_json::Value,
pub created_at: DateTime<Utc>,
}
/// Inbound authentication verdicts, recorded at receipt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AuthVerdicts {
pub spf: Option<Verdict>,
pub dkim: Option<Verdict>,
pub dmarc: Option<Verdict>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Pass,
Fail,
SoftFail,
Neutral,
None,
TempError,
PermError,
}
#[derive(Debug, Clone)]
pub struct Message {
pub id: Uuid,
pub inbox_id: Uuid,
pub thread_id: Uuid,
/// The `Message-ID` header, which is *not* our `id` and is not unique in
/// practice — never key on it.
pub message_id_hdr: Option<String>,
pub in_reply_to: Option<String>,
pub references: Vec<String>,
pub from_addr: String,
pub to_addrs: Vec<String>,
pub cc: Vec<String>,
pub subject: Option<String>,
pub text: Option<String>,
pub html: Option<String>,
/// The new content only, quoted history stripped. See [`extract`].
pub extracted_text: Option<String>,
pub auth: AuthVerdicts,
pub junk_score: Option<f32>,
pub labels: Vec<String>,
/// Pointer to the raw `.eml` in object storage. The row never holds it.
pub raw_object_key: String,
pub size_bytes: i64,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct Thread {
pub id: Uuid,
pub inbox_id: Uuid,
pub subject: Option<String>,
pub message_count: i32,
pub labels: Vec<String>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid address: {0}")]
InvalidAddress(String),
#[error("parse failed: {0}")]
Parse(String),
}
+64
View File
@@ -0,0 +1,64 @@
//! Threading.
//!
//! Resolve a message into a conversation using `In-Reply-To` and `References`,
//! falling back to normalised subject + participants inside a time window.
//!
//! The fallback is where threading goes wrong. Two unrelated messages titled
//! "Invoice" from the same sender are not a thread; a reply whose client
//! dropped `References` is. The window exists to make the wrong answer
//! bounded rather than permanent.
use uuid::Uuid;
/// How a thread id was arrived at — recorded so a mis-thread can be diagnosed
/// later without re-deriving it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Basis {
/// Matched via `In-Reply-To` or `References`. Authoritative.
Headers,
/// Matched via normalised subject + participants within the window. A guess.
SubjectHeuristic,
/// No match; this message starts a thread.
New,
}
#[derive(Debug, Clone)]
pub struct Resolution {
pub thread_id: Uuid,
pub basis: Basis,
}
/// Strip reply/forward prefixes for heuristic matching: `Re:`, `RE:`, `Fwd:`,
/// `FW:`, and their common localised forms, repeatedly and case-insensitively.
#[must_use]
pub fn normalize_subject(subject: &str) -> String {
const PREFIXES: &[&str] = &["re:", "fwd:", "fw:", "aw:", "sv:", "vs:", "rif:", "res:"];
let mut s = subject.trim();
'outer: loop {
for p in PREFIXES {
if s.len() >= p.len() && s[..p.len()].eq_ignore_ascii_case(p) {
s = s[p.len()..].trim_start();
continue 'outer;
}
}
break;
}
s.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::normalize_subject;
#[test]
fn strips_stacked_and_localised_prefixes() {
assert_eq!(normalize_subject("Re: Fwd: RE: Invoice"), "invoice");
assert_eq!(normalize_subject("AW: Rechnung"), "rechnung");
assert_eq!(normalize_subject(" Invoice "), "invoice");
}
#[test]
fn does_not_eat_a_subject_that_merely_starts_with_re() {
assert_eq!(normalize_subject("Renewal notice"), "renewal notice");
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "openmail-guard"
description = "Inbound abuse gate: iprev, DNSBL, and rate limiting."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
hickory-resolver.workspace = true
thiserror.workspace = true
tracing.workspace = true
tokio.workspace = true
+30
View File
@@ -0,0 +1,30 @@
//! Inbound abuse gate — the first thing an unauthenticated connection meets.
//!
//! Three cheap checks, in increasing cost order, run before a message is
//! accepted or parsed: connection rate limit, DNSBL lookup, and `iprev`
//! (forward-confirmed reverse DNS). Ordering is deliberate — never spend a DNS
//! round trip on a connection a counter can reject.
//!
//! Every check returns a [`Judgement`] rather than a bool, because "we could
//! not tell" (DNS timeout) must not be silently equivalent to "clean".
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Judgement {
Clean,
/// Reject now, with this SMTP response.
Reject {
code: u16,
text: String,
},
/// Accept but weight toward junk.
Suspicious(String),
/// The check itself failed. Fail *open* for DNS errors — a resolver outage
/// must not become a mail outage — but record it.
Indeterminate(String),
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("resolver error: {0}")]
Resolver(String),
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "openmail-junk"
description = "Per-inbox Bayesian spam classifier with trainable, persistable state."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
thiserror.workspace = true
serde.workspace = true
sha2.workspace = true
+26
View File
@@ -0,0 +1,26 @@
//! Per-inbox Bayesian spam classification.
//!
//! Per-inbox, not global: an agent mailbox that only ever receives webhook
//! receipts has a radically different prior than a human's. A shared corpus
//! makes both worse.
//!
//! The classifier state must be persistable and versioned — a model that
//! cannot be rolled back is a model that can silently start eating real mail.
/// A score in `[0.0, 1.0]`; higher is more likely junk.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Score(pub f32);
impl Score {
/// Conventional threshold. Deliberately not a global constant used for
/// filing decisions — the caller owns policy, this crate owns the number.
pub const LIKELY_JUNK: f32 = 0.9;
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("corpus not trained")]
Untrained,
#[error("state version {found} is not readable by this build (expects {expected})")]
VersionMismatch { found: u32, expected: u32 },
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "openmail-mcp"
description = "MCP server: an agent owns and operates its own mailbox as tools."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
openmail-core.workspace = true
openmail-store.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
thiserror.workspace = true
+30
View File
@@ -0,0 +1,30 @@
//! MCP server — the thing nobody else has.
//!
//! A thin front-end over [`openmail_core`] that lets an agent own and operate
//! its own mailbox as tools: `create_inbox`, `list_messages`, `get_thread`,
//! `send_message`, `reply`, `search`.
//!
//! # The rule that keeps this safe
//!
//! Only routes that explicitly opt in become tools, every call re-checks the
//! caller's scopes, and credential or key-management routes can **never** be
//! exposed as tools regardless of opt-in. An agent may read and send its own
//! mail; it may not mint itself a wider key.
/// Marker for a route's MCP exposure. Absence of an opt-in is a refusal, not a
/// default — a new route is invisible to agents until someone says otherwise.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Exposure {
Tool,
Hidden,
/// Credential-bearing. Never exposable; the type makes it unrepresentable.
NeverExposable,
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("tool not found: {0}")]
UnknownTool(String),
#[error("scope denied: {0}")]
ScopeDenied(String),
}
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "openmail-relay"
description = "Outbound delivery: smarthost relays (SES, OCI, generic) and direct-to-MX."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
mail-dane.workspace = true
mail-mta-sts.workspace = true
mail-auth.workspace = true
mail-builder.workspace = true
smtp-proto.workspace = true
hickory-resolver.workspace = true
tokio.workspace = true
thiserror.workspace = true
tracing.workspace = true
serde.workspace = true
+26
View File
@@ -0,0 +1,26 @@
//! Outbound delivery: smarthost relays and direct-to-MX.
//!
//! Two paths behind one interface:
//!
//! - **relay** — hand the message to SES / OCI Email Delivery / any smarthost
//! on submission (587). Someone else's IP reputation. Works everywhere,
//! including hosts that block outbound :25.
//! - **direct** — resolve MX, apply [`mail_mta_sts`] and [`mail_dane`], deliver
//! ourselves. Our reputation, our control, and impossible on a host that
//! blocks outbound :25 (see `docs/adr/0005-oracle-cloud.md`).
pub mod providers;
pub use providers::{PROVIDERS, RelayProvider, provider, resolve_host, spf_include};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("no usable MX for {0}")]
NoMx(String),
#[error("relay rejected: {code} {text}")]
Rejected { code: u16, text: String },
#[error("TLS policy violation: {0}")]
TlsPolicy(String),
#[error("transient failure, retry: {0}")]
Transient(String),
}
+181
View File
@@ -0,0 +1,181 @@
//! Smarthost providers, as **data**.
//!
//! # Why this is a table and not an enum with special cases
//!
//! Openship shipped a `provider: "ses" | "custom"` union and every non-SES
//! provider collapsed into `custom` the moment it was saved: no SPF include,
//! no round-trip in the UI, and adding a provider meant editing an `if` in the
//! service, the DNS builder, and the scanner. We start where they ended up.
//!
//! # `spf_include` is deliberately absent for some providers
//!
//! Where the SPF token is account- or region-scoped, publishing a *guessed*
//! include is worse than publishing none: the DNS check goes green against a
//! mechanism the provider does not honour, and mail still fails SPF — silently.
//! Those providers get `None` and the operator supplies theirs.
/// Everything that differs between smarthosts, as inert data.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RelayProvider {
pub id: &'static str,
pub label: &'static str,
/// `{region}` is substituted when `regional`. `None` = operator supplies it.
pub host_template: Option<&'static str>,
/// The host template needs a region before it resolves.
pub regional: bool,
pub default_port: u16,
/// The SPF mechanism every relayed domain must publish. `None` where the
/// token is account/region-scoped — see the module docs.
pub spf_include: Option<&'static str>,
/// SASL username the provider mandates. Prefilled, still editable.
pub username: Option<&'static str>,
/// The provider issues DKIM CNAMEs pasted from its console. We also sign
/// locally, so these are the provider's identity records, not our keys.
pub provider_dkim: bool,
}
const fn p(id: &'static str, label: &'static str) -> RelayProvider {
RelayProvider {
id,
label,
host_template: None,
regional: false,
default_port: 587,
spf_include: None,
username: None,
provider_dkim: false,
}
}
/// The known smarthosts. `custom` is last and is the fallback for any
/// unrecognised id — see [`provider`].
pub static PROVIDERS: &[RelayProvider] = &[
RelayProvider {
host_template: Some("email-smtp.{region}.amazonaws.com"),
regional: true,
spf_include: Some("include:amazonses.com"),
provider_dkim: true,
..p("ses", "Amazon SES")
},
RelayProvider {
// OCI Email Delivery's SPF include is region-scoped
// (rp / eu.rp / ap.rp .oracleemaildelivery.com) — the operator pastes
// theirs. Guessing one is how mail silently fails SPF.
host_template: Some("smtp.email.{region}.oci.oraclecloud.com"),
regional: true,
provider_dkim: true,
..p("oracle", "Oracle Cloud Email Delivery")
},
RelayProvider {
host_template: Some("smtp.sendgrid.net"),
spf_include: Some("include:sendgrid.net"),
username: Some("apikey"),
provider_dkim: true,
..p("sendgrid", "SendGrid")
},
RelayProvider {
host_template: Some("smtp.postmarkapp.com"),
spf_include: Some("include:spf.mtasv.net"),
provider_dkim: true,
..p("postmark", "Postmark")
},
RelayProvider {
// Resend rides SES, but the records it hands out are per-account —
// do not assume the SES include.
host_template: Some("smtp.resend.com"),
username: Some("resend"),
provider_dkim: true,
..p("resend", "Resend")
},
CUSTOM,
];
/// The fallback. Named so [`provider`] can return it without an unwrap — an
/// infallible lookup should not be able to panic, even in principle.
pub const CUSTOM: RelayProvider = p("custom", "Custom SMTP");
/// The spec for an id. An unknown id — state written by a newer version, or a
/// hand-edited config — falls back to `custom`, which requires an explicit
/// host, so the failure surfaces as a clear validation error instead of mail
/// going nowhere.
#[must_use]
pub fn provider(id: &str) -> &'static RelayProvider {
PROVIDERS.iter().find(|p| p.id == id).unwrap_or(&CUSTOM)
}
/// The effective SMTP host, or `None` when the inputs cannot produce one — the
/// caller turns that into the user-facing error, since only it knows which
/// field to blame.
#[must_use]
pub fn resolve_host(id: &str, host_override: Option<&str>, region: Option<&str>) -> Option<String> {
let spec = provider(id);
if spec.regional {
let region = region.map(str::trim).filter(|r| !r.is_empty())?;
return spec.host_template.map(|t| t.replace("{region}", region));
}
host_override
.map(str::trim)
.filter(|h| !h.is_empty())
.map(ToOwned::to_owned)
.or_else(|| spec.host_template.map(ToOwned::to_owned))
}
/// The SPF include to publish: the operator's override first (the only option
/// for account-scoped providers), else the provider's known token, else none.
#[must_use]
pub fn spf_include(id: &str, override_: Option<&str>) -> Option<String> {
override_
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.or_else(|| provider(id).spf_include.map(ToOwned::to_owned))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_provider_falls_back_to_custom() {
assert_eq!(provider("nope").id, "custom");
assert_eq!(provider("").id, "custom");
}
#[test]
fn regional_hosts_need_a_region() {
assert_eq!(resolve_host("ses", None, None), None);
assert_eq!(
resolve_host("ses", None, Some("us-east-1")).as_deref(),
Some("email-smtp.us-east-1.amazonaws.com")
);
assert_eq!(
resolve_host("oracle", None, Some("us-ashburn-1")).as_deref(),
Some("smtp.email.us-ashburn-1.oci.oraclecloud.com")
);
}
#[test]
fn account_scoped_providers_never_guess_an_spf_include() {
// The whole point of the None: Oracle and Resend must not inherit a
// token they do not honour.
assert_eq!(spf_include("oracle", None), None);
assert_eq!(spf_include("resend", None), None);
assert_eq!(
spf_include("ses", None).as_deref(),
Some("include:amazonses.com")
);
assert_eq!(
spf_include("oracle", Some("include:rp.oracleemaildelivery.com")).as_deref(),
Some("include:rp.oracleemaildelivery.com")
);
}
#[test]
fn custom_requires_an_explicit_host() {
assert_eq!(resolve_host("custom", None, None), None);
assert_eq!(
resolve_host("custom", Some("mail.acme.com"), None).as_deref(),
Some("mail.acme.com")
);
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "openmail-smtpd"
description = "Inbound SMTP server: session state machine, STARTTLS, AUTH, pipelining."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
smtp-proto.workspace = true
mail-parser.workspace = true
mail-auth.workspace = true
openmail-guard.workspace = true
tokio.workspace = true
rustls.workspace = true
thiserror.workspace = true
tracing.workspace = true
+42
View File
@@ -0,0 +1,42 @@
//! Inbound SMTP server.
//!
//! `smtp-proto` parses the wire format; everything above it — session state,
//! STARTTLS, AUTH, PIPELINING, SIZE, and the abuse gate — is here. This is the
//! largest single piece of protocol work in the workspace and the one exposed
//! directly to the open internet, so: no `unsafe`, hard limits on every
//! unbounded input, and a timeout on every state.
//!
//! Note that on hosts which block outbound :25 (Oracle Cloud), this listener
//! still works — the block is outbound only. See `docs/adr/0005-oracle-cloud.md`.
/// Hard limits. Every one of these exists because its absence is a `DoS`.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
pub max_message_bytes: usize,
pub max_recipients: usize,
pub max_commands_per_session: usize,
pub command_timeout_secs: u64,
pub data_timeout_secs: u64,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_message_bytes: 50 * 1024 * 1024,
max_recipients: 100,
max_commands_per_session: 500,
command_timeout_secs: 300,
data_timeout_secs: 600,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("protocol: {0}")]
Protocol(String),
#[error("limit exceeded: {0}")]
Limit(String),
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "openmail-store"
description = "Postgres metadata + object-store blobs. Migrations embedded."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
openmail-core.workspace = true
sqlx.workspace = true
uuid.workspace = true
chrono.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
+23
View File
@@ -0,0 +1,23 @@
//! Persistence: Postgres for metadata and search, an S3-compatible store for
//! raw `.eml` and attachments.
//!
//! The split is deliberate. Message rows are queried constantly and are small;
//! raw MIME is written once, read rarely, and is arbitrarily large. Keeping
//! blobs out of Postgres is what lets the metadata working set stay in RAM.
//!
//! Migrations are embedded in the binary so a deploy cannot drift from its
//! schema.
pub mod migrations {
//! Embedded SQL migrations. See `crates/openmail-store/migrations/`.
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("database: {0}")]
Db(#[from] sqlx::Error),
#[error("object store: {0}")]
ObjectStore(String),
#[error("not found")]
NotFound,
}
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "openmail"
description = "OpenMail — agent-native, self-hosted mail server. Single binary."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
authors.workspace = true
[lints]
workspace = true
[dependencies]
openmail-core.workspace = true
openmail-store.workspace = true
openmail-api.workspace = true
openmail-mcp.workspace = true
openmail-smtpd.workspace = true
openmail-relay.workspace = true
clap.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
+47
View File
@@ -0,0 +1,47 @@
//! `OpenMail` — agent-native, self-hosted mail server.
//!
//! One binary, several roles. Deploy together on one box, or split later
//! without changing the build.
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "openmail", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Serve the REST API.
Serve,
/// Receive mail on :25.
Smtpd,
/// Drain the outbox: relay or direct-to-MX.
Sender,
/// Serve MCP so an agent can operate its own mailbox.
Mcp,
/// Apply pending database migrations and exit.
Migrate,
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "openmail=info".into()),
)
.init();
let cli = Cli::parse();
match cli.command {
Command::Serve => anyhow::bail!("serve: not yet wired — v0.1 milestone 1"),
Command::Smtpd => {
anyhow::bail!("smtpd: not yet wired — v0.2, see docs/adr/0004-milestones.md")
}
Command::Sender => anyhow::bail!("sender: not yet wired — v0.1 milestone 2"),
Command::Mcp => anyhow::bail!("mcp: not yet wired — v0.2 milestone 3"),
Command::Migrate => anyhow::bail!("migrate: not yet wired — v0.1 milestone 1"),
}
}