@bobfrankston/mailx 1.0.218 → 1.0.220

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":1060,"y":329}
package/client/app.js CHANGED
@@ -192,6 +192,24 @@ document.getElementById("folder-tree")?.addEventListener("click", (e) => {
192
192
  document.querySelector(".folder-panel")?.classList.remove("open");
193
193
  }
194
194
  });
195
+ // Close folder overlay when user clicks outside it (narrow mode OR
196
+ // medium-width mode where the folder panel slides in as an overlay).
197
+ // Uses capture phase so it beats any child handler that might stopPropagation.
198
+ document.addEventListener("pointerdown", (e) => {
199
+ const panel = document.querySelector(".folder-panel");
200
+ if (!panel || !panel.classList.contains("open"))
201
+ return;
202
+ const target = e.target;
203
+ // Ignore clicks inside the panel itself and on the hamburger toggle
204
+ if (target.closest(".folder-panel") || target.closest("#btn-menu"))
205
+ return;
206
+ // Only auto-dismiss when we're in overlay mode (small or medium screens).
207
+ // On wide screens the panel is a permanent column and the "open" class
208
+ // is irrelevant.
209
+ if (window.innerWidth <= 1100 || window.innerHeight <= 600) {
210
+ panel.classList.remove("open");
211
+ }
212
+ }, true);
195
213
  // ── Toolbar actions ──
196
214
  document.getElementById("btn-sync")?.addEventListener("click", async () => {
197
215
  const btn = document.getElementById("btn-sync");
@@ -371,30 +389,49 @@ function showComposeOverlay() {
371
389
  closeBtn.addEventListener("mouseenter", () => closeBtn.style.color = "#c00");
372
390
  closeBtn.addEventListener("mouseleave", () => closeBtn.style.color = "#666");
373
391
  closeBtn.addEventListener("click", () => {
392
+ // compose.ts handles the prompt (Save/Discard/Cancel) and then calls
393
+ // window.close() which is redirected to wrapper.remove() at line below.
394
+ // If the user cancels the prompt, closeCompose() is never called and
395
+ // the wrapper stays. Don't force-remove on a timer — that defeats Cancel.
374
396
  try {
375
397
  const win = frame.contentWindow;
376
398
  if (win)
377
399
  win.dispatchEvent(new Event("compose-save-and-close"));
378
400
  }
379
401
  catch { /* */ }
380
- setTimeout(() => wrapper.remove(), 500);
381
402
  });
382
403
  titleBar.appendChild(closeBtn);
383
- // Drag to move
404
+ // Drag to move. While dragging we set pointer-events:none on the iframe
405
+ // so mouse events don't get swallowed by the inner document the moment
406
+ // the cursor crosses into the iframe region. Without that, drag only
407
+ // worked if you stayed on the title bar pixels, which is why it felt
408
+ // broken except at the lower-right (resize grip) corner.
384
409
  let dragX = 0, dragY = 0;
385
410
  titleBar.addEventListener("mousedown", (e) => {
386
411
  if (e.target === closeBtn)
387
412
  return;
388
413
  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`;
414
+ const rect = wrapper.getBoundingClientRect();
415
+ dragX = e.clientX - rect.left;
416
+ dragY = e.clientY - rect.top;
417
+ // Clamp movement to the viewport so the title bar stays grabbable.
418
+ const clamp = (val, min, max) => Math.max(min, Math.min(max, val));
419
+ frame.style.pointerEvents = "none";
420
+ document.body.style.userSelect = "none";
421
+ const onMove = (ev) => {
422
+ ev.preventDefault();
423
+ const w = wrapper.offsetWidth;
424
+ const h = wrapper.offsetHeight;
425
+ const left = clamp(ev.clientX - dragX, 0, window.innerWidth - 40);
426
+ const top = clamp(ev.clientY - dragY, 0, window.innerHeight - 40);
427
+ wrapper.style.left = `${left}px`;
428
+ wrapper.style.top = `${top}px`;
394
429
  wrapper.style.bottom = "auto";
395
430
  wrapper.style.right = "auto";
396
431
  };
397
432
  const onUp = () => {
433
+ frame.style.pointerEvents = "";
434
+ document.body.style.userSelect = "";
398
435
  document.removeEventListener("mousemove", onMove);
399
436
  document.removeEventListener("mouseup", onUp);
400
437
  };
@@ -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.220",
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.9",
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.9",
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
  }
@@ -341,11 +341,34 @@ export class MailxService {
341
341
  const account = settings.accounts.find(a => a.id === msg.from);
342
342
  if (!account)
343
343
  throw new Error(`Unknown account: ${msg.from}`);
344
+ // Vet every recipient address — refuse to send if any field contains a
345
+ // non-email (e.g. "Bob Frankston <Bob Frankston>" from a bad contact
346
+ // autocomplete). This catches garbage BEFORE it hits SMTP, where the
347
+ // server would either accept-and-bounce or reject the whole envelope.
348
+ const emailRe = /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/;
349
+ const validateList = (label, list) => {
350
+ if (!list)
351
+ return;
352
+ for (const a of list) {
353
+ const addr = (a?.address || "").trim();
354
+ if (!addr)
355
+ throw new Error(`${label} has an empty address`);
356
+ if (!emailRe.test(addr))
357
+ throw new Error(`${label} has an invalid address: "${addr}"${a?.name ? ` (displayed as "${a.name}")` : ""}`);
358
+ }
359
+ };
360
+ validateList("To", msg.to);
361
+ validateList("Cc", msg.cc);
362
+ validateList("Bcc", msg.bcc);
363
+ if (!msg.to?.length)
364
+ throw new Error("No To recipients");
344
365
  // Extract bare email from fromAddress (may be "Name <addr>" or just "addr")
345
366
  let fromAddr = msg.fromAddress || account.email;
346
367
  const angleMatch = fromAddr.match(/<([^>]+)>/);
347
368
  if (angleMatch)
348
369
  fromAddr = angleMatch[1];
370
+ if (!emailRe.test(fromAddr))
371
+ throw new Error(`From address is not a valid email: "${fromAddr}"`);
349
372
  const fromHeader = `${account.name} <${fromAddr}>`;
350
373
  const to = msg.to.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
351
374
  const cc = msg.cc?.map((a) => a.name ? `${a.name} <${a.address}>` : a.address).join(", ");
@@ -369,6 +369,11 @@ export class MailxDB {
369
369
  // ── Contacts ──
370
370
  /** Record an address used in sent mail */
371
371
  recordSentAddress(name, email) {
372
+ // Don't pollute the contacts table with non-addresses. Anything without
373
+ // an @ or without a TLD-ish tail would show up in autocomplete and end
374
+ // up back in To/Cc headers as "Name <not an email>".
375
+ if (!email || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(email))
376
+ return;
372
377
  const now = Date.now();
373
378
  const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(email);
374
379
  if (existing) {
@@ -387,6 +392,9 @@ export class MailxDB {
387
392
  GROUP BY from_address`).all();
388
393
  let added = 0;
389
394
  for (const r of rows) {
395
+ // Skip invalid addresses so contact autocomplete never proposes non-emails
396
+ if (!r.from_address || !/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(r.from_address))
397
+ continue;
390
398
  const existing = this.db.prepare("SELECT id FROM contacts WHERE email = ?").get(r.from_address);
391
399
  if (!existing) {
392
400
  this.db.prepare("INSERT INTO contacts (source, name, email, last_used, use_count, updated_at) VALUES ('received', ?, ?, ?, ?, ?)").run(r.from_name || "", r.from_address, r.last, r.cnt, now);
@@ -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 });