@bobfrankston/mailx 1.0.253 → 1.0.260

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 (34) hide show
  1. package/bin/mailx.js +112 -0
  2. package/client/.msger-window.json +1 -1
  3. package/client/components/message-viewer.js +82 -7
  4. package/package.json +9 -8
  5. package/packages/mailx-imap/index.d.ts +6 -0
  6. package/packages/mailx-imap/index.js +244 -57
  7. package/packages/mailx-imap/package.json +2 -1
  8. package/packages/mailx-imap/providers/gmail-api.d.ts +5 -29
  9. package/packages/mailx-imap/providers/gmail-api.js +5 -286
  10. package/packages/mailx-imap/providers/types.d.ts +6 -59
  11. package/packages/mailx-imap/providers/types.js +5 -2
  12. package/packages/mailx-service/index.d.ts +0 -4
  13. package/packages/mailx-service/index.js +18 -62
  14. package/packages/mailx-store-web/android-bootstrap.js +37 -22
  15. package/packages/mailx-store-web/db.js +8 -7
  16. package/packages/mailx-store-web/gmail-api-web.d.ts +7 -33
  17. package/packages/mailx-store-web/gmail-api-web.js +7 -258
  18. package/packages/mailx-store-web/imap-web-provider.d.ts +1 -1
  19. package/packages/mailx-store-web/imap-web-provider.js +2 -2
  20. package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
  21. package/packages/mailx-store-web/main-thread-host.js +287 -0
  22. package/packages/mailx-store-web/package.json +2 -1
  23. package/packages/mailx-store-web/provider-types.d.ts +4 -47
  24. package/packages/mailx-store-web/provider-types.js +3 -3
  25. package/packages/mailx-store-web/sync-manager.d.ts +61 -0
  26. package/packages/mailx-store-web/sync-manager.js +422 -0
  27. package/packages/mailx-store-web/web-service.d.ts +0 -4
  28. package/packages/mailx-store-web/web-service.js +1 -59
  29. package/packages/mailx-store-web/worker-entry.d.ts +8 -0
  30. package/packages/mailx-store-web/worker-entry.js +187 -0
  31. package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
  32. package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
  33. package/packages/mailx-types/index.d.ts +14 -0
  34. package/packages/mailx-types/index.js +96 -1
@@ -1,289 +1,8 @@
1
1
  /**
2
- * Gmail API provider replaces IMAP for Gmail accounts.
3
- * Uses REST API for fast, reliable sync without connection limit issues.
2
+ * Back-compat re-export. The canonical Gmail provider lives in
3
+ * @bobfrankston/mailx-sync. mailx-imap re-exports it under its old name so
4
+ * call sites here keep compiling. Both desktop (this package) and Android
5
+ * (mailx-store-web) consume the same single implementation now.
4
6
  */
5
- const API = "https://gmail.googleapis.com/gmail/v1/users/me";
6
- /** Convert Gmail hex ID to integer UID (lower 48 bits — deterministic, stable) */
7
- function idToUid(id) {
8
- const hex = id.length > 12 ? id.slice(-12) : id;
9
- return parseInt(hex, 16);
10
- }
11
- /** Map Gmail label to IMAP-style specialUse */
12
- function labelSpecialUse(label) {
13
- switch (label.id) {
14
- case "INBOX": return "inbox";
15
- case "SENT": return "sent";
16
- case "DRAFT": return "drafts";
17
- case "TRASH": return "trash";
18
- case "SPAM": return "junk";
19
- case "STARRED": return "";
20
- case "IMPORTANT": return "";
21
- default: return "";
22
- }
23
- }
24
- /** Parse RFC 2822 headers from Gmail metadata payload */
25
- function getHeader(headers, name) {
26
- return headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || "";
27
- }
28
- /** Parse "Name <addr>" or "addr" into { name, address } */
29
- function parseAddress(raw) {
30
- const match = raw.match(/^"?([^"<]*?)"?\s*<([^>]+)>/);
31
- if (match)
32
- return { name: match[1].trim(), address: match[2].trim() };
33
- return { address: raw.trim() };
34
- }
35
- function parseAddressList(raw) {
36
- if (!raw)
37
- return [];
38
- // Split on commas that aren't inside quotes
39
- return raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(s => parseAddress(s.trim())).filter(a => a.address);
40
- }
41
- export class GmailApiProvider {
42
- tokenProvider;
43
- constructor(tokenProvider) {
44
- this.tokenProvider = tokenProvider;
45
- }
46
- async fetch(path, options = {}) {
47
- const token = await this.tokenProvider();
48
- const maxAttempts = 6;
49
- const baseDelayMs = 1000;
50
- const maxDelayMs = 60_000;
51
- let lastStatus = 0;
52
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
53
- const res = await globalThis.fetch(`${API}${path}`, {
54
- ...options,
55
- headers: {
56
- "Authorization": `Bearer ${token}`,
57
- "Content-Type": "application/json",
58
- ...options.headers,
59
- },
60
- });
61
- if (res.status === 429 || res.status >= 500) {
62
- lastStatus = res.status;
63
- // Honor Retry-After if present (seconds or HTTP-date)
64
- const retryAfter = res.headers.get("Retry-After");
65
- let delay = baseDelayMs * Math.pow(2, attempt);
66
- if (retryAfter) {
67
- const asInt = parseInt(retryAfter, 10);
68
- if (!isNaN(asInt))
69
- delay = asInt * 1000;
70
- else {
71
- const when = Date.parse(retryAfter);
72
- if (!isNaN(when))
73
- delay = Math.max(0, when - Date.now());
74
- }
75
- }
76
- // Full jitter to avoid synchronized retries
77
- delay = Math.min(maxDelayMs, delay);
78
- delay = Math.floor(delay * (0.5 + Math.random() * 0.5));
79
- console.log(` [gmail] ${res.status} (attempt ${attempt + 1}/${maxAttempts}), waiting ${(delay / 1000).toFixed(1)}s${retryAfter ? ` (Retry-After: ${retryAfter})` : ""}...`);
80
- await new Promise(r => setTimeout(r, delay));
81
- continue;
82
- }
83
- if (!res.ok) {
84
- const err = await res.text().catch(() => "");
85
- throw new Error(`Gmail API ${res.status}: ${err.substring(0, 200)}`);
86
- }
87
- return res.json();
88
- }
89
- throw new Error(`Gmail API: failed after ${maxAttempts} retries (last status ${lastStatus})`);
90
- }
91
- async listFolders() {
92
- const data = await this.fetch("/labels");
93
- const labels = data.labels || [];
94
- const folders = [];
95
- for (const label of labels) {
96
- // Skip system labels that aren't useful as folders
97
- if (["UNREAD", "STARRED", "IMPORTANT", "CATEGORY_PERSONAL",
98
- "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_UPDATES",
99
- "CATEGORY_FORUMS", "CHAT"].includes(label.id))
100
- continue;
101
- const specialUse = labelSpecialUse(label);
102
- // Map Gmail path separators (/) to IMAP-style
103
- const path = label.name || label.id;
104
- const name = path.includes("/") ? path.split("/").pop() : path;
105
- folders.push({
106
- path,
107
- name,
108
- delimiter: "/",
109
- specialUse,
110
- flags: label.type === "system" ? ["\\Noselect"] : [],
111
- });
112
- }
113
- return folders;
114
- }
115
- /** List message IDs matching a query, handling pagination.
116
- * IMPORTANT: on any error we throw — do NOT return a partial list, because
117
- * callers use this for sync reconciliation and a short list would delete
118
- * real messages from the local DB. Returning [] silently caused the
119
- * "INBOX empty in mailx" bug when a rate-limit hit mid-pagination. */
120
- async listMessageIds(query, maxResults = 500) {
121
- const ids = [];
122
- let pageToken = "";
123
- let truncated = false;
124
- while (true) {
125
- const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
126
- if (pageToken)
127
- params.set("pageToken", pageToken);
128
- const data = await this.fetch(`/messages?${params}`);
129
- for (const msg of data.messages || []) {
130
- ids.push(msg.id);
131
- }
132
- if (!data.nextPageToken)
133
- break;
134
- if (ids.length >= maxResults) {
135
- // Hit the caller's cap but the server has more. Flag it so
136
- // reconcile-style callers can refuse to treat this as complete.
137
- truncated = true;
138
- break;
139
- }
140
- pageToken = data.nextPageToken;
141
- }
142
- ids._truncated = truncated;
143
- return ids;
144
- }
145
- /** Batch-fetch message metadata or full content */
146
- async batchFetch(ids, options = {}, onChunk) {
147
- const all = [];
148
- const chunkSize = options.source ? 10 : 50; // Smaller chunks for full bodies
149
- const format = options.source ? "raw" : "metadata";
150
- for (let i = 0; i < ids.length; i += chunkSize) {
151
- const chunk = ids.slice(i, i + chunkSize);
152
- // Sequential fetches to avoid Gmail 429 rate limits
153
- const messages = [];
154
- for (const id of chunk) {
155
- const params = new URLSearchParams({ format });
156
- if (format === "metadata") {
157
- for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date", "In-Reply-To", "References"]) {
158
- params.append("metadataHeaders", h);
159
- }
160
- }
161
- messages.push(await this.fetch(`/messages/${id}?${params}`));
162
- }
163
- const parsed = messages.map(msg => this.parseMessage(msg, options));
164
- all.push(...parsed);
165
- if (onChunk)
166
- onChunk(parsed);
167
- }
168
- return all;
169
- }
170
- /** Parse a Gmail API message response into ProviderMessage */
171
- parseMessage(msg, options = {}) {
172
- const labels = msg.labelIds || [];
173
- const headers = msg.payload?.headers || [];
174
- let source = "";
175
- if (options.source && msg.raw) {
176
- // Gmail returns URL-safe base64 — convert to standard base64 then decode
177
- const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
178
- source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
179
- }
180
- const fromRaw = getHeader(headers, "From");
181
- const toRaw = getHeader(headers, "To");
182
- const ccRaw = getHeader(headers, "Cc");
183
- const dateRaw = getHeader(headers, "Date") || "";
184
- const subject = getHeader(headers, "Subject") || msg.snippet || "";
185
- const messageId = getHeader(headers, "Message-ID") || "";
186
- const inReplyTo = getHeader(headers, "In-Reply-To") || "";
187
- const referencesRaw = getHeader(headers, "References") || "";
188
- const references = referencesRaw.trim()
189
- ? referencesRaw.split(/\s+/).filter(r => r.startsWith("<") && r.endsWith(">"))
190
- : [];
191
- return {
192
- uid: idToUid(msg.id),
193
- messageId,
194
- providerId: msg.id,
195
- date: dateRaw ? new Date(dateRaw) : (msg.internalDate ? new Date(Number(msg.internalDate)) : null),
196
- subject,
197
- from: parseAddressList(fromRaw),
198
- to: parseAddressList(toRaw),
199
- cc: parseAddressList(ccRaw),
200
- inReplyTo,
201
- references,
202
- seen: !labels.includes("UNREAD"),
203
- flagged: labels.includes("STARRED"),
204
- answered: false, // Gmail API doesn't expose this directly
205
- draft: labels.includes("DRAFT"),
206
- size: msg.sizeEstimate || 0,
207
- source,
208
- };
209
- }
210
- async fetchSince(folder, sinceUid, options = {}) {
211
- // Gmail message IDs are hash-derived, NOT monotonic — filtering by
212
- // `uid > sinceUid` silently drops new messages whose hash happens to
213
- // fall below the high-water mark. Fetch the most recent page and let
214
- // upsertMessage dedupe by (account, folder, uid). The sinceUid arg is
215
- // kept for interface compatibility but no longer used for filtering.
216
- void sinceUid;
217
- const query = `in:${this.folderToLabel(folder)}`;
218
- const ids = await this.listMessageIds(query, 200);
219
- return this.batchFetch(ids, options);
220
- }
221
- async fetchByDate(folder, since, before, options = {}, onChunk) {
222
- const afterDate = this.formatDate(since);
223
- const beforeDate = this.formatDate(before);
224
- const query = `in:${this.folderToLabel(folder)} after:${afterDate} before:${beforeDate}`;
225
- const ids = await this.listMessageIds(query);
226
- return this.batchFetch(ids, options, onChunk);
227
- }
228
- async fetchByUids(folder, uids, options = {}) {
229
- // UIDs are derived from Gmail IDs — we'd need a reverse lookup
230
- // For now, fetch all messages in folder and filter
231
- const query = `in:${this.folderToLabel(folder)}`;
232
- const ids = await this.listMessageIds(query);
233
- const uidSet = new Set(uids);
234
- const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
235
- return this.batchFetch(matchingIds, options);
236
- }
237
- async fetchOne(folder, uid, options = {}) {
238
- // Need to find the Gmail ID from the UID — search all messages in folder
239
- const query = `in:${this.folderToLabel(folder)}`;
240
- const ids = await this.listMessageIds(query, 1000);
241
- const id = ids.find(id => idToUid(id) === uid);
242
- if (!id)
243
- return null;
244
- const format = options.source ? "raw" : "metadata";
245
- const params = new URLSearchParams({ format });
246
- if (format === "metadata") {
247
- for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date", "In-Reply-To", "References"]) {
248
- params.append("metadataHeaders", h);
249
- }
250
- }
251
- const msg = await this.fetch(`/messages/${id}?${params}`);
252
- return this.parseMessage(msg, options);
253
- }
254
- async getUids(folder) {
255
- const query = `in:${this.folderToLabel(folder)}`;
256
- const ids = await this.listMessageIds(query, 10000);
257
- const result = ids.map(idToUid);
258
- // Propagate the truncation flag so reconcile can refuse to delete.
259
- if (ids._truncated)
260
- result._truncated = true;
261
- return result;
262
- }
263
- async close() {
264
- // No persistent connection to close
265
- }
266
- /** Map folder path to Gmail label query term */
267
- folderToLabel(path) {
268
- const lower = path.toLowerCase();
269
- if (lower === "inbox")
270
- return "inbox";
271
- if (lower === "sent" || lower === "[gmail]/sent mail")
272
- return "sent";
273
- if (lower === "drafts" || lower === "[gmail]/drafts")
274
- return "drafts";
275
- if (lower === "trash" || lower === "[gmail]/trash")
276
- return "trash";
277
- if (lower === "spam" || lower === "[gmail]/spam" || lower === "junk email")
278
- return "spam";
279
- if (lower === "archive" || lower === "[gmail]/all mail")
280
- return "all";
281
- // Custom label — use exact name
282
- return `"${path}"`;
283
- }
284
- /** Format date for Gmail query (YYYY/MM/DD) */
285
- formatDate(d) {
286
- return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
287
- }
288
- }
7
+ export { GmailApiProvider } from "@bobfrankston/mailx-sync";
289
8
  //# sourceMappingURL=gmail-api.js.map
@@ -1,62 +1,9 @@
1
1
  /**
2
- * Mail provider interface abstraction over IMAP, Gmail API, and Microsoft Graph.
3
- * Sync code uses this interface; never calls IMAP/REST directly.
2
+ * Back-compat re-export. The canonical mail provider types live in
3
+ * @bobfrankston/mailx-sync. New code should import from there directly.
4
+ *
5
+ * This shim keeps existing `import { ... } from "./types.js"` paths inside
6
+ * mailx-imap working without churn.
4
7
  */
5
- export interface ProviderFolder {
6
- path: string;
7
- name: string;
8
- delimiter: string;
9
- specialUse: string;
10
- flags: string[];
11
- }
12
- export interface ProviderMessage {
13
- uid: number;
14
- messageId: string;
15
- providerId: string;
16
- date: Date | null;
17
- subject: string;
18
- from: {
19
- name?: string;
20
- address?: string;
21
- }[];
22
- to: {
23
- name?: string;
24
- address?: string;
25
- }[];
26
- cc: {
27
- name?: string;
28
- address?: string;
29
- }[];
30
- inReplyTo?: string;
31
- references?: string[];
32
- seen: boolean;
33
- flagged: boolean;
34
- answered: boolean;
35
- draft: boolean;
36
- size: number;
37
- source: string;
38
- }
39
- export interface FetchOptions {
40
- source?: boolean;
41
- }
42
- /**
43
- * A mail provider that can list folders, fetch messages, and perform actions.
44
- * Implementations: ImapProvider (existing iflow), GmailApiProvider, GraphApiProvider.
45
- */
46
- export interface MailProvider {
47
- /** List all folders/labels */
48
- listFolders(): Promise<ProviderFolder[]>;
49
- /** Fetch messages newer than sinceUid (incremental sync) */
50
- fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
51
- /** Fetch messages by date range (first sync) */
52
- fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
53
- /** Fetch specific messages by UID */
54
- fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
55
- /** Fetch a single message by UID */
56
- fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
57
- /** Get all UIDs in a folder (for reconciliation) */
58
- getUids(folder: string): Promise<number[]>;
59
- /** Close/cleanup */
60
- close(): Promise<void>;
61
- }
8
+ export type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "@bobfrankston/mailx-sync";
62
9
  //# sourceMappingURL=types.d.ts.map
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Mail provider interface abstraction over IMAP, Gmail API, and Microsoft Graph.
3
- * Sync code uses this interface; never calls IMAP/REST directly.
2
+ * Back-compat re-export. The canonical mail provider types live in
3
+ * @bobfrankston/mailx-sync. New code should import from there directly.
4
+ *
5
+ * This shim keeps existing `import { ... } from "./types.js"` paths inside
6
+ * mailx-imap working without churn.
4
7
  */
5
8
  export {};
6
9
  //# sourceMappingURL=types.js.map
@@ -6,10 +6,6 @@
6
6
  import { MailxDB } from "@bobfrankston/mailx-store";
7
7
  import { ImapManager } from "@bobfrankston/mailx-imap";
8
8
  import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSettings } from "@bobfrankston/mailx-types";
9
- export declare function sanitizeHtml(html: string): {
10
- html: string;
11
- hasRemoteContent: boolean;
12
- };
13
9
  export declare class MailxService {
14
10
  private db;
15
11
  private imapManager;
@@ -7,43 +7,8 @@ import * as dns from "node:dns/promises";
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
10
+ import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
10
11
  import { simpleParser } from "mailparser";
11
- // ── Quoted-printable encoding (readable in debug .eml files) ──
12
- function encodeQuotedPrintable(text) {
13
- const bytes = Buffer.from(text, "utf-8");
14
- let line = "";
15
- let result = "";
16
- for (let i = 0; i < bytes.length; i++) {
17
- const b = bytes[i];
18
- let encoded;
19
- if (b === 0x0D && bytes[i + 1] === 0x0A) {
20
- // CRLF — output as-is
21
- result += line + "\r\n";
22
- line = "";
23
- i++; // skip LF
24
- continue;
25
- }
26
- else if (b === 0x0A) {
27
- // Bare LF — normalize to CRLF
28
- result += line + "\r\n";
29
- line = "";
30
- continue;
31
- }
32
- else if ((b >= 33 && b <= 126 && b !== 61) || b === 9 || b === 32) {
33
- encoded = String.fromCharCode(b);
34
- }
35
- else {
36
- encoded = "=" + b.toString(16).toUpperCase().padStart(2, "0");
37
- }
38
- if (line.length + encoded.length > 75) {
39
- result += line + "=\r\n";
40
- line = "";
41
- }
42
- line += encoded;
43
- }
44
- result += line;
45
- return result;
46
- }
47
12
  // ── Email provider detection (MX-based) ──
48
13
  const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
49
14
  const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
@@ -67,30 +32,7 @@ async function detectEmailProvider(domain) {
67
32
  catch { /* DNS lookup failed */ }
68
33
  return null;
69
34
  }
70
- // ── Sanitize ──
71
- export function sanitizeHtml(html) {
72
- let hasRemoteContent = false;
73
- let clean = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
74
- clean = clean.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
75
- clean = clean.replace(/<img\b([^>]*)\bsrc\s*=\s*("[^"]*"|'[^']*')/gi, (match, before, src) => {
76
- const url = src.slice(1, -1);
77
- if (url.startsWith("data:") || url.startsWith("cid:"))
78
- return match;
79
- hasRemoteContent = true;
80
- return `<img${before}src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect fill='%23888' width='20' height='20' rx='3'/%3E%3Ctext x='10' y='14' text-anchor='middle' fill='white' font-size='12'%3E⊘%3C/text%3E%3C/svg%3E" data-blocked-src=${src} title="Remote image blocked"`;
81
- });
82
- clean = clean.replace(/<link\b[^>]*rel\s*=\s*["']stylesheet["'][^>]*>/gi, (match) => {
83
- hasRemoteContent = true;
84
- return `<!-- blocked: ${match.replace(/--/g, "")} -->`;
85
- });
86
- clean = clean.replace(/url\s*\(\s*(['"]?)(https?:\/\/[^)]+)\1\s*\)/gi, (_match, _q, url) => {
87
- hasRemoteContent = true;
88
- return `url("") /* blocked: ${url} */`;
89
- });
90
- clean = clean.replace(/<\/?form\b[^>]*>/gi, "");
91
- clean = clean.replace(/<iframe\b[^>]*>[\s\S]*?<\/iframe>/gi, "");
92
- return { html: clean, hasRemoteContent };
93
- }
35
+ // sanitizeHtml and encodeQuotedPrintable imported from @bobfrankston/mailx-types (shared with Android)
94
36
  // ── Service ──
95
37
  export class MailxService {
96
38
  db;
@@ -154,13 +96,27 @@ export class MailxService {
154
96
  err.isNotFound = true;
155
97
  throw err;
156
98
  }
99
+ // Don't stuff the error text into bodyText — it bleeds into the
100
+ // viewer's main content area. Surface as a structured error field
101
+ // so the UI can render a banner with retry UX above the (empty)
102
+ // body. The caller keeps the envelope so the header still shows.
103
+ const rawErr = fetchErr.message || "connection failed";
104
+ const isTransient = /connection|Too many|UNAVAILABLE|rate|429|5\d\d|timeout|ENOTFOUND|ECONNRESET|ETIMEDOUT/i.test(rawErr);
157
105
  return {
158
- ...envelope, bodyHtml: "", bodyText: `[Message body unavailable: ${fetchErr.message || "connection failed"}]`,
106
+ ...envelope, bodyHtml: "", bodyText: "",
107
+ bodyError: rawErr,
108
+ bodyErrorTransient: isTransient,
159
109
  hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
160
110
  };
161
111
  }
162
112
  if (!raw) {
163
- bodyText = "[Message body not cached locally and the server fetch returned nothing. Try re-syncing the folder.]";
113
+ // Same treatment as the thrown-error case: structured field, not body text.
114
+ return {
115
+ ...envelope, bodyHtml: "", bodyText: "",
116
+ bodyError: "Message body not cached locally and the server fetch returned nothing.",
117
+ bodyErrorTransient: true,
118
+ hasRemoteContent: false, remoteAllowed: false, attachments: [], emlPath: "", deliveredTo: "", returnPath: "", listUnsubscribe: ""
119
+ };
164
120
  }
165
121
  else {
166
122
  const parsed = await simpleParser(raw);
@@ -110,25 +110,30 @@ class AndroidSyncManager {
110
110
  async syncAll() {
111
111
  const accounts = this.db.getAccounts();
112
112
  vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map(a => a.id).join(",")}`);
113
+ // Phase 1: Sync INBOX for every account first — user sees new mail fast.
113
114
  for (const account of accounts) {
114
- const hasProvider = this.providers.has(account.id);
115
- vlog(`syncAll: ${account.id} hasProvider=${hasProvider}`);
115
+ if (!this.providers.has(account.id))
116
+ continue;
116
117
  try {
117
118
  const folders = await this.syncFolders(account.id);
118
- vlog(`syncAll: ${account.id} got ${folders.length} folders`);
119
- const sorted = [...folders].sort((a, b) => {
120
- if (a.specialUse === "inbox")
121
- return -1;
122
- if (b.specialUse === "inbox")
123
- return 1;
124
- return 0;
125
- });
126
- // Sync every folder, not just the first five — the old slice(0, 5)
127
- // meant subfolders past the cutoff (e.g. _spam, custom labels)
128
- // never picked up moves made on other clients, and those moves
129
- // also stayed visible in the source folder because reconcile
130
- // (below in syncFolder) never ran for the target.
131
- for (const folder of sorted) {
119
+ const inbox = folders.find(f => f.specialUse === "inbox");
120
+ if (inbox) {
121
+ await this.syncFolder(account.id, inbox.id);
122
+ emitEvent({ type: "syncComplete", accountId: account.id });
123
+ }
124
+ }
125
+ catch (e) {
126
+ console.error(`[sync] ${account.id} inbox: ${e.message}`);
127
+ }
128
+ }
129
+ // Phase 2: Remaining folders (sent, drafts, trash, then everything else).
130
+ for (const account of accounts) {
131
+ if (!this.providers.has(account.id))
132
+ continue;
133
+ try {
134
+ const folders = this.db.getFolders(account.id);
135
+ const remaining = folders.filter(f => f.specialUse !== "inbox");
136
+ for (const folder of remaining) {
132
137
  try {
133
138
  await this.syncFolder(account.id, folder.id);
134
139
  }
@@ -689,13 +694,15 @@ async function waitForNativeBridge(timeoutMs = 5000) {
689
694
  });
690
695
  }
691
696
  export async function initAndroid() {
692
- console.log("[android] Initializing mailx...");
693
- // Wait for C# to inject the native bridge (TCP/FS/HTTP + OAuth)
697
+ console.log("[android] Initializing mailx (Worker mode)...");
698
+ // Use the Worker host all service logic runs off the main thread.
699
+ const { createWorkerHost } = await import("./main-thread-host.js");
700
+ await createWorkerHost();
701
+ console.log("[android] Worker host initialized");
702
+ return;
703
+ // ── Legacy main-thread path (kept for reference, unreachable) ──
704
+ // eslint-disable-next-line no-unreachable
694
705
  await waitForNativeBridge();
695
- console.log(`[android] Native bridge: ${window._nativeBridge ? "ready" : "timeout"}`);
696
- // iflow-direct's BridgeTransport expects a global `msgapi` with a .tcp
697
- // subobject. Our MAUI shell exposes the same API under `_nativeBridge`, so
698
- // alias it. Must happen before any IMAP client is constructed.
699
706
  if (window._nativeBridge && !window.msgapi) {
700
707
  window.msgapi = window._nativeBridge;
701
708
  }
@@ -788,6 +795,14 @@ export async function initAndroid() {
788
795
  vlog("periodic sync poll");
789
796
  syncManager.syncAll().catch(e => console.error(`[android] Periodic sync error: ${e.message}`));
790
797
  }, SYNC_INTERVAL_MS);
798
+ // Immediate sync when app comes back to foreground (e.g. user switches from
799
+ // another app). Without this, new messages wait up to 2 minutes after resume.
800
+ document.addEventListener("visibilitychange", () => {
801
+ if (document.visibilityState === "visible") {
802
+ console.log("[sync] resume poll");
803
+ syncManager.syncAll().catch(e => console.error(`[android] Resume sync error: ${e.message}`));
804
+ }
805
+ });
791
806
  console.log("[android] Initialization complete");
792
807
  emitEvent({ type: "connected" });
793
808
  }
@@ -7,6 +7,7 @@
7
7
  * Bodies are stored in IndexedDB via WebMessageStore (not filesystem).
8
8
  */
9
9
  import initSqlJs from "sql.js";
10
+ import { parseSearchQuery } from "@bobfrankston/mailx-types";
10
11
  const SCHEMA = `
11
12
  CREATE TABLE IF NOT EXISTS accounts (
12
13
  id TEXT PRIMARY KEY,
@@ -433,20 +434,20 @@ export class WebMailxDB {
433
434
  // ── Search ──
434
435
  searchMessages(query, page = 1, pageSize = 50, accountId, folderId) {
435
436
  const offset = (page - 1) * pageSize;
436
- const term = `%${query}%`;
437
- let where = "(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)";
438
- const params = [term, term, term, term];
437
+ const parsed = parseSearchQuery(query);
438
+ const allParams = [...parsed.params];
439
+ let where = parsed.conditions.length > 0 ? parsed.conditions.join(" AND ") : "1=0";
439
440
  if (accountId && folderId) {
440
441
  where += " AND account_id = ? AND folder_id = ?";
441
- params.push(accountId, folderId);
442
+ allParams.push(accountId, folderId);
442
443
  }
443
444
  else if (accountId) {
444
445
  where += " AND account_id = ?";
445
- params.push(accountId);
446
+ allParams.push(accountId);
446
447
  }
447
- const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, params);
448
+ const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`, allParams);
448
449
  const total = countRow?.cnt || 0;
449
- const rows = this.all(`SELECT * FROM messages WHERE ${where} ORDER BY date DESC LIMIT ? OFFSET ?`, [...params, pageSize, offset]);
450
+ const rows = this.all(`SELECT * FROM messages WHERE ${where} ORDER BY date DESC LIMIT ? OFFSET ?`, [...allParams, pageSize, offset]);
450
451
  return { items: rows.map(r => this.rowToEnvelope(r)), total, page, pageSize };
451
452
  }
452
453
  // ── Sync Actions ──
@@ -1,37 +1,11 @@
1
1
  /**
2
- * Web-compatible Gmail API provider.
3
- * Identical to packages/mailx-imap/providers/gmail-api.ts but uses
4
- * atob() instead of Buffer.from() for base64 decoding (no Node.js deps).
2
+ * Back-compat re-export. The Gmail provider is now a single implementation
3
+ * in @bobfrankston/mailx-sync, used by both desktop (mailx-imap) and
4
+ * Android (this package). Bug fixes (e.g. 403-quota retry, bounded
5
+ * concurrency) land in one place and apply to both platforms.
5
6
  *
6
- * This file is a standalone copy not imported from mailx-imap because
7
- * mailx-imap depends on Node.js modules (node:events, node:fs, mailparser, etc.)
8
- * that aren't available in a WebView.
7
+ * `GmailApiWebProvider` is kept as an alias for back-compat with existing
8
+ * Android wiring; it's the same class as `GmailApiProvider`.
9
9
  */
10
- import type { MailProvider, ProviderFolder, ProviderMessage, FetchOptions } from "./provider-types.js";
11
- export declare class GmailApiWebProvider implements MailProvider {
12
- private tokenProvider;
13
- constructor(tokenProvider: () => Promise<string>);
14
- private apiFetch;
15
- listFolders(): Promise<ProviderFolder[]>;
16
- private listMessageIds;
17
- private batchFetch;
18
- private parseMessage;
19
- fetchSince(folder: string, sinceUid: number, options?: FetchOptions): Promise<ProviderMessage[]>;
20
- fetchByDate(folder: string, since: Date, before: Date, options?: FetchOptions, onChunk?: (msgs: ProviderMessage[]) => void): Promise<ProviderMessage[]>;
21
- fetchByUids(folder: string, uids: number[], options?: FetchOptions): Promise<ProviderMessage[]>;
22
- /** Fetch a message directly by Gmail provider ID — fast path, no listing needed */
23
- fetchById(providerId: string, options?: FetchOptions): Promise<ProviderMessage | null>;
24
- fetchOne(folder: string, uid: number, options?: FetchOptions): Promise<ProviderMessage | null>;
25
- getUids(folder: string): Promise<number[]>;
26
- close(): Promise<void>;
27
- /** Send an RFC 2822 message via Gmail API users.messages.send. The server
28
- * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
29
- * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
30
- sendRaw(rawRfc822: string): Promise<{
31
- id: string;
32
- threadId: string;
33
- }>;
34
- private folderToLabel;
35
- private formatDate;
36
- }
10
+ export { GmailApiProvider as GmailApiWebProvider } from "@bobfrankston/mailx-sync";
37
11
  //# sourceMappingURL=gmail-api-web.d.ts.map