/** * IMAP web provider — implements MailProvider using CompatImapClient + BridgeTransport. * Used for non-Gmail accounts on Android/WebView where Node.js isn't available. * * The native shell (MAUI) exposes TCP via window._nativeBridge.tcp.*; we alias it * to window.msgapi in initAndroid() because iflow-direct's BridgeTransport expects * that global name. * * Includes automatic retry on broken pipe / connection errors: if an operation fails * with a connection-related error, we create a fresh client and retry once. */ import { CompatImapClient, BridgeTransport, type ImapClientConfig, type TransportFactory } from "@bobfrankston/iflow-direct"; import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./provider-types.js"; /** * Convert a NativeFolder (from iflow-direct) into a ProviderFolder, * detecting special-use from the IMAP flags. */ function toProviderFolder(f: { path: string; delimiter: string; flags: string[] }, special: Record): ProviderFolder { const flagsLower = (f.flags || []).map(x => x.toLowerCase()); let specialUse = ""; if (f.path === special.inbox || flagsLower.includes("\\inbox") || f.path.toUpperCase() === "INBOX") specialUse = "inbox"; else if (f.path === special.sent || flagsLower.includes("\\sent")) specialUse = "sent"; else if (f.path === special.trash || flagsLower.includes("\\trash")) specialUse = "trash"; else if (f.path === special.drafts || flagsLower.includes("\\drafts")) specialUse = "drafts"; else if (f.path === special.spam || f.path === special.junk || flagsLower.includes("\\junk")) specialUse = "junk"; else if (f.path === special.archive || flagsLower.includes("\\archive")) specialUse = "archive"; // Leaf name = last path segment after delimiter const leaf = f.delimiter ? f.path.split(f.delimiter).pop() || f.path : f.path; return { path: f.path, name: leaf, delimiter: f.delimiter || "/", specialUse, flags: f.flags || [], }; } function toProviderMessage(m: any): ProviderMessage { return { uid: m.uid, messageId: m.messageId || "", providerId: "", date: m.date || null, sentDate: m.sentDate || undefined, subject: m.subject || "", from: m.from || [], to: m.to || [], cc: m.cc || [], seen: !!m.seen, flagged: !!m.flagged, answered: !!m.answered, draft: !!m.draft, size: m.size || 0, source: m.source || "", }; } /** Check if an error is a connection/broken-pipe error worth retrying */ function isConnectionError(e: any): boolean { const msg = (e?.message || "").toLowerCase(); return msg.includes("broken pipe") || msg.includes("not connected") || msg.includes("connection") || msg.includes("socket") || msg.includes("timeout") || msg.includes("econnreset") || msg.includes("epipe") || msg.includes("closed"); } export class ImapWebProvider implements MailProvider { private client: CompatImapClient; private config: ImapClientConfig; private transportFactory: TransportFactory; private specialFolders: Record = {}; private folderListCache: ProviderFolder[] | null = null; constructor(config: ImapClientConfig, transportFactory?: () => any) { this.config = config; this.transportFactory = transportFactory || (() => new BridgeTransport()); this.client = new CompatImapClient(config, this.transportFactory); } /** Create a fresh client (after broken pipe / connection error) */ private reconnect(): void { console.log("[imap-web] reconnecting after connection error"); try { this.client.logout(); } catch { /* ignore */ } this.client = new CompatImapClient(this.config, this.transportFactory); } /** Run an operation with one retry on connection error */ private async withRetry(op: () => Promise, label: string): Promise { try { return await op(); } catch (e: any) { if (isConnectionError(e)) { console.warn(`[imap-web] ${label}: ${e.message} — reconnecting and retrying`); this.reconnect(); return await op(); } throw e; } } async listFolders(): Promise { const native = await this.withRetry(() => this.client.getFolderList(), "listFolders"); const special = this.client.getSpecialFolders(native); this.specialFolders = special as any; const result = native.map(f => toProviderFolder(f, this.specialFolders)); this.folderListCache = result; return result; } async fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise { const msgs = await this.withRetry( () => this.client.fetchMessagesSinceUid(folder, sinceUid, { source: !!options?.source }), `fetchSince(${folder})` ); return msgs.map(toProviderMessage); } async fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise { const wrappedChunk = onChunk ? (raw: any[]) => onChunk(raw.map(toProviderMessage)) : undefined; const msgs = await this.withRetry( () => this.client.fetchMessageByDate(folder, since, before, { source: !!options?.source }, wrappedChunk), `fetchByDate(${folder})` ); return msgs.map(toProviderMessage); } async fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise { if (!uids.length) return []; const range = uids.join(","); const msgs = await this.withRetry( () => this.client.fetchMessages(folder, range, { source: !!options?.source }), `fetchByUids(${folder})` ); return msgs.map(toProviderMessage); } async fetchOne(folder: string, uid: number, options?: FetchOptions): Promise { const msg = await this.withRetry( () => this.client.fetchMessageByUid(folder, uid, { source: !!options?.source }), `fetchOne(${folder}/${uid})` ); return msg ? toProviderMessage(msg) : null; } async getUids(folder: string): Promise { return this.withRetry(() => this.client.getUids(folder), `getUids(${folder})`); } /** APPEND a raw message to this account's Sent folder. Used by the * Android send path after a successful SMTP delivery — without it a * phone-sent message never got a Sent copy on IMAP accounts (Gmail's * API self-files; SMTP does not). Resolves the Sent path from the * special-folder map (populated by listFolders; refreshed here if the * cache is cold). Returns the appended UID when the server reports it. */ async appendToSent(raw: string): Promise { if (!this.specialFolders.sent) await this.listFolders(); const sentPath = this.specialFolders.sent || "Sent"; return this.withRetry( () => this.client.appendMessage(sentPath, raw, ["\\Seen"]), `appendToSent(${sentPath})` ); } async close(): Promise { try { await this.client.logout(); } catch { /* ignore */ } } }