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
@@ -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);
|
||||
@@ -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-"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! `inboxes` queries.
|
||||
//!
|
||||
//! Wired in v0.1 — see `docs/adr/0004-milestones.md`.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! `messages` queries.
|
||||
//!
|
||||
//! Wired in v0.1 — see `docs/adr/0004-milestones.md`.
|
||||
@@ -0,0 +1,3 @@
|
||||
//! `threads` queries.
|
||||
//!
|
||||
//! Wired in v0.1 — see `docs/adr/0004-milestones.md`.
|
||||
Reference in New Issue
Block a user