@bobfrankston/mailx 1.0.212 → 1.0.214

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.
@@ -1 +1 @@
1
- {"height":1344,"width":2151,"x":380,"y":46}
1
+ {"height":1344,"width":2151,"x":304,"y":36}
@@ -37,23 +37,90 @@ export function initViewer() {
37
37
  // "messages" change (sync reload) — don't touch the viewer
38
38
  });
39
39
  }
40
- /** Re-dispatch keydown events from iframe contentDocument onto the parent document
41
- * so global shortcuts (Delete, Ctrl+D, Ctrl+R, etc.) fire while the preview has focus. */
42
- function forwardKeysToParent(iframe) {
40
+ // Zoom is persisted across messages via localStorage
41
+ const ZOOM_KEY = "mailx-preview-zoom";
42
+ const ZOOM_MIN = 0.5;
43
+ const ZOOM_MAX = 3.0;
44
+ const ZOOM_STEP = 0.1;
45
+ let previewZoom = clampZoom(parseFloat(localStorage.getItem(ZOOM_KEY) || "1"));
46
+ function clampZoom(z) {
47
+ if (!Number.isFinite(z) || z <= 0)
48
+ return 1;
49
+ return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.round(z * 100) / 100));
50
+ }
51
+ function applyZoom(doc) {
52
+ if (doc.body)
53
+ doc.body.style.zoom = String(previewZoom);
54
+ }
55
+ function setZoom(z, doc) {
56
+ previewZoom = clampZoom(z);
57
+ localStorage.setItem(ZOOM_KEY, String(previewZoom));
58
+ applyZoom(doc);
59
+ }
60
+ /** Install preview iframe controls: key forwarding to parent, Ctrl+wheel zoom,
61
+ * keyboard zoom shortcuts (Ctrl+= / Ctrl+- / Ctrl+0), and the right-click menu. */
62
+ function installPreviewControls(iframe) {
43
63
  const attach = () => {
44
64
  const doc = iframe.contentDocument;
45
65
  if (!doc)
46
66
  return;
67
+ applyZoom(doc);
47
68
  doc.addEventListener("keydown", (e) => {
48
69
  const target = e.target;
49
70
  if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)))
50
71
  return;
72
+ if (e.ctrlKey && (e.key === "=" || e.key === "+")) {
73
+ e.preventDefault();
74
+ setZoom(previewZoom + ZOOM_STEP, doc);
75
+ return;
76
+ }
77
+ if (e.ctrlKey && e.key === "-") {
78
+ e.preventDefault();
79
+ setZoom(previewZoom - ZOOM_STEP, doc);
80
+ return;
81
+ }
82
+ if (e.ctrlKey && e.key === "0") {
83
+ e.preventDefault();
84
+ setZoom(1, doc);
85
+ return;
86
+ }
51
87
  document.dispatchEvent(new KeyboardEvent("keydown", {
52
88
  key: e.key, code: e.code,
53
89
  ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
54
90
  bubbles: true, cancelable: true,
55
91
  }));
56
92
  });
93
+ doc.addEventListener("wheel", (e) => {
94
+ if (!e.ctrlKey)
95
+ return;
96
+ e.preventDefault();
97
+ setZoom(previewZoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP), doc);
98
+ }, { passive: false });
99
+ doc.addEventListener("contextmenu", (e) => {
100
+ e.preventDefault();
101
+ const me = e;
102
+ const rect = iframe.getBoundingClientRect();
103
+ const x = rect.left + me.clientX;
104
+ const y = rect.top + me.clientY;
105
+ const pct = Math.round(previewZoom * 100);
106
+ const items = [
107
+ { label: "Copy", action: () => doc.execCommand("copy") },
108
+ { label: "Select all", action: () => {
109
+ const sel = doc.defaultView?.getSelection();
110
+ if (!sel)
111
+ return;
112
+ const range = doc.createRange();
113
+ range.selectNodeContents(doc.body);
114
+ sel.removeAllRanges();
115
+ sel.addRange(range);
116
+ } },
117
+ { label: "", action: () => { }, separator: true },
118
+ { label: "Zoom in", action: () => setZoom(previewZoom + ZOOM_STEP, doc) },
119
+ { label: "Zoom out", action: () => setZoom(previewZoom - ZOOM_STEP, doc) },
120
+ { label: `Reset zoom (${pct}%)`, action: () => setZoom(1, doc) },
121
+ ];
122
+ showContextMenu(x, y, items);
123
+ });
57
124
  };
58
125
  if (iframe.contentDocument?.readyState === "complete")
59
126
  attach();
@@ -274,7 +341,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
274
341
  const iframe = document.createElement("iframe");
275
342
  iframe.srcdoc = wrapHtmlBody(full.bodyHtml, true);
276
343
  bodyEl.appendChild(iframe);
277
- forwardKeysToParent(iframe);
344
+ installPreviewControls(iframe);
278
345
  }
279
346
  };
280
347
  banner.querySelector("#btn-load-remote").addEventListener("click", loadRemote);
@@ -303,7 +370,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
303
370
  iframe.sandbox.add("allow-top-navigation-by-user-activation");
304
371
  iframe.srcdoc = wrapHtmlBody(msg.bodyHtml, msg.remoteAllowed);
305
372
  bodyEl.appendChild(iframe);
306
- forwardKeysToParent(iframe);
373
+ installPreviewControls(iframe);
307
374
  }
308
375
  else if (msg.bodyText) {
309
376
  const pre = document.createElement("pre");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.212",
3
+ "version": "1.0.214",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.2",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.22",
27
- "@bobfrankston/msger": "^0.1.274",
27
+ "@bobfrankston/msger": "^0.1.276",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -78,7 +78,7 @@
78
78
  "@bobfrankston/iflow-node": "^0.1.2",
79
79
  "@bobfrankston/miscinfo": "^1.0.8",
80
80
  "@bobfrankston/oauthsupport": "^1.0.22",
81
- "@bobfrankston/msger": "^0.1.274",
81
+ "@bobfrankston/msger": "^0.1.276",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -346,7 +346,7 @@ export class ImapManager extends EventEmitter {
346
346
  const tokenDir = path.join(getConfigDir(), "tokens", account.imap.user.replace(/[@.]/g, "_"));
347
347
  tokenProvider = async () => {
348
348
  const result = await authenticateOAuth(credPath, {
349
- scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly",
349
+ scope: "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/calendar",
350
350
  tokenDirectory: tokenDir,
351
351
  credentialsKey: "installed",
352
352
  loginHint: account.imap.user,
@@ -360,6 +360,9 @@ export class ImapManager extends EventEmitter {
360
360
  username: account.imap.user,
361
361
  password: account.imap.password,
362
362
  tokenProvider,
363
+ // Slow Dovecot servers (e.g. iecc.com) can stall >60s during multi-body FETCH.
364
+ // Raise the inactivity timeout so the connection isn't dropped mid-stream.
365
+ inactivityTimeout: 180000,
363
366
  });
364
367
  this.configs.set(account.id, config);
365
368
  // Register account in DB
@@ -1731,19 +1734,36 @@ export class ImapManager extends EventEmitter {
1731
1734
  return;
1732
1735
  // Gmail/API accounts: send directly via SMTP from local queue (no IMAP outbox)
1733
1736
  const sentDir = path.join(getConfigDir(), "sending", accountId, "sent");
1737
+ const failedDir = path.join(getConfigDir(), "sending", accountId, "failed");
1734
1738
  fs.mkdirSync(sentDir, { recursive: true });
1739
+ fs.mkdirSync(failedDir, { recursive: true });
1735
1740
  if (this.isGmailAccount(accountId)) {
1736
1741
  for (const { dir, file } of filesToSend) {
1737
1742
  const filePath = path.join(dir, file);
1738
1743
  const raw = fs.readFileSync(filePath, "utf-8");
1739
1744
  try {
1740
1745
  await this.sendRawViaSMTP(accountId, raw);
1741
- // Move to sent/
1742
1746
  fs.renameSync(filePath, path.join(sentDir, file));
1743
1747
  console.log(` [outbox] Sent ${file} via SMTP → sent/`);
1744
1748
  }
1745
1749
  catch (e) {
1746
- console.error(` [outbox] Send failed for ${file}: ${e.message}`);
1750
+ // Critical: NEVER leave a .ltr in the queue after a send attempt unless the
1751
+ // failure was clearly pre-connect. An error after the SMTP session opened may
1752
+ // mean the server actually accepted DATA but we lost the ack — retrying would
1753
+ // double-send. Classify conservatively: only keep-in-queue for clearly
1754
+ // network-level / pre-auth errors.
1755
+ const code = String(e?.code || "");
1756
+ const cmd = String(e?.command || "").toUpperCase();
1757
+ const preConnect = /^(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNECTION|ETIMEDOUT)$/.test(code)
1758
+ && (!cmd || cmd === "CONN");
1759
+ if (preConnect) {
1760
+ console.error(` [outbox] Pre-connect failure for ${file} (${code}), will retry: ${e.message}`);
1761
+ }
1762
+ else {
1763
+ // Ambiguous or terminal — move to failed/ so we don't resend if SMTP actually delivered
1764
+ fs.renameSync(filePath, path.join(failedDir, file));
1765
+ console.error(` [outbox] Send failed for ${file} → failed/ (no auto-retry, code=${code || "?"}): ${e.message}`);
1766
+ }
1747
1767
  }
1748
1768
  }
1749
1769
  return;
@@ -1939,12 +1959,21 @@ export class ImapManager extends EventEmitter {
1939
1959
  this.outboxBackoffDelay.delete(accountId);
1940
1960
  }
1941
1961
  catch (e) {
1942
- // Exponential backoff: 60s 120s 300s (max 5min)
1943
- const prevDelay = this.outboxBackoffDelay.get(accountId) || 0;
1944
- const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 60000;
1945
- this.outboxBackoffDelay.set(accountId, delay);
1946
- this.outboxBackoff.set(accountId, now + delay);
1947
- console.error(` [outbox] Error for ${accountId}: ${imapError(e)} (retry in ${Math.round(delay / 1000)}s)`);
1962
+ // Stale-socket errors (Dovecot silently drops idle connections):
1963
+ // don't back off — just reconnect on the next tick. The 300s
1964
+ // backoff is meant for real auth/network failures, not dead sockets.
1965
+ const msg = String(e?.message || e);
1966
+ if (/Not connected|ECONNRESET|socket hang up|EPIPE|write after end/i.test(msg)) {
1967
+ console.error(` [outbox] Stale connection for ${accountId}: ${msg} will retry next tick`);
1968
+ }
1969
+ else {
1970
+ // Exponential backoff: 60s → 120s → 300s (max 5min)
1971
+ const prevDelay = this.outboxBackoffDelay.get(accountId) || 0;
1972
+ const delay = prevDelay ? Math.min(prevDelay * 2, 300000) : 60000;
1973
+ this.outboxBackoffDelay.set(accountId, delay);
1974
+ this.outboxBackoff.set(accountId, now + delay);
1975
+ console.error(` [outbox] Error for ${accountId}: ${imapError(e)} (retry in ${Math.round(delay / 1000)}s)`);
1976
+ }
1948
1977
  }
1949
1978
  }
1950
1979
  };
@@ -38,6 +38,13 @@ function emitEvent(event) {
38
38
  function toEmailAddress(addr) {
39
39
  return { name: addr?.name || "", address: addr?.address || "" };
40
40
  }
41
+ /** Verbose log — goes to logit but doesn't clutter the screen (silent=true) */
42
+ function vlog(msg) {
43
+ try {
44
+ fetch(`https://rmf39.aaz.lt/logit/${encodeURIComponent("V/" + msg.substring(0, 800))}?log=mailx-android&silent=true`).catch(() => { });
45
+ }
46
+ catch { /* ignore */ }
47
+ }
41
48
  // ── Sync Manager ──
42
49
  class AndroidSyncManager {
43
50
  db;
@@ -51,6 +58,7 @@ class AndroidSyncManager {
51
58
  on(_event, _handler) { }
52
59
  emit(event, ...args) { emitEvent({ type: event, ...args[0] }); }
53
60
  async addAccount(account) {
61
+ vlog(`addAccount id=${account.id} email=${account.email} host=${account.imap?.host} auth=${account.imap?.auth}`);
54
62
  this.db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
55
63
  if (this.isGmailAccount(account)) {
56
64
  const tokenProvider = this.tokenProviders.get(account.id);
@@ -62,6 +70,9 @@ class AndroidSyncManager {
62
70
  console.warn(`[sync] ${account.id}: no token provider`);
63
71
  }
64
72
  }
73
+ else {
74
+ vlog(`addAccount ${account.id}: NOT a Gmail account — needs IMAP via TCP bridge (not yet implemented)`);
75
+ }
65
76
  }
66
77
  setTokenProvider(accountId, provider) {
67
78
  this.tokenProviders.set(accountId, provider);
@@ -74,9 +85,13 @@ class AndroidSyncManager {
74
85
  }
75
86
  async syncAll() {
76
87
  const accounts = this.db.getAccounts();
88
+ vlog(`syncAll: ${accounts.length} accounts in DB: ${accounts.map(a => a.id).join(",")}`);
77
89
  for (const account of accounts) {
90
+ const hasProvider = this.providers.has(account.id);
91
+ vlog(`syncAll: ${account.id} hasProvider=${hasProvider}`);
78
92
  try {
79
93
  const folders = await this.syncFolders(account.id);
94
+ vlog(`syncAll: ${account.id} got ${folders.length} folders`);
80
95
  const sorted = [...folders].sort((a, b) => {
81
96
  if (a.specialUse === "inbox")
82
97
  return -1;
@@ -97,14 +112,18 @@ class AndroidSyncManager {
97
112
  }
98
113
  catch (e) {
99
114
  console.error(`[sync] ${account.id}: ${e.message}`);
115
+ vlog(`syncAll: ${account.id} ERROR: ${e.message}`);
100
116
  emitEvent({ type: "syncError", accountId: account.id, error: e.message });
101
117
  }
102
118
  }
103
119
  }
104
120
  async syncFolders(accountId) {
105
121
  const provider = this.getProvider(accountId);
106
- if (!provider)
107
- return this.db.getFolders(accountId);
122
+ if (!provider) {
123
+ const existing = this.db.getFolders(accountId);
124
+ vlog(`syncFolders: ${accountId} no provider, returning ${existing.length} cached folders`);
125
+ return existing;
126
+ }
108
127
  emitEvent({ type: "syncProgress", accountId, phase: "folders", progress: 0 });
109
128
  const providerFolders = await provider.listFolders();
110
129
  for (const folder of providerFolders) {
@@ -269,7 +288,7 @@ const OAUTH_CLIENT = {
269
288
  // Use full drive scope so we can read/write the desktop's accounts.jsonc + clients.jsonc.
270
289
  // drive.file is per-consent-grant: files created by desktop's grant aren't visible to Android's grant
271
290
  // even with the same client_id. drive (full) lets us see all files the user has access to.
272
- const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive";
291
+ const OAUTH_SCOPES = "https://mail.google.com/ https://www.googleapis.com/auth/contacts.readonly https://www.googleapis.com/auth/drive https://www.googleapis.com/auth/calendar";
273
292
  // ── Token cache (IndexedDB) ──
274
293
  async function getCachedToken(email) {
275
294
  const key = `oauth-token-${email.replace(/[@.]/g, "_")}`;
@@ -546,8 +565,11 @@ export async function initAndroid() {
546
565
  // Use canonical GDrive accounts (upsert handles overwrites)
547
566
  accounts = gdriveAccounts;
548
567
  for (const account of accounts) {
549
- if (!account.enabled)
568
+ vlog(`init: registering ${account.id} email=${account.email} enabled=${account.enabled} imap=${JSON.stringify(account.imap)}`);
569
+ if (!account.enabled) {
570
+ vlog(`init: ${account.id} disabled, skipping`);
550
571
  continue;
572
+ }
551
573
  const domain = account.email?.split("@")[1]?.toLowerCase() || "";
552
574
  if (domain === "gmail.com" || domain === "googlemail.com") {
553
575
  syncManager.setTokenProvider(account.id, createNativeTokenProvider(account.email));
@@ -134,29 +134,62 @@ function parseMimeParts(body, boundary, topHeaders) {
134
134
  });
135
135
  }
136
136
  else if (partType.includes("text/html")) {
137
- html = decodeBody(partBody, partEncoding);
137
+ const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
138
+ html = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8");
138
139
  }
139
140
  else if (partType.includes("text/plain")) {
140
- text = decodeBody(partBody, partEncoding);
141
+ const charsetMatch = partType.match(/charset="?([^";\s]+)"?/i);
142
+ text = decodeBody(partBody, partEncoding, charsetMatch?.[1] || "utf-8");
141
143
  }
142
144
  }
143
145
  return { html, text, headers: topHeaders, attachments };
144
146
  }
145
- function decodeBody(body, encoding) {
147
+ function decodeBody(body, encoding, charset = "utf-8") {
148
+ // Step 1: decode the transfer encoding to a byte array
149
+ let bytes;
146
150
  if (encoding === "base64") {
147
151
  try {
148
- return atob(body.replace(/\s/g, ""));
152
+ const binary = atob(body.replace(/\s/g, ""));
153
+ bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
149
154
  }
150
155
  catch {
151
156
  return body;
152
157
  }
153
158
  }
154
- if (encoding === "quoted-printable") {
155
- return body
156
- .replace(/=\r?\n/g, "")
157
- .replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
159
+ else if (encoding === "quoted-printable") {
160
+ // Decode QP into bytes (NOT into a string — multi-byte UTF-8 must stay as bytes)
161
+ const cleaned = body.replace(/=\r?\n/g, "");
162
+ const out = [];
163
+ for (let i = 0; i < cleaned.length; i++) {
164
+ const c = cleaned[i];
165
+ if (c === "=" && i + 2 < cleaned.length && /[0-9A-Fa-f]{2}/.test(cleaned.substr(i + 1, 2))) {
166
+ out.push(parseInt(cleaned.substr(i + 1, 2), 16));
167
+ i += 2;
168
+ }
169
+ else {
170
+ // Existing character — encode as its byte (assumes ASCII for QP source)
171
+ out.push(c.charCodeAt(0) & 0xff);
172
+ }
173
+ }
174
+ bytes = new Uint8Array(out);
175
+ }
176
+ else if (encoding === "7bit" || encoding === "8bit" || encoding === "" || encoding === "binary") {
177
+ // No transfer encoding — body is already a string of single-byte chars
178
+ bytes = Uint8Array.from(body, c => c.charCodeAt(0) & 0xff);
179
+ }
180
+ else {
181
+ // Unknown encoding — return as-is
182
+ return body;
183
+ }
184
+ // Step 2: decode bytes using the declared charset (default UTF-8)
185
+ try {
186
+ const normalized = charset.toLowerCase().replace("windows-", "windows-").replace("iso-", "iso-");
187
+ return new TextDecoder(normalized).decode(bytes);
188
+ }
189
+ catch {
190
+ // Unknown charset — fall back to UTF-8 with replacement chars
191
+ return new TextDecoder("utf-8").decode(bytes);
158
192
  }
159
- return body;
160
193
  }
161
194
  // ── Quoted-printable encoding (for compose/send) ──
162
195
  function encodeQuotedPrintable(text) {