@bobfrankston/rmfmail 1.1.44 → 1.1.49
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/TODO.md +9 -0
- package/client/app.bundle.js +403 -83
- package/client/app.bundle.js.map +4 -4
- package/client/app.js +190 -35
- package/client/app.js.map +1 -1
- package/client/app.ts +175 -34
- package/client/components/calendar-sidebar.js +221 -88
- package/client/components/calendar-sidebar.js.map +1 -1
- package/client/components/calendar-sidebar.ts +224 -83
- package/client/components/message-viewer.js +24 -1
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +25 -1
- package/client/compose/compose.bundle.js +14 -0
- package/client/compose/compose.bundle.js.map +2 -2
- package/client/compose/spellcheck.js +15 -0
- package/client/compose/spellcheck.js.map +1 -1
- package/client/compose/spellcheck.ts +14 -0
- package/client/help/search-help.js +75 -0
- package/client/help/search-help.js.map +1 -0
- package/client/help/search-help.ts +75 -0
- package/client/index.html +7 -7
- package/client/lib/api-client.js +5 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +5 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +204 -6
- package/docs/search.md +5 -1
- package/package.json +1 -1
- package/packages/mailx-service/google-sync.d.ts +3 -0
- package/packages/mailx-service/google-sync.d.ts.map +1 -1
- package/packages/mailx-service/google-sync.js +1 -0
- package/packages/mailx-service/google-sync.js.map +1 -1
- package/packages/mailx-service/google-sync.ts +4 -0
- package/packages/mailx-service/index.d.ts +11 -0
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +99 -127
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +91 -122
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +2 -0
- package/packages/mailx-settings/index.d.ts.map +1 -1
- package/packages/mailx-settings/index.js +4 -1
- package/packages/mailx-settings/index.js.map +1 -1
- package/packages/mailx-settings/index.ts +4 -1
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-45524 → node_modules.npmglobalize-stash-43988}/.package-lock.json +0 -0
package/client/app.ts
CHANGED
|
@@ -406,7 +406,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
406
406
|
currentFolderId = folderId;
|
|
407
407
|
// Drop search state on folder switch — input alone wasn't enough,
|
|
408
408
|
// searchMode stayed true and the next loadMessages was rerouted.
|
|
409
|
-
if (searchInput) searchInput.value = "";
|
|
409
|
+
if (searchInput) { searchInput.value = ""; updateSearchHighlight(); }
|
|
410
410
|
clearSearchMode();
|
|
411
411
|
markAsSeen();
|
|
412
412
|
releaseFocus();
|
|
@@ -425,7 +425,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
425
425
|
// stayed active, so "All Inboxes" appeared to be ignoring its own
|
|
426
426
|
// contents (Bob 2026-05-09: "I just switched to all inboxes but the
|
|
427
427
|
// search is still showing even though all the entries are there.").
|
|
428
|
-
if (searchInput) searchInput.value = "";
|
|
428
|
+
if (searchInput) { searchInput.value = ""; updateSearchHighlight(); }
|
|
429
429
|
clearSearchMode();
|
|
430
430
|
releaseFocus();
|
|
431
431
|
loadUnifiedInbox();
|
|
@@ -440,7 +440,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
440
440
|
// setActiveTabView (that would be a no-op update, but keeping them separate
|
|
441
441
|
// keeps "user navigated" distinct from "tab restored").
|
|
442
442
|
function applyTabView(tab: ViewTab): void {
|
|
443
|
-
if (searchInput) searchInput.value = "";
|
|
443
|
+
if (searchInput) { searchInput.value = ""; updateSearchHighlight(); }
|
|
444
444
|
clearSearchMode();
|
|
445
445
|
releaseFocus();
|
|
446
446
|
const v = tab.view;
|
|
@@ -457,7 +457,7 @@ function applyTabView(tab: ViewTab): void {
|
|
|
457
457
|
setTitle(`${APP_NAME} - ${tab.title}`);
|
|
458
458
|
setNarrowFolderTitle(tab.title);
|
|
459
459
|
} else {
|
|
460
|
-
if (searchInput) searchInput.value = v.query;
|
|
460
|
+
if (searchInput) { searchInput.value = v.query; updateSearchHighlight(); }
|
|
461
461
|
loadSearchResults(v.query, v.scope, v.accountId, v.folderId, v.includeTrash);
|
|
462
462
|
setTitle(`${APP_NAME} - Search`);
|
|
463
463
|
setNarrowFolderTitle(`Search: ${v.query}`);
|
|
@@ -752,9 +752,14 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
|
|
|
752
752
|
|
|
753
753
|
type ComposeMode = "new" | "reply" | "replyAll" | "forward";
|
|
754
754
|
|
|
755
|
-
async function openCompose(mode: ComposeMode): Promise<void> {
|
|
755
|
+
async function openCompose(mode: ComposeMode, overrideMsg?: any, overrideAccountId?: string): Promise<void> {
|
|
756
756
|
logClientEvent("openCompose-entry", { mode });
|
|
757
|
-
|
|
757
|
+
// `overrideMsg` lets a detached surface (the message pop-out) compose a
|
|
758
|
+
// reply/forward for ITS message rather than whatever the main viewer has
|
|
759
|
+
// selected. Same `{ message, accountId }` shape getCurrentMessage returns.
|
|
760
|
+
const current = overrideMsg
|
|
761
|
+
? { message: overrideMsg, accountId: overrideAccountId || currentAccountId }
|
|
762
|
+
: getCurrentMessage();
|
|
758
763
|
// Local-first: if the row is selected we already have its headers in the
|
|
759
764
|
// local DB. Populate the compose form unconditionally; the user can edit
|
|
760
765
|
// anything missing. Don't show "still loading" alerts — the message IS
|
|
@@ -1812,6 +1817,77 @@ function recordSearchHistory(query: string): void {
|
|
|
1812
1817
|
}
|
|
1813
1818
|
refreshSearchHistoryDatalist();
|
|
1814
1819
|
|
|
1820
|
+
// ── Live search-syntax highlighting ──
|
|
1821
|
+
// An overlay behind the search input colors recognized qualifiers
|
|
1822
|
+
// (from:/to:/subject:/date:/after:/before:/has:/is:/folder:), FTS boolean
|
|
1823
|
+
// operators (OR/NOT/AND), quoted phrases, and /regex/ literals — reparsed
|
|
1824
|
+
// on every keystroke so the user sees how mailx will interpret the query.
|
|
1825
|
+
// An invalid /regex/ only goes red after a brief settle delay, so it
|
|
1826
|
+
// doesn't flash red while the pattern is still being typed.
|
|
1827
|
+
const searchHl = document.getElementById("search-hl");
|
|
1828
|
+
const SEARCH_QUALIFIERS = new Set(["from", "to", "subject", "date", "after", "before", "has", "is", "folder"]);
|
|
1829
|
+
const SEARCH_REGEX_SETTLE_MS = 500;
|
|
1830
|
+
let searchRegexSettleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1831
|
+
|
|
1832
|
+
function escapeHl(s: string): string {
|
|
1833
|
+
return s.replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c] || c));
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function renderSearchHighlight(settled: boolean): void {
|
|
1837
|
+
if (!searchHl || !searchInput) return;
|
|
1838
|
+
const text = searchInput.value;
|
|
1839
|
+
// Tokenize keeping whitespace runs so the overlay lines up glyph-for-
|
|
1840
|
+
// glyph with the input's own text.
|
|
1841
|
+
const tokens = text.match(/\s+|"[^"]*"?|\S+/g) || [];
|
|
1842
|
+
let html = "";
|
|
1843
|
+
for (const tok of tokens) {
|
|
1844
|
+
if (/^\s+$/.test(tok)) { html += escapeHl(tok); continue; }
|
|
1845
|
+
// /regex/ literal — leading slash, body, optional closing slash.
|
|
1846
|
+
if (tok.startsWith("/") && tok.length > 1) {
|
|
1847
|
+
let bad = false;
|
|
1848
|
+
if (tok.endsWith("/")) {
|
|
1849
|
+
try { new RegExp(tok.slice(1, -1), "i"); } catch { bad = true; }
|
|
1850
|
+
}
|
|
1851
|
+
const cls = (bad && settled) ? "sh-regex-bad" : "sh-regex";
|
|
1852
|
+
html += `<span class="${cls}">${escapeHl(tok)}</span>`;
|
|
1853
|
+
continue;
|
|
1854
|
+
}
|
|
1855
|
+
// qualifier:value — color the keyword: prefix when recognized.
|
|
1856
|
+
const qm = tok.match(/^([a-z]+):(.*)$/i);
|
|
1857
|
+
if (qm && SEARCH_QUALIFIERS.has(qm[1].toLowerCase())) {
|
|
1858
|
+
html += `<span class="sh-key">${escapeHl(qm[1] + ":")}</span>${escapeHl(qm[2])}`;
|
|
1859
|
+
continue;
|
|
1860
|
+
}
|
|
1861
|
+
// FTS boolean operator.
|
|
1862
|
+
if (/^(OR|NOT|AND)$/.test(tok)) {
|
|
1863
|
+
html += `<span class="sh-op">${escapeHl(tok)}</span>`;
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
// Quoted phrase.
|
|
1867
|
+
if (tok.startsWith("\"")) {
|
|
1868
|
+
html += `<span class="sh-phrase">${escapeHl(tok)}</span>`;
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
html += escapeHl(tok);
|
|
1872
|
+
}
|
|
1873
|
+
searchHl.innerHTML = html;
|
|
1874
|
+
searchHl.scrollLeft = searchInput.scrollLeft;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
function updateSearchHighlight(): void {
|
|
1878
|
+
renderSearchHighlight(false);
|
|
1879
|
+
if (searchRegexSettleTimer) clearTimeout(searchRegexSettleTimer);
|
|
1880
|
+
searchRegexSettleTimer = setTimeout(() => renderSearchHighlight(true), SEARCH_REGEX_SETTLE_MS);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// Keep the overlay scroll-aligned when the input scrolls horizontally,
|
|
1884
|
+
// and seed it once at startup.
|
|
1885
|
+
searchInput?.addEventListener("scroll", () => {
|
|
1886
|
+
if (searchHl) searchHl.scrollLeft = searchInput.scrollLeft;
|
|
1887
|
+
});
|
|
1888
|
+
searchInput?.addEventListener("change", () => updateSearchHighlight());
|
|
1889
|
+
updateSearchHighlight();
|
|
1890
|
+
|
|
1815
1891
|
function doSearch(immediate = false): void {
|
|
1816
1892
|
const query = searchInput.value.trim();
|
|
1817
1893
|
if (query.length === 0) { reloadCurrentFolder(); return; }
|
|
@@ -1858,6 +1934,7 @@ let reloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
1858
1934
|
|
|
1859
1935
|
searchInput?.addEventListener("input", () => {
|
|
1860
1936
|
clearTimeout(searchTimeout);
|
|
1937
|
+
updateSearchHighlight();
|
|
1861
1938
|
if (searchInput.value.trim() === "") {
|
|
1862
1939
|
// Cleared — reset immediately, no debounce. Must exit search mode
|
|
1863
1940
|
// first; otherwise reloadCurrentFolder() sees searchMode=true and
|
|
@@ -1878,6 +1955,7 @@ searchInput?.addEventListener("keydown", (e) => {
|
|
|
1878
1955
|
}
|
|
1879
1956
|
if (e.key === "Escape") {
|
|
1880
1957
|
searchInput.value = "";
|
|
1958
|
+
updateSearchHighlight();
|
|
1881
1959
|
clearSearchMode();
|
|
1882
1960
|
// Clear any client-side filters
|
|
1883
1961
|
const body = document.getElementById("ml-body");
|
|
@@ -3314,40 +3392,88 @@ optSnippet?.addEventListener("change", () => {
|
|
|
3314
3392
|
});
|
|
3315
3393
|
|
|
3316
3394
|
// ── Search help button (?) ──
|
|
3317
|
-
//
|
|
3318
|
-
//
|
|
3319
|
-
//
|
|
3320
|
-
//
|
|
3321
|
-
//
|
|
3322
|
-
//
|
|
3323
|
-
//
|
|
3395
|
+
// Toggles a NON-modal panel with the search-syntax reference so it stays
|
|
3396
|
+
// visible while the user types into the search box — build a query
|
|
3397
|
+
// against it, then dismiss. The panel is anchored directly below the
|
|
3398
|
+
// search bar (no dark backdrop, no centered modal).
|
|
3399
|
+
//
|
|
3400
|
+
// The help content is HTML compiled into the app (client/help/search-help.ts),
|
|
3401
|
+
// NOT a docs/*.md file. Feature help is application content; the docs/*.md
|
|
3402
|
+
// files are only a stand-in for proper settings UI and stay out of this path.
|
|
3403
|
+
// The module is dynamically imported so its markup isn't in the cold-start
|
|
3404
|
+
// bundle.
|
|
3324
3405
|
document.getElementById("search-help")?.addEventListener("click", async () => {
|
|
3325
|
-
const
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3406
|
+
const btn = document.getElementById("search-help") as HTMLButtonElement | null;
|
|
3407
|
+
if (!btn) return;
|
|
3408
|
+
|
|
3409
|
+
// Toggle: a second click on ? closes the open panel.
|
|
3410
|
+
const existing = document.getElementById("search-help-panel");
|
|
3411
|
+
if (existing) { existing.remove(); btn.setAttribute("aria-expanded", "false"); return; }
|
|
3412
|
+
|
|
3413
|
+
const { SEARCH_HELP_HTML } = await import("./help/search-help.js");
|
|
3414
|
+
|
|
3415
|
+
const searchBar = document.querySelector("search.ml-search") as HTMLElement | null;
|
|
3416
|
+
|
|
3331
3417
|
const panel = document.createElement("div");
|
|
3332
|
-
panel.
|
|
3418
|
+
panel.id = "search-help-panel";
|
|
3419
|
+
panel.className = "search-help-panel";
|
|
3333
3420
|
panel.innerHTML = `
|
|
3334
|
-
<div
|
|
3335
|
-
<span
|
|
3336
|
-
<button type="button" id="search-help-close"
|
|
3421
|
+
<div class="search-help-head">
|
|
3422
|
+
<span>Search syntax</span>
|
|
3423
|
+
<button type="button" id="search-help-close" title="Close (Esc)" aria-label="Close">×</button>
|
|
3337
3424
|
</div>
|
|
3338
|
-
<
|
|
3339
|
-
(md || "Help not available — check that docs/search.md is deployed.")
|
|
3340
|
-
.replace(/[&<>]/g, (c: string) => ({ "&": "&", "<": "<", ">": ">" }[c] || c))
|
|
3341
|
-
}</pre>
|
|
3425
|
+
<div class="search-help-body">${SEARCH_HELP_HTML}</div>
|
|
3342
3426
|
`;
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3427
|
+
document.body.appendChild(panel);
|
|
3428
|
+
btn.setAttribute("aria-expanded", "true");
|
|
3429
|
+
|
|
3430
|
+
// Anchor below the search bar, clamped to the viewport. Re-runs on
|
|
3431
|
+
// resize so the panel tracks the bar if the layout reflows.
|
|
3432
|
+
const position = (): void => {
|
|
3433
|
+
const anchor = searchBar || btn;
|
|
3434
|
+
const rect = anchor.getBoundingClientRect();
|
|
3435
|
+
const margin = 8;
|
|
3436
|
+
const top = rect.bottom + 2;
|
|
3437
|
+
const width = Math.min(640, Math.max(320, rect.width));
|
|
3438
|
+
let left = rect.left;
|
|
3439
|
+
if (left + width > window.innerWidth - margin) left = window.innerWidth - margin - width;
|
|
3440
|
+
if (left < margin) left = margin;
|
|
3441
|
+
panel.style.top = `${top}px`;
|
|
3442
|
+
panel.style.left = `${left}px`;
|
|
3443
|
+
panel.style.width = `${width}px`;
|
|
3444
|
+
panel.style.maxHeight = `${Math.max(160, window.innerHeight - top - margin)}px`;
|
|
3445
|
+
};
|
|
3446
|
+
position();
|
|
3447
|
+
|
|
3448
|
+
const close = (): void => {
|
|
3449
|
+
panel.remove();
|
|
3450
|
+
btn.setAttribute("aria-expanded", "false");
|
|
3451
|
+
window.removeEventListener("resize", position);
|
|
3452
|
+
document.removeEventListener("keydown", onKey, true);
|
|
3453
|
+
document.removeEventListener("mousedown", onOutside);
|
|
3454
|
+
};
|
|
3455
|
+
// Esc always closes the help panel while it's open — including when
|
|
3456
|
+
// focus is in the search box. Capture phase + stopPropagation so the
|
|
3457
|
+
// search input doesn't also clear/blur on the same keystroke.
|
|
3458
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
3459
|
+
if (e.key === "Escape") {
|
|
3460
|
+
e.preventDefault();
|
|
3461
|
+
e.stopPropagation();
|
|
3462
|
+
close();
|
|
3463
|
+
}
|
|
3464
|
+
};
|
|
3465
|
+
// Click outside dismisses — except clicks on the search bar itself
|
|
3466
|
+
// (typing a query must not close the reference) or the ? button
|
|
3467
|
+
// (its own handler toggles).
|
|
3468
|
+
const onOutside = (e: MouseEvent): void => {
|
|
3469
|
+
const t = e.target as Node;
|
|
3470
|
+
if (panel.contains(t) || btn.contains(t) || searchBar?.contains(t)) return;
|
|
3471
|
+
close();
|
|
3472
|
+
};
|
|
3346
3473
|
panel.querySelector("#search-help-close")?.addEventListener("click", close);
|
|
3347
|
-
|
|
3348
|
-
document.addEventListener("keydown",
|
|
3349
|
-
|
|
3350
|
-
});
|
|
3474
|
+
window.addEventListener("resize", position);
|
|
3475
|
+
document.addEventListener("keydown", onKey, true);
|
|
3476
|
+
document.addEventListener("mousedown", onOutside);
|
|
3351
3477
|
});
|
|
3352
3478
|
|
|
3353
3479
|
// ── JSONC config file editor ──
|
|
@@ -4288,6 +4414,21 @@ function renderDiagnosticsBadge(snapshot: any[]): void {
|
|
|
4288
4414
|
host.title = `Connection issues — ${summary}\n\n${detail}`;
|
|
4289
4415
|
}
|
|
4290
4416
|
// Q64: pop-out a message into a floating overlay (real-OS-window pending C44).
|
|
4417
|
+
// Action buttons on the message pop-out window — Reply / Reply All / Forward
|
|
4418
|
+
// act on the popped-out message specifically (not the main viewer's), Delete
|
|
4419
|
+
// removes it. The pop-out lives in message-viewer.ts and can't import
|
|
4420
|
+
// openCompose directly, so it fires this event.
|
|
4421
|
+
document.addEventListener("mailx-popout-action", ((e: any) => {
|
|
4422
|
+
const { action, msg, accountId } = e.detail || {};
|
|
4423
|
+
if (!msg) return;
|
|
4424
|
+
if (action === "reply" || action === "replyAll" || action === "forward") {
|
|
4425
|
+
openCompose(action as ComposeMode, msg, accountId);
|
|
4426
|
+
} else if (action === "delete") {
|
|
4427
|
+
deleteMessage(accountId, msg.uid).catch((err: any) =>
|
|
4428
|
+
console.error(`[popout] delete failed: ${err?.message || err}`));
|
|
4429
|
+
}
|
|
4430
|
+
}) as EventListener);
|
|
4431
|
+
|
|
4291
4432
|
document.addEventListener("mailx-popout-message", (async (e: any) => {
|
|
4292
4433
|
const { accountId, uid, folderId, subject } = e.detail || {};
|
|
4293
4434
|
if (!accountId || !uid) return;
|