/** * Browser-compatible settings for Android/WebView. * Replaces @bobfrankston/mailx-settings which depends on node:fs. * * Settings are stored in IndexedDB and synced to Google Drive * via the GDrive API (same API as desktop cloud mode). * * On first run, settings are fetched from GDrive. Subsequent reads * use the local IndexedDB cache. Writes go to both. */ import type { AccountConfig, MailxSettings, AutocompleteSettings } from "@bobfrankston/mailx-types"; const IDB_NAME = "mailx-settings"; const IDB_VERSION = 1; const STORE_NAME = "files"; // ── IndexedDB helpers ── function openSettingsDb(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, IDB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE_NAME)) { db.createObjectStore(STORE_NAME); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function idbRead(key: string): Promise { const db = await openSettingsDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).get(key); req.onsuccess = () => resolve(req.result as string | null); req.onerror = () => reject(req.error); }); } async function idbWrite(key: string, value: string): Promise { const db = await openSettingsDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).put(value, key); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function idbDelete(key: string): Promise { const db = await openSettingsDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readwrite"); tx.objectStore(STORE_NAME).delete(key); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } /** All keys with the given prefix (prev-version pruning). */ async function idbListKeys(prefix: string): Promise { const db = await openSettingsDb(); return new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, "readonly"); const req = tx.objectStore(STORE_NAME).getAllKeys(IDBKeyRange.bound(prefix, prefix + "￿")); req.onsuccess = () => resolve((req.result as IDBValidKey[]).map(String)); req.onerror = () => reject(req.error); }); } // ── GDrive API ── /** GDrive folder ID for the ".rmfmail" app folder */ let gDriveFolderId: string | null = null; /** GDrive folder name (resolved from query — ".rmfmail" today, but kept dynamic in case the query changes) */ let gDriveFolderName: string | null = null; /** Full path "My Drive/home/.rmfmail" — surfaced in About so the user can verify */ let gDriveFolderPath: string | null = null; /** Owner email of the matched .rmfmail folder — surfaces wrong-account picks (e.g. spouse's shared folder) */ let gDriveFolderOwner: string | null = null; /** OAuth token provider — set by bootstrap */ let tokenProvider: (() => Promise) | null = null; export function setGDriveTokenProvider(provider: () => Promise): void { tokenProvider = provider; } export function setGDriveFolderId(folderId: string, folderName?: string, ownerEmail?: string, folderPath?: string): void { gDriveFolderId = folderId; if (folderName) gDriveFolderName = folderName; if (ownerEmail) gDriveFolderOwner = ownerEmail; if (folderPath) gDriveFolderPath = folderPath; } export async function cloudRead(filename: string): Promise { return gDriveRead(filename); } export async function cloudWrite(filename: string, content: string): Promise { return gDriveWrite(filename, content); } /** Cheap freshness probe: one Drive metadata request (no content download). * Returns the file's modifiedTime, or null if unreachable / missing. */ export async function cloudStat(filename: string): Promise<{ id: string; modifiedTime: string } | null> { if (!tokenProvider || !gDriveFolderId) return null; try { const token = await tokenProvider(); const q = encodeURIComponent(`name='${filename}' and '${gDriveFolderId}' in parents and trashed=false`); const res = await globalThis.fetch( `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,modifiedTime)`, { headers: { "Authorization": `Bearer ${token}` } } ); if (!res.ok) return null; const data = await res.json() as any; const f = data.files?.[0]; return f?.id ? { id: f.id, modifiedTime: f.modifiedTime || "" } : null; } catch (e: any) { console.error(`[settings] GDrive stat ${filename}: ${e.message}`); return null; } } /** Find a config file by name in the .rmfmail folder, DETERMINISTICALLY. * Drive allows duplicate names; a second device signed into a different * Google account created its own allowlist.jsonc in the shared folder and * files?.[0] then made every device's pick a coin flip — stale bases and * cross-account clobbers (2026-07-23 wipe). Preference order: a file THIS * account owns, then newest modifiedTime. Duplicates are logged loudly. */ async function gDriveFindFile(filename: string, token: string): Promise<{ id: string; modifiedTime: string; ownedByMe: boolean; capabilitiesCanEdit: boolean } | null> { const q = encodeURIComponent(`name='${filename}' and '${gDriveFolderId}' in parents and trashed=false`); const res = await globalThis.fetch( `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,modifiedTime,ownedByMe,capabilities(canEdit))`, { headers: { "Authorization": `Bearer ${token}` } } ); if (!res.ok) return null; const files: any[] = ((await res.json()) as any).files || []; if (files.length === 0) return null; if (files.length > 1) { console.warn(`[settings] ${filename}: ${files.length} same-named files in the shared folder — using own/newest. Clean the duplicates (owners differ?).`); files.sort((a, b) => (b.ownedByMe === true ? 1 : 0) - (a.ownedByMe === true ? 1 : 0) || String(b.modifiedTime).localeCompare(String(a.modifiedTime))); } const f = files[0]; return { id: f.id, modifiedTime: f.modifiedTime || '', ownedByMe: f.ownedByMe === true, capabilitiesCanEdit: f.capabilities?.canEdit !== false }; } async function gDriveRead(filename: string): Promise { if (!tokenProvider || !gDriveFolderId) return null; try { const token = await tokenProvider(); const found = await gDriveFindFile(filename, token); const fileId = found?.id; if (!fileId) return null; // Download content const res = await globalThis.fetch( `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, { headers: { "Authorization": `Bearer ${token}` } } ); if (!res.ok) return null; return res.text(); } catch (e: any) { console.error(`[settings] GDrive read ${filename}: ${e.message}`); return null; } } async function gDriveWrite(filename: string, content: string): Promise { if (!tokenProvider || !gDriveFolderId) return false; try { const token = await tokenProvider(); const found = await gDriveFindFile(filename, token); const fileId = found?.id; if (fileId) { // A file EXISTS but this account can't edit it (someone else's // copy in a shared folder). Creating a same-named sibling is how // the duplicate-file mess started — never fork; fail loudly and // keep the change local-only. if (!found!.capabilitiesCanEdit) { console.error(`[settings] ${filename}: existing shared file is not editable by this account — NOT creating a duplicate. Change kept locally.`); return false; } // Update existing const res = await globalThis.fetch( `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`, { method: "PATCH", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json", }, body: content, } ); if (!res.ok) console.error(`[settings] ${filename}: cloud update failed (${res.status})`); return res.ok; } else { // Create new const metadata = JSON.stringify({ name: filename, parents: [gDriveFolderId], mimeType: "application/json", }); const boundary = "----mailx" + Date.now(); const body = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n--${boundary}\r\nContent-Type: application/json\r\n\r\n${content}\r\n--${boundary}--`; const res = await globalThis.fetch( "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": `multipart/related; boundary=${boundary}`, }, body, } ); return res.ok; } } catch (e: any) { console.error(`[settings] GDrive write ${filename}: ${e.message}`); return false; } } // ── Provider defaults (same as desktop mailx-settings) ── interface ProviderDefaults { label: string; imap: { host: string; port: number; tls: boolean; auth: "password" | "oauth2" }; smtp: { host: string; port: number; tls: boolean; auth: "password" | "oauth2" }; } const PROVIDERS: Record = { "gmail.com": { label: "Gmail", imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" }, smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" }, }, "googlemail.com": { label: "Gmail", imap: { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2" }, smtp: { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2" }, }, "outlook.com": { label: "Outlook", imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" }, smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" }, }, "hotmail.com": { label: "Hotmail", imap: { host: "outlook.office365.com", port: 993, tls: true, auth: "oauth2" }, smtp: { host: "smtp.office365.com", port: 587, tls: true, auth: "oauth2" }, }, "yahoo.com": { label: "Yahoo", imap: { host: "imap.mail.yahoo.com", port: 993, tls: true, auth: "password" }, smtp: { host: "smtp.mail.yahoo.com", port: 587, tls: true, auth: "password" }, }, "icloud.com": { label: "iCloud", imap: { host: "imap.mail.me.com", port: 993, tls: true, auth: "password" }, smtp: { host: "smtp.mail.me.com", port: 587, tls: true, auth: "password" }, }, }; function normalizeAccount(acct: any, globalName?: string): AccountConfig { const email = acct.email || ""; const domain = email.split("@")[1]?.toLowerCase() || ""; const provider = PROVIDERS[domain]; const user = acct.imap?.user || acct.user || email; return { id: acct.id || domain.split(".")[0] || "account", name: acct.name || globalName || email.split("@")[0], label: acct.label || provider?.label, email, imap: { host: acct.imap?.host || provider?.imap.host || `imap.${domain}`, port: acct.imap?.port || provider?.imap.port || 993, tls: acct.imap?.tls ?? provider?.imap.tls ?? true, auth: acct.imap?.auth || provider?.imap.auth || "password", user: acct.imap?.user || user, password: acct.imap?.password || acct.password, }, smtp: { host: acct.smtp?.host || provider?.smtp.host || `smtp.${domain}`, port: acct.smtp?.port || provider?.smtp.port || 587, tls: acct.smtp?.tls ?? provider?.smtp.tls ?? true, auth: acct.smtp?.auth || provider?.smtp.auth || "password", user: acct.smtp?.user || user, password: acct.smtp?.password || acct.password, }, enabled: acct.enabled ?? true, defaultSend: acct.defaultSend, syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"), relayDomains: acct.relayDomains, }; } // ── Default settings ── const DEFAULT_PREFERENCES = { ui: { theme: "system" as const, editor: "quill" as const, folderWidth: 220, listViewerSplit: 40, fontSize: 15, composeFontSize: 20, }, sync: { intervalMinutes: 5, historyDays: 30, prefetch: true, }, autocomplete: { enabled: false, provider: "off" as const, ollamaUrl: "", ollamaModel: "", cloudApiKey: "", cloudModel: "", debounceMs: 600, maxTokens: 60, }, }; const DEFAULT_ALLOWLIST = { senders: [] as string[], domains: [] as string[], recipients: [] as string[], flaggedSenders: [] as string[], flaggedDomains: [] as string[], }; // ── JSONC parser (strips comments and trailing commas) ── function parseJsonc(text: string): any { // Strip /* block comments */ and // line comments, but preserve content inside strings let stripped = ""; let i = 0; let inString = false; let stringChar = ""; while (i < text.length) { const c = text[i]; const next = text[i + 1]; if (inString) { stripped += c; if (c === "\\" && i + 1 < text.length) { stripped += text[i + 1]; i += 2; continue; } if (c === stringChar) inString = false; i++; continue; } if (c === '"' || c === "'") { inString = true; stringChar = c; stripped += c; i++; continue; } if (c === "/" && next === "/") { // Line comment — skip to end of line while (i < text.length && text[i] !== "\n") i++; continue; } if (c === "/" && next === "*") { // Block comment — skip to */ i += 2; while (i < text.length - 1 && !(text[i] === "*" && text[i + 1] === "/")) i++; i += 2; continue; } stripped += c; i++; } // Strip trailing commas before } or ] stripped = stripped.replace(/,(\s*[}\]])/g, "$1"); return JSON.parse(stripped); } // ── Public API ── // Stale-while-revalidate for SHARED JSONC files. Cache-first reads froze // these files at their install-day snapshot: once IndexedDB had a copy, // GDrive was never consulted again, so desktop edits (new allowlist // entries, preference changes) never reached the phone (Bob 2026-07-22: // "android doesn't seem to be honoring the email white list"). Reads stay // instant off the cache; a throttled background pull updates the cache so // the NEXT read sees the shared copy. Caches are caches, never the source // of truth. (Per-device files like devices//state.json are exempt — // only this device writes them.) // prev-versioning (Bob's prev/ convention, IndexedDB flavor): before a // shared config file's cache changes, stash the outgoing version under a // `prev:` key so a clobber leaves a local trail (the 2026-07-23 allowlist // wipe had NO local history anywhere — Drive revisions only reach ~30 days). const PREV_KEEP = 5; async function stashPrevIdb(filename: string, aboutToBecome: string): Promise { try { const old = await idbRead(filename); if (!old || old === aboutToBecome) return; const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); await idbWrite(`prev:${filename}:${stamp}`, old); const keys = await idbListKeys(`prev:${filename}:`); for (const k of keys.sort().slice(0, Math.max(0, keys.length - PREV_KEEP))) { await idbDelete(k); } } catch { /* archival never blocks a save */ } } const SHARED_REFRESH_MS = 5 * 60_000; const SHARED_FILES = ["allowlist.jsonc", "preferences.jsonc"] as const; const __lastSharedRefresh = new Map(); /** Pull one shared file from GDrive into the cache. Returns true if the * cached copy changed. `force` bypasses the throttle (startup prime). */ async function pullSharedFile(filename: string, force: boolean): Promise { const now = Date.now(); if (!force && now - (__lastSharedRefresh.get(filename) || 0) < SHARED_REFRESH_MS) return false; __lastSharedRefresh.set(filename, now); try { const fresh = await gDriveRead(filename); if (!fresh) return false; const cached = await idbRead(filename); if (fresh === cached) return false; await idbWrite(filename, fresh); console.log(`[settings] ${filename} refreshed from GDrive (shared copy changed)`); return true; } catch { /* offline / auth pending — keep serving the cache */ } return false; } function refreshSharedFile(filename: string, _cached: string): void { void pullSharedFile(filename, false); } /** Prime the shared configs from GDrive. The lazy stale-while-revalidate * path alone left the phone a step behind: the read that TRIGGERS the pull * is still served the stale cache, so a desktop approval only took effect * on the *second* message opened — and an app update didn't help, because * IndexedDB survives a reinstall (Bob 2026-07-31: "approved a site on my * PC … reinstall didn't pick up the new approval on Android"). Called once * GDrive auth is live at boot and again on resume, so the cache is already * current before the first message is rendered. */ export async function primeSharedConfigs(force = true): Promise { for (const f of SHARED_FILES) { try { await pullSharedFile(f, force); } catch (e: any) { console.warn(`[settings] prime ${f}: ${e?.message || e}`); } } } /** Load accounts — first from IndexedDB cache, then GDrive */ export async function loadAccounts(): Promise { // Try local cache first const cached = await idbRead("accounts.jsonc"); if (cached) { try { const data = parseJsonc(cached); const raw: any[] = data.accounts || (Array.isArray(data) ? data : []); if (raw.length > 0) { return raw.map((a: any) => normalizeAccount(a, data.name)); } } catch (e: any) { console.warn(`[settings] Cached accounts.jsonc parse failed: ${e.message}`); } } // Try GDrive const content = await gDriveRead("accounts.jsonc"); if (content) { await idbWrite("accounts.jsonc", content); try { const data = parseJsonc(content); const raw: any[] = data.accounts || (Array.isArray(data) ? data : []); return raw.map((a: any) => normalizeAccount(a, data.name)); } catch (e: any) { console.warn(`[settings] GDrive accounts.jsonc parse failed: ${e.message}`); } } return []; } /** Load accounts directly from GDrive, bypassing local cache */ export async function loadAccountsFromCloud(): Promise { const content = await gDriveRead("accounts.jsonc"); if (content) { await idbWrite("accounts.jsonc", content); try { const data = parseJsonc(content); const raw: any[] = data.accounts || (Array.isArray(data) ? data : []); return raw.map((a: any) => normalizeAccount(a, data.name)); } catch (e: any) { console.warn(`[settings] loadAccountsFromCloud parse failed: ${e.message}`); } } return []; } /** Save accounts to IndexedDB and GDrive */ export async function saveAccounts(accounts: AccountConfig[]): Promise { const content = JSON.stringify({ accounts }, null, 2); await idbWrite("accounts.jsonc", content); await gDriveWrite("accounts.jsonc", content); } /** Load preferences */ export async function loadPreferences(): Promise { const cached = await idbRead("preferences.jsonc"); if (cached) { refreshSharedFile("preferences.jsonc", cached); try { const data = parseJsonc(cached); return { ui: { ...DEFAULT_PREFERENCES.ui, ...data.ui }, sync: { ...DEFAULT_PREFERENCES.sync, ...data.sync }, autocomplete: { ...DEFAULT_PREFERENCES.autocomplete, ...data.autocomplete }, }; } catch { /* parse error */ } } // Try GDrive const content = await gDriveRead("preferences.jsonc"); if (content) { await idbWrite("preferences.jsonc", content); try { const data = parseJsonc(content); return { ui: { ...DEFAULT_PREFERENCES.ui, ...data.ui }, sync: { ...DEFAULT_PREFERENCES.sync, ...data.sync }, autocomplete: { ...DEFAULT_PREFERENCES.autocomplete, ...data.autocomplete }, }; } catch { /* parse error */ } } return { ...DEFAULT_PREFERENCES }; } /** Save preferences */ export async function savePreferences(prefs: any): Promise { const content = JSON.stringify(prefs, null, 2); await stashPrevIdb("preferences.jsonc", content); await idbWrite("preferences.jsonc", content); await gDriveWrite("preferences.jsonc", content); } /** Load full settings (accounts + preferences combined) */ export async function loadSettings(): Promise { const accounts = await loadAccounts(); const prefs = await loadPreferences(); return { accounts, ui: prefs.ui, sync: prefs.sync, autocomplete: prefs.autocomplete as AutocompleteSettings, store: { basePath: "indexeddb", compressionBoundaryDays: 365, }, }; } /** Save full settings */ export async function saveSettings(settings: MailxSettings): Promise { await saveAccounts(settings.accounts); await savePreferences({ ui: settings.ui, sync: settings.sync, autocomplete: settings.autocomplete }); } /** Load allowlist */ export async function loadAllowlist(): Promise { const cached = await idbRead("allowlist.jsonc"); if (cached) { refreshSharedFile("allowlist.jsonc", cached); try { return parseJsonc(cached); } catch { /* */ } } const content = await gDriveRead("allowlist.jsonc"); if (content) { await idbWrite("allowlist.jsonc", content); try { return parseJsonc(content); } catch { /* */ } } return { ...DEFAULT_ALLOWLIST }; } const ALLOWLIST_KEYS = ["senders", "domains", "recipients", "flaggedSenders", "flaggedDomains"] as const; function allowlistEntryCount(l: any): number { return ALLOWLIST_KEYS.reduce((n, k) => n + (Array.isArray(l?.[k]) ? l[k].length : 0), 0); } /** Read-modify-write against the FRESHEST copy. The load→mutate→save shape * clobbered the shared file twice (2026-07-22/23): a phone whose cached * base was 12 days stale wrote it back minus everything added since, and a * fresh install whose base was DEFAULT_ALLOWLIST wiped the file to ~nothing * (restored from Drive revisions). Mutations now apply to the live cloud * copy when reachable; the cache is only the offline fallback. */ export async function updateAllowlist(mutate: (list: typeof DEFAULT_ALLOWLIST) => typeof DEFAULT_ALLOWLIST | void): Promise { let base: typeof DEFAULT_ALLOWLIST | null = null; try { const fresh = await gDriveRead("allowlist.jsonc"); if (fresh) { await idbWrite("allowlist.jsonc", fresh); try { base = parseJsonc(fresh); } catch { /* corrupt cloud copy — fall back */ } } } catch { /* offline / auth pending */ } if (!base) base = await loadAllowlist(); const out = mutate(base) || base; await saveAllowlist(out); return out; } /** Save allowlist. Backstop shrink guard: a write that would discard more * than half of a substantial cloud copy is the wipe signature (stale or * default base) — keep it out of the shared file. The local cache still * updates so the device's own view is consistent; the next * stale-while-revalidate pull re-syncs it with the cloud. */ export async function saveAllowlist(list: typeof DEFAULT_ALLOWLIST): Promise { const content = JSON.stringify(list, null, 2); await stashPrevIdb("allowlist.jsonc", content); await idbWrite("allowlist.jsonc", content); try { const cloudRaw = await gDriveRead("allowlist.jsonc"); if (cloudRaw) { const cloudCount = allowlistEntryCount(parseJsonc(cloudRaw)); const newCount = allowlistEntryCount(list); if (cloudCount > 10 && newCount < cloudCount / 2) { console.error(`[settings] REFUSING allowlist cloud write: ${cloudCount} → ${newCount} entries (stale/default base — see 2026-07-23 wipe). Local cache updated only.`); return; } } } catch { /* cloud unreadable — proceed; the guard only fires when it can verify */ } await gDriveWrite("allowlist.jsonc", content); } /** Load autocomplete settings */ export async function loadAutocomplete(): Promise { const prefs = await loadPreferences(); return prefs.autocomplete as AutocompleteSettings; } /** Save autocomplete settings */ export async function saveAutocomplete(settings: AutocompleteSettings): Promise { const prefs = await loadPreferences(); (prefs as any).autocomplete = settings; await savePreferences(prefs); } /** Get history days — read from preferences */ export async function getHistoryDays(): Promise { const prefs = await loadPreferences(); return prefs.sync.historyDays || 30; } /** Get prefetch setting */ export async function getPrefetch(): Promise { const prefs = await loadPreferences(); return prefs.sync.prefetch !== false; } /** Get storage info */ export function getStorageInfo(): { provider: string; mode: string; folderId?: string; folderName?: string; folderPath?: string; folderOwner?: string } { return { provider: gDriveFolderId ? "gdrive" : "local", mode: gDriveFolderId ? "api" : "local", folderId: gDriveFolderId || undefined, folderName: gDriveFolderName || undefined, folderPath: gDriveFolderPath || undefined, folderOwner: gDriveFolderOwner || undefined, }; } /** Clear all cached settings — used for "Reset Store" */ export async function clearSettings(): Promise { await idbDelete("accounts.jsonc"); await idbDelete("preferences.jsonc"); await idbDelete("allowlist.jsonc"); } // ── Per-device settings ── const DEVICE_ID_KEY = "mailx-device-id"; /** Get or create a stable device ID (UUID stored in localStorage) */ export function getDeviceId(): string { let id = localStorage.getItem(DEVICE_ID_KEY); if (!id) { id = crypto.randomUUID(); localStorage.setItem(DEVICE_ID_KEY, id); } return id; }