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
+1
View File
@@ -16,6 +16,7 @@ workspace = true
openmail-core.workspace = true
openmail-store.workspace = true
axum.workspace = true
sqlx.workspace = true
tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true
+48
View File
@@ -0,0 +1,48 @@
//! Bearer authentication and scopes.
//!
//! # Why scopes are enumerated, not strings
//!
//! The MCP surface hands tool access to an agent. If a scope is a free string
//! compared at the call site, "the agent can send mail" and "the agent can mint
//! keys" are one typo apart. Making them a closed enum means a new capability
//! cannot be granted by accident — it has to be added here first.
/// What a key is allowed to do.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
/// Read messages, threads and inbox metadata.
Read,
/// Send and reply.
Send,
/// Create and delete inboxes.
ManageInboxes,
/// Create, list and revoke API keys. **Never exposable over MCP** — see
/// `openmail_mcp::Exposure::NeverExposable`.
ManageKeys,
}
impl Scope {
/// May this scope ever be reachable from an MCP tool?
///
/// The answer for [`Scope::ManageKeys`] is permanently no. An agent that
/// can mint keys can escape every other limit placed on it, so the refusal
/// lives in the type system rather than in a config file someone can edit.
#[must_use]
pub const fn mcp_exposable(self) -> bool {
!matches!(self, Self::ManageKeys)
}
}
#[cfg(test)]
mod tests {
use super::Scope;
#[test]
fn key_management_is_never_reachable_from_an_agent() {
assert!(!Scope::ManageKeys.mcp_exposable());
assert!(Scope::Read.mcp_exposable());
assert!(Scope::Send.mcp_exposable());
assert!(Scope::ManageInboxes.mcp_exposable());
}
}
+49
View File
@@ -0,0 +1,49 @@
//! API errors and their wire representation.
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("unauthorized")]
Unauthorized,
#[error("forbidden: missing scope {0}")]
Forbidden(&'static str),
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error("not implemented")]
NotImplemented,
#[error(transparent)]
Store(#[from] openmail_store::Error),
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let status = match &self {
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::NotFound | Self::Store(openmail_store::Error::NotFound) => StatusCode::NOT_FOUND,
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
Self::NotImplemented => StatusCode::NOT_IMPLEMENTED,
Self::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
// Internal errors are logged in full and reported as a bare string. A
// database error message can carry schema and connection detail, and
// this endpoint is reachable by anyone with a key.
let message = match &self {
Self::Store(e) if status == StatusCode::INTERNAL_SERVER_ERROR => {
tracing::error!(error = ?e, "internal error");
"internal error".to_owned()
}
other => other.to_string(),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
+17 -27
View File
@@ -1,31 +1,21 @@
//! 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}
//! ```
//! Bearer auth, agent-shaped resources. Paths stay close to the shape existing
//! agent-mail tooling expects, so a client can target 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.
#[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),
pub mod auth;
pub mod error;
pub mod routes;
pub use error::Error;
use sqlx::PgPool;
/// Everything a handler may reach. Deliberately small — a handler that needs
/// something not in here is usually a handler doing too much.
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
}
+59
View File
@@ -0,0 +1,59 @@
//! The v0 route table.
//!
//! ```text
//! POST /v0/inboxes create
//! GET /v0/inboxes list
//! GET /v0/inboxes/{id} get
//! DELETE /v0/inboxes/{id} delete
//! POST /v0/inboxes/{id}/messages/send send
//! GET /v0/inboxes/{id}/messages list
//! GET /v0/inboxes/{id}/messages/{mid} get
//! POST /v0/inboxes/{id}/messages/{mid}/reply reply
//! GET /v0/inboxes/{id}/threads list
//! GET /v0/inboxes/{id}/threads/{tid} get (with messages)
//! POST /v0/inboxes/{id}/ingest accept a raw .eml
//! GET /v0/health liveness — unauthenticated
//! ```
//!
//! `ingest` is how v0.1 closes the receive→thread→extract loop with **zero
//! mail infrastructure**: POST a raw message, get back a threaded, extracted
//! one. It is the whole reason the agent layer is testable in CI.
use axum::{
Router,
routing::{get, post},
};
use tower_http::limit::RequestBodyLimitLayer;
use crate::AppState;
/// Maximum accepted message size. Matches `openmail_smtpd::Limits::default`,
/// because an ingest path with a larger limit than the SMTP path is a way in.
pub const MAX_BODY_BYTES: usize = 50 * 1024 * 1024;
/// Build the v0 router.
pub fn router(state: AppState) -> Router {
Router::new()
.route("/v0/health", get(health))
.route("/v0/inboxes", post(todo_handler).get(todo_handler))
.route("/v0/inboxes/{id}", get(todo_handler).delete(todo_handler))
.route("/v0/inboxes/{id}/messages", get(todo_handler))
.route("/v0/inboxes/{id}/messages/send", post(todo_handler))
.route("/v0/inboxes/{id}/messages/{mid}", get(todo_handler))
.route("/v0/inboxes/{id}/messages/{mid}/reply", post(todo_handler))
.route("/v0/inboxes/{id}/threads", get(todo_handler))
.route("/v0/inboxes/{id}/threads/{tid}", get(todo_handler))
.route("/v0/inboxes/{id}/ingest", post(todo_handler))
.layer(RequestBodyLimitLayer::new(MAX_BODY_BYTES))
.with_state(state)
}
/// Liveness. Deliberately unauthenticated and deliberately dumb: it must not
/// touch the database, or a slow query turns into a restart loop.
async fn health() -> &'static str {
"ok"
}
async fn todo_handler() -> crate::Error {
crate::Error::NotImplemented
}