Scaffold tier 2 and 3, the schema, the website, and self-hosted-first

Store: the full Postgres schema as an embedded migration. UUIDv7 keys so
`ORDER BY id` is a free chronological index; raw MIME and attachments live
in object storage with only a key in the row; `pods` present from day one
because retrofitting tenancy costs more than an unused column. API keys are
stored as a SHA-256 hash — a database dump must not be a set of live
credentials.

API: the v0 route table, including `ingest`, which closes the
receive→thread→extract loop with zero mail infrastructure and is what makes
the agent layer testable in CI. Scopes are a closed enum rather than
strings, so "can send mail" and "can mint keys" are not one typo apart.
Internal errors are logged in full and reported as a bare string.

MCP: the tool catalogue, six tools. Adding a row here is the only way an
agent gains a capability — a new REST route is invisible until someone opts
it in. Three tests guard the rule that no tool can ever reach key
management; CI fails rather than production.

ADR 0006: enterprise self-hosted first. A hosted offering comes only after
we have run this ourselves long enough to have a deliverability record
worth selling. `pods` stays in the schema as the thing that keeps that path
open — do not remove it as dead code.

Also: multi-stage Dockerfile running as a non-root system user with no
shell in the runtime image, and the openmail.karti.ai static page.

17 tests, zero clippy warnings, fmt clean.

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:20:51 -07:00
co-authored by Claude Opus 5
parent 36b15ddcaf
commit a42b798a0e
21 changed files with 915 additions and 44 deletions
+23 -12
View File
@@ -1,23 +1,32 @@
//! MCP server — the thing nobody else has.
//! MCP server — the surface 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`.
//! its own mailbox as tools.
//!
//! # 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.
//! 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 regardless of opt-in. An agent may read and send its own mail. It
//! may not mint itself a wider key — that is the one escape that would make
//! every other limit decorative.
//!
//! The refusal is encoded in [`Exposure`] and enforced by
//! [`Tool::is_representable`], which is a compile-time-checkable property
//! rather than a config flag someone can flip.
/// 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.
pub mod tools;
pub use tools::{TOOLS, Tool};
/// How a capability may be reached from an agent.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Exposure {
/// Callable as an MCP tool, subject to the caller's scopes.
Tool,
/// Reachable over REST but not offered to agents.
Hidden,
/// Credential-bearing. Never exposable; the type makes it unrepresentable.
/// Credential-bearing. Never exposable, under any configuration.
NeverExposable,
}
@@ -25,6 +34,8 @@ pub enum Exposure {
pub enum Error {
#[error("tool not found: {0}")]
UnknownTool(String),
#[error("scope denied: {0}")]
ScopeDenied(String),
#[error("scope denied for {0}")]
ScopeDenied(&'static str),
#[error("invalid arguments: {0}")]
InvalidArgs(String),
}
+121
View File
@@ -0,0 +1,121 @@
//! The tool catalogue.
//!
//! Adding a capability here is the *only* way an agent gains one. A new REST
//! route is invisible to agents until someone adds a row — absence of an opt-in
//! is a refusal, not a default.
use crate::Exposure;
/// One MCP tool.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Tool {
pub name: &'static str,
pub description: &'static str,
pub exposure: Exposure,
/// The scope a caller must hold. Kept as a string here rather than
/// importing `openmail-api` so the MCP crate does not depend on the HTTP
/// layer — the two surfaces are siblings, not a stack.
pub required_scope: &'static str,
}
impl Tool {
/// A tool row is only valid if its exposure permits being a tool at all.
/// `NeverExposable` in this table is a bug, and this is the assertion that
/// catches it — see the test below, which runs in CI.
#[must_use]
pub const fn is_representable(&self) -> bool {
matches!(self.exposure, Exposure::Tool)
}
}
/// Every tool an agent can call. Six, deliberately: an agent needs to create a
/// mailbox, read what arrived, and answer it. Everything else is the operator's
/// job.
pub static TOOLS: &[Tool] = &[
Tool {
name: "create_inbox",
description: "Provision a new mailbox and return its address.",
exposure: Exposure::Tool,
required_scope: "manage_inboxes",
},
Tool {
name: "list_messages",
description: "List messages in an inbox, newest first. Returns previews, not bodies.",
exposure: Exposure::Tool,
required_scope: "read",
},
Tool {
name: "get_thread",
description: "Fetch a conversation with each message's quoted history stripped.",
exposure: Exposure::Tool,
required_scope: "read",
},
Tool {
name: "send_message",
description: "Send a new message from an inbox.",
exposure: Exposure::Tool,
required_scope: "send",
},
Tool {
name: "reply",
description: "Reply in-thread, setting In-Reply-To and References correctly.",
exposure: Exposure::Tool,
required_scope: "send",
},
Tool {
name: "search",
description: "Full-text search across an inbox.",
exposure: Exposure::Tool,
required_scope: "read",
},
];
#[cfg(test)]
mod tests {
use super::{TOOLS, Tool};
use crate::Exposure;
#[test]
fn no_tool_is_credential_bearing() {
// The guard for the rule in the crate docs. If someone adds a
// key-management tool, CI fails here rather than in production.
for t in TOOLS {
assert!(
t.is_representable(),
"{} must not be exposed as a tool",
t.name
);
}
}
#[test]
fn no_tool_grants_key_management() {
for t in TOOLS {
assert_ne!(
t.required_scope, "manage_keys",
"{} would let an agent mint credentials",
t.name
);
}
}
#[test]
fn tool_names_are_unique() {
let mut names: Vec<&str> = TOOLS.iter().map(|t| t.name).collect();
names.sort_unstable();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "duplicate tool name");
}
#[test]
fn never_exposable_is_not_representable() {
let t = Tool {
name: "create_api_key",
description: "",
exposure: Exposure::NeverExposable,
required_scope: "manage_keys",
};
assert!(!t.is_representable());
}
}