@bobfrankston/mailx 1.0.379 → 1.0.382
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/mailx.js +18 -0
- package/client/app.js +107 -1
- package/client/components/calendar-sidebar.js +6 -1
- package/client/components/folder-tree.js +25 -0
- package/client/components/message-list.js +75 -1
- package/client/components/message-viewer.js +7 -0
- package/client/index.html +6 -4
- package/client/lib/api-client.js +2 -2
- package/client/styles/components.css +124 -0
- package/client/styles/layout.css +17 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +26 -1
- package/packages/mailx-service/index.js +4 -4
- package/packages/mailx-store/db.d.ts +4 -0
- package/packages/mailx-store/db.js +37 -4
- package/packages/mailx-store-web/db.js +54 -0
- package/packages/mailx-store-web/web-service.js +19 -10
- package/packages/mailx-types/index.d.ts +15 -3
- package/packages/mailx-types/index.js +90 -2
package/bin/mailx.js
CHANGED
|
@@ -958,6 +958,18 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
958
958
|
catch (e) {
|
|
959
959
|
console.error(` [readme] Could not write sending README: ${e?.message || e}`);
|
|
960
960
|
}
|
|
961
|
+
// `--server` mode — hand off to the Express HTTP server package (self-
|
|
962
|
+
// contained; initializes its own DB / IMAP manager / MailxService). No
|
|
963
|
+
// WebView2, no IPC bridge. Useful for remote access (phone before MAUI
|
|
964
|
+
// was a thing) and for JSON-RPC debugging from devtools. Loopback by
|
|
965
|
+
// default; passes --external through if the user asked for it. See C34.
|
|
966
|
+
if (hasFlag("server")) {
|
|
967
|
+
writeInstanceFile(process.pid);
|
|
968
|
+
await import("@bobfrankston/mailx-server");
|
|
969
|
+
// mailx-server's index.ts self-invokes `start()` — once imported,
|
|
970
|
+
// Express owns the event loop. Don't fall through to showService.
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
961
973
|
const { NodeTcpTransport } = await import("@bobfrankston/node-tcp-transport");
|
|
962
974
|
const imapManager = new ImapManager(db, () => new NodeTcpTransport());
|
|
963
975
|
// Native client is the only option (iflow-direct)
|
|
@@ -1310,6 +1322,12 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
1310
1322
|
console.error(` [contacts] periodic seed error: ${e.message}`);
|
|
1311
1323
|
}
|
|
1312
1324
|
}, 30 * 60_000);
|
|
1325
|
+
// Drain store_sync (calendar / tasks / contacts two-way pushes) every
|
|
1326
|
+
// 30s. Local edits also drain immediately; this picks up rows that
|
|
1327
|
+
// failed their first attempt (network blip, token refresh, 5xx).
|
|
1328
|
+
setInterval(() => {
|
|
1329
|
+
svc.drainStoreSync().catch((e) => console.error(` [store_sync] periodic drain error: ${e?.message || e}`));
|
|
1330
|
+
}, 30_000);
|
|
1313
1331
|
// Auto-update: periodically check npm for a newer version and push a
|
|
1314
1332
|
// notification to the WebView so the user can update with one click.
|
|
1315
1333
|
const UPDATE_CHECK_MS = 30 * 60_000; // 30 minutes
|
package/client/app.js
CHANGED
|
@@ -82,6 +82,11 @@ async function updateNewMessageCount() {
|
|
|
82
82
|
if (inbox)
|
|
83
83
|
totalUnread += inbox.unreadCount || 0;
|
|
84
84
|
}
|
|
85
|
+
// Rail badge: unread count on the Inbox and Unified-inbox rail buttons.
|
|
86
|
+
// Visible even when those views aren't the active one — part of C33
|
|
87
|
+
// "rail icon badges for unread counts."
|
|
88
|
+
updateRailBadge("rail-inbox", totalUnread);
|
|
89
|
+
updateRailBadge("rail-unified", totalUnread);
|
|
85
90
|
// First load: set baseline
|
|
86
91
|
if (lastSeenCount === 0) {
|
|
87
92
|
lastSeenCount = totalUnread;
|
|
@@ -100,6 +105,23 @@ async function updateNewMessageCount() {
|
|
|
100
105
|
}
|
|
101
106
|
catch { /* offline */ }
|
|
102
107
|
}
|
|
108
|
+
function updateRailBadge(buttonId, count) {
|
|
109
|
+
const btn = document.getElementById(buttonId);
|
|
110
|
+
if (!btn)
|
|
111
|
+
return;
|
|
112
|
+
let badge = btn.querySelector(".rail-badge");
|
|
113
|
+
if (count <= 0) {
|
|
114
|
+
if (badge)
|
|
115
|
+
badge.remove();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!badge) {
|
|
119
|
+
badge = document.createElement("span");
|
|
120
|
+
badge.className = "rail-badge";
|
|
121
|
+
btn.appendChild(badge);
|
|
122
|
+
}
|
|
123
|
+
badge.textContent = count > 999 ? "999+" : String(count);
|
|
124
|
+
}
|
|
103
125
|
// ── Taskbar flash via title alternation ──
|
|
104
126
|
let titleFlashTimer = null;
|
|
105
127
|
let titleFlashPhase = false;
|
|
@@ -1804,6 +1826,7 @@ const optThreaded = document.getElementById("opt-threaded");
|
|
|
1804
1826
|
const optFlagged = document.getElementById("opt-flagged");
|
|
1805
1827
|
const optFolderCounts = document.getElementById("opt-folder-counts");
|
|
1806
1828
|
const optCalendarSidebar = document.getElementById("opt-calendar-sidebar");
|
|
1829
|
+
const optThreadFilter = document.getElementById("opt-thread-filter");
|
|
1807
1830
|
// Toggle dropdown — also close any other open toolbar menu so they can't
|
|
1808
1831
|
// overlap. Without this, opening View while Settings was already open left
|
|
1809
1832
|
// both visible at once (user-reported screenshot).
|
|
@@ -1855,6 +1878,41 @@ if (savedFlagged)
|
|
|
1855
1878
|
document.getElementById("ml-body")?.classList.add("flagged-only");
|
|
1856
1879
|
if (savedFolderCounts)
|
|
1857
1880
|
document.getElementById("folder-tree")?.classList.add("show-folder-counts");
|
|
1881
|
+
// "Only this conversation" toggle — hides rows whose threadId differs from
|
|
1882
|
+
// the currently-selected message's threadId. Client-side only (no server
|
|
1883
|
+
// round-trip); toggling off restores the full list. Persisted per-session
|
|
1884
|
+
// but not across reloads (thread context is tied to current selection).
|
|
1885
|
+
optThreadFilter?.addEventListener("change", () => {
|
|
1886
|
+
const body = document.getElementById("ml-body");
|
|
1887
|
+
if (!body)
|
|
1888
|
+
return;
|
|
1889
|
+
body.classList.toggle("thread-filter-on", optThreadFilter.checked);
|
|
1890
|
+
applyThreadFilter();
|
|
1891
|
+
});
|
|
1892
|
+
messageState.subscribe(() => applyThreadFilter());
|
|
1893
|
+
function applyThreadFilter() {
|
|
1894
|
+
const body = document.getElementById("ml-body");
|
|
1895
|
+
if (!body)
|
|
1896
|
+
return;
|
|
1897
|
+
if (!optThreadFilter?.checked) {
|
|
1898
|
+
body.querySelectorAll(".ml-row.thread-filter-hidden")
|
|
1899
|
+
.forEach(r => r.classList.remove("thread-filter-hidden"));
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
const sel = messageState.getSelected();
|
|
1903
|
+
const tid = sel?.threadId;
|
|
1904
|
+
if (!tid)
|
|
1905
|
+
return;
|
|
1906
|
+
body.querySelectorAll(".ml-row").forEach(r => {
|
|
1907
|
+
const rowTid = r.dataset.threadId;
|
|
1908
|
+
if (rowTid === tid || r.classList.contains("selected")) {
|
|
1909
|
+
r.classList.remove("thread-filter-hidden");
|
|
1910
|
+
}
|
|
1911
|
+
else {
|
|
1912
|
+
r.classList.add("thread-filter-hidden");
|
|
1913
|
+
}
|
|
1914
|
+
});
|
|
1915
|
+
}
|
|
1858
1916
|
// S51 — Calendar sidebar: View-menu toggle, restore from localStorage,
|
|
1859
1917
|
// hide auto-magically on narrow screens (CSS handles that).
|
|
1860
1918
|
(async () => {
|
|
@@ -1912,6 +1970,12 @@ document.getElementById("btn-edit-jsonc")?.addEventListener("click", async () =>
|
|
|
1912
1970
|
settingsDropdown.hidden = true;
|
|
1913
1971
|
await openJsoncEditor("accounts.jsonc");
|
|
1914
1972
|
});
|
|
1973
|
+
// Allow other components (remote-content banner, etc.) to open the editor
|
|
1974
|
+
// pre-selected to a specific file.
|
|
1975
|
+
document.addEventListener("mailx-open-jsonc-editor", async (ev) => {
|
|
1976
|
+
const file = ev.detail?.file || "accounts.jsonc";
|
|
1977
|
+
await openJsoncEditor(file);
|
|
1978
|
+
});
|
|
1915
1979
|
// Q61: open ~/.mailx in OS file explorer.
|
|
1916
1980
|
document.getElementById("btn-open-mailx-dir")?.addEventListener("click", async () => {
|
|
1917
1981
|
const settingsDropdown = document.getElementById("settings-dropdown");
|
|
@@ -1959,7 +2023,10 @@ async function openJsoncEditor(initialFile) {
|
|
|
1959
2023
|
</label>
|
|
1960
2024
|
<div class="mailx-modal-split">
|
|
1961
2025
|
<label class="mailx-modal-label mailx-modal-split-left">Contents (JSONC — comments and trailing commas allowed)
|
|
1962
|
-
<
|
|
2026
|
+
<div class="jsonc-editor-wrap">
|
|
2027
|
+
<div class="jsonc-gutter" id="jsonc-gutter" aria-hidden="true"></div>
|
|
2028
|
+
<textarea class="mailx-modal-input mailx-modal-textarea jsonc-textarea" id="jsonc-content" spellcheck="false"></textarea>
|
|
2029
|
+
</div>
|
|
1963
2030
|
</label>
|
|
1964
2031
|
<div class="mailx-modal-split-right mailx-help-panel">
|
|
1965
2032
|
<div class="mailx-help-title">
|
|
@@ -1978,12 +2045,31 @@ async function openJsoncEditor(initialFile) {
|
|
|
1978
2045
|
document.body.appendChild(backdrop);
|
|
1979
2046
|
const fileSelect = panel.querySelector("#jsonc-file");
|
|
1980
2047
|
const textarea = panel.querySelector("#jsonc-content");
|
|
2048
|
+
const gutter = panel.querySelector("#jsonc-gutter");
|
|
1981
2049
|
const errorEl = panel.querySelector("#jsonc-error");
|
|
1982
2050
|
const saveBtn = panel.querySelector('[data-action="save"]');
|
|
1983
2051
|
const helpBody = panel.querySelector("#jsonc-help-body");
|
|
1984
2052
|
const helpToggle = panel.querySelector("#jsonc-help-toggle");
|
|
1985
2053
|
const helpPanel = panel.querySelector(".mailx-help-panel");
|
|
1986
2054
|
fileSelect.value = initialFile;
|
|
2055
|
+
// Line-number gutter — recomputed whenever the textarea content changes,
|
|
2056
|
+
// scroll-synced so numbers stay aligned. errorLine (1-based) is highlighted
|
|
2057
|
+
// red so the "Line N, col M" error message in the status bar points at a
|
|
2058
|
+
// visible marker in the gutter.
|
|
2059
|
+
let errorLine = 0;
|
|
2060
|
+
const renderGutter = () => {
|
|
2061
|
+
const lines = textarea.value.split("\n").length;
|
|
2062
|
+
let html = "";
|
|
2063
|
+
for (let i = 1; i <= lines; i++) {
|
|
2064
|
+
html += i === errorLine
|
|
2065
|
+
? `<div class="jsonc-gutter-line jsonc-gutter-error">${i}</div>`
|
|
2066
|
+
: `<div class="jsonc-gutter-line">${i}</div>`;
|
|
2067
|
+
}
|
|
2068
|
+
gutter.innerHTML = html;
|
|
2069
|
+
};
|
|
2070
|
+
const syncScroll = () => { gutter.scrollTop = textarea.scrollTop; };
|
|
2071
|
+
textarea.addEventListener("scroll", syncScroll);
|
|
2072
|
+
textarea.addEventListener("input", renderGutter);
|
|
1987
2073
|
helpToggle.addEventListener("click", () => {
|
|
1988
2074
|
const open = helpPanel.classList.toggle("mailx-help-collapsed");
|
|
1989
2075
|
helpToggle.textContent = open ? "▸ Help" : "▾ Help";
|
|
@@ -2005,12 +2091,16 @@ async function openJsoncEditor(initialFile) {
|
|
|
2005
2091
|
errorEl.textContent = "";
|
|
2006
2092
|
textarea.classList.remove("mailx-modal-input-error");
|
|
2007
2093
|
saveBtn.disabled = false;
|
|
2094
|
+
errorLine = 0;
|
|
2095
|
+
renderGutter();
|
|
2008
2096
|
};
|
|
2009
2097
|
const showValidation = (err) => {
|
|
2010
2098
|
errorEl.textContent = `Line ${err.line}, col ${err.col}: ${err.message}`;
|
|
2011
2099
|
errorEl.hidden = false;
|
|
2012
2100
|
textarea.classList.add("mailx-modal-input-error");
|
|
2013
2101
|
saveBtn.disabled = true;
|
|
2102
|
+
errorLine = err.line;
|
|
2103
|
+
renderGutter();
|
|
2014
2104
|
// Select the problem character so the browser draws a visible marker
|
|
2015
2105
|
try {
|
|
2016
2106
|
textarea.setSelectionRange(err.pos, err.pos + 1);
|
|
@@ -2033,13 +2123,16 @@ async function openJsoncEditor(initialFile) {
|
|
|
2033
2123
|
const loadFile = async () => {
|
|
2034
2124
|
textarea.value = "Loading...";
|
|
2035
2125
|
clearValidation();
|
|
2126
|
+
renderGutter();
|
|
2036
2127
|
try {
|
|
2037
2128
|
const r = await readJsoncFile(fileSelect.value);
|
|
2038
2129
|
textarea.value = r?.content || "";
|
|
2130
|
+
renderGutter();
|
|
2039
2131
|
scheduleValidate();
|
|
2040
2132
|
}
|
|
2041
2133
|
catch (e) {
|
|
2042
2134
|
textarea.value = "";
|
|
2135
|
+
renderGutter();
|
|
2043
2136
|
errorEl.textContent = `Failed to load: ${e.message}`;
|
|
2044
2137
|
errorEl.hidden = false;
|
|
2045
2138
|
}
|
|
@@ -2739,6 +2832,19 @@ document.getElementById("status-queue")?.addEventListener("click", async () => {
|
|
|
2739
2832
|
})();
|
|
2740
2833
|
console.log("mailx client initialized, location:", location.href);
|
|
2741
2834
|
updateNewMessageCount();
|
|
2835
|
+
// Offline indicator — show/hide based on navigator.onLine. Doesn't gate any
|
|
2836
|
+
// functionality (the store is local-first; edits queue and replay on
|
|
2837
|
+
// reconnect regardless) but tells the user their queued actions are stacking
|
|
2838
|
+
// up for a later push rather than hitting the server now.
|
|
2839
|
+
const offlineEl = document.getElementById("status-offline");
|
|
2840
|
+
function refreshOfflineIndicator() {
|
|
2841
|
+
if (!offlineEl)
|
|
2842
|
+
return;
|
|
2843
|
+
offlineEl.hidden = navigator.onLine;
|
|
2844
|
+
}
|
|
2845
|
+
window.addEventListener("online", refreshOfflineIndicator);
|
|
2846
|
+
window.addEventListener("offline", refreshOfflineIndicator);
|
|
2847
|
+
refreshOfflineIndicator();
|
|
2742
2848
|
// ── Midnight refresh — update date display when day changes ──
|
|
2743
2849
|
function scheduleMiddnightRefresh() {
|
|
2744
2850
|
const now = new Date();
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* All storage goes through the service-side two-way cache (calendar_events
|
|
14
14
|
* and tasks tables); this file does not use localStorage for data.
|
|
15
15
|
*/
|
|
16
|
-
import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, } from "../lib/api-client.js";
|
|
16
|
+
import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
|
|
17
17
|
const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
|
|
18
18
|
let viewYear = new Date().getFullYear();
|
|
19
19
|
let viewMonth = new Date().getMonth();
|
|
@@ -105,6 +105,7 @@ async function renderTasks() {
|
|
|
105
105
|
html += `<div class="cal-side-task" data-uuid="${t.uuid}">
|
|
106
106
|
<input type="checkbox" ${done ? "checked" : ""} class="cal-side-task-check">
|
|
107
107
|
<span class="cal-side-task-title${done ? " done" : ""}">${escapeHtml(t.title)}</span>
|
|
108
|
+
<button class="cal-side-task-delete" title="Delete task" aria-label="Delete task">×</button>
|
|
108
109
|
</div>`;
|
|
109
110
|
}
|
|
110
111
|
host.innerHTML = html;
|
|
@@ -115,6 +116,10 @@ async function renderTasks() {
|
|
|
115
116
|
await updateTask(uuid, { completedMs: checked ? Date.now() : null });
|
|
116
117
|
renderTasks();
|
|
117
118
|
});
|
|
119
|
+
row.querySelector(".cal-side-task-delete")?.addEventListener("click", async () => {
|
|
120
|
+
await deleteTask(uuid);
|
|
121
|
+
renderTasks();
|
|
122
|
+
});
|
|
118
123
|
});
|
|
119
124
|
}
|
|
120
125
|
async function refresh() {
|
|
@@ -770,9 +770,34 @@ async function loadFolderTree(container) {
|
|
|
770
770
|
const delimiter = folders[0]?.delimiter || ".";
|
|
771
771
|
const tree = buildTree(folders, delimiter, account.id);
|
|
772
772
|
sortFolders(tree);
|
|
773
|
+
// Case-duplicate detection: fold folder paths to lowercase and
|
|
774
|
+
// flag any whose form matches another. Common with servers that
|
|
775
|
+
// let users create `Archive` and `archive` as distinct folders,
|
|
776
|
+
// or `Sent Items` alongside a `Sent items` rename gone sideways.
|
|
777
|
+
// A ⚠ glyph on the affected rows lets the user notice before
|
|
778
|
+
// losing mail to the wrong one.
|
|
779
|
+
const lowerCounts = new Map();
|
|
780
|
+
for (const f of folders) {
|
|
781
|
+
const key = (f.path || "").toLowerCase();
|
|
782
|
+
lowerCounts.set(key, (lowerCounts.get(key) || 0) + 1);
|
|
783
|
+
}
|
|
784
|
+
const duplicatePaths = new Set();
|
|
785
|
+
for (const [k, c] of lowerCounts)
|
|
786
|
+
if (c > 1)
|
|
787
|
+
duplicatePaths.add(k);
|
|
773
788
|
for (const node of tree) {
|
|
774
789
|
renderNode(node, accountEl, 1);
|
|
775
790
|
}
|
|
791
|
+
if (duplicatePaths.size > 0) {
|
|
792
|
+
accountEl.querySelectorAll(".ft-folder").forEach(el => {
|
|
793
|
+
const p = (el.dataset.folderPath || "").toLowerCase();
|
|
794
|
+
if (duplicatePaths.has(p)) {
|
|
795
|
+
el.classList.add("ft-folder-duplicate");
|
|
796
|
+
el.title = (el.title ? el.title + " — " : "") +
|
|
797
|
+
"Case-duplicate folder name on the server (another folder with the same name in different case exists)";
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
}
|
|
776
801
|
}
|
|
777
802
|
fragment.appendChild(accountEl);
|
|
778
803
|
}
|
|
@@ -18,6 +18,12 @@ let searchMode = false;
|
|
|
18
18
|
let currentSearchQuery = "";
|
|
19
19
|
let showToInsteadOfFrom = false;
|
|
20
20
|
let touchWasScroll = false;
|
|
21
|
+
// Current sort column/direction — cycled by clicking the ml-header columns.
|
|
22
|
+
// "date desc" is the default (newest first). Clicking a column flips direction
|
|
23
|
+
// if it's already active, or switches to that column with its own default dir
|
|
24
|
+
// (text columns default asc, date defaults desc).
|
|
25
|
+
let currentSort = "date";
|
|
26
|
+
let currentSortDir = "desc";
|
|
21
27
|
/** Flip the "not-downloaded" indicator off for rows whose bodies just cached.
|
|
22
28
|
* Called from the bodyCached service event — covers both background prefetch
|
|
23
29
|
* and on-demand fetch. No-op for rows not currently rendered. */
|
|
@@ -119,6 +125,71 @@ export function initMessageList(handler) {
|
|
|
119
125
|
syncDomToState();
|
|
120
126
|
}
|
|
121
127
|
});
|
|
128
|
+
// Sort column headers — click to cycle. Date defaults desc (newest first);
|
|
129
|
+
// From/Subject default asc on first click so alphabetical order reads
|
|
130
|
+
// naturally. Clicking the currently-active column flips direction.
|
|
131
|
+
const header = document.getElementById("ml-header");
|
|
132
|
+
if (header) {
|
|
133
|
+
header.addEventListener("click", (e) => {
|
|
134
|
+
const col = e.target.closest(".ml-col-sortable");
|
|
135
|
+
if (!col)
|
|
136
|
+
return;
|
|
137
|
+
const key = col.dataset.sort;
|
|
138
|
+
if (!key)
|
|
139
|
+
return;
|
|
140
|
+
if (currentSort === key) {
|
|
141
|
+
currentSortDir = currentSortDir === "asc" ? "desc" : "asc";
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
currentSort = key;
|
|
145
|
+
currentSortDir = key === "date" ? "desc" : "asc";
|
|
146
|
+
}
|
|
147
|
+
// Only per-folder lists support server-side sort today; unified and
|
|
148
|
+
// search paths sort client-side on the fetched page. Reload if we
|
|
149
|
+
// have an active per-folder context.
|
|
150
|
+
if (!searchMode && !unifiedMode && currentAccountId && currentFolderId) {
|
|
151
|
+
loadMessages(currentAccountId, currentFolderId, 1, "", false);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
applyClientSideSort();
|
|
155
|
+
updateSortIndicators();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
updateSortIndicators();
|
|
160
|
+
}
|
|
161
|
+
/** Reorder currently-loaded state messages in-place by currentSort/currentSortDir.
|
|
162
|
+
* Used for unified-inbox and search results where the server can't re-sort
|
|
163
|
+
* a single page on our behalf. */
|
|
164
|
+
function applyClientSideSort() {
|
|
165
|
+
const items = [...state.getMessages()];
|
|
166
|
+
const sign = currentSortDir === "asc" ? 1 : -1;
|
|
167
|
+
items.sort((a, b) => {
|
|
168
|
+
if (currentSort === "from") {
|
|
169
|
+
const av = (a.from?.name || a.from?.address || "").toLowerCase();
|
|
170
|
+
const bv = (b.from?.name || b.from?.address || "").toLowerCase();
|
|
171
|
+
return av < bv ? -sign : av > bv ? sign : 0;
|
|
172
|
+
}
|
|
173
|
+
if (currentSort === "subject") {
|
|
174
|
+
const av = (a.subject || "").replace(/^(re:|fwd:|fw:)\s*/i, "").toLowerCase();
|
|
175
|
+
const bv = (b.subject || "").replace(/^(re:|fwd:|fw:)\s*/i, "").toLowerCase();
|
|
176
|
+
return av < bv ? -sign : av > bv ? sign : 0;
|
|
177
|
+
}
|
|
178
|
+
// date
|
|
179
|
+
return ((a.date || 0) - (b.date || 0)) * sign;
|
|
180
|
+
});
|
|
181
|
+
state.setMessages(items);
|
|
182
|
+
}
|
|
183
|
+
function updateSortIndicators() {
|
|
184
|
+
const header = document.getElementById("ml-header");
|
|
185
|
+
if (!header)
|
|
186
|
+
return;
|
|
187
|
+
header.querySelectorAll(".ml-col-sortable").forEach(c => {
|
|
188
|
+
c.classList.remove("ml-col-sort-asc", "ml-col-sort-desc");
|
|
189
|
+
if (c.dataset.sort === currentSort) {
|
|
190
|
+
c.classList.add(currentSortDir === "asc" ? "ml-col-sort-asc" : "ml-col-sort-desc");
|
|
191
|
+
}
|
|
192
|
+
});
|
|
122
193
|
}
|
|
123
194
|
/**
|
|
124
195
|
* Sync DOM rows to current state after messages are removed.
|
|
@@ -296,8 +367,9 @@ export async function loadMessages(accountId, folderId, page = 1, specialUse = "
|
|
|
296
367
|
}
|
|
297
368
|
try {
|
|
298
369
|
const flaggedOnly = document.getElementById("ml-body")?.classList.contains("flagged-only") || false;
|
|
299
|
-
const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly);
|
|
370
|
+
const result = await apiGetMessages(accountId, folderId, 1, 50, flaggedOnly, currentSort, currentSortDir);
|
|
300
371
|
totalMessages = result.total;
|
|
372
|
+
updateSortIndicators();
|
|
301
373
|
if (result.items.length === 0) {
|
|
302
374
|
state.setMessages([]);
|
|
303
375
|
body.innerHTML = `<div class="ml-empty">${flaggedOnly ? "No flagged messages" : "No messages"}</div>`;
|
|
@@ -472,6 +544,8 @@ function appendMessages(body, accountId, items) {
|
|
|
472
544
|
row.dataset.uid = String(msg.uid);
|
|
473
545
|
row.dataset.accountId = msgAccountId;
|
|
474
546
|
row.dataset.folderId = String(msg.folderId);
|
|
547
|
+
if (msg.threadId)
|
|
548
|
+
row.dataset.threadId = msg.threadId;
|
|
475
549
|
const flag = document.createElement("span");
|
|
476
550
|
flag.className = "ml-flag";
|
|
477
551
|
flag.textContent = msg.flags.includes("\\Flagged") ? "\u2605" : "\u2606";
|
|
@@ -536,6 +536,7 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
536
536
|
(returnPath && returnPath !== senderAddr ? `<div><span class="mv-rb-label">Return-Path:</span> ${escapeText(returnPath)}</div>` : "") +
|
|
537
537
|
`</div>` +
|
|
538
538
|
(deliveredTo || toAddr ? `<div class="mv-rb-actions"><button id="btn-allow-to">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : "") +
|
|
539
|
+
`<div class="mv-rb-actions"><button id="btn-edit-allowlist" title="View / edit the full allowlist">Edit allowlist…</button></div>` +
|
|
539
540
|
`</div>`;
|
|
540
541
|
bodyEl.appendChild(banner);
|
|
541
542
|
// Toggle dropdown — click arrow or text to expand details
|
|
@@ -577,6 +578,12 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
577
578
|
await allowRemoteContent("recipient", addr);
|
|
578
579
|
loadRemote();
|
|
579
580
|
});
|
|
581
|
+
// "Edit allowlist…" — fires a document-level event that app.ts
|
|
582
|
+
// listens for and opens the JSONC editor pre-selected to
|
|
583
|
+
// allowlist.jsonc. Keeps message-viewer free of the editor import.
|
|
584
|
+
banner.querySelector("#btn-edit-allowlist")?.addEventListener("click", () => {
|
|
585
|
+
document.dispatchEvent(new CustomEvent("mailx-open-jsonc-editor", { detail: { file: "allowlist.jsonc" } }));
|
|
586
|
+
});
|
|
580
587
|
}
|
|
581
588
|
// Body fetch error — show banner above (empty) body instead of polluting
|
|
582
589
|
// the main content area with the error text. Transient errors get a retry.
|
package/client/index.html
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
<label class="tb-menu-item"><input type="checkbox" id="opt-preview" checked> Preview pane</label>
|
|
27
27
|
<label class="tb-menu-item"><input type="checkbox" id="opt-snippet" checked> Preview snippets</label>
|
|
28
28
|
<label class="tb-menu-item"><input type="checkbox" id="opt-threaded"> Group by thread</label>
|
|
29
|
+
<label class="tb-menu-item"><input type="checkbox" id="opt-thread-filter"> Only this conversation</label>
|
|
29
30
|
<label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
30
31
|
<label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
31
32
|
<label class="tb-menu-item"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
|
|
@@ -110,11 +111,11 @@
|
|
|
110
111
|
<label class="search-server-check" title="Also search the IMAP server (slower; spans all folders on all accounts)"><input type="checkbox" id="search-server-too"> Server</label>
|
|
111
112
|
</search>
|
|
112
113
|
<div class="ml-folder-title" id="ml-folder-title"></div>
|
|
113
|
-
<div class="ml-header">
|
|
114
|
+
<div class="ml-header" id="ml-header">
|
|
114
115
|
<span class="ml-col ml-col-flag"></span>
|
|
115
|
-
<span class="ml-col ml-col-from" data-sort="from">From</span>
|
|
116
|
-
<span class="ml-col ml-col-date" data-sort="date">Date</span>
|
|
117
|
-
<span class="ml-col ml-col-subject">Subject</span>
|
|
116
|
+
<span class="ml-col ml-col-from ml-col-sortable" data-sort="from">From</span>
|
|
117
|
+
<span class="ml-col ml-col-date ml-col-sortable" data-sort="date">Date</span>
|
|
118
|
+
<span class="ml-col ml-col-subject ml-col-sortable" data-sort="subject">Subject</span>
|
|
118
119
|
</div>
|
|
119
120
|
<div class="ml-body" id="ml-body">
|
|
120
121
|
<div class="ml-empty">Select a folder to view messages</div>
|
|
@@ -181,6 +182,7 @@
|
|
|
181
182
|
<footer class="status-bar" id="status-bar">
|
|
182
183
|
<span id="status-accounts"></span>
|
|
183
184
|
<span id="status-sync">Syncing...</span>
|
|
185
|
+
<span id="status-offline" class="status-offline" hidden title="No network — local actions queue for later">⚡ offline</span>
|
|
184
186
|
<span id="status-diag" class="status-diag" hidden title=""></span>
|
|
185
187
|
<span id="status-pending"></span>
|
|
186
188
|
<span id="status-queue"></span>
|
package/client/lib/api-client.js
CHANGED
|
@@ -102,9 +102,9 @@ export function getAccounts() {
|
|
|
102
102
|
export function getFolders(accountId) {
|
|
103
103
|
return ipc().getFolders(accountId);
|
|
104
104
|
}
|
|
105
|
-
export function getMessages(accountId, folderId, page = 1, pageSize = 50, flaggedOnly = false) {
|
|
105
|
+
export function getMessages(accountId, folderId, page = 1, pageSize = 50, flaggedOnly = false, sort, sortDir) {
|
|
106
106
|
abortMessageListRequests();
|
|
107
|
-
return ipc().getMessages(accountId, folderId, page, pageSize,
|
|
107
|
+
return ipc().getMessages(accountId, folderId, page, pageSize, sort, sortDir, undefined, flaggedOnly);
|
|
108
108
|
}
|
|
109
109
|
export function getUnifiedInbox(page = 1, pageSize = 50) {
|
|
110
110
|
abortMessageListRequests();
|
|
@@ -205,6 +205,15 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
205
205
|
|
|
206
206
|
.ft-folder-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
207
207
|
|
|
208
|
+
/* Case-duplicate warning — another folder exists with the same name in a
|
|
209
|
+
different case on the server. Hover for the full explanation via title. */
|
|
210
|
+
.ft-folder-duplicate .ft-folder-name::after {
|
|
211
|
+
content: " ⚠";
|
|
212
|
+
color: oklch(0.65 0.2 65);
|
|
213
|
+
font-weight: 600;
|
|
214
|
+
margin-left: 4px;
|
|
215
|
+
}
|
|
216
|
+
|
|
208
217
|
.ft-badge {
|
|
209
218
|
font-size: 0.7rem;
|
|
210
219
|
padding: 0.1rem 0.4rem;
|
|
@@ -332,6 +341,18 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
332
341
|
user-select: none;
|
|
333
342
|
|
|
334
343
|
.ml-col { cursor: pointer; &:hover { color: var(--color-text); } }
|
|
344
|
+
.ml-col-sortable { position: relative; padding-right: 14px; }
|
|
345
|
+
.ml-col-sort-asc::after,
|
|
346
|
+
.ml-col-sort-desc::after {
|
|
347
|
+
position: absolute;
|
|
348
|
+
right: 2px;
|
|
349
|
+
top: 50%;
|
|
350
|
+
transform: translateY(-50%);
|
|
351
|
+
font-size: 10px;
|
|
352
|
+
color: var(--color-accent);
|
|
353
|
+
}
|
|
354
|
+
.ml-col-sort-asc::after { content: "▲"; }
|
|
355
|
+
.ml-col-sort-desc::after { content: "▼"; }
|
|
335
356
|
}
|
|
336
357
|
|
|
337
358
|
/* Narrow-mode folder title above the list — hidden on wide where the window
|
|
@@ -603,6 +624,64 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
603
624
|
white-space: pre;
|
|
604
625
|
tab-size: 2;
|
|
605
626
|
}
|
|
627
|
+
|
|
628
|
+
/* JSONC editor with line-number gutter. Gutter + textarea share the same
|
|
629
|
+
line-height so numbers stay aligned; textarea owns the scroll and the
|
|
630
|
+
gutter syncs via JS. Error line is highlighted red in the gutter so the
|
|
631
|
+
"Line N, col M" error points at a visible marker. */
|
|
632
|
+
.jsonc-editor-wrap {
|
|
633
|
+
flex: 1;
|
|
634
|
+
display: flex;
|
|
635
|
+
min-height: 200px;
|
|
636
|
+
border: 1px solid var(--color-border);
|
|
637
|
+
border-radius: var(--radius-sm);
|
|
638
|
+
overflow: hidden;
|
|
639
|
+
background: var(--color-bg-surface);
|
|
640
|
+
}
|
|
641
|
+
.jsonc-gutter {
|
|
642
|
+
flex: 0 0 auto;
|
|
643
|
+
min-width: 3em;
|
|
644
|
+
padding: 6px 6px 6px 10px;
|
|
645
|
+
background: oklch(0.94 0.005 250);
|
|
646
|
+
border-right: 1px solid var(--color-border);
|
|
647
|
+
color: var(--color-text-muted);
|
|
648
|
+
font-family: var(--font-mono);
|
|
649
|
+
font-size: 13px;
|
|
650
|
+
line-height: 1.5;
|
|
651
|
+
text-align: right;
|
|
652
|
+
user-select: none;
|
|
653
|
+
overflow: hidden;
|
|
654
|
+
white-space: pre;
|
|
655
|
+
tab-size: 2;
|
|
656
|
+
}
|
|
657
|
+
.jsonc-gutter-line {
|
|
658
|
+
line-height: 1.5;
|
|
659
|
+
font-variant-numeric: tabular-nums;
|
|
660
|
+
}
|
|
661
|
+
.jsonc-gutter-error {
|
|
662
|
+
background: oklch(0.65 0.2 25);
|
|
663
|
+
color: #fff;
|
|
664
|
+
font-weight: 600;
|
|
665
|
+
border-radius: 2px;
|
|
666
|
+
padding: 0 4px;
|
|
667
|
+
margin: 0 -4px;
|
|
668
|
+
}
|
|
669
|
+
.jsonc-editor-wrap .jsonc-textarea {
|
|
670
|
+
flex: 1;
|
|
671
|
+
border: 0;
|
|
672
|
+
border-radius: 0;
|
|
673
|
+
line-height: 1.5;
|
|
674
|
+
resize: none;
|
|
675
|
+
/* The outer wrap draws the focus ring so gutter + area move together */
|
|
676
|
+
}
|
|
677
|
+
.jsonc-editor-wrap:focus-within {
|
|
678
|
+
outline: 2px solid var(--color-accent);
|
|
679
|
+
outline-offset: -1px;
|
|
680
|
+
}
|
|
681
|
+
.jsonc-editor-wrap:has(.mailx-modal-input-error) {
|
|
682
|
+
outline: 2px solid oklch(0.65 0.2 25);
|
|
683
|
+
outline-offset: -1px;
|
|
684
|
+
}
|
|
606
685
|
.mailx-modal-input:focus {
|
|
607
686
|
outline: 2px solid var(--color-accent);
|
|
608
687
|
outline-offset: -1px;
|
|
@@ -816,6 +895,21 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
816
895
|
opacity: 0.9;
|
|
817
896
|
}
|
|
818
897
|
|
|
898
|
+
/* Filter: only this conversation — rows not in the current thread hide.
|
|
899
|
+
The selected row stays visible regardless so toggling doesn't leave the
|
|
900
|
+
list empty when the selection is a singleton thread. */
|
|
901
|
+
.ml-row.thread-filter-hidden { display: none; }
|
|
902
|
+
|
|
903
|
+
/* Offline indicator — sits in the status bar; amber tone so it's visible
|
|
904
|
+
but doesn't scream (being offline is normal local-first behavior, not
|
|
905
|
+
an error). */
|
|
906
|
+
.status-offline {
|
|
907
|
+
color: oklch(0.65 0.18 65);
|
|
908
|
+
font-weight: 600;
|
|
909
|
+
font-size: var(--font-size-sm);
|
|
910
|
+
padding: 0 6px;
|
|
911
|
+
}
|
|
912
|
+
|
|
819
913
|
/* S51 — calendar sidebar (Thunderbird Lightning Events & Tasks pane).
|
|
820
914
|
Right-docked, fixed width. Visible by default; user hides via View menu.
|
|
821
915
|
Hides automatically on narrow screens (< 1100px) — Android uses the
|
|
@@ -926,11 +1020,41 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
|
|
|
926
1020
|
align-items: center;
|
|
927
1021
|
gap: 6px;
|
|
928
1022
|
padding: 2px 0;
|
|
1023
|
+
|
|
1024
|
+
.cal-side-task-delete {
|
|
1025
|
+
margin-left: auto;
|
|
1026
|
+
opacity: 0;
|
|
1027
|
+
background: transparent;
|
|
1028
|
+
border: 0;
|
|
1029
|
+
cursor: pointer;
|
|
1030
|
+
color: var(--color-text-muted);
|
|
1031
|
+
padding: 0 4px;
|
|
1032
|
+
font-size: 14px;
|
|
1033
|
+
line-height: 1;
|
|
1034
|
+
transition: opacity 0.15s, color 0.15s;
|
|
1035
|
+
}
|
|
1036
|
+
&:hover .cal-side-task-delete { opacity: 1; }
|
|
1037
|
+
.cal-side-task-delete:hover { color: oklch(0.55 0.22 25); }
|
|
1038
|
+
}
|
|
1039
|
+
.cal-side-task-title {
|
|
1040
|
+
flex: 1;
|
|
1041
|
+
overflow: hidden;
|
|
1042
|
+
text-overflow: ellipsis;
|
|
1043
|
+
white-space: nowrap;
|
|
929
1044
|
}
|
|
930
1045
|
.cal-side-task-title.done {
|
|
931
1046
|
text-decoration: line-through;
|
|
932
1047
|
color: var(--color-text-muted);
|
|
933
1048
|
}
|
|
1049
|
+
.cal-side-task-header-row {
|
|
1050
|
+
display: flex;
|
|
1051
|
+
align-items: center;
|
|
1052
|
+
gap: var(--gap-xs);
|
|
1053
|
+
padding: 2px 0;
|
|
1054
|
+
|
|
1055
|
+
label { flex: 1; }
|
|
1056
|
+
.cal-side-new { width: auto; padding: 2px 8px; font-size: 0.85em; }
|
|
1057
|
+
}
|
|
934
1058
|
|
|
935
1059
|
.ml-empty {
|
|
936
1060
|
grid-column: 1 / -1;
|
package/client/styles/layout.css
CHANGED
|
@@ -89,6 +89,23 @@ body.calendar-sidebar-on {
|
|
|
89
89
|
.rail-btn[disabled] {
|
|
90
90
|
color: oklch(0.55 0.01 250);
|
|
91
91
|
}
|
|
92
|
+
.rail-btn { position: relative; }
|
|
93
|
+
.rail-badge {
|
|
94
|
+
position: absolute;
|
|
95
|
+
top: 3px;
|
|
96
|
+
right: 6px;
|
|
97
|
+
min-width: 18px;
|
|
98
|
+
padding: 1px 4px;
|
|
99
|
+
border-radius: 9px;
|
|
100
|
+
background: oklch(0.65 0.22 25);
|
|
101
|
+
color: #fff;
|
|
102
|
+
font-size: 10px;
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
line-height: 1.4;
|
|
105
|
+
text-align: center;
|
|
106
|
+
font-variant-numeric: tabular-nums;
|
|
107
|
+
pointer-events: none;
|
|
108
|
+
}
|
|
92
109
|
.rail-btn[disabled] {
|
|
93
110
|
opacity: 0.35;
|
|
94
111
|
cursor: not-allowed;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.382",
|
|
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.347",
|
|
28
28
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
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.347",
|
|
92
92
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
93
93
|
"@capacitor/android": "^8.3.0",
|
|
94
94
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -2299,8 +2299,33 @@ export class ImapManager extends EventEmitter {
|
|
|
2299
2299
|
await api.setFlags(folder.path, action.uid, action.flags || []);
|
|
2300
2300
|
console.log(` [api] ${accountId}: flags synced UID ${action.uid}`);
|
|
2301
2301
|
}
|
|
2302
|
+
else if ((action.action === "delete" || action.action === "trash") && api.trashMessage) {
|
|
2303
|
+
await api.trashMessage(folder.path, action.uid);
|
|
2304
|
+
console.log(` [api] ${accountId}: trashed UID ${action.uid} from ${folder.path}`);
|
|
2305
|
+
}
|
|
2306
|
+
else if (action.action === "move" && api.moveMessage) {
|
|
2307
|
+
const target = folders.find(f => f.id === action.targetFolderId);
|
|
2308
|
+
if (!target) {
|
|
2309
|
+
// Unreachable target — drop the action rather than loop.
|
|
2310
|
+
console.error(` [api] ${accountId}: move target folder ${action.targetFolderId} missing — dropping UID ${action.uid}`);
|
|
2311
|
+
this.db.completeSyncAction(action.id);
|
|
2312
|
+
continue;
|
|
2313
|
+
}
|
|
2314
|
+
await api.moveMessage(folder.path, action.uid, target.path);
|
|
2315
|
+
console.log(` [api] ${accountId}: moved UID ${action.uid} ${folder.path} → ${target.path}`);
|
|
2316
|
+
}
|
|
2302
2317
|
else {
|
|
2303
|
-
//
|
|
2318
|
+
// Unsupported action on Gmail. After 5 retries, drop it
|
|
2319
|
+
// so stale rows don't mark messages pending-reconcile
|
|
2320
|
+
// forever. Previously "continue" here caused the pink
|
|
2321
|
+
// rows that shouldn't have been pink.
|
|
2322
|
+
if (action.attempts >= 5) {
|
|
2323
|
+
console.warn(` [api] ${accountId}: dropping stale action "${action.action}" UID ${action.uid} after ${action.attempts} attempts (unsupported on Gmail API path)`);
|
|
2324
|
+
this.db.completeSyncAction(action.id);
|
|
2325
|
+
}
|
|
2326
|
+
else {
|
|
2327
|
+
this.db.failSyncAction(action.id, `unsupported Gmail action: ${action.action}`);
|
|
2328
|
+
}
|
|
2304
2329
|
continue;
|
|
2305
2330
|
}
|
|
2306
2331
|
this.db.completeSyncAction(action.id);
|
|
@@ -498,7 +498,7 @@ export class MailxService {
|
|
|
498
498
|
async updateCalendarEventLocal(uuid, patch) {
|
|
499
499
|
// Merge with existing row before writing so partial patches don't
|
|
500
500
|
// null-out unspecified fields in upsert.
|
|
501
|
-
const existing = this.db.
|
|
501
|
+
const existing = this.db.getCalendarEventByUuid(uuid);
|
|
502
502
|
if (!existing)
|
|
503
503
|
throw new Error(`No calendar event ${uuid}`);
|
|
504
504
|
this.db.upsertCalendarEvent({
|
|
@@ -515,7 +515,7 @@ export class MailxService {
|
|
|
515
515
|
this.drainStoreSync().catch(() => { });
|
|
516
516
|
}
|
|
517
517
|
async deleteCalendarEventLocal(uuid) {
|
|
518
|
-
const ev = this.db.
|
|
518
|
+
const ev = this.db.getCalendarEventByUuid(uuid);
|
|
519
519
|
if (!ev)
|
|
520
520
|
return;
|
|
521
521
|
this.db.deleteCalendarEventLocal(uuid);
|
|
@@ -555,7 +555,7 @@ export class MailxService {
|
|
|
555
555
|
return uuid;
|
|
556
556
|
}
|
|
557
557
|
async updateTaskLocal(uuid, patch) {
|
|
558
|
-
const existing = this.db.
|
|
558
|
+
const existing = this.db.getTaskByUuid(uuid);
|
|
559
559
|
if (!existing)
|
|
560
560
|
throw new Error(`No task ${uuid}`);
|
|
561
561
|
this.db.upsertTask({
|
|
@@ -570,7 +570,7 @@ export class MailxService {
|
|
|
570
570
|
this.drainStoreSync().catch(() => { });
|
|
571
571
|
}
|
|
572
572
|
async deleteTaskLocal(uuid) {
|
|
573
|
-
const task = this.db.
|
|
573
|
+
const task = this.db.getTaskByUuid(uuid);
|
|
574
574
|
if (!task)
|
|
575
575
|
return;
|
|
576
576
|
this.db.deleteTaskLocal(uuid);
|
|
@@ -48,6 +48,10 @@ export declare class MailxDB {
|
|
|
48
48
|
dirty?: boolean;
|
|
49
49
|
}): string;
|
|
50
50
|
getCalendarEvents(accountId: string, fromMs: number, toMs: number): any[];
|
|
51
|
+
/** Lookup by uuid only — used by patch/delete paths that don't have an
|
|
52
|
+
* accountId context. Returns the row even when it's soft-deleted. */
|
|
53
|
+
getCalendarEventByUuid(uuid: string): any | null;
|
|
54
|
+
getTaskByUuid(uuid: string): any | null;
|
|
51
55
|
getDirtyCalendarEvents(accountId: string): any[];
|
|
52
56
|
private calendarRowToObject;
|
|
53
57
|
markCalendarEventClean(uuid: string, providerId: string, etag: string): void;
|
|
@@ -396,6 +396,16 @@ export class MailxDB {
|
|
|
396
396
|
`).all(accountId, fromMs, toMs);
|
|
397
397
|
return rows.map(this.calendarRowToObject);
|
|
398
398
|
}
|
|
399
|
+
/** Lookup by uuid only — used by patch/delete paths that don't have an
|
|
400
|
+
* accountId context. Returns the row even when it's soft-deleted. */
|
|
401
|
+
getCalendarEventByUuid(uuid) {
|
|
402
|
+
const r = this.db.prepare("SELECT * FROM calendar_events WHERE uuid = ?").get(uuid);
|
|
403
|
+
return r ? this.calendarRowToObject(r) : null;
|
|
404
|
+
}
|
|
405
|
+
getTaskByUuid(uuid) {
|
|
406
|
+
const r = this.db.prepare("SELECT * FROM tasks WHERE uuid = ?").get(uuid);
|
|
407
|
+
return r ? this.taskRowToObject(r) : null;
|
|
408
|
+
}
|
|
399
409
|
getDirtyCalendarEvents(accountId) {
|
|
400
410
|
const rows = this.db.prepare(`
|
|
401
411
|
SELECT * FROM calendar_events WHERE account_id = ? AND (dirty = 1 OR deleted = 1)
|
|
@@ -997,11 +1007,34 @@ export class MailxDB {
|
|
|
997
1007
|
}
|
|
998
1008
|
/** Search contacts by name or email prefix */
|
|
999
1009
|
searchContacts(query, limit = 10) {
|
|
1000
|
-
|
|
1001
|
-
|
|
1010
|
+
// Two-pass ranking so autocomplete feels responsive: rows whose name or
|
|
1011
|
+
// local-part starts with the query rank first (exact prefix match is
|
|
1012
|
+
// usually what the user wants), then substring matches fill out the
|
|
1013
|
+
// rest. Within each tier, sort by (recency-weighted use_count) so a
|
|
1014
|
+
// contact the user messaged today beats one from two years ago even
|
|
1015
|
+
// if the older one has more total sends. Recency bonus: +1 per send
|
|
1016
|
+
// decayed by ~half every 30 days.
|
|
1017
|
+
const prefixQ = `${query}%`;
|
|
1018
|
+
const substr = `%${query}%`;
|
|
1019
|
+
const rows = this.db.prepare(`SELECT name, email, source, use_count, last_used,
|
|
1020
|
+
(CASE
|
|
1021
|
+
WHEN lower(name) LIKE lower(?) THEN 3
|
|
1022
|
+
WHEN substr(email, 1, instr(email, '@') - 1) LIKE lower(?) THEN 2
|
|
1023
|
+
WHEN email LIKE ? OR name LIKE ? THEN 1
|
|
1024
|
+
ELSE 0
|
|
1025
|
+
END) AS match_rank
|
|
1026
|
+
FROM contacts
|
|
1002
1027
|
WHERE email LIKE ? OR name LIKE ?
|
|
1003
|
-
ORDER BY use_count DESC, last_used DESC
|
|
1004
|
-
LIMIT ?`).all(
|
|
1028
|
+
ORDER BY match_rank DESC, use_count DESC, last_used DESC
|
|
1029
|
+
LIMIT ?`).all(prefixQ, prefixQ, substr, substr, substr, substr, limit);
|
|
1030
|
+
// Recency-weighted rescore, best-in-JS since SQLite lacks log/exp
|
|
1031
|
+
// natively. 30-day half-life — close enough to "recent contacts
|
|
1032
|
+
// float up" without being fussy about the exact decay curve.
|
|
1033
|
+
const now = Date.now();
|
|
1034
|
+
const HALF_LIFE_MS = 30 * 86400_000;
|
|
1035
|
+
const score = (r) => r.match_rank * 10_000
|
|
1036
|
+
+ (r.use_count || 0) * Math.pow(0.5, Math.max(0, now - (r.last_used || 0)) / HALF_LIFE_MS);
|
|
1037
|
+
rows.sort((a, b) => score(b) - score(a));
|
|
1005
1038
|
return rows.map(r => ({ name: r.name, email: r.email, source: r.source, useCount: r.use_count }));
|
|
1006
1039
|
}
|
|
1007
1040
|
/** List all contacts (address-book view) with pagination + optional filter. */
|
|
@@ -109,6 +109,60 @@ const SCHEMA = `
|
|
|
109
109
|
last_error TEXT,
|
|
110
110
|
UNIQUE(account_id, action, uid, folder_id)
|
|
111
111
|
);
|
|
112
|
+
|
|
113
|
+
-- Calendar events two-way cache (Android parity with desktop's
|
|
114
|
+
-- packages/mailx-store/db.ts calendar_events table). uuid = stable
|
|
115
|
+
-- local identity, provider_id = Google Calendar event id when known,
|
|
116
|
+
-- dirty = local edit not yet pushed, deleted = tombstone pending delete.
|
|
117
|
+
CREATE TABLE IF NOT EXISTS calendar_events (
|
|
118
|
+
uuid TEXT PRIMARY KEY,
|
|
119
|
+
account_id TEXT NOT NULL,
|
|
120
|
+
provider_id TEXT,
|
|
121
|
+
calendar_id TEXT DEFAULT 'primary',
|
|
122
|
+
title TEXT NOT NULL DEFAULT '',
|
|
123
|
+
start_ms INTEGER NOT NULL,
|
|
124
|
+
end_ms INTEGER NOT NULL,
|
|
125
|
+
all_day INTEGER DEFAULT 0,
|
|
126
|
+
location TEXT DEFAULT '',
|
|
127
|
+
notes TEXT DEFAULT '',
|
|
128
|
+
etag TEXT,
|
|
129
|
+
last_synced INTEGER DEFAULT 0,
|
|
130
|
+
dirty INTEGER DEFAULT 0,
|
|
131
|
+
deleted INTEGER DEFAULT 0,
|
|
132
|
+
updated_at INTEGER NOT NULL
|
|
133
|
+
);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS idx_calendar_events_start ON calendar_events(account_id, start_ms);
|
|
135
|
+
|
|
136
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
137
|
+
uuid TEXT PRIMARY KEY,
|
|
138
|
+
account_id TEXT NOT NULL,
|
|
139
|
+
provider_id TEXT,
|
|
140
|
+
list_id TEXT DEFAULT '@default',
|
|
141
|
+
title TEXT NOT NULL DEFAULT '',
|
|
142
|
+
notes TEXT DEFAULT '',
|
|
143
|
+
due_ms INTEGER,
|
|
144
|
+
completed_ms INTEGER,
|
|
145
|
+
etag TEXT,
|
|
146
|
+
last_synced INTEGER DEFAULT 0,
|
|
147
|
+
dirty INTEGER DEFAULT 0,
|
|
148
|
+
deleted INTEGER DEFAULT 0,
|
|
149
|
+
updated_at INTEGER NOT NULL
|
|
150
|
+
);
|
|
151
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_account ON tasks(account_id);
|
|
152
|
+
|
|
153
|
+
-- Generic store-sync queue for non-message domains (calendar/tasks/contacts).
|
|
154
|
+
CREATE TABLE IF NOT EXISTS store_sync (
|
|
155
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
156
|
+
kind TEXT NOT NULL,
|
|
157
|
+
op TEXT NOT NULL,
|
|
158
|
+
account_id TEXT NOT NULL,
|
|
159
|
+
target_uuid TEXT NOT NULL,
|
|
160
|
+
payload TEXT,
|
|
161
|
+
attempts INTEGER DEFAULT 0,
|
|
162
|
+
last_error TEXT,
|
|
163
|
+
created_at INTEGER NOT NULL,
|
|
164
|
+
UNIQUE(kind, target_uuid, op)
|
|
165
|
+
);
|
|
112
166
|
`;
|
|
113
167
|
const IDB_NAME = "mailx-sqldb";
|
|
114
168
|
const IDB_STORE = "database";
|
|
@@ -311,21 +311,30 @@ export class WebMailxService {
|
|
|
311
311
|
}
|
|
312
312
|
async syncAccount(accountId) {
|
|
313
313
|
const folders = await this.syncManager.syncFolders(accountId);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
});
|
|
321
|
-
for (const folder of sorted) {
|
|
314
|
+
// INBOX-first: await INBOX so the UI re-renders new mail immediately,
|
|
315
|
+
// then fire the rest in the background so labels don't block the
|
|
316
|
+
// list. S57 (Android parity with desktop's local-first rule).
|
|
317
|
+
const inbox = folders.find(f => f.specialUse === "inbox");
|
|
318
|
+
const others = folders.filter(f => f.specialUse !== "inbox");
|
|
319
|
+
if (inbox) {
|
|
322
320
|
try {
|
|
323
|
-
await this.syncManager.syncFolder(accountId,
|
|
321
|
+
await this.syncManager.syncFolder(accountId, inbox.id);
|
|
324
322
|
}
|
|
325
323
|
catch (e) {
|
|
326
|
-
console.error(` Skipping
|
|
324
|
+
console.error(` Skipping INBOX ${inbox.path}: ${e.message}`);
|
|
327
325
|
}
|
|
328
326
|
}
|
|
327
|
+
// Background fan-out: don't await; errors log. UI already has INBOX.
|
|
328
|
+
(async () => {
|
|
329
|
+
for (const folder of others) {
|
|
330
|
+
try {
|
|
331
|
+
await this.syncManager.syncFolder(accountId, folder.id);
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
console.error(` Skipping folder ${folder.path}: ${e.message}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
})().catch(() => { });
|
|
329
338
|
}
|
|
330
339
|
async reauthenticate(accountId) {
|
|
331
340
|
return this.syncManager.reauthenticate(accountId);
|
|
@@ -278,10 +278,22 @@ export declare function sanitizeHtml(html: string): {
|
|
|
278
278
|
/** Encode text as RFC 2045 quoted-printable. */
|
|
279
279
|
export declare function encodeQuotedPrintable(text: string): string;
|
|
280
280
|
/** Parse search query into structured conditions.
|
|
281
|
-
* Supports qualifiers: from:, to:, subject
|
|
282
|
-
*
|
|
281
|
+
* Supports qualifiers: from:, to:, subject:, date:, has:attachment,
|
|
282
|
+
* is:flagged, is:unread, is:read. Unqualified terms search across subject /
|
|
283
|
+
* from / preview. Returns { conditions, params } for SQL WHERE clause with
|
|
284
|
+
* LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
|
|
285
|
+
* range comparisons).
|
|
286
|
+
*
|
|
287
|
+
* Date syntax (matches Gmail-ish conventions):
|
|
288
|
+
* - date:2026-04-22 exact day
|
|
289
|
+
* - date:2026-04 month
|
|
290
|
+
* - date:>2026-04-01 after
|
|
291
|
+
* - date:<2026-04-01 before
|
|
292
|
+
* - date:2026-04-01..2026-04-30 range
|
|
293
|
+
* - date:today / yesterday / last7 / last30
|
|
294
|
+
*/
|
|
283
295
|
export declare function parseSearchQuery(query: string): {
|
|
284
296
|
conditions: string[];
|
|
285
|
-
params: string[];
|
|
297
|
+
params: (string | number)[];
|
|
286
298
|
};
|
|
287
299
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -66,16 +66,68 @@ export function encodeQuotedPrintable(text) {
|
|
|
66
66
|
return result;
|
|
67
67
|
}
|
|
68
68
|
/** Parse search query into structured conditions.
|
|
69
|
-
* Supports qualifiers: from:, to:, subject
|
|
70
|
-
*
|
|
69
|
+
* Supports qualifiers: from:, to:, subject:, date:, has:attachment,
|
|
70
|
+
* is:flagged, is:unread, is:read. Unqualified terms search across subject /
|
|
71
|
+
* from / preview. Returns { conditions, params } for SQL WHERE clause with
|
|
72
|
+
* LIKE plus structured predicates (flags_json LIKE, has_attachments=1, date
|
|
73
|
+
* range comparisons).
|
|
74
|
+
*
|
|
75
|
+
* Date syntax (matches Gmail-ish conventions):
|
|
76
|
+
* - date:2026-04-22 exact day
|
|
77
|
+
* - date:2026-04 month
|
|
78
|
+
* - date:>2026-04-01 after
|
|
79
|
+
* - date:<2026-04-01 before
|
|
80
|
+
* - date:2026-04-01..2026-04-30 range
|
|
81
|
+
* - date:today / yesterday / last7 / last30
|
|
82
|
+
*/
|
|
71
83
|
export function parseSearchQuery(query) {
|
|
72
84
|
const parts = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
73
85
|
const conditions = [];
|
|
74
86
|
const params = [];
|
|
87
|
+
const dayStart = (y, m, d) => new Date(y, m - 1, d).getTime();
|
|
88
|
+
const parseDateSpec = (spec) => {
|
|
89
|
+
const now = new Date();
|
|
90
|
+
const today0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
91
|
+
if (spec === "today")
|
|
92
|
+
return { from: today0, to: today0 + 86400_000 };
|
|
93
|
+
if (spec === "yesterday")
|
|
94
|
+
return { from: today0 - 86400_000, to: today0 };
|
|
95
|
+
const lastN = spec.match(/^last(\d+)$/i);
|
|
96
|
+
if (lastN)
|
|
97
|
+
return { from: today0 - parseInt(lastN[1]) * 86400_000 };
|
|
98
|
+
const rangeMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})\.\.(\d{4})-(\d{2})-(\d{2})$/);
|
|
99
|
+
if (rangeMatch)
|
|
100
|
+
return {
|
|
101
|
+
from: dayStart(+rangeMatch[1], +rangeMatch[2], +rangeMatch[3]),
|
|
102
|
+
to: dayStart(+rangeMatch[4], +rangeMatch[5], +rangeMatch[6]) + 86400_000,
|
|
103
|
+
};
|
|
104
|
+
const gtMatch = spec.match(/^>(\d{4})-(\d{2})-(\d{2})$/);
|
|
105
|
+
if (gtMatch)
|
|
106
|
+
return { from: dayStart(+gtMatch[1], +gtMatch[2], +gtMatch[3]) + 86400_000 };
|
|
107
|
+
const ltMatch = spec.match(/^<(\d{4})-(\d{2})-(\d{2})$/);
|
|
108
|
+
if (ltMatch)
|
|
109
|
+
return { to: dayStart(+ltMatch[1], +ltMatch[2], +ltMatch[3]) };
|
|
110
|
+
const monthMatch = spec.match(/^(\d{4})-(\d{2})$/);
|
|
111
|
+
if (monthMatch) {
|
|
112
|
+
const y = +monthMatch[1], m = +monthMatch[2];
|
|
113
|
+
const from = dayStart(y, m, 1);
|
|
114
|
+
const to = m === 12 ? dayStart(y + 1, 1, 1) : dayStart(y, m + 1, 1);
|
|
115
|
+
return { from, to };
|
|
116
|
+
}
|
|
117
|
+
const dayMatch = spec.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
118
|
+
if (dayMatch) {
|
|
119
|
+
const from = dayStart(+dayMatch[1], +dayMatch[2], +dayMatch[3]);
|
|
120
|
+
return { from, to: from + 86400_000 };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
75
124
|
for (const part of parts) {
|
|
76
125
|
const fromMatch = part.match(/^from:(.+)$/i);
|
|
77
126
|
const toMatch = part.match(/^to:(.+)$/i);
|
|
78
127
|
const subjectMatch = part.match(/^subject:(.+)$/i);
|
|
128
|
+
const hasMatch = part.match(/^has:(.+)$/i);
|
|
129
|
+
const isMatch = part.match(/^is:(.+)$/i);
|
|
130
|
+
const dateMatch = part.match(/^date:(.+)$/i);
|
|
79
131
|
if (fromMatch) {
|
|
80
132
|
const term = `%${fromMatch[1].replace(/"/g, "")}%`;
|
|
81
133
|
conditions.push("(from_name LIKE ? OR from_address LIKE ?)");
|
|
@@ -91,6 +143,42 @@ export function parseSearchQuery(query) {
|
|
|
91
143
|
conditions.push("subject LIKE ?");
|
|
92
144
|
params.push(term);
|
|
93
145
|
}
|
|
146
|
+
else if (hasMatch) {
|
|
147
|
+
const v = hasMatch[1].toLowerCase();
|
|
148
|
+
if (v === "attachment" || v === "attachments") {
|
|
149
|
+
conditions.push("has_attachments = 1");
|
|
150
|
+
}
|
|
151
|
+
// Unknown has: qualifier — silently drop; treating as a literal
|
|
152
|
+
// search term would be confusing.
|
|
153
|
+
}
|
|
154
|
+
else if (isMatch) {
|
|
155
|
+
const v = isMatch[1].toLowerCase();
|
|
156
|
+
if (v === "flagged" || v === "starred") {
|
|
157
|
+
conditions.push("flags_json LIKE ?");
|
|
158
|
+
params.push("%\\\\Flagged%");
|
|
159
|
+
}
|
|
160
|
+
else if (v === "unread") {
|
|
161
|
+
conditions.push("flags_json NOT LIKE ?");
|
|
162
|
+
params.push("%\\\\Seen%");
|
|
163
|
+
}
|
|
164
|
+
else if (v === "read") {
|
|
165
|
+
conditions.push("flags_json LIKE ?");
|
|
166
|
+
params.push("%\\\\Seen%");
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (dateMatch) {
|
|
170
|
+
const spec = parseDateSpec(dateMatch[1]);
|
|
171
|
+
if (spec) {
|
|
172
|
+
if (spec.from !== undefined) {
|
|
173
|
+
conditions.push("date >= ?");
|
|
174
|
+
params.push(spec.from);
|
|
175
|
+
}
|
|
176
|
+
if (spec.to !== undefined) {
|
|
177
|
+
conditions.push("date < ?");
|
|
178
|
+
params.push(spec.to);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
94
182
|
else {
|
|
95
183
|
const term = `%${part}%`;
|
|
96
184
|
conditions.push("(subject LIKE ? OR from_name LIKE ? OR from_address LIKE ? OR preview LIKE ?)");
|