@bobfrankston/mailx 1.0.85 → 1.0.88
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/client/lib/local-service.js +167 -25
- package/package.json +2 -2
|
@@ -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.88",
|
|
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",
|