-- Gmail Integration — Phase 1 migration -- Creates all tables for email capture, matching, threading, attachments. -- This migration is IDEMPOTENT: safe to re-run. -- Applied by email_integration.db.apply_migrations() on server startup when -- CRM_GMAIL_INTEGRATION_ENABLED is truthy. -- -- DO NOT modify this file in place after it ships. Create 0002_*.sql, etc. -- ============================================================================ -- email_accounts — one row per enrolled team-member mailbox -- ============================================================================ CREATE TABLE IF NOT EXISTS email_accounts ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, email_address TEXT NOT NULL UNIQUE, auth_method TEXT NOT NULL, -- 'dwd' | 'oauth' oauth_refresh_enc BLOB, oauth_token_enc BLOB, oauth_token_exp TEXT, sync_enabled INTEGER NOT NULL DEFAULT 1, sync_status TEXT NOT NULL DEFAULT 'pending', sync_error TEXT, last_history_id TEXT, last_synced_at TEXT, backfill_complete INTEGER NOT NULL DEFAULT 0, backfill_cursor TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY(user_id) REFERENCES users(id) ); CREATE INDEX IF NOT EXISTS idx_email_accounts_user ON email_accounts(user_id); CREATE INDEX IF NOT EXISTS idx_email_accounts_sync ON email_accounts(sync_enabled, sync_status); -- ============================================================================ -- emails — canonical email record, dedup'd across accounts by RFC Message-ID -- ============================================================================ CREATE TABLE IF NOT EXISTS emails ( id TEXT PRIMARY KEY, rfc_message_id TEXT NOT NULL UNIQUE, gmail_thread_id TEXT, rfc_thread_root_id TEXT, thread_id TEXT, -- FK email_threads.id (populated by threads.py) subject TEXT, from_email TEXT NOT NULL, from_name TEXT, to_emails_json TEXT NOT NULL DEFAULT '[]', cc_emails_json TEXT NOT NULL DEFAULT '[]', bcc_emails_json TEXT NOT NULL DEFAULT '[]', reply_to TEXT, sent_at TEXT NOT NULL, body_text TEXT, body_html TEXT, snippet TEXT, in_reply_to TEXT, references_json TEXT DEFAULT '[]', has_attachments INTEGER NOT NULL DEFAULT 0, size_estimate INTEGER, is_matched INTEGER NOT NULL DEFAULT 0, match_status TEXT NOT NULL DEFAULT 'unmatched', -- unmatched|matched|skipped raw_headers_json TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails(gmail_thread_id); CREATE INDEX IF NOT EXISTS idx_emails_rfc_thread ON emails(rfc_thread_root_id); CREATE INDEX IF NOT EXISTS idx_emails_thread_fk ON emails(thread_id); CREATE INDEX IF NOT EXISTS idx_emails_from ON emails(from_email); CREATE INDEX IF NOT EXISTS idx_emails_sent_at ON emails(sent_at); CREATE INDEX IF NOT EXISTS idx_emails_matched ON emails(is_matched, sent_at); CREATE INDEX IF NOT EXISTS idx_emails_in_reply_to ON emails(in_reply_to); -- ============================================================================ -- email_recipients — denormalized for fast address lookups -- ============================================================================ CREATE TABLE IF NOT EXISTS email_recipients ( id TEXT PRIMARY KEY, email_id TEXT NOT NULL, address TEXT NOT NULL, display_name TEXT, kind TEXT NOT NULL, -- from|to|cc|bcc|reply_to FOREIGN KEY(email_id) REFERENCES emails(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_email_recipients_addr ON email_recipients(address); CREATE INDEX IF NOT EXISTS idx_email_recipients_email ON email_recipients(email_id); -- ============================================================================ -- email_account_messages — per-mailbox sighting of an email -- ============================================================================ CREATE TABLE IF NOT EXISTS email_account_messages ( id TEXT PRIMARY KEY, email_id TEXT NOT NULL, account_id TEXT NOT NULL, gmail_message_id TEXT NOT NULL, gmail_thread_id TEXT NOT NULL, labels_json TEXT DEFAULT '[]', is_sent INTEGER NOT NULL DEFAULT 0, first_seen_at TEXT DEFAULT (datetime('now')), deleted_at TEXT, FOREIGN KEY(email_id) REFERENCES emails(id) ON DELETE CASCADE, FOREIGN KEY(account_id) REFERENCES email_accounts(id) ON DELETE CASCADE, UNIQUE(account_id, gmail_message_id) ); CREATE INDEX IF NOT EXISTS idx_eam_email ON email_account_messages(email_id); CREATE INDEX IF NOT EXISTS idx_eam_account ON email_account_messages(account_id); CREATE INDEX IF NOT EXISTS idx_eam_gmail_msg ON email_account_messages(gmail_message_id); -- ============================================================================ -- email_attachments — metadata; bytes on disk under data/email_attachments/ -- ============================================================================ CREATE TABLE IF NOT EXISTS email_attachments ( id TEXT PRIMARY KEY, email_id TEXT NOT NULL, gmail_attachment_id TEXT NOT NULL, filename TEXT NOT NULL, sanitized_filename TEXT NOT NULL, mime_type TEXT, size_bytes INTEGER, sha256_hex TEXT, storage_path TEXT NOT NULL, download_status TEXT NOT NULL DEFAULT 'pending', -- pending|downloaded|failed|skipped download_attempts INTEGER NOT NULL DEFAULT 0, download_error TEXT, downloaded_at TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY(email_id) REFERENCES emails(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_attach_email ON email_attachments(email_id); CREATE INDEX IF NOT EXISTS idx_attach_sha ON email_attachments(sha256_hex); CREATE INDEX IF NOT EXISTS idx_attach_status ON email_attachments(download_status); -- ============================================================================ -- email_threads — thread roll-up for UI -- ============================================================================ CREATE TABLE IF NOT EXISTS email_threads ( id TEXT PRIMARY KEY, gmail_thread_id TEXT, rfc_thread_root_id TEXT, subject_normalized TEXT, first_message_at TEXT, last_message_at TEXT, message_count INTEGER NOT NULL DEFAULT 0, participant_count INTEGER NOT NULL DEFAULT 0, participants_json TEXT DEFAULT '[]', is_matched INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); CREATE UNIQUE INDEX IF NOT EXISTS idx_threads_gmail_uniq ON email_threads(gmail_thread_id) WHERE gmail_thread_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_threads_rfc_root ON email_threads(rfc_thread_root_id); CREATE INDEX IF NOT EXISTS idx_threads_last_msg ON email_threads(last_message_at); -- ============================================================================ -- email_investor_links — matched investors -- ============================================================================ CREATE TABLE IF NOT EXISTS email_investor_links ( id TEXT PRIMARY KEY, email_id TEXT NOT NULL, fundraising_investor_id TEXT, fundraising_contact_id TEXT, contact_id TEXT, organization_id TEXT, matched_address TEXT NOT NULL, match_kind TEXT NOT NULL, -- exact_email|domain_match|manual match_confidence REAL NOT NULL DEFAULT 1.0, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY(email_id) REFERENCES emails(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_eil_email ON email_investor_links(email_id); CREATE INDEX IF NOT EXISTS idx_eil_investor ON email_investor_links(fundraising_investor_id); CREATE INDEX IF NOT EXISTS idx_eil_fr_contact ON email_investor_links(fundraising_contact_id); CREATE INDEX IF NOT EXISTS idx_eil_contact ON email_investor_links(contact_id); -- ============================================================================ -- email_sync_runs — per-run observability -- ============================================================================ CREATE TABLE IF NOT EXISTS email_sync_runs ( id TEXT PRIMARY KEY, account_id TEXT NOT NULL, kind TEXT NOT NULL, -- backfill|incremental|manual started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, -- running|ok|error|partial messages_seen INTEGER NOT NULL DEFAULT 0, messages_stored INTEGER NOT NULL DEFAULT 0, attachments_saved INTEGER NOT NULL DEFAULT 0, api_calls INTEGER NOT NULL DEFAULT 0, retries INTEGER NOT NULL DEFAULT 0, error TEXT, FOREIGN KEY(account_id) REFERENCES email_accounts(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_sync_runs_account ON email_sync_runs(account_id, started_at);