| import Database from "better-sqlite3"; | |
| import { mkdirSync } from "node:fs"; | |
| import { dirname } from "node:path"; | |
| import { config } from "./config"; | |
| import { | |
| deleteRemoteMail, | |
| deleteRemoteMailsByProvider, | |
| deleteRemoteSettings, | |
| pushMail, | |
| pushMailbox, | |
| pushSetting, | |
| replaceRemoteAttachments | |
| } from "./supabase-sync"; | |
| mkdirSync(dirname(config.DATABASE_PATH), { recursive: true }); | |
| export const db = new Database(config.DATABASE_PATH); | |
| db.pragma("journal_mode = WAL"); | |
| db.pragma("foreign_keys = ON"); | |
| db.exec(` | |
| CREATE TABLE IF NOT EXISTS mailboxes ( | |
| id TEXT PRIMARY KEY, | |
| email TEXT NOT NULL UNIQUE, | |
| prefix TEXT NOT NULL, | |
| display_name TEXT, | |
| account_id TEXT, | |
| status TEXT NOT NULL DEFAULT 'active', | |
| openclaw_status TEXT, | |
| install_command TEXT, | |
| auth_url TEXT, | |
| comm_level INTEGER, | |
| ext_receive_type INTEGER, | |
| ext_send_type INTEGER, | |
| created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_mailboxes_email ON mailboxes(email); | |
| CREATE INDEX IF NOT EXISTS idx_mailboxes_status ON mailboxes(status); | |
| CREATE TABLE IF NOT EXISTS mails ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| provider_mail_id TEXT NOT NULL, | |
| mailbox_email TEXT NOT NULL, | |
| source TEXT, | |
| address TEXT, | |
| subject TEXT, | |
| text TEXT, | |
| html TEXT, | |
| raw_json TEXT NOT NULL, | |
| header_raw TEXT, | |
| has_attachments INTEGER NOT NULL DEFAULT 0, | |
| received_at TEXT, | |
| created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| UNIQUE(mailbox_email, provider_mail_id) | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_mails_mailbox_email ON mails(mailbox_email); | |
| CREATE INDEX IF NOT EXISTS idx_mails_created_at ON mails(created_at); | |
| CREATE TABLE IF NOT EXISTS attachments ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| mail_id INTEGER NOT NULL, | |
| provider_part_id TEXT NOT NULL, | |
| filename TEXT, | |
| content_type TEXT, | |
| size INTEGER, | |
| created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| FOREIGN KEY(mail_id) REFERENCES mails(id) ON DELETE CASCADE | |
| ); | |
| CREATE INDEX IF NOT EXISTS idx_attachments_mail_id ON attachments(mail_id); | |
| CREATE TABLE IF NOT EXISTS app_settings ( | |
| key TEXT PRIMARY KEY, | |
| value TEXT NOT NULL, | |
| updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP | |
| ); | |
| `); | |
| function ensureColumn(table: string, column: string, definition: string): void { | |
| const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; | |
| if (rows.some((row) => row.name === column)) return; | |
| db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run(); | |
| } | |
| ensureColumn("mailboxes", "comm_level", "INTEGER"); | |
| ensureColumn("mailboxes", "ext_receive_type", "INTEGER"); | |
| ensureColumn("mailboxes", "ext_send_type", "INTEGER"); | |
| export type MailboxRow = { | |
| id: string; | |
| email: string; | |
| prefix: string; | |
| display_name: string | null; | |
| account_id: string | null; | |
| status: string; | |
| openclaw_status: string | null; | |
| install_command: string | null; | |
| auth_url: string | null; | |
| comm_level: number | null; | |
| ext_receive_type: number | null; | |
| ext_send_type: number | null; | |
| created_at: string; | |
| updated_at: string; | |
| }; | |
| export type MailRow = { | |
| id: number; | |
| provider_mail_id: string; | |
| mailbox_email: string; | |
| source: string | null; | |
| address: string | null; | |
| subject: string | null; | |
| text: string | null; | |
| html: string | null; | |
| raw_json: string; | |
| header_raw: string | null; | |
| has_attachments: number; | |
| received_at: string | null; | |
| created_at: string; | |
| }; | |
| export type AttachmentRow = { | |
| id: number; | |
| mail_id: number; | |
| provider_part_id: string; | |
| filename: string | null; | |
| content_type: string | null; | |
| size: number | null; | |
| created_at: string; | |
| }; | |
| export function upsertMailbox(input: { | |
| id: string; | |
| email: string; | |
| prefix: string; | |
| displayName?: string | null; | |
| accountId?: string | null; | |
| status?: string | null; | |
| openclawStatus?: string | null; | |
| installCommand?: string | null; | |
| authUrl?: string | null; | |
| commLevel?: number | null; | |
| extReceiveType?: number | null; | |
| extSendType?: number | null; | |
| }): MailboxRow { | |
| db.prepare(` | |
| INSERT INTO mailboxes | |
| ( | |
| id, email, prefix, display_name, account_id, status, openclaw_status, | |
| install_command, auth_url, comm_level, ext_receive_type, ext_send_type | |
| ) | |
| VALUES | |
| ( | |
| @id, @email, @prefix, @displayName, @accountId, @status, @openclawStatus, | |
| @installCommand, @authUrl, @commLevel, @extReceiveType, @extSendType | |
| ) | |
| ON CONFLICT(id) DO UPDATE SET | |
| email = excluded.email, | |
| prefix = excluded.prefix, | |
| display_name = excluded.display_name, | |
| account_id = excluded.account_id, | |
| status = excluded.status, | |
| openclaw_status = excluded.openclaw_status, | |
| install_command = excluded.install_command, | |
| auth_url = excluded.auth_url, | |
| comm_level = excluded.comm_level, | |
| ext_receive_type = excluded.ext_receive_type, | |
| ext_send_type = excluded.ext_send_type, | |
| updated_at = CURRENT_TIMESTAMP | |
| ON CONFLICT(email) DO UPDATE SET | |
| id = excluded.id, | |
| prefix = excluded.prefix, | |
| display_name = excluded.display_name, | |
| account_id = excluded.account_id, | |
| status = excluded.status, | |
| openclaw_status = excluded.openclaw_status, | |
| install_command = excluded.install_command, | |
| auth_url = excluded.auth_url, | |
| comm_level = excluded.comm_level, | |
| ext_receive_type = excluded.ext_receive_type, | |
| ext_send_type = excluded.ext_send_type, | |
| updated_at = CURRENT_TIMESTAMP | |
| `).run({ | |
| ...input, | |
| status: input.status ?? "active", | |
| displayName: input.displayName ?? null, | |
| accountId: input.accountId ?? null, | |
| openclawStatus: input.openclawStatus ?? null, | |
| installCommand: input.installCommand ?? null, | |
| authUrl: input.authUrl ?? null, | |
| commLevel: input.commLevel ?? null, | |
| extReceiveType: input.extReceiveType ?? null, | |
| extSendType: input.extSendType ?? null | |
| }); | |
| const saved = getMailboxById(input.id)!; | |
| pushMailbox(saved); | |
| return saved; | |
| } | |
| export function listMailboxes(includeDeleted = false): MailboxRow[] { | |
| const sql = includeDeleted | |
| ? "SELECT * FROM mailboxes ORDER BY created_at DESC, email ASC" | |
| : "SELECT * FROM mailboxes WHERE status != 'deleted' ORDER BY created_at DESC, email ASC"; | |
| return db.prepare(sql).all() as MailboxRow[]; | |
| } | |
| export function listActiveMailboxes(): MailboxRow[] { | |
| return db.prepare("SELECT * FROM mailboxes WHERE status = 'active' ORDER BY email ASC").all() as MailboxRow[]; | |
| } | |
| export function getMailboxById(id: string): MailboxRow | undefined { | |
| return db.prepare("SELECT * FROM mailboxes WHERE id = ?").get(id) as MailboxRow | undefined; | |
| } | |
| export function getMailboxByEmail(email: string): MailboxRow | undefined { | |
| return db.prepare("SELECT * FROM mailboxes WHERE email = ? AND status != 'deleted'").get(email) as MailboxRow | undefined; | |
| } | |
| export function markMailboxDeleted(id: string): void { | |
| db.prepare("UPDATE mailboxes SET status = 'deleted', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id); | |
| const deleted = getMailboxById(id); | |
| if (deleted) pushMailbox(deleted); | |
| } | |
| export function updateMailboxCommSettings(id: string, input: { | |
| commLevel: number; | |
| extReceiveType?: number | null; | |
| extSendType?: number | null; | |
| }): MailboxRow | undefined { | |
| db.prepare(` | |
| UPDATE mailboxes | |
| SET | |
| comm_level = @commLevel, | |
| ext_receive_type = @extReceiveType, | |
| ext_send_type = @extSendType, | |
| updated_at = CURRENT_TIMESTAMP | |
| WHERE id = @id | |
| `).run({ | |
| id, | |
| commLevel: input.commLevel, | |
| extReceiveType: input.extReceiveType ?? null, | |
| extSendType: input.extSendType ?? null | |
| }); | |
| const updated = getMailboxById(id); | |
| if (updated) pushMailbox(updated); | |
| return updated; | |
| } | |
| export function markMailboxesMissingDeleted(remoteEmails: string[]): MailboxRow[] { | |
| const remoteEmailSet = new Set(remoteEmails.map((email) => email.trim().toLowerCase())); | |
| const missing = listActiveMailboxes().filter((mailbox) => !remoteEmailSet.has(mailbox.email.toLowerCase())); | |
| const transaction = db.transaction(() => { | |
| const statement = db.prepare("UPDATE mailboxes SET status = 'deleted', updated_at = CURRENT_TIMESTAMP WHERE id = ?"); | |
| for (const mailbox of missing) { | |
| statement.run(mailbox.id); | |
| } | |
| }); | |
| transaction(); | |
| for (const mailbox of missing) { | |
| const row = getMailboxById(mailbox.id); | |
| if (row) pushMailbox(row); | |
| } | |
| return missing; | |
| } | |
| export function saveMail(input: { | |
| providerMailId: string; | |
| mailboxEmail: string; | |
| source?: string | null; | |
| address?: string | null; | |
| subject?: string | null; | |
| text?: string | null; | |
| html?: string | null; | |
| rawJson: string; | |
| headerRaw?: string | null; | |
| hasAttachments?: boolean; | |
| receivedAt?: string | null; | |
| attachments?: Array<{ | |
| providerPartId: string; | |
| filename?: string | null; | |
| contentType?: string | null; | |
| size?: number | null; | |
| }>; | |
| }): MailRow { | |
| const transaction = db.transaction(() => { | |
| db.prepare(` | |
| INSERT INTO mails | |
| (provider_mail_id, mailbox_email, source, address, subject, text, html, raw_json, header_raw, has_attachments, received_at) | |
| VALUES | |
| (@providerMailId, @mailboxEmail, @source, @address, @subject, @text, @html, @rawJson, @headerRaw, @hasAttachments, @receivedAt) | |
| ON CONFLICT(mailbox_email, provider_mail_id) DO UPDATE SET | |
| source = excluded.source, | |
| address = excluded.address, | |
| subject = excluded.subject, | |
| text = excluded.text, | |
| html = excluded.html, | |
| raw_json = excluded.raw_json, | |
| header_raw = excluded.header_raw, | |
| has_attachments = excluded.has_attachments, | |
| received_at = excluded.received_at | |
| `).run({ | |
| ...input, | |
| source: input.source ?? null, | |
| address: input.address ?? null, | |
| subject: input.subject ?? null, | |
| text: input.text ?? null, | |
| html: input.html ?? null, | |
| headerRaw: input.headerRaw ?? null, | |
| hasAttachments: input.hasAttachments ? 1 : 0, | |
| receivedAt: input.receivedAt ?? null | |
| }); | |
| const row = db.prepare(` | |
| SELECT * FROM mails WHERE mailbox_email = ? AND provider_mail_id = ? | |
| `).get(input.mailboxEmail, input.providerMailId) as MailRow; | |
| db.prepare("DELETE FROM attachments WHERE mail_id = ?").run(row.id); | |
| const insertAttachment = db.prepare(` | |
| INSERT INTO attachments (mail_id, provider_part_id, filename, content_type, size) | |
| VALUES (?, ?, ?, ?, ?) | |
| `); | |
| for (const attachment of input.attachments ?? []) { | |
| insertAttachment.run( | |
| row.id, | |
| attachment.providerPartId, | |
| attachment.filename ?? null, | |
| attachment.contentType ?? null, | |
| attachment.size ?? null | |
| ); | |
| } | |
| return row; | |
| }); | |
| const saved = transaction(); | |
| pushMail(saved); | |
| replaceRemoteAttachments(saved.id, listAttachments(saved.id)); | |
| return saved; | |
| } | |
| export function listMails(input: { | |
| mailboxEmail?: string; | |
| limit: number; | |
| offset: number; | |
| }): { items: MailRow[]; count: number } { | |
| const where = input.mailboxEmail ? "WHERE mailbox_email = ?" : ""; | |
| const params = input.mailboxEmail ? [input.mailboxEmail] : []; | |
| const items = db.prepare(` | |
| SELECT * FROM mails ${where} | |
| ORDER BY created_at DESC, id DESC | |
| LIMIT ? OFFSET ? | |
| `).all(...params, input.limit, input.offset) as MailRow[]; | |
| const count = db.prepare(`SELECT COUNT(*) AS count FROM mails ${where}`).get(...params) as { count: number }; | |
| return { items, count: count.count }; | |
| } | |
| export function listMailProviderIds(mailboxEmail: string): string[] { | |
| const rows = db.prepare("SELECT provider_mail_id FROM mails WHERE mailbox_email = ?") | |
| .all(mailboxEmail) as Array<{ provider_mail_id: string }>; | |
| return rows.map((row) => row.provider_mail_id); | |
| } | |
| export function getMailById(id: number): MailRow | undefined { | |
| return db.prepare("SELECT * FROM mails WHERE id = ?").get(id) as MailRow | undefined; | |
| } | |
| export function getMailByProviderId(mailboxEmail: string, providerMailId: string): MailRow | undefined { | |
| return db.prepare("SELECT * FROM mails WHERE mailbox_email = ? AND provider_mail_id = ?") | |
| .get(mailboxEmail, providerMailId) as MailRow | undefined; | |
| } | |
| export function deleteMailById(id: number): boolean { | |
| const result = db.prepare("DELETE FROM mails WHERE id = ?").run(id); | |
| if (result.changes > 0) deleteRemoteMail(id); | |
| return result.changes > 0; | |
| } | |
| export function deleteMailsByProviderIds(mailboxEmail: string, providerMailIds: string[]): number { | |
| if (providerMailIds.length === 0) return 0; | |
| const transaction = db.transaction(() => { | |
| const statement = db.prepare("DELETE FROM mails WHERE mailbox_email = ? AND provider_mail_id = ?"); | |
| let count = 0; | |
| for (const providerMailId of providerMailIds) { | |
| count += statement.run(mailboxEmail, providerMailId).changes; | |
| } | |
| return count; | |
| }); | |
| const removed = transaction(); | |
| deleteRemoteMailsByProvider(mailboxEmail, providerMailIds); | |
| return removed; | |
| } | |
| export function listAttachments(mailId: number): AttachmentRow[] { | |
| return db.prepare("SELECT * FROM attachments WHERE mail_id = ? ORDER BY id ASC").all(mailId) as AttachmentRow[]; | |
| } | |
| export function getSetting(key: string): string | undefined { | |
| const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined; | |
| return row?.value; | |
| } | |
| export function setSetting(key: string, value: string): void { | |
| db.prepare(` | |
| INSERT INTO app_settings (key, value) | |
| VALUES (?, ?) | |
| ON CONFLICT(key) DO UPDATE SET | |
| value = excluded.value, | |
| updated_at = CURRENT_TIMESTAMP | |
| `).run(key, value); | |
| pushSetting(key, value); | |
| } | |
| export function deleteSettings(keys: string[]): void { | |
| const transaction = db.transaction(() => { | |
| const statement = db.prepare("DELETE FROM app_settings WHERE key = ?"); | |
| for (const key of keys) { | |
| statement.run(key); | |
| } | |
| }); | |
| transaction(); | |
| deleteRemoteSettings(keys); | |
| } | |