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
+21
View File
@@ -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
+47
View File
@@ -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");
}
}
+108
View File
@@ -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),
}
+64
View File
@@ -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");
}
}