@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.
- package/client/compose/compose.js +78 -19
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +33 -0
- package/packages/mailx-service/index.js +46 -11
|
@@ -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 {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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:
|
|
386
|
-
//
|
|
387
|
-
//
|
|
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 (
|
|
401
|
-
init.
|
|
402
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
591
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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 { /*
|
|
681
|
-
|
|
682
|
-
|
|
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);
|