@bobfrankston/mailx 1.0.333 → 1.0.338

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/app.js CHANGED
@@ -482,27 +482,15 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
482
482
  });
483
483
  async function openCompose(mode) {
484
484
  const current = getCurrentMessage();
485
- // Reply / Reply-All / Forward all need an original message to populate
486
- // From, To, Subject, and the quoted body. Two failure modes used to
487
- // silently produce a blank compose:
488
- // (1) getCurrentMessage() returns null viewer still loading, message
489
- // cleared mid-folder-switch, or fetch failed.
490
- // (2) currentMessage is set but is a stub header metadata arrived
491
- // but body / from / subject haven't been populated yet.
492
- // Bail out in both cases instead of opening an empty form.
493
- if (mode === "reply" || mode === "replyAll" || mode === "forward") {
494
- const m = current?.message;
495
- const stubReason = !current ? "no current message" :
496
- !m?.from ? "msg.from missing" :
497
- !m?.subject && m?.subject !== "" ? "msg.subject missing" :
498
- (mode !== "forward" && !m?.messageId) ? "msg.messageId missing (can't thread reply)" :
499
- null;
500
- if (stubReason) {
501
- console.warn(`[compose] ${mode} ignored — ${stubReason}; current=`, current);
502
- alert(`Cannot ${mode === "forward" ? "forward" : "reply to"} this message yet — ` +
503
- `it's still loading (${stubReason}). Please wait a moment and try again.`);
504
- return;
505
- }
485
+ // Local-first: if the row is selected we already have its headers in the
486
+ // local DB. Populate the compose form unconditionally; the user can edit
487
+ // anything missing. Don't show "still loading" alerts — the message IS
488
+ // loaded (it's in the list), body is a separate fetch that isn't needed
489
+ // for Reply's headers. Missing fields become empty strings.
490
+ if ((mode === "reply" || mode === "replyAll" || mode === "forward") && !current) {
491
+ // Only true blocker: no message selected at all.
492
+ console.warn(`[compose] ${mode} no message selected`);
493
+ return;
506
494
  }
507
495
  const accounts = await getAccounts();
508
496
  const accountId = current?.accountId || accounts[0]?.id || "";
@@ -554,21 +542,31 @@ async function openCompose(mode) {
554
542
  console.log(`[compose] no identity match`);
555
543
  return undefined;
556
544
  }
545
+ // Defensive: msg.from / msg.to may be missing on rows that arrived before
546
+ // headers finished loading. Don't push undefined into init.to — that
547
+ // bubbles to the compose form as literal "undefined". Empty-out gracefully.
557
548
  if (msg && mode === "reply") {
558
- init.to = [msg.from];
549
+ init.to = msg.from ? [msg.from] : [];
559
550
  init.subject = `Re: ${cleanSubject}`;
560
551
  init.bodyHtml = quoteBody(msg);
561
- init.inReplyTo = msg.messageId;
562
- init.references = [...(msg.references || []), msg.messageId];
552
+ init.inReplyTo = msg.messageId || "";
553
+ init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
563
554
  init.fromAddress = detectReplyFrom();
564
555
  }
565
556
  else if (msg && mode === "replyAll") {
566
- init.to = [msg.from, ...msg.to.filter((a) => a.address !== msg.from.address)];
567
- init.cc = msg.cc || [];
557
+ const toList = msg.from ? [msg.from] : [];
558
+ if (Array.isArray(msg.to)) {
559
+ for (const a of msg.to) {
560
+ if (a?.address && a.address !== msg.from?.address)
561
+ toList.push(a);
562
+ }
563
+ }
564
+ init.to = toList;
565
+ init.cc = Array.isArray(msg.cc) ? msg.cc : [];
568
566
  init.subject = `Re: ${cleanSubject}`;
569
567
  init.bodyHtml = quoteBody(msg);
570
- init.inReplyTo = msg.messageId;
571
- init.references = [...(msg.references || []), msg.messageId];
568
+ init.inReplyTo = msg.messageId || "";
569
+ init.references = [...(msg.references || []), msg.messageId].filter(Boolean);
572
570
  init.fromAddress = detectReplyFrom();
573
571
  }
574
572
  else if (msg && mode === "forward") {
@@ -80,6 +80,45 @@ await loadEditorAssets(editorType);
80
80
  const container = document.getElementById("compose-editor");
81
81
  container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
82
82
  const editor = await createEditor(container, editorType);
83
+ // Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
84
+ // Persists per-session in localStorage so zoom survives window pop/close cycles.
85
+ (() => {
86
+ const STORAGE_KEY = "mailx.compose.zoom";
87
+ const MIN = 0.5, MAX = 3, STEP = 0.1;
88
+ let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1") || 1;
89
+ const applyZoom = () => {
90
+ container.style.fontSize = `${zoom}em`;
91
+ localStorage.setItem(STORAGE_KEY, String(zoom));
92
+ };
93
+ applyZoom();
94
+ container.addEventListener("wheel", (e) => {
95
+ if (!e.ctrlKey)
96
+ return;
97
+ e.preventDefault();
98
+ const delta = e.deltaY < 0 ? STEP : -STEP;
99
+ zoom = Math.min(MAX, Math.max(MIN, Math.round((zoom + delta) * 10) / 10));
100
+ applyZoom();
101
+ }, { passive: false });
102
+ document.addEventListener("keydown", (e) => {
103
+ if (!(e.ctrlKey || e.metaKey))
104
+ return;
105
+ if (e.key === "=" || e.key === "+") {
106
+ zoom = Math.min(MAX, zoom + STEP);
107
+ applyZoom();
108
+ e.preventDefault();
109
+ }
110
+ else if (e.key === "-") {
111
+ zoom = Math.max(MIN, zoom - STEP);
112
+ applyZoom();
113
+ e.preventDefault();
114
+ }
115
+ else if (e.key === "0") {
116
+ zoom = 1;
117
+ applyZoom();
118
+ e.preventDefault();
119
+ }
120
+ });
121
+ })();
83
122
  // ── Populate from init data ──
84
123
  // From field is a free-text input with a <datalist> of known accounts. The
85
124
  // user can pick a preset or type an arbitrary "Name <addr@domain>" — no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.333",
3
+ "version": "1.0.338",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -39,7 +39,7 @@
39
39
  "@bobfrankston/tcp-transport": "^0.1.4",
40
40
  "@bobfrankston/node-tcp-transport": "^0.1.4",
41
41
  "@bobfrankston/smtp-direct": "^0.1.4",
42
- "@bobfrankston/mailx-sync": "^0.1.7"
42
+ "@bobfrankston/mailx-sync": "^0.1.8"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/mailparser": "^3.4.6"
@@ -103,7 +103,7 @@
103
103
  "@bobfrankston/tcp-transport": "^0.1.4",
104
104
  "@bobfrankston/node-tcp-transport": "^0.1.4",
105
105
  "@bobfrankston/smtp-direct": "^0.1.4",
106
- "@bobfrankston/mailx-sync": "^0.1.7"
106
+ "@bobfrankston/mailx-sync": "^0.1.8"
107
107
  }
108
108
  }
109
109
  }
@@ -187,7 +187,11 @@ export declare class ImapManager extends EventEmitter {
187
187
  uid: number;
188
188
  folderId: number;
189
189
  }[]): Promise<void>;
190
- /** Bulk move messages — local-first, single IMAP connection for all */
190
+ /** Bulk move messages — queues the IMAP action only. The service layer
191
+ * (MailxService.moveMessages) owns the local DB mutation via
192
+ * updateMessageFolder; this method used to ALSO deleteMessage here,
193
+ * which wiped the row the service just updated — the message vanished
194
+ * on the next reconcile and "spam folder empty" was the symptom. */
191
195
  moveMessages(accountId: string, messages: {
192
196
  uid: number;
193
197
  folderId: number;
@@ -197,7 +201,8 @@ export declare class ImapManager extends EventEmitter {
197
201
  private debounceSyncActions;
198
202
  /** Move a message to Trash (delete) — local-first, queues IMAP sync */
199
203
  trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
200
- /** Move a message between folders — local-first, queues IMAP sync */
204
+ /** Move a message between folders — queues IMAP sync only. Service
205
+ * layer owns the local DB update (see MailxService.moveMessage). */
201
206
  moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void>;
202
207
  /** Move message across accounts using iflow's moveMessageToServer */
203
208
  moveMessageCrossAccount(fromAccountId: string, uid: number, fromFolderId: number, toAccountId: string, toFolderId: number): Promise<void>;
@@ -218,7 +223,13 @@ export declare class ImapManager extends EventEmitter {
218
223
  * Tries the specific UID first, then falls back to searchByHeader so orphaned copies
219
224
  * from earlier failed autosaves are cleaned up at the same time. */
220
225
  deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
221
- /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP */
226
+ /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
227
+ * Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
228
+ * kick processLocalQueue. The file IS the queue — durable across crashes,
229
+ * visible in the filesystem, consumed by the existing outbox worker that
230
+ * handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
231
+ * sync_actions "send" branch was removed because it duplicated the same
232
+ * work and risked double-send when both paths fired on the same message. */
222
233
  queueOutgoingLocal(accountId: string, rawMessage: string): void;
223
234
  /** Guard against concurrent processSendActions for the same account */
224
235
  private sendingAccounts;
@@ -2052,27 +2052,18 @@ export class ImapManager extends EventEmitter {
2052
2052
  // Process all queued actions in one IMAP session
2053
2053
  this.debounceSyncActions(accountId);
2054
2054
  }
2055
- /** Bulk move messages — local-first, single IMAP connection for all */
2055
+ /** Bulk move messages — queues the IMAP action only. The service layer
2056
+ * (MailxService.moveMessages) owns the local DB mutation via
2057
+ * updateMessageFolder; this method used to ALSO deleteMessage here,
2058
+ * which wiped the row the service just updated — the message vanished
2059
+ * on the next reconcile and "spam folder empty" was the symptom. */
2056
2060
  async moveMessages(accountId, messages, targetFolderId) {
2057
2061
  if (messages.length === 0)
2058
2062
  return;
2059
- // Local first
2060
- for (const msg of messages) {
2061
- this.db.deleteMessage(accountId, msg.uid);
2062
- }
2063
- console.log(` Moved ${messages.length} messages locally (→ folder ${targetFolderId})`);
2064
- // Queue IMAP actions
2065
2063
  for (const msg of messages) {
2066
2064
  this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
2067
2065
  }
2068
- // Recalc folder counts (source folders + destination) so the tree
2069
- // badge updates immediately.
2070
- const sourceFolderIds = new Set(messages.map(m => m.folderId));
2071
- for (const fid of sourceFolderIds)
2072
- this.db.recalcFolderCounts(fid);
2073
- this.db.recalcFolderCounts(targetFolderId);
2074
- this.emit("folderCountsChanged", accountId, {});
2075
- // Process all queued actions in one IMAP session
2066
+ console.log(` [move] ${accountId}: queued IMAP MOVE for ${messages.length} message(s) folder ${targetFolderId}`);
2076
2067
  this.debounceSyncActions(accountId);
2077
2068
  }
2078
2069
  /** Debounced sync actions — batches rapid local changes into one IMAP operation */
@@ -2092,25 +2083,25 @@ export class ImapManager extends EventEmitter {
2092
2083
  // Local first — remove from DB immediately
2093
2084
  this.db.deleteMessage(accountId, uid);
2094
2085
  this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
2095
- console.log(` Deleted message UID ${uid} locally`);
2096
- // Queue IMAP action
2086
+ // Queue IMAP action + log the resolution so "I deleted a message and
2087
+ // now it's in neither trash nor deleted" is diagnosable from the log.
2097
2088
  if (trash && trash.id !== folderId) {
2089
+ const trashFolder = this.db.getFolders(accountId).find(f => f.id === trash.id);
2098
2090
  this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId: trash.id });
2091
+ console.log(` [trash] ${accountId} UID ${uid}: queued MOVE to "${trashFolder?.path || trash.path}" (id=${trash.id}, specialUse=trash)`);
2099
2092
  }
2100
2093
  else {
2101
2094
  this.db.queueSyncAction(accountId, "delete", uid, folderId);
2095
+ console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
2102
2096
  }
2103
2097
  // Debounced sync — batches multiple deletes into one IMAP session
2104
2098
  this.debounceSyncActions(accountId);
2105
2099
  }
2106
- /** Move a message between folders — local-first, queues IMAP sync */
2100
+ /** Move a message between folders — queues IMAP sync only. Service
2101
+ * layer owns the local DB update (see MailxService.moveMessage). */
2107
2102
  async moveMessage(accountId, uid, fromFolderId, toFolderId) {
2108
- // Local first
2109
- this.db.deleteMessage(accountId, uid);
2110
- console.log(` Moved UID ${uid} locally (folder ${fromFolderId} → ${toFolderId})`);
2111
- // Queue IMAP action
2112
2103
  this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
2113
- // Debounced sync batches multiple moves into one IMAP session
2104
+ console.log(` [move] ${accountId}: queued IMAP MOVE UID ${uid} folder ${fromFolderId} ${toFolderId}`);
2114
2105
  this.debounceSyncActions(accountId);
2115
2106
  }
2116
2107
  /** Move message across accounts using iflow's moveMessageToServer */
@@ -2221,13 +2212,26 @@ export class ImapManager extends EventEmitter {
2221
2212
  break;
2222
2213
  case "move": {
2223
2214
  const target = folders.find(f => f.id === action.targetFolderId);
2224
- if (target) {
2225
- const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
2226
- if (msg) {
2227
- await client.moveMessage(msg, folder.path, target.path);
2228
- console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
2229
- }
2215
+ if (!target) {
2216
+ // Target folder gone treat as permanent failure so the
2217
+ // action doesn't loop forever. User must re-delete manually.
2218
+ console.error(` [sync] Move target folder ${action.targetFolderId} missing — dropping action UID ${action.uid}`);
2219
+ throw new Error(`move target folder ${action.targetFolderId} not found`);
2230
2220
  }
2221
+ const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
2222
+ if (!msg) {
2223
+ // Message no longer in source folder. Two real cases:
2224
+ // (a) another client already moved/deleted it — nothing to do,
2225
+ // just mark the action done.
2226
+ // (b) the server is lying (transient SELECT miss) — the retry
2227
+ // will pick it up. We can't tell these apart from one fetch,
2228
+ // so log loud and treat as (a) after the first failure; the
2229
+ // attempts counter handles (b) via the failSyncAction path.
2230
+ console.log(` [sync] Move UID ${action.uid} in ${folder.path}: message gone (attempt ${action.attempts + 1}); dropping action`);
2231
+ break;
2232
+ }
2233
+ await client.moveMessage(msg, folder.path, target.path);
2234
+ console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
2231
2235
  break;
2232
2236
  }
2233
2237
  case "flags":
@@ -2409,13 +2413,23 @@ export class ImapManager extends EventEmitter {
2409
2413
  catch { /* ignore */ }
2410
2414
  }
2411
2415
  }
2412
- /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP */
2416
+ /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
2417
+ * Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
2418
+ * kick processLocalQueue. The file IS the queue — durable across crashes,
2419
+ * visible in the filesystem, consumed by the existing outbox worker that
2420
+ * handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
2421
+ * sync_actions "send" branch was removed because it duplicated the same
2422
+ * work and risked double-send when both paths fired on the same message. */
2413
2423
  queueOutgoingLocal(accountId, rawMessage) {
2414
- // Use folderId=0 and uid=Date.now() as placeholder — the worker will handle the real IMAP append
2415
- this.db.queueSyncAction(accountId, "send", Date.now(), 0, { rawMessage });
2416
- console.log(` [outbox] Queued locally for ${accountId}`);
2417
- // Try immediate processing
2418
- this.processSendActions(accountId).catch(() => { });
2424
+ const outboxDir = path.join(getConfigDir(), "outbox", accountId);
2425
+ fs.mkdirSync(outboxDir, { recursive: true });
2426
+ const now = new Date();
2427
+ const pad2 = (n) => String(n).padStart(2, "0");
2428
+ const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
2429
+ const filePath = path.join(outboxDir, filename);
2430
+ fs.writeFileSync(filePath, rawMessage);
2431
+ console.log(` [outbox] Queued ${filePath}`);
2432
+ this.processLocalQueue(accountId).catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`));
2419
2433
  }
2420
2434
  /** Guard against concurrent processSendActions for the same account */
2421
2435
  sendingAccounts = new Set();
@@ -2985,10 +2999,17 @@ export class ImapManager extends EventEmitter {
2985
2999
  // config.jsonc is per-machine / local-only — never polled.
2986
3000
  const cloudFiles = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc"];
2987
3001
  const CLOUD_POLL_MS = 3 * 60 * 1000;
3002
+ // Normalize before comparing: strip BOM, CRLF→LF, trailing whitespace.
3003
+ // Without this, cloud round-trips that re-wrap newlines or add a
3004
+ // trailing newline trigger a local overwrite every poll, which fires
3005
+ // fs.watch, which shows the spurious "accounts.jsonc changed" banner.
3006
+ const normalize = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
2988
3007
  const pollCloud = async () => {
2989
3008
  let cloudRead;
3009
+ let parseJsonc;
2990
3010
  try {
2991
3011
  ({ cloudRead } = await import("@bobfrankston/mailx-settings"));
3012
+ ({ parseJsonc } = await import("jsonc-parser").then(m => ({ parseJsonc: m.parse })));
2992
3013
  }
2993
3014
  catch {
2994
3015
  return; /* cloud module unavailable */
@@ -3004,17 +3025,25 @@ export class ImapManager extends EventEmitter {
3004
3025
  localContent = fs.readFileSync(localPath, "utf-8");
3005
3026
  }
3006
3027
  catch { /* missing */ }
3007
- if (localContent === cloudContent)
3008
- continue;
3009
- // Cloud copy differs — write through so watchers / downstream
3010
- // readers see the new value. fs.watch above will fire and
3011
- // emit configChanged UI banner.
3028
+ if (localContent !== null) {
3029
+ if (normalize(localContent) === normalize(cloudContent))
3030
+ continue;
3031
+ // Semantic check: parse both as JSONC and compare structures.
3032
+ // Catches reorderings that normalize() doesn't (e.g. JSON with
3033
+ // same keys in different order after a cloud-side re-serialize).
3034
+ try {
3035
+ const a = parseJsonc(localContent);
3036
+ const b = parseJsonc(cloudContent);
3037
+ if (a !== undefined && b !== undefined &&
3038
+ JSON.stringify(a) === JSON.stringify(b))
3039
+ continue;
3040
+ }
3041
+ catch { /* fall through to write */ }
3042
+ }
3012
3043
  fs.writeFileSync(localPath, cloudContent);
3013
3044
  console.log(` [cloud-poll] ${filename} updated from cloud copy`);
3014
3045
  }
3015
3046
  catch (e) {
3016
- // Drive unreachable, auth expired, file missing in cloud —
3017
- // silent retry on next tick; no user-visible fallout.
3018
3047
  console.log(` [cloud-poll] ${filename} check skipped: ${e?.message || e}`);
3019
3048
  }
3020
3049
  }
@@ -453,7 +453,11 @@ export function loadAccounts() {
453
453
  localContent = fs.readFileSync(localPath, "utf-8");
454
454
  }
455
455
  catch { /* missing */ }
456
- if (sharedContent !== localContent) {
456
+ // Normalize before comparing — GDrive-mounted copies often
457
+ // differ in BOM / line endings / trailing newline without any
458
+ // semantic change, and that triggered the spurious banner.
459
+ const norm = (s) => s.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/[ \t\r\n]+$/, "");
460
+ if (norm(sharedContent) !== norm(localContent)) {
457
461
  fs.mkdirSync(LOCAL_DIR, { recursive: true });
458
462
  fs.writeFileSync(localPath, sharedContent);
459
463
  }