@bobfrankston/mailx 1.0.378 → 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 +45 -79
- 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 +10 -5
- package/client/lib/api-client.js +31 -2
- package/client/lib/mailxapi.js +13 -0
- package/client/styles/components.css +124 -0
- package/client/styles/layout.css +34 -10
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +31 -2
- package/packages/mailx-service/google-sync.d.ts +83 -0
- package/packages/mailx-service/google-sync.js +140 -0
- package/packages/mailx-service/index.d.ts +44 -2
- package/packages/mailx-service/index.js +218 -3
- package/packages/mailx-service/jsonrpc.js +25 -0
- package/packages/mailx-store/db.d.ts +49 -0
- package/packages/mailx-store/db.js +255 -6
- 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();
|
|
@@ -10,68 +10,33 @@
|
|
|
10
10
|
* Sidebar and the full-screen calendar modal (calendar.ts) read the SAME
|
|
11
11
|
* underlying data — two views onto one source.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
13
|
+
* All storage goes through the service-side two-way cache (calendar_events
|
|
14
|
+
* and tasks tables); this file does not use localStorage for data.
|
|
14
15
|
*/
|
|
15
|
-
import {
|
|
16
|
-
const LOCAL_STORE_KEY = "mailx-cal-events-v1";
|
|
17
|
-
const TASK_STORE_KEY = "mailx-tasks-v1";
|
|
16
|
+
import { getCalendarEvents, createCalendarEvent, getTasks, createTask, updateTask, deleteTask, } from "../lib/api-client.js";
|
|
18
17
|
const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
|
|
19
18
|
let viewYear = new Date().getFullYear();
|
|
20
19
|
let viewMonth = new Date().getMonth();
|
|
21
20
|
let viewDay = new Date().getDate();
|
|
22
21
|
let lastEvents = [];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const arr = JSON.parse(raw);
|
|
29
|
-
return Array.isArray(arr) ? arr : [];
|
|
30
|
-
}
|
|
31
|
-
catch {
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
function loadTasks() {
|
|
36
|
-
try {
|
|
37
|
-
const raw = localStorage.getItem(TASK_STORE_KEY);
|
|
38
|
-
if (!raw)
|
|
39
|
-
return [];
|
|
40
|
-
const arr = JSON.parse(raw);
|
|
41
|
-
return Array.isArray(arr) ? arr : [];
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
return [];
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
function saveTasks(tasks) {
|
|
48
|
-
try {
|
|
49
|
-
localStorage.setItem(TASK_STORE_KEY, JSON.stringify(tasks));
|
|
50
|
-
}
|
|
51
|
-
catch { /* */ }
|
|
52
|
-
}
|
|
53
|
-
/** Fetch upcoming events from Google Calendar via the primary account's
|
|
54
|
-
* OAuth token. Returns merged Google + local events for the next 30 days
|
|
55
|
-
* starting at `from`. Quietly returns local-only on any error so the
|
|
56
|
-
* sidebar still works without network / without Google Calendar scope. */
|
|
22
|
+
/** Fetch events from the local two-way cache; service returns local rows
|
|
23
|
+
* immediately and kicks a background refresh from Google. Next render
|
|
24
|
+
* (view-nav or user action) picks up the refreshed rows. No localStorage
|
|
25
|
+
* — everything lives in the service-side DB so phone / desktop share
|
|
26
|
+
* the same events. */
|
|
57
27
|
async function fetchUpcoming(from) {
|
|
58
|
-
const local = loadLocalEvents();
|
|
59
|
-
let google = [];
|
|
60
|
-
try {
|
|
61
|
-
const primary = await getPrimaryAccount("calendar");
|
|
62
|
-
if (primary?.email) {
|
|
63
|
-
// The OAuth token is held by the service-side OAuthTokenManager;
|
|
64
|
-
// calendar fetch goes through a server-side proxy method (not
|
|
65
|
-
// implemented yet — this is the seam). For now: local-only.
|
|
66
|
-
// Wire-up: TODO — service.fetchGoogleCalendarEvents(accountId, from, days).
|
|
67
|
-
// Sidebar already renders correctly with the merged result.
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
catch { /* google unavailable, fall back to local */ }
|
|
71
28
|
const horizon = from.getTime() + 30 * 86400_000;
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.
|
|
29
|
+
const rows = await getCalendarEvents(from.getTime(), horizon);
|
|
30
|
+
return rows.map((r) => ({
|
|
31
|
+
id: r.uuid,
|
|
32
|
+
title: r.title,
|
|
33
|
+
start: r.startMs,
|
|
34
|
+
end: r.endMs,
|
|
35
|
+
allDay: !!r.allDay,
|
|
36
|
+
location: r.location,
|
|
37
|
+
notes: r.notes,
|
|
38
|
+
source: r.providerId ? "google" : "local",
|
|
39
|
+
}));
|
|
75
40
|
}
|
|
76
41
|
function formatDayHeader(d, today, tomorrow) {
|
|
77
42
|
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
@@ -124,9 +89,9 @@ function renderEvents(events) {
|
|
|
124
89
|
}
|
|
125
90
|
body.innerHTML = html;
|
|
126
91
|
}
|
|
127
|
-
function renderTasks() {
|
|
92
|
+
async function renderTasks() {
|
|
128
93
|
const showDone = document.getElementById("cal-side-show-done")?.checked || false;
|
|
129
|
-
const tasks =
|
|
94
|
+
const tasks = await getTasks(showDone);
|
|
130
95
|
const host = document.getElementById("cal-side-tasks");
|
|
131
96
|
if (!host)
|
|
132
97
|
return;
|
|
@@ -136,23 +101,24 @@ function renderTasks() {
|
|
|
136
101
|
}
|
|
137
102
|
let html = "<div class='cal-side-task-head'>Title</div>";
|
|
138
103
|
for (const t of tasks) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<
|
|
104
|
+
const done = !!t.completedMs;
|
|
105
|
+
html += `<div class="cal-side-task" data-uuid="${t.uuid}">
|
|
106
|
+
<input type="checkbox" ${done ? "checked" : ""} class="cal-side-task-check">
|
|
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>
|
|
142
109
|
</div>`;
|
|
143
110
|
}
|
|
144
111
|
host.innerHTML = html;
|
|
145
112
|
host.querySelectorAll(".cal-side-task").forEach(row => {
|
|
146
|
-
const
|
|
147
|
-
row.querySelector(".cal-side-task-check")?.addEventListener("change", (e) => {
|
|
113
|
+
const uuid = row.dataset.uuid;
|
|
114
|
+
row.querySelector(".cal-side-task-check")?.addEventListener("change", async (e) => {
|
|
148
115
|
const checked = e.target.checked;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
116
|
+
await updateTask(uuid, { completedMs: checked ? Date.now() : null });
|
|
117
|
+
renderTasks();
|
|
118
|
+
});
|
|
119
|
+
row.querySelector(".cal-side-task-delete")?.addEventListener("click", async () => {
|
|
120
|
+
await deleteTask(uuid);
|
|
121
|
+
renderTasks();
|
|
156
122
|
});
|
|
157
123
|
});
|
|
158
124
|
}
|
|
@@ -225,19 +191,19 @@ export function initCalendarSidebar() {
|
|
|
225
191
|
alert("Couldn't parse that date.");
|
|
226
192
|
return;
|
|
227
193
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
title, start,
|
|
231
|
-
|
|
232
|
-
};
|
|
233
|
-
const all = loadLocalEvents();
|
|
234
|
-
all.push(ev);
|
|
235
|
-
try {
|
|
236
|
-
localStorage.setItem(LOCAL_STORE_KEY, JSON.stringify(all));
|
|
237
|
-
}
|
|
238
|
-
catch { /* */ }
|
|
194
|
+
// Two-way cache: commit locally, service queues the push to Google.
|
|
195
|
+
await createCalendarEvent({
|
|
196
|
+
title, startMs: start, endMs: start + (allDay ? 86400_000 : 3600_000), allDay,
|
|
197
|
+
});
|
|
239
198
|
await refresh();
|
|
240
199
|
});
|
|
200
|
+
wireOnce("cal-side-new-task", async () => {
|
|
201
|
+
const title = prompt("Task title:");
|
|
202
|
+
if (!title)
|
|
203
|
+
return;
|
|
204
|
+
await createTask({ title });
|
|
205
|
+
renderTasks();
|
|
206
|
+
});
|
|
241
207
|
const showDoneCb = document.getElementById("cal-side-show-done");
|
|
242
208
|
if (showDoneCb && !showDoneCb.__wired) {
|
|
243
209
|
showDoneCb.__wired = true;
|
|
@@ -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>
|
|
@@ -170,7 +171,10 @@
|
|
|
170
171
|
<div class="cal-side-empty">Loading…</div>
|
|
171
172
|
</div>
|
|
172
173
|
<footer class="cal-side-foot">
|
|
173
|
-
<
|
|
174
|
+
<div class="cal-side-task-header-row">
|
|
175
|
+
<label><input type="checkbox" id="cal-side-show-done"> Show completed Tasks</label>
|
|
176
|
+
<button class="cal-side-new" id="cal-side-new-task" title="New task">+ Task</button>
|
|
177
|
+
</div>
|
|
174
178
|
<div class="cal-side-tasks" id="cal-side-tasks"></div>
|
|
175
179
|
</footer>
|
|
176
180
|
</aside>
|
|
@@ -178,6 +182,7 @@
|
|
|
178
182
|
<footer class="status-bar" id="status-bar">
|
|
179
183
|
<span id="status-accounts"></span>
|
|
180
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>
|
|
181
186
|
<span id="status-diag" class="status-diag" hidden title=""></span>
|
|
182
187
|
<span id="status-pending"></span>
|
|
183
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();
|
|
@@ -141,6 +141,35 @@ export function getDiagnostics() {
|
|
|
141
141
|
export function getPrimaryAccount(feature) {
|
|
142
142
|
return ipc().getPrimaryAccount?.(feature) ?? Promise.resolve(null);
|
|
143
143
|
}
|
|
144
|
+
// Calendar / Tasks: two-way cache. Reads return local-cached rows; writes
|
|
145
|
+
// commit locally and queue a push to Google. Service layer handles drain.
|
|
146
|
+
export function getCalendarEvents(fromMs, toMs) {
|
|
147
|
+
return ipc().getCalendarEvents?.(fromMs, toMs) ?? Promise.resolve([]);
|
|
148
|
+
}
|
|
149
|
+
export function createCalendarEvent(ev) {
|
|
150
|
+
return ipc().createCalendarEvent?.(ev);
|
|
151
|
+
}
|
|
152
|
+
export function updateCalendarEvent(uuid, patch) {
|
|
153
|
+
return ipc().updateCalendarEvent?.(uuid, patch);
|
|
154
|
+
}
|
|
155
|
+
export function deleteCalendarEvent(uuid) {
|
|
156
|
+
return ipc().deleteCalendarEvent?.(uuid);
|
|
157
|
+
}
|
|
158
|
+
export function getTasks(includeCompleted = false) {
|
|
159
|
+
return ipc().getTasks?.(includeCompleted) ?? Promise.resolve([]);
|
|
160
|
+
}
|
|
161
|
+
export function createTask(t) {
|
|
162
|
+
return ipc().createTask?.(t);
|
|
163
|
+
}
|
|
164
|
+
export function updateTask(uuid, patch) {
|
|
165
|
+
return ipc().updateTask?.(uuid, patch);
|
|
166
|
+
}
|
|
167
|
+
export function deleteTask(uuid) {
|
|
168
|
+
return ipc().deleteTask?.(uuid);
|
|
169
|
+
}
|
|
170
|
+
export function drainStoreSync() {
|
|
171
|
+
return ipc().drainStoreSync?.();
|
|
172
|
+
}
|
|
144
173
|
export function getOutboxStatus() {
|
|
145
174
|
return ipc().getOutboxStatus();
|
|
146
175
|
}
|