@bobfrankston/mailx 1.0.313 → 1.0.317

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.
@@ -4,7 +4,7 @@
4
4
  * Receives init data via window.opener.postMessage or URL params.
5
5
  */
6
6
  import { createEditor } from "./editor.js";
7
- import { getVersion, getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
7
+ import { getSettings, getAccounts, searchContacts, sendMessage, saveDraft as apiSaveDraft, deleteDraft } from "../lib/api-client.js";
8
8
  /** Close compose window */
9
9
  function closeCompose() {
10
10
  window.close();
@@ -45,15 +45,37 @@ async function loadEditorAssets(type) {
45
45
  }
46
46
  }
47
47
  // ── Determine editor type from settings ──
48
+ //
49
+ // Compose must open fast. The previous flow awaited getVersion() then
50
+ // getSettings() sequentially before the editor was even loaded — any
51
+ // service-side stall (busy sync, slow IMAP, hung OAuth refresh) turned
52
+ // "click Reply" into a multi-second / multi-minute wait with a blank
53
+ // compose window. Local-first: read the editor-type preference from a
54
+ // tiny localStorage cache that we update whenever getSettings succeeds
55
+ // in the background. Default to quill on first run / cache miss.
48
56
  let editorType = "quill";
49
57
  let appSettings = null;
50
58
  try {
51
- await getVersion(); // verify server is up
52
- appSettings = await getSettings();
53
- if (appSettings.ui?.editor === "tiptap")
54
- editorType = "tiptap";
59
+ const cached = localStorage.getItem("mailx-editor-type");
60
+ if (cached === "tiptap" || cached === "quill")
61
+ editorType = cached;
55
62
  }
56
- catch { /* default to quill */ }
63
+ catch { /* private-mode / SecurityError — default quill */ }
64
+ // Refresh the cache asynchronously — doesn't block compose open.
65
+ (async () => {
66
+ try {
67
+ appSettings = await getSettings();
68
+ const next = appSettings?.ui?.editor === "tiptap" ? "tiptap" : "quill";
69
+ try {
70
+ localStorage.setItem("mailx-editor-type", next);
71
+ }
72
+ catch { /* */ }
73
+ // Note: we don't hot-swap the editor if the preference changed while
74
+ // compose was opening — the old type is already instantiated. Next
75
+ // compose open will pick up the new preference.
76
+ }
77
+ catch { /* non-fatal */ }
78
+ })();
57
79
  await loadEditorAssets(editorType);
58
80
  const container = document.getElementById("compose-editor");
59
81
  container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
@@ -382,26 +404,63 @@ function scheduleDraftSave() {
382
404
  clearTimeout(draftDebounceTimer);
383
405
  draftDebounceTimer = setTimeout(() => { draftDebounceTimer = null; saveDraft(); }, DRAFT_INPUT_DEBOUNCE_MS);
384
406
  }
385
- // ── Initialize: always fetch real accounts from the API before applying init, then
386
- // start the auto-save timer. Callers like message-viewer's Edit Draft pass
387
- // init.accounts=[], so we can't trust what's in the init blob. ──
407
+ // ── Initialize: local-first population.
408
+ //
409
+ // Reply / Reply-All / Forward callers pre-populate `init.accounts` with the
410
+ // full account list (app.ts:openCompose). In that common case we do NOT need
411
+ // to call getAccounts() — everything required to fill the compose form is
412
+ // already in sessionStorage and reads synchronously. That turns "click Reply"
413
+ // into an instant-open instead of "wait for getAccounts IPC to respond,
414
+ // which can take >120s when the service is busy syncing / hung on IMAP".
415
+ //
416
+ // getAccounts is still called (non-blocking) to refresh the dropdown with
417
+ // the freshest data — and it IS awaited only in the fallback path where
418
+ // init doesn't have an account list (message-viewer's Edit Draft passes
419
+ // init.accounts=[]).
388
420
  (async () => {
389
- let accounts = [];
390
- try {
391
- accounts = await getAccounts();
392
- }
393
- catch (e) {
394
- console.error("Failed to load accounts:", e);
395
- }
396
421
  const stored = sessionStorage.getItem("composeInit");
397
422
  if (stored) {
398
423
  sessionStorage.removeItem("composeInit");
399
424
  const init = JSON.parse(stored);
400
- if (!init.accounts || init.accounts.length === 0)
401
- init.accounts = accounts;
402
- applyInit(init);
425
+ if (init.accounts && init.accounts.length > 0) {
426
+ // Happy path — init is complete. Apply immediately. Kick
427
+ // getAccounts in the background to refresh the dropdown if the
428
+ // user keeps compose open long enough for the result.
429
+ applyInit(init);
430
+ getAccounts().then((fresh) => {
431
+ if (Array.isArray(fresh) && fresh.length > 0) {
432
+ init.accounts = fresh;
433
+ // Re-populate the From dropdown only — don't clobber
434
+ // anything the user may have already typed.
435
+ try {
436
+ populateFromOptions(fresh);
437
+ }
438
+ catch { /* */ }
439
+ }
440
+ }).catch(() => { });
441
+ }
442
+ else {
443
+ // Edit Draft / other callers that didn't pre-fill accounts.
444
+ // Have to wait on getAccounts here — the From dropdown needs it.
445
+ let fresh = [];
446
+ try {
447
+ fresh = await getAccounts();
448
+ }
449
+ catch (e) {
450
+ console.error("Failed to load accounts:", e);
451
+ }
452
+ init.accounts = fresh;
453
+ applyInit(init);
454
+ }
403
455
  }
404
456
  else {
457
+ let accounts = [];
458
+ try {
459
+ accounts = await getAccounts();
460
+ }
461
+ catch (e) {
462
+ console.error("Failed to load accounts:", e);
463
+ }
405
464
  populateFromOptions(accounts);
406
465
  toInput.focus();
407
466
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.313",
3
+ "version": "1.0.317",
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.7",
25
25
  "@bobfrankston/miscinfo": "^1.0.9",
26
26
  "@bobfrankston/oauthsupport": "^1.0.24",
27
- "@bobfrankston/msger": "^0.1.342",
27
+ "@bobfrankston/msger": "^0.1.343",
28
28
  "@bobfrankston/mailx-host": "^0.1.3",
29
29
  "@capacitor/android": "^8.3.0",
30
30
  "@capacitor/cli": "^8.3.0",
@@ -88,7 +88,7 @@
88
88
  "@bobfrankston/iflow-node": "^0.1.7",
89
89
  "@bobfrankston/miscinfo": "^1.0.9",
90
90
  "@bobfrankston/oauthsupport": "^1.0.24",
91
- "@bobfrankston/msger": "^0.1.342",
91
+ "@bobfrankston/msger": "^0.1.343",
92
92
  "@bobfrankston/mailx-host": "^0.1.3",
93
93
  "@capacitor/android": "^8.3.0",
94
94
  "@capacitor/cli": "^8.3.0",
@@ -564,6 +564,11 @@ export class ImapManager extends EventEmitter {
564
564
  const folders = await client.getFolderList();
565
565
  console.log(` [diag] ${accountId}: getFolderList done in ${Date.now() - t0}ms (${folders.length} folders)`);
566
566
  const specialFolders = client.getSpecialFolders(folders);
567
+ // Collect server paths so we can prune anything the server no longer
568
+ // has (user-renamed / -deleted / case-flipped a folder from another
569
+ // client). IMAP paths are case-sensitive, so "Foo" → "foo" is a real
570
+ // delete+create of two distinct mailboxes.
571
+ const serverPaths = new Set();
567
572
  for (const folder of folders) {
568
573
  // Skip non-selectable folders (virtual parents like "Added", "Added2")
569
574
  const flags = folder.flags;
@@ -584,6 +589,34 @@ export class ImapManager extends EventEmitter {
584
589
  else if (specialFolders.archive === folder.path)
585
590
  specialUse = "archive";
586
591
  this.db.upsertFolder(accountId, folder.path, folder.name || folder.path.split(folder.delimiter || "/").pop() || folder.path, specialUse, folder.delimiter || "/");
592
+ serverPaths.add(folder.path);
593
+ }
594
+ // Prune: any local folder whose exact path (case-sensitive) isn't in
595
+ // the server's list has been deleted or renamed server-side. Safety
596
+ // rails: only prune when the server returned a non-empty list (empty
597
+ // result is more likely a transient protocol / auth error than "all
598
+ // your folders were deleted"). Never prune INBOX under any
599
+ // circumstances — even a broken server response shouldn't make us
600
+ // drop the account's primary mailbox. All other special-use folders
601
+ // ARE prunable: if the user actually deleted Sent on the server,
602
+ // we should reflect that locally, and the next sync will re-detect
603
+ // the server's real Sent folder and re-upsert.
604
+ if (folders.length > 0) {
605
+ const localFolders = this.db.getFolders(accountId);
606
+ const stale = localFolders.filter(f => !serverPaths.has(f.path) &&
607
+ f.specialUse !== "inbox");
608
+ for (const f of stale) {
609
+ console.log(` [sync] ${accountId}: pruning stale folder "${f.path}" (id=${f.id}) — no longer on server`);
610
+ try {
611
+ this.db.deleteFolder(f.id);
612
+ }
613
+ catch (e) {
614
+ console.error(` [sync] ${accountId}: prune failed for "${f.path}": ${e.message}`);
615
+ }
616
+ }
617
+ if (stale.length > 0) {
618
+ this.emit("folderCountsChanged", accountId, {});
619
+ }
587
620
  }
588
621
  this.emit("syncProgress", accountId, "folders", 100);
589
622
  // Notify UI that folder structure changed — triggers tree re-render
@@ -587,16 +587,33 @@ export class MailxService {
587
587
  throw new Error("Folder not found");
588
588
  const client = this.imapManager.createPublicClient(accountId);
589
589
  try {
590
- if (client.deleteMailbox) {
591
- await client.deleteMailbox(folder.path);
590
+ try {
591
+ if (client.deleteMailbox) {
592
+ await client.deleteMailbox(folder.path);
593
+ }
594
+ else {
595
+ await client.withConnection(async () => {
596
+ await client.client.mailboxDelete(folder.path);
597
+ });
598
+ }
592
599
  }
593
- else {
594
- await client.withConnection(async () => {
595
- await client.client.mailboxDelete(folder.path);
596
- });
600
+ catch (e) {
601
+ // Server already doesn't have this folder — common case when
602
+ // the user deleted / renamed it from another client and mailx
603
+ // is still showing the stale local row. Silently treat as
604
+ // success and proceed with local cleanup; the user's intent
605
+ // ("make this go away") is met either way.
606
+ const msg = String(e?.message || e || "").toLowerCase();
607
+ const alreadyGone = /nonexistent|does not exist|no such|not found|NO \[.*\] Mailbox|404/i.test(msg);
608
+ if (!alreadyGone)
609
+ throw e;
610
+ console.log(` [folder] ${accountId} delete "${folder.path}": server says already gone — cleaning local DB`);
597
611
  }
598
612
  this.db.deleteFolder(folderId);
599
- await client.logout();
613
+ try {
614
+ await client.logout();
615
+ }
616
+ catch { /* ignore */ }
600
617
  }
601
618
  finally {
602
619
  try {
@@ -647,6 +664,16 @@ export class MailxService {
647
664
  }
648
665
  // ── Drafts ──
649
666
  async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId) {
667
+ // Local-first: commit the draft to the local filesystem synchronously
668
+ // and return immediately. The IMAP APPEND (and the previous-draft
669
+ // delete) run in the background. Previously this method awaited IMAP
670
+ // inline, which produced the 30/120s `mailxapi timeout: saveDraft`
671
+ // the user reported — every IMAP stall (slow server, hung OAuth,
672
+ // maxed connection pool) froze autosave. The local `.eml` written
673
+ // below is the user's crash-safety net; IMAP is a sync target, not
674
+ // a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
675
+ // so the reconciler can de-duplicate on the server by header search
676
+ // even without the previousDraftUid round-trip.
650
677
  const settings = loadSettings();
651
678
  const account = settings.accounts.find(a => a.id === accountId);
652
679
  if (!account)
@@ -663,7 +690,8 @@ export class MailxService {
663
690
  `MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
664
691
  ].filter(h => h !== null).join("\r\n");
665
692
  const raw = `${headers}\r\n\r\n${bodyEncoded}`;
666
- // Save local editing copy crash recovery, keep last 3
693
+ // Local commit: write editing copy to disk. Crash recovery lives in
694
+ // the last 3 files. Synchronous fs (~ms) so the caller returns fast.
667
695
  try {
668
696
  const editingDir = path.join(getConfigDir(), "sending", accountId, "editing");
669
697
  fs.mkdirSync(editingDir, { recursive: true });
@@ -677,9 +705,16 @@ export class MailxService {
677
705
  fs.unlinkSync(path.join(editingDir, files.shift()));
678
706
  }
679
707
  }
680
- catch { /* ignore */ }
681
- const draftUid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
682
- return { draftUid, draftId: id };
708
+ catch { /* non-fatal — draft stays in memory at least */ }
709
+ // Background reconcile to server Drafts folder. Fire-and-forget
710
+ // the ACK to the client is already on its way.
711
+ this.imapManager.saveDraft(accountId, raw, previousDraftUid, id).catch((e) => {
712
+ console.error(` [draft] background IMAP save failed for ${id}: ${e?.message || e}`);
713
+ // Surface as an event so the UI can show a status-bar hint without
714
+ // blocking the caller. Draft is preserved on disk regardless.
715
+ this.emit?.("draftSaveDeferred", { accountId, draftId: id, error: String(e?.message || e) });
716
+ });
717
+ return { draftUid: null, draftId: id };
683
718
  }
684
719
  async deleteDraft(accountId, draftUid, draftId) {
685
720
  await this.imapManager.deleteDraft(accountId, draftUid, draftId);