@bobfrankston/mailx 1.0.83 → 1.0.87
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.
|
@@ -6,6 +6,118 @@
|
|
|
6
6
|
* This is the Android equivalent of the Express server + MailxService.
|
|
7
7
|
*/
|
|
8
8
|
import * as store from "./local-store.js";
|
|
9
|
+
// ── IMAP Parsing Helpers ──
|
|
10
|
+
/** Extract a balanced parenthesized group starting at position */
|
|
11
|
+
function extractParenGroup(s, start) {
|
|
12
|
+
if (s[start] !== "(")
|
|
13
|
+
return "";
|
|
14
|
+
let depth = 0;
|
|
15
|
+
for (let i = start; i < s.length; i++) {
|
|
16
|
+
if (s[i] === "(")
|
|
17
|
+
depth++;
|
|
18
|
+
else if (s[i] === ")") {
|
|
19
|
+
depth--;
|
|
20
|
+
if (depth === 0)
|
|
21
|
+
return s.substring(start, i + 1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return s.substring(start);
|
|
25
|
+
}
|
|
26
|
+
/** Tokenize top-level items in a parenthesized IMAP list */
|
|
27
|
+
function tokenizeEnvelope(s) {
|
|
28
|
+
const tokens = [];
|
|
29
|
+
const str = s.trim();
|
|
30
|
+
const start = str.startsWith("(") ? 1 : 0;
|
|
31
|
+
const end = str.endsWith(")") ? str.length - 1 : str.length;
|
|
32
|
+
let i = start;
|
|
33
|
+
while (i < end) {
|
|
34
|
+
while (i < end && str[i] === " ")
|
|
35
|
+
i++;
|
|
36
|
+
if (i >= end)
|
|
37
|
+
break;
|
|
38
|
+
if (str[i] === "(") {
|
|
39
|
+
let depth = 1, j = i + 1;
|
|
40
|
+
while (j < end && depth > 0) {
|
|
41
|
+
if (str[j] === "(")
|
|
42
|
+
depth++;
|
|
43
|
+
else if (str[j] === ")")
|
|
44
|
+
depth--;
|
|
45
|
+
j++;
|
|
46
|
+
}
|
|
47
|
+
tokens.push(str.substring(i, j));
|
|
48
|
+
i = j;
|
|
49
|
+
}
|
|
50
|
+
else if (str[i] === '"') {
|
|
51
|
+
let j = i + 1;
|
|
52
|
+
while (j < end) {
|
|
53
|
+
if (str[j] === "\\" && j + 1 < end) {
|
|
54
|
+
j += 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (str[j] === '"') {
|
|
58
|
+
j++;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
j++;
|
|
62
|
+
}
|
|
63
|
+
tokens.push(str.substring(i, j));
|
|
64
|
+
i = j;
|
|
65
|
+
}
|
|
66
|
+
else if (str.substring(i, i + 3).toUpperCase() === "NIL") {
|
|
67
|
+
tokens.push("NIL");
|
|
68
|
+
i += 3;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
let j = i;
|
|
72
|
+
while (j < end && str[j] !== " " && str[j] !== ")" && str[j] !== "(")
|
|
73
|
+
j++;
|
|
74
|
+
tokens.push(str.substring(i, j));
|
|
75
|
+
i = j;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return tokens;
|
|
79
|
+
}
|
|
80
|
+
/** Remove quotes from an IMAP string */
|
|
81
|
+
function unquoteImap(s) {
|
|
82
|
+
if (!s || s === "NIL")
|
|
83
|
+
return "";
|
|
84
|
+
if (s.startsWith('"') && s.endsWith('"'))
|
|
85
|
+
return s.slice(1, -1).replace(/\\(.)/g, "$1");
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
/** Parse IMAP address list: ((name NIL mailbox host)...) */
|
|
89
|
+
function parseImapAddressList(token) {
|
|
90
|
+
if (!token || token === "NIL")
|
|
91
|
+
return [];
|
|
92
|
+
const addrs = [];
|
|
93
|
+
const re = /\(([^)]*)\)/g;
|
|
94
|
+
let m;
|
|
95
|
+
while ((m = re.exec(token)) !== null) {
|
|
96
|
+
const parts = tokenizeEnvelope(m[1]);
|
|
97
|
+
if (parts.length >= 4) {
|
|
98
|
+
const name = unquoteImap(parts[0]);
|
|
99
|
+
const mailbox = unquoteImap(parts[2]);
|
|
100
|
+
const host = unquoteImap(parts[3]);
|
|
101
|
+
addrs.push({ name, address: mailbox && host ? `${mailbox}@${host}` : mailbox || "" });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return addrs;
|
|
105
|
+
}
|
|
106
|
+
/** Decode =?charset?encoding?text?= encoded words */
|
|
107
|
+
function decodeEncodedWords(s) {
|
|
108
|
+
if (!s)
|
|
109
|
+
return "";
|
|
110
|
+
return s.replace(/=\?([^?]+)\?([BQ])\?([^?]+)\?=/gi, (_match, _charset, encoding, text) => {
|
|
111
|
+
try {
|
|
112
|
+
if (encoding.toUpperCase() === "B")
|
|
113
|
+
return atob(text);
|
|
114
|
+
return text.replace(/=([0-9A-F]{2})/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))).replace(/_/g, " ");
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return text;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
9
121
|
let settings = null;
|
|
10
122
|
const eventHandlers = [];
|
|
11
123
|
function emit(event) {
|
|
@@ -132,32 +244,52 @@ class BridgeImapClient {
|
|
|
132
244
|
async fetchHeaders(range) {
|
|
133
245
|
const resp = await this.command(`UID FETCH ${range} (UID FLAGS ENVELOPE RFC822.SIZE INTERNALDATE)`);
|
|
134
246
|
const messages = [];
|
|
135
|
-
|
|
136
|
-
|
|
247
|
+
// Join all response lines to handle multi-line FETCH responses
|
|
248
|
+
const fullResp = resp.join("\r\n");
|
|
249
|
+
// Split on "* N FETCH" boundaries
|
|
250
|
+
const fetchBlocks = fullResp.split(/(?=\* \d+ FETCH)/);
|
|
251
|
+
for (const block of fetchBlocks) {
|
|
252
|
+
if (!block.includes("FETCH"))
|
|
137
253
|
continue;
|
|
138
|
-
const uid =
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
const
|
|
254
|
+
const uid = block.match(/UID\s+(\d+)/)?.[1];
|
|
255
|
+
if (!uid)
|
|
256
|
+
continue;
|
|
257
|
+
const flags = block.match(/FLAGS\s+\(([^)]*)\)/)?.[1] || "";
|
|
258
|
+
const size = block.match(/RFC822\.SIZE\s+(\d+)/)?.[1] || "0";
|
|
259
|
+
const dateMatch = block.match(/INTERNALDATE\s+"([^"]+)"/);
|
|
260
|
+
// Parse ENVELOPE: (date subject from sender reply-to to cc bcc in-reply-to message-id)
|
|
144
261
|
let subject = "", fromName = "", fromAddr = "", messageId = "";
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
262
|
+
let toAddrs = [];
|
|
263
|
+
let ccAddrs = [];
|
|
264
|
+
const envStart = block.indexOf("ENVELOPE (");
|
|
265
|
+
if (envStart >= 0) {
|
|
266
|
+
const envStr = extractParenGroup(block, envStart + 9);
|
|
267
|
+
const tokens = tokenizeEnvelope(envStr);
|
|
268
|
+
// tokens[0]=date, [1]=subject, [2]=from, [3]=sender, [4]=reply-to, [5]=to, [6]=cc, [7]=bcc, [8]=in-reply-to, [9]=message-id
|
|
269
|
+
if (tokens.length >= 10) {
|
|
270
|
+
subject = unquoteImap(tokens[1]);
|
|
271
|
+
messageId = unquoteImap(tokens[9]);
|
|
272
|
+
const fromList = parseImapAddressList(tokens[2]);
|
|
273
|
+
if (fromList.length > 0) {
|
|
274
|
+
fromName = fromList[0].name;
|
|
275
|
+
fromAddr = fromList[0].address;
|
|
276
|
+
}
|
|
277
|
+
toAddrs = parseImapAddressList(tokens[5]);
|
|
278
|
+
ccAddrs = parseImapAddressList(tokens[6]);
|
|
279
|
+
}
|
|
160
280
|
}
|
|
281
|
+
messages.push({
|
|
282
|
+
uid: parseInt(uid),
|
|
283
|
+
flags: flags.split(/\s+/).filter(Boolean),
|
|
284
|
+
size: parseInt(size),
|
|
285
|
+
date: dateMatch ? new Date(dateMatch[1]).getTime() : Date.now(),
|
|
286
|
+
subject: decodeEncodedWords(subject),
|
|
287
|
+
fromName: decodeEncodedWords(fromName),
|
|
288
|
+
fromAddr,
|
|
289
|
+
messageId,
|
|
290
|
+
to: toAddrs,
|
|
291
|
+
cc: ccAddrs,
|
|
292
|
+
});
|
|
161
293
|
}
|
|
162
294
|
return messages;
|
|
163
295
|
}
|
|
@@ -335,8 +467,8 @@ async function syncFolder(client, accountId, folder) {
|
|
|
335
467
|
subject: msg.subject || "(no subject)",
|
|
336
468
|
fromName: msg.fromName || "",
|
|
337
469
|
fromAddress: msg.fromAddr || "",
|
|
338
|
-
toJson:
|
|
339
|
-
ccJson:
|
|
470
|
+
toJson: JSON.stringify(msg.to || []),
|
|
471
|
+
ccJson: JSON.stringify(msg.cc || []),
|
|
340
472
|
flags: msg.flags.join(","),
|
|
341
473
|
size: msg.size,
|
|
342
474
|
hasAttachments: false,
|
|
@@ -454,6 +586,16 @@ export async function initialize(initialSettings) {
|
|
|
454
586
|
syncAccount(account).catch(e => console.error(`[local-service] ${e.message}`));
|
|
455
587
|
}
|
|
456
588
|
}
|
|
589
|
+
// Periodic re-sync every 30 seconds
|
|
590
|
+
setInterval(async () => {
|
|
591
|
+
if (!settings)
|
|
592
|
+
return;
|
|
593
|
+
for (const account of settings.accounts) {
|
|
594
|
+
if (account.enabled !== false) {
|
|
595
|
+
syncAccount(account).catch(e => console.error(`[local-service] periodic: ${e.message}`));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}, 30000);
|
|
457
599
|
}
|
|
458
600
|
export function getSettings() {
|
|
459
601
|
return settings;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.87",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"postinstall": "node launcher/builder/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow": "^1.0.
|
|
23
|
+
"@bobfrankston/iflow": "^1.0.39",
|
|
24
24
|
"@bobfrankston/miscinfo": "^1.0.7",
|
|
25
25
|
"@bobfrankston/oauthsupport": "^1.0.20",
|
|
26
26
|
"@bobfrankston/rust-builder": "^0.1.3",
|
|
@@ -31,6 +31,8 @@ export declare class ImapManager extends EventEmitter {
|
|
|
31
31
|
private inboxSyncing;
|
|
32
32
|
/** Use native IMAP client instead of imapflow. Set to true to enable. */
|
|
33
33
|
useNativeClient: boolean;
|
|
34
|
+
/** Accounts hitting connection limits — back off until this time */
|
|
35
|
+
private connectionBackoff;
|
|
34
36
|
constructor(db: MailxDB);
|
|
35
37
|
/** Get OAuth access token for an account (for SMTP auth) */
|
|
36
38
|
getOAuthToken(accountId: string): Promise<string | null>;
|
|
@@ -71,6 +71,8 @@ export class ImapManager extends EventEmitter {
|
|
|
71
71
|
inboxSyncing = false;
|
|
72
72
|
/** Use native IMAP client instead of imapflow. Set to true to enable. */
|
|
73
73
|
useNativeClient = false;
|
|
74
|
+
/** Accounts hitting connection limits — back off until this time */
|
|
75
|
+
connectionBackoff = new Map();
|
|
74
76
|
constructor(db) {
|
|
75
77
|
super();
|
|
76
78
|
this.db = db;
|
|
@@ -169,6 +171,11 @@ export class ImapManager extends EventEmitter {
|
|
|
169
171
|
createClient(accountId) {
|
|
170
172
|
if (this.reauthenticating.has(accountId))
|
|
171
173
|
throw new Error(`Account ${accountId} is re-authenticating`);
|
|
174
|
+
// Check connection backoff
|
|
175
|
+
const backoffUntil = this.connectionBackoff.get(accountId);
|
|
176
|
+
if (backoffUntil && Date.now() < backoffUntil) {
|
|
177
|
+
throw new Error(`Account ${accountId} in connection backoff (${Math.round((backoffUntil - Date.now()) / 1000)}s remaining)`);
|
|
178
|
+
}
|
|
172
179
|
const config = this.configs.get(accountId);
|
|
173
180
|
if (!config)
|
|
174
181
|
throw new Error(`No config for account ${accountId}`);
|
|
@@ -457,15 +464,23 @@ export class ImapManager extends EventEmitter {
|
|
|
457
464
|
}
|
|
458
465
|
}
|
|
459
466
|
catch (e) {
|
|
460
|
-
|
|
461
|
-
|
|
467
|
+
const errMsg = imapError(e);
|
|
468
|
+
this.emit("syncError", accountId, errMsg);
|
|
469
|
+
console.error(`Sync error for ${accountId}: ${errMsg}`);
|
|
470
|
+
// Connection limit — back off for 60 seconds
|
|
471
|
+
if (errMsg.includes("max_userip_connections") || errMsg.includes("Too many simultaneous")) {
|
|
472
|
+
this.connectionBackoff.set(accountId, Date.now() + 60000);
|
|
473
|
+
console.log(` [backoff] ${accountId}: connection limit hit, backing off 60s`);
|
|
474
|
+
}
|
|
462
475
|
// Emit user-facing error once per account per session
|
|
463
476
|
if (!this.accountErrorShown.has(accountId)) {
|
|
464
477
|
this.accountErrorShown.add(accountId);
|
|
465
478
|
const config = this.configs.get(accountId);
|
|
466
479
|
const isOAuth = !!config?.tokenProvider;
|
|
467
|
-
const hint =
|
|
468
|
-
|
|
480
|
+
const hint = errMsg.includes("max_userip_connections") || errMsg.includes("Too many")
|
|
481
|
+
? "Too many connections — backing off"
|
|
482
|
+
: isOAuth ? "Authentication may have expired" : "Check server connectivity";
|
|
483
|
+
this.emit("accountError", accountId, errMsg, hint);
|
|
469
484
|
}
|
|
470
485
|
}
|
|
471
486
|
finally {
|
|
@@ -208,6 +208,17 @@ imapManager.on("accountError", (accountId, error, hint) => {
|
|
|
208
208
|
});
|
|
209
209
|
// ── Startup ──
|
|
210
210
|
async function start() {
|
|
211
|
+
// Check if another instance is already running on this port
|
|
212
|
+
const net = await import("node:net");
|
|
213
|
+
const portInUse = await new Promise((resolve) => {
|
|
214
|
+
const s = net.createConnection({ port: PORT, host: "127.0.0.1" });
|
|
215
|
+
s.once("connect", () => { s.destroy(); resolve(true); });
|
|
216
|
+
s.once("error", () => resolve(false));
|
|
217
|
+
});
|
|
218
|
+
if (portInUse) {
|
|
219
|
+
console.log(`mailx server already running on port ${PORT} — exiting`);
|
|
220
|
+
process.exit(0);
|
|
221
|
+
}
|
|
211
222
|
console.log("mailx server starting...");
|
|
212
223
|
// Seed contacts (fast — skips existing)
|
|
213
224
|
const seeded = db.seedContactsFromMessages();
|