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