@bobfrankston/rmfmail 1.0.679 → 1.0.681
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/bin/build-quill.js +35 -0
- package/client/app.bundle.js +34 -17
- package/client/app.bundle.js.map +2 -2
- package/client/app.js +67 -26
- package/client/app.js.map +1 -1
- package/client/app.ts +67 -28
- package/client/compose/compose.bundle.js +62 -2
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/compose.js +51 -3
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +47 -3
- package/client/lib/quill/quill.js +3 -0
- package/client/lib/quill/quill.snow.css +10 -0
- package/client/lib/rmf-tiny.js +21 -0
- package/docs/accounts.md +7 -2
- package/package.json +4 -4
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +11 -6
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +19 -11
- package/packages/mailx-service/local-store.d.ts.map +1 -1
- package/packages/mailx-service/local-store.js +9 -0
- package/packages/mailx-service/local-store.js.map +1 -1
- package/packages/mailx-service/local-store.ts +9 -0
- package/packages/mailx-settings/index.d.ts +1 -1
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +12 -6
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +12 -6
package/client/app.ts
CHANGED
|
@@ -171,9 +171,15 @@ function updateBadge(count: number): void {
|
|
|
171
171
|
async function updateNewMessageCount(): Promise<void> {
|
|
172
172
|
try {
|
|
173
173
|
const accounts = await getAccounts();
|
|
174
|
+
// Fan out folder queries in parallel — earlier code awaited each
|
|
175
|
+
// account's `getFolders` in series, so an N-account setup paid N
|
|
176
|
+
// back-to-back IPC round-trips on every count refresh (folderCountsChanged,
|
|
177
|
+
// sync events, IDLE updates).
|
|
178
|
+
const folderLists = await Promise.all(
|
|
179
|
+
accounts.map((acct: any) => getFolders(acct.id).catch(() => [] as any[])),
|
|
180
|
+
);
|
|
174
181
|
let totalUnread = 0;
|
|
175
|
-
for (const
|
|
176
|
-
const folders = await getFolders(acct.id);
|
|
182
|
+
for (const folders of folderLists) {
|
|
177
183
|
const inbox = folders.find((f: any) => f.specialUse === "inbox");
|
|
178
184
|
if (inbox) totalUnread += inbox.unreadCount || 0;
|
|
179
185
|
}
|
|
@@ -242,9 +248,12 @@ window.addEventListener("focus", stopTitleFlash);
|
|
|
242
248
|
/** Call when user actively views messages — resets the badge */
|
|
243
249
|
function markAsSeen(): void {
|
|
244
250
|
getAccounts().then(async (accounts: any[]) => {
|
|
251
|
+
// Parallel folder fetch — see updateNewMessageCount for rationale.
|
|
252
|
+
const folderLists = await Promise.all(
|
|
253
|
+
accounts.map((acct: any) => getFolders(acct.id).catch(() => [] as any[])),
|
|
254
|
+
);
|
|
245
255
|
let total = 0;
|
|
246
|
-
for (const
|
|
247
|
-
const folders = await getFolders(acct.id);
|
|
256
|
+
for (const folders of folderLists) {
|
|
248
257
|
const inbox = folders.find((f: any) => f.specialUse === "inbox");
|
|
249
258
|
if (inbox) total += inbox.unreadCount || 0;
|
|
250
259
|
}
|
|
@@ -718,9 +727,30 @@ async function openCompose(mode: ComposeMode): Promise<void> {
|
|
|
718
727
|
console.warn(`[compose] ${mode} — no message selected`);
|
|
719
728
|
return;
|
|
720
729
|
}
|
|
721
|
-
|
|
722
|
-
|
|
730
|
+
// Parallel-load: kick off getAccounts AND open the iframe in the same
|
|
731
|
+
// tick. The iframe doesn't need the account list until after its editor
|
|
732
|
+
// bootstraps (200-500 ms for TinyMCE, less for Quill); by then the IPC
|
|
733
|
+
// round-trip has resolved. Earlier code awaited getAccounts FIRST,
|
|
734
|
+
// adding the IPC latency to the perceived Ctrl+N → editor-visible time.
|
|
735
|
+
// We post `compose-init-ready` to the iframe once init is in
|
|
736
|
+
// sessionStorage so compose.ts's IIFE can read synchronously without
|
|
737
|
+
// polling.
|
|
738
|
+
const accountsP = getAccounts();
|
|
723
739
|
const msg = current?.message;
|
|
740
|
+
// Title bar text needs the subject from msg (no IPC dependency) — build
|
|
741
|
+
// it now so the iframe can be opened with the final title and avoid a
|
|
742
|
+
// flash of placeholder text.
|
|
743
|
+
const titlePrefix =
|
|
744
|
+
mode === "reply" ? "Reply" :
|
|
745
|
+
mode === "replyAll" ? "Reply All" :
|
|
746
|
+
mode === "forward" ? "Forward" :
|
|
747
|
+
"Compose";
|
|
748
|
+
const titleSubject = mode === "new" ? "" : (msg?.subject || "");
|
|
749
|
+
const frame = showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
|
|
750
|
+
// Now finish initialisation off the critical path — editor bootstrap
|
|
751
|
+
// inside the iframe runs concurrently with this await.
|
|
752
|
+
const accounts = await accountsP;
|
|
753
|
+
const accountId = current?.accountId || accounts[0]?.id || "";
|
|
724
754
|
const rePrefix = /^(re|fwd?):\s*/i;
|
|
725
755
|
const cleanSubject = msg ? msg.subject.replace(rePrefix, "") : "";
|
|
726
756
|
|
|
@@ -733,7 +763,7 @@ async function openCompose(mode: ComposeMode): Promise<void> {
|
|
|
733
763
|
bodyHtml: "",
|
|
734
764
|
inReplyTo: "",
|
|
735
765
|
references: [],
|
|
736
|
-
accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
|
|
766
|
+
accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),
|
|
737
767
|
};
|
|
738
768
|
|
|
739
769
|
// Auto-detect reply From: if the message was delivered to an identity address
|
|
@@ -806,23 +836,16 @@ async function openCompose(mode: ComposeMode): Promise<void> {
|
|
|
806
836
|
}
|
|
807
837
|
|
|
808
838
|
|
|
809
|
-
// Store init data for compose window to pick up
|
|
839
|
+
// Store init data for compose window to pick up. sessionStorage is the
|
|
840
|
+
// canonical handoff path — same origin between parent and iframe; the
|
|
841
|
+
// compose IIFE reads it after the editor finishes booting. We also
|
|
842
|
+
// postMessage the iframe so it can short-circuit the listen-for-message
|
|
843
|
+
// wait if it's already past editor init.
|
|
810
844
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
811
|
-
|
|
812
|
-
// Popup windows don't work in IPC mode (custom protocol doesn't propagate to child windows)
|
|
813
|
-
// Title reflects mode + subject so the user can see what they're replying to
|
|
814
|
-
// ("Re: Stars of STEM 2026" instead of just "Compose"). Forward shows the
|
|
815
|
-
// forward target subject; new compose stays generic.
|
|
816
|
-
const titlePrefix =
|
|
817
|
-
mode === "reply" ? "Reply" :
|
|
818
|
-
mode === "replyAll" ? "Reply All" :
|
|
819
|
-
mode === "forward" ? "Forward" :
|
|
820
|
-
"Compose";
|
|
821
|
-
const titleSubject = mode === "new" ? "" : (msg?.subject || init.subject || "");
|
|
822
|
-
showComposeOverlay(titleSubject ? `${titlePrefix}: ${titleSubject}` : titlePrefix);
|
|
845
|
+
try { frame?.contentWindow?.postMessage({ type: "compose-init-ready" }, "*"); } catch { /* */ }
|
|
823
846
|
}
|
|
824
847
|
|
|
825
|
-
function showComposeOverlay(title = "Compose"):
|
|
848
|
+
function showComposeOverlay(title = "Compose"): HTMLIFrameElement {
|
|
826
849
|
const wrapper = document.createElement("div");
|
|
827
850
|
wrapper.className = "compose-overlay";
|
|
828
851
|
// Full-screen on small/short screens, floating on larger
|
|
@@ -972,6 +995,7 @@ function showComposeOverlay(title = "Compose"): void {
|
|
|
972
995
|
wrapper.appendChild(frame);
|
|
973
996
|
if (!isSmall) addComposeResizeHandles(wrapper, frame);
|
|
974
997
|
document.body.appendChild(wrapper);
|
|
998
|
+
return frame;
|
|
975
999
|
}
|
|
976
1000
|
|
|
977
1001
|
/** Drop a transparent full-viewport shield in front of every other element
|
|
@@ -1071,6 +1095,12 @@ function sanitizeQuotedBody(msg: any): string {
|
|
|
1071
1095
|
// future provider/path that didn't go through that pipeline.
|
|
1072
1096
|
const isPlainText = !msg.bodyHtml;
|
|
1073
1097
|
if (isPlainText) {
|
|
1098
|
+
// Full HTML escape. Leaving `>` unescaped was tempting for source
|
|
1099
|
+
// readability but breaks HTML in edge cases — TinyMCE's normalize-
|
|
1100
|
+
// on-paste re-interprets the input, and stray `>` near sequences
|
|
1101
|
+
// like `<!--` / `-->` / `<!` in plain-text bodies can be misread
|
|
1102
|
+
// by the parser. Per Bob 2026-05-12: "not just ugly, it breaks
|
|
1103
|
+
// the HTML." Trivial source-clutter is the lesser evil.
|
|
1074
1104
|
const escaped = String(msg.bodyText || "")
|
|
1075
1105
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1076
1106
|
return `<div style="white-space:pre-wrap;font-family:inherit;margin:0">${escaped}</div>`;
|
|
@@ -2519,7 +2549,12 @@ async function openComposeFromMailto(m: {
|
|
|
2519
2549
|
to: string[]; cc: string[]; bcc: string[];
|
|
2520
2550
|
subject: string; body: string; inReplyTo: string;
|
|
2521
2551
|
}): Promise<void> {
|
|
2522
|
-
|
|
2552
|
+
// Open the iframe immediately and load accounts in parallel — same
|
|
2553
|
+
// pattern as openCompose. The mailto handler should never feel like
|
|
2554
|
+
// "waiting for the system" to a user who clicked a link.
|
|
2555
|
+
const accountsP = getAccounts();
|
|
2556
|
+
const frame = showComposeOverlay(m.subject ? `Compose: ${m.subject}` : "Compose");
|
|
2557
|
+
const accounts = await accountsP;
|
|
2523
2558
|
const accountId = accounts[0]?.id || "";
|
|
2524
2559
|
const escape = (s: string) => s.replace(/[&<>]/g, c =>
|
|
2525
2560
|
({ "&": "&", "<": "<", ">": ">" }[c]!));
|
|
@@ -2536,10 +2571,10 @@ async function openComposeFromMailto(m: {
|
|
|
2536
2571
|
bodyHtml,
|
|
2537
2572
|
inReplyTo: m.inReplyTo,
|
|
2538
2573
|
references: m.inReplyTo ? [m.inReplyTo] : [],
|
|
2539
|
-
accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature })),
|
|
2574
|
+
accounts: accounts.map((a: any) => ({ id: a.id, name: a.name, email: a.email, signature: a.signature, sig: a.sig })),
|
|
2540
2575
|
};
|
|
2541
2576
|
sessionStorage.setItem("composeInit", JSON.stringify(init));
|
|
2542
|
-
|
|
2577
|
+
try { frame?.contentWindow?.postMessage({ type: "compose-init-ready" }, "*"); } catch { /* */ }
|
|
2543
2578
|
}
|
|
2544
2579
|
|
|
2545
2580
|
// ── Keyboard shortcuts ──
|
|
@@ -4090,16 +4125,20 @@ function renderOutboxStatus(s: any): void {
|
|
|
4090
4125
|
setInterval(async () => {
|
|
4091
4126
|
try {
|
|
4092
4127
|
const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
|
|
4093
|
-
|
|
4094
|
-
|
|
4128
|
+
// Run in parallel — neither call depends on the other and each is a
|
|
4129
|
+
// separate IPC round-trip. Earlier code awaited them serially.
|
|
4130
|
+
const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);
|
|
4131
|
+
renderOutboxStatus(outbox);
|
|
4132
|
+
renderDiagnosticsBadge(diag);
|
|
4095
4133
|
} catch { /* service unreachable */ }
|
|
4096
4134
|
}, 15000);
|
|
4097
4135
|
// First read on startup so the bar isn't blank.
|
|
4098
4136
|
(async () => {
|
|
4099
4137
|
try {
|
|
4100
4138
|
const { getOutboxStatus, getDiagnostics } = await import("./lib/api-client.js");
|
|
4101
|
-
|
|
4102
|
-
|
|
4139
|
+
const [outbox, diag] = await Promise.all([getOutboxStatus(), getDiagnostics()]);
|
|
4140
|
+
renderOutboxStatus(outbox);
|
|
4141
|
+
renderDiagnosticsBadge(diag);
|
|
4103
4142
|
} catch { /* */ }
|
|
4104
4143
|
})();
|
|
4105
4144
|
|
|
@@ -596,6 +596,27 @@ async function createTinyMceEditor(container2, opts = {}) {
|
|
|
596
596
|
// everything that the schema allows.
|
|
597
597
|
paste_word_valid_elements: "@[style|class],-strong/b,-em/i,-u,-s,-sub,-sup,-strike,-p,-ol,-ul,-li,-h1,-h2,-h3,-h4,-h5,-h6,-blockquote,-table[border|cellpadding|cellspacing|width|height|class|style],-tr,-td[colspan|rowspan|width|height|class|style|valign|align|background|bgcolor],-th,-thead,-tbody,-tfoot,-pre,-br,-a[href|target|title],-img[src|alt|width|height|style|class]",
|
|
598
598
|
paste_retain_style_properties: "color background background-color font-family font-size font-weight font-style text-decoration text-align padding padding-top padding-bottom padding-left padding-right margin margin-top margin-bottom margin-left margin-right border border-top border-bottom border-left border-right",
|
|
599
|
+
// Auto-link bare URLs in pasted content. TinyMCE's `autolink`
|
|
600
|
+
// plugin only fires on TYPED space/enter; URLs that arrive via
|
|
601
|
+
// clipboard (browser address bar, terminal copy) come in as
|
|
602
|
+
// plain text and stay un-linked. paste_preprocess runs on the
|
|
603
|
+
// HTML the paste plugin produced.
|
|
604
|
+
//
|
|
605
|
+
// CRITICAL: skip auto-link when the content already contains
|
|
606
|
+
// anchors. Naive regex over the whole content would wrap
|
|
607
|
+
// `<a href="X">X</a>` in ANOTHER anchor (nested anchors are
|
|
608
|
+
// invalid HTML and browsers split them, producing visible
|
|
609
|
+
// junk). For HTML pastes that already have linked URLs (the
|
|
610
|
+
// common case from a browser address bar or another mail
|
|
611
|
+
// client), TinyMCE preserves them — no auto-link pass needed.
|
|
612
|
+
// For plain-text pastes (no anchors present), wrap any bare
|
|
613
|
+
// http(s)://… runs. Trailing sentence punctuation is excluded
|
|
614
|
+
// from the URL.
|
|
615
|
+
paste_preprocess: (_plugin, args) => {
|
|
616
|
+
if (/<a[\s>]/i.test(args.content))
|
|
617
|
+
return;
|
|
618
|
+
args.content = args.content.replace(/(^|[\s(\[])((?:https?|ftp):\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]])/gi, (_m, lead, url) => `${lead}<a href="${url}">${url}</a>`);
|
|
619
|
+
},
|
|
599
620
|
content_style: "body { font-family: system-ui, sans-serif; font-size: 14px; }",
|
|
600
621
|
init_instance_callback: (ed) => resolve(ed),
|
|
601
622
|
setup: (ed) => {
|
|
@@ -2941,6 +2962,15 @@ document.addEventListener("contextmenu", () => {
|
|
|
2941
2962
|
|
|
2942
2963
|
// client/compose/compose.ts
|
|
2943
2964
|
logClientEvent("compose-module-loaded", { href: location.href, version: window.mailxVersion || "?" });
|
|
2965
|
+
var _composeT0 = performance.now();
|
|
2966
|
+
function _ctick(label) {
|
|
2967
|
+
const ms = (performance.now() - _composeT0).toFixed(0).padStart(5);
|
|
2968
|
+
try {
|
|
2969
|
+
logClientEvent(`compose-tick ${ms}ms ${label}`);
|
|
2970
|
+
} catch {
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
_ctick("module body executing");
|
|
2944
2974
|
function closeCompose() {
|
|
2945
2975
|
logClientEvent("compose-close");
|
|
2946
2976
|
try {
|
|
@@ -2979,8 +3009,8 @@ async function loadEditorAssets(type) {
|
|
|
2979
3009
|
loadScript(`${cdn}/@tiptap/extension-placeholder@2/dist/index.umd.js`)
|
|
2980
3010
|
]);
|
|
2981
3011
|
} else {
|
|
2982
|
-
loadCSS("
|
|
2983
|
-
await loadScript("
|
|
3012
|
+
loadCSS("../lib/quill/quill.snow.css");
|
|
3013
|
+
await loadScript("../lib/quill/quill.js");
|
|
2984
3014
|
}
|
|
2985
3015
|
}
|
|
2986
3016
|
var editorType = "quill";
|
|
@@ -3062,7 +3092,9 @@ async function tryEditor(type) {
|
|
|
3062
3092
|
}
|
|
3063
3093
|
}
|
|
3064
3094
|
var activeEditorType = editorType;
|
|
3095
|
+
_ctick(`editor load start (${editorType})`);
|
|
3065
3096
|
editor = await tryEditor(editorType);
|
|
3097
|
+
_ctick(`editor load end (${editorType}, ok=${!!editor})`);
|
|
3066
3098
|
if (!editor) {
|
|
3067
3099
|
const fallbackType = editorType === "quill" ? "tiptap" : "quill";
|
|
3068
3100
|
logClientEvent("compose-editor-fallback-other", { from: editorType, to: fallbackType });
|
|
@@ -3647,13 +3679,41 @@ function scheduleDraftSave() {
|
|
|
3647
3679
|
});
|
|
3648
3680
|
}, DRAFT_INPUT_DEBOUNCE_MS);
|
|
3649
3681
|
}
|
|
3682
|
+
var _parentInitReady = !!sessionStorage.getItem("composeInit");
|
|
3683
|
+
var _parentInitListeners = [];
|
|
3684
|
+
window.addEventListener("message", (e) => {
|
|
3685
|
+
if (e.data?.type !== "compose-init-ready") return;
|
|
3686
|
+
_parentInitReady = true;
|
|
3687
|
+
for (const fn of _parentInitListeners.splice(0)) fn();
|
|
3688
|
+
});
|
|
3689
|
+
function waitForParentInit(maxMs) {
|
|
3690
|
+
if (_parentInitReady) return Promise.resolve();
|
|
3691
|
+
return new Promise((resolve) => {
|
|
3692
|
+
const timer = setTimeout(() => {
|
|
3693
|
+
_parentInitReady = true;
|
|
3694
|
+
resolve();
|
|
3695
|
+
}, maxMs);
|
|
3696
|
+
_parentInitListeners.push(() => {
|
|
3697
|
+
clearTimeout(timer);
|
|
3698
|
+
resolve();
|
|
3699
|
+
});
|
|
3700
|
+
});
|
|
3701
|
+
}
|
|
3650
3702
|
(async () => {
|
|
3703
|
+
_ctick("init IIFE start");
|
|
3704
|
+
if (!sessionStorage.getItem("composeInit")) {
|
|
3705
|
+
_ctick("waiting for parent init");
|
|
3706
|
+
await waitForParentInit(1500);
|
|
3707
|
+
_ctick("parent init received");
|
|
3708
|
+
}
|
|
3651
3709
|
const stored = sessionStorage.getItem("composeInit");
|
|
3652
3710
|
if (stored) {
|
|
3653
3711
|
sessionStorage.removeItem("composeInit");
|
|
3654
3712
|
const init = JSON.parse(stored);
|
|
3713
|
+
_ctick(`init parsed (mode=${init.mode}, bodyHtml=${init.bodyHtml?.length || 0} bytes)`);
|
|
3655
3714
|
if (init.accounts && init.accounts.length > 0) {
|
|
3656
3715
|
applyInit(init);
|
|
3716
|
+
_ctick("applyInit done \u2014 compose visible");
|
|
3657
3717
|
getAccounts().then((fresh) => {
|
|
3658
3718
|
if (Array.isArray(fresh) && fresh.length > 0) {
|
|
3659
3719
|
init.accounts = fresh;
|