/** * Android bootstrap — wires WebMailxDB + WebMessageStore + GmailApiWebProvider + WebMailxService * into the mailxapi bridge. This replaces Node.js backend for Android WebView. * * On Android, everything runs in the same JavaScript context: * - wa-sqlite for metadata (via WebMailxDB) * - IndexedDB for message bodies (via WebMessageStore) * - Gmail/Outlook sync via REST APIs (plain fetch — no native bridge needed) * - IMAP accounts use BridgeTransport (via MAUI TCP bridge) — not yet implemented * * The existing client UI (app.ts, components/) is completely unchanged — * it calls window.mailxapi.* which this module provides. */ import { WebMailxDB } from "./db.js"; import { WebMessageStore } from "./web-message-store.js"; import { WebMailxService, type WebSyncManager } from "./web-service.js"; import { loadAccounts, loadAccountsFromCloud, saveAccounts, loadSettings, clearSettings, getDeviceId, setGDriveTokenProvider, setGDriveFolderId, primeSharedConfigs } from "./web-settings.js"; import { GmailApiWebProvider } from "./gmail-api-web.js"; import { ImapWebProvider } from "./imap-web-provider.js"; import { SmtpClient, type SmtpAuth } from "@bobfrankston/smtp-direct"; import { BridgeTcpTransport } from "@bobfrankston/tcp-transport"; import type { MailProvider, ProviderMessage } from "./provider-types.js"; import type { Folder, EmailAddress, AccountConfig } from "@bobfrankston/mailx-types"; // ── State ── let db: WebMailxDB; let bodyStore: WebMessageStore; let service: WebMailxService; let syncManager: AndroidSyncManager; const eventHandlers: ((event: any) => void)[] = []; // ── Event emitter ── function emitEvent(event: any): void { for (const h of eventHandlers) { try { h(event); } catch { /* ignore */ } } if (typeof (window as any)._msgapiServiceEvent === "function") { (window as any)._msgapiServiceEvent(event); } } // ── Helpers ── function toEmailAddress(addr: { name?: string; address?: string } | undefined): EmailAddress { return { name: addr?.name || "", address: addr?.address || "" }; } /** Verbose log — goes to logit but doesn't clutter the screen (silent=true) */ function vlog(msg: string): void { try { fetch(`https://rmf39.aaz.lt/logit/${encodeURIComponent("V/" + msg.substring(0, 800))}?log=mailx-android&silent=true`).catch(() => {}); } catch { /* ignore */ } } /** C156: user-visible bootstrap narration. A new-device install looked * frozen (Bob 2026-07-22) because the stage breadcrumbs only went to vlog. * This logs (console capture shows it pre-UI) AND emits a bootstrapStatus * event the client renders in the status bar + folder-tree loading slot. */ function narrate(msg: string): void { console.log(`[android] ${msg}`); emitEvent({ type: "bootstrapStatus", message: msg }); } // ── Sync Manager ── /** Race a promise against a wall-clock timeout. On timeout it REJECTS so the * caller's try/catch moves on — the underlying op may keep running in the * background, but it can no longer STALL the whole sync. Critical on Android: * an auth-failing IMAP account (outlook/aol) or a slow BridgeTransport * listFolders/fetch would otherwise hang Phase 1 forever and new mail for the * healthy accounts never arrived (Bob 2026-06-27). */ function withTimeout(label: string, ms: number, fn: () => Promise): Promise { return new Promise((resolve, reject) => { let done = false; const timer = setTimeout(() => { if (done) return; done = true; reject(new Error(`timeout after ${ms}ms: ${label}`)); }, ms); fn().then( (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } }, (e) => { if (!done) { done = true; clearTimeout(timer); reject(e); } }, ); }); } class AndroidSyncManager implements WebSyncManager { private providers = new Map(); private tokenProviders = new Map Promise>(); // Dedicated body-fetch lane per IMAP account, mirroring the desktop's // per-workload connections (v1.2.50). Body fetches used the SAME // connection as folder sync; iflow-direct serializes single commands but // not SELECT+FETCH *pairs*, so a concurrent folder sync could re-SELECT // another mailbox between fetchOne's SELECT and its UID FETCH — the FETCH // then ran against the wrong mailbox and returned nothing, which the // viewer showed as "no body text" (Bob 2026-07-03, bobma/4970037-38). // Lazy: the connection opens on the first body fetch. Gmail accounts skip // this — the Gmail provider is stateless HTTP with no SELECT state. private fetchProviders = new Map(); private imapAccountConfigs = new Map(); // Per-account FIFO so two body fetches (interactive click vs prefetch) // can't interleave their own SELECT+FETCH pairs on the fetch lane either. private fetchChains = new Map>(); // One prefetch session per account — prevents every syncAll tick from // spawning parallel fetch loops that race on IndexedDB and blow through // Gmail's per-user quota. private prefetchingAccounts = new Set(); // Single-flight guard for syncAll. It's triggered from THREE places — boot, // a 60s interval, and every visibilitychange→visible — with no coordination. // On Android everything (incl. wa-sqlite) runs on the main thread, so // overlapping runs stacked up: re-listing folders repeatedly, contending on // the DB, and freezing the event loop ~1s at a time — the sync never // advanced to fetching new mail, so the list stayed stale despite "Synced" // (Bob 2026-06-27). Coalesce concurrent calls to one in-flight run. private syncAllInflight: Promise | null = null; // Separate guard for the Phase-2/3 background pass (other folders + body // prefetch). It runs DETACHED from the inbox guard so a long folder sweep // can't make the 60s poll skip the inbox refresh (new mail must keep // flowing). One background pass at a time — overlapping triggers no-op. private bgSyncInflight: Promise | null = null; constructor( private db: WebMailxDB, private bodyStore: WebMessageStore, ) {} on(_event: string, _handler: (...args: any[]) => void): void { /* stub */ } emit(event: string, ...args: any[]): void { emitEvent({ type: event, ...args[0] }); } async addAccount(account: AccountConfig): Promise { vlog(`addAccount id=${account.id} email=${account.email} host=${account.imap?.host} auth=${account.imap?.auth}`); this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account)); // Idempotent: a provider already registered for this account is kept // as-is. The Android bootstrap re-runs the account load periodically; // recreating the provider each time built a fresh ImapWebProvider + // BridgeTransport and replaced the live one — which orphaned any // body fetch in flight on the old provider, so `fetchOne` came back // with no source and the message showed "Body parsed empty". A real // config change is rare and handled by a restart. if (this.providers.has(account.id)) { vlog(`addAccount ${account.id}: provider already registered — keeping it`); return; } if (this.isGmailAccount(account)) { const tokenProvider = this.tokenProviders.get(account.id); if (tokenProvider) { this.providers.set(account.id, new GmailApiWebProvider(tokenProvider)); console.log(`[sync] ${account.id}: Gmail API provider registered`); } else { console.warn(`[sync] ${account.id}: no token provider`); } } else if (account.imap?.host && account.imap?.user) { // Generic IMAP account — use BridgeTransport through MAUI's TCP bridge try { const provider = new ImapWebProvider({ server: account.imap.host, port: account.imap.port || 993, username: account.imap.user, password: account.imap.password, inactivityTimeout: 300000, // 300s for slow Dovecot fetchChunkSize: 10, fetchChunkSizeMax: 100, }, () => new BridgeTcpTransport()); this.providers.set(account.id, provider); this.imapAccountConfigs.set(account.id, account); vlog(`addAccount ${account.id}: IMAP provider registered (${account.imap.host}:${account.imap.port})`); console.log(`[sync] ${account.id}: IMAP provider registered (${account.imap.host})`); } catch (e: any) { vlog(`addAccount ${account.id}: IMAP provider FAILED: ${e.message}`); console.error(`[sync] ${account.id}: IMAP provider failed: ${e.message}`); } } else { vlog(`addAccount ${account.id}: no imap config, skipping`); } } setTokenProvider(accountId: string, provider: () => Promise): void { this.tokenProviders.set(accountId, provider); } private isGmailAccount(account: AccountConfig): boolean { return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false; } private getProvider(accountId: string): MailProvider | null { return this.providers.get(accountId) || null; } /** Provider for BODY fetches. IMAP accounts get their own lazily-opened * connection so a fetch can never share (and race) the sync connection's * SELECT state. Gmail (stateless HTTP) reuses the main provider. */ private getFetchProvider(accountId: string): MailProvider | null { const cfg = this.imapAccountConfigs.get(accountId); if (!cfg?.imap?.host || !cfg.imap.user) return this.getProvider(accountId); let p = this.fetchProviders.get(accountId); if (!p) { try { p = new ImapWebProvider({ server: cfg.imap.host, port: cfg.imap.port || 993, username: cfg.imap.user, password: cfg.imap.password, inactivityTimeout: 300000, fetchChunkSize: 10, fetchChunkSizeMax: 100, }, () => new BridgeTcpTransport()); this.fetchProviders.set(accountId, p); console.log(`[fetchBody] ${accountId}: dedicated fetch connection opened`); } catch (e: any) { console.error(`[fetchBody] ${accountId}: fetch-lane provider failed (${e?.message || e}) — falling back to sync connection`); return this.getProvider(accountId); } } return p; } /** Per-account FIFO for body fetches: one SELECT+FETCH pair at a time on * the fetch lane. A rejected task never breaks the chain. */ private enqueueFetch(accountId: string, fn: () => Promise): Promise { const prev = this.fetchChains.get(accountId) || Promise.resolve(); const next = prev.then(fn, fn); this.fetchChains.set(accountId, next.then(() => { /* */ }, () => { /* */ })); return next; } async syncAll(): Promise { // The inbox guard covers ONLY Phase 1 (inbox sync). Overlapping triggers // coalesce so they can't thrash the single main thread. if (this.syncAllInflight) { console.log("[sync] inbox sync already in flight — coalescing this trigger"); return this.syncAllInflight; } const t0 = Date.now(); console.log("[sync] syncAll START (inbox phase)"); this.syncAllInflight = this._syncInboxes() .catch((e: any) => { console.error(`[sync] inbox sync error: ${e?.message || e}`); }) .finally(() => { console.log(`[sync] inbox phase DONE in ${Date.now() - t0}ms`); this.syncAllInflight = null; }); // Phase 2/3 (other folders + body prefetch) run DETACHED, under their own // guard — they must not hold the inbox guard, or a slow folder sweep would // make the next 60s poll skip new inbox mail. this._kickBackgroundSync(); return this.syncAllInflight; } /** Phase 1: sync every account's INBOX IN PARALLEL so a slow account (e.g. * Gmail's metadata fetch, or an auth-failing IMAP account) can't delay new * mail for the others. Each account is time-boxed; network fetches overlap * even though JS is single-threaded (the awaits yield). */ private async _syncInboxes(): Promise { const accounts = this.db.getAccounts().filter(a => this.providers.has(a.id)); vlog(`syncInboxes: ${accounts.length} accounts: ${accounts.map(a => a.id).join(",")}`); await Promise.all(accounts.map(account => withTimeout(`${account.id} inbox`, 60000, async () => { const folders = await this.syncFolders(account.id); const inbox = folders.find(f => f.specialUse === "inbox"); if (inbox) { console.log(`[sync] ${account.id}: fetching INBOX (folderId=${inbox.id})`); await this.syncFolder(account.id, inbox.id); console.log(`[sync] ${account.id}: INBOX fetch done`); emitEvent({ type: "syncComplete", accountId: account.id }); } }).catch((e: any) => console.error(`[sync] ${account.id} inbox: ${e?.message || e}`)), )); } /** Phase 2 + 3: remaining folders, then body prefetch. Runs in the * background under its own single-flight guard. */ private _kickBackgroundSync(): void { if (this.bgSyncInflight) return; const t0 = Date.now(); this.bgSyncInflight = this._syncRemainingAndPrefetch() .catch((e: any) => console.error(`[sync] background sweep error: ${e?.message || e}`)) .finally(() => { console.log(`[sync] background sweep DONE in ${Date.now() - t0}ms`); this.bgSyncInflight = null; }); } private async _syncRemainingAndPrefetch(): Promise { const accounts = this.db.getAccounts().filter(a => this.providers.has(a.id)); // Phase 2: remaining folders (per account, per folder time-boxed). for (const account of accounts) { try { const folders = this.db.getFolders(account.id); const remaining = folders.filter(f => f.specialUse !== "inbox"); for (const folder of remaining) { try { await withTimeout(`${account.id} ${folder.path}`, 45000, () => this.syncFolder(account.id, folder.id)); } catch (e: any) { console.error(`[sync] Skip ${folder.path}: ${e.message}`); } } this.db.updateLastSync(account.id, Date.now()); emitEvent({ type: "syncComplete", accountId: account.id }); } catch (e: any) { console.error(`[sync] ${account.id}: ${e.message}`); emitEvent({ type: "syncError", accountId: account.id, error: e.message }); } } // Phase 3: body prefetch (fire-and-forget per account). for (const account of accounts) { this.prefetchBodies(account.id).catch(e => console.error(`[prefetch] ${account.id}: ${e.message}`)); } } /** Background body prefetch — download bodies for messages that don't have * them yet, so tapping a message in the list opens instantly from cache. */ async prefetchBodies(accountId: string): Promise { if (this.prefetchingAccounts.has(accountId)) return; this.prefetchingAccounts.add(accountId); try { const BATCH_SIZE = 20; const THROTTLE_MS = 150; const RATE_LIMIT_PAUSE_MS = 30000; const ERROR_BUDGET = 10; const CONCURRENCY = 2; // S62: 2 in-flight per account let totalFetched = 0; let errors = 0; let announced = false; // Per-session blacklist: messages whose fetch errored once. // `getMessagesWithoutBody` returns the same set every batch // (no per-row failed-marker in DB yet), so without this set // a single failing message would burn the entire ERROR_BUDGET // by being retried first in every batch. Skipping locally // unblocks subsequent messages; next syncAll cycle starts // a new prefetch session and the blacklist resets. const failedThisSession = new Set(); // S62: INBOX always first. Within each folder the DB returns rows // most-recent-first (PRIMARY KEY order), so newest unfetched INBOX // mail wins the queue. A slow label (`[Gmail]/Jerrry`, etc.) can't // starve INBOX any more. const folderPriority = (folderId: number): number => { const f = this.db.getFolders(accountId).find((x: any) => x.id === folderId); return f?.specialUse === "inbox" ? 0 : 1; }; let rateLimitCooldownUntil = 0; while (true) { const allMissing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE * 4); const missing = allMissing.filter((m: any) => !failedThisSession.has(m.uid)); if (missing.length === 0) break; // Cap to BATCH_SIZE after filtering so a deep blacklist // doesn't leave us with a tiny working set per pass. if (missing.length > BATCH_SIZE) missing.length = BATCH_SIZE; if (!announced) { console.log(`[prefetch] ${accountId}: ${missing.length}+ bodies to fetch`); vlog(`prefetch ${accountId} start: ${missing.length}+ pending`); announced = true; } // Sort this batch INBOX-first. getMessagesWithoutBody doesn't // know the priority, and re-querying per folder would multiply // the SELECTs. One in-memory sort is cheap. missing.sort((a: any, b: any) => folderPriority(a.folderId) - folderPriority(b.folderId)); let progressedThisBatch = false; let batchAborted = false; // Bounded-concurrency worker pool. Each worker pulls the next // unclaimed item from `missing`. Shared flags (errors, // rateLimitCooldownUntil, progressedThisBatch) are updated // inside the loop — sql.js is single-threaded so there's no // actual race on reads/writes. let cursor = 0; const worker = async (): Promise => { while (cursor < missing.length) { if (batchAborted) return; if (errors >= ERROR_BUDGET) return; const idx = cursor++; const m = missing[idx]; // Honor rate-limit cooldown across workers. const now = Date.now(); if (rateLimitCooldownUntil > now) { await new Promise(r => setTimeout(r, rateLimitCooldownUntil - now)); } if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) { this.db.updateBodyPath(accountId, m.folderId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`); progressedThisBatch = true; continue; } try { const result = await this.fetchMessageBody(accountId, m.folderId, m.uid); if (result) { totalFetched++; progressedThisBatch = true; emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId }); } else { errors++; failedThisSession.add(m.uid); } } catch (e: any) { errors++; failedThisSession.add(m.uid); const msg = String(e?.message || ""); if (/429|rate|too many/i.test(msg)) { console.log(`[prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`); rateLimitCooldownUntil = Date.now() + RATE_LIMIT_PAUSE_MS; } else { console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`); } } // Throttle kept per-request to spread load on flaky // phone networks; concurrency-2 means effective request // rate is ~1 per THROTTLE_MS/2. await new Promise(r => setTimeout(r, THROTTLE_MS)); } }; await Promise.all(Array.from({ length: Math.min(CONCURRENCY, missing.length) }, () => worker())); if (errors >= ERROR_BUDGET) { console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`); vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`); return; } if (!progressedThisBatch) { console.warn(`[prefetch] ${accountId}: batch made no progress, stopping`); break; } } if (totalFetched > 0) { console.log(`[prefetch] ${accountId}: done — cached ${totalFetched} bodies`); vlog(`prefetch ${accountId} done: ${totalFetched} cached`); } } finally { this.prefetchingAccounts.delete(accountId); } } async syncFolders(accountId: string): Promise { const provider = this.getProvider(accountId); if (!provider) { const existing = this.db.getFolders(accountId); vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`); return existing; } emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 }); console.log(`[sync] ${accountId}: listing folders from provider`); let providerFolders: any[] = []; try { providerFolders = await provider.listFolders(); } catch (e: any) { console.error(`[sync] ${accountId}: listFolders threw: ${e?.message || e}`); throw e; } console.log(`[sync] ${accountId}: provider returned ${providerFolders.length} folders` + (providerFolders.length > 0 ? ` (sample: ${providerFolders.slice(0, 3).map((f: any) => f.path || f.name).join(", ")})` : "")); for (const folder of providerFolders) { const flags = folder.flags || []; if (flags.some((f: string) => f.toLowerCase() === "\\noselect")) continue; this.db.upsertFolder(accountId, folder.path, folder.name, folder.specialUse, folder.delimiter); } emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 100 }); const dbFolders = this.db.getFolders(accountId); console.log(`[sync] ${accountId}: ${dbFolders.length} folders in db, inbox=${dbFolders.some((f: any) => f.specialUse === "inbox") ? "yes" : "no"}`); emitEvent({ type: "folderCountsChanged", accountId, counts: {} }); return dbFolders; } async syncFolder(accountId: string, folderId: number): Promise { const provider = this.getProvider(accountId); if (!provider) return; const folders = this.db.getFolders(accountId); const folder = folders.find(f => f.id === folderId); if (!folder) return; emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 }); const highestUid = this.db.getHighestUid(accountId, folderId); const startDate = new Date(Date.now() - 30 * 86400000); const account = this.db.getAccounts().find(a => a.id === accountId); const isGoogle = !!account && isGoogleAccount(account); let messages: ProviderMessage[]; if (highestUid > 0) { const opts: { source: boolean; knownUids?: Set } = { source: false }; if (isGoogle) { // Gmail re-lists a recent page (IDs aren't monotonic). Give it // the set of UIDs we already have so it fetches only new ones // instead of re-pulling ~200 every sync (Bob 2026-06-27). opts.knownUids = new Set(this.db.getUidsForFolder(accountId, folderId)); } messages = await provider.fetchSince(folder.path, highestUid, opts); // IMAP UIDs are monotonic — keep the high-water guard. For Gmail the // knownUids skip already returned only NEW messages; applying // `uid > highestUid` to Gmail's HASH uids is a lottery that wrongly // drops new mail whose hash falls below the mark (the long-standing // "Gmail new messages missing" bug). if (!isGoogle) { messages = messages.filter(m => m.uid > highestUid); } } else { const tomorrow = new Date(Date.now() + 86400000); messages = await provider.fetchByDate(folder.path, startDate, tomorrow, { source: false }); } if (messages.length > 0) { console.log(`[sync] ${folder.path}: ${messages.length} messages`); this.storeProviderMessages(accountId, folderId, messages); this.db.recalcFolderCounts(folderId); emitEvent({ type: "folderCountsChanged", accountId, counts: {} }); } // Reconcile deletions — messages present locally but no longer on the // server (moved away, deleted on another client). Without this, the // Android client never drops removed rows: e.g., moves to _spam from // another client showed up in _spam (next time it synced) but never // disappeared from INBOX. // // Same safety guards as the desktop reconcile path: // - Skip if the server list is empty but local has messages (likely // a transient API failure that returned []). // - Refuse to delete more than 50% of local in one pass — better to // keep phantoms than to wipe a folder on a sync bug. Rebuild local // cache fixes a stuck state. try { const serverUidsArr = await provider.getUids(folder.path); const serverUids = new Set(serverUidsArr); const localUids = this.db.getUidsForFolder(accountId, folderId); if (serverUidsArr.length === 0 && localUids.length > 0) { console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`); } else { const toDelete = localUids.filter(uid => !serverUids.has(uid)); const RECONCILE_DELETE_THRESHOLD = 0.5; if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) { console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`); } else { for (const uid of toDelete) { this.db.deleteMessage(accountId, folderId, uid); this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => {}); } if (toDelete.length > 0) { console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`); this.db.recalcFolderCounts(folderId); emitEvent({ type: "folderCountsChanged", accountId, counts: {} }); } } } } catch (e: any) { console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`); } emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] }); emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 }); } private storeProviderMessages(accountId: string, folderId: number, messages: ProviderMessage[]): void { this.db.beginTransaction(); try { for (const msg of messages) { const flags: string[] = []; if (msg.seen) flags.push("\\Seen"); if (msg.flagged) flags.push("\\Flagged"); if (msg.answered) flags.push("\\Answered"); if (msg.draft) flags.push("\\Draft"); // Store the Gmail providerId in bodyPath as "gmail:" so we can // fetch the body directly without re-listing 1000 messages from the folder const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : ""; const sentRaw = (msg as any).sentDate instanceof Date ? (msg as any).sentDate.getTime() : NaN; this.db.upsertMessage({ accountId, folderId, uid: msg.uid, messageId: msg.messageId || "", inReplyTo: "", references: [], date: msg.date ? msg.date.getTime() : Date.now(), sentDate: Number.isFinite(sentRaw) ? sentRaw : undefined, subject: msg.subject || "", from: toEmailAddress(msg.from?.[0]), to: msg.to.map(a => toEmailAddress(a)), cc: msg.cc.map(a => toEmailAddress(a)), flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath, }); } this.db.commitTransaction(); } catch (e: any) { this.db.rollbackTransaction(); console.error(`[sync] storeMessages error: ${e.message}`); } } async fetchMessageBody(accountId: string, folderId: number, uid: number): Promise { const t0 = Date.now(); if (await this.bodyStore.hasMessage(accountId, folderId, uid)) { // Cache hit is the common case on every body open — not worth a // log line each time. Only an actual fetch (below) is logged. return await this.bodyStore.getMessage(accountId, folderId, uid); } const provider = this.getFetchProvider(accountId); if (!provider) { console.warn(`[fetchBody] No provider for ${accountId}`); return null; } // Look up the Gmail providerId stored in body_path during sync const envelope = this.db.getMessageByUid(accountId, uid, folderId); const bp = (envelope as any)?.bodyPath || ""; // 60 s wall-clock cap — infinite hang was the user-reported symptom // ("fetch message body on android is infinite"). A dead BridgeTransport // socket won't recover by waiting. Legit fetches finish in seconds. const FETCH_TIMEOUT_MS = 60_000; let __folderPath = "(n/a)"; let msg: any = null; try { // FIFO per account: the SELECT+FETCH pair must not interleave with // another body fetch's pair on the shared fetch-lane connection. msg = await this.enqueueFetch(accountId, () => Promise.race([ (async (): Promise => { if (bp.startsWith("gmail:") && (provider as any).fetchById) { const providerId = bp.substring(6); __folderPath = `gmail:${bp.substring(6)}`; return (provider as any).fetchById(providerId, { source: true }); } const folders = this.db.getFolders(accountId); const folder = folders.find(f => f.id === folderId); if (!folder) { __folderPath = "(folder row missing)"; return null; } __folderPath = folder.path || "(empty path)"; return provider.fetchOne(folder.path, uid, { source: true }); })(), new Promise((_, reject) => setTimeout( () => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)), FETCH_TIMEOUT_MS )), ])); } catch (e: any) { console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`); throw e; } if (!msg?.source) { // Detail so the next log pins WHY: folder path used, and whether // the provider returned null vs a message with an empty body. const why = msg == null ? "fetchOne returned null" : "message had no .source"; console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, path=${__folderPath}, ${why}, ${Date.now() - t0}ms)`); return null; } // Encode the UTF-8 string back to bytes for storage const raw = new TextEncoder().encode(msg.source); await this.bodyStore.putMessage(accountId, folderId, uid, raw); this.db.updateBodyPath(accountId, folderId, uid, `idb:${accountId}/${folderId}/${uid}`); console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`); return raw; } async updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise { this.db.updateMessageFlags(accountId, folderId, uid, flags); this.db.recalcFolderCounts(folderId); this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags }); emitEvent({ type: "folderCountsChanged", accountId, counts: {} }); } async trashMessage(accountId: string, folderId: number, uid: number): Promise { this.db.deleteMessage(accountId, folderId, uid); this.db.queueSyncAction(accountId, "trash", uid, folderId); emitEvent({ type: "messageDeleted", accountId, folderId, uid }); emitEvent({ type: "folderCountsChanged", accountId, counts: {} }); } async trashMessages(accountId: string, messages: { uid: number; folderId: number }[]): Promise { for (const m of messages) await this.trashMessage(accountId, m.folderId, m.uid); } async moveMessage(accountId: string, uid: number, folderId: number, targetFolderId: number): Promise { this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId }); emitEvent({ type: "messageMoved", accountId, fromFolderId: folderId, toFolderId: targetFolderId, uid }); } async moveMessages(accountId: string, messages: { uid: number; folderId: number }[], targetFolderId: number): Promise { for (const m of messages) await this.moveMessage(accountId, m.uid, m.folderId, targetFolderId); } async moveMessageCrossAccount(): Promise { throw new Error("Cross-account move not supported on mobile"); } async undeleteMessage(accountId: string, uid: number, folderId: number): Promise { this.db.queueSyncAction(accountId, "undelete", uid, folderId); } /** Q112: drain queued move/flag/trash actions to the provider. Android is * standalone — it pushes state changes directly to Gmail (or other * provider) the same way desktop does. Called from the periodic 2-min * tick above. `send` actions drain separately via `processSendQueue`. */ async processSyncActions(accountId: string): Promise { const provider: any = this.providers.get(accountId); if (!provider) return; const pending = this.db.getPendingSyncActions(accountId) .filter((a: any) => a.action !== "send"); if (pending.length === 0) return; const folders = this.db.getFolders(accountId); const folderPath = (id: number): string | null => { const f = folders.find((x: any) => x.id === id); return f?.path || null; }; for (const p of pending) { const path = folderPath(p.folderId); if (!path) { this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`); continue; } try { if (p.action === "flags" && typeof provider.setFlags === "function") { await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : [])); } else if (p.action === "trash" && typeof provider.trashMessage === "function") { // Pass the stored Gmail id so the provider trashes by id // instead of its capped list-and-hash search — that search // misses messages past the most-recent ~1000 in a large // mailbox, fails, and the deletion un-happens. await provider.trashMessage(path, p.uid, this.db.getProviderId(accountId, p.uid)); } else if (p.action === "move" && typeof provider.moveMessage === "function") { const toId = p.targetFolderId as number; const toPath = folderPath(toId); if (!toPath) { this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`); continue; } await provider.moveMessage(path, p.uid, toPath, this.db.getProviderId(accountId, p.uid)); } else { this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`); continue; } this.db.completeSyncActionByUid(accountId, p.action, p.uid); } catch (e: any) { const msg = e?.message || String(e); console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`); this.db.failSyncActionByUid(accountId, p.action, p.uid, msg); } } } /** In-flight send tracker keyed by queueUid. Prevents * processSendQueue from re-firing the same row when it overlaps * with an in-progress attempt (e.g., the periodic tick fires while * the original attemptSend's promise is still pending). Without * this, a slow Gmail/SMTP send race-conditions into a double-send. */ private sendInFlight = new Set(); async queueOutgoingLocal(accountId: string, rawMessage: string): Promise { // Local-first: PERSIST to sync_actions before attempting the network // send, so a crash / offline / process kill between now and SMTP ACK // doesn't drop the message. Desktop parity — PC writes `.ltr` to disk // synchronously; Android writes a sync_actions row and now FLUSHES // sql.js → IndexedDB before returning. The previous version relied on // the 1-second scheduleSave debounce, so a tab-close inside the debounce // window erased the row before it was persisted — the "letter just // disappeared" symptom user-reported 2026-04-30. // // Equivalent of PC's `~/.mailx/outbox//*.ltr` durable write. const queueUid = -Date.now(); this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage }); await this.db.flush(); this.attemptSend(accountId, queueUid, rawMessage); } /** Kick off a send for a message that's already in the queue. Called by * queueOutgoingLocal on a fresh submit AND by processSendQueue on * startup / periodic tick for anything stranded from a prior run. * Guards against double-send via sendInFlight. */ private attemptSend(accountId: string, queueUid: number, rawMessage: string): void { if (this.sendInFlight.has(queueUid)) return; this.sendInFlight.add(queueUid); // Helper to mark complete + flush + clear in-flight — used on every // success/failure exit. Flush ensures the row deletion or attempt // counter actually reaches IndexedDB before the next process-kill, // matching the "persist before network" rule for the post-network // outcome too. Without flushing on completion, a successful send // followed by a fast app-close left the row in the queue, which // looked like a "stuck" message on next launch. const finishSend = (success: boolean, error?: string) => { if (success) { this.db.completeSyncActionByUid(accountId, "send", queueUid); } else { this.db.failSyncActionByUid(accountId, "send", queueUid, error || "send failed"); } this.db.flush().catch(() => { /* save will retry on next mutation */ }); this.sendInFlight.delete(queueUid); }; const provider = this.getProvider(accountId); if (provider && typeof (provider as any).sendRaw === "function") { (provider as any).sendRaw(rawMessage) .then((result: { id: string; threadId: string }) => { console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`); finishSend(true); emitEvent({ type: "sendComplete", accountId, messageId: result.id }); }) .catch((e: any) => { console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`); finishSend(false, e.message || String(e)); emitEvent({ type: "sendError", accountId, error: e.message }); }); return; } // Non-Gmail: use smtp-direct + BridgeTransport. Pull SMTP config from the // stored account JSON. const accounts = db.getAccountConfigs(); const row = accounts.find(a => a.id === accountId); if (!row) { const e = "Unknown account"; console.error(`[send] ${accountId}: ${e}`); finishSend(false, e); emitEvent({ type: "sendError", accountId, error: e }); return; } let account: AccountConfig; try { account = JSON.parse(row.configJson); } catch { const e = "Account config malformed"; finishSend(false, e); emitEvent({ type: "sendError", accountId, error: e }); return; } if (!account.smtp) { const e = "No SMTP config for this account"; console.error(`[send] ${accountId}: ${e}`); finishSend(false, e); emitEvent({ type: "sendError", accountId, error: e }); return; } this.sendViaSmtpDirect(accountId, account, rawMessage) .then((result) => { console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`); finishSend(true); emitEvent({ type: "sendComplete", accountId }); }) .catch((e: any) => { console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`); finishSend(false, e.message || String(e)); emitEvent({ type: "sendError", accountId, error: e.message }); }); } /** Drain any stranded 'send' queue entries — called at startup and on * each periodic sync tick so messages queued while offline or stranded * by a crash get a retry. Each row keeps its queueUid as tracking key. */ async processSendQueue(accountId: string): Promise { const pending = this.db.getPendingSyncActions(accountId).filter(a => a.action === "send" && a.rawMessage); if (pending.length === 0) return; console.log(`[send] ${accountId}: draining ${pending.length} queued message(s)`); for (const p of pending) { this.attemptSend(accountId, p.uid, p.rawMessage); } } /** Build SMTP config from account, send via smtp-direct over BridgeTransport. */ private async sendViaSmtpDirect( accountId: string, account: AccountConfig, raw: string, ): Promise<{ accepted: string[]; rejected: { address: string; code: number; message: string }[] }> { const SMTP_PORT_STARTTLS = 587; const SMTP_PORT_IMPLICIT_TLS = 465; const smtp = account.smtp!; const smtpPort = smtp.port || SMTP_PORT_STARTTLS; const smtpHost = smtp.host || account.imap?.host; if (!smtpHost) throw new Error("No SMTP host"); // Auth: password → PLAIN; oauth2 → XOAUTH2 (token from this account's provider) const smtpUser = smtp.user || account.imap?.user || account.email; const authType = smtp.auth || (account.imap?.password ? "password" : undefined); let auth: SmtpAuth | undefined; if (authType === "password") { const pass = smtp.password || account.imap?.password; if (!pass) throw new Error("SMTP password not configured"); auth = { method: "PLAIN", user: smtpUser, pass }; } else if (authType === "oauth2") { const tp = this.tokenProviders.get(accountId); if (!tp) throw new Error("OAuth token provider not registered"); const token = await tp(); auth = { method: "XOAUTH2", user: smtpUser, token }; } // Recipients from the HEADER SECTION only, unfolded — matching over // the whole raw file scraped Cc/Bcc lines out of the quoted reply // chain in the body and silently added those addresses to RCPT TO // (desktop hit this 2026-07-21). See mailx-imap sendRaw. const parseAddrs = (s: string) => s.match(/[\w.+-]+@[\w.-]+/g) || []; const headerEnd = raw.search(/\r?\n\r?\n/); const headerSec = (headerEnd === -1 ? raw : raw.slice(0, headerEnd)) .replace(/\r?\n[ \t]+/g, " "); const toMatch = headerSec.match(/^To:\s*(.+)$/mi); const ccMatch = headerSec.match(/^Cc:\s*(.+)$/mi); const bccMatch = headerSec.match(/^Bcc:\s*(.+)$/mi); const fromMatch = headerSec.match(/^From:\s*(.+)$/mi); const recipients = [ ...(toMatch ? parseAddrs(toMatch[1]) : []), ...(ccMatch ? parseAddrs(ccMatch[1]) : []), ...(bccMatch ? parseAddrs(bccMatch[1]) : []), ]; const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email; if (recipients.length === 0) throw new Error("No recipients"); // Bcc stripped from the transmitted copy — headers only, folds included. const stripBcc = (h: string): string => h.replace(/^Bcc:[^\n]*(\r?\n[ \t][^\n]*)*(\r?\n|$)/mi, "").replace(/\r?\n$/, ""); const rawToSend = headerEnd === -1 ? stripBcc(raw) : stripBcc(raw.slice(0, headerEnd)) + raw.slice(headerEnd); const client = new SmtpClient({ host: smtpHost, port: smtpPort, secure: smtpPort === SMTP_PORT_IMPLICIT_TLS, auth, localname: "mailx-android", }, () => new BridgeTcpTransport()); try { await client.connect(); return await client.sendMail({ from: sender, to: recipients }, rawToSend); } finally { try { await client.quit(); } catch { /* ignore */ } } } async saveDraft(_accountId: string, _raw: string, _prevUid?: number, _draftId?: string): Promise { return null; } async deleteDraft(_accountId: string, _draftUid: number): Promise { } async reauthenticate(_accountId: string): Promise { return false; } async searchOnServer(): Promise { return []; } async syncAllContacts(): Promise { } } // ── OAuth credentials (same "installed" client as desktop) ── // Same credentials as desktop mailx (iflow-credentials.json from @bobfrankston/iflow-direct) const OAUTH_CLIENT = { clientId: "884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u.apps.googleusercontent.com", clientSecret: "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", authUri: "https://accounts.google.com/o/oauth2/auth", tokenUri: "https://oauth2.googleapis.com/token", // Reverse client ID scheme — auto-allowed for Google "installed" apps redirectUri: "com.googleusercontent.apps.884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u:/oauth2callback", }; // Use full drive scope so we can read/write the desktop's accounts.jsonc + clients.jsonc. // drive.file is per-consent-grant: files created by desktop's grant aren't visible to Android's grant // even with the same client_id. drive (full) lets us see all files the user has access to. // `tasks` added 2026-05-05 to match desktop. Without it, the Tasks pane's // HTTP calls 403 even though the same OAuth grant works for Calendar. const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/tasks"; // ── Token cache (IndexedDB) ── /** Canonicalize email so Gmail's dot/+tag/case equivalences collapse to one * cache key. `bob.frankston+work@Gmail.com` and `bobfrankston@gmail.com` * hit the same mailbox — they must hit the same cached token, or the * user sees a fresh consent prompt every time the dotted vs dotless form * is used by different code paths (mirrors the desktop fix in * mailx-settings/index.ts:canonicalEmail; can't import that here because * this file runs in the Android WebView, not Node). */ function canonEmail(email: string): string { const s = (email || "").trim().toLowerCase(); const at = s.indexOf("@"); if (at < 0) return s; let local = s.slice(0, at); const domain = s.slice(at + 1); if (domain === "gmail.com" || domain === "googlemail.com") { const plus = local.indexOf("+"); if (plus >= 0) local = local.slice(0, plus); local = local.replace(/\./g, ""); return `${local}@gmail.com`; } return `${local}@${domain}`; } function tokenKey(email: string): string { return `oauth-token-${canonEmail(email).replace(/[@.]/g, "_")}`; } async function getCachedToken(email: string): Promise<{ access_token: string; refresh_token?: string; expires_at?: number } | null> { const key = tokenKey(email); const raw = localStorage.getItem(key); if (!raw) { console.log(`[oauth] no cached token for ${email} (localStorage key=${key} missing — fresh consent will follow)`); return null; } try { const parsed = JSON.parse(raw); const hasRefresh = !!parsed?.refresh_token; const expiresAt = parsed?.expires_at || 0; const expiresIn = expiresAt ? Math.round((expiresAt - Date.now()) / 1000) : 0; console.log(`[oauth] cached token for ${email}: hasRefresh=${hasRefresh}, expiresIn=${expiresIn}s`); return parsed; } catch { return null; } } async function setCachedToken(email: string, token: { access_token: string; refresh_token?: string; expires_at?: number }): Promise { const key = tokenKey(email); localStorage.setItem(key, JSON.stringify(token)); } async function clearCachedToken(email: string): Promise { const key = tokenKey(email); localStorage.removeItem(key); } // ── Token exchange ── async function exchangeCodeForTokens(code: string): Promise<{ access_token: string; refresh_token?: string; expires_in: number }> { const body = new URLSearchParams({ code, client_id: OAUTH_CLIENT.clientId, client_secret: OAUTH_CLIENT.clientSecret, redirect_uri: OAUTH_CLIENT.redirectUri, grant_type: "authorization_code", }); const res = await fetch(OAUTH_CLIENT.tokenUri, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!res.ok) { const text = await res.text(); throw new Error(`Token exchange failed: ${res.status} ${text}`); } return res.json(); } async function refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number }> { const body = new URLSearchParams({ refresh_token: refreshToken, client_id: OAUTH_CLIENT.clientId, client_secret: OAUTH_CLIENT.clientSecret, grant_type: "refresh_token", }); const res = await fetch(OAUTH_CLIENT.tokenUri, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), }); if (!res.ok) { const text = await res.text(); throw new Error(`Token refresh failed: ${res.status} ${text}`); } return res.json(); } // ── Token provider (browser OAuth, same as desktop) ── function createNativeTokenProvider(email: string): () => Promise { return () => { // C158: single-flight per email. Startup fires several concurrent // token requests (GDrive reconcile, contacts sync, syncAll) — with // no cached token each one launched its OWN browser-consent intent // (three "Starting OAuth flow" in one boot, 2026-07-22 logit trail). // Concurrent callers now share one resolution. Keyed on a window // global, not a module local, because the double-init bug that // exposed this also loads the module twice — two module instances // must still share the guard. const w = window as any; const inflight: Map> = w.__rmfOAuthInflight ||= new Map(); const key = canonEmail(email); const existing = inflight.get(key); if (existing) return existing; const p = fetchTokenForEmail(email).finally(() => inflight.delete(key)); inflight.set(key, p); return p; }; } async function fetchTokenForEmail(email: string): Promise { // Check cached token first const cached = await getCachedToken(email); if (cached?.access_token) { const expiresAt = cached.expires_at || 0; const bufferMs = 5 * 60 * 1000; // 5 min buffer if (Date.now() < expiresAt - bufferMs) { return cached.access_token; } // Try refresh if (cached.refresh_token) { try { console.log(`[oauth] Refreshing token for ${email}`); const refreshed = await refreshAccessToken(cached.refresh_token); const token = { access_token: refreshed.access_token, refresh_token: cached.refresh_token, expires_at: Date.now() + refreshed.expires_in * 1000, }; await setCachedToken(email, token); return token.access_token; } catch (e: any) { console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`); } } } // No valid token — start browser OAuth flow const bridge = (window as any)._nativeBridge; if (!bridge?.app?.startOAuth) { throw new Error("No native OAuth bridge"); } const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({ client_id: OAUTH_CLIENT.clientId, redirect_uri: OAUTH_CLIENT.redirectUri, response_type: "code", scope: OAUTH_SCOPES, access_type: "offline", prompt: "consent", login_hint: email, }).toString(); console.log(`[oauth] Starting browser consent for ${email}`); narrate(`Signing into ${email}…`); const code = await bridge.app.startOAuth(authUrl); const tokens = await exchangeCodeForTokens(code); const token = { access_token: tokens.access_token, refresh_token: tokens.refresh_token, expires_at: Date.now() + tokens.expires_in * 1000, }; await setCachedToken(email, token); console.log(`[oauth] Token obtained for ${email}`); return token.access_token; } // ── GDrive folder lookup ── async function registerDeviceInGDrive( tokenProvider: () => Promise, folderId: string, accountIds: string[] ): Promise { try { const token = await tokenProvider(); // Use persistent Android device ID (survives factory reset & app data clear) const bridge = (window as any)._nativeBridge; let deviceId = "android-unknown"; if (bridge?.app?.getAndroidId) { try { const androidId = await bridge.app.getAndroidId(); deviceId = `android-${androidId.substring(0, 12)}`; } catch { deviceId = `android-${getDeviceId().substring(0, 8)}`; } } // Read existing clients.jsonc const q = encodeURIComponent(`name='clients.jsonc' and '${folderId}' in parents and trashed=false`); const listRes = await fetch( `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id)`, { headers: { "Authorization": `Bearer ${token}` } } ); if (!listRes.ok) { console.warn(`[gdrive] clients.jsonc list failed: ${listRes.status}`); return; } const listData = await listRes.json() as any; const fileId = listData.files?.[0]?.id; let clients: any = {}; if (fileId) { const readRes = await fetch( `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, { headers: { "Authorization": `Bearer ${token}` } } ); if (readRes.ok) { try { clients = JSON.parse(await readRes.text()); } catch { /* */ } } } // Remove stale android-* entries (from old random-UUID approach) — keep only this device for (const key of Object.keys(clients)) { if (key.startsWith("android-") && key !== deviceId) { delete clients[key]; } } clients[deviceId] = { hostname: deviceId, platform: "android", accounts: accountIds, lastSeen: new Date().toISOString(), version: (window as any)._nativeBridge?.info?.version || "?", }; const content = JSON.stringify(clients, null, 2); if (fileId) { const upRes = await 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 (upRes.ok) console.log(`[android] Registered device in clients.jsonc as ${deviceId}`); else console.warn(`[gdrive] clients.jsonc update failed: ${upRes.status}`); } } catch (e: any) { console.warn(`[android] Device registration failed: ${e.message}`); } } // ── Google Contacts sync (People API, incremental) ── // // Mirrors mailx-imap's syncGoogleContactsImpl. Persists nextSyncToken in // localStorage per account so subsequent calls only fetch deltas. Web DB // has no kv table, so we use localStorage (one row per device — that's // what we want; sync tokens are per-account per-device). // // In-flight guard prevents the periodic timer from stacking calls when a // sync is still running. const contactsSyncing = new Map>(); function getContactsSyncToken(accountId: string): string { try { return localStorage.getItem(`mailx-contacts-synctoken-${accountId}`) || ""; } catch { return ""; } } function setContactsSyncToken(accountId: string, token: string | null): void { try { const key = `mailx-contacts-synctoken-${accountId}`; if (token === null) localStorage.removeItem(key); else localStorage.setItem(key, token); } catch { /* private mode */ } } /** True when the account is Google-backed (Gmail address or Gmail IMAP host). * Only Google accounts have a People API + OAuth token; running the contacts * sync for a plain IMAP account is meaningless and triggers a bogus OAuth * consent prompt for an address Google doesn't own. */ function isGoogleAccount(account: { email?: string; imap?: { host?: string } }): boolean { return !!(account.imap?.host?.toLowerCase().includes("gmail") || account.email?.toLowerCase().endsWith("@gmail.com")); } async function syncGoogleContactsForAccount( db: WebMailxDB, accountId: string, tokenProvider: () => Promise, ): Promise { const inFlight = contactsSyncing.get(accountId); if (inFlight) return inFlight; const promise = (async (): Promise => { const token = await tokenProvider(); if (!token) return 0; let changed = 0; let nextPageToken: string | undefined; let syncToken = getContactsSyncToken(accountId); try { do { const params = new URLSearchParams({ // Full contact card — matches the desktop sync in // mailx-imap so mobile doesn't silently hold less. personFields: "names,emailAddresses,organizations,photos,phoneNumbers,addresses,urls,biographies,birthdays", pageSize: "100", }); if (nextPageToken) params.set("pageToken", nextPageToken); if (syncToken) params.set("syncToken", syncToken); else params.set("requestSyncToken", "true"); const url = `https://people.googleapis.com/v1/people/me/connections?${params}`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) { const err = await res.text().catch(() => ""); // Expired sync token: People API returns HTTP 400 // FAILED_PRECONDITION / EXPIRED_SYNC_TOKEN (older behavior // was 410 — match both via the body). Drop the token and // restart as a full sync. if (syncToken && (res.status === 410 || /EXPIRED_SYNC_TOKEN|sync token is expired/i.test(err))) { console.log(`[contacts] ${accountId}: sync token expired — clearing for full resync`); setContactsSyncToken(accountId, null); syncToken = ""; nextPageToken = undefined; changed = 0; continue; } console.error(`[contacts] API error for ${accountId}: ${res.status} ${err}`); return changed; } const data = await res.json() as any; if (data.connections) { for (const person of data.connections) { const googleId = person.resourceName || ""; if (person.metadata?.deleted) { const removed = db.deleteContactByGoogleId(googleId); if (removed > 0) changed += removed; continue; } const name = person.names?.[0]?.displayName || ""; const org = person.organizations?.[0]?.name || ""; // Google returns typed arrays, primary first; keep // the first of each. Birthday year is optional, so // an unknown one is written as --MM-DD. const bd = person.birthdays?.[0]?.date; const pad = (n: number) => String(n).padStart(2, "0"); const card = { title: person.organizations?.[0]?.title || "", phone: person.phoneNumbers?.[0]?.value || "", address: person.addresses?.[0]?.formattedValue || "", website: person.urls?.[0]?.value || "", notes: person.biographies?.[0]?.value || "", birthday: bd?.month && bd?.day ? `${bd.year ? bd.year : "-"}-${pad(bd.month)}-${pad(bd.day)}` : "", }; for (const e of person.emailAddresses || []) { const email = e.value?.toLowerCase(); if (!email) continue; const existing = db.searchContacts(email, 1); const wasNew = !(existing.length > 0 && existing[0].email === email); db.recordSentAddress(name, email); db.setContactGoogleId(email, googleId, org, card); if (wasNew) changed++; } } } nextPageToken = data.nextPageToken; if (data.nextSyncToken) { setContactsSyncToken(accountId, data.nextSyncToken); syncToken = data.nextSyncToken; } } while (nextPageToken); console.log(`[contacts] ${accountId}: ${changed} change(s) (${syncToken ? "incremental" : "full"})`); } catch (e: any) { console.error(`[contacts] Sync error for ${accountId}: ${e.message}`); } return changed; })().finally(() => contactsSyncing.delete(accountId)); contactsSyncing.set(accountId, promise); return promise; } async function findGDriveMailxFolder(tokenProvider: () => Promise): Promise<{ id: string; name: string; path: string; ownerEmail?: string } | null> { const token = await tokenProvider(); const headers = { "Authorization": `Bearer ${token}` }; // Two-step lookup: My Drive root → home/ → .rmfmail // Direct My-Drive-root scope finds shared folders too (spouse's .rmfmail // appears at root of "Shared with me"). Path scope avoids that AND // matches the user's actual layout: ~/home/.rmfmail. const homeQ = encodeURIComponent("name='home' and mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed=false"); const homeRes = await fetch( `https://www.googleapis.com/drive/v3/files?q=${homeQ}&fields=files(id,name)&spaces=drive`, { headers } ); if (!homeRes.ok) { console.warn(`[gdrive] home folder search failed: ${homeRes.status}`); return null; } const homeData = await homeRes.json() as any; const home = homeData.files?.[0]; if (!home?.id) { console.warn("[gdrive] 'home' folder not found at My Drive root"); return null; } const q = encodeURIComponent(`name='.rmfmail' and mimeType='application/vnd.google-apps.folder' and '${home.id}' in parents and trashed=false`); const res = await fetch( `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name,owners(emailAddress))&spaces=drive`, { headers } ); if (!res.ok) { console.warn(`[gdrive] .rmfmail search failed: ${res.status}`); return null; } const data = await res.json() as any; const folder = data.files?.[0]; if (!folder?.id) return null; const homeName = home.name || "home"; const folderName = folder.name || ".rmfmail"; return { id: folder.id, name: folderName, path: `My Drive/${homeName}/${folderName}`, ownerEmail: folder.owners?.[0]?.emailAddress, }; } // ── Initialization ── async function waitForNativeBridge(timeoutMs: number = 5000): Promise { if ((window as any)._nativeBridge) return; return new Promise((resolve) => { const start = Date.now(); const check = () => { if ((window as any)._nativeBridge || Date.now() - start > timeoutMs) { resolve(); } else { setTimeout(check, 50); } }; // Also listen for the event C# dispatches after bridge injection window.addEventListener("nativebridgeready", () => resolve(), { once: true }); check(); }); } export function initAndroid(): Promise { // C158: idempotency guard. The 2026-07-22 fold/unfold logit trail showed // ONE WebView reload executing the boot module TWICE — duplicate "bridge // installed", duplicate GDrive lookups, tripled OAuth launches. The // guard lives on window (not a module local) so it holds even when the // module itself is instantiated twice (bundle + package-path specifiers // resolve to distinct module instances). const w = window as any; if (w.__rmfInitAndroid) { console.warn("[android] initAndroid called again — duplicate suppressed (C158)"); vlog("C158: duplicate initAndroid call suppressed"); return w.__rmfInitAndroid; } return w.__rmfInitAndroid = initAndroidOnce(); } async function initAndroidOnce(): Promise { console.log("[android] Initializing mailx (main-thread mode)..."); // First words on screen. A fresh install has no local accounts, so the // UI's zero-account path used to paint an empty pane while the Drive // pull ran — "blank screen and 'syncing' at the bottom" (Bob // 2026-08-01). Every stage below narrates; this is the one that lands // before there is anything at all to show. narrate("Initializing…"); // Main-thread path: async I/O (fetch, TCP bridge) doesn't block the UI, // and only sql.js is CPU-bound enough to maybe warrant a Worker later. // Worker path was reverted 2026-04-14 (stuck at "Initializing..." on Android). await waitForNativeBridge(); if ((window as any)._nativeBridge && !(window as any).msgapi) { (window as any).msgapi = (window as any)._nativeBridge; } db = new WebMailxDB("mailx"); await db.waitReady(); bodyStore = new WebMessageStore(); syncManager = new AndroidSyncManager(db, bodyStore); service = new WebMailxService(db, bodyStore, syncManager); let accounts = await loadAccounts(); console.log(`[android] ${accounts.length} account(s) found`); narrate(accounts.length ? `Opening ${accounts.map(a => a.email || a.id).join(", ")}…` : "No local accounts yet — checking Google Drive…"); // Find a Gmail account to use as the GDrive token provider let gmailTokenProvider: (() => Promise) | null = null; for (const account of accounts) { if (!account.enabled) continue; const domain = account.email?.split("@")[1]?.toLowerCase() || ""; if (domain === "gmail.com" || domain === "googlemail.com") { const tp = createNativeTokenProvider(account.email); syncManager.setTokenProvider(account.id, tp); if (!gmailTokenProvider) gmailTokenProvider = tp; } await syncManager.addAccount(account); } // Install the mailxapi bridge + drain pending queues IMMEDIATELY using // the local-cache account list. UI shouldn't wait on GDrive (which can // be slow on cold network) before becoming actionable. GDrive // reconciliation (below) runs in the background and re-registers fresh // accounts when it returns. installBridge(); for (const account of accounts) { if (!account.enabled) continue; syncManager.processSendQueue(account.id) .catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`)); syncManager.processSyncActions(account.id) .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`)); } // First sync from local accounts on a tiny delay so the UI gets to paint. setTimeout(() => { syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`)); }, 1000); // GDrive reconciliation runs in the background — accounts.jsonc on the // shared cloud may have been edited from another device, so we re-pull // and re-register if it differs from the cached copy. The user can // already see and use mail by the time this resolves. if (gmailTokenProvider) { const tp = gmailTokenProvider; // Retry loop, not one-shot. On a brand-new install this runs seconds // after the OAuth redirect; the native token fetch can hang or fail // while the exchange is still settling, and the old one-shot then // left the device with ONLY the locally-created account until the // next full app restart (Bob 2026-07-22: "only showing me gmail — // not my other mailboxes"). Each attempt logs breadcrumbs (vlog → // logit) so a silent stall is visible in the server log. const RECONCILE_RETRY_MS = 30_000; const RECONCILE_MAX_TRIES = 5; let reconcileTry = 0; const reconcile = async (): Promise => { reconcileTry++; setGDriveTokenProvider(tp); try { narrate(`Fetching settings from Google Drive… (try ${reconcileTry})`); vlog(`gdrive-reconcile try ${reconcileTry}: token fetch`); // Watchdog the token fetch — it crosses into native code and has // hung on cold boots; without a timeout the whole reconcile just // vanishes with no log line. await Promise.race([ tp(), new Promise((_, rej) => setTimeout(() => rej(new Error("token fetch timed out (20s)")), 20_000)), ]); vlog(`gdrive-reconcile try ${reconcileTry}: folder lookup`); const folder = await findGDriveMailxFolder(tp); if (!folder) { emitEvent({ type: "fatal", key: "gdrive-folder-missing", message: "GDrive folder '.rmfmail' not found — app cannot start. Create it or sign in with the correct Google account.", }); } else { const folderId = folder.id; setGDriveFolderId(folderId, folder.name, folder.ownerEmail, folder.path); console.log(`[android] GDrive ${folder.path} folder: ${folderId} (owner=${folder.ownerEmail || "?"})`); // DEBUG: list all files in the folder try { const tk = await tp(); const lr = await fetch( `https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`, { headers: { "Authorization": `Bearer ${tk}` } } ); if (lr.ok) { const ld = await lr.json() as any; const names = (ld.files || []).map((f: any) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(","); console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`); } else { console.warn(`[android] List folder failed: ${lr.status}`); } } catch (e: any) { console.warn(`[android] List debug: ${e.message}`); } // Shared allowlist/preferences: pull NOW that Drive auth is // live, instead of waiting for the first message read to // trigger the lazy revalidate (which is itself served stale). primeSharedConfigs() .catch(e => console.warn(`[android] prime shared configs: ${e?.message || e}`)); // Read accounts directly from GDrive (bypass IndexedDB cache) narrate("Loading accounts from Google Drive…"); const gdriveAccounts = await loadAccountsFromCloud(); console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`); if (gdriveAccounts.length > 0) { // Use canonical GDrive accounts (upsert handles overwrites) accounts = gdriveAccounts; const enabled = accounts.filter(a => a.enabled); narrate(`Found ${enabled.length} account(s): ${enabled.map(a => a.email || a.id).join(", ")}`); let setUp = 0; for (const account of accounts) { vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`); if (!account.enabled) { vlog(`init: ${account.id} disabled, skipping`); continue; } narrate(`Setting up ${account.email || account.id}… (${++setUp}/${enabled.length})`); const domain = account.email?.split("@")[1]?.toLowerCase() || ""; if (domain === "gmail.com" || domain === "googlemail.com") { syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email)); } await syncManager.addAccount(account); } console.log(`[android] Loaded ${accounts.length} accounts from GDrive`); // C157: sync NOW. The boot-time syncAll ran before these // providers existed (fresh install: zero local accounts), // so without this kick the folder tree stayed empty until // the next 60 s poll tick — on top of OAuth consent time, // that read as "frozen for minutes". syncAll's inbox // phase lists folders first, so the tree paints in // seconds; message counts stream in behind. narrate(`Syncing ${enabled.map(a => a.email || a.id).join(", ")}…`); syncManager.syncAll().catch(e => console.error(`[android] post-reconcile sync: ${e.message}`)); } // Register this Android device in clients.jsonc await registerDeviceInGDrive(tp, folderId, accounts.map(a => a.id)); } } catch (e: any) { vlog(`gdrive-reconcile try ${reconcileTry} FAILED: ${e.message}`); if (reconcileTry < RECONCILE_MAX_TRIES) { console.warn(`[android] GDrive reconcile failed (${e.message}) — retrying in ${RECONCILE_RETRY_MS / 1000}s`); setTimeout(() => { void reconcile(); }, RECONCILE_RETRY_MS); return; } emitEvent({ type: "fatal", key: "gdrive-access-failed", message: `GDrive access failed after ${RECONCILE_MAX_TRIES} attempts: ${e.message}. Mail from this device's local accounts still works; restart the app to retry cloud settings.`, }); } }; void reconcile(); } // Startup contacts load. TWO sources, both wanted: // 1. The shared GDrive contacts.jsonc — desktop's full union (everything // it discovered from its mailbox + Google contacts + preferred). This // is how Android gets contacts it never saw in its own partial corpus. // 2. Google People API — keeps Google-side contacts current. Runs for // Google accounts ONLY; an IMAP account has no People API and would // pop a bogus OAuth consent. service.loadContactsConfig() .catch((e: any) => console.error(`[android] startup contacts.jsonc load: ${e?.message || e}`)); for (const account of db.getAccounts()) { if (!account.email || !isGoogleAccount(account)) continue; const tp = createNativeTokenProvider(account.email); syncGoogleContactsForAccount(db, account.id, tp) .catch(e => console.error(`[android] startup contacts sync ${account.id}: ${e.message}`)); } // Periodic re-sync (no IDLE on Android — WebView can't hold a long-lived // socket reliably, and Gmail API doesn't expose an equivalent — so we // poll). 60 s is the sweet spot Bob 2026-05-25: 2 min "wasn't picking // up new posts in a timely fashion". A 30 s tick is fine power-wise on // Gmail (one cheap REST call with a since-modseq filter), but 60 s // keeps a margin for shared-IP rate limits. Pull-to-refresh remains // the explicit "I want it now" gesture (PTR_THRESHOLD in app.ts). const SYNC_INTERVAL_MS = 60 * 1000; let contactsSyncTickCounter = 0; setInterval(() => { console.log("[sync] periodic poll"); vlog("periodic sync poll"); // Retry any failed/stranded sends every poll tick for (const account of db.getAccounts()) { syncManager.processSendQueue(account.id) .catch(e => console.error(`[android] retry ${account.id}: ${e.message}`)); syncManager.processSyncActions(account.id) .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`)); } syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`)); // Shared contacts.jsonc freshness: one Drive metadata request, // self-throttled inside the service (~3 min); full reload only when // modifiedTime changed. This is how a denylist/preferred edit made // on desktop (or directly on Drive) reaches a running phone — // startup-only loading left it stale (Bob 2026-07-07). service.maybeRefreshContactsConfig() .catch(e => console.error(`[android] contacts.jsonc freshness check: ${e?.message || e}`)); // Same for allowlist/preferences (self-throttled to 5 min inside). // Desktop polls its shared configs every 3 min; without this the // phone only refreshed at launch/resume, so a site approved on the // PC could sit unseen for as long as the app stayed open. primeSharedConfigs(false) .catch(e => console.error(`[android] shared config freshness check: ${e?.message || e}`)); // Contacts: once every 8 ticks (~16 min). Incremental, so cheap — // a clean tick is one HTTP round-trip with empty connections list. if (++contactsSyncTickCounter % 8 === 0) { for (const account of db.getAccounts()) { if (!account.email || !isGoogleAccount(account)) continue; const tp = createNativeTokenProvider(account.email); syncGoogleContactsForAccount(db, account.id, tp) .catch(e => console.error(`[android] periodic contacts sync ${account.id}: ${e.message}`)); } } }, SYNC_INTERVAL_MS); // Immediate sync + send-queue drain when app comes back to foreground // (e.g. user switches from another app). Without the send-queue drain, // a message queued while offline waits up to 2 minutes after resume // before retrying — long enough for the user to think it's stuck. document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { console.log("[sync] resume poll"); for (const account of db.getAccounts()) { syncManager.processSendQueue(account.id) .catch(e => console.error(`[android] resume send-drain ${account.id}: ${e.message}`)); } syncManager.syncAll().catch(e => console.error(`[android] Resume sync error: ${e.message}`)); service.maybeRefreshContactsConfig() .catch(e => console.error(`[android] resume contacts.jsonc check: ${e?.message || e}`)); // Same freshness treatment for allowlist/preferences — throttled // (5 min) so a quick app-switch costs nothing. primeSharedConfigs(false) .catch(e => console.error(`[android] resume shared config check: ${e?.message || e}`)); } }); console.log("[android] Initialization complete"); emitEvent({ type: "connected" }); } export async function resetStore(): Promise { await service.resetStore(); await clearSettings(); console.log("[android] Store reset"); } // ── mailxapi Bridge ── function installBridge(): void { const api = { isApp: true, platform: "android", // Link / contact / calendar handoff to native apps. On Android THIS // object is window.mailxapi — NOT client/lib/mailxapi.js — so the // openExternal there never ran on the phone, and this object had no // openExternal at all, so app.ts fell through to window.open() (a no-op // in the WebView). That's why preview links / the LinkedIn "View message" // button did nothing even after the mailxapi.js fixes (Bob 2026-06-18, // proven by the [android-link] trace stopping at "parent got linkClick"). // We're definitively on the Android host here, so just navigate the top // frame to the mailxapi-intent:// scheme that MainPage.xaml.cs intercepts. openExternal: (url: string) => { if (!url) return; try { console.log("[android-link] android-api openExternal → intent " + url); window.location.href = "mailxapi-intent://open/" + encodeURIComponent(url); } catch (e) { console.error("[android-link] openExternal failed", e); } }, openContact: (email: string) => { if (!email) return; try { window.location.href = "mailxapi-intent://contact/" + encodeURIComponent(email); } catch (e) { console.error("openContact failed", e); } }, openCalendarEvent: (htmlLink: string) => { if (!htmlLink) return; try { window.location.href = "mailxapi-intent://calendar/" + encodeURIComponent(htmlLink); } catch (e) { console.error("openCalendarEvent failed", e); } }, getAccounts: () => service.getAccounts(), getFolders: (accountId: string) => service.getFolders(accountId), // Full arg pass-through — this adapter used to stop at pageSize, so // sort toggles and the per-folder search box silently did nothing on // the phone (service.getMessages supports all seven). getMessages: (accountId: string, folderId: number, page: number, pageSize: number, sort?: string, sortDir?: string, search?: string) => service.getMessages(accountId, folderId, page, pageSize, sort, sortDir, search), getUnifiedInbox: (page: number, pageSize: number) => service.getUnifiedInbox(page, pageSize), getMessage: (accountId: string, uid: number, allowRemote: boolean, folderId?: number) => service.getMessage(accountId, uid, allowRemote, folderId), updateFlags: async (accountId: string, uid: number, flags: string[]) => { await service.updateFlags(accountId, uid, flags); return { ok: true }; }, deleteMessage: async (accountId: string, uid: number) => { await service.deleteMessage(accountId, uid); return { ok: true }; }, deleteMessages: async (accountId: string, uids: number[]) => { await service.deleteMessages(accountId, uids); return { ok: true, count: uids.length }; }, undeleteMessage: async (accountId: string, uid: number, folderId: number) => { await service.undeleteMessage(accountId, uid, folderId); return { ok: true }; }, moveMessage: async (accountId: string, uid: number, targetFolderId: number, targetAccountId?: string) => { await service.moveMessage(accountId, uid, targetFolderId, targetAccountId); return { ok: true }; }, moveMessages: async (accountId: string, uids: number[], targetFolderId: number) => { await service.moveMessages(accountId, uids, targetFolderId); return { ok: true, count: uids.length }; }, sendMessage: async (msg: any) => { await service.send(msg); return { ok: true }; }, saveDraft: (p: any) => service.saveDraft(p.accountId, p.subject, p.bodyHtml, p.bodyText, p.to, p.cc, p.previousDraftUid, p.draftId, p.bcc, p.from, p.attachments), deleteDraft: async (accountId: string, draftUid: number) => { await service.deleteDraft(accountId, draftUid); return { ok: true }; }, // No IMAP-side draft probe on Android — compose keeps its local copy. checkDraftNewer: async (): Promise => null, // Full arg pass-through — dropping scope/accountId/folderId made every // Android search behave as all-mailboxes regardless of the UI scope. searchMessages: (query: string, page: number, pageSize: number, scope?: string, accountId?: string, folderId?: number, includeTrashSpam?: boolean) => service.searchMessages(query, page, pageSize, scope, accountId, folderId, includeTrashSpam), searchContacts: (query: string) => service.searchContacts(query), listContacts: (query: string, page = 1, pageSize = 100) => service.listContacts(query || "", page, pageSize), upsertContact: (name: string, email: string) => service.upsertContact(name || "", email), deleteContact: (email: string) => service.deleteContact(email), addContact: (name: string, email: string) => service.addContact(name || "", email), hasCcHistoryTo: (email: string) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }), hasBccHistoryTo: (email: string) => ({ hasBcc: service.hasBccHistoryTo?.(email) ?? false }), // ⊘ / ★ in compose autocomplete. The service has implemented these // since the contacts-config share (web-service.ts), but they were // never exposed here, so the compose iframe's IPC relay failed with // `parent bridge has no method "addToDenylist"` (Bob 2026-07-07 // screenshot). Signatures mirror client/lib/mailxapi.js — positional // args in, object built here. addPreferredContact: async (name: string, email: string, source?: string, organization?: string) => { await service.addPreferredContact({ name, email, source, organization }); return { ok: true }; }, addToDenylist: async (email: string) => { await service.addToDenylist(email); return { ok: true }; }, loadContactsConfig: () => service.loadContactsConfig(), // Cross-device reminder dismissed/snoozed state (reminders.jsonc on // Drive) — alarm poller pushes/pulls through these. getReminderState: () => service.getReminderState(), mergeReminderState: (patch: any) => service.mergeReminderState(patch), syncAll: async () => { await service.syncAll(); return { ok: true }; }, syncAccount: async (accountId: string) => { await service.syncAccount(accountId); return { ok: true }; }, getSyncPending: () => service.getSyncPending(), getPrimaryAccount: (feature?: string) => { // Resolve primary account for a feature (calendar/tasks/contacts): // per-feature flag → catch-all `primary` → first account. const all = db.getAccountConfigs().map(r => { try { return { id: r.id, name: r.name, email: r.email, ...JSON.parse(r.configJson) }; } catch { return { id: r.id, name: r.name, email: r.email }; } }); if (feature) { const key = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1); const perFeature = all.find((a: any) => a[key]); if (perFeature) return perFeature; } return all.find((a: any) => a.primary) || all[0] || null; }, reauthenticate: async (accountId: string) => ({ ok: await service.reauthenticate(accountId) }), markFolderRead: (_accountId: string, folderId: number) => { service.markFolderRead(folderId); return { ok: true }; }, createFolder: async () => ({ ok: false, error: "Not supported on mobile" }), renameFolder: async () => ({ ok: false, error: "Not supported on mobile" }), deleteFolder: async () => ({ ok: false, error: "Not supported on mobile" }), emptyFolder: async () => ({ ok: false, error: "Not supported on mobile" }), allowRemoteContent: async (type: string, value: string) => { await service.allowRemoteContent(type as any, value); return { ok: true }; }, getAllowlist: () => service.getAllowlist(), flagSenderOrDomain: async (type: string, value: string) => { return await service.flagSenderOrDomain(type as any, value); }, getPriorityLists: () => service.getPriorityLists(), setPrioritySender: async (email: string, value: boolean, name?: string) => { await service.setPrioritySender(email, !!value, name); return { ok: true }; }, setPriorityDomain: async (domain: string, value: boolean) => { await service.setPriorityDomain(domain, !!value); return { ok: true }; }, getSettings: () => service.getSettings(), saveSettingsData: async (data: any) => { await service.saveSettingsData(data); return { ok: true }; }, getVersion: async () => { const settings = await service.getSettings(); const nativeVersion = (window as any)._nativeBridge?.info?.version || "?"; return { version: nativeVersion, theme: settings.ui?.theme || "system", storage: service.getStorageInfo(), platform: "android" }; }, getAutocompleteSettings: () => service.getAutocompleteSettings(), saveAutocompleteSettings: async (settings: any) => { await service.saveAutocompleteSettings(settings); return { ok: true }; }, getDeviceAccounts: async () => { const bridge = (window as any)._nativeBridge; if (bridge?.app?.getDeviceAccounts) { return bridge.app.getDeviceAccounts(); } return []; }, setupAccount: async (name: string, email: string, _password: string) => { try { if (!email || !email.includes("@")) { return { ok: false, error: "Email address required" }; } const domain = email.split("@")[1].toLowerCase(); const id = domain.split(".")[0] || "account"; const account: AccountConfig = { id, name: name || email.split("@")[0], email, enabled: true, imap: { host: `imap.${domain}`, port: 993, tls: true, auth: "oauth2" as const, user: email }, smtp: { host: `smtp.${domain}`, port: 587, tls: true, auth: "oauth2" as const, user: email }, }; // Apply known provider defaults if (domain === "gmail.com" || domain === "googlemail.com") { account.label = "Gmail"; account.imap = { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2", user: email }; account.smtp = { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2", user: email }; } const existing = await loadAccounts(); if (existing.some(a => a.email === email)) { return { ok: true, message: "Account already exists" }; } existing.push(account); await saveAccounts(existing); // Set up token provider before adding account const setupDomain = email.split("@")[1].toLowerCase(); if (setupDomain === "gmail.com" || setupDomain === "googlemail.com") { syncManager.setTokenProvider(account.id, createNativeTokenProvider(email)); } await syncManager.addAccount(account); db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account)); console.log(`[android] Account added: ${email}`); // C157: the "Syncing..." message below was aspirational — no // sync was actually kicked, so the new account showed nothing // until the next 60 s poll tick. syncManager.syncAll().catch(e => console.error(`[android] post-setup sync: ${e.message}`)); return { ok: true, message: `Added ${email}. Syncing...` }; } catch (e: any) { return { ok: false, error: e.message }; } }, repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }), resetStore: () => resetStore(), resetAll: async () => { const bridge = (window as any)._nativeBridge; if (bridge?.app?.resetAll) { await bridge.app.resetAll(); } else { await resetStore(); location.reload(); } }, restart: () => { location.reload(); }, onEvent: (handler: (event: any) => void) => { eventHandlers.push(handler); }, // ── Wire-shape adapters (C154) ── // These service methods return the raw value, but api-client (and the // desktop jsonrpc dispatcher) speak a wrapped shape on the wire — // adapt here so both platforms answer identically. readJsoncFile: async (name: string) => ({ content: await service.readJsoncFile(name) }), writeJsoncFile: async (name: string, content: string) => { await service.writeJsoncFile(name, content); return { ok: true }; }, formatJsonc: async (content: string) => ({ content: await service.formatJsonc(content) }), readConfigHelp: async (name: string) => ({ content: await service.readConfigHelp(name) }), drainStoreSync: async () => { await service.drainStoreSync(); return { ok: true }; }, syncFolderNow: async (accountId: string, folderId: number) => { await service.syncFolderNow(accountId, folderId); return { ok: true }; }, }; // ── Contract backstop (C154) ── // Every remaining MailxApi method the service implements (real impl or // explicit notImpl stub) gets a direct pass-through. The contract's // positional signatures match api-client's call shapes, so no per-method // adapter is needed — and a future service method can never again // silently miss the phone and fail as `parent bridge has no method "X"` // (the ⊘ denylist bug, Bob 2026-07-07). Methods whose wire shape differs // from the service return are adapted explicitly above and win here. const svcAny = service as any; const svcNames = new Set([ ...Object.getOwnPropertyNames(Object.getPrototypeOf(service)), ...Object.getOwnPropertyNames(service), ]); let backfilled = 0; for (const name of svcNames) { if (name === "constructor" || name === "notImpl") continue; if (typeof svcAny[name] !== "function") continue; if (name in api) continue; (api as any)[name] = (...args: any[]) => svcAny[name](...args); backfilled++; } (window as any).mailxapi = api; window.dispatchEvent(new CustomEvent("mailxapiready")); console.log(`[android] mailxapi bridge installed (${backfilled} contract methods backfilled)`); }