/** * Web-compatible MailxService for Android/browser. * Replaces @bobfrankston/mailx-service which depends on Node.js (fs, dns, mailparser). * * Key differences from desktop: * - Uses WebMailxDB (wa-sqlite) instead of MailxDB (node:sqlite) * - Uses WebMessageStore (IndexedDB) instead of FileMessageStore (filesystem) * - Uses postal-mime or manual header parsing instead of mailparser's simpleParser * - Settings via IndexedDB + GDrive API instead of filesystem * - No dns.resolveMx — provider detection is static (Gmail/Outlook/Yahoo/iCloud) */ import type { WebMailxDB } from "./db.js"; import type { WebMessageStore } from "./web-message-store.js"; import type { Folder, AutocompleteSettings, MailxApi } from "@bobfrankston/mailx-types"; import { buildMimeMessage, sanitizeHtml, encodeQuotedPrintable, REMINDER_STATE_FILE, normalizeReminderState, mergeReminderStates, reminderStatesEqual, type ReminderState } from "@bobfrankston/mailx-types"; import { loadSettings, saveSettings, loadAccounts, loadAllowlist, saveAllowlist, updateAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, cloudRead, cloudWrite } from "./web-settings.js"; // sanitizeHtml and encodeQuotedPrintable imported from @bobfrankston/mailx-types (shared with desktop) // ── Simple email parser (replaces mailparser for browser) ── interface ParsedMessage { html: string; text: string; headers: Map; attachments: { filename: string; contentType: string; size: number; contentId: string; content: Uint8Array }[]; } /** Parse an RFC 2822 message from raw bytes. Handles basic MIME. */ function parseEmailSource(raw: string): ParsedMessage { const headers = new Map(); const headerEnd = raw.indexOf("\r\n\r\n"); const headerSection = headerEnd >= 0 ? raw.substring(0, headerEnd) : raw; const body = headerEnd >= 0 ? raw.substring(headerEnd + 4) : ""; // Parse headers (handle continuations) const headerLines = headerSection.split("\r\n"); let lastKey = ""; for (const line of headerLines) { if (line.startsWith(" ") || line.startsWith("\t")) { // Continuation if (lastKey) { headers.set(lastKey, (headers.get(lastKey) || "") + " " + line.trim()); } } else { const colon = line.indexOf(":"); if (colon > 0) { lastKey = line.substring(0, colon).toLowerCase().trim(); headers.set(lastKey, line.substring(colon + 1).trim()); } } } const contentType = headers.get("content-type") || "text/plain"; const transferEncoding = (headers.get("content-transfer-encoding") || "").toLowerCase(); const attachments: ParsedMessage["attachments"] = []; // Check for multipart const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/i); if (boundaryMatch) { const boundary = boundaryMatch[1]; return parseMimeParts(body, boundary, headers); } // Single part let decoded = decodeBody(body, transferEncoding); const isHtml = contentType.includes("text/html"); return { html: isHtml ? decoded : "", text: isHtml ? "" : decoded, headers, attachments, }; } function parseMimeParts(body: string, boundary: string, topHeaders: Map): ParsedMessage { const parts = body.split("--" + boundary); let html = ""; let text = ""; const attachments: ParsedMessage["attachments"] = []; for (let i = 1; i < parts.length; i++) { const part = parts[i]; if (part.startsWith("--")) break; // End marker const partHeaderEnd = part.indexOf("\r\n\r\n"); if (partHeaderEnd < 0) continue; const partHeaderSection = part.substring(0, partHeaderEnd); const partBody = part.substring(partHeaderEnd + 4).replace(/\r?\n$/, ""); // Parse part headers const partHeaders = new Map(); const partHeaderLines = partHeaderSection.split("\r\n"); let lastKey = ""; for (const line of partHeaderLines) { if (line.startsWith(" ") || line.startsWith("\t")) { if (lastKey) partHeaders.set(lastKey, (partHeaders.get(lastKey) || "") + " " + line.trim()); } else { const colon = line.indexOf(":"); if (colon > 0) { lastKey = line.substring(0, colon).toLowerCase().trim(); partHeaders.set(lastKey, line.substring(colon + 1).trim()); } } } const partType = partHeaders.get("content-type") || "text/plain"; const partEncoding = (partHeaders.get("content-transfer-encoding") || "").toLowerCase(); const disposition = partHeaders.get("content-disposition") || ""; // Nested multipart const nestedBoundary = partType.match(/boundary="?([^";\s]+)"?/i); if (nestedBoundary) { const nested = parseMimeParts(partBody, nestedBoundary[1], topHeaders); if (!html && nested.html) html = nested.html; if (!text && nested.text) text = nested.text; attachments.push(...nested.attachments); continue; } if (disposition.includes("attachment") || (partType.includes("application/") && !partType.includes("text/"))) { const filenameMatch = disposition.match(/filename="?([^";\r\n]+)"?/i) || partType.match(/name="?([^";\r\n]+)"?/i); const decoded = decodeBody(partBody, partEncoding); attachments.push({ filename: filenameMatch?.[1]?.trim() || `attachment-${attachments.length}`, contentType: partType.split(";")[0].trim(), size: decoded.length, contentId: (partHeaders.get("content-id") || "").replace(/[<>]/g, ""), content: new TextEncoder().encode(decoded), }); } else if (partType.includes("text/html")) { const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i); html = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8"); } else if (partType.includes("text/plain")) { const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i); text = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8"); } } return { html, text, headers: topHeaders, attachments }; } function decodeBody(body: string, encoding: string, charset: string = "utf-8"): string { // Step 1: decode the transfer encoding to a byte array let bytes: Uint8Array; if (encoding === "base64") { try { const binary = atob(body.replace(/\s/g, "")); bytes = Uint8Array.from(binary, c => c.charCodeAt(0)); } catch { return body; } } else if (encoding === "quoted-printable") { // Decode QP into bytes (NOT into a string — multi-byte UTF-8 must stay as bytes) const cleaned = body.replace(/=\r?\n/g, ""); const out: number[] = []; for (let i = 0; i < cleaned.length; i++) { const c = cleaned[i]; if (c === "=" && i + 2 < cleaned.length && /[0-9A-Fa-f]{2}/.test(cleaned.substr(i + 1, 2))) { out.push(parseInt(cleaned.substr(i + 1, 2), 16)); i += 2; } else { // Existing character — encode as its byte (assumes ASCII for QP source) out.push(c.charCodeAt(0) & 0xff); } } bytes = new Uint8Array(out); } else if (encoding === "7bit" || encoding === "8bit" || encoding === "" || encoding === "binary") { // No transfer encoding — body is already a string of single-byte chars bytes = Uint8Array.from(body, c => c.charCodeAt(0) & 0xff); } else { // Unknown encoding — return as-is return body; } // Step 2: decode bytes using the declared charset (default UTF-8) try { const normalized = charset.toLowerCase().replace("windows-", "windows-").replace("iso-", "iso-"); return new TextDecoder(normalized).decode(bytes); } catch { // Unknown charset — fall back to UTF-8 with replacement chars return new TextDecoder("utf-8").decode(bytes); } } // ── Quoted-printable encoding (for compose/send) ── // encodeQuotedPrintable imported from @bobfrankston/mailx-types // ── Types for sync manager ── export interface WebSyncManager { syncAll(): Promise; syncFolders(accountId: string): Promise; syncFolder(accountId: string, folderId: number): Promise; fetchMessageBody(accountId: string, folderId: number, uid: number): Promise; updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise; trashMessage(accountId: string, folderId: number, uid: number): Promise; trashMessages(accountId: string, messages: { uid: number; folderId: number }[]): Promise; moveMessage(accountId: string, uid: number, folderId: number, targetFolderId: number): Promise; moveMessages(accountId: string, messages: { uid: number; folderId: number }[], targetFolderId: number): Promise; moveMessageCrossAccount(accountId: string, uid: number, folderId: number, targetAccountId: string, targetFolderId: number): Promise; undeleteMessage(accountId: string, uid: number, folderId: number): Promise; queueOutgoingLocal(accountId: string, rawMessage: string): void | Promise; saveDraft(accountId: string, raw: string, previousDraftUid?: number, draftId?: string): Promise; deleteDraft(accountId: string, draftUid: number): Promise; reauthenticate(accountId: string): Promise; searchOnServer(accountId: string, folderPath: string, criteria: any): Promise; syncAllContacts(): Promise; addAccount(account: any): Promise; on(event: string, handler: (...args: any[]) => void): void; emit(event: string, ...args: any[]): void; } // ── Service ── export class WebMailxService implements MailxApi { constructor( private db: WebMailxDB, private bodyStore: WebMessageStore, private syncManager: WebSyncManager, ) {} // ── Accounts ── async getAccounts(): Promise { const dbAccounts = this.db.getAccounts(); const settings = await loadSettings(); const ordered: any[] = []; for (const cfg of settings.accounts) { const a = dbAccounts.find(d => d.id === cfg.id); // Merge the FULL shared (Drive) config over the local DB row, not just // label/defaultSend. The old code cherry-picked two fields and dropped // everything else — notably `identityDomains` — so the phone only knew // `["bob.ma"]` (derived from the email domain) while desktop had all // three domains, and reply auto-From for bobfrankston.com/frankston.com // aliases fell back to the default From (Bob 2026-06-19). The Drive // config is authoritative for config fields; the DB row keeps its // sync-state fields (last_sync etc., which cfg doesn't carry). if (a) ordered.push({ ...a, ...cfg }); } for (const a of dbAccounts) { if (!ordered.find((o: any) => o.id === a.id)) ordered.push(a); } return ordered; } // ── Folders ── getFolders(accountId: string): Folder[] { return this.db.getFolders(accountId); } // ── Messages ── getUnifiedInbox(page = 1, pageSize = 50): any { return this.db.getUnifiedInbox(page, pageSize); } getMessages(accountId: string, folderId: number, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search?: string): any { return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort as any, sortDir: sortDir as any, search }); } async getMessage(accountId: string, uid: number, allowRemote = false, folderId?: number): Promise { const envelope = this.db.getMessageByUid(accountId, uid, folderId); if (!envelope) throw new Error("Message not found"); let bodyHtml = ""; let bodyText = ""; let hasRemoteContent = false; let attachments: { id: number; filename: string; mimeType: string; size: number; contentId: string }[] = []; let deliveredTo = "", returnPath = "", listUnsubscribe = ""; let raw: Uint8Array | null = null; try { raw = await this.syncManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid); } catch (fetchErr: any) { // Mirror the desktop service: surface as structured bodyError // so the viewer shows its dedicated error banner instead of // rendering the message text verbatim in the body area. const rawErr = fetchErr.message || "fetch failed"; // "Deleted on the server" is claimed ONLY on positive server // evidence (the server said the UID doesn't exist). Everything // else — network, rate limits, and especially app bugs like a // ReferenceError from a mis-initialized bridge — is retryable. // The old logic defaulted UNKNOWN errors to permanent, so // "msgapi is not defined" rendered as "message may have been // deleted" (Bob 2026-07-12). const serverSaysGone = /no such message|not found|does not exist|nonexistent|expunged|invalid uid/i.test(rawErr); return { ...envelope, bodyHtml: "", bodyText: "", bodyError: rawErr, bodyErrorTransient: !serverSaysGone, hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: "" }; } if (!raw) { return { ...envelope, bodyHtml: "", bodyText: "", bodyError: "Message body not cached locally and the server fetch returned nothing.", bodyErrorTransient: true, hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: "" }; } else { const source = new TextDecoder().decode(raw); const parsed = parseEmailSource(source); bodyHtml = parsed.html || ""; bodyText = parsed.text || ""; attachments = (parsed.attachments || []).map((a, i) => ({ id: i, filename: a.filename || `attachment-${i}`, mimeType: a.contentType || "application/octet-stream", size: a.size || 0, contentId: a.contentId || "" })); // Header extraction — parity with desktop store.ts. The web parser // hardcoded these to "", so on Android msg.deliveredTo was always // empty and reply auto-From couldn't pick the alias the message was // delivered to (Bob 2026-06-18 "android reply doesn't handle the // delivered-to"). FIRST Delivered-To = final delivery (agents prepend). const hEnd = source.search(/\r?\n\r?\n/); const hb = hEnd >= 0 ? source.slice(0, hEnd) : source; const unfolded: string[] = []; for (const ln of hb.split(/\r?\n/)) { if (/^[ \t]/.test(ln) && unfolded.length) unfolded[unfolded.length - 1] += " " + ln.trim(); else unfolded.push(ln); } const firstHeader = (name: string): string => { const re = new RegExp("^" + name + ":\\s*(.*)$", "i"); for (const ln of unfolded) { const m = ln.match(re); if (m) return m[1].trim(); } return ""; }; deliveredTo = firstHeader("Delivered-To"); returnPath = firstHeader("Return-Path").replace(/[<>]/g, ""); listUnsubscribe = firstHeader("List-Unsubscribe"); } // Sanitize HTML + compute flagged-sender state. flaggedSenders / // flaggedDomains live in the same allowlist.jsonc as remote-content // permissions; matching this on phone parity-fixes the viewer's // ⚠ FLAGGED banner (which only painted on desktop before). const allowList = await loadAllowlist(); const senderAddr = (envelope.from?.address || "").toLowerCase(); const senderDomain = senderAddr.split("@")[1] || ""; const isFlagged = !!( (allowList.flaggedSenders || []).some((s: string) => (s || "").toLowerCase() === senderAddr) || (allowList.flaggedDomains || []).some((d: string) => (d || "").toLowerCase() === senderDomain) ); if (bodyHtml && !allowRemote) { const toAddrs = (envelope.to || []).map((a: any) => a.address); const isAllowed = allowList.senders.includes(senderAddr) || allowList.domains.includes(senderDomain) || toAddrs.some((a: string) => allowList.recipients?.includes(a)); if (isAllowed) { allowRemote = true; } else { const result = sanitizeHtml(bodyHtml); bodyHtml = result.html; hasRemoteContent = result.hasRemoteContent; } } return { ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote, attachments, deliveredTo, returnPath, listUnsubscribe, isFlagged, }; } /** Flag (or unflag) a sender / domain. Mirrors the desktop service so * the viewer's right-click "Flag sender" / "Flag domain" buttons work * on phone — was a no-op on Android before. Toggles membership in * flaggedSenders / flaggedDomains in allowlist.jsonc, which syncs * back to the cloud copy. */ async flagSenderOrDomain(type: "sender" | "domain", value: string): Promise<{ flagged: boolean }> { const v = (value || "").trim().toLowerCase(); if (!v) return { flagged: false }; // RMW via updateAllowlist — the mutation applies to the FRESH cloud // copy, never a stale/default cached base (the 2026-07-23 wipe). let flagged = false; await updateAllowlist((list: any) => { const key = type === "sender" ? "flaggedSenders" : "flaggedDomains"; const arr: string[] = Array.isArray(list[key]) ? list[key] : []; const idx = arr.findIndex((x: string) => (x || "").toLowerCase() === v); if (idx >= 0) { arr.splice(idx, 1); flagged = false; } else { arr.push(v); flagged = true; } list[key] = arr; return list; }); return { flagged }; } async updateFlags(accountId: string, uid: number, flags: string[]): Promise { const envelope = this.db.getMessageByUid(accountId, uid); await this.syncManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags); } // ── Remote content allow-list ── /** Read side of the shared allowlist, so the UI can ask whether a sender * has been vouched for. Mirrors MailxService.getAllowlist — the desktop * and Android halves must answer the same question the same way, or the * display-name warning would differ between them. */ async getAllowlist(): Promise<{ senders: string[]; domains: string[]; recipients: string[]; flaggedSenders: string[]; flaggedDomains: string[] }> { const list: any = (await loadAllowlist()) || {}; return { senders: list.senders || [], domains: list.domains || [], recipients: list.recipients || [], flaggedSenders: list.flaggedSenders || [], flaggedDomains: list.flaggedDomains || [], }; } async allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise { // RMW via updateAllowlist — see flagSenderOrDomain. await updateAllowlist((list) => { if (type === "sender" && !list.senders.includes(value)) list.senders.push(value); else if (type === "domain" && !list.domains.includes(value)) list.domains.push(value); else if (type === "recipient") { if (!list.recipients) list.recipients = []; if (!list.recipients.includes(value)) list.recipients.push(value); } return list; }); } // ── Search ── async search(q: string, page = 1, pageSize = 50, scope = "all", accountId?: string, folderId?: number): Promise { if (!q.trim()) return { items: [], total: 0, page, pageSize }; // On mobile, always use local search (no server-side IMAP search) if (scope === "current" && accountId && folderId) { return this.db.searchMessages(q, page, pageSize, accountId, folderId); } return this.db.searchMessages(q, page, pageSize); } // ── Sync ── getSyncPending(): { pending: number } { return { pending: this.db.getTotalPendingSyncCount() }; } async syncAll(): Promise { await this.syncManager.syncAll(); } async syncAccount(accountId: string): Promise { const folders = await this.syncManager.syncFolders(accountId); // INBOX-first: await INBOX so the UI re-renders new mail immediately, // then fire the rest in the background so labels don't block the // list. S57 (Android parity with desktop's local-first rule). const inbox = folders.find(f => f.specialUse === "inbox"); const others = folders.filter(f => f.specialUse !== "inbox"); if (inbox) { try { await this.syncManager.syncFolder(accountId, inbox.id); } catch (e: any) { console.error(` Skipping INBOX ${inbox.path}: ${e.message}`); } } // Background fan-out: don't await; errors log. UI already has INBOX. (async () => { for (const folder of others) { try { await this.syncManager.syncFolder(accountId, folder.id); } catch (e: any) { console.error(` Skipping folder ${folder.path}: ${e.message}`); } } })().catch(() => { /* top-level already logged */ }); } async reauthenticate(accountId: string): Promise { return this.syncManager.reauthenticate(accountId); } // ── Send ── async send(msg: any): Promise { console.log(`[send] from=${msg?.from} to=${(msg?.to || []).length} subject="${msg?.subject || ""}" attachments=${Array.isArray(msg?.attachments) ? msg.attachments.length : 0}`); const settings = await loadSettings(); const account = settings.accounts.find(a => a.id === msg.from); if (!account) throw new Error(`Unknown account: ${msg.from}`); const fromHeader = msg.fromAddress || `${account.name} <${account.email}>`; const to = msg.to.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", "); const cc = msg.cc?.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", "); const bcc = msg.bcc?.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", "); // Same assembler as desktop (mailx-types buildMimeMessage). This path // previously interpolated From/To/Cc/Bcc RAW — only Subject got RFC 2047 // in the 2026-08-08 fix — so an accented display name sent from Android // went out as an EAI message, which receivers without SMTPUTF8 must // bounce. Sharing the assembler is what makes that drift impossible; // another hand-applied patch would not have. const built = buildMimeMessage({ from: fromHeader, to, cc, bcc, subject: msg.subject, bodyHtml: msg.bodyHtml, bodyText: msg.bodyText, attachments: (msg as any).attachments, inReplyTo: msg.inReplyTo, references: msg.references, domain: account.email.split("@")[1] || "mailx.local", }); const rawMessage = built.raw; // queueOutgoingLocal on the Android bridge is async (it flushes // sql.js → IndexedDB before returning so a tab-close in the // debounce window can't lose the row). On the web-worker // SyncManager it's synchronous and returns void; awaiting an // undefined value is benign, so this works for both. await this.syncManager.queueOutgoingLocal(account.id, rawMessage); for (const addr of msg.to) this.db.recordSentAddress(addr.name, addr.address); if (msg.cc) for (const addr of msg.cc) this.db.recordSentAddress(addr.name, addr.address); if (msg.bcc) for (const addr of msg.bcc) this.db.recordSentAddress(addr.name, addr.address); } // ── Delete / Move ── async deleteMessage(accountId: string, uid: number): Promise { const envelope = this.db.getMessageByUid(accountId, uid); if (!envelope) throw new Error("Message not found"); await this.syncManager.trashMessage(accountId, envelope.folderId, envelope.uid); } async deleteMessages(accountId: string, uids: number[]): Promise { const messages = uids.map(uid => { const env = this.db.getMessageByUid(accountId, uid); if (!env) return null; return { uid: env.uid, folderId: env.folderId }; }).filter(m => m !== null); await this.syncManager.trashMessages(accountId, messages); } async moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise { const envelope = this.db.getMessageByUid(accountId, uid); if (!envelope) throw new Error("Message not found"); if (targetAccountId && targetAccountId !== accountId) { await this.syncManager.moveMessageCrossAccount(accountId, envelope.uid, envelope.folderId, targetAccountId, targetFolderId); } else { await this.syncManager.moveMessage(accountId, envelope.uid, envelope.folderId, targetFolderId); } } async moveMessages(accountId: string, uids: number[], targetFolderId: number): Promise { const messages = uids.map(uid => { const env = this.db.getMessageByUid(accountId, uid); if (!env) return null; return { uid: env.uid, folderId: env.folderId }; }).filter(m => m !== null); await this.syncManager.moveMessages(accountId, messages, targetFolderId); } async undeleteMessage(accountId: string, uid: number, folderId: number): Promise { await this.syncManager.undeleteMessage(accountId, uid, folderId); } // ── Drafts ── async saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number, draftId?: string, bcc?: string, from?: string, attachments?: { filename: string; mimeType: string; dataBase64: string }[]): Promise<{ draftUid: number | null; draftId: string }> { const settings = await loadSettings(); const account = settings.accounts.find(a => a.id === accountId); if (!account) throw new Error(`Unknown account: ${accountId}`); const id = draftId || `mailx-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // `attachments` mirrors desktop — a draft without them loses the file // when compose closes. Fixed in both on 2026-08-11; this is the same // shape in two files, so change them together. const raw = buildMimeMessage({ from: from?.trim() || `${account.name} <${account.email}>`, to, cc, bcc, subject: subject || "(no subject)", bodyHtml, bodyText, attachments, domain: account.email.split("@")[1] || "mailx.local", extraHeaders: [`X-Mailx-Draft-ID: ${id}`], omitMessageId: true, simpleHtml: true, }).raw; const draftUid = await this.syncManager.saveDraft(accountId, raw, previousDraftUid, id); // Was `{ uid, draftId }` — silent drift from desktop's `{ draftUid, draftId }` // contract. Clients reading `data.draftUid` got undefined on Android, // breaking dedup on the next save. return { draftUid, draftId: id }; } async deleteDraft(accountId: string, draftUid: number): Promise { await this.syncManager.deleteDraft(accountId, draftUid); } // ── Contacts ── searchContacts(query: string): any[] { query = (query || "").trim(); if (query.length < 1) return []; // Fire-and-forget freshness kick — NEVER awaited (local-first: this // is the keystroke → suggestions path). A denylist edit made on // desktop applies to the next keystrokes once the reload lands. void this.maybeRefreshContactsConfig().catch(() => { /* probe is best-effort */ }); return this.db.searchContacts(query); } /** Re-pull contacts.jsonc only when the Drive copy actually changed. * One metadata request (cloudStat) throttled to a few minutes; the * full download + reload runs only on a new modifiedTime. Kicked from * autocomplete, the periodic sync tick, and app-resume. */ private _contactsStatAt = 0; private _contactsModifiedTime: string | null = null; private static readonly CONTACTS_STAT_MS = 3 * 60 * 1000; async maybeRefreshContactsConfig(): Promise { const now = Date.now(); if (now - this._contactsStatAt < WebMailxService.CONTACTS_STAT_MS) return; this._contactsStatAt = now; const { cloudStat } = await import("./web-settings.js"); const stat = await cloudStat("contacts.jsonc"); if (!stat || stat.modifiedTime === this._contactsModifiedTime) return; this._contactsModifiedTime = stat.modifiedTime; console.log(`[contacts] contacts.jsonc changed on Drive (${stat.modifiedTime}) — reloading`); await this.loadContactsConfig(); } /** Address-book listing — paginated, filterable. Mirrors mailx-service's * signature so the same client-side address-book modal works on Android * without an "ipc(...).listContacts is not a function" crash. */ listContacts(query: string, page = 1, pageSize = 100) { return this.db.listContacts(query || "", page, pageSize); } /** Manual upsert from the address-book UI. The desktop path queues a * Google People sync; Android relies on the desktop pushing changes * back, so this is local-only for now. */ upsertContact(name: string, email: string): { ok: true } { this.db.upsertContact(name || "", email); return { ok: true }; } deleteContact(email: string): { ok: true } { this.db.deleteContactLocal(email); return { ok: true }; } addContact(name: string, email: string): boolean { if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) return false; this.db.recordSentAddress(name || "", email); return true; } /** Q49 heuristic mirror: true if the user has ever sent a message to * `recipientEmail` that had a non-empty Cc field. Compose uses this to * decide whether to auto-expand the Cc row on reply. */ hasCcHistoryTo(recipientEmail: string): boolean { return (this.db as any).hasCcHistoryTo?.(recipientEmail) ?? false; } // ── Settings ── async getSettings(): Promise { return loadSettings(); } async saveSettingsData(settings: any): Promise { await saveSettings(settings); } getStorageInfo(): { provider: string; mode: string; folderId?: string; folderName?: string; folderPath?: string; folderOwner?: string } { return getStorageInfo(); } // ── Folder management (limited on mobile — read-only) ── markFolderRead(folderId: number): void { this.db.markFolderRead(folderId); } // ── Autocomplete ── async getAutocompleteSettings(): Promise { return loadAutocomplete(); } async saveAutocompleteSettings(settings: AutocompleteSettings): Promise { await saveAutocomplete(settings); } async autocomplete(_req: any): Promise<{ suggestion: string }> { // Autocomplete disabled on mobile by default return { suggestion: "" }; } // ── Reset ── async resetStore(): Promise { await this.db.resetStore(); await this.bodyStore.clear(); console.log("[service] Store reset complete"); } // ── JSONC config editor (Android: GDrive only; no local config.jsonc) ── // Mirror of the desktop service so the in-app editor works on phone. // Android sandbox has no per-machine config.jsonc; selecting it returns // null (the editor displays "(file not available on this platform)"). async readJsoncFile(name: string): Promise { const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"]; if (!WHITELIST.includes(name)) throw new Error(`File not allowed: ${name}`); if (name === "config.jsonc") return null; // local-only on desktop; n/a on Android const { cloudRead } = await import("./web-settings.js"); return await cloudRead(name); } async writeJsoncFile(name: string, content: string): Promise { const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"]; if (!WHITELIST.includes(name)) throw new Error(`File not allowed: ${name}`); if (name === "config.jsonc") throw new Error("config.jsonc is local-only — not editable on Android"); const { parse: parseJsonc } = await import("jsonc-parser"); const errors: any[] = []; parseJsonc(content, errors, { allowTrailingComma: true }); if (errors.length) { throw new Error(`JSONC parse error: ${errors.map(e => e.error).join(", ")}`); } const { cloudWrite } = await import("./web-settings.js"); const ok = await cloudWrite(name, content); if (!ok) throw new Error(`Failed to save ${name} to Drive`); } async formatJsonc(content: string): Promise { const { format, applyEdits } = await import("jsonc-parser"); const edits = format(content, undefined, { tabSize: 2, insertSpaces: true, eol: "\n", insertFinalNewline: true, }); return applyEdits(content, edits); } async readConfigHelp(_name: string): Promise { // Help markdown isn't bundled in the Android assets — return empty // so the editor's help panel just shows "No help available". return ""; } // ── Priority senders / domains (Android parity) ── // Cached in-memory; refreshed on every read since contacts.jsonc is // small (~hundreds of entries) and reads hit IndexedDB cache. /** Compute and return the priority sender / domain index from the * current contacts.jsonc. Reads from cloud (or local cache) every * call — small file, simpler than maintaining a long-lived cache. */ async getPriorityLists(): Promise<{ senders: string[]; domains: string[] }> { const { cloudRead } = await import("./web-settings.js"); const raw = await cloudRead("contacts.jsonc"); if (!raw) return { senders: [], domains: [] }; const cfg = parseJsoncLoose(raw); const senders = Array.isArray(cfg?.preferred) ? cfg.preferred.filter((e: any) => e?.priority === true && e.email).map((e: any) => (e.email as string).toLowerCase()) : []; const domains = Array.isArray(cfg?.priorityDomains) ? cfg.priorityDomains.map((d: string) => (d || "").toLowerCase()).filter(Boolean) : []; return { senders, domains }; } async setPrioritySender(email: string, value: boolean, name?: string): Promise { const lower = (email || "").trim().toLowerCase(); if (!lower) return; const { cloudRead, cloudWrite } = await import("./web-settings.js"); const raw = await cloudRead("contacts.jsonc"); const cfg = raw ? parseJsoncLoose(raw) || {} : {}; if (!Array.isArray(cfg.preferred)) cfg.preferred = []; const idx = cfg.preferred.findIndex((e: any) => (e?.email || "").toLowerCase() === lower); if (idx >= 0) { if (value) cfg.preferred[idx].priority = true; else delete cfg.preferred[idx].priority; } else if (value) { cfg.preferred.push({ name: name || "", email, priority: true }); } await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2)); } async setPriorityDomain(domain: string, value: boolean): Promise { const lower = (domain || "").trim().toLowerCase(); if (!lower) return; const { cloudRead, cloudWrite } = await import("./web-settings.js"); const raw = await cloudRead("contacts.jsonc"); const cfg = raw ? parseJsoncLoose(raw) || {} : {}; if (!Array.isArray(cfg.priorityDomains)) cfg.priorityDomains = []; const i = cfg.priorityDomains.findIndex((d: string) => (d || "").toLowerCase() === lower); if (value && i < 0) cfg.priorityDomains.push(lower); else if (!value && i >= 0) cfg.priorityDomains.splice(i, 1); await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2)); } // ── MailxApi contract: name aliases ─────────────────────────────── // The IPC dispatch names (`sendMessage`, `searchMessages`) differ from // the historical web method names (`send`, `search`). Thin aliases keep // the existing internal method bodies while satisfying `implements // MailxApi`. NEW methods should use the IPC name directly so this // section doesn't grow. sendMessage(msg: any): Promise { return this.send(msg); } searchMessages(query: string, page?: number, pageSize?: number, scope?: string, accountId?: string, folderId?: number, _includeTrashSpam?: boolean): Promise { // Android `search` lacks the includeTrashSpam flag — folder scoping // is enforced UI-side anyway. Drop the 7th argument silently. return this.search(query, page, pageSize, scope, accountId, folderId); } // ── MailxApi contract: explicit stubs for unimplemented Android features ─ // // Every method below is in the contract but has no Android implementation // today. Each is declared explicitly so it's *visible* in this file — // before the contract was added, these were just absent and IPC calls // returned "parent bridge has no method X", silently swallowed by // `.catch()` in callers. Now the gap is in one auditable list. // // Implementation policy: // - Pure read with no Android backing: return [] / {} / null / false. // UI degrades gracefully; no crash. // - Write that mutates state: throw `notImpl()` so a UI button that // calls it surfaces an error rather than appearing to succeed. // - OS-specific (Word, msger popups, OS file open): also `notImpl()`. // These are declared optional `?:` in the contract so we technically // could omit them entirely — explicit stubs are clearer. // // To wire one up: replace the stub body, run tsc, the contract enforces // the signature. private notImpl(name: string): never { throw new Error(`Not implemented on Android: ${name}`); } // Threads / attachments / message reads getThreadMessages(_accountId: string, _threadId: string): any { return []; } async getMessageSource(_accountId: string, _uid: number, _folderId?: number): Promise<{ dataBase64: string; filename: string }> { return this.notImpl("getMessageSource"); } async getAttachment(_accountId: string, _uid: number, _attachmentId: number, _folderId?: number): Promise<{ content: any; contentType: string; filename: string }> { return this.notImpl("getAttachment"); } // Android opens attachments via the native bridge (_nativeBridge.openAttachment), // not this service path — getAttachment + base64 hand-off covers it. async openAttachment(_accountId: string, _uid: number, _attachmentId: number, _folderId?: number): Promise<{ ok: boolean; path: string }> { return this.notImpl("openAttachment"); } // Spam / abuse /** Mark as spam = move to the account's \Junk folder, exactly as desktop's * MailxService.markAsSpamMessages does. This was a notImpl() stub, which * is worse than it sounds: the client optimistically removes the rows * from the list BEFORE calling this, then only writes the rejection to * the status bar. So on Android the spam button looked like it worked, * nothing moved on the server, and the next sync brought every message * straight back (Bob 2026-08-07: "attempts to delete spam but they keep * coming back"). moveMessages was implemented all along — spam just never * got wired to it. */ async markAsSpamMessages(accountId: string, uids: number[]): Promise<{ targetFolderId: number; moved: number }> { const target = this.db.getFolders(accountId).find((f: any) => f.specialUse === "junk"); if (!target) throw new Error(`No \\Junk/\\Spam folder found for ${accountId}`); await this.moveMessages(accountId, uids, target.id); return { targetFolderId: target.id, moved: uids.length }; } async recordSpamReport(_accountId: string, _uid: number, _folderId: number): Promise { return this.notImpl("recordSpamReport"); } // Folder CRUD async createFolder(_accountId: string, _parentPath: string, _name: string): Promise { this.notImpl("createFolder"); } async renameFolder(_accountId: string, _folderId: number, _newName: string): Promise { this.notImpl("renameFolder"); } async deleteFolder(_accountId: string, _folderId: number): Promise { this.notImpl("deleteFolder"); } async moveFolderToTrash(_accountId: string, _folderId: number): Promise { this.notImpl("moveFolderToTrash"); } async emptyFolder(_accountId: string, _folderId: number): Promise { this.notImpl("emptyFolder"); } // Outbox management getOutboxStatus(): any { return { count: 0, items: [] }; } listQueuedOutgoing(): any[] { return []; } cancelQueuedOutgoing(_filePath: string): { ok: true } { return { ok: true }; } // Sync infrastructure — getSyncPending already defined above async drainStoreSync(): Promise { /* no-op on Android */ } reauthGoogleScopes(): { cleared: number } { return { cleared: 0 }; } /** No per-folder sync lane on Android — a folder "sync now" refreshes the * whole account (cheap: Gmail incremental sync / one IMAP pass). */ async syncFolderNow(accountId: string, _folderId: number): Promise { await this.syncAccount(accountId); } cancelServerSearch(): void { /* search is local-only on Android — nothing to cancel */ } async copyMessages(_accountId: string, _uids: number[], _folderIds: number[] | undefined, _targetFolderId: number): Promise { this.notImpl("copyMessages"); } /** `{}` = play the built-in chime — no custom sound files on Android. */ async getReminderSound(): Promise<{ mute?: boolean; dataBase64?: string; mime?: string }> { return {}; } // Contacts extensions. The contacts.jsonc mutation logic is shared with // the desktop service — see mailx-types/contacts-config.ts. These used // to be `notImpl()` stubs, so the phone's ★ / ⊘ autocomplete buttons did // nothing; now they run the same code desktop does. async addPreferredContact(entry: { name: string; email: string; source?: string; organization?: string }): Promise { const { cloudRead, cloudWrite } = await import("./web-settings.js"); const { addContactsPreferredEntry } = await import("@bobfrankston/mailx-types"); await addContactsPreferredEntry(entry, cloudRead, cloudWrite); await this.loadContactsConfig(); } async addToDenylist(email: string): Promise { const { cloudRead, cloudWrite } = await import("./web-settings.js"); const { addContactsDenylistEntry } = await import("@bobfrankston/mailx-types"); await addContactsDenylistEntry(email, cloudRead, cloudWrite); await this.loadContactsConfig(); } hasBccHistoryTo(_email: string): boolean { return false; } /** Pull the shared GDrive `contacts.jsonc` and merge its entries into the * local contacts table. Desktop flushes the full union there (everything * it discovered from its mailbox corpus + Google contacts + preferred); * this is how an Android device gets contacts it never saw in its own * (partial) on-device corpus. Called at startup AND on a periodic tick / * app-resume / after a ⊘ so a denylist edit made on another device * reaches a running phone (Bob 2026-07-07: GDrive edit was still being * offered). The denylist refresh runs every call; the contact IMPORT * runs once per session — recordSentAddress bumps `use_count`, so * re-importing on every reload would inflate autocomplete rank. */ private _contactsImported = false; async loadContactsConfig(): Promise { try { const { cloudRead } = await import("./web-settings.js"); const raw = await cloudRead("contacts.jsonc"); if (!raw) return null; const cfg = parseJsoncLoose(raw); if (!cfg) return null; // Apply the denylist so denylisted addresses are filtered out of // autocomplete — without this the ⊘ button writes the cloud file // but the phone keeps suggesting the address. this.db.setContactsDenylist(Array.isArray(cfg.denylist) ? cfg.denylist : []); this.db.setContactsDenyPatterns(Array.isArray(cfg.denylistPatterns) ? cfg.denylistPatterns : []); if (this._contactsImported) return { imported: 0 }; this._contactsImported = true; let imported = 0; for (const list of [cfg.preferred, cfg.discovered]) { if (!Array.isArray(list)) continue; for (const e of list) { const email = (e?.email || "").trim(); if (!email) continue; this.db.recordSentAddress((e?.name || "").trim(), email); imported++; } } if (imported > 0) console.log(`[contacts] loaded ${imported} from contacts.jsonc`); return { imported }; } catch (e: any) { console.error(`[contacts] loadContactsConfig failed: ${e?.message || e}`); return null; } } // Calendar — Android UI has its own native calendar; no JS-side data. async getCalendarEvents(_fromMs: number, _toMs: number): Promise { return []; } async getCalendars(): Promise> { return []; } async createCalendarEvent(_ev: any): Promise<{ uuid: string }> { return this.notImpl("createCalendarEvent"); } async updateCalendarEvent(_uuid: string, _patch: any): Promise<{ ok: true }> { return this.notImpl("updateCalendarEvent"); } async deleteCalendarEvent(_uuid: string): Promise<{ ok: true }> { return this.notImpl("deleteCalendarEvent"); } // Tasks — same story async getTasks(_includeCompleted?: boolean): Promise { return []; } async createTask(_t: { title: string; notes?: string; dueMs?: number }): Promise<{ uuid: string }> { return this.notImpl("createTask"); } async updateTask(_uuid: string, _patch: any): Promise<{ ok: true }> { return this.notImpl("updateTask"); } async deleteTask(_uuid: string): Promise<{ ok: true }> { return this.notImpl("deleteTask"); } // Reminder dismissed/snoozed state — same reminders.jsonc on the shared // Drive folder the desktop uses, same mailx-types merge. Alarms don't // fire on Android today (getCalendarEvents/getTasks return []), but the // API is live so this device still contributes its state and adopts the // desktops' — the moment calendar lands here, sync just works. private reminderStateCache: { state: ReminderState; at: number } | null = null; async getReminderState(): Promise { if (this.reminderStateCache && Date.now() - this.reminderStateCache.at < 60_000) { return this.reminderStateCache.state; } const now = Date.now(); let state: ReminderState = { dismissed: {}, snoozed: {} }; try { const json = await cloudRead(REMINDER_STATE_FILE); if (json != null) state = normalizeReminderState(JSON.parse(json), now); } catch { /* offline — empty state; localStorage still rules locally */ } this.reminderStateCache = { state, at: Date.now() }; return state; } async mergeReminderState(patch: any): Promise { const now = Date.now(); const local = normalizeReminderState(patch, now); let cloud: ReminderState = { dismissed: {}, snoozed: {} }; let hadCloud = false; try { const json = await cloudRead(REMINDER_STATE_FILE); if (json != null) { cloud = normalizeReminderState(JSON.parse(json), now); hadCloud = true; } } catch { /* offline */ } const merged = mergeReminderStates(cloud, local, now); if (!(hadCloud && reminderStatesEqual(merged, cloud))) { try { await cloudWrite(REMINDER_STATE_FILE, JSON.stringify(merged, null, 2) + "\n"); } catch (e: any) { console.error(`[reminders] cloud write failed: ${e?.message || e}`); } } this.reminderStateCache = { state: merged, at: Date.now() }; return merged; } // User dictionary (cloud-mirrored) — Android can implement these via // the same userdict.csv cloud round-trip the desktop uses; until that // wire-up lands, return safe defaults so spellcheck.ts catch() paths // don't fire and clutter logs. async getUserDict(): Promise { return []; } async addUserDictWord(_word: string): Promise { return []; } async addUserDictWords(_words: string[]): Promise { return []; } async removeUserDictWord(_word: string): Promise { return []; } // Diagnostics / version / primary account getDiagnostics(): any { return { ok: true, platform: "android" }; } getPrimaryAccount(_feature?: string): any { return null; } getVersion(): any { return { version: "android", platform: "android" }; } consumePendingMailto(): any { return null; } logClientEvent(..._args: any[]): void { /* no-op — server-side logging not wired on Android */ } // Setup / repair / unsubscribe async setupAccount(_name: string, _email: string, _password?: string): Promise<{ ok: boolean; error?: string; message?: string }> { return this.notImpl("setupAccount"); } async repairAccounts(): Promise<{ ok: boolean; error?: string; message?: string }> { return this.notImpl("repairAccounts"); } async unsubscribeOneClick(_url: string): Promise<{ ok: boolean; status: number; statusText: string }> { return this.notImpl("unsubscribeOneClick"); } // AI transforms — pipe through to web autocomplete or no-op async aiTransform(_req: any): Promise { return this.notImpl("aiTransform"); } } /** Loose JSONC parse — strips // comments and trailing commas before JSON.parse. * Sufficient for contacts.jsonc which is machine-written; doesn't pull in * jsonc-parser as a dep on the Android side where bundle size matters. */ function parseJsoncLoose(raw: string): any { try { const stripped = raw .replace(/^\s*\/\/.*$/gm, "") .replace(/,(\s*[}\]])/g, "$1"); return JSON.parse(stripped); } catch { return null; } }