Files
openmail/crates/openmail-api/src/error.rs
T
Karti TripathiandClaude Opus 5 a42b798a0e 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
2026-09-02 13:20:51 -07:00

50 lines
1.6 KiB
Rust

//! 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()
}
}