@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.
- package/bin/mailx.js +112 -0
- package/client/.msger-window.json +1 -1
- package/client/android.html +2 -0
- package/client/app.js +4 -2
- package/client/components/message-viewer.js +82 -7
- package/package.json +11 -8
- package/packages/mailx-imap/index.d.ts +6 -0
- package/packages/mailx-imap/index.js +100 -33
- package/packages/mailx-imap/package.json +2 -1
- package/packages/mailx-imap/providers/gmail-api.d.ts +5 -40
- package/packages/mailx-imap/providers/gmail-api.js +5 -336
- package/packages/mailx-imap/providers/types.d.ts +6 -59
- package/packages/mailx-imap/providers/types.js +5 -2
- package/packages/mailx-service/index.js +16 -2
- package/packages/mailx-store-web/android-bootstrap.js +5 -7
- package/packages/mailx-store-web/gmail-api-web.d.ts +7 -37
- package/packages/mailx-store-web/gmail-api-web.js +7 -298
- package/packages/mailx-store-web/imap-web-provider.d.ts +1 -1
- package/packages/mailx-store-web/imap-web-provider.js +2 -2
- package/packages/mailx-store-web/main-thread-host.d.ts +15 -0
- package/packages/mailx-store-web/main-thread-host.js +288 -0
- package/packages/mailx-store-web/package.json +2 -1
- package/packages/mailx-store-web/provider-types.d.ts +4 -47
- package/packages/mailx-store-web/provider-types.js +3 -3
- package/packages/mailx-store-web/sync-manager.d.ts +61 -0
- package/packages/mailx-store-web/sync-manager.js +422 -0
- package/packages/mailx-store-web/worker-bundle.js +6838 -0
- package/packages/mailx-store-web/worker-entry.d.ts +8 -0
- package/packages/mailx-store-web/worker-entry.js +187 -0
- package/packages/mailx-store-web/worker-tcp-transport.d.ts +28 -0
- package/packages/mailx-store-web/worker-tcp-transport.js +98 -0
|
@@ -1,302 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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.
|
|
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": {
|