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,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),
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user