@bobfrankston/mailx 1.0.85 → 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
- for (const line of resp) {
136
- if (!line.startsWith("* ") || !line.includes("FETCH"))
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 = line.match(/UID\s+(\d+)/)?.[1];
139
- const flags = line.match(/FLAGS\s+\(([^)]*)\)/)?.[1] || "";
140
- const size = line.match(/RFC822\.SIZE\s+(\d+)/)?.[1] || "0";
141
- const dateMatch = line.match(/INTERNALDATE\s+"([^"]+)"/);
142
- // Simplified envelope parsing extract subject from the ENVELOPE
143
- const envMatch = line.match(/ENVELOPE\s+\(/);
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
- if (envMatch) {
146
- // Very simplified — real parsing would need the full tokenizer
147
- const subMatch = line.match(/ENVELOPE\s+\("[^"]*"\s+"([^"]*)"/);
148
- if (subMatch)
149
- subject = subMatch[1];
150
- }
151
- if (uid) {
152
- messages.push({
153
- uid: parseInt(uid),
154
- flags: flags.split(/\s+/).filter(Boolean),
155
- size: parseInt(size),
156
- date: dateMatch ? new Date(dateMatch[1]).getTime() : Date.now(),
157
- subject,
158
- fromName, fromAddr, messageId,
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.85",
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.38",
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",
@@ -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();