@bobfrankston/mailx-store-web 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/android-bootstrap.d.ts +16 -0
  2. package/android-bootstrap.d.ts.map +1 -0
  3. package/android-bootstrap.js +1438 -0
  4. package/android-bootstrap.js.map +1 -0
  5. package/android-bootstrap.ts +1450 -0
  6. package/db.d.ts +146 -0
  7. package/db.d.ts.map +1 -0
  8. package/db.js +725 -0
  9. package/db.js.map +1 -0
  10. package/db.ts +831 -0
  11. package/gmail-api-web.d.ts +11 -0
  12. package/gmail-api-web.d.ts.map +1 -0
  13. package/gmail-api-web.js +11 -0
  14. package/gmail-api-web.js.map +1 -0
  15. package/gmail-api-web.ts +11 -0
  16. package/imap-web-provider.d.ts +33 -0
  17. package/imap-web-provider.d.ts.map +1 -0
  18. package/imap-web-provider.js +140 -0
  19. package/imap-web-provider.js.map +1 -0
  20. package/imap-web-provider.ts +156 -0
  21. package/index.d.ts +10 -0
  22. package/index.d.ts.map +1 -0
  23. package/index.js +10 -0
  24. package/index.js.map +1 -0
  25. package/index.ts +10 -0
  26. package/main-thread-host.d.ts +15 -0
  27. package/main-thread-host.d.ts.map +1 -0
  28. package/main-thread-host.js +292 -0
  29. package/main-thread-host.js.map +1 -0
  30. package/main-thread-host.ts +322 -0
  31. package/package.json +41 -0
  32. package/provider-types.d.ts +7 -0
  33. package/provider-types.d.ts.map +1 -0
  34. package/provider-types.js +7 -0
  35. package/provider-types.js.map +1 -0
  36. package/provider-types.ts +7 -0
  37. package/sql-wasm-esm.js +10 -0
  38. package/sql.js.d.ts +29 -0
  39. package/sync-manager.d.ts +68 -0
  40. package/sync-manager.d.ts.map +1 -0
  41. package/sync-manager.js +506 -0
  42. package/sync-manager.js.map +1 -0
  43. package/sync-manager.ts +508 -0
  44. package/tsconfig.json +10 -0
  45. package/web-jsonrpc.d.ts +20 -0
  46. package/web-jsonrpc.d.ts.map +1 -0
  47. package/web-jsonrpc.js +112 -0
  48. package/web-jsonrpc.js.map +1 -0
  49. package/web-jsonrpc.ts +126 -0
  50. package/web-message-store.d.ts +16 -0
  51. package/web-message-store.d.ts.map +1 -0
  52. package/web-message-store.js +89 -0
  53. package/web-message-store.js.map +1 -0
  54. package/web-message-store.ts +97 -0
  55. package/web-service.d.ts +136 -0
  56. package/web-service.d.ts.map +1 -0
  57. package/web-service.js +687 -0
  58. package/web-service.js.map +1 -0
  59. package/web-service.ts +754 -0
  60. package/web-settings.d.ts +91 -0
  61. package/web-settings.d.ts.map +1 -0
  62. package/web-settings.js +518 -0
  63. package/web-settings.js.map +1 -0
  64. package/web-settings.ts +547 -0
  65. package/worker-bundle.js +6838 -0
  66. package/worker-entry.d.ts +8 -0
  67. package/worker-entry.d.ts.map +1 -0
  68. package/worker-entry.js +218 -0
  69. package/worker-entry.js.map +1 -0
  70. package/worker-entry.ts +245 -0
  71. package/worker-tcp-transport.d.ts +28 -0
  72. package/worker-tcp-transport.d.ts.map +1 -0
  73. package/worker-tcp-transport.js +98 -0
  74. package/worker-tcp-transport.js.map +1 -0
  75. package/worker-tcp-transport.ts +101 -0
@@ -0,0 +1,1450 @@
1
+ /**
2
+ * Android bootstrap — wires WebMailxDB + WebMessageStore + GmailApiWebProvider + WebMailxService
3
+ * into the mailxapi bridge. This replaces Node.js backend for Android WebView.
4
+ *
5
+ * On Android, everything runs in the same JavaScript context:
6
+ * - wa-sqlite for metadata (via WebMailxDB)
7
+ * - IndexedDB for message bodies (via WebMessageStore)
8
+ * - Gmail/Outlook sync via REST APIs (plain fetch — no native bridge needed)
9
+ * - IMAP accounts use BridgeTransport (via MAUI TCP bridge) — not yet implemented
10
+ *
11
+ * The existing client UI (app.ts, components/) is completely unchanged —
12
+ * it calls window.mailxapi.* which this module provides.
13
+ */
14
+
15
+ import { WebMailxDB } from "./db.js";
16
+ import { WebMessageStore } from "./web-message-store.js";
17
+ import { WebMailxService, type WebSyncManager } from "./web-service.js";
18
+ import {
19
+ loadAccounts, loadAccountsFromCloud, saveAccounts, loadSettings, clearSettings, getDeviceId,
20
+ setGDriveTokenProvider, setGDriveFolderId
21
+ } from "./web-settings.js";
22
+ import { GmailApiWebProvider } from "./gmail-api-web.js";
23
+ import { ImapWebProvider } from "./imap-web-provider.js";
24
+ import { SmtpClient, type SmtpAuth } from "@bobfrankston/smtp-direct";
25
+ import { BridgeTcpTransport } from "@bobfrankston/tcp-transport";
26
+ import type { MailProvider, ProviderMessage } from "./provider-types.js";
27
+ import type { Folder, EmailAddress, AccountConfig } from "@bobfrankston/mailx-types";
28
+
29
+ // ── State ──
30
+
31
+ let db: WebMailxDB;
32
+ let bodyStore: WebMessageStore;
33
+ let service: WebMailxService;
34
+ let syncManager: AndroidSyncManager;
35
+ const eventHandlers: ((event: any) => void)[] = [];
36
+
37
+ // ── Event emitter ──
38
+
39
+ function emitEvent(event: any): void {
40
+ for (const h of eventHandlers) {
41
+ try { h(event); } catch { /* ignore */ }
42
+ }
43
+ if (typeof (window as any)._msgapiServiceEvent === "function") {
44
+ (window as any)._msgapiServiceEvent(event);
45
+ }
46
+ }
47
+
48
+ // ── Helpers ──
49
+
50
+ function toEmailAddress(addr: { name?: string; address?: string } | undefined): EmailAddress {
51
+ return { name: addr?.name || "", address: addr?.address || "" };
52
+ }
53
+
54
+ /** Verbose log — goes to logit but doesn't clutter the screen (silent=true) */
55
+ function vlog(msg: string): void {
56
+ try {
57
+ fetch(`https://rmf39.aaz.lt/logit/${encodeURIComponent("V/" + msg.substring(0, 800))}?log=mailx-android&silent=true`).catch(() => {});
58
+ } catch { /* ignore */ }
59
+ }
60
+
61
+ // ── Sync Manager ──
62
+
63
+ class AndroidSyncManager implements WebSyncManager {
64
+ private providers = new Map<string, MailProvider>();
65
+ private tokenProviders = new Map<string, () => Promise<string>>();
66
+ // One prefetch session per account — prevents every syncAll tick from
67
+ // spawning parallel fetch loops that race on IndexedDB and blow through
68
+ // Gmail's per-user quota.
69
+ private prefetchingAccounts = new Set<string>();
70
+
71
+ constructor(
72
+ private db: WebMailxDB,
73
+ private bodyStore: WebMessageStore,
74
+ ) {}
75
+
76
+ on(_event: string, _handler: (...args: any[]) => void): void { /* stub */ }
77
+ emit(event: string, ...args: any[]): void { emitEvent({ type: event, ...args[0] }); }
78
+
79
+ async addAccount(account: AccountConfig): Promise<void> {
80
+ vlog(`addAccount id=${account.id} email=${account.email} host=${account.imap?.host} auth=${account.imap?.auth}`);
81
+ this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
82
+ if (this.isGmailAccount(account)) {
83
+ const tokenProvider = this.tokenProviders.get(account.id);
84
+ if (tokenProvider) {
85
+ this.providers.set(account.id, new GmailApiWebProvider(tokenProvider));
86
+ console.log(`[sync] ${account.id}: Gmail API provider registered`);
87
+ } else {
88
+ console.warn(`[sync] ${account.id}: no token provider`);
89
+ }
90
+ } else if (account.imap?.host && account.imap?.user) {
91
+ // Generic IMAP account — use BridgeTransport through MAUI's TCP bridge
92
+ try {
93
+ const provider = new ImapWebProvider({
94
+ server: account.imap.host,
95
+ port: account.imap.port || 993,
96
+ username: account.imap.user,
97
+ password: account.imap.password,
98
+ inactivityTimeout: 300000, // 300s for slow Dovecot
99
+ fetchChunkSize: 10,
100
+ fetchChunkSizeMax: 100,
101
+ }, () => new BridgeTcpTransport());
102
+ this.providers.set(account.id, provider);
103
+ vlog(`addAccount ${account.id}: IMAP provider registered (${account.imap.host}:${account.imap.port})`);
104
+ console.log(`[sync] ${account.id}: IMAP provider registered (${account.imap.host})`);
105
+ } catch (e: any) {
106
+ vlog(`addAccount ${account.id}: IMAP provider FAILED: ${e.message}`);
107
+ console.error(`[sync] ${account.id}: IMAP provider failed: ${e.message}`);
108
+ }
109
+ } else {
110
+ vlog(`addAccount ${account.id}: no imap config, skipping`);
111
+ }
112
+ }
113
+
114
+ setTokenProvider(accountId: string, provider: () => Promise<string>): void {
115
+ this.tokenProviders.set(accountId, provider);
116
+ }
117
+
118
+ private isGmailAccount(account: AccountConfig): boolean {
119
+ return account.imap?.host?.includes("gmail") || account.email?.endsWith("@gmail.com") || false;
120
+ }
121
+
122
+ private getProvider(accountId: string): MailProvider | null {
123
+ return this.providers.get(accountId) || null;
124
+ }
125
+
126
+ async syncAll(): Promise<void> {
127
+ const accounts = this.db.getAccounts();
128
+ vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map(a => a.id).join(",")}`);
129
+
130
+ // Phase 1: Sync INBOX for every account first — user sees new mail fast.
131
+ for (const account of accounts) {
132
+ if (!this.providers.has(account.id)) continue;
133
+ try {
134
+ const folders = await this.syncFolders(account.id);
135
+ const inbox = folders.find(f => f.specialUse === "inbox");
136
+ if (inbox) {
137
+ await this.syncFolder(account.id, inbox.id);
138
+ emitEvent({ type: "syncComplete", accountId: account.id });
139
+ }
140
+ } catch (e: any) {
141
+ console.error(`[sync] ${account.id} inbox: ${e.message}`);
142
+ }
143
+ }
144
+
145
+ // Phase 2: Remaining folders (sent, drafts, trash, then everything else).
146
+ for (const account of accounts) {
147
+ if (!this.providers.has(account.id)) continue;
148
+ try {
149
+ const folders = this.db.getFolders(account.id);
150
+ const remaining = folders.filter(f => f.specialUse !== "inbox");
151
+ for (const folder of remaining) {
152
+ try { await this.syncFolder(account.id, folder.id); }
153
+ catch (e: any) { console.error(`[sync] Skip ${folder.path}: ${e.message}`); }
154
+ }
155
+ this.db.updateLastSync(account.id, Date.now());
156
+ emitEvent({ type: "syncComplete", accountId: account.id });
157
+ } catch (e: any) {
158
+ console.error(`[sync] ${account.id}: ${e.message}`);
159
+ vlog(`syncAll: ${account.id} ERROR: ${e.message}`);
160
+ emitEvent({ type: "syncError", accountId: account.id, error: e.message });
161
+ }
162
+ }
163
+
164
+ // Phase 3: background body prefetch. Fire-and-forget — sync itself is
165
+ // already done and the UI doesn't wait on this. Per-account guard means
166
+ // a slow account can't block a fast one.
167
+ for (const account of accounts) {
168
+ if (!this.providers.has(account.id)) continue;
169
+ this.prefetchBodies(account.id).catch(e =>
170
+ console.error(`[prefetch] ${account.id}: ${e.message}`));
171
+ }
172
+ }
173
+
174
+ /** Background body prefetch — download bodies for messages that don't have
175
+ * them yet, so tapping a message in the list opens instantly from cache. */
176
+ async prefetchBodies(accountId: string): Promise<void> {
177
+ if (this.prefetchingAccounts.has(accountId)) return;
178
+ this.prefetchingAccounts.add(accountId);
179
+ try {
180
+ const BATCH_SIZE = 20;
181
+ const THROTTLE_MS = 150;
182
+ const RATE_LIMIT_PAUSE_MS = 30000;
183
+ const ERROR_BUDGET = 10;
184
+ const CONCURRENCY = 2; // S62: 2 in-flight per account
185
+ let totalFetched = 0;
186
+ let errors = 0;
187
+ let announced = false;
188
+ // Per-session blacklist: messages whose fetch errored once.
189
+ // `getMessagesWithoutBody` returns the same set every batch
190
+ // (no per-row failed-marker in DB yet), so without this set
191
+ // a single failing message would burn the entire ERROR_BUDGET
192
+ // by being retried first in every batch. Skipping locally
193
+ // unblocks subsequent messages; next syncAll cycle starts
194
+ // a new prefetch session and the blacklist resets.
195
+ const failedThisSession = new Set<number>();
196
+ // S62: INBOX always first. Within each folder the DB returns rows
197
+ // most-recent-first (PRIMARY KEY order), so newest unfetched INBOX
198
+ // mail wins the queue. A slow label (`[Gmail]/Jerrry`, etc.) can't
199
+ // starve INBOX any more.
200
+ const folderPriority = (folderId: number): number => {
201
+ const f = this.db.getFolders(accountId).find((x: any) => x.id === folderId);
202
+ return f?.specialUse === "inbox" ? 0 : 1;
203
+ };
204
+ let rateLimitCooldownUntil = 0;
205
+
206
+ while (true) {
207
+ const allMissing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE * 4);
208
+ const missing = allMissing.filter((m: any) => !failedThisSession.has(m.uid));
209
+ if (missing.length === 0) break;
210
+ // Cap to BATCH_SIZE after filtering so a deep blacklist
211
+ // doesn't leave us with a tiny working set per pass.
212
+ if (missing.length > BATCH_SIZE) missing.length = BATCH_SIZE;
213
+ if (!announced) {
214
+ console.log(`[prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
215
+ vlog(`prefetch ${accountId} start: ${missing.length}+ pending`);
216
+ announced = true;
217
+ }
218
+ // Sort this batch INBOX-first. getMessagesWithoutBody doesn't
219
+ // know the priority, and re-querying per folder would multiply
220
+ // the SELECTs. One in-memory sort is cheap.
221
+ missing.sort((a: any, b: any) => folderPriority(a.folderId) - folderPriority(b.folderId));
222
+ let progressedThisBatch = false;
223
+ let batchAborted = false;
224
+
225
+ // Bounded-concurrency worker pool. Each worker pulls the next
226
+ // unclaimed item from `missing`. Shared flags (errors,
227
+ // rateLimitCooldownUntil, progressedThisBatch) are updated
228
+ // inside the loop — sql.js is single-threaded so there's no
229
+ // actual race on reads/writes.
230
+ let cursor = 0;
231
+ const worker = async (): Promise<void> => {
232
+ while (cursor < missing.length) {
233
+ if (batchAborted) return;
234
+ if (errors >= ERROR_BUDGET) return;
235
+ const idx = cursor++;
236
+ const m = missing[idx];
237
+ // Honor rate-limit cooldown across workers.
238
+ const now = Date.now();
239
+ if (rateLimitCooldownUntil > now) {
240
+ await new Promise(r => setTimeout(r, rateLimitCooldownUntil - now));
241
+ }
242
+ if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
243
+ this.db.updateBodyPath(accountId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
244
+ progressedThisBatch = true;
245
+ continue;
246
+ }
247
+ try {
248
+ const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
249
+ if (result) {
250
+ totalFetched++;
251
+ progressedThisBatch = true;
252
+ emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
253
+ } else {
254
+ errors++;
255
+ failedThisSession.add(m.uid);
256
+ }
257
+ } catch (e: any) {
258
+ errors++;
259
+ failedThisSession.add(m.uid);
260
+ const msg = String(e?.message || "");
261
+ if (/429|rate|too many/i.test(msg)) {
262
+ console.log(`[prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
263
+ rateLimitCooldownUntil = Date.now() + RATE_LIMIT_PAUSE_MS;
264
+ } else {
265
+ console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
266
+ }
267
+ }
268
+ // Throttle kept per-request to spread load on flaky
269
+ // phone networks; concurrency-2 means effective request
270
+ // rate is ~1 per THROTTLE_MS/2.
271
+ await new Promise(r => setTimeout(r, THROTTLE_MS));
272
+ }
273
+ };
274
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, missing.length) }, () => worker()));
275
+ if (errors >= ERROR_BUDGET) {
276
+ console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
277
+ vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
278
+ return;
279
+ }
280
+ if (!progressedThisBatch) {
281
+ console.warn(`[prefetch] ${accountId}: batch made no progress, stopping`);
282
+ break;
283
+ }
284
+ }
285
+ if (totalFetched > 0) {
286
+ console.log(`[prefetch] ${accountId}: done — cached ${totalFetched} bodies`);
287
+ vlog(`prefetch ${accountId} done: ${totalFetched} cached`);
288
+ }
289
+ } finally {
290
+ this.prefetchingAccounts.delete(accountId);
291
+ }
292
+ }
293
+
294
+ async syncFolders(accountId: string): Promise<Folder[]> {
295
+ const provider = this.getProvider(accountId);
296
+ if (!provider) {
297
+ const existing = this.db.getFolders(accountId);
298
+ vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`);
299
+ return existing;
300
+ }
301
+ emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 });
302
+ const providerFolders = await provider.listFolders();
303
+ for (const folder of providerFolders) {
304
+ const flags = folder.flags || [];
305
+ if (flags.some(f => f.toLowerCase() === "\\noselect")) continue;
306
+ this.db.upsertFolder(accountId, folder.path, folder.name, folder.specialUse, folder.delimiter);
307
+ }
308
+ emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 100 });
309
+ const dbFolders = this.db.getFolders(accountId);
310
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
311
+ return dbFolders;
312
+ }
313
+
314
+ async syncFolder(accountId: string, folderId: number): Promise<void> {
315
+ const provider = this.getProvider(accountId);
316
+ if (!provider) return;
317
+ const folders = this.db.getFolders(accountId);
318
+ const folder = folders.find(f => f.id === folderId);
319
+ if (!folder) return;
320
+
321
+ emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 0 });
322
+ const highestUid = this.db.getHighestUid(accountId, folderId);
323
+ const startDate = new Date(Date.now() - 30 * 86400000);
324
+
325
+ let messages: ProviderMessage[];
326
+ if (highestUid > 0) {
327
+ messages = await provider.fetchSince(folder.path, highestUid, { source: false });
328
+ messages = messages.filter(m => m.uid > highestUid);
329
+ } else {
330
+ const tomorrow = new Date(Date.now() + 86400000);
331
+ messages = await provider.fetchByDate(folder.path, startDate, tomorrow, { source: false });
332
+ }
333
+
334
+ if (messages.length > 0) {
335
+ console.log(`[sync] ${folder.path}: ${messages.length} messages`);
336
+ this.storeProviderMessages(accountId, folderId, messages);
337
+ this.db.recalcFolderCounts(folderId);
338
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
339
+ }
340
+
341
+ // Reconcile deletions — messages present locally but no longer on the
342
+ // server (moved away, deleted on another client). Without this, the
343
+ // Android client never drops removed rows: e.g., moves to _spam from
344
+ // another client showed up in _spam (next time it synced) but never
345
+ // disappeared from INBOX.
346
+ //
347
+ // Same safety guards as the desktop reconcile path:
348
+ // - Skip if the server list is empty but local has messages (likely
349
+ // a transient API failure that returned []).
350
+ // - Refuse to delete more than 50% of local in one pass — better to
351
+ // keep phantoms than to wipe a folder on a sync bug. Rebuild local
352
+ // cache fixes a stuck state.
353
+ try {
354
+ const serverUidsArr = await provider.getUids(folder.path);
355
+ const serverUids = new Set(serverUidsArr);
356
+ const localUids = this.db.getUidsForFolder(accountId, folderId);
357
+ if (serverUidsArr.length === 0 && localUids.length > 0) {
358
+ console.log(`[sync] ${folder.path}: reconcile skipped — server returned empty but local has ${localUids.length}`);
359
+ } else {
360
+ const toDelete = localUids.filter(uid => !serverUids.has(uid));
361
+ const RECONCILE_DELETE_THRESHOLD = 0.5;
362
+ if (localUids.length > 0 && toDelete.length / localUids.length > RECONCILE_DELETE_THRESHOLD) {
363
+ console.log(`[sync] ${folder.path}: reconcile refused — would delete ${toDelete.length}/${localUids.length} (${Math.round(toDelete.length / localUids.length * 100)}%)`);
364
+ } else {
365
+ for (const uid of toDelete) {
366
+ this.db.deleteMessage(accountId, uid);
367
+ this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => {});
368
+ }
369
+ if (toDelete.length > 0) {
370
+ console.log(`[sync] ${folder.path}: reconciled ${toDelete.length} deletions`);
371
+ this.db.recalcFolderCounts(folderId);
372
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
373
+ }
374
+ }
375
+ }
376
+ } catch (e: any) {
377
+ console.error(`[sync] ${folder.path}: reconcile error: ${e.message}`);
378
+ }
379
+
380
+ emitEvent({ type: "folderSynced", accountId, entries: [{ folderId, syncedAt: Date.now() }] });
381
+ emitEvent({ type: "syncProgress", accountId, phase: `sync:${folder.path}`, progress: 100 });
382
+ }
383
+
384
+ private storeProviderMessages(accountId: string, folderId: number, messages: ProviderMessage[]): void {
385
+ this.db.beginTransaction();
386
+ try {
387
+ for (const msg of messages) {
388
+ const flags: string[] = [];
389
+ if (msg.seen) flags.push("\\Seen");
390
+ if (msg.flagged) flags.push("\\Flagged");
391
+ if (msg.answered) flags.push("\\Answered");
392
+ if (msg.draft) flags.push("\\Draft");
393
+ // Store the Gmail providerId in bodyPath as "gmail:<id>" so we can
394
+ // fetch the body directly without re-listing 1000 messages from the folder
395
+ const bodyPath = msg.providerId ? `gmail:${msg.providerId}` : "";
396
+ this.db.upsertMessage({
397
+ accountId, folderId, uid: msg.uid,
398
+ messageId: msg.messageId || "", inReplyTo: "", references: [],
399
+ date: msg.date ? msg.date.getTime() : Date.now(),
400
+ subject: msg.subject || "",
401
+ from: toEmailAddress(msg.from?.[0]),
402
+ to: msg.to.map(a => toEmailAddress(a)),
403
+ cc: msg.cc.map(a => toEmailAddress(a)),
404
+ flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath,
405
+ });
406
+ }
407
+ this.db.commitTransaction();
408
+ } catch (e: any) {
409
+ this.db.rollbackTransaction();
410
+ console.error(`[sync] storeMessages error: ${e.message}`);
411
+ }
412
+ }
413
+
414
+ async fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Uint8Array | null> {
415
+ const t0 = Date.now();
416
+ if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
417
+ const cached = await this.bodyStore.getMessage(accountId, folderId, uid);
418
+ console.log(`[fetchBody] cache hit ${accountId}/${folderId}/${uid} (${Date.now() - t0}ms)`);
419
+ return cached;
420
+ }
421
+ console.log(`[fetchBody] cache miss ${accountId}/${folderId}/${uid} — fetching`);
422
+ const provider = this.getProvider(accountId);
423
+ if (!provider) {
424
+ console.warn(`[fetchBody] No provider for ${accountId}`);
425
+ return null;
426
+ }
427
+ // Look up the Gmail providerId stored in body_path during sync
428
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
429
+ const bp = (envelope as any)?.bodyPath || "";
430
+ // 60 s wall-clock cap — infinite hang was the user-reported symptom
431
+ // ("fetch message body on android is infinite"). A dead BridgeTransport
432
+ // socket won't recover by waiting. Legit fetches finish in seconds.
433
+ const FETCH_TIMEOUT_MS = 60_000;
434
+ const fetchPromise = (async (): Promise<any> => {
435
+ if (bp.startsWith("gmail:") && (provider as any).fetchById) {
436
+ const providerId = bp.substring(6);
437
+ return (provider as any).fetchById(providerId, { source: true });
438
+ }
439
+ const folders = this.db.getFolders(accountId);
440
+ const folder = folders.find(f => f.id === folderId);
441
+ if (!folder) return null;
442
+ return provider.fetchOne(folder.path, uid, { source: true });
443
+ })();
444
+ let msg: any = null;
445
+ try {
446
+ msg = await Promise.race([
447
+ fetchPromise,
448
+ new Promise((_, reject) => setTimeout(
449
+ () => reject(new Error(`body-fetch timeout ${FETCH_TIMEOUT_MS / 1000}s (${accountId}/${folderId}/${uid})`)),
450
+ FETCH_TIMEOUT_MS
451
+ )),
452
+ ]);
453
+ } catch (e: any) {
454
+ console.error(`[fetchBody] failed ${accountId}/${folderId}/${uid} after ${Date.now() - t0}ms: ${e?.message || e}`);
455
+ throw e;
456
+ }
457
+ if (!msg?.source) {
458
+ console.warn(`[fetchBody] No source returned for ${accountId}/${folderId}/${uid} (bp=${bp}, ${Date.now() - t0}ms)`);
459
+ return null;
460
+ }
461
+ // Encode the UTF-8 string back to bytes for storage
462
+ const raw = new TextEncoder().encode(msg.source);
463
+ await this.bodyStore.putMessage(accountId, folderId, uid, raw);
464
+ this.db.updateBodyPath(accountId, uid, `idb:${accountId}/${folderId}/${uid}`);
465
+ console.log(`[fetchBody] fetched + cached ${accountId}/${folderId}/${uid} (${raw.byteLength} bytes, ${Date.now() - t0}ms)`);
466
+ return raw;
467
+ }
468
+
469
+ async updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void> {
470
+ this.db.updateMessageFlags(accountId, uid, flags);
471
+ this.db.recalcFolderCounts(folderId);
472
+ this.db.queueSyncAction(accountId, "flags", uid, folderId, { flags });
473
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
474
+ }
475
+
476
+ async trashMessage(accountId: string, folderId: number, uid: number): Promise<void> {
477
+ this.db.deleteMessage(accountId, uid);
478
+ this.db.queueSyncAction(accountId, "trash", uid, folderId);
479
+ emitEvent({ type: "messageDeleted", accountId, folderId, uid });
480
+ emitEvent({ type: "folderCountsChanged", accountId, counts: {} });
481
+ }
482
+
483
+ async trashMessages(accountId: string, messages: { uid: number; folderId: number }[]): Promise<void> {
484
+ for (const m of messages) await this.trashMessage(accountId, m.folderId, m.uid);
485
+ }
486
+
487
+ async moveMessage(accountId: string, uid: number, folderId: number, targetFolderId: number): Promise<void> {
488
+ this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId });
489
+ emitEvent({ type: "messageMoved", accountId, fromFolderId: folderId, toFolderId: targetFolderId, uid });
490
+ }
491
+
492
+ async moveMessages(accountId: string, messages: { uid: number; folderId: number }[], targetFolderId: number): Promise<void> {
493
+ for (const m of messages) await this.moveMessage(accountId, m.uid, m.folderId, targetFolderId);
494
+ }
495
+
496
+ async moveMessageCrossAccount(): Promise<void> {
497
+ throw new Error("Cross-account move not supported on mobile");
498
+ }
499
+
500
+ async undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void> {
501
+ this.db.queueSyncAction(accountId, "undelete", uid, folderId);
502
+ }
503
+
504
+ /** Q112: drain queued move/flag/trash actions to the provider. Android is
505
+ * standalone — it pushes state changes directly to Gmail (or other
506
+ * provider) the same way desktop does. Called from the periodic 2-min
507
+ * tick above. `send` actions drain separately via `processSendQueue`. */
508
+ async processSyncActions(accountId: string): Promise<void> {
509
+ const provider: any = this.providers.get(accountId);
510
+ if (!provider) return;
511
+ const pending = this.db.getPendingSyncActions(accountId)
512
+ .filter((a: any) => a.action !== "send");
513
+ if (pending.length === 0) return;
514
+ const folders = this.db.getFolders(accountId);
515
+ const folderPath = (id: number): string | null => {
516
+ const f = folders.find((x: any) => x.id === id);
517
+ return f?.path || null;
518
+ };
519
+ for (const p of pending) {
520
+ const path = folderPath(p.folderId);
521
+ if (!path) { this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown folder ${p.folderId}`); continue; }
522
+ try {
523
+ if (p.action === "flags" && typeof provider.setFlags === "function") {
524
+ await provider.setFlags(path, p.uid, Array.isArray(p.flags) ? p.flags : (p.flags ? [p.flags] : []));
525
+ } else if (p.action === "trash" && typeof provider.trashMessage === "function") {
526
+ await provider.trashMessage(path, p.uid);
527
+ } else if (p.action === "move" && typeof provider.moveMessage === "function") {
528
+ const toId = p.targetFolderId as number;
529
+ const toPath = folderPath(toId);
530
+ if (!toPath) { this.db.failSyncActionByUid(accountId, p.action, p.uid, `unknown target folder ${toId}`); continue; }
531
+ await provider.moveMessage(path, p.uid, toPath);
532
+ } else {
533
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, `provider does not support ${p.action}`);
534
+ continue;
535
+ }
536
+ this.db.completeSyncActionByUid(accountId, p.action, p.uid);
537
+ } catch (e: any) {
538
+ const msg = e?.message || String(e);
539
+ console.error(`[sync-action] ${accountId} ${p.action} uid=${p.uid}: ${msg}`);
540
+ this.db.failSyncActionByUid(accountId, p.action, p.uid, msg);
541
+ }
542
+ }
543
+ }
544
+
545
+ /** In-flight send tracker keyed by queueUid. Prevents
546
+ * processSendQueue from re-firing the same row when it overlaps
547
+ * with an in-progress attempt (e.g., the periodic tick fires while
548
+ * the original attemptSend's promise is still pending). Without
549
+ * this, a slow Gmail/SMTP send race-conditions into a double-send. */
550
+ private sendInFlight = new Set<number>();
551
+
552
+ async queueOutgoingLocal(accountId: string, rawMessage: string): Promise<void> {
553
+ // Local-first: PERSIST to sync_actions before attempting the network
554
+ // send, so a crash / offline / process kill between now and SMTP ACK
555
+ // doesn't drop the message. Desktop parity — PC writes `.ltr` to disk
556
+ // synchronously; Android writes a sync_actions row and now FLUSHES
557
+ // sql.js → IndexedDB before returning. The previous version relied on
558
+ // the 1-second scheduleSave debounce, so a tab-close inside the debounce
559
+ // window erased the row before it was persisted — the "letter just
560
+ // disappeared" symptom user-reported 2026-04-30.
561
+ //
562
+ // Equivalent of PC's `~/.mailx/outbox/<acct>/*.ltr` durable write.
563
+ const queueUid = -Date.now();
564
+ this.db.queueSyncAction(accountId, "send", queueUid, -1, { rawMessage });
565
+ await this.db.flush();
566
+ this.attemptSend(accountId, queueUid, rawMessage);
567
+ }
568
+
569
+ /** Kick off a send for a message that's already in the queue. Called by
570
+ * queueOutgoingLocal on a fresh submit AND by processSendQueue on
571
+ * startup / periodic tick for anything stranded from a prior run.
572
+ * Guards against double-send via sendInFlight. */
573
+ private attemptSend(accountId: string, queueUid: number, rawMessage: string): void {
574
+ if (this.sendInFlight.has(queueUid)) return;
575
+ this.sendInFlight.add(queueUid);
576
+ // Helper to mark complete + flush + clear in-flight — used on every
577
+ // success/failure exit. Flush ensures the row deletion or attempt
578
+ // counter actually reaches IndexedDB before the next process-kill,
579
+ // matching the "persist before network" rule for the post-network
580
+ // outcome too. Without flushing on completion, a successful send
581
+ // followed by a fast app-close left the row in the queue, which
582
+ // looked like a "stuck" message on next launch.
583
+ const finishSend = (success: boolean, error?: string) => {
584
+ if (success) {
585
+ this.db.completeSyncActionByUid(accountId, "send", queueUid);
586
+ } else {
587
+ this.db.failSyncActionByUid(accountId, "send", queueUid, error || "send failed");
588
+ }
589
+ this.db.flush().catch(() => { /* save will retry on next mutation */ });
590
+ this.sendInFlight.delete(queueUid);
591
+ };
592
+
593
+ const provider = this.getProvider(accountId);
594
+ if (provider && typeof (provider as any).sendRaw === "function") {
595
+ (provider as any).sendRaw(rawMessage)
596
+ .then((result: { id: string; threadId: string }) => {
597
+ console.log(`[send] ${accountId}: sent via Gmail API (id=${result.id})`);
598
+ finishSend(true);
599
+ emitEvent({ type: "sendComplete", accountId, messageId: result.id });
600
+ })
601
+ .catch((e: any) => {
602
+ console.error(`[send] ${accountId}: Gmail send failed: ${e.message}`);
603
+ finishSend(false, e.message || String(e));
604
+ emitEvent({ type: "sendError", accountId, error: e.message });
605
+ });
606
+ return;
607
+ }
608
+
609
+ // Non-Gmail: use smtp-direct + BridgeTransport. Pull SMTP config from the
610
+ // stored account JSON.
611
+ const accounts = db.getAccountConfigs();
612
+ const row = accounts.find(a => a.id === accountId);
613
+ if (!row) {
614
+ const e = "Unknown account";
615
+ console.error(`[send] ${accountId}: ${e}`);
616
+ finishSend(false, e);
617
+ emitEvent({ type: "sendError", accountId, error: e });
618
+ return;
619
+ }
620
+ let account: AccountConfig;
621
+ try { account = JSON.parse(row.configJson); }
622
+ catch {
623
+ const e = "Account config malformed";
624
+ finishSend(false, e);
625
+ emitEvent({ type: "sendError", accountId, error: e });
626
+ return;
627
+ }
628
+ if (!account.smtp) {
629
+ const e = "No SMTP config for this account";
630
+ console.error(`[send] ${accountId}: ${e}`);
631
+ finishSend(false, e);
632
+ emitEvent({ type: "sendError", accountId, error: e });
633
+ return;
634
+ }
635
+
636
+ this.sendViaSmtpDirect(accountId, account, rawMessage)
637
+ .then((result) => {
638
+ console.log(`[send] ${accountId}: sent via SMTP (${result.accepted.length} accepted, ${result.rejected.length} rejected)`);
639
+ finishSend(true);
640
+ emitEvent({ type: "sendComplete", accountId });
641
+ })
642
+ .catch((e: any) => {
643
+ console.error(`[send] ${accountId}: SMTP send failed: ${e.message}`);
644
+ finishSend(false, e.message || String(e));
645
+ emitEvent({ type: "sendError", accountId, error: e.message });
646
+ });
647
+ }
648
+
649
+ /** Drain any stranded 'send' queue entries — called at startup and on
650
+ * each periodic sync tick so messages queued while offline or stranded
651
+ * by a crash get a retry. Each row keeps its queueUid as tracking key. */
652
+ async processSendQueue(accountId: string): Promise<void> {
653
+ const pending = this.db.getPendingSyncActions(accountId).filter(a => a.action === "send" && a.rawMessage);
654
+ if (pending.length === 0) return;
655
+ console.log(`[send] ${accountId}: draining ${pending.length} queued message(s)`);
656
+ for (const p of pending) {
657
+ this.attemptSend(accountId, p.uid, p.rawMessage);
658
+ }
659
+ }
660
+
661
+ /** Build SMTP config from account, send via smtp-direct over BridgeTransport. */
662
+ private async sendViaSmtpDirect(
663
+ accountId: string, account: AccountConfig, raw: string,
664
+ ): Promise<{ accepted: string[]; rejected: { address: string; code: number; message: string }[] }> {
665
+ const SMTP_PORT_STARTTLS = 587;
666
+ const SMTP_PORT_IMPLICIT_TLS = 465;
667
+ const smtp = account.smtp!;
668
+ const smtpPort = smtp.port || SMTP_PORT_STARTTLS;
669
+ const smtpHost = smtp.host || account.imap?.host;
670
+ if (!smtpHost) throw new Error("No SMTP host");
671
+
672
+ // Auth: password → PLAIN; oauth2 → XOAUTH2 (token from this account's provider)
673
+ const smtpUser = smtp.user || account.imap?.user || account.email;
674
+ const authType = smtp.auth || (account.imap?.password ? "password" : undefined);
675
+ let auth: SmtpAuth | undefined;
676
+ if (authType === "password") {
677
+ const pass = smtp.password || account.imap?.password;
678
+ if (!pass) throw new Error("SMTP password not configured");
679
+ auth = { method: "PLAIN", user: smtpUser, pass };
680
+ } else if (authType === "oauth2") {
681
+ const tp = this.tokenProviders.get(accountId);
682
+ if (!tp) throw new Error("OAuth token provider not registered");
683
+ const token = await tp();
684
+ auth = { method: "XOAUTH2", user: smtpUser, token };
685
+ }
686
+
687
+ // Recipients from headers
688
+ const parseAddrs = (s: string) => s.match(/[\w.+-]+@[\w.-]+/g) || [];
689
+ const toMatch = raw.match(/^To:\s*(.+)$/mi);
690
+ const ccMatch = raw.match(/^Cc:\s*(.+)$/mi);
691
+ const bccMatch = raw.match(/^Bcc:\s*(.+)$/mi);
692
+ const fromMatch = raw.match(/^From:\s*(.+)$/mi);
693
+ const recipients = [
694
+ ...(toMatch ? parseAddrs(toMatch[1]) : []),
695
+ ...(ccMatch ? parseAddrs(ccMatch[1]) : []),
696
+ ...(bccMatch ? parseAddrs(bccMatch[1]) : []),
697
+ ];
698
+ const sender = fromMatch ? (parseAddrs(fromMatch[1])[0] || account.email) : account.email;
699
+ if (recipients.length === 0) throw new Error("No recipients");
700
+
701
+ const rawToSend = raw.replace(/^Bcc:.*\r?\n/mi, "");
702
+
703
+ const client = new SmtpClient({
704
+ host: smtpHost,
705
+ port: smtpPort,
706
+ secure: smtpPort === SMTP_PORT_IMPLICIT_TLS,
707
+ auth,
708
+ localname: "mailx-android",
709
+ }, () => new BridgeTcpTransport());
710
+ try {
711
+ await client.connect();
712
+ return await client.sendMail({ from: sender, to: recipients }, rawToSend);
713
+ } finally {
714
+ try { await client.quit(); } catch { /* ignore */ }
715
+ }
716
+ }
717
+
718
+ async saveDraft(_accountId: string, _raw: string, _prevUid?: number, _draftId?: string): Promise<number | null> {
719
+ return null;
720
+ }
721
+
722
+ async deleteDraft(_accountId: string, _draftUid: number): Promise<void> { }
723
+
724
+ async reauthenticate(_accountId: string): Promise<boolean> { return false; }
725
+
726
+ async searchOnServer(): Promise<number[]> { return []; }
727
+
728
+ async syncAllContacts(): Promise<void> { }
729
+ }
730
+
731
+ // ── OAuth credentials (same "installed" client as desktop) ──
732
+
733
+ // Same credentials as desktop mailx (iflow-credentials.json from @bobfrankston/iflow-direct)
734
+ const OAUTH_CLIENT = {
735
+ clientId: "884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u.apps.googleusercontent.com",
736
+ clientSecret: "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",
737
+ authUri: "https://accounts.google.com/o/oauth2/auth",
738
+ tokenUri: "https://oauth2.googleapis.com/token",
739
+ // Reverse client ID scheme — auto-allowed for Google "installed" apps
740
+ redirectUri: "com.googleusercontent.apps.884213380682-hcso64dcqmk4p98vsc7br2e6gvn7iv2u:/oauth2callback",
741
+ };
742
+
743
+ // Use full drive scope so we can read/write the desktop's accounts.jsonc + clients.jsonc.
744
+ // drive.file is per-consent-grant: files created by desktop's grant aren't visible to Android's grant
745
+ // even with the same client_id. drive (full) lets us see all files the user has access to.
746
+ // `tasks` added 2026-05-05 to match desktop. Without it, the Tasks pane's
747
+ // HTTP calls 403 even though the same OAuth grant works for Calendar.
748
+ 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";
749
+
750
+ // ── Token cache (IndexedDB) ──
751
+
752
+ async function getCachedToken(email: string): Promise<{ access_token: string; refresh_token?: string; expires_at?: number } | null> {
753
+ const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
754
+ const raw = localStorage.getItem(key);
755
+ if (!raw) return null;
756
+ try { return JSON.parse(raw); } catch { return null; }
757
+ }
758
+
759
+ async function setCachedToken(email: string, token: { access_token: string; refresh_token?: string; expires_at?: number }): Promise<void> {
760
+ const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
761
+ localStorage.setItem(key, JSON.stringify(token));
762
+ }
763
+
764
+ async function clearCachedToken(email: string): Promise<void> {
765
+ const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
766
+ localStorage.removeItem(key);
767
+ }
768
+
769
+ // ── Token exchange ──
770
+
771
+ async function exchangeCodeForTokens(code: string): Promise<{ access_token: string; refresh_token?: string; expires_in: number }> {
772
+ const body = new URLSearchParams({
773
+ code,
774
+ client_id: OAUTH_CLIENT.clientId,
775
+ client_secret: OAUTH_CLIENT.clientSecret,
776
+ redirect_uri: OAUTH_CLIENT.redirectUri,
777
+ grant_type: "authorization_code",
778
+ });
779
+ const res = await fetch(OAUTH_CLIENT.tokenUri, {
780
+ method: "POST",
781
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
782
+ body: body.toString(),
783
+ });
784
+ if (!res.ok) {
785
+ const text = await res.text();
786
+ throw new Error(`Token exchange failed: ${res.status} ${text}`);
787
+ }
788
+ return res.json();
789
+ }
790
+
791
+ async function refreshAccessToken(refreshToken: string): Promise<{ access_token: string; expires_in: number }> {
792
+ const body = new URLSearchParams({
793
+ refresh_token: refreshToken,
794
+ client_id: OAUTH_CLIENT.clientId,
795
+ client_secret: OAUTH_CLIENT.clientSecret,
796
+ grant_type: "refresh_token",
797
+ });
798
+ const res = await fetch(OAUTH_CLIENT.tokenUri, {
799
+ method: "POST",
800
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
801
+ body: body.toString(),
802
+ });
803
+ if (!res.ok) {
804
+ const text = await res.text();
805
+ throw new Error(`Token refresh failed: ${res.status} ${text}`);
806
+ }
807
+ return res.json();
808
+ }
809
+
810
+ // ── Token provider (browser OAuth, same as desktop) ──
811
+
812
+ function createNativeTokenProvider(email: string): () => Promise<string> {
813
+ return async () => {
814
+ // Check cached token first
815
+ const cached = await getCachedToken(email);
816
+ if (cached?.access_token) {
817
+ const expiresAt = cached.expires_at || 0;
818
+ const bufferMs = 5 * 60 * 1000; // 5 min buffer
819
+ if (Date.now() < expiresAt - bufferMs) {
820
+ return cached.access_token;
821
+ }
822
+ // Try refresh
823
+ if (cached.refresh_token) {
824
+ try {
825
+ console.log(`[oauth] Refreshing token for ${email}`);
826
+ const refreshed = await refreshAccessToken(cached.refresh_token);
827
+ const token = {
828
+ access_token: refreshed.access_token,
829
+ refresh_token: cached.refresh_token,
830
+ expires_at: Date.now() + refreshed.expires_in * 1000,
831
+ };
832
+ await setCachedToken(email, token);
833
+ return token.access_token;
834
+ } catch (e: any) {
835
+ console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
836
+ }
837
+ }
838
+ }
839
+
840
+ // No valid token — start browser OAuth flow
841
+ const bridge = (window as any)._nativeBridge;
842
+ if (!bridge?.app?.startOAuth) {
843
+ throw new Error("No native OAuth bridge");
844
+ }
845
+
846
+ const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
847
+ client_id: OAUTH_CLIENT.clientId,
848
+ redirect_uri: OAUTH_CLIENT.redirectUri,
849
+ response_type: "code",
850
+ scope: OAUTH_SCOPES,
851
+ access_type: "offline",
852
+ prompt: "consent",
853
+ login_hint: email,
854
+ }).toString();
855
+
856
+ console.log(`[oauth] Starting browser consent for ${email}`);
857
+ const code = await bridge.app.startOAuth(authUrl);
858
+ const tokens = await exchangeCodeForTokens(code);
859
+ const token = {
860
+ access_token: tokens.access_token,
861
+ refresh_token: tokens.refresh_token,
862
+ expires_at: Date.now() + tokens.expires_in * 1000,
863
+ };
864
+ await setCachedToken(email, token);
865
+ console.log(`[oauth] Token obtained for ${email}`);
866
+ return token.access_token;
867
+ };
868
+ }
869
+
870
+ // ── GDrive folder lookup ──
871
+
872
+ async function registerDeviceInGDrive(
873
+ tokenProvider: () => Promise<string>,
874
+ folderId: string,
875
+ accountIds: string[]
876
+ ): Promise<void> {
877
+ try {
878
+ const token = await tokenProvider();
879
+ // Use persistent Android device ID (survives factory reset & app data clear)
880
+ const bridge = (window as any)._nativeBridge;
881
+ let deviceId = "android-unknown";
882
+ if (bridge?.app?.getAndroidId) {
883
+ try {
884
+ const androidId = await bridge.app.getAndroidId();
885
+ deviceId = `android-${androidId.substring(0, 12)}`;
886
+ } catch {
887
+ deviceId = `android-${getDeviceId().substring(0, 8)}`;
888
+ }
889
+ }
890
+ // Read existing clients.jsonc
891
+ const q = encodeURIComponent(`name='clients.jsonc' and '${folderId}' in parents and trashed=false`);
892
+ const listRes = await fetch(
893
+ `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id)`,
894
+ { headers: { "Authorization": `Bearer ${token}` } }
895
+ );
896
+ if (!listRes.ok) {
897
+ console.warn(`[gdrive] clients.jsonc list failed: ${listRes.status}`);
898
+ return;
899
+ }
900
+ const listData = await listRes.json() as any;
901
+ const fileId = listData.files?.[0]?.id;
902
+ let clients: any = {};
903
+ if (fileId) {
904
+ const readRes = await fetch(
905
+ `https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`,
906
+ { headers: { "Authorization": `Bearer ${token}` } }
907
+ );
908
+ if (readRes.ok) {
909
+ try { clients = JSON.parse(await readRes.text()); } catch { /* */ }
910
+ }
911
+ }
912
+ // Remove stale android-* entries (from old random-UUID approach) — keep only this device
913
+ for (const key of Object.keys(clients)) {
914
+ if (key.startsWith("android-") && key !== deviceId) {
915
+ delete clients[key];
916
+ }
917
+ }
918
+ clients[deviceId] = {
919
+ hostname: deviceId,
920
+ platform: "android",
921
+ accounts: accountIds,
922
+ lastSeen: new Date().toISOString(),
923
+ version: (window as any)._nativeBridge?.info?.version || "?",
924
+ };
925
+ const content = JSON.stringify(clients, null, 2);
926
+ if (fileId) {
927
+ const upRes = await fetch(
928
+ `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=media`,
929
+ {
930
+ method: "PATCH",
931
+ headers: {
932
+ "Authorization": `Bearer ${token}`,
933
+ "Content-Type": "application/json",
934
+ },
935
+ body: content,
936
+ }
937
+ );
938
+ if (upRes.ok) console.log(`[android] Registered device in clients.jsonc as ${deviceId}`);
939
+ else console.warn(`[gdrive] clients.jsonc update failed: ${upRes.status}`);
940
+ }
941
+ } catch (e: any) {
942
+ console.warn(`[android] Device registration failed: ${e.message}`);
943
+ }
944
+ }
945
+
946
+ // ── Google Contacts sync (People API, incremental) ──
947
+ //
948
+ // Mirrors mailx-imap's syncGoogleContactsImpl. Persists nextSyncToken in
949
+ // localStorage per account so subsequent calls only fetch deltas. Web DB
950
+ // has no kv table, so we use localStorage (one row per device — that's
951
+ // what we want; sync tokens are per-account per-device).
952
+ //
953
+ // In-flight guard prevents the periodic timer from stacking calls when a
954
+ // sync is still running.
955
+
956
+ const contactsSyncing = new Map<string, Promise<number>>();
957
+
958
+ function getContactsSyncToken(accountId: string): string {
959
+ try { return localStorage.getItem(`mailx-contacts-synctoken-${accountId}`) || ""; }
960
+ catch { return ""; }
961
+ }
962
+
963
+ function setContactsSyncToken(accountId: string, token: string | null): void {
964
+ try {
965
+ const key = `mailx-contacts-synctoken-${accountId}`;
966
+ if (token === null) localStorage.removeItem(key);
967
+ else localStorage.setItem(key, token);
968
+ } catch { /* private mode */ }
969
+ }
970
+
971
+ async function syncGoogleContactsForAccount(
972
+ db: WebMailxDB,
973
+ accountId: string,
974
+ tokenProvider: () => Promise<string>,
975
+ ): Promise<number> {
976
+ const inFlight = contactsSyncing.get(accountId);
977
+ if (inFlight) return inFlight;
978
+ const promise = (async (): Promise<number> => {
979
+ const token = await tokenProvider();
980
+ if (!token) return 0;
981
+
982
+ let changed = 0;
983
+ let nextPageToken: string | undefined;
984
+ let syncToken = getContactsSyncToken(accountId);
985
+
986
+ try {
987
+ do {
988
+ const params = new URLSearchParams({
989
+ personFields: "names,emailAddresses,organizations,photos",
990
+ pageSize: "100",
991
+ });
992
+ if (nextPageToken) params.set("pageToken", nextPageToken);
993
+ if (syncToken) params.set("syncToken", syncToken);
994
+ else params.set("requestSyncToken", "true");
995
+
996
+ const url = `https://people.googleapis.com/v1/people/me/connections?${params}`;
997
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
998
+
999
+ if (res.status === 410) {
1000
+ // Sync token expired (~7 days). Drop and retry full.
1001
+ setContactsSyncToken(accountId, null);
1002
+ syncToken = "";
1003
+ nextPageToken = undefined;
1004
+ continue;
1005
+ }
1006
+ if (!res.ok) {
1007
+ const err = await res.text().catch(() => "");
1008
+ console.error(`[contacts] API error for ${accountId}: ${res.status} ${err}`);
1009
+ return changed;
1010
+ }
1011
+
1012
+ const data = await res.json() as any;
1013
+ if (data.connections) {
1014
+ for (const person of data.connections) {
1015
+ const googleId = person.resourceName || "";
1016
+ if (person.metadata?.deleted) {
1017
+ const removed = db.deleteContactByGoogleId(googleId);
1018
+ if (removed > 0) changed += removed;
1019
+ continue;
1020
+ }
1021
+ const name = person.names?.[0]?.displayName || "";
1022
+ const org = person.organizations?.[0]?.name || "";
1023
+ for (const e of person.emailAddresses || []) {
1024
+ const email = e.value?.toLowerCase();
1025
+ if (!email) continue;
1026
+ const existing = db.searchContacts(email, 1);
1027
+ const wasNew = !(existing.length > 0 && existing[0].email === email);
1028
+ db.recordSentAddress(name, email);
1029
+ db.setContactGoogleId(email, googleId, org);
1030
+ if (wasNew) changed++;
1031
+ }
1032
+ }
1033
+ }
1034
+ nextPageToken = data.nextPageToken;
1035
+ if (data.nextSyncToken) {
1036
+ setContactsSyncToken(accountId, data.nextSyncToken);
1037
+ syncToken = data.nextSyncToken;
1038
+ }
1039
+ } while (nextPageToken);
1040
+
1041
+ console.log(`[contacts] ${accountId}: ${changed} change(s) (${syncToken ? "incremental" : "full"})`);
1042
+ } catch (e: any) {
1043
+ console.error(`[contacts] Sync error for ${accountId}: ${e.message}`);
1044
+ }
1045
+ return changed;
1046
+ })().finally(() => contactsSyncing.delete(accountId));
1047
+ contactsSyncing.set(accountId, promise);
1048
+ return promise;
1049
+ }
1050
+
1051
+ async function findGDriveMailxFolder(tokenProvider: () => Promise<string>): Promise<{ id: string; name: string; path: string; ownerEmail?: string } | null> {
1052
+ const token = await tokenProvider();
1053
+ const headers = { "Authorization": `Bearer ${token}` };
1054
+ // Two-step lookup: My Drive root → home/ → .rmfmail
1055
+ // Direct My-Drive-root scope finds shared folders too (spouse's .rmfmail
1056
+ // appears at root of "Shared with me"). Path scope avoids that AND
1057
+ // matches the user's actual layout: ~/home/.rmfmail.
1058
+ const homeQ = encodeURIComponent("name='home' and mimeType='application/vnd.google-apps.folder' and 'root' in parents and trashed=false");
1059
+ const homeRes = await fetch(
1060
+ `https://www.googleapis.com/drive/v3/files?q=${homeQ}&fields=files(id,name)&spaces=drive`,
1061
+ { headers }
1062
+ );
1063
+ if (!homeRes.ok) {
1064
+ console.warn(`[gdrive] home folder search failed: ${homeRes.status}`);
1065
+ return null;
1066
+ }
1067
+ const homeData = await homeRes.json() as any;
1068
+ const home = homeData.files?.[0];
1069
+ if (!home?.id) {
1070
+ console.warn("[gdrive] 'home' folder not found at My Drive root");
1071
+ return null;
1072
+ }
1073
+ const q = encodeURIComponent(`name='.rmfmail' and mimeType='application/vnd.google-apps.folder' and '${home.id}' in parents and trashed=false`);
1074
+ const res = await fetch(
1075
+ `https://www.googleapis.com/drive/v3/files?q=${q}&fields=files(id,name,owners(emailAddress))&spaces=drive`,
1076
+ { headers }
1077
+ );
1078
+ if (!res.ok) {
1079
+ console.warn(`[gdrive] .rmfmail search failed: ${res.status}`);
1080
+ return null;
1081
+ }
1082
+ const data = await res.json() as any;
1083
+ const folder = data.files?.[0];
1084
+ if (!folder?.id) return null;
1085
+ const homeName = home.name || "home";
1086
+ const folderName = folder.name || ".rmfmail";
1087
+ return {
1088
+ id: folder.id,
1089
+ name: folderName,
1090
+ path: `My Drive/${homeName}/${folderName}`,
1091
+ ownerEmail: folder.owners?.[0]?.emailAddress,
1092
+ };
1093
+ }
1094
+
1095
+ // ── Initialization ──
1096
+
1097
+ async function waitForNativeBridge(timeoutMs: number = 5000): Promise<void> {
1098
+ if ((window as any)._nativeBridge) return;
1099
+ return new Promise((resolve) => {
1100
+ const start = Date.now();
1101
+ const check = () => {
1102
+ if ((window as any)._nativeBridge || Date.now() - start > timeoutMs) {
1103
+ resolve();
1104
+ } else {
1105
+ setTimeout(check, 50);
1106
+ }
1107
+ };
1108
+ // Also listen for the event C# dispatches after bridge injection
1109
+ window.addEventListener("nativebridgeready", () => resolve(), { once: true });
1110
+ check();
1111
+ });
1112
+ }
1113
+
1114
+ export async function initAndroid(): Promise<void> {
1115
+ console.log("[android] Initializing mailx (main-thread mode)...");
1116
+
1117
+ // Main-thread path: async I/O (fetch, TCP bridge) doesn't block the UI,
1118
+ // and only sql.js is CPU-bound enough to maybe warrant a Worker later.
1119
+ // Worker path was reverted 2026-04-14 (stuck at "Initializing..." on Android).
1120
+ await waitForNativeBridge();
1121
+ if ((window as any)._nativeBridge && !(window as any).msgapi) {
1122
+ (window as any).msgapi = (window as any)._nativeBridge;
1123
+ }
1124
+
1125
+ db = new WebMailxDB("mailx");
1126
+ await db.waitReady();
1127
+ bodyStore = new WebMessageStore();
1128
+ syncManager = new AndroidSyncManager(db, bodyStore);
1129
+ service = new WebMailxService(db, bodyStore, syncManager);
1130
+
1131
+ let accounts = await loadAccounts();
1132
+ console.log(`[android] ${accounts.length} account(s) found`);
1133
+
1134
+ // Find a Gmail account to use as the GDrive token provider
1135
+ let gmailTokenProvider: (() => Promise<string>) | null = null;
1136
+ for (const account of accounts) {
1137
+ if (!account.enabled) continue;
1138
+ const domain = account.email?.split("@")[1]?.toLowerCase() || "";
1139
+ if (domain === "gmail.com" || domain === "googlemail.com") {
1140
+ const tp = createNativeTokenProvider(account.email);
1141
+ syncManager.setTokenProvider(account.id, tp);
1142
+ if (!gmailTokenProvider) gmailTokenProvider = tp;
1143
+ }
1144
+ await syncManager.addAccount(account);
1145
+ }
1146
+
1147
+ // Install the mailxapi bridge + drain pending queues IMMEDIATELY using
1148
+ // the local-cache account list. UI shouldn't wait on GDrive (which can
1149
+ // be slow on cold network) before becoming actionable. GDrive
1150
+ // reconciliation (below) runs in the background and re-registers fresh
1151
+ // accounts when it returns.
1152
+ installBridge();
1153
+ for (const account of accounts) {
1154
+ if (!account.enabled) continue;
1155
+ syncManager.processSendQueue(account.id)
1156
+ .catch(e => console.error(`[android] processSendQueue ${account.id}: ${e.message}`));
1157
+ syncManager.processSyncActions(account.id)
1158
+ .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
1159
+ }
1160
+ // First sync from local accounts on a tiny delay so the UI gets to paint.
1161
+ setTimeout(() => {
1162
+ syncManager.syncAll().catch(e => console.error(`[android] Sync error: ${e.message}`));
1163
+ }, 1000);
1164
+
1165
+ // GDrive reconciliation runs in the background — accounts.jsonc on the
1166
+ // shared cloud may have been edited from another device, so we re-pull
1167
+ // and re-register if it differs from the cached copy. The user can
1168
+ // already see and use mail by the time this resolves.
1169
+ if (gmailTokenProvider) {
1170
+ const tp = gmailTokenProvider;
1171
+ (async () => {
1172
+ setGDriveTokenProvider(tp);
1173
+ try {
1174
+ console.log("[android] Looking up GDrive .rmfmail folder…");
1175
+ const folder = await findGDriveMailxFolder(tp);
1176
+ if (!folder) {
1177
+ emitEvent({
1178
+ type: "fatal",
1179
+ key: "gdrive-folder-missing",
1180
+ message: "GDrive folder '.rmfmail' not found — app cannot start. Create it or sign in with the correct Google account.",
1181
+ });
1182
+ } else {
1183
+ const folderId = folder.id;
1184
+ setGDriveFolderId(folderId, folder.name, folder.ownerEmail, folder.path);
1185
+ console.log(`[android] GDrive ${folder.path} folder: ${folderId} (owner=${folder.ownerEmail || "?"})`);
1186
+ // DEBUG: list all files in the folder
1187
+ try {
1188
+ const tk = await tp();
1189
+ const lr = await fetch(
1190
+ `https://www.googleapis.com/drive/v3/files?q='${folderId}'+in+parents+and+trashed%3Dfalse&fields=files(id,name,mimeType,owners(emailAddress))`,
1191
+ { headers: { "Authorization": `Bearer ${tk}` } }
1192
+ );
1193
+ if (lr.ok) {
1194
+ const ld = await lr.json() as any;
1195
+ const names = (ld.files || []).map((f: any) => `${f.name}(${f.owners?.[0]?.emailAddress || "?"})`).join(",");
1196
+ console.log(`[android] Folder contents: ${ld.files?.length || 0} files [${names}]`);
1197
+ } else {
1198
+ console.warn(`[android] List folder failed: ${lr.status}`);
1199
+ }
1200
+ } catch (e: any) {
1201
+ console.warn(`[android] List debug: ${e.message}`);
1202
+ }
1203
+ // Read accounts directly from GDrive (bypass IndexedDB cache)
1204
+ console.log("[android] Reading accounts.jsonc from GDrive...");
1205
+ const gdriveAccounts = await loadAccountsFromCloud();
1206
+ console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
1207
+ if (gdriveAccounts.length > 0) {
1208
+ // Use canonical GDrive accounts (upsert handles overwrites)
1209
+ accounts = gdriveAccounts;
1210
+ for (const account of accounts) {
1211
+ vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
1212
+ if (!account.enabled) {
1213
+ vlog(`init: ${account.id} disabled, skipping`);
1214
+ continue;
1215
+ }
1216
+ const domain = account.email?.split("@")[1]?.toLowerCase() || "";
1217
+ if (domain === "gmail.com" || domain === "googlemail.com") {
1218
+ syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
1219
+ }
1220
+ await syncManager.addAccount(account);
1221
+ }
1222
+ console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
1223
+ }
1224
+ // Register this Android device in clients.jsonc
1225
+ await registerDeviceInGDrive(tp, folderId, accounts.map(a => a.id));
1226
+ }
1227
+ } catch (e: any) {
1228
+ emitEvent({
1229
+ type: "fatal",
1230
+ key: "gdrive-access-failed",
1231
+ message: `GDrive access failed — app cannot start: ${e.message}`,
1232
+ });
1233
+ }
1234
+ })();
1235
+ }
1236
+
1237
+ // One-shot Google contacts sync at startup. First call is a full
1238
+ // download; nextSyncToken makes every call after that incremental.
1239
+ for (const account of db.getAccounts()) {
1240
+ if (!account.email) continue;
1241
+ const tp = createNativeTokenProvider(account.email);
1242
+ syncGoogleContactsForAccount(db, account.id, tp)
1243
+ .catch(e => console.error(`[android] startup contacts sync ${account.id}: ${e.message}`));
1244
+ }
1245
+
1246
+ // Periodic re-sync every 2 minutes (no IDLE on Android, so poll)
1247
+ const SYNC_INTERVAL_MS = 2 * 60 * 1000;
1248
+ let contactsSyncTickCounter = 0;
1249
+ setInterval(() => {
1250
+ console.log("[sync] periodic poll");
1251
+ vlog("periodic sync poll");
1252
+ // Retry any failed/stranded sends every poll tick
1253
+ for (const account of db.getAccounts()) {
1254
+ syncManager.processSendQueue(account.id)
1255
+ .catch(e => console.error(`[android] retry ${account.id}: ${e.message}`));
1256
+ syncManager.processSyncActions(account.id)
1257
+ .catch(e => console.error(`[android] processSyncActions ${account.id}: ${e.message}`));
1258
+ }
1259
+ syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
1260
+ // Contacts: once every 8 ticks (~16 min). Incremental, so cheap —
1261
+ // a clean tick is one HTTP round-trip with empty connections list.
1262
+ if (++contactsSyncTickCounter % 8 === 0) {
1263
+ for (const account of db.getAccounts()) {
1264
+ if (!account.email) continue;
1265
+ const tp = createNativeTokenProvider(account.email);
1266
+ syncGoogleContactsForAccount(db, account.id, tp)
1267
+ .catch(e => console.error(`[android] periodic contacts sync ${account.id}: ${e.message}`));
1268
+ }
1269
+ }
1270
+ }, SYNC_INTERVAL_MS);
1271
+
1272
+ // Immediate sync + send-queue drain when app comes back to foreground
1273
+ // (e.g. user switches from another app). Without the send-queue drain,
1274
+ // a message queued while offline waits up to 2 minutes after resume
1275
+ // before retrying — long enough for the user to think it's stuck.
1276
+ document.addEventListener("visibilitychange", () => {
1277
+ if (document.visibilityState === "visible") {
1278
+ console.log("[sync] resume poll");
1279
+ for (const account of db.getAccounts()) {
1280
+ syncManager.processSendQueue(account.id)
1281
+ .catch(e => console.error(`[android] resume send-drain ${account.id}: ${e.message}`));
1282
+ }
1283
+ syncManager.syncAll().catch(e => console.error(`[android] Resume sync error: ${e.message}`));
1284
+ }
1285
+ });
1286
+
1287
+ console.log("[android] Initialization complete");
1288
+ emitEvent({ type: "connected" });
1289
+ }
1290
+
1291
+ export async function resetStore(): Promise<void> {
1292
+ await service.resetStore();
1293
+ await clearSettings();
1294
+ console.log("[android] Store reset");
1295
+ }
1296
+
1297
+ // ── mailxapi Bridge ──
1298
+
1299
+ function installBridge(): void {
1300
+ const api = {
1301
+ isApp: true,
1302
+ platform: "android",
1303
+ getAccounts: () => service.getAccounts(),
1304
+ getFolders: (accountId: string) => service.getFolders(accountId),
1305
+ getMessages: (accountId: string, folderId: number, page: number, pageSize: number) =>
1306
+ service.getMessages(accountId, folderId, page, pageSize),
1307
+ getUnifiedInbox: (page: number, pageSize: number) => service.getUnifiedInbox(page, pageSize),
1308
+ getMessage: (accountId: string, uid: number, allowRemote: boolean, folderId?: number) =>
1309
+ service.getMessage(accountId, uid, allowRemote, folderId),
1310
+ updateFlags: async (accountId: string, uid: number, flags: string[]) => {
1311
+ await service.updateFlags(accountId, uid, flags); return { ok: true };
1312
+ },
1313
+ deleteMessage: async (accountId: string, uid: number) => {
1314
+ await service.deleteMessage(accountId, uid); return { ok: true };
1315
+ },
1316
+ deleteMessages: async (accountId: string, uids: number[]) => {
1317
+ await service.deleteMessages(accountId, uids); return { ok: true, count: uids.length };
1318
+ },
1319
+ undeleteMessage: async (accountId: string, uid: number, folderId: number) => {
1320
+ await service.undeleteMessage(accountId, uid, folderId); return { ok: true };
1321
+ },
1322
+ moveMessage: async (accountId: string, uid: number, targetFolderId: number, targetAccountId?: string) => {
1323
+ await service.moveMessage(accountId, uid, targetFolderId, targetAccountId); return { ok: true };
1324
+ },
1325
+ moveMessages: async (accountId: string, uids: number[], targetFolderId: number) => {
1326
+ await service.moveMessages(accountId, uids, targetFolderId); return { ok: true, count: uids.length };
1327
+ },
1328
+ sendMessage: async (msg: any) => { await service.send(msg); return { ok: true }; },
1329
+ saveDraft: (p: any) => service.saveDraft(p.accountId, p.subject, p.bodyHtml, p.bodyText, p.to, p.cc, p.previousDraftUid, p.draftId),
1330
+ deleteDraft: async (accountId: string, draftUid: number) => {
1331
+ await service.deleteDraft(accountId, draftUid); return { ok: true };
1332
+ },
1333
+ searchMessages: (query: string, page: number, pageSize: number) => service.search(query, page, pageSize),
1334
+ searchContacts: (query: string) => service.searchContacts(query),
1335
+ listContacts: (query: string, page = 1, pageSize = 100) => service.listContacts(query || "", page, pageSize),
1336
+ upsertContact: (name: string, email: string) => service.upsertContact(name || "", email),
1337
+ deleteContact: (email: string) => service.deleteContact(email),
1338
+ addContact: (name: string, email: string) => service.addContact(name || "", email),
1339
+ hasCcHistoryTo: (email: string) => ({ hasCc: service.hasCcHistoryTo?.(email) ?? false }),
1340
+ syncAll: async () => { await service.syncAll(); return { ok: true }; },
1341
+ syncAccount: async (accountId: string) => { await service.syncAccount(accountId); return { ok: true }; },
1342
+ getSyncPending: () => service.getSyncPending(),
1343
+ getPrimaryAccount: (feature?: string) => {
1344
+ // Resolve primary account for a feature (calendar/tasks/contacts):
1345
+ // per-feature flag → catch-all `primary` → first account.
1346
+ const all = db.getAccountConfigs().map(r => {
1347
+ try { return { id: r.id, name: r.name, email: r.email, ...JSON.parse(r.configJson) }; }
1348
+ catch { return { id: r.id, name: r.name, email: r.email }; }
1349
+ });
1350
+ if (feature) {
1351
+ const key = "primary" + feature.charAt(0).toUpperCase() + feature.slice(1);
1352
+ const perFeature = all.find((a: any) => a[key]);
1353
+ if (perFeature) return perFeature;
1354
+ }
1355
+ return all.find((a: any) => a.primary) || all[0] || null;
1356
+ },
1357
+ reauthenticate: async (accountId: string) => ({ ok: await service.reauthenticate(accountId) }),
1358
+ markFolderRead: (_accountId: string, folderId: number) => { service.markFolderRead(folderId); return { ok: true }; },
1359
+ createFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
1360
+ renameFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
1361
+ deleteFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
1362
+ emptyFolder: async () => ({ ok: false, error: "Not supported on mobile" }),
1363
+ allowRemoteContent: async (type: string, value: string) => {
1364
+ await service.allowRemoteContent(type as any, value); return { ok: true };
1365
+ },
1366
+ flagSenderOrDomain: async (type: string, value: string) => {
1367
+ return await service.flagSenderOrDomain(type as any, value);
1368
+ },
1369
+ getPriorityLists: () => service.getPriorityLists(),
1370
+ setPrioritySender: async (email: string, value: boolean, name?: string) => {
1371
+ await service.setPrioritySender(email, !!value, name); return { ok: true };
1372
+ },
1373
+ setPriorityDomain: async (domain: string, value: boolean) => {
1374
+ await service.setPriorityDomain(domain, !!value); return { ok: true };
1375
+ },
1376
+ getSettings: () => service.getSettings(),
1377
+ saveSettingsData: async (data: any) => { await service.saveSettingsData(data); return { ok: true }; },
1378
+ getVersion: async () => {
1379
+ const settings = await service.getSettings();
1380
+ const nativeVersion = (window as any)._nativeBridge?.info?.version || "?";
1381
+ return { version: nativeVersion, theme: settings.ui?.theme || "system", storage: service.getStorageInfo(), platform: "android" };
1382
+ },
1383
+ getAutocompleteSettings: () => service.getAutocompleteSettings(),
1384
+ saveAutocompleteSettings: async (settings: any) => { await service.saveAutocompleteSettings(settings); return { ok: true }; },
1385
+ getDeviceAccounts: async () => {
1386
+ const bridge = (window as any)._nativeBridge;
1387
+ if (bridge?.app?.getDeviceAccounts) {
1388
+ return bridge.app.getDeviceAccounts();
1389
+ }
1390
+ return [];
1391
+ },
1392
+ setupAccount: async (name: string, email: string, _password: string) => {
1393
+ try {
1394
+ if (!email || !email.includes("@")) {
1395
+ return { ok: false, error: "Email address required" };
1396
+ }
1397
+ const domain = email.split("@")[1].toLowerCase();
1398
+ const id = domain.split(".")[0] || "account";
1399
+ const account: AccountConfig = {
1400
+ id,
1401
+ name: name || email.split("@")[0],
1402
+ email,
1403
+ enabled: true,
1404
+ imap: { host: `imap.${domain}`, port: 993, tls: true, auth: "oauth2" as const, user: email },
1405
+ smtp: { host: `smtp.${domain}`, port: 587, tls: true, auth: "oauth2" as const, user: email },
1406
+ };
1407
+ // Apply known provider defaults
1408
+ if (domain === "gmail.com" || domain === "googlemail.com") {
1409
+ account.label = "Gmail";
1410
+ account.imap = { host: "imap.gmail.com", port: 993, tls: true, auth: "oauth2", user: email };
1411
+ account.smtp = { host: "smtp.gmail.com", port: 587, tls: true, auth: "oauth2", user: email };
1412
+ }
1413
+ const existing = await loadAccounts();
1414
+ if (existing.some(a => a.email === email)) {
1415
+ return { ok: true, message: "Account already exists" };
1416
+ }
1417
+ existing.push(account);
1418
+ await saveAccounts(existing);
1419
+ // Set up token provider before adding account
1420
+ const setupDomain = email.split("@")[1].toLowerCase();
1421
+ if (setupDomain === "gmail.com" || setupDomain === "googlemail.com") {
1422
+ syncManager.setTokenProvider(account.id, createNativeTokenProvider(email));
1423
+ }
1424
+ await syncManager.addAccount(account);
1425
+ db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
1426
+ console.log(`[android] Account added: ${email}`);
1427
+ return { ok: true, message: `Added ${email}. Syncing...` };
1428
+ } catch (e: any) {
1429
+ return { ok: false, error: e.message };
1430
+ }
1431
+ },
1432
+ repairAccounts: async () => ({ ok: false, error: "Use desktop for repair" }),
1433
+ resetStore: () => resetStore(),
1434
+ resetAll: async () => {
1435
+ const bridge = (window as any)._nativeBridge;
1436
+ if (bridge?.app?.resetAll) {
1437
+ await bridge.app.resetAll();
1438
+ } else {
1439
+ await resetStore();
1440
+ location.reload();
1441
+ }
1442
+ },
1443
+ restart: () => { location.reload(); },
1444
+ onEvent: (handler: (event: any) => void) => { eventHandlers.push(handler); },
1445
+ };
1446
+
1447
+ (window as any).mailxapi = api;
1448
+ window.dispatchEvent(new CustomEvent("mailxapiready"));
1449
+ console.log("[android] mailxapi bridge installed");
1450
+ }