/** * Sync manager — extracted from android-bootstrap.ts for use in both * main-thread and Worker contexts. * * Platform-specific dependencies are injected: * - emitEvent: posts events to the UI * - vlog: verbose remote logging * - createTcpTransport: factory for TCP transport (BridgeTcpTransport or WorkerTcpTransport) */ import type { WebMailxDB } from "./db.js"; import type { WebMessageStore } from "./web-message-store.js"; import type { WebSyncManager } from "./web-service.js"; import { GmailApiWebProvider } from "./gmail-api-web.js"; import { ImapWebProvider } from "./imap-web-provider.js"; import { SmtpClient, type SmtpAuth } from "@bobfrankston/smtp-direct"; import { storeBus } from "@bobfrankston/mailx-bus"; import type { MailProvider, ProviderMessage } from "./provider-types.js"; import type { Folder, EmailAddress, AccountConfig } from "@bobfrankston/mailx-types"; function toEmailAddress(addr: { name?: string; address?: string } | undefined): EmailAddress { return { name: addr?.name || "", address: addr?.address || "" }; } export interface SyncManagerDeps { emitEvent: (event: any) => void; vlog: (msg: string) => void; createTcpTransport: () => any; // TcpTransport — BridgeTcpTransport or WorkerTcpTransport } export class SyncManager implements WebSyncManager { private providers = new Map(); private tokenProviders = new Map Promise>(); private deps: SyncManagerDeps; constructor( private db: WebMailxDB, private bodyStore: WebMessageStore, deps: SyncManagerDeps, ) { this.deps = deps; } on(_event: string, _handler: (...args: any[]) => void): void { /* stub */ } emit(event: string, ...args: any[]): void { this.deps.emitEvent({ type: event, ...args[0] }); } async addAccount(account: AccountConfig): Promise { this.deps.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)); 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) { try { const provider = new ImapWebProvider({ server: account.imap.host, port: account.imap.port || 993, username: account.imap.user, password: account.imap.password, inactivityTimeout: 300000, fetchChunkSize: 10, fetchChunkSizeMax: 100, }, this.deps.createTcpTransport); this.providers.set(account.id, provider); this.deps.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) { this.deps.vlog(`addAccount ${account.id}: IMAP provider FAILED: ${e.message}`); console.error(`[sync] ${account.id}: IMAP provider failed: ${e.message}`); } } else { this.deps.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; } async syncAll(): Promise { const accounts = this.db.getAccounts(); this.deps.vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map((a: any) => a.id).join(",")}`); // Phase 1: Sync INBOX for every account first — user sees new mail fast. for (const account of accounts) { if (!this.providers.has(account.id)) continue; try { const folders = await this.syncFolders(account.id); const inbox = folders.find((f: any) => f.specialUse === "inbox"); if (inbox) { await this.syncFolder(account.id, inbox.id); this.deps.emitEvent({ type: "syncComplete", accountId: account.id }); } } catch (e: any) { console.error(`[sync] ${account.id} inbox: ${e.message}`); } } // Phase 2: Remaining folders. for (const account of accounts) { if (!this.providers.has(account.id)) continue; try { const folders = this.db.getFolders(account.id); const remaining = folders.filter((f: any) => f.specialUse !== "inbox"); for (const folder of remaining) { try { await this.syncFolder(account.id, folder.id); } catch (e: any) { console.error(`[sync] Skip ${folder.path}: ${e.message}`); } } this.db.updateLastSync(account.id, Date.now()); this.deps.emitEvent({ type: "syncComplete", accountId: account.id }); } catch (e: any) { console.error(`[sync] ${account.id}: ${e.message}`); this.deps.vlog(`syncAll: ${account.id} ERROR: ${e.message}`); this.deps.emitEvent({ type: "syncError", accountId: account.id, error: e.message }); } } } async syncAccount(accountId: string): Promise { const folders = await this.syncFolders(accountId); for (const folder of folders) { try { await this.syncFolder(accountId, folder.id); } catch (e: any) { console.error(`[sync] Skip ${folder.path}: ${e.message}`); } } this.db.updateLastSync(accountId, Date.now()); this.deps.emitEvent({ type: "syncComplete", accountId }); } async syncFolders(accountId: string): Promise { const provider = this.getProvider(accountId); if (!provider) { const existing = this.db.getFolders(accountId); this.deps.vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`); return existing; } this.deps.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); } this.deps.emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 100 }); const dbFolders = this.db.getFolders(accountId); console.log(`[sync] ${accountId}: ${dbFolders.length} folders in db after upsert` + `, inbox=${dbFolders.some((f: any) => f.specialUse === "inbox") ? "yes" : "no"}`); this.deps.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: any) => f.id === folderId); if (!folder) return; this.deps.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); let messages: ProviderMessage[]; if (highestUid > 0) { messages = await provider.fetchSince(folder.path, highestUid, { source: false }); messages = messages.filter((m: any) => 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); this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} }); } // Reconcile deletions 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: number) => !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}`); } 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); this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} }); } } } } catch (e: any) { console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`); } this.deps.emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] }); this.deps.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"); const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : ""; // `messages.date` is NOT NULL. An unparseable envelope date // yields an *Invalid Date* — truthy, `instanceof Date`, but // `.getTime()` is NaN, which binds as NULL and fails the // constraint, aborting the whole batch. Default any non-finite // value to now. const dateRaw = msg.date instanceof Date ? msg.date.getTime() : typeof msg.date === "number" ? msg.date : NaN; const dateMs = Number.isFinite(dateRaw) ? dateRaw : Date.now(); const sentRaw = (msg as any).sentDate instanceof Date ? (msg as any).sentDate.getTime() : typeof (msg as any).sentDate === "number" ? (msg as any).sentDate : NaN; this.db.upsertMessage({ accountId, folderId, uid: msg.uid, messageId: msg.messageId || "", inReplyTo: "", references: [], date: dateMs, sentDate: Number.isFinite(sentRaw) ? sentRaw : undefined, subject: msg.subject || "", from: toEmailAddress(msg.from?.[0]), to: msg.to.map((a: any) => toEmailAddress(a)), cc: msg.cc.map((a: any) => 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(); // Cache first — IndexedDB lookup is O(1) and should always win on a // previously-fetched body. If this path misses on something the user // clearly fetched before, the cache is broken (wrong key shape, wiped // IndexedDB, account-id change) and needs investigation — log so it's // visible. 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.getProvider(accountId); if (!provider) { console.warn(`[fetchBody] no provider for ${accountId}`); return null; } const envelope = this.db.getMessageByUid(accountId, uid, folderId); const bp = (envelope as any)?.bodyPath || ""; // Wall-clock timeout — without this, an IMAP provider that dangles // (Dovecot silently dropped the socket, BridgeTransport stalled) hung // the viewer forever. 60 s is generous for a single-message BODY[] // fetch on a phone connection; legit large bodies finish in seconds, // and anything longer means the socket is dead and retrying on a fresh // one is faster than waiting. const FETCH_TIMEOUT_MS = 60_000; const fetchPromise = (async (): Promise => { if (bp.startsWith("gmail:") && (provider as any).fetchById) { const providerId = bp.substring(6); return (provider as any).fetchById(providerId, { source: true }); } const folders = this.db.getFolders(accountId); const folder = folders.find((f: any) => f.id === folderId); if (!folder) return null; return provider.fetchOne(folder.path, uid, { source: true }); })(); let msg: any = null; try { msg = await Promise.race([ fetchPromise, 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) { console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, ${Date.now() - t0}ms)`); return null; } 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; } /** Publish a Store event AND the legacy emitEvent() shape until every * consumer subscribes via storeBus. Same shape as the desktop side — * that's the load-bearing property for cross-platform code sharing. */ private publishStore(event: { topic: string; kind: string; [k: string]: any }): void { storeBus.publish(event as any); // Bridge to existing emitEvent listeners. Drop this when all WebView // consumers switch to subscribeStore (mailx-bus → bridge → WebView). if (event.kind === "folderCountsChanged") { this.deps.emitEvent({ type: "folderCountsChanged", accountId: event.accountId, counts: {} }); } else if (event.kind === "messageMoved") { this.deps.emitEvent({ type: "messageMoved", accountId: event.accountId, fromFolderId: event.folderId, toFolderId: event.targetFolderId, uid: event.uid }); } else if (event.kind === "messageRemoved") { this.deps.emitEvent({ type: "messageDeleted", accountId: event.accountId, folderId: event.folderId, uid: event.uid }); } } 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 }); const env: any = this.db.getMessageByUid(accountId, uid, folderId); const msgUuid: string | undefined = env?.uuid; if (msgUuid) this.publishStore({ topic: `message:${msgUuid}`, kind: "flagsChanged", accountId, folderId, uid, msgUuid, flags }); this.publishStore({ topic: `account:${accountId}`, kind: "folderCountsChanged", accountId, folderId }); } async trashMessage(accountId: string, folderId: number, uid: number): Promise { const env: any = this.db.getMessageByUid(accountId, uid, folderId); const msgUuid: string | undefined = env?.uuid; this.db.deleteMessage(accountId, folderId, uid); this.db.queueSyncAction(accountId, "trash", uid, folderId); if (msgUuid) this.publishStore({ topic: `message:${msgUuid}`, kind: "messageRemoved", accountId, folderId, uid, msgUuid }); this.publishStore({ topic: `account:${accountId}`, kind: "folderCountsChanged", accountId, folderId }); } 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 { const env: any = this.db.getMessageByUid(accountId, uid, folderId); const msgUuid: string | undefined = env?.uuid; this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId }); if (msgUuid) this.publishStore({ topic: `message:${msgUuid}`, kind: "messageMoved", accountId, folderId, targetFolderId, uid, msgUuid }); } 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 to Gmail (or other provider) the * same way desktop does, so local actions propagate without needing a * desktop to relay them. Called from android-bootstrap on startup and * every 2-min sync tick. `send` actions are drained separately by * processSendQueue. */ async processSyncActions(accountId: string): Promise { const provider: any = this.getProvider(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") { await provider.trashMessage(path, 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); } else { // Unsupported action for this provider — don't loop forever. 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); } } } async markFolderRead(folderId: number): Promise { this.db.markFolderRead(folderId); } async emptyFolder(accountId: string, folderId: number): Promise { const uids = this.db.getUidsForFolder(accountId, folderId); for (const uid of uids) { this.db.deleteMessage(accountId, folderId, uid); this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => {}); } this.db.recalcFolderCounts(folderId); this.deps.emitEvent({ type: "folderCountsChanged", accountId, counts: {} }); } queueOutgoingLocal(accountId: string, rawMessage: string): void { 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})`); this.deps.emitEvent({ type: "sendComplete", accountId, messageId: result.id }); }) .catch((e: any) => { console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`); this.deps.emitEvent({ type: "sendError", accountId, error: e.message }); }); return; } const accounts = this.db.getAccountConfigs(); const row = accounts.find((a: any) => a.id === accountId); if (!row) { const e = "Unknown account"; this.deps.emitEvent({ type: "sendError", accountId, error: e }); throw new Error(e); } let account: AccountConfig; try { account = JSON.parse(row.configJson); } catch { const e = "Account config malformed"; this.deps.emitEvent({ type: "sendError", accountId, error: e }); throw new Error(e); } if (!account.smtp) { const e = "No SMTP config for this account"; this.deps.emitEvent({ type: "sendError", accountId, error: e }); throw new Error(e); } this.sendViaSmtpDirect(accountId, account, rawMessage) .then((result) => { console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`); this.deps.emitEvent({ type: "sendComplete", accountId }); // File a Sent copy. SMTP delivers but doesn't store anything — // without this APPEND a phone-sent message on an IMAP account // never appeared in Sent (Bob 2026-07-01, the 19:36 Screenshot). // Gmail's API path self-files, so only this branch needs it. // Best-effort: the message was delivered either way; failure is // logged, and the desktop's sent-sweep can repair later. const p = this.getProvider(accountId) as any; if (p && typeof p.appendToSent === "function") { p.appendToSent(rawMessage) .then((uid: number | null) => console.log(`[send] ${accountId}: Sent copy filed${uid ? ` (uid ${uid})` : ""}`)) .catch((e: any) => console.error(`[send] ${accountId}: Sent copy APPEND failed: ${e.message}`)); } }) .catch((e: any) => { console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`); this.deps.emitEvent({ type: "sendError", accountId, error: e.message }); }); } private async sendViaSmtpDirect( accountId: string, account: AccountConfig, raw: string, ): Promise<{ accepted: string[]; rejected: { address: string; code: number; message: string }[] }> { const smtp = account.smtp!; const smtpPort = smtp.port || 587; const smtpHost = smtp.host || account.imap?.host; if (!smtpHost) throw new Error("No SMTP host"); 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 }; } const parseAddrs = (s: string) => s.match(/[\w.+-]+@[\w.-]+/g) || []; // 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; same code shape here). See mailx-imap sendRaw. 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"); // Strip Bcc from the transmitted copy — headers only (a body line // that merely LOOKS like "Bcc:" in quoted text must not be eaten). // Folded continuation lines go with it. The `(\r?\n|$)` tail // consumes the line ending so no blank line is left mid-headers; // the final trim handles a Bcc sitting as the last header line. 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 === 465, auth, localname: "mailx-android", }, this.deps.createTcpTransport); 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, _uid: number): Promise {} async reauthenticate(_accountId: string): Promise { return false; } async searchOnServer(_accountId: string, _query: string): Promise { return []; } async syncAllContacts(): Promise {} }