@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.
- package/bin/mailx.js +112 -0
- package/client/.msger-window.json +1 -1
- package/client/components/message-viewer.js +82 -7
- package/package.json +9 -8
- package/packages/mailx-imap/index.d.ts +6 -0
- package/packages/mailx-imap/index.js +244 -57
- package/packages/mailx-imap/package.json +2 -1
- package/packages/mailx-imap/providers/gmail-api.d.ts +5 -29
- package/packages/mailx-imap/providers/gmail-api.js +5 -286
- package/packages/mailx-imap/providers/types.d.ts +6 -59
- package/packages/mailx-imap/providers/types.js +5 -2
- package/packages/mailx-service/index.d.ts +0 -4
- package/packages/mailx-service/index.js +18 -62
- package/packages/mailx-store-web/android-bootstrap.js +37 -22
- package/packages/mailx-store-web/db.js +8 -7
- package/packages/mailx-store-web/gmail-api-web.d.ts +7 -33
- package/packages/mailx-store-web/gmail-api-web.js +7 -258
- 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 +287 -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/web-service.d.ts +0 -4
- package/packages/mailx-store-web/web-service.js +1 -59
- 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
- package/packages/mailx-types/index.d.ts +14 -0
- package/packages/mailx-types/index.js +96 -1
|
@@ -1,289 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
115
|
+
if (!this.providers.has(account.id))
|
|
116
|
+
continue;
|
|
116
117
|
try {
|
|
117
118
|
const folders = await this.syncFolders(account.id);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
//
|
|
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
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
+
allParams.push(accountId, folderId);
|
|
442
443
|
}
|
|
443
444
|
else if (accountId) {
|
|
444
445
|
where += " AND account_id = ?";
|
|
445
|
-
|
|
446
|
+
allParams.push(accountId);
|
|
446
447
|
}
|
|
447
|
-
const countRow = this.get(`SELECT COUNT(*) as cnt FROM messages WHERE ${where}`,
|
|
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 ?`, [...
|
|
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
|
-
*
|
|
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
|
-
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
|