@bobfrankston/mailx 1.0.333 → 1.0.336

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.
@@ -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.336",
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`);
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;
2230
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();