@bobfrankston/mailx 1.0.256 → 1.0.264

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 (31) hide show
  1. package/bin/mailx.js +112 -0
  2. package/client/.msger-window.json +1 -1
  3. package/client/android.html +2 -0
  4. package/client/app.js +4 -2
  5. package/client/components/message-viewer.js +82 -7
  6. package/package.json +11 -8
  7. package/packages/mailx-imap/index.d.ts +6 -0
  8. package/packages/mailx-imap/index.js +100 -33
  9. package/packages/mailx-imap/package.json +2 -1
  10. package/packages/mailx-imap/providers/gmail-api.d.ts +5 -40
  11. package/packages/mailx-imap/providers/gmail-api.js +5 -336
  12. package/packages/mailx-imap/providers/types.d.ts +6 -59
  13. package/packages/mailx-imap/providers/types.js +5 -2
  14. package/packages/mailx-service/index.js +16 -2
  15. package/packages/mailx-store-web/android-bootstrap.js +5 -7
  16. package/packages/mailx-store-web/gmail-api-web.d.ts +7 -37
  17. package/packages/mailx-store-web/gmail-api-web.js +7 -298
  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 +288 -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/worker-bundle.js +6838 -0
  28. package/packages/mailx-store-web/worker-entry.d.ts +8 -0
  29. package/packages/mailx-store-web/worker-entry.js +187 -0
  30. package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
  31. package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
@@ -1,302 +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
- const API = "https://gmail.googleapis.com/gmail/v1/users/me";
11
- /** Convert Gmail hex ID to integer UID (lower 48 bits) */
12
- function idToUid(id) {
13
- const hex = id.length > 12 ? id.slice(-12) : id;
14
- return parseInt(hex, 16);
15
- }
16
- /** Map Gmail label to IMAP-style specialUse */
17
- function labelSpecialUse(label) {
18
- switch (label.id) {
19
- case "INBOX": return "inbox";
20
- case "SENT": return "sent";
21
- case "DRAFT": return "drafts";
22
- case "TRASH": return "trash";
23
- case "SPAM": return "junk";
24
- default: return "";
25
- }
26
- }
27
- function getHeader(headers, name) {
28
- return headers.find(h => h.name.toLowerCase() === name.toLowerCase())?.value || "";
29
- }
30
- function parseAddress(raw) {
31
- const match = raw.match(/^"?([^"<]*?)"?\s*<([^>]+)>/);
32
- if (match)
33
- return { name: match[1].trim(), address: match[2].trim() };
34
- return { address: raw.trim() };
35
- }
36
- function parseAddressList(raw) {
37
- if (!raw)
38
- return [];
39
- return raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/).map(s => parseAddress(s.trim())).filter(a => a.address);
40
- }
41
- export class GmailApiWebProvider {
42
- tokenProvider;
43
- constructor(tokenProvider) {
44
- this.tokenProvider = tokenProvider;
45
- }
46
- async apiFetch(path, options = {}) {
47
- const token = await this.tokenProvider();
48
- for (let attempt = 0; attempt < 3; attempt++) {
49
- const res = await globalThis.fetch(`${API}${path}`, {
50
- ...options,
51
- headers: {
52
- "Authorization": `Bearer ${token}`,
53
- "Content-Type": "application/json",
54
- ...options.headers,
55
- },
56
- });
57
- if (res.status === 429) {
58
- const delay = (attempt + 1) * 2000;
59
- console.log(`[gmail] Rate limited, waiting ${delay / 1000}s...`);
60
- await new Promise(r => setTimeout(r, delay));
61
- continue;
62
- }
63
- if (!res.ok) {
64
- const err = await res.text().catch(() => "");
65
- throw new Error(`Gmail API ${res.status}: ${err.substring(0, 200)}`);
66
- }
67
- return res.json();
68
- }
69
- throw new Error("Gmail API: rate limited after 3 retries");
70
- }
71
- async listFolders() {
72
- const data = await this.apiFetch("/labels");
73
- const labels = data.labels || [];
74
- const folders = [];
75
- for (const label of labels) {
76
- if (["UNREAD", "STARRED", "IMPORTANT", "CATEGORY_PERSONAL",
77
- "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "CATEGORY_UPDATES",
78
- "CATEGORY_FORUMS", "CHAT"].includes(label.id))
79
- continue;
80
- const specialUse = labelSpecialUse(label);
81
- const path = label.name || label.id;
82
- const name = path.includes("/") ? path.split("/").pop() : path;
83
- // System labels like INBOX, SENT, TRASH are selectable — don't mark \Noselect
84
- // Only container labels (categories) should be noselect
85
- const noSelect = label.type === "system" && !specialUse && !label.messagesTotal;
86
- folders.push({
87
- path, name, delimiter: "/", specialUse,
88
- flags: noSelect ? ["\\Noselect"] : [],
89
- });
90
- }
91
- return folders;
92
- }
93
- async listMessageIds(query, maxResults = 500) {
94
- const ids = [];
95
- let pageToken = "";
96
- while (true) {
97
- const params = new URLSearchParams({ q: query, maxResults: String(Math.min(maxResults - ids.length, 500)) });
98
- if (pageToken)
99
- params.set("pageToken", pageToken);
100
- const data = await this.apiFetch(`/messages?${params}`);
101
- for (const msg of data.messages || []) {
102
- ids.push(msg.id);
103
- }
104
- if (!data.nextPageToken || ids.length >= maxResults)
105
- break;
106
- pageToken = data.nextPageToken;
107
- }
108
- return ids;
109
- }
110
- async batchFetch(ids, options = {}, onChunk) {
111
- const all = [];
112
- const chunkSize = options.source ? 10 : 50;
113
- const format = options.source ? "raw" : "metadata";
114
- for (let i = 0; i < ids.length; i += chunkSize) {
115
- const chunk = ids.slice(i, i + chunkSize);
116
- const messages = [];
117
- for (const id of chunk) {
118
- const params = new URLSearchParams({ format });
119
- if (format === "metadata") {
120
- for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
121
- params.append("metadataHeaders", h);
122
- }
123
- }
124
- messages.push(await this.apiFetch(`/messages/${id}?${params}`));
125
- }
126
- const parsed = messages.map(msg => this.parseMessage(msg, options));
127
- all.push(...parsed);
128
- if (onChunk)
129
- onChunk(parsed);
130
- }
131
- return all;
132
- }
133
- parseMessage(msg, options = {}) {
134
- const labels = msg.labelIds || [];
135
- const headers = msg.payload?.headers || [];
136
- let source = "";
137
- if (options.source && msg.raw) {
138
- // URL-safe base64 → standard base64 → UTF-8 string
139
- // atob() returns a binary string (1 byte per char). Must decode bytes as UTF-8
140
- // to handle multi-byte characters correctly (e.g. smart quotes).
141
- const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
142
- const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
143
- const binary = atob(padded);
144
- const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
145
- source = new TextDecoder("utf-8").decode(bytes);
146
- }
147
- const fromRaw = getHeader(headers, "From");
148
- const toRaw = getHeader(headers, "To");
149
- const ccRaw = getHeader(headers, "Cc");
150
- const dateRaw = getHeader(headers, "Date") || "";
151
- const subject = getHeader(headers, "Subject") || msg.snippet || "";
152
- const messageId = getHeader(headers, "Message-ID") || "";
153
- return {
154
- uid: idToUid(msg.id),
155
- messageId,
156
- providerId: msg.id,
157
- date: dateRaw ? new Date(dateRaw) : (msg.internalDate ? new Date(Number(msg.internalDate)) : null),
158
- subject,
159
- from: parseAddressList(fromRaw),
160
- to: parseAddressList(toRaw),
161
- cc: parseAddressList(ccRaw),
162
- seen: !labels.includes("UNREAD"),
163
- flagged: labels.includes("STARRED"),
164
- answered: false,
165
- draft: labels.includes("DRAFT"),
166
- size: msg.sizeEstimate || 0,
167
- source,
168
- };
169
- }
170
- async fetchSince(folder, sinceUid, options = {}) {
171
- const query = `in:${this.folderToLabel(folder)}`;
172
- const ids = await this.listMessageIds(query, 200);
173
- const messages = await this.batchFetch(ids, options);
174
- return messages.filter(m => m.uid > sinceUid);
175
- }
176
- async fetchByDate(folder, since, before, options = {}, onChunk) {
177
- const afterDate = this.formatDate(since);
178
- const beforeDate = this.formatDate(before);
179
- const query = `in:${this.folderToLabel(folder)} after:${afterDate} before:${beforeDate}`;
180
- const ids = await this.listMessageIds(query);
181
- return this.batchFetch(ids, options, onChunk);
182
- }
183
- async fetchByUids(folder, uids, options = {}) {
184
- const query = `in:${this.folderToLabel(folder)}`;
185
- const ids = await this.listMessageIds(query);
186
- const uidSet = new Set(uids);
187
- const matchingIds = ids.filter(id => uidSet.has(idToUid(id)));
188
- return this.batchFetch(matchingIds, options);
189
- }
190
- /** Fetch a message directly by Gmail provider ID — fast path, no listing needed */
191
- async fetchById(providerId, options = {}) {
192
- const format = options.source ? "raw" : "metadata";
193
- const params = new URLSearchParams({ format });
194
- if (format === "metadata") {
195
- for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
196
- params.append("metadataHeaders", h);
197
- }
198
- }
199
- try {
200
- const msg = await this.apiFetch(`/messages/${providerId}?${params}`);
201
- return this.parseMessage(msg, options);
202
- }
203
- catch (e) {
204
- console.warn(`[gmail] fetchById ${providerId} failed: ${e.message}`);
205
- return null;
206
- }
207
- }
208
- async fetchOne(folder, uid, options = {}) {
209
- const query = `in:${this.folderToLabel(folder)}`;
210
- const ids = await this.listMessageIds(query, 1000);
211
- const id = ids.find(id => idToUid(id) === uid);
212
- if (!id)
213
- return null;
214
- const format = options.source ? "raw" : "metadata";
215
- const params = new URLSearchParams({ format });
216
- if (format === "metadata") {
217
- for (const h of ["From", "To", "Cc", "Subject", "Message-ID", "Date"]) {
218
- params.append("metadataHeaders", h);
219
- }
220
- }
221
- const msg = await this.apiFetch(`/messages/${id}?${params}`);
222
- return this.parseMessage(msg, options);
223
- }
224
- async getUids(folder) {
225
- const query = `in:${this.folderToLabel(folder)}`;
226
- const ids = await this.listMessageIds(query, 10000);
227
- return ids.map(idToUid);
228
- }
229
- async close() { }
230
- /** Bulk-fetch raw bodies for many UIDs in one Gmail label. Lists the label
231
- * once, builds UID→ID map, streams bodies via `onBody` with bounded
232
- * concurrency. Mirrors GmailApiProvider.fetchBodiesBatch on desktop. */
233
- async fetchBodiesBatch(folder, uids, onBody) {
234
- if (uids.length === 0)
235
- return;
236
- const query = `in:${this.folderToLabel(folder)}`;
237
- const ids = await this.listMessageIds(query, 10000);
238
- const uidToId = new Map();
239
- for (const id of ids)
240
- uidToId.set(idToUid(id), id);
241
- const wanted = [];
242
- for (const uid of uids) {
243
- const id = uidToId.get(uid);
244
- if (id)
245
- wanted.push({ uid, id });
246
- }
247
- if (wanted.length === 0)
248
- return;
249
- const CONCURRENCY = 10;
250
- let cursor = 0;
251
- const worker = async () => {
252
- while (cursor < wanted.length) {
253
- const idx = cursor++;
254
- const { uid, id } = wanted[idx];
255
- try {
256
- const msg = await this.apiFetch(`/messages/${id}?format=raw`);
257
- if (!msg?.raw)
258
- continue;
259
- const base64 = msg.raw.replace(/-/g, "+").replace(/_/g, "/");
260
- const source = new TextDecoder().decode(Uint8Array.from(atob(base64), c => c.charCodeAt(0)));
261
- onBody(uid, source);
262
- }
263
- catch (e) {
264
- console.error(`[gmail batch] UID ${uid}: ${e.message}`);
265
- }
266
- }
267
- };
268
- await Promise.all(Array.from({ length: Math.min(CONCURRENCY, wanted.length) }, () => worker()));
269
- }
270
- /** Send an RFC 2822 message via Gmail API users.messages.send. The server
271
- * handles SMTP — we just hand it the raw bytes base64url-encoded. Auto-files
272
- * a copy into the Sent label, so caller does NOT need to APPEND to Sent. */
273
- async sendRaw(rawRfc822) {
274
- const b64 = btoa(unescape(encodeURIComponent(rawRfc822)))
275
- .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
276
- const data = await this.apiFetch("/messages/send", {
277
- method: "POST",
278
- body: JSON.stringify({ raw: b64 }),
279
- });
280
- return { id: data.id, threadId: data.threadId };
281
- }
282
- folderToLabel(path) {
283
- const lower = path.toLowerCase();
284
- if (lower === "inbox")
285
- return "inbox";
286
- if (lower === "sent" || lower === "[gmail]/sent mail")
287
- return "sent";
288
- if (lower === "drafts" || lower === "[gmail]/drafts")
289
- return "drafts";
290
- if (lower === "trash" || lower === "[gmail]/trash")
291
- return "trash";
292
- if (lower === "spam" || lower === "[gmail]/spam" || lower === "junk email")
293
- return "spam";
294
- if (lower === "archive" || lower === "[gmail]/all mail")
295
- return "all";
296
- return `"${path}"`;
297
- }
298
- formatDate(d) {
299
- return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`;
300
- }
301
- }
10
+ export { GmailApiProvider as GmailApiWebProvider } from "@bobfrankston/mailx-sync";
302
11
  //# sourceMappingURL=gmail-api-web.js.map
@@ -17,7 +17,7 @@ export declare class ImapWebProvider implements MailProvider {
17
17
  private transportFactory;
18
18
  private specialFolders;
19
19
  private folderListCache;
20
- constructor(config: ImapClientConfig);
20
+ constructor(config: ImapClientConfig, transportFactory?: () => any);
21
21
  /** Create a fresh client (after broken pipe / connection error) */
22
22
  private reconnect;
23
23
  /** Run an operation with one retry on connection error */
@@ -71,9 +71,9 @@ export class ImapWebProvider {
71
71
  transportFactory;
72
72
  specialFolders = {};
73
73
  folderListCache = null;
74
- constructor(config) {
74
+ constructor(config, transportFactory) {
75
75
  this.config = config;
76
- this.transportFactory = () => new BridgeTransport();
76
+ this.transportFactory = transportFactory || (() => new BridgeTransport());
77
77
  this.client = new CompatImapClient(config, this.transportFactory);
78
78
  }
79
79
  /** Create a fresh client (after broken pipe / connection error) */
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Main thread Worker host — thin shim that keeps UI responsive.
3
+ *
4
+ * Responsibilities:
5
+ * 1. Create Worker and proxy mailxapi calls to it
6
+ * 2. Proxy TCP calls from Worker to msgapi.tcp (C# native bridge)
7
+ * 3. Proxy native bridge calls (OAuth, device info)
8
+ * 4. Forward events from Worker to UI event handlers
9
+ * 5. Set up visibilitychange/periodic sync triggers
10
+ *
11
+ * The UI code (api-client.ts) calls window.mailxapi.* exactly as before —
12
+ * this host just routes those calls to the Worker instead of running them inline.
13
+ */
14
+ export declare function createWorkerHost(): Promise<void>;
15
+ //# sourceMappingURL=main-thread-host.d.ts.map
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Main thread Worker host — thin shim that keeps UI responsive.
3
+ *
4
+ * Responsibilities:
5
+ * 1. Create Worker and proxy mailxapi calls to it
6
+ * 2. Proxy TCP calls from Worker to msgapi.tcp (C# native bridge)
7
+ * 3. Proxy native bridge calls (OAuth, device info)
8
+ * 4. Forward events from Worker to UI event handlers
9
+ * 5. Set up visibilitychange/periodic sync triggers
10
+ *
11
+ * The UI code (api-client.ts) calls window.mailxapi.* exactly as before —
12
+ * this host just routes those calls to the Worker instead of running them inline.
13
+ */
14
+ const eventHandlers = [];
15
+ let worker;
16
+ let rpcCounter = 0;
17
+ const pendingRpc = new Map();
18
+ // ── Worker communication ──
19
+ function sendRpc(action, params = {}) {
20
+ const id = ++rpcCounter;
21
+ return new Promise((resolve, reject) => {
22
+ pendingRpc.set(id, { resolve, reject });
23
+ worker.postMessage({ type: "rpc", id, action, params });
24
+ // 5-minute timeout for long operations like syncAll
25
+ setTimeout(() => {
26
+ if (pendingRpc.has(id)) {
27
+ pendingRpc.delete(id);
28
+ reject(new Error(`RPC ${action} timeout`));
29
+ }
30
+ }, 300000);
31
+ });
32
+ }
33
+ // ── TCP proxy: relay Worker TCP requests to msgapi.tcp ──
34
+ function handleTcpRequest(msg) {
35
+ const msgapi = window.msgapi;
36
+ if (!msgapi?.tcp) {
37
+ worker.postMessage({ type: "tcp-response", reqId: msg.reqId, error: "TCP bridge not available" });
38
+ return;
39
+ }
40
+ switch (msg.op) {
41
+ case "connect": {
42
+ msgapi.tcp.connect(msg.host, msg.port, msg.tls)
43
+ .then((streamId) => {
44
+ const sid = Number(streamId);
45
+ // Register event forwarding for this stream
46
+ msgapi.tcp.onData(sid, (data) => {
47
+ worker.postMessage({ type: "tcp-data", streamId: sid, data });
48
+ });
49
+ msgapi.tcp.onClose(sid, (hadError) => {
50
+ worker.postMessage({ type: "tcp-close", streamId: sid, hadError });
51
+ });
52
+ msgapi.tcp.onError(sid, (message) => {
53
+ worker.postMessage({ type: "tcp-error", streamId: sid, message });
54
+ });
55
+ worker.postMessage({ type: "tcp-response", reqId: msg.reqId, result: String(sid) });
56
+ })
57
+ .catch((e) => {
58
+ worker.postMessage({ type: "tcp-response", reqId: msg.reqId, error: e.message });
59
+ });
60
+ break;
61
+ }
62
+ case "write": {
63
+ const data = msg.data;
64
+ // btoa for the bridge (C# expects base64)
65
+ msgapi.tcp.write(msg.streamId, data)
66
+ .then(() => {
67
+ worker.postMessage({ type: "tcp-response", reqId: msg.reqId, result: "ok" });
68
+ })
69
+ .catch((e) => {
70
+ worker.postMessage({ type: "tcp-response", reqId: msg.reqId, error: e.message });
71
+ });
72
+ break;
73
+ }
74
+ case "upgradeTLS": {
75
+ msgapi.tcp.upgradeTLS(msg.streamId, msg.servername)
76
+ .then(() => {
77
+ worker.postMessage({ type: "tcp-response", reqId: msg.reqId, result: "ok" });
78
+ })
79
+ .catch((e) => {
80
+ worker.postMessage({ type: "tcp-response", reqId: msg.reqId, error: e.message });
81
+ });
82
+ break;
83
+ }
84
+ case "close": {
85
+ try {
86
+ msgapi.tcp.close(msg.streamId);
87
+ }
88
+ catch { /* ignore */ }
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ // ── Native bridge proxy: relay Worker native requests ──
94
+ function handleNativeRequest(msg) {
95
+ const bridge = window._nativeBridge;
96
+ switch (msg.op) {
97
+ case "refreshToken": {
98
+ const email = msg.args?.[0];
99
+ if (bridge?.app?.startOAuth) {
100
+ // Use the existing OAuth flow
101
+ // For now, use the cached token or refresh
102
+ bridge.app.refreshToken?.(email)
103
+ .then((token) => {
104
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, result: token });
105
+ })
106
+ .catch((e) => {
107
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, error: e.message });
108
+ });
109
+ }
110
+ else {
111
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, error: "No OAuth bridge" });
112
+ }
113
+ break;
114
+ }
115
+ case "vlog": {
116
+ // Verbose logging via logit
117
+ const text = msg.args?.[0] || "";
118
+ fetch(`https://rmf39.aaz.lt/logit/${encodeURIComponent("V/" + text)}?log=mailx-android`).catch(() => { });
119
+ break;
120
+ }
121
+ case "getAndroidId": {
122
+ bridge?.app?.getAndroidId?.()
123
+ .then((id) => {
124
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, result: id });
125
+ })
126
+ .catch((e) => {
127
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, error: e.message });
128
+ });
129
+ break;
130
+ }
131
+ case "getDeviceAccounts": {
132
+ bridge?.app?.getDeviceAccounts?.()
133
+ .then((accounts) => {
134
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, result: accounts });
135
+ })
136
+ .catch((e) => {
137
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, error: e.message });
138
+ });
139
+ break;
140
+ }
141
+ case "localStorageGet": {
142
+ const key = msg.args?.[0];
143
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, result: localStorage.getItem(key) });
144
+ break;
145
+ }
146
+ case "localStorageSet": {
147
+ const [key, value] = msg.args || [];
148
+ localStorage.setItem(key, value);
149
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, result: "ok" });
150
+ break;
151
+ }
152
+ default: {
153
+ worker.postMessage({ type: "native-response", reqId: msg.reqId, error: `Unknown native op: ${msg.op}` });
154
+ }
155
+ }
156
+ }
157
+ // ── Event handling ──
158
+ function handleWorkerEvent(event) {
159
+ for (const handler of eventHandlers) {
160
+ try {
161
+ handler(event);
162
+ }
163
+ catch { /* ignore */ }
164
+ }
165
+ // Also dispatch to the global mailxapi event callback
166
+ const cb = window._mailxapiEvent;
167
+ if (cb)
168
+ try {
169
+ cb(event);
170
+ }
171
+ catch { /* ignore */ }
172
+ }
173
+ // ── Worker message router ──
174
+ function onWorkerMessage(e) {
175
+ const msg = e.data;
176
+ if (msg.type === "rpc-response") {
177
+ const pending = pendingRpc.get(msg.id);
178
+ if (pending) {
179
+ pendingRpc.delete(msg.id);
180
+ if (msg.error)
181
+ pending.reject(new Error(msg.error));
182
+ else
183
+ pending.resolve(msg.result);
184
+ }
185
+ return;
186
+ }
187
+ if (msg.type === "event") {
188
+ handleWorkerEvent(msg.event);
189
+ return;
190
+ }
191
+ if (msg.type === "tcp") {
192
+ handleTcpRequest(msg);
193
+ return;
194
+ }
195
+ if (msg.type === "native") {
196
+ handleNativeRequest(msg);
197
+ return;
198
+ }
199
+ if (msg.type === "init-complete") {
200
+ console.log("[host] Worker initialization complete");
201
+ return;
202
+ }
203
+ if (msg.type === "init-error") {
204
+ console.error(`[host] Worker init failed: ${msg.error}`);
205
+ return;
206
+ }
207
+ }
208
+ // ── Install mailxapi bridge (same interface as before, routes to Worker) ──
209
+ function installBridge() {
210
+ window.mailxapi = {
211
+ isApp: true,
212
+ platform: "android",
213
+ // All service methods proxy to the Worker
214
+ getAccounts: () => sendRpc("getAccounts"),
215
+ getFolders: (accountId) => sendRpc("getFolders", { accountId }),
216
+ getMessages: (accountId, folderId, page, pageSize) => sendRpc("getMessages", { accountId, folderId, page, pageSize }),
217
+ getUnifiedInbox: (page, pageSize) => sendRpc("getUnifiedInbox", { page, pageSize }),
218
+ getMessage: (accountId, uid, allowRemote, folderId) => sendRpc("getMessage", { accountId, uid, allowRemote, folderId }),
219
+ getAttachment: (accountId, uid, index, folderId) => sendRpc("getAttachment", { accountId, uid, index, folderId }),
220
+ updateFlags: (accountId, uid, flags) => sendRpc("updateFlags", { accountId, uid, flags }),
221
+ deleteMessage: (accountId, uid) => sendRpc("deleteMessage", { accountId, uid }),
222
+ deleteMessages: (accountId, uids) => sendRpc("deleteMessages", { accountId, uids }),
223
+ undeleteMessage: (accountId, uid, folderId) => sendRpc("undeleteMessage", { accountId, uid, folderId }),
224
+ moveMessage: (accountId, uid, targetFolderId, targetAccountId) => sendRpc("moveMessage", { accountId, uid, targetFolderId, targetAccountId }),
225
+ moveMessages: (accountId, uids, targetFolderId) => sendRpc("moveMessages", { accountId, uids, targetFolderId }),
226
+ markFolderRead: (folderId) => sendRpc("markFolderRead", { folderId }),
227
+ sendMessage: async (msg) => { await sendRpc("sendMessage", msg); return { ok: true }; },
228
+ saveDraft: (p) => sendRpc("saveDraft", p),
229
+ deleteDraft: (accountId, draftUid) => sendRpc("deleteDraft", { accountId, draftUid }),
230
+ syncAll: () => sendRpc("syncAll"),
231
+ syncAccount: (accountId) => sendRpc("syncAccount", { accountId }),
232
+ triggerSync: () => sendRpc("syncAll"),
233
+ getSyncPending: () => sendRpc("getSyncPending"),
234
+ reauthenticate: (accountId) => sendRpc("reauthenticate", { accountId }),
235
+ searchMessages: (query, page, pageSize) => sendRpc("searchMessages", { query, page, pageSize }),
236
+ searchContacts: (query) => sendRpc("searchContacts", { query }),
237
+ getSettings: () => sendRpc("getSettings"),
238
+ saveSettings: (data) => sendRpc("saveSettingsData", data),
239
+ allowRemoteContent: (type, value) => sendRpc("allowRemoteContent", { type, value }),
240
+ getVersion: () => sendRpc("getVersion"),
241
+ getAutocompleteSettings: () => sendRpc("getAutocompleteSettings"),
242
+ saveAutocompleteSettings: (data) => sendRpc("saveAutocompleteSettings", data),
243
+ emptyFolder: (accountId, folderId) => sendRpc("emptyFolder", { accountId, folderId }),
244
+ resetAll: () => sendRpc("resetStore"),
245
+ // Event registration
246
+ onEvent: (handler) => { eventHandlers.push(handler); },
247
+ };
248
+ }
249
+ // ── Public entry point ──
250
+ export async function createWorkerHost() {
251
+ console.log("[host] Creating service worker...");
252
+ // Wait for native bridge
253
+ await new Promise((resolve) => {
254
+ const check = () => {
255
+ if (window._nativeBridge) {
256
+ resolve();
257
+ return;
258
+ }
259
+ setTimeout(check, 50);
260
+ };
261
+ window.addEventListener("nativebridgeready", () => resolve(), { once: true });
262
+ check();
263
+ });
264
+ // Alias for TCP bridge
265
+ if (window._nativeBridge && !window.msgapi) {
266
+ window.msgapi = window._nativeBridge;
267
+ }
268
+ // Create Worker
269
+ // Note: Worker URL must point to the bundled worker entry.
270
+ // For now, use the same-origin asset path.
271
+ // Use bundled worker (esbuild inlines all dependencies — no import map needed)
272
+ worker = new Worker("../packages/mailx-store-web/worker-bundle.js");
273
+ worker.onmessage = onWorkerMessage;
274
+ worker.onerror = (e) => console.error("[host] Worker error:", e.message);
275
+ // Install the mailxapi bridge
276
+ installBridge();
277
+ // Tell Worker to initialize
278
+ worker.postMessage({ type: "init", config: {} });
279
+ // Sync on resume
280
+ document.addEventListener("visibilitychange", () => {
281
+ if (document.visibilityState === "visible") {
282
+ console.log("[sync] resume poll");
283
+ sendRpc("syncAll").catch(() => { });
284
+ }
285
+ });
286
+ console.log("[host] Worker host ready");
287
+ }
288
+ //# sourceMappingURL=main-thread-host.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -13,6 +13,7 @@
13
13
  "@bobfrankston/iflow-direct": "file:../../../MailApps/iflow-direct",
14
14
  "@bobfrankston/tcp-transport": "file:../../../MailApps/tcp-transport",
15
15
  "@bobfrankston/smtp-direct": "file:../../../MailApps/smtp-direct",
16
+ "@bobfrankston/mailx-sync": "file:../../../MailApps/mailx-sync",
16
17
  "sql.js": "^1.14.1"
17
18
  },
18
19
  "repository": {