428040d964
Blockers in the ingest path (all funnel real mail in milestone 2): - H1: coerce body bytes to valid UTF-8 (ToValidUTF8) — non-UTF-8/mid-rune cuts no longer abort the ingest tx and drop the message. - H2: EnsurePart's recoverable parse error is non-fatal — proceed with the guaranteed-usable Part so messy real-world mail is stored, not rejected. - H3: extractBodies descends into message/rfc822 (Part.Message via SetMessageReaderAt) — forwarded/bounce bodies no longer lost. - H4: GetMessage/GetThread/GetThreadMessages scoped to inbox_id — no cross-inbox access; reply no longer a confused deputy. Hardening: - M1: /healthz no longer leaks DB error to unauthenticated callers. - M2: all DB errors funnel through handleErr; malformed UUID -> 404, dup -> 409, internal errors no longer echo the driver string. - M3: index messages(inbox_id, message_id_hdr) for thread resolution. - M4: pods UNIQUE(name) + ON CONFLICT (name) — no duplicate default pods. - L1: skip empty-User/Host addresses (no literal "@"). - L2: skip attachment-disposition parts when picking the body. - L3: case-insensitive, trimmed 'Re:' detection. Verified e2e vs Postgres 16: latin1 body stored valid UTF-8; rfc822-only body extracted; cross-inbox 404; malformed UUID 404; dup 409; threading regression OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
152 lines
6.2 KiB
SQL
152 lines
6.2 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(),
|
|
UNIQUE (name)
|
|
);
|
|
|
|
-- 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);
|
|
-- Supports per-delivery thread resolution (message_id_hdr = ANY(...) per inbox).
|
|
CREATE INDEX IF NOT EXISTS idx_messages_msgid ON messages(inbox_id, message_id_hdr) WHERE message_id_hdr IS NOT NULL;
|
|
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';
|