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:
co-authored by
Claude Opus 5
parent
428040d964
commit
36b15ddcaf
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user