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:
co-authored by
Claude Opus 5
parent
36b15ddcaf
commit
a42b798a0e
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user