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
@@ -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);