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