4ce2df0a2b
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 18s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 15s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 54s
6.4 KiB
6.4 KiB
03 — Database Design (Part 6)
Schema for a small-team, self-hosted, one-org platform: multi-provider accounts per user, a normalised email store scaling to millions of messages, settings, feature flags, and audit. PostgreSQL + EF Core (+ pgvector for the blueprint's semantic tier).
Entity map
users ─┬─< accounts ─┬─< provider_tokens (1:1, encrypted)
│ ├─< email_threads ─< email_messages ─┬─< attachments
│ │ └─< message_labels >─ labels
│ └─ sync_state (cursor)
├─< user_settings (1:1)
└─< audit_logs (actor)
system_settings (singleton) feature_flags
Tables
Identity & access
users— the app identity (from OAuth).id (uuid pk) · primary_email (citext, unique) · display_name · avatar_url · role (enum: Admin|Member) · status (enum: Active|Suspended) · created_at · last_login_atFirst-ever user is bootstrapped as Admin (see 05).accounts— a connected mailbox and a login identity (OAuth-is-login).id (uuid pk) · user_id (fk) · provider (enum: Google|Microsoft|Imap) · provider_account_id (text, the OAuth 'sub' — immutable) · email (citext) · display_name · is_login_identity (bool) · status (enum: Active|ReauthNeeded|Disabled) · scopes (text[]) · added_at · last_sync_atUnique:(provider, provider_account_id)→ resolves an OAuth login to exactly one account→user; prevents the same mailbox linking twice.provider_tokens— 1:1 withaccounts, encrypted at rest (Data Protection API).account_id (pk/fk) · access_token_enc (bytea) · refresh_token_enc (bytea) · expires_at_utc · token_type · rotated_atNever logged; see 06.sessions— server-side app sessions (opaque cookie).id · user_id · created_at · expires_at · ip · user_agent · revoked_at(supports "sign out everywhere" + admin revoke).
Email (normalised, provider-agnostic)
email_threads— per account.id (uuid pk) · account_id (fk) · user_id (denorm) · provider_thread_id · subject · participants (jsonb) · message_count · last_message_atUnique:(account_id, provider_thread_id).email_messages— the big table.id (uuid pk) · account_id (fk) · user_id (denorm) · thread_id (fk) · provider_message_id · sender_id (fk) · subject · snippet · body_text · sent_at_utc · received_at_utc · size_bytes · flags (unread/starred/important/in_inbox/trashed as bits or bools) · has_attachments · category (enum) · has_list_unsubscribe · supports_one_click_unsub · search_vector (tsvector, generated, weighted) · embedding (vector(768), nullable) · created_atUnique:(account_id, provider_message_id)(idempotent upsert).labels(id · account_id · provider_label_id · name · type) +message_labels(message_id · label_id, pk both).attachments(id · message_id · filename · mime · size · provider_attachment_id · content_text nullablefor future OCR/search).senders/domains(existing) — kept, scoped per user (or global with per-user stats materialised insender_importance).
Settings, flags, audit
user_settings— 1:1 withusers.user_id (pk) · theme (enum: system|light|dark) · inbox_layout (jsonb) · notifications (jsonb) · ai_prefs (jsonb) · provider_prefs (jsonb) · updated_atsystem_settings— singleton (org-wide, admin-managed).id (const) · maintenance_mode (bool) · default_theme · registration_open (bool) · updated_by · updated_at(+ arbitraryvalues jsonbfor growth).feature_flags— the flag system (drives AI gating).key (pk text) · enabled (bool) · scope (enum: SystemOnly|UserOverridable) · description · rollout (jsonb, e.g. per-role) · updated_by · updated_atSeeded flags:ai.enabled,ai.semantic_search,ai.ask_inbox,provider.google,provider.microsoft,provider.imap,maintenance.readonly.audit_logs— admin + security events.id (bigserial) · actor_user_id (fk, nullable for system) · action (text) · target_type · target_id · metadata (jsonb) · ip · created_atAppend-only; indexed on(created_at),(actor_user_id),(action).
Indexing & performance
| Index | Column(s) | For |
|---|---|---|
| GIN | email_messages.search_vector |
FTS |
GIN pg_trgm |
sender/subject | fuzzy (fixes non-sargable .Contains) |
| HNSW | email_messages.embedding |
semantic k-NN |
| btree | (user_id, sent_at_utc desc), (account_id, sent_at_utc desc), (thread_id) |
keyset pagination, scoping, threading |
| unique | (account_id, provider_message_id), (provider, provider_account_id) |
idempotency, login resolution |
Scalability (millions of emails, multi-account, incremental)
- Multi-account = first-class via
accounts; every email carriesaccount_id+ denormaliseduser_id(so cross-account per-user search is a single indexed scan). - Millions of rows: keyset (cursor) pagination, top-N-by-score ranking,
pg_trgm/GIN/HNSW indexes. If a single user exceeds ~1–2M messages, list-partitionemail_messagesbyaccount_id(or hash byuser_id). - Incremental sync: per-account
sync_statecursor (historyId/deltaLink/uidvalidity+modseq) → only deltas fetched; idempotent upserts on the unique key. - Idempotency & resumability: all sync writes keyed on
(account_id, provider_message_id); cursor advanced atomically with the batch.
RBAC in the schema
Small-team model = a role column on users (Admin|Member) — no separate roles/permissions tables yet. Extensible to roles/permissions/org_id if this ever grows to multi-tenant, without reshaping the email tables.
Migration from today (see full Migration Guide)
- Create
usersfrom existing OAuth identity; set first user = Admin. - Create one
Googleaccountper existing user; move current Gmail tokens →provider_tokens. - Backfill
email_messages.account_id/user_id, rename/extend from the currentEmailtable; widensearch_vector; add nullableembedding. - Add
user_settings,system_settings,feature_flags(seedai.enabledfrom the currentAi:Mode),audit_logs. - All additive + backfill; no destructive step — safe to run behind a maintenance window.