@bobfrankston/mailx 1.0.218 → 1.0.219

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":243,"y":28}
1
+ {"height":1344,"width":2151,"x":194,"y":22}
package/client/app.js CHANGED
@@ -371,30 +371,49 @@ function showComposeOverlay() {
371
371
  closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
372
372
  closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
373
373
  closeBtn.addEventListener("click", () => {
374
+ // compose.ts handles the prompt (Save/Discard/Cancel) and then calls
375
+ // window.close() which is redirected to wrapper.remove() at line below.
376
+ // If the user cancels the prompt, closeCompose() is never called and
377
+ // the wrapper stays. Don't force-remove on a timer — that defeats Cancel.
374
378
  try {
375
379
  const win = frame.contentWindow;
376
380
  if (win)
377
381
  win.dispatchEvent(new Event("compose-save-and-close"));
378
382
  }
379
383
  catch { /* */ }
380
- setTimeout(() => wrapper.remove(), 500);
381
384
  });
382
385
  titleBar.appendChild(closeBtn);
383
- // Drag to move
386
+ // Drag to move. While dragging we set pointer-events:none on the iframe
387
+ // so mouse events don't get swallowed by the inner document the moment
388
+ // the cursor crosses into the iframe region. Without that, drag only
389
+ // worked if you stayed on the title bar pixels, which is why it felt
390
+ // broken except at the lower-right (resize grip) corner.
384
391
  let dragX = 0, dragY = 0;
385
392
  titleBar.addEventListener("mousedown", (e) => {
386
393
  if (e.target === closeBtn)
387
394
  return;
388
395
  e.preventDefault();
389
- dragX = e.clientX - wrapper.offsetLeft;
390
- dragY = e.clientY - wrapper.offsetTop;
391
- const onMove = (e) => {
392
- wrapper.style.left = `${e.clientX - dragX}px`;
393
- wrapper.style.top = `${e.clientY - dragY}px`;
396
+ const rect = wrapper.getBoundingClientRect();
397
+ dragX = e.clientX - rect.left;
398
+ dragY = e.clientY - rect.top;
399
+ // Clamp movement to the viewport so the title bar stays grabbable.
400
+ const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
401
+ frame.style.pointerEvents = "none";
402
+ document.body.style.userSelect = "none";
403
+ const onMove = (ev) => {
404
+ ev.preventDefault();
405
+ const w = wrapper.offsetWidth;
406
+ const h = wrapper.offsetHeight;
407
+ const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
408
+ const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
409
+ wrapper.style.left = `${left}px`;
410
+ wrapper.style.top = `${top}px`;
394
411
  wrapper.style.bottom = "auto";
395
412
  wrapper.style.right = "auto";
396
413
  };
397
414
  const onUp = () => {
415
+ frame.style.pointerEvents = "";
416
+ document.body.style.userSelect = "";
398
417
  document.removeEventListener("mousemove", onMove);
399
418
  document.removeEventListener("mouseup", onUp);
400
419
  };
@@ -439,18 +439,65 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
439
439
  alert(`Send failed: ${e.message}`);
440
440
  }
441
441
  });
442
- // ── Discard ──
443
- document.getElementById("btn-discard")?.addEventListener("click", () => {
444
- if (editor.getText().trim() || toInput.value) {
445
- if (!confirm("Discard this message?"))
446
- return;
442
+ // ── Close handling ──
443
+ /** True if the compose has anything worth asking about. */
444
+ function composeHasContent() {
445
+ return !!(editor.getText().trim() || toInput.value.trim() || ccInput.value.trim() || bccInput.value.trim() || subjectInput.value.trim());
446
+ }
447
+ /** Ask Save/Discard/Cancel. Returns "save" | "discard" | "cancel". */
448
+ function promptSaveOrDiscard() {
449
+ // Three-way prompt built from two native dialogs: OK(save) / Cancel → then
450
+ // for Cancel, ask "Really discard?" which becomes Discard / Cancel.
451
+ const saveIt = confirm("Save this message as a draft?\n\nOK = Save as draft\nCancel = continue...");
452
+ if (saveIt)
453
+ return "save";
454
+ const discard = confirm("Discard the message without saving?\n\nOK = Discard\nCancel = Keep editing");
455
+ if (discard)
456
+ return "discard";
457
+ return "cancel";
458
+ }
459
+ /** Handle any "close the compose" action (Discard button, Escape, X, window close). */
460
+ async function handleCloseRequest() {
461
+ if (!composeHasContent()) {
462
+ closeCompose();
463
+ return true;
447
464
  }
448
- window.close();
465
+ const choice = promptSaveOrDiscard();
466
+ if (choice === "cancel")
467
+ return false;
468
+ // Stop auto-save so it can't race with our explicit save/discard.
469
+ if (draftDebounceTimer) {
470
+ clearTimeout(draftDebounceTimer);
471
+ draftDebounceTimer = null;
472
+ }
473
+ if (draftTimer) {
474
+ clearInterval(draftTimer);
475
+ draftTimer = null;
476
+ }
477
+ if (choice === "save") {
478
+ try {
479
+ await saveDraft();
480
+ }
481
+ catch { /* already logged */ }
482
+ }
483
+ else {
484
+ // Discard: if we have a tracked draft, delete it so the orphan doesn't stick around.
485
+ if (draftUid || draftId) {
486
+ try {
487
+ await deleteDraft(getFromAccountId(), draftUid || 0, draftId || "");
488
+ }
489
+ catch { /* ignore */ }
490
+ }
491
+ }
492
+ closeCompose();
493
+ return true;
494
+ }
495
+ document.getElementById("btn-discard")?.addEventListener("click", () => {
496
+ handleCloseRequest();
449
497
  });
450
498
  // ── Save and close (X button from parent) ──
451
- window.addEventListener("compose-save-and-close", async () => {
452
- await saveDraft();
453
- closeCompose();
499
+ window.addEventListener("compose-save-and-close", () => {
500
+ handleCloseRequest();
454
501
  });
455
502
  // ── Keyboard shortcuts ──
456
503
  document.addEventListener("keydown", (e) => {
@@ -458,17 +505,20 @@ document.addEventListener("keydown", (e) => {
458
505
  document.getElementById("btn-send")?.click();
459
506
  }
460
507
  if (e.key === "Escape") {
461
- document.getElementById("btn-discard")?.click();
462
- }
463
- // Ctrl+K = trigger address completion
464
- if (e.ctrlKey && e.key === "k") {
465
508
  e.preventDefault();
509
+ handleCloseRequest();
510
+ }
511
+ // Ctrl+K in an address field = trigger address completion.
512
+ // NOTE: Ctrl+K is ALSO the editor's "insert link" shortcut. Scope this handler
513
+ // strictly to the to/cc/bcc inputs so it doesn't shadow the editor binding when
514
+ // focus is in the body.
515
+ if (e.ctrlKey && (e.key === "k" || e.key === "K")) {
466
516
  const active = document.activeElement;
467
517
  const addressFields = [toInput, ccInput, bccInput];
468
- const target = addressFields.includes(active) ? active : toInput;
469
- target.focus();
470
- // Force trigger autocomplete by dispatching input event
471
- target.dispatchEvent(new Event("input"));
518
+ if (addressFields.includes(active)) {
519
+ e.preventDefault();
520
+ active.dispatchEvent(new Event("input"));
521
+ }
472
522
  }
473
523
  });
474
524
  //# sourceMappingURL=compose.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.218",
3
+ "version": "1.0.219",
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,11 +20,11 @@
20
20
  "postinstall": "node bin/postinstall.js"
21
21
  },
22
22
  "dependencies": {
23
- "@bobfrankston/iflow-direct": "^0.1.7",
23
+ "@bobfrankston/iflow-direct": "^0.1.8",
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.280",
27
+ "@bobfrankston/msger": "^0.1.282",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -74,11 +74,11 @@
74
74
  },
75
75
  ".transformedSnapshot": {
76
76
  "dependencies": {
77
- "@bobfrankston/iflow-direct": "^0.1.7",
77
+ "@bobfrankston/iflow-direct": "^0.1.8",
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.280",
81
+ "@bobfrankston/msger": "^0.1.282",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -1250,8 +1250,15 @@ export class ImapManager extends EventEmitter {
1250
1250
  /** Fetch a single message body on demand, caching in the store.
1251
1251
  * Uses its own fresh connection — never blocked by background prefetch. */
1252
1252
  async fetchMessageBody(accountId, folderId, uid) {
1253
- // Already cached?
1253
+ // Already cached? If the file is on disk but body_path wasn't written to
1254
+ // the DB (e.g. from an interrupted earlier run), the prefetch loop would
1255
+ // otherwise keep returning the same missing rows forever — once saw
1256
+ // "gmail: 17266796 bodies cached" in the logs, which is the counter
1257
+ // spinning on the same 100 rows.
1254
1258
  if (await this.bodyStore.hasMessage(accountId, folderId, uid)) {
1259
+ const existingPath = this.bodyStore.getMessagePath?.(accountId, folderId, uid);
1260
+ if (existingPath)
1261
+ this.db.updateBodyPath(accountId, uid, existingPath);
1255
1262
  return this.bodyStore.getMessage(accountId, folderId, uid);
1256
1263
  }
1257
1264
  if (!this.configs.has(accountId))
@@ -1359,6 +1366,14 @@ export class ImapManager extends EventEmitter {
1359
1366
  this.db.queueSyncAction(accountId, "delete", msg.uid, msg.folderId);
1360
1367
  }
1361
1368
  }
1369
+ // Recalc folder counts so the tree badge updates immediately instead
1370
+ // of showing stale numbers until the next full sync.
1371
+ const sourceFolderIds = new Set(messages.map(m => m.folderId));
1372
+ for (const fid of sourceFolderIds)
1373
+ this.db.recalcFolderCounts(fid);
1374
+ if (trash)
1375
+ this.db.recalcFolderCounts(trash.id);
1376
+ this.emit("folderCountsChanged", accountId, {});
1362
1377
  // Process all queued actions in one IMAP session
1363
1378
  this.debounceSyncActions(accountId);
1364
1379
  }
@@ -1375,6 +1390,13 @@ export class ImapManager extends EventEmitter {
1375
1390
  for (const msg of messages) {
1376
1391
  this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
1377
1392
  }
1393
+ // Recalc folder counts (source folders + destination) so the tree
1394
+ // badge updates immediately.
1395
+ const sourceFolderIds = new Set(messages.map(m => m.folderId));
1396
+ for (const fid of sourceFolderIds)
1397
+ this.db.recalcFolderCounts(fid);
1398
+ this.db.recalcFolderCounts(targetFolderId);
1399
+ this.emit("folderCountsChanged", accountId, {});
1378
1400
  // Process all queued actions in one IMAP session
1379
1401
  this.debounceSyncActions(accountId);
1380
1402
  }
@@ -8,6 +8,8 @@ export declare class FileMessageStore implements MessageStore {
8
8
  private basePath;
9
9
  constructor(basePath: string);
10
10
  private messagePath;
11
+ /** Public lookup of the on-disk path without touching the file. */
12
+ getMessagePath(accountId: string, folderId: number, uid: number): string;
11
13
  putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;
12
14
  getMessage(accountId: string, folderId: number, uid: number): Promise<Buffer>;
13
15
  deleteMessage(accountId: string, folderId: number, uid: number): Promise<void>;
@@ -14,6 +14,10 @@ export class FileMessageStore {
14
14
  messagePath(accountId, folderId, uid) {
15
15
  return path.join(this.basePath, accountId, String(folderId), `${uid}.eml`);
16
16
  }
17
+ /** Public lookup of the on-disk path without touching the file. */
18
+ getMessagePath(accountId, folderId, uid) {
19
+ return this.messagePath(accountId, folderId, uid);
20
+ }
17
21
  async putMessage(accountId, folderId, uid, raw) {
18
22
  const filePath = this.messagePath(accountId, folderId, uid);
19
23
  fs.mkdirSync(path.dirname(filePath), { recursive: true });