@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
package/web-service.ts ADDED
@@ -0,0 +1,754 @@
1
+ /**
2
+ * Web-compatible MailxService for Android/browser.
3
+ * Replaces @bobfrankston/mailx-service which depends on Node.js (fs, dns, mailparser).
4
+ *
5
+ * Key differences from desktop:
6
+ * - Uses WebMailxDB (wa-sqlite) instead of MailxDB (node:sqlite)
7
+ * - Uses WebMessageStore (IndexedDB) instead of FileMessageStore (filesystem)
8
+ * - Uses postal-mime or manual header parsing instead of mailparser's simpleParser
9
+ * - Settings via IndexedDB + GDrive API instead of filesystem
10
+ * - No dns.resolveMx — provider detection is static (Gmail/Outlook/Yahoo/iCloud)
11
+ */
12
+
13
+ import type { WebMailxDB } from "./db.js";
14
+ import type { WebMessageStore } from "./web-message-store.js";
15
+ import type { Folder, AutocompleteSettings } from "@bobfrankston/mailx-types";
16
+ import { sanitizeHtml, encodeQuotedPrintable, htmlToPlainText } from "@bobfrankston/mailx-types";
17
+ import {
18
+ loadSettings, saveSettings, loadAccounts, loadAllowlist, saveAllowlist,
19
+ loadAutocomplete, saveAutocomplete, getStorageInfo
20
+ } from "./web-settings.js";
21
+
22
+ // sanitizeHtml and encodeQuotedPrintable imported from @bobfrankston/mailx-types (shared with desktop)
23
+
24
+ // ── Simple email parser (replaces mailparser for browser) ──
25
+
26
+ interface ParsedMessage {
27
+ html: string;
28
+ text: string;
29
+ headers: Map<string, string>;
30
+ attachments: { filename: string; contentType: string; size: number; contentId: string; content: Uint8Array }[];
31
+ }
32
+
33
+ /** Parse an RFC 2822 message from raw bytes. Handles basic MIME. */
34
+ function parseEmailSource(raw: string): ParsedMessage {
35
+ const headers = new Map<string, string>();
36
+ const headerEnd = raw.indexOf("\r\n\r\n");
37
+ const headerSection = headerEnd >= 0 ? raw.substring(0, headerEnd) : raw;
38
+ const body = headerEnd >= 0 ? raw.substring(headerEnd + 4) : "";
39
+
40
+ // Parse headers (handle continuations)
41
+ const headerLines = headerSection.split("\r\n");
42
+ let lastKey = "";
43
+ for (const line of headerLines) {
44
+ if (line.startsWith(" ") || line.startsWith("\t")) {
45
+ // Continuation
46
+ if (lastKey) {
47
+ headers.set(lastKey, (headers.get(lastKey) || "") + " " + line.trim());
48
+ }
49
+ } else {
50
+ const colon = line.indexOf(":");
51
+ if (colon > 0) {
52
+ lastKey = line.substring(0, colon).toLowerCase().trim();
53
+ headers.set(lastKey, line.substring(colon + 1).trim());
54
+ }
55
+ }
56
+ }
57
+
58
+ const contentType = headers.get("content-type") || "text/plain";
59
+ const transferEncoding = (headers.get("content-transfer-encoding") || "").toLowerCase();
60
+ const attachments: ParsedMessage["attachments"] = [];
61
+
62
+ // Check for multipart
63
+ const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/i);
64
+ if (boundaryMatch) {
65
+ const boundary = boundaryMatch[1];
66
+ return parseMimeParts(body, boundary, headers);
67
+ }
68
+
69
+ // Single part
70
+ let decoded = decodeBody(body, transferEncoding);
71
+ const isHtml = contentType.includes("text/html");
72
+
73
+ return {
74
+ html: isHtml ? decoded : "",
75
+ text: isHtml ? "" : decoded,
76
+ headers,
77
+ attachments,
78
+ };
79
+ }
80
+
81
+ function parseMimeParts(body: string, boundary: string, topHeaders: Map<string, string>): ParsedMessage {
82
+ const parts = body.split("--" + boundary);
83
+ let html = "";
84
+ let text = "";
85
+ const attachments: ParsedMessage["attachments"] = [];
86
+
87
+ for (let i = 1; i < parts.length; i++) {
88
+ const part = parts[i];
89
+ if (part.startsWith("--")) break; // End marker
90
+
91
+ const partHeaderEnd = part.indexOf("\r\n\r\n");
92
+ if (partHeaderEnd < 0) continue;
93
+ const partHeaderSection = part.substring(0, partHeaderEnd);
94
+ const partBody = part.substring(partHeaderEnd + 4).replace(/\r?\n$/, "");
95
+
96
+ // Parse part headers
97
+ const partHeaders = new Map<string, string>();
98
+ const partHeaderLines = partHeaderSection.split("\r\n");
99
+ let lastKey = "";
100
+ for (const line of partHeaderLines) {
101
+ if (line.startsWith(" ") || line.startsWith("\t")) {
102
+ if (lastKey) partHeaders.set(lastKey, (partHeaders.get(lastKey) || "") + " " + line.trim());
103
+ } else {
104
+ const colon = line.indexOf(":");
105
+ if (colon > 0) {
106
+ lastKey = line.substring(0, colon).toLowerCase().trim();
107
+ partHeaders.set(lastKey, line.substring(colon + 1).trim());
108
+ }
109
+ }
110
+ }
111
+
112
+ const partType = partHeaders.get("content-type") || "text/plain";
113
+ const partEncoding = (partHeaders.get("content-transfer-encoding") || "").toLowerCase();
114
+ const disposition = partHeaders.get("content-disposition") || "";
115
+
116
+ // Nested multipart
117
+ const nestedBoundary = partType.match(/boundary="?([^";\s]+)"?/i);
118
+ if (nestedBoundary) {
119
+ const nested = parseMimeParts(partBody, nestedBoundary[1], topHeaders);
120
+ if (!html && nested.html) html = nested.html;
121
+ if (!text && nested.text) text = nested.text;
122
+ attachments.push(...nested.attachments);
123
+ continue;
124
+ }
125
+
126
+ if (disposition.includes("attachment") || (partType.includes("application/") && !partType.includes("text/"))) {
127
+ const filenameMatch = disposition.match(/filename="?([^";\r\n]+)"?/i) || partType.match(/name="?([^";\r\n]+)"?/i);
128
+ const decoded = decodeBody(partBody, partEncoding);
129
+ attachments.push({
130
+ filename: filenameMatch?.[1]?.trim() || `attachment-${attachments.length}`,
131
+ contentType: partType.split(";")[0].trim(),
132
+ size: decoded.length,
133
+ contentId: (partHeaders.get("content-id") || "").replace(/[<>]/g, ""),
134
+ content: new TextEncoder().encode(decoded),
135
+ });
136
+ } else if (partType.includes("text/html")) {
137
+ const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
138
+ html = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8");
139
+ } else if (partType.includes("text/plain")) {
140
+ const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
141
+ text = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8");
142
+ }
143
+ }
144
+
145
+ return { html, text, headers: topHeaders, attachments };
146
+ }
147
+
148
+ function decodeBody(body: string, encoding: string, charset: string = "utf-8"): string {
149
+ // Step 1: decode the transfer encoding to a byte array
150
+ let bytes: Uint8Array;
151
+ if (encoding === "base64") {
152
+ try {
153
+ const binary = atob(body.replace(/\s/g, ""));
154
+ bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
155
+ } catch {
156
+ return body;
157
+ }
158
+ } else if (encoding === "quoted-printable") {
159
+ // Decode QP into bytes (NOT into a string — multi-byte UTF-8 must stay as bytes)
160
+ const cleaned = body.replace(/=\r?\n/g, "");
161
+ const out: number[] = [];
162
+ for (let i = 0; i < cleaned.length; i++) {
163
+ const c = cleaned[i];
164
+ if (c === "=" && i + 2 < cleaned.length && /[0-9A-Fa-f]{2}/.test(cleaned.substr(i + 1, 2))) {
165
+ out.push(parseInt(cleaned.substr(i + 1, 2), 16));
166
+ i += 2;
167
+ } else {
168
+ // Existing character — encode as its byte (assumes ASCII for QP source)
169
+ out.push(c.charCodeAt(0) & 0xff);
170
+ }
171
+ }
172
+ bytes = new Uint8Array(out);
173
+ } else if (encoding === "7bit" || encoding === "8bit" || encoding === "" || encoding === "binary") {
174
+ // No transfer encoding — body is already a string of single-byte chars
175
+ bytes = Uint8Array.from(body, c => c.charCodeAt(0) & 0xff);
176
+ } else {
177
+ // Unknown encoding — return as-is
178
+ return body;
179
+ }
180
+ // Step 2: decode bytes using the declared charset (default UTF-8)
181
+ try {
182
+ const normalized = charset.toLowerCase().replace("windows-", "windows-").replace("iso-", "iso-");
183
+ return new TextDecoder(normalized).decode(bytes);
184
+ } catch {
185
+ // Unknown charset — fall back to UTF-8 with replacement chars
186
+ return new TextDecoder("utf-8").decode(bytes);
187
+ }
188
+ }
189
+
190
+ // ── Quoted-printable encoding (for compose/send) ──
191
+
192
+ // encodeQuotedPrintable imported from @bobfrankston/mailx-types
193
+
194
+ // ── Types for sync manager ──
195
+
196
+ export interface WebSyncManager {
197
+ syncAll(): Promise<void>;
198
+ syncFolders(accountId: string): Promise<Folder[]>;
199
+ syncFolder(accountId: string, folderId: number): Promise<void>;
200
+ fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Uint8Array | null>;
201
+ updateFlagsLocal(accountId: string, uid: number, folderId: number, flags: string[]): Promise<void>;
202
+ trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
203
+ trashMessages(accountId: string, messages: { uid: number; folderId: number }[]): Promise<void>;
204
+ moveMessage(accountId: string, uid: number, folderId: number, targetFolderId: number): Promise<void>;
205
+ moveMessages(accountId: string, messages: { uid: number; folderId: number }[], targetFolderId: number): Promise<void>;
206
+ moveMessageCrossAccount(accountId: string, uid: number, folderId: number, targetAccountId: string, targetFolderId: number): Promise<void>;
207
+ undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
208
+ queueOutgoingLocal(accountId: string, rawMessage: string): void | Promise<void>;
209
+ saveDraft(accountId: string, raw: string, previousDraftUid?: number, draftId?: string): Promise<number | null>;
210
+ deleteDraft(accountId: string, draftUid: number): Promise<void>;
211
+ reauthenticate(accountId: string): Promise<boolean>;
212
+ searchOnServer(accountId: string, folderPath: string, criteria: any): Promise<number[]>;
213
+ syncAllContacts(): Promise<void>;
214
+ addAccount(account: any): Promise<void>;
215
+ on(event: string, handler: (...args: any[]) => void): void;
216
+ emit(event: string, ...args: any[]): void;
217
+ }
218
+
219
+ // ── Service ──
220
+
221
+ export class WebMailxService {
222
+ constructor(
223
+ private db: WebMailxDB,
224
+ private bodyStore: WebMessageStore,
225
+ private syncManager: WebSyncManager,
226
+ ) {}
227
+
228
+ // ── Accounts ──
229
+
230
+ async getAccounts(): Promise<any[]> {
231
+ const dbAccounts = this.db.getAccounts();
232
+ const settings = await loadSettings();
233
+ const ordered: any[] = [];
234
+ for (const cfg of settings.accounts) {
235
+ const a = dbAccounts.find(d => d.id === cfg.id);
236
+ if (a) ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false });
237
+ }
238
+ for (const a of dbAccounts) {
239
+ if (!ordered.find((o: any) => o.id === a.id)) ordered.push(a);
240
+ }
241
+ return ordered;
242
+ }
243
+
244
+ // ── Folders ──
245
+
246
+ getFolders(accountId: string): Folder[] {
247
+ return this.db.getFolders(accountId);
248
+ }
249
+
250
+ // ── Messages ──
251
+
252
+ getUnifiedInbox(page = 1, pageSize = 50): any {
253
+ return this.db.getUnifiedInbox(page, pageSize);
254
+ }
255
+
256
+ getMessages(accountId: string, folderId: number, page = 1, pageSize = 50, sort = "date", sortDir = "desc", search?: string): any {
257
+ return this.db.getMessages({ accountId, folderId, page, pageSize, sort: sort as any, sortDir: sortDir as any, search });
258
+ }
259
+
260
+ async getMessage(accountId: string, uid: number, allowRemote = false, folderId?: number): Promise<any> {
261
+ const envelope = this.db.getMessageByUid(accountId, uid, folderId);
262
+ if (!envelope) throw new Error("Message not found");
263
+
264
+ let bodyHtml = "";
265
+ let bodyText = "";
266
+ let hasRemoteContent = false;
267
+ let attachments: { id: number; filename: string; mimeType: string; size: number; contentId: string }[] = [];
268
+
269
+ let raw: Uint8Array | null = null;
270
+ try {
271
+ raw = await this.syncManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
272
+ } catch (fetchErr: any) {
273
+ // Mirror the desktop service: surface as structured bodyError
274
+ // so the viewer shows its dedicated error banner instead of
275
+ // rendering the message text verbatim in the body area.
276
+ const rawErr = fetchErr.message || "fetch failed";
277
+ const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
278
+ return {
279
+ ...envelope, bodyHtml: "", bodyText: "",
280
+ bodyError: rawErr, bodyErrorTransient: isTransient,
281
+ hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
282
+ };
283
+ }
284
+
285
+ if (!raw) {
286
+ return {
287
+ ...envelope, bodyHtml: "", bodyText: "",
288
+ bodyError: "Message body not cached locally and the server fetch returned nothing.",
289
+ bodyErrorTransient: true,
290
+ hasRemoteContent: false, remoteAllowed: false, attachments: [], deliveredTo: "", returnPath: "", listUnsubscribe: ""
291
+ };
292
+ } else {
293
+ const source = new TextDecoder().decode(raw);
294
+ const parsed = parseEmailSource(source);
295
+ bodyHtml = parsed.html || "";
296
+ bodyText = parsed.text || "";
297
+ attachments = (parsed.attachments || []).map((a, i) => ({
298
+ id: i,
299
+ filename: a.filename || `attachment-${i}`,
300
+ mimeType: a.contentType || "application/octet-stream",
301
+ size: a.size || 0,
302
+ contentId: a.contentId || ""
303
+ }));
304
+ }
305
+
306
+ // Sanitize HTML + compute flagged-sender state. flaggedSenders /
307
+ // flaggedDomains live in the same allowlist.jsonc as remote-content
308
+ // permissions; matching this on phone parity-fixes the viewer's
309
+ // ⚠ FLAGGED banner (which only painted on desktop before).
310
+ const allowList = await loadAllowlist();
311
+ const senderAddr = (envelope.from?.address || "").toLowerCase();
312
+ const senderDomain = senderAddr.split("@")[1] || "";
313
+ const isFlagged = !!(
314
+ (allowList.flaggedSenders || []).some((s: string) => (s || "").toLowerCase() === senderAddr) ||
315
+ (allowList.flaggedDomains || []).some((d: string) => (d || "").toLowerCase() === senderDomain)
316
+ );
317
+ if (bodyHtml && !allowRemote) {
318
+ const toAddrs = (envelope.to || []).map((a: any) => a.address);
319
+ const isAllowed = allowList.senders.includes(senderAddr) ||
320
+ allowList.domains.includes(senderDomain) ||
321
+ toAddrs.some((a: string) => allowList.recipients?.includes(a));
322
+
323
+ if (isAllowed) {
324
+ allowRemote = true;
325
+ } else {
326
+ const result = sanitizeHtml(bodyHtml);
327
+ bodyHtml = result.html;
328
+ hasRemoteContent = result.hasRemoteContent;
329
+ }
330
+ }
331
+
332
+ return {
333
+ ...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
334
+ attachments, deliveredTo: "", returnPath: "", listUnsubscribe: "",
335
+ isFlagged,
336
+ };
337
+ }
338
+
339
+ /** Flag (or unflag) a sender / domain. Mirrors the desktop service so
340
+ * the viewer's right-click "Flag sender" / "Flag domain" buttons work
341
+ * on phone — was a no-op on Android before. Toggles membership in
342
+ * flaggedSenders / flaggedDomains in allowlist.jsonc, which syncs
343
+ * back to the cloud copy. */
344
+ async flagSenderOrDomain(type: "sender" | "domain", value: string): Promise<{ flagged: boolean }> {
345
+ const list = await loadAllowlist() as any;
346
+ const key = type === "sender" ? "flaggedSenders" : "flaggedDomains";
347
+ const arr: string[] = Array.isArray(list[key]) ? list[key] : [];
348
+ const v = (value || "").trim().toLowerCase();
349
+ if (!v) return { flagged: false };
350
+ const idx = arr.findIndex((x: string) => (x || "").toLowerCase() === v);
351
+ let flagged: boolean;
352
+ if (idx >= 0) {
353
+ arr.splice(idx, 1);
354
+ flagged = false;
355
+ } else {
356
+ arr.push(v);
357
+ flagged = true;
358
+ }
359
+ list[key] = arr;
360
+ await saveAllowlist(list);
361
+ return { flagged };
362
+ }
363
+
364
+ async updateFlags(accountId: string, uid: number, flags: string[]): Promise<void> {
365
+ const envelope = this.db.getMessageByUid(accountId, uid);
366
+ await this.syncManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
367
+ }
368
+
369
+ // ── Remote content allow-list ──
370
+
371
+ async allowRemoteContent(type: "sender" | "domain" | "recipient", value: string): Promise<void> {
372
+ const list = await loadAllowlist();
373
+ if (type === "sender" && !list.senders.includes(value)) list.senders.push(value);
374
+ else if (type === "domain" && !list.domains.includes(value)) list.domains.push(value);
375
+ else if (type === "recipient") {
376
+ if (!list.recipients) list.recipients = [];
377
+ if (!list.recipients.includes(value)) list.recipients.push(value);
378
+ }
379
+ await saveAllowlist(list);
380
+ }
381
+
382
+ // ── Search ──
383
+
384
+ async search(q: string, page = 1, pageSize = 50, scope = "all", accountId?: string, folderId?: number): Promise<any> {
385
+ if (!q.trim()) return { items: [], total: 0, page, pageSize };
386
+ // On mobile, always use local search (no server-side IMAP search)
387
+ if (scope === "current" && accountId && folderId) {
388
+ return this.db.searchMessages(q, page, pageSize, accountId, folderId);
389
+ }
390
+ return this.db.searchMessages(q, page, pageSize);
391
+ }
392
+
393
+ // ── Sync ──
394
+
395
+ getSyncPending(): { pending: number } {
396
+ return { pending: this.db.getTotalPendingSyncCount() };
397
+ }
398
+
399
+ async syncAll(): Promise<void> {
400
+ await this.syncManager.syncAll();
401
+ }
402
+
403
+ async syncAccount(accountId: string): Promise<void> {
404
+ const folders = await this.syncManager.syncFolders(accountId);
405
+ // INBOX-first: await INBOX so the UI re-renders new mail immediately,
406
+ // then fire the rest in the background so labels don't block the
407
+ // list. S57 (Android parity with desktop's local-first rule).
408
+ const inbox = folders.find(f => f.specialUse === "inbox");
409
+ const others = folders.filter(f => f.specialUse !== "inbox");
410
+ if (inbox) {
411
+ try { await this.syncManager.syncFolder(accountId, inbox.id); }
412
+ catch (e: any) { console.error(` Skipping INBOX ${inbox.path}: ${e.message}`); }
413
+ }
414
+ // Background fan-out: don't await; errors log. UI already has INBOX.
415
+ (async () => {
416
+ for (const folder of others) {
417
+ try { await this.syncManager.syncFolder(accountId, folder.id); }
418
+ catch (e: any) { console.error(` Skipping folder ${folder.path}: ${e.message}`); }
419
+ }
420
+ })().catch(() => { /* top-level already logged */ });
421
+ }
422
+
423
+ async reauthenticate(accountId: string): Promise<boolean> {
424
+ return this.syncManager.reauthenticate(accountId);
425
+ }
426
+
427
+ // ── Send ──
428
+
429
+ async send(msg: any): Promise<void> {
430
+ const settings = await loadSettings();
431
+ const account = settings.accounts.find(a => a.id === msg.from);
432
+ if (!account) throw new Error(`Unknown account: ${msg.from}`);
433
+
434
+ const fromHeader = msg.fromAddress || `${account.name} <${account.email}>`;
435
+ const to = msg.to.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
436
+ const cc = msg.cc?.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
437
+ const bcc = msg.bcc?.map((a: any) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
438
+ // HTML-bodied send gets a multipart/alternative wrapper with a
439
+ // text/plain derived from the HTML — matches desktop's path; spam
440
+ // filters score HTML-only mail harshly without it.
441
+ const hasHtml = !!msg.bodyHtml;
442
+ const htmlBody = msg.bodyHtml || "";
443
+ const textBody = msg.bodyText || (hasHtml ? htmlToPlainText(htmlBody) : "");
444
+ const htmlEncoded = hasHtml ? encodeQuotedPrintable(htmlBody) : "";
445
+ const textEncoded = encodeQuotedPrintable(textBody);
446
+
447
+ const domain = account.email.split("@")[1] || "mailx.local";
448
+ const messageId = `<${Date.now()}.${Math.random().toString(36).slice(2)}@${domain}>`;
449
+
450
+ const envelope = [
451
+ `From: ${fromHeader}`, `To: ${to}`,
452
+ cc ? `Cc: ${cc}` : null, bcc ? `Bcc: ${bcc}` : null,
453
+ `Subject: ${msg.subject}`, `Date: ${new Date().toUTCString()}`,
454
+ `Message-ID: ${messageId}`,
455
+ msg.inReplyTo ? `In-Reply-To: ${msg.inReplyTo}` : null,
456
+ msg.references?.length ? `References: ${msg.references.join(" ")}` : null,
457
+ `MIME-Version: 1.0`,
458
+ ].filter(h => h !== null);
459
+
460
+ let rawMessage: string;
461
+ if (hasHtml) {
462
+ const altBoundary = `mailx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
463
+ const body =
464
+ `--${altBoundary}\r\n` +
465
+ `Content-Type: text/plain; charset=UTF-8\r\n` +
466
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
467
+ `${textEncoded}\r\n` +
468
+ `--${altBoundary}\r\n` +
469
+ `Content-Type: text/html; charset=UTF-8\r\n` +
470
+ `Content-Transfer-Encoding: quoted-printable\r\n\r\n` +
471
+ `${htmlEncoded}\r\n` +
472
+ `--${altBoundary}--\r\n`;
473
+ const headers = [
474
+ ...envelope,
475
+ `Content-Type: multipart/alternative; boundary="${altBoundary}"`,
476
+ ].join("\r\n");
477
+ rawMessage = `${headers}\r\n\r\n${body}`;
478
+ } else {
479
+ const headers = [
480
+ ...envelope,
481
+ `Content-Type: text/plain; charset=UTF-8`,
482
+ `Content-Transfer-Encoding: quoted-printable`,
483
+ ].join("\r\n");
484
+ rawMessage = `${headers}\r\n\r\n${textEncoded}`;
485
+ }
486
+
487
+ // queueOutgoingLocal on the Android bridge is async (it flushes
488
+ // sql.js → IndexedDB before returning so a tab-close in the
489
+ // debounce window can't lose the row). On the web-worker
490
+ // SyncManager it's synchronous and returns void; awaiting an
491
+ // undefined value is benign, so this works for both.
492
+ await this.syncManager.queueOutgoingLocal(account.id, rawMessage);
493
+
494
+ for (const addr of msg.to) this.db.recordSentAddress(addr.name, addr.address);
495
+ if (msg.cc) for (const addr of msg.cc) this.db.recordSentAddress(addr.name, addr.address);
496
+ if (msg.bcc) for (const addr of msg.bcc) this.db.recordSentAddress(addr.name, addr.address);
497
+ }
498
+
499
+ // ── Delete / Move ──
500
+
501
+ async deleteMessage(accountId: string, uid: number): Promise<void> {
502
+ const envelope = this.db.getMessageByUid(accountId, uid);
503
+ if (!envelope) throw new Error("Message not found");
504
+ await this.syncManager.trashMessage(accountId, envelope.folderId, envelope.uid);
505
+ }
506
+
507
+ async deleteMessages(accountId: string, uids: number[]): Promise<void> {
508
+ const messages = uids.map(uid => {
509
+ const env = this.db.getMessageByUid(accountId, uid);
510
+ if (!env) return null;
511
+ return { uid: env.uid, folderId: env.folderId };
512
+ }).filter(m => m !== null);
513
+ await this.syncManager.trashMessages(accountId, messages);
514
+ }
515
+
516
+ async moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void> {
517
+ const envelope = this.db.getMessageByUid(accountId, uid);
518
+ if (!envelope) throw new Error("Message not found");
519
+ if (targetAccountId && targetAccountId !== accountId) {
520
+ await this.syncManager.moveMessageCrossAccount(accountId, envelope.uid, envelope.folderId, targetAccountId, targetFolderId);
521
+ } else {
522
+ await this.syncManager.moveMessage(accountId, envelope.uid, envelope.folderId, targetFolderId);
523
+ }
524
+ }
525
+
526
+ async moveMessages(accountId: string, uids: number[], targetFolderId: number): Promise<void> {
527
+ const messages = uids.map(uid => {
528
+ const env = this.db.getMessageByUid(accountId, uid);
529
+ if (!env) return null;
530
+ return { uid: env.uid, folderId: env.folderId };
531
+ }).filter(m => m !== null);
532
+ await this.syncManager.moveMessages(accountId, messages, targetFolderId);
533
+ }
534
+
535
+ async undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void> {
536
+ await this.syncManager.undeleteMessage(accountId, uid, folderId);
537
+ }
538
+
539
+ // ── Drafts ──
540
+
541
+ async saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number, draftId?: string): Promise<{ uid: number | null; draftId: string }> {
542
+ const settings = await loadSettings();
543
+ const account = settings.accounts.find(a => a.id === accountId);
544
+ if (!account) throw new Error(`Unknown account: ${accountId}`);
545
+
546
+ const id = draftId || `mailx-draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
547
+ const body = bodyHtml || bodyText || "";
548
+ const bodyEncoded = encodeQuotedPrintable(body);
549
+
550
+ const headers = [
551
+ `From: ${account.name} <${account.email}>`,
552
+ to ? `To: ${to}` : null, cc ? `Cc: ${cc}` : null,
553
+ `Subject: ${subject || "(no subject)"}`, `Date: ${new Date().toUTCString()}`,
554
+ `X-Mailx-Draft-ID: ${id}`,
555
+ `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
556
+ ].filter(h => h !== null).join("\r\n");
557
+ const raw = `${headers}\r\n\r\n${bodyEncoded}`;
558
+ const uid = await this.syncManager.saveDraft(accountId, raw, previousDraftUid, id);
559
+ return { uid, draftId: id };
560
+ }
561
+
562
+ async deleteDraft(accountId: string, draftUid: number): Promise<void> {
563
+ await this.syncManager.deleteDraft(accountId, draftUid);
564
+ }
565
+
566
+ // ── Contacts ──
567
+
568
+ searchContacts(query: string): any[] {
569
+ query = (query || "").trim();
570
+ if (query.length < 1) return [];
571
+ return this.db.searchContacts(query);
572
+ }
573
+
574
+ /** Address-book listing — paginated, filterable. Mirrors mailx-service's
575
+ * signature so the same client-side address-book modal works on Android
576
+ * without an "ipc(...).listContacts is not a function" crash. */
577
+ listContacts(query: string, page = 1, pageSize = 100) {
578
+ return this.db.listContacts(query || "", page, pageSize);
579
+ }
580
+
581
+ /** Manual upsert from the address-book UI. The desktop path queues a
582
+ * Google People sync; Android relies on the desktop pushing changes
583
+ * back, so this is local-only for now. */
584
+ upsertContact(name: string, email: string): { ok: true } {
585
+ this.db.upsertContact(name || "", email);
586
+ return { ok: true };
587
+ }
588
+
589
+ deleteContact(email: string): { ok: true } {
590
+ this.db.deleteContactLocal(email);
591
+ return { ok: true };
592
+ }
593
+
594
+ addContact(name: string, email: string): boolean {
595
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email)) return false;
596
+ this.db.recordSentAddress(name || "", email);
597
+ return true;
598
+ }
599
+
600
+ /** Q49 heuristic mirror: true if the user has ever sent a message to
601
+ * `recipientEmail` that had a non-empty Cc field. Compose uses this to
602
+ * decide whether to auto-expand the Cc row on reply. */
603
+ hasCcHistoryTo(recipientEmail: string): boolean {
604
+ return (this.db as any).hasCcHistoryTo?.(recipientEmail) ?? false;
605
+ }
606
+
607
+ // ── Settings ──
608
+
609
+ async getSettings(): Promise<any> {
610
+ return loadSettings();
611
+ }
612
+
613
+ async saveSettingsData(settings: any): Promise<void> {
614
+ await saveSettings(settings);
615
+ }
616
+
617
+ getStorageInfo(): { provider: string; mode: string; folderId?: string; folderName?: string; folderPath?: string; folderOwner?: string } {
618
+ return getStorageInfo();
619
+ }
620
+
621
+ // ── Folder management (limited on mobile — read-only) ──
622
+
623
+ markFolderRead(folderId: number): void {
624
+ this.db.markFolderRead(folderId);
625
+ }
626
+
627
+ // ── Autocomplete ──
628
+
629
+ async getAutocompleteSettings(): Promise<AutocompleteSettings> {
630
+ return loadAutocomplete();
631
+ }
632
+
633
+ async saveAutocompleteSettings(settings: AutocompleteSettings): Promise<void> {
634
+ await saveAutocomplete(settings);
635
+ }
636
+
637
+ async autocomplete(_req: any): Promise<{ suggestion: string }> {
638
+ // Autocomplete disabled on mobile by default
639
+ return { suggestion: "" };
640
+ }
641
+
642
+ // ── Reset ──
643
+
644
+ async resetStore(): Promise<void> {
645
+ await this.db.resetStore();
646
+ await this.bodyStore.clear();
647
+ console.log("[service] Store reset complete");
648
+ }
649
+
650
+ // ── JSONC config editor (Android: GDrive only; no local config.jsonc) ──
651
+ // Mirror of the desktop service so the in-app editor works on phone.
652
+ // Android sandbox has no per-machine config.jsonc; selecting it returns
653
+ // null (the editor displays "(file not available on this platform)").
654
+
655
+ async readJsoncFile(name: string): Promise<string | null> {
656
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
657
+ if (!WHITELIST.includes(name)) throw new Error(`File not allowed: ${name}`);
658
+ if (name === "config.jsonc") return null; // local-only on desktop; n/a on Android
659
+ const { cloudRead } = await import("./web-settings.js");
660
+ return await cloudRead(name);
661
+ }
662
+
663
+ async writeJsoncFile(name: string, content: string): Promise<void> {
664
+ const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc", "contacts.jsonc"];
665
+ if (!WHITELIST.includes(name)) throw new Error(`File not allowed: ${name}`);
666
+ if (name === "config.jsonc") throw new Error("config.jsonc is local-only — not editable on Android");
667
+ const { parse: parseJsonc } = await import("jsonc-parser");
668
+ const errors: any[] = [];
669
+ parseJsonc(content, errors, { allowTrailingComma: true });
670
+ if (errors.length) {
671
+ throw new Error(`JSONC parse error: ${errors.map(e => e.error).join(", ")}`);
672
+ }
673
+ const { cloudWrite } = await import("./web-settings.js");
674
+ const ok = await cloudWrite(name, content);
675
+ if (!ok) throw new Error(`Failed to save ${name} to Drive`);
676
+ }
677
+
678
+ async formatJsonc(content: string): Promise<string> {
679
+ const { format, applyEdits } = await import("jsonc-parser");
680
+ const edits = format(content, undefined, {
681
+ tabSize: 2, insertSpaces: true, eol: "\n", insertFinalNewline: true,
682
+ });
683
+ return applyEdits(content, edits);
684
+ }
685
+
686
+ async readConfigHelp(_name: string): Promise<string> {
687
+ // Help markdown isn't bundled in the Android assets — return empty
688
+ // so the editor's help panel just shows "No help available".
689
+ return "";
690
+ }
691
+
692
+ // ── Priority senders / domains (Android parity) ──
693
+ // Cached in-memory; refreshed on every read since contacts.jsonc is
694
+ // small (~hundreds of entries) and reads hit IndexedDB cache.
695
+
696
+ /** Compute and return the priority sender / domain index from the
697
+ * current contacts.jsonc. Reads from cloud (or local cache) every
698
+ * call — small file, simpler than maintaining a long-lived cache. */
699
+ async getPriorityLists(): Promise<{ senders: string[]; domains: string[] }> {
700
+ const { cloudRead } = await import("./web-settings.js");
701
+ const raw = await cloudRead("contacts.jsonc");
702
+ if (!raw) return { senders: [], domains: [] };
703
+ const cfg = parseJsoncLoose(raw);
704
+ const senders = Array.isArray(cfg?.preferred)
705
+ ? cfg.preferred.filter((e: any) => e?.priority === true && e.email).map((e: any) => (e.email as string).toLowerCase())
706
+ : [];
707
+ const domains = Array.isArray(cfg?.priorityDomains)
708
+ ? cfg.priorityDomains.map((d: string) => (d || "").toLowerCase()).filter(Boolean)
709
+ : [];
710
+ return { senders, domains };
711
+ }
712
+
713
+ async setPrioritySender(email: string, value: boolean, name?: string): Promise<void> {
714
+ const lower = (email || "").trim().toLowerCase();
715
+ if (!lower) return;
716
+ const { cloudRead, cloudWrite } = await import("./web-settings.js");
717
+ const raw = await cloudRead("contacts.jsonc");
718
+ const cfg = raw ? parseJsoncLoose(raw) || {} : {};
719
+ if (!Array.isArray(cfg.preferred)) cfg.preferred = [];
720
+ const idx = cfg.preferred.findIndex((e: any) => (e?.email || "").toLowerCase() === lower);
721
+ if (idx >= 0) {
722
+ if (value) cfg.preferred[idx].priority = true;
723
+ else delete cfg.preferred[idx].priority;
724
+ } else if (value) {
725
+ cfg.preferred.push({ name: name || "", email, priority: true });
726
+ }
727
+ await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
728
+ }
729
+
730
+ async setPriorityDomain(domain: string, value: boolean): Promise<void> {
731
+ const lower = (domain || "").trim().toLowerCase();
732
+ if (!lower) return;
733
+ const { cloudRead, cloudWrite } = await import("./web-settings.js");
734
+ const raw = await cloudRead("contacts.jsonc");
735
+ const cfg = raw ? parseJsoncLoose(raw) || {} : {};
736
+ if (!Array.isArray(cfg.priorityDomains)) cfg.priorityDomains = [];
737
+ const i = cfg.priorityDomains.findIndex((d: string) => (d || "").toLowerCase() === lower);
738
+ if (value && i < 0) cfg.priorityDomains.push(lower);
739
+ else if (!value && i >= 0) cfg.priorityDomains.splice(i, 1);
740
+ await cloudWrite("contacts.jsonc", JSON.stringify(cfg, null, 2));
741
+ }
742
+ }
743
+
744
+ /** Loose JSONC parse — strips // comments and trailing commas before JSON.parse.
745
+ * Sufficient for contacts.jsonc which is machine-written; doesn't pull in
746
+ * jsonc-parser as a dep on the Android side where bundle size matters. */
747
+ function parseJsoncLoose(raw: string): any {
748
+ try {
749
+ const stripped = raw
750
+ .replace(/^\s*\/\/.*$/gm, "")
751
+ .replace(/,(\s*[}\]])/g, "$1");
752
+ return JSON.parse(stripped);
753
+ } catch { return null; }
754
+ }