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
}
+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());
}
}
+1
View File
@@ -20,3 +20,4 @@ chrono.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
sha2.workspace = true
@@ -0,0 +1,182 @@
-- OpenMail initial schema.
--
-- Design notes that are load-bearing:
--
-- * UUIDv7 primary keys, generated by the application, not the database.
-- They sort by creation time, so `ORDER BY id` is a free chronological
-- index and pagination cursors are opaque-but-ordered without a second
-- column.
--
-- * Raw MIME and attachments are NEVER stored here. Rows hold an object-store
-- key. Message metadata is queried constantly and is small; raw MIME is
-- written once, read rarely, and is arbitrarily large. Keeping blobs out is
-- what lets the metadata working set stay in RAM.
--
-- * `pods` exists from day one even though v0.1 is single-tenant. Retrofitting
-- tenancy into a live schema costs far more than an unused column, and it is
-- what keeps a hosted offering possible later without a migration.
CREATE TABLE pods (
id uuid PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE domains (
id uuid PRIMARY KEY,
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
name text NOT NULL,
-- Reference to the DKIM private key in the secret store. The key itself is
-- never a column: a database backup must not be a signing-key compromise.
dkim_privkey_ref text,
dkim_selector text,
verified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (name)
);
CREATE TABLE inboxes (
id uuid PRIMARY KEY,
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
-- Stored lowercase; the API lowercases on the way in. Two inboxes differing
-- only by case is a support ticket, not a feature.
address text NOT NULL UNIQUE,
display_name text,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE threads (
id uuid PRIMARY KEY,
inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
subject text,
-- Normalised subject used by the heuristic threading fallback. Stored so
-- the match is an index lookup, not a scan with a function call.
subject_norm text,
message_count integer NOT NULL DEFAULT 0,
labels text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE messages (
id uuid PRIMARY KEY,
inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
thread_id uuid NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
-- The Message-ID header. NOT unique: real senders reuse and forge them.
-- Indexed for threading lookups, never used as a key.
message_id_hdr text,
in_reply_to text,
references_hdr text[] NOT NULL DEFAULT '{}',
from_addr text NOT NULL,
to_addrs text[] NOT NULL DEFAULT '{}',
cc_addrs text[] NOT NULL DEFAULT '{}',
subject text,
body_text text,
body_html text,
-- The reply with quoted history stripped. What an agent actually reads.
extracted_text text,
preview text,
-- Inbound auth verdicts, recorded at receipt. Null means "not evaluated"
-- (e.g. an ingested message), which is distinct from "failed".
spf text,
dkim text,
dmarc text,
junk_score real,
labels text[] NOT NULL DEFAULT '{}',
headers jsonb NOT NULL DEFAULT '{}'::jsonb,
raw_object_key text NOT NULL,
size_bytes bigint NOT NULL DEFAULT 0,
search tsvector,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE attachments (
id uuid PRIMARY KEY,
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
filename text,
content_type text,
size_bytes bigint NOT NULL DEFAULT 0,
object_key text NOT NULL,
inline boolean NOT NULL DEFAULT false,
content_id text
);
CREATE TABLE drafts (
id uuid PRIMARY KEY,
inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
thread_id uuid REFERENCES threads(id) ON DELETE SET NULL,
to_addrs text[] NOT NULL DEFAULT '{}',
cc_addrs text[] NOT NULL DEFAULT '{}',
bcc_addrs text[] NOT NULL DEFAULT '{}',
subject text,
body_text text,
body_html text,
send_at timestamptz,
-- Caller-supplied idempotency key. A retried send must not send twice.
client_id text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (inbox_id, client_id)
);
CREATE TABLE api_keys (
id uuid PRIMARY KEY,
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
-- Only the hash. A database dump must not be a set of live credentials.
token_hash bytea NOT NULL UNIQUE,
name text,
scopes text[] NOT NULL DEFAULT '{}',
last_used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz
);
CREATE TABLE webhooks (
id uuid PRIMARY KEY,
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
url text NOT NULL,
event_types text[] NOT NULL DEFAULT '{}',
secret bytea NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id uuid PRIMARY KEY,
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'queued',
attempts integer NOT NULL DEFAULT 0,
last_error text,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE events (
id uuid PRIMARY KEY,
inbox_id uuid REFERENCES inboxes(id) ON DELETE CASCADE,
type text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Indexes -------------------------------------------------------------------
CREATE INDEX messages_inbox_created ON messages (inbox_id, created_at DESC);
CREATE INDEX messages_thread ON messages (thread_id);
CREATE INDEX messages_msgid ON messages (message_id_hdr) WHERE message_id_hdr IS NOT NULL;
CREATE INDEX messages_search ON messages USING gin (search);
CREATE INDEX messages_labels ON messages USING gin (labels);
CREATE INDEX threads_inbox_updated ON threads (inbox_id, updated_at DESC);
-- The heuristic threading lookup: same inbox, same normalised subject, recent.
CREATE INDEX threads_subject_norm ON threads (inbox_id, subject_norm, updated_at DESC)
WHERE subject_norm IS NOT NULL;
-- The sender's hot path: due work only.
CREATE INDEX outbox_due ON outbox (next_attempt_at) WHERE status = 'queued';
CREATE INDEX events_inbox_created ON events (inbox_id, created_at DESC);
+38
View File
@@ -0,0 +1,38 @@
//! Object storage for raw `.eml` and attachments.
//!
//! # Key layout
//!
//! ```text
//! raw/{pod_id}/{inbox_id}/{message_id}.eml
//! att/{pod_id}/{message_id}/{attachment_id}
//! ```
//!
//! Pod-first so a tenant's objects can be lifecycled, exported or deleted with
//! a single prefix operation — the shape a deletion request actually arrives
//! in.
use uuid::Uuid;
/// The canonical key for a message's raw MIME.
#[must_use]
pub fn raw_key(pod: Uuid, inbox: Uuid, message: Uuid) -> String {
format!("raw/{pod}/{inbox}/{message}.eml")
}
/// The canonical key for one attachment.
#[must_use]
pub fn attachment_key(pod: Uuid, message: Uuid, attachment: Uuid) -> String {
format!("att/{pod}/{message}/{attachment}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_pod_prefixed_so_a_tenant_is_one_prefix() {
let (p, i, m) = (Uuid::nil(), Uuid::nil(), Uuid::nil());
assert!(raw_key(p, i, m).starts_with("raw/00000000-"));
assert!(attachment_key(p, m, m).starts_with("att/00000000-"));
}
}
+3
View File
@@ -0,0 +1,3 @@
//! `inboxes` queries.
//!
//! Wired in v0.1 — see `docs/adr/0004-milestones.md`.
+30
View File
@@ -0,0 +1,30 @@
//! API key storage.
//!
//! Only the hash is ever persisted. A database dump must not be a set of live
//! credentials — this is the single most common way a self-hosted service
//! turns a backup leak into a full compromise.
use sha2::{Digest, Sha256};
/// Hash a bearer token for storage and lookup.
///
/// SHA-256, not a password KDF: these are 256-bit random tokens, not
/// user-chosen secrets, so there is nothing to brute-force and the lookup
/// happens on every request.
#[must_use]
pub fn hash_token(token: &str) -> Vec<u8> {
Sha256::digest(token.as_bytes()).to_vec()
}
#[cfg(test)]
mod tests {
use super::hash_token;
#[test]
fn hashing_is_stable_and_not_the_input() {
let h = hash_token("om_live_abc");
assert_eq!(h, hash_token("om_live_abc"));
assert_ne!(h, b"om_live_abc".to_vec());
assert_eq!(h.len(), 32);
}
}
+27 -5
View File
@@ -5,19 +5,41 @@
//! raw MIME is written once, read rarely, and is arbitrarily large. Keeping
//! blobs out of Postgres is what lets the metadata working set stay in RAM.
//!
//! Migrations are embedded in the binary so a deploy cannot drift from its
//! schema.
//! Migrations are embedded in the binary so a deployment cannot drift from the
//! schema it was built against.
pub mod migrations {
//! Embedded SQL migrations. See `crates/openmail-store/migrations/`.
}
pub mod blobs;
pub mod inboxes;
pub mod keys;
pub mod messages;
pub mod threads;
use sqlx::postgres::{PgPool, PgPoolOptions};
/// Embedded migrations, applied by `openmail migrate` and on `serve` startup.
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("database: {0}")]
Db(#[from] sqlx::Error),
#[error("migration: {0}")]
Migrate(#[from] sqlx::migrate::MigrateError),
#[error("object store: {0}")]
ObjectStore(String),
#[error("not found")]
NotFound,
}
/// Open the pool and apply pending migrations.
///
/// # Errors
/// Fails if the database is unreachable or a migration does not apply.
pub async fn connect(url: &str, max_connections: u32) -> Result<PgPool, Error> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(url)
.await?;
MIGRATOR.run(&pool).await?;
Ok(pool)
}
+3
View File
@@ -0,0 +1,3 @@
//! `messages` queries.
//!
//! Wired in v0.1 — see `docs/adr/0004-milestones.md`.
+3
View File
@@ -0,0 +1,3 @@
//! `threads` queries.
//!
//! Wired in v0.1 — see `docs/adr/0004-milestones.md`.