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
+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));
}
}