551739baa3
- Confirmed core bet via spike/mimecheck: mox/message parses a messy multipart message standalone (envelope, MIME tree, attachment, body) with only an io.ReaderAt — no mox store/config. Option B is viable. - cmd/openmail single binary: serve|migrate|smtpd|sender|version subcommands. - internal/api: chi router, constant-time bearer auth, /healthz, stubbed v0 AgentMail-shaped routes (501 until core services land). - internal/store: pgxpool + embedded, idempotent, tracked SQL migrations. - internal/store/migrations/0001_init.sql: full native schema (pods, inboxes, threads, messages, attachments, drafts, api_keys, webhooks, outbox, events, domains) with FTS + GIN indexes. Validated end-to-end against Postgres 16. - deploy/: docker-compose (postgres+minio+openmail+caddy), Caddyfile, DNS.md (MX/SPF/DKIM/DMARC/DANE/MTA-STS). Makefile, .gitignore. - Verified: go vet clean; build OK; health/auth smoke tests; migrate idempotent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
6.0 KiB
SQL
149 lines
6.0 KiB
SQL
-- OpenMail initial schema. See ARCHITECTURE.md §3.
|
|
-- Native, agent-shaped data model (not Mox's per-account bbolt index).
|
|
|
|
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
|
|
|
|
-- Tenant isolation.
|
|
CREATE TABLE IF NOT EXISTS pods (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name text NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Per-domain DKIM keys + DNS verification state.
|
|
CREATE TABLE IF NOT EXISTS domains (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
|
|
name text NOT NULL,
|
|
dkim_selector text,
|
|
dkim_privkey_ref text, -- pointer to key in secret store; never the key itself
|
|
verified boolean NOT NULL DEFAULT false,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE (name)
|
|
);
|
|
|
|
-- An agent-owned address; first-class API resource.
|
|
CREATE TABLE IF NOT EXISTS inboxes (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
|
|
address text NOT NULL,
|
|
display_name text,
|
|
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE (address)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS threads (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
|
|
subject text,
|
|
last_message_id uuid,
|
|
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 IF NOT EXISTS messages (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
inbox_id uuid NOT NULL REFERENCES inboxes(id) ON DELETE CASCADE,
|
|
thread_id uuid REFERENCES threads(id) ON DELETE SET NULL,
|
|
message_id_hdr text, -- RFC 5322 Message-ID
|
|
in_reply_to text,
|
|
"references" text[] NOT NULL DEFAULT '{}',
|
|
from_addr text,
|
|
to_addrs text[] NOT NULL DEFAULT '{}',
|
|
cc text[] NOT NULL DEFAULT '{}',
|
|
bcc text[] NOT NULL DEFAULT '{}',
|
|
subject text,
|
|
preview text,
|
|
text text,
|
|
html text,
|
|
extracted_text text, -- quoted history stripped
|
|
extracted_html text,
|
|
raw_object_key text, -- pointer to raw .eml in object store
|
|
spf text, -- inbound auth verdicts (from mox pkgs)
|
|
dkim text,
|
|
dmarc text,
|
|
junk_score real,
|
|
labels text[] NOT NULL DEFAULT '{}',
|
|
size_bytes bigint,
|
|
headers jsonb NOT NULL DEFAULT '{}'::jsonb,
|
|
ts tsvector, -- full-text search
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
filename text,
|
|
content_type text,
|
|
size_bytes bigint,
|
|
object_key text NOT NULL,
|
|
inline boolean NOT NULL DEFAULT false,
|
|
content_id text
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS drafts (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
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 text[] NOT NULL DEFAULT '{}',
|
|
bcc text[] NOT NULL DEFAULT '{}',
|
|
subject text,
|
|
text text,
|
|
html text,
|
|
send_at timestamptz,
|
|
client_id text,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Bearer tokens; only the hash is stored.
|
|
CREATE TABLE IF NOT EXISTS api_keys (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
|
|
hash bytea NOT NULL,
|
|
scopes text[] NOT NULL DEFAULT '{}',
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE (hash)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS webhooks (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
pod_id uuid NOT NULL REFERENCES pods(id) ON DELETE CASCADE,
|
|
url text NOT NULL,
|
|
event_types text[] NOT NULL DEFAULT '{}',
|
|
secret text NOT NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Outbound send queue + retries.
|
|
CREATE TABLE IF NOT EXISTS outbox (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
status text NOT NULL DEFAULT 'queued', -- queued|sending|sent|failed
|
|
attempts integer NOT NULL DEFAULT 0,
|
|
last_error text,
|
|
next_attempt_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS events (
|
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
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()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_inboxes_pod ON inboxes(pod_id);
|
|
CREATE INDEX IF NOT EXISTS idx_threads_inbox ON threads(inbox_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_inbox_time ON messages(inbox_id, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages USING gin(ts);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_labels ON messages USING gin(labels);
|
|
CREATE INDEX IF NOT EXISTS idx_threads_labels ON threads USING gin(labels);
|
|
CREATE INDEX IF NOT EXISTS idx_outbox_due ON outbox(next_attempt_at) WHERE status = 'queued';
|