@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.js
CHANGED
|
@@ -7,7 +7,7 @@ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, rel
|
|
|
7
7
|
import { seenOf, flaggedOf, draftOf, setSeen, setFlagged } from "@bobfrankston/mailx-types";
|
|
8
8
|
import { initTabs, setActiveView as setActiveTabView, openTab } from "./components/tabs.js";
|
|
9
9
|
import { getCurrentMessage, initViewer, popOutCurrentMessage, toggleFullscreenPreview, showPreviewBodyMenu, wrapHtmlBody } from "./components/message-viewer.js";
|
|
10
|
-
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore } from "./lib/api-client.js";
|
|
10
|
+
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessage, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages, logClientEvent, sendMessage as apiSendMessage, subscribeStore } from "./lib/api-client.js";
|
|
11
11
|
import * as messageState from "./lib/message-state.js";
|
|
12
12
|
// ── New message badge (favicon + title) ──
|
|
13
13
|
/** The user-visible app name. Single point of change for the rename;
|
|
@@ -413,8 +413,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
413
413
|
currentFolderId = folderId;
|
|
414
414
|
// Drop search state on folder switch — input alone wasn't enough,
|
|
415
415
|
// searchMode stayed true and the next loadMessages was rerouted.
|
|
416
|
-
if (searchInput)
|
|
416
|
+
if (searchInput) {
|
|
417
417
|
searchInput.value = "";
|
|
418
|
+
updateSearchHighlight();
|
|
419
|
+
}
|
|
418
420
|
clearSearchMode();
|
|
419
421
|
markAsSeen();
|
|
420
422
|
releaseFocus();
|
|
@@ -433,8 +435,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
433
435
|
// stayed active, so "All Inboxes" appeared to be ignoring its own
|
|
434
436
|
// contents (Bob 2026-05-09: "I just switched to all inboxes but the
|
|
435
437
|
// search is still showing even though all the entries are there.").
|
|
436
|
-
if (searchInput)
|
|
438
|
+
if (searchInput) {
|
|
437
439
|
searchInput.value = "";
|
|
440
|
+
updateSearchHighlight();
|
|
441
|
+
}
|
|
438
442
|
clearSearchMode();
|
|
439
443
|
releaseFocus();
|
|
440
444
|
loadUnifiedInbox();
|
|
@@ -448,8 +452,10 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
448
452
|
// setActiveTabView (that would be a no-op update, but keeping them separate
|
|
449
453
|
// keeps "user navigated" distinct from "tab restored").
|
|
450
454
|
function applyTabView(tab) {
|
|
451
|
-
if (searchInput)
|
|
455
|
+
if (searchInput) {
|
|
452
456
|
searchInput.value = "";
|
|
457
|
+
updateSearchHighlight();
|
|
458
|
+
}
|
|
453
459
|
clearSearchMode();
|
|
454
460
|
releaseFocus();
|
|
455
461
|
const v = tab.view;
|
|
@@ -468,8 +474,10 @@ function applyTabView(tab) {
|
|
|
468
474
|
setNarrowFolderTitle(tab.title);
|
|
469
475
|
}
|
|
470
476
|
else {
|
|
471
|
-
if (searchInput)
|
|
477
|
+
if (searchInput) {
|
|
472
478
|
searchInput.value = v.query;
|
|
479
|
+
updateSearchHighlight();
|
|
480
|
+
}
|
|
473
481
|
loadSearchResults(v.query, v.scope, v.accountId, v.folderId, v.includeTrash);
|
|
474
482
|
setTitle(`${APP_NAME} - Search`);
|
|
475
483
|
setNarrowFolderTitle(`Search: ${v.query}`);
|
|
@@ -784,9 +792,14 @@ document.getElementById("btn-factory-reset")?.addEventListener("click", async ()
|
|
|
784
792
|
location.reload();
|
|
785
793
|
}
|
|
786
794
|
});
|
|
787
|
-
async function openCompose(mode) {
|
|
795
|
+
async function openCompose(mode, overrideMsg, overrideAccountId) {
|
|
788
796
|
logClientEvent("openCompose-entry", { mode });
|
|
789
|
-
|
|
797
|
+
// `overrideMsg` lets a detached surface (the message pop-out) compose a
|
|
798
|
+
// reply/forward for ITS message rather than whatever the main viewer has
|
|
799
|
+
// selected. Same `{ message, accountId }` shape getCurrentMessage returns.
|
|
800
|
+
const current = overrideMsg
|
|
801
|
+
? { message: overrideMsg, accountId: overrideAccountId || currentAccountId }
|
|
802
|
+
: getCurrentMessage();
|
|
790
803
|
// Local-first: if the row is selected we already have its headers in the
|
|
791
804
|
// local DB. Populate the compose form unconditionally; the user can edit
|
|
792
805
|
// anything missing. Don't show "still loading" alerts — the message IS
|
|
@@ -1888,6 +1901,83 @@ function recordSearchHistory(query) {
|
|
|
1888
1901
|
refreshSearchHistoryDatalist();
|
|
1889
1902
|
}
|
|
1890
1903
|
refreshSearchHistoryDatalist();
|
|
1904
|
+
// ── Live search-syntax highlighting ──
|
|
1905
|
+
// An overlay behind the search input colors recognized qualifiers
|
|
1906
|
+
// (from:/to:/subject:/date:/after:/before:/has:/is:/folder:), FTS boolean
|
|
1907
|
+
// operators (OR/NOT/AND), quoted phrases, and /regex/ literals — reparsed
|
|
1908
|
+
// on every keystroke so the user sees how mailx will interpret the query.
|
|
1909
|
+
// An invalid /regex/ only goes red after a brief settle delay, so it
|
|
1910
|
+
// doesn't flash red while the pattern is still being typed.
|
|
1911
|
+
const searchHl = document.getElementById("search-hl");
|
|
1912
|
+
const SEARCH_QUALIFIERS = new Set(["from", "to", "subject", "date", "after", "before", "has", "is", "folder"]);
|
|
1913
|
+
const SEARCH_REGEX_SETTLE_MS = 500;
|
|
1914
|
+
let searchRegexSettleTimer = null;
|
|
1915
|
+
function escapeHl(s) {
|
|
1916
|
+
return s.replace(/[&<>]/g, c => ({ "&": "&", "<": "<", ">": ">" }[c] || c));
|
|
1917
|
+
}
|
|
1918
|
+
function renderSearchHighlight(settled) {
|
|
1919
|
+
if (!searchHl || !searchInput)
|
|
1920
|
+
return;
|
|
1921
|
+
const text = searchInput.value;
|
|
1922
|
+
// Tokenize keeping whitespace runs so the overlay lines up glyph-for-
|
|
1923
|
+
// glyph with the input's own text.
|
|
1924
|
+
const tokens = text.match(/\s+|"[^"]*"?|\S+/g) || [];
|
|
1925
|
+
let html = "";
|
|
1926
|
+
for (const tok of tokens) {
|
|
1927
|
+
if (/^\s+$/.test(tok)) {
|
|
1928
|
+
html += escapeHl(tok);
|
|
1929
|
+
continue;
|
|
1930
|
+
}
|
|
1931
|
+
// /regex/ literal — leading slash, body, optional closing slash.
|
|
1932
|
+
if (tok.startsWith("/") && tok.length > 1) {
|
|
1933
|
+
let bad = false;
|
|
1934
|
+
if (tok.endsWith("/")) {
|
|
1935
|
+
try {
|
|
1936
|
+
new RegExp(tok.slice(1, -1), "i");
|
|
1937
|
+
}
|
|
1938
|
+
catch {
|
|
1939
|
+
bad = true;
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
const cls = (bad && settled) ? "sh-regex-bad" : "sh-regex";
|
|
1943
|
+
html += `<span class="${cls}">${escapeHl(tok)}</span>`;
|
|
1944
|
+
continue;
|
|
1945
|
+
}
|
|
1946
|
+
// qualifier:value — color the keyword: prefix when recognized.
|
|
1947
|
+
const qm = tok.match(/^([a-z]+):(.*)$/i);
|
|
1948
|
+
if (qm && SEARCH_QUALIFIERS.has(qm[1].toLowerCase())) {
|
|
1949
|
+
html += `<span class="sh-key">${escapeHl(qm[1] + ":")}</span>${escapeHl(qm[2])}`;
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
// FTS boolean operator.
|
|
1953
|
+
if (/^(OR|NOT|AND)$/.test(tok)) {
|
|
1954
|
+
html += `<span class="sh-op">${escapeHl(tok)}</span>`;
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
// Quoted phrase.
|
|
1958
|
+
if (tok.startsWith("\"")) {
|
|
1959
|
+
html += `<span class="sh-phrase">${escapeHl(tok)}</span>`;
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
html += escapeHl(tok);
|
|
1963
|
+
}
|
|
1964
|
+
searchHl.innerHTML = html;
|
|
1965
|
+
searchHl.scrollLeft = searchInput.scrollLeft;
|
|
1966
|
+
}
|
|
1967
|
+
function updateSearchHighlight() {
|
|
1968
|
+
renderSearchHighlight(false);
|
|
1969
|
+
if (searchRegexSettleTimer)
|
|
1970
|
+
clearTimeout(searchRegexSettleTimer);
|
|
1971
|
+
searchRegexSettleTimer = setTimeout(() => renderSearchHighlight(true), SEARCH_REGEX_SETTLE_MS);
|
|
1972
|
+
}
|
|
1973
|
+
// Keep the overlay scroll-aligned when the input scrolls horizontally,
|
|
1974
|
+
// and seed it once at startup.
|
|
1975
|
+
searchInput?.addEventListener("scroll", () => {
|
|
1976
|
+
if (searchHl)
|
|
1977
|
+
searchHl.scrollLeft = searchInput.scrollLeft;
|
|
1978
|
+
});
|
|
1979
|
+
searchInput?.addEventListener("change", () => updateSearchHighlight());
|
|
1980
|
+
updateSearchHighlight();
|
|
1891
1981
|
function doSearch(immediate = false) {
|
|
1892
1982
|
const query = searchInput.value.trim();
|
|
1893
1983
|
if (query.length === 0) {
|
|
@@ -1931,6 +2021,7 @@ let currentFolderId = 0;
|
|
|
1931
2021
|
let reloadDebounceTimer = null;
|
|
1932
2022
|
searchInput?.addEventListener("input", () => {
|
|
1933
2023
|
clearTimeout(searchTimeout);
|
|
2024
|
+
updateSearchHighlight();
|
|
1934
2025
|
if (searchInput.value.trim() === "") {
|
|
1935
2026
|
// Cleared — reset immediately, no debounce. Must exit search mode
|
|
1936
2027
|
// first; otherwise reloadCurrentFolder() sees searchMode=true and
|
|
@@ -1953,6 +2044,7 @@ searchInput?.addEventListener("keydown", (e) => {
|
|
|
1953
2044
|
}
|
|
1954
2045
|
if (e.key === "Escape") {
|
|
1955
2046
|
searchInput.value = "";
|
|
2047
|
+
updateSearchHighlight();
|
|
1956
2048
|
clearSearchMode();
|
|
1957
2049
|
// Clear any client-side filters
|
|
1958
2050
|
const body = document.getElementById("ml-body");
|
|
@@ -3499,42 +3591,90 @@ optSnippet?.addEventListener("change", () => {
|
|
|
3499
3591
|
localStorage.setItem("mailx-snippet", String(optSnippet.checked));
|
|
3500
3592
|
});
|
|
3501
3593
|
// ── Search help button (?) ──
|
|
3502
|
-
//
|
|
3503
|
-
//
|
|
3504
|
-
//
|
|
3505
|
-
//
|
|
3506
|
-
//
|
|
3507
|
-
//
|
|
3508
|
-
//
|
|
3594
|
+
// Toggles a NON-modal panel with the search-syntax reference so it stays
|
|
3595
|
+
// visible while the user types into the search box — build a query
|
|
3596
|
+
// against it, then dismiss. The panel is anchored directly below the
|
|
3597
|
+
// search bar (no dark backdrop, no centered modal).
|
|
3598
|
+
//
|
|
3599
|
+
// The help content is HTML compiled into the app (client/help/search-help.ts),
|
|
3600
|
+
// NOT a docs/*.md file. Feature help is application content; the docs/*.md
|
|
3601
|
+
// files are only a stand-in for proper settings UI and stay out of this path.
|
|
3602
|
+
// The module is dynamically imported so its markup isn't in the cold-start
|
|
3603
|
+
// bundle.
|
|
3509
3604
|
document.getElementById("search-help")?.addEventListener("click", async () => {
|
|
3510
|
-
const
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3605
|
+
const btn = document.getElementById("search-help");
|
|
3606
|
+
if (!btn)
|
|
3607
|
+
return;
|
|
3608
|
+
// Toggle: a second click on ? closes the open panel.
|
|
3609
|
+
const existing = document.getElementById("search-help-panel");
|
|
3610
|
+
if (existing) {
|
|
3611
|
+
existing.remove();
|
|
3612
|
+
btn.setAttribute("aria-expanded", "false");
|
|
3613
|
+
return;
|
|
3614
|
+
}
|
|
3615
|
+
const { SEARCH_HELP_HTML } = await import("./help/search-help.js");
|
|
3616
|
+
const searchBar = document.querySelector("search.ml-search");
|
|
3516
3617
|
const panel = document.createElement("div");
|
|
3517
|
-
panel.
|
|
3618
|
+
panel.id = "search-help-panel";
|
|
3619
|
+
panel.className = "search-help-panel";
|
|
3518
3620
|
panel.innerHTML = `
|
|
3519
|
-
<div
|
|
3520
|
-
<span
|
|
3521
|
-
<button type="button" id="search-help-close"
|
|
3621
|
+
<div class="search-help-head">
|
|
3622
|
+
<span>Search syntax</span>
|
|
3623
|
+
<button type="button" id="search-help-close" title="Close (Esc)" aria-label="Close">×</button>
|
|
3522
3624
|
</div>
|
|
3523
|
-
<
|
|
3524
|
-
.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c] || c))}</pre>
|
|
3625
|
+
<div class="search-help-body">${SEARCH_HELP_HTML}</div>
|
|
3525
3626
|
`;
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
panel.
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3627
|
+
document.body.appendChild(panel);
|
|
3628
|
+
btn.setAttribute("aria-expanded", "true");
|
|
3629
|
+
// Anchor below the search bar, clamped to the viewport. Re-runs on
|
|
3630
|
+
// resize so the panel tracks the bar if the layout reflows.
|
|
3631
|
+
const position = () => {
|
|
3632
|
+
const anchor = searchBar || btn;
|
|
3633
|
+
const rect = anchor.getBoundingClientRect();
|
|
3634
|
+
const margin = 8;
|
|
3635
|
+
const top = rect.bottom + 2;
|
|
3636
|
+
const width = Math.min(640, Math.max(320, rect.width));
|
|
3637
|
+
let left = rect.left;
|
|
3638
|
+
if (left + width > window.innerWidth - margin)
|
|
3639
|
+
left = window.innerWidth - margin - width;
|
|
3640
|
+
if (left < margin)
|
|
3641
|
+
left = margin;
|
|
3642
|
+
panel.style.top = `${top}px`;
|
|
3643
|
+
panel.style.left = `${left}px`;
|
|
3644
|
+
panel.style.width = `${width}px`;
|
|
3645
|
+
panel.style.maxHeight = `${Math.max(160, window.innerHeight - top - margin)}px`;
|
|
3646
|
+
};
|
|
3647
|
+
position();
|
|
3648
|
+
const close = () => {
|
|
3649
|
+
panel.remove();
|
|
3650
|
+
btn.setAttribute("aria-expanded", "false");
|
|
3651
|
+
window.removeEventListener("resize", position);
|
|
3652
|
+
document.removeEventListener("keydown", onKey, true);
|
|
3653
|
+
document.removeEventListener("mousedown", onOutside);
|
|
3654
|
+
};
|
|
3655
|
+
// Esc always closes the help panel while it's open — including when
|
|
3656
|
+
// focus is in the search box. Capture phase + stopPropagation so the
|
|
3657
|
+
// search input doesn't also clear/blur on the same keystroke.
|
|
3658
|
+
const onKey = (e) => {
|
|
3533
3659
|
if (e.key === "Escape") {
|
|
3660
|
+
e.preventDefault();
|
|
3661
|
+
e.stopPropagation();
|
|
3534
3662
|
close();
|
|
3535
|
-
document.removeEventListener("keydown", escClose);
|
|
3536
3663
|
}
|
|
3537
|
-
}
|
|
3664
|
+
};
|
|
3665
|
+
// Click outside dismisses — except clicks on the search bar itself
|
|
3666
|
+
// (typing a query must not close the reference) or the ? button
|
|
3667
|
+
// (its own handler toggles).
|
|
3668
|
+
const onOutside = (e) => {
|
|
3669
|
+
const t = e.target;
|
|
3670
|
+
if (panel.contains(t) || btn.contains(t) || searchBar?.contains(t))
|
|
3671
|
+
return;
|
|
3672
|
+
close();
|
|
3673
|
+
};
|
|
3674
|
+
panel.querySelector("#search-help-close")?.addEventListener("click", close);
|
|
3675
|
+
window.addEventListener("resize", position);
|
|
3676
|
+
document.addEventListener("keydown", onKey, true);
|
|
3677
|
+
document.addEventListener("mousedown", onOutside);
|
|
3538
3678
|
});
|
|
3539
3679
|
// ── JSONC config file editor ──
|
|
3540
3680
|
document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () => {
|
|
@@ -4592,6 +4732,21 @@ function renderDiagnosticsBadge(snapshot) {
|
|
|
4592
4732
|
host.title = `Connection issues — ${summary}\n\n${detail}`;
|
|
4593
4733
|
}
|
|
4594
4734
|
// Q64: pop-out a message into a floating overlay (real-OS-window pending C44).
|
|
4735
|
+
// Action buttons on the message pop-out window — Reply / Reply All / Forward
|
|
4736
|
+
// act on the popped-out message specifically (not the main viewer's), Delete
|
|
4737
|
+
// removes it. The pop-out lives in message-viewer.ts and can't import
|
|
4738
|
+
// openCompose directly, so it fires this event.
|
|
4739
|
+
document.addEventListener("mailx-popout-action", ((e) => {
|
|
4740
|
+
const { action, msg, accountId } = e.detail || {};
|
|
4741
|
+
if (!msg)
|
|
4742
|
+
return;
|
|
4743
|
+
if (action === "reply" || action === "replyAll" || action === "forward") {
|
|
4744
|
+
openCompose(action, msg, accountId);
|
|
4745
|
+
}
|
|
4746
|
+
else if (action === "delete") {
|
|
4747
|
+
deleteMessage(accountId, msg.uid).catch((err) => console.error(`[popout] delete failed: ${err?.message || err}`));
|
|
4748
|
+
}
|
|
4749
|
+
}));
|
|
4595
4750
|
document.addEventListener("mailx-popout-message", (async (e) => {
|
|
4596
4751
|
const { accountId, uid, folderId, subject } = e.detail || {};
|
|
4597
4752
|
if (!accountId || !uid)
|