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