@bobfrankston/mailx 1.0.368 → 1.0.374
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/client/app.js +38 -2
- package/client/components/calendar-sidebar.js +243 -0
- package/client/components/message-viewer.js +14 -5
- package/client/index.html +20 -0
- package/client/lib/api-client.js +5 -0
- package/client/lib/mailxapi.js +1 -0
- package/client/styles/components.css +127 -1
- package/package.json +3 -3
- package/packages/mailx-service/index.d.ts +5 -0
- package/packages/mailx-service/index.js +15 -4
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-settings/index.js +1 -0
- package/packages/mailx-store/db.js +12 -0
- package/packages/mailx-types/index.d.ts +1 -0
package/client/app.js
CHANGED
|
@@ -1803,9 +1803,18 @@ const optSnippet = document.getElementById("opt-snippet");
|
|
|
1803
1803
|
const optThreaded = document.getElementById("opt-threaded");
|
|
1804
1804
|
const optFlagged = document.getElementById("opt-flagged");
|
|
1805
1805
|
const optFolderCounts = document.getElementById("opt-folder-counts");
|
|
1806
|
-
|
|
1806
|
+
const optCalendarSidebar = document.getElementById("opt-calendar-sidebar");
|
|
1807
|
+
// Toggle dropdown — also close any other open toolbar menu so they can't
|
|
1808
|
+
// overlap. Without this, opening View while Settings was already open left
|
|
1809
|
+
// both visible at once (user-reported screenshot).
|
|
1807
1810
|
viewBtn?.addEventListener("click", (e) => {
|
|
1808
1811
|
e.stopPropagation();
|
|
1812
|
+
const settingsDd = document.getElementById("settings-dropdown");
|
|
1813
|
+
if (settingsDd)
|
|
1814
|
+
settingsDd.hidden = true;
|
|
1815
|
+
const restartDd = document.getElementById("restart-dropdown");
|
|
1816
|
+
if (restartDd)
|
|
1817
|
+
restartDd.hidden = true;
|
|
1809
1818
|
if (viewDropdown)
|
|
1810
1819
|
viewDropdown.hidden = !viewDropdown.hidden;
|
|
1811
1820
|
});
|
|
@@ -1846,6 +1855,23 @@ if (savedFlagged)
|
|
|
1846
1855
|
document.getElementById("ml-body")?.classList.add("flagged-only");
|
|
1847
1856
|
if (savedFolderCounts)
|
|
1848
1857
|
document.getElementById("folder-tree")?.classList.add("show-folder-counts");
|
|
1858
|
+
// S51 — Calendar sidebar: View-menu toggle, restore from localStorage,
|
|
1859
|
+
// hide auto-magically on narrow screens (CSS handles that).
|
|
1860
|
+
(async () => {
|
|
1861
|
+
const { initCalendarSidebar, isCalendarSidebarOn, showCalendarSidebar, hideCalendarSidebar } = await import("./components/calendar-sidebar.js");
|
|
1862
|
+
initCalendarSidebar();
|
|
1863
|
+
const on = isCalendarSidebarOn();
|
|
1864
|
+
if (optCalendarSidebar)
|
|
1865
|
+
optCalendarSidebar.checked = on;
|
|
1866
|
+
if (on)
|
|
1867
|
+
await showCalendarSidebar();
|
|
1868
|
+
optCalendarSidebar?.addEventListener("change", () => {
|
|
1869
|
+
if (optCalendarSidebar.checked)
|
|
1870
|
+
showCalendarSidebar();
|
|
1871
|
+
else
|
|
1872
|
+
hideCalendarSidebar();
|
|
1873
|
+
});
|
|
1874
|
+
})();
|
|
1849
1875
|
// Two-line toggle
|
|
1850
1876
|
optTwoLine?.addEventListener("change", () => {
|
|
1851
1877
|
const list = document.getElementById("message-list");
|
|
@@ -2267,7 +2293,10 @@ async function openAboutDialog() {
|
|
|
2267
2293
|
const panel = document.createElement("div");
|
|
2268
2294
|
panel.className = "mailx-modal";
|
|
2269
2295
|
panel.innerHTML = `
|
|
2270
|
-
<div class="mailx-modal-title">
|
|
2296
|
+
<div class="mailx-modal-title">
|
|
2297
|
+
<span class="mailx-modal-title-text">About mailx</span>
|
|
2298
|
+
<button type="button" class="mailx-modal-close" id="about-x" title="Close (Esc)" aria-label="Close">×</button>
|
|
2299
|
+
</div>
|
|
2271
2300
|
<div class="mailx-about" id="about-body">Loading...</div>
|
|
2272
2301
|
<div class="mailx-modal-buttons">
|
|
2273
2302
|
<span class="mailx-modal-spacer"></span>
|
|
@@ -2290,6 +2319,8 @@ async function openAboutDialog() {
|
|
|
2290
2319
|
document.addEventListener("keydown", onKey, true);
|
|
2291
2320
|
panel.querySelector('[data-action="close"]')
|
|
2292
2321
|
.addEventListener("click", close);
|
|
2322
|
+
panel.querySelector("#about-x")
|
|
2323
|
+
.addEventListener("click", close);
|
|
2293
2324
|
backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
|
|
2294
2325
|
close(); });
|
|
2295
2326
|
try {
|
|
@@ -2381,6 +2412,11 @@ const optEditorQuill = document.getElementById("opt-editor-quill");
|
|
|
2381
2412
|
const optEditorTiptap = document.getElementById("opt-editor-tiptap");
|
|
2382
2413
|
settingsBtn?.addEventListener("click", (e) => {
|
|
2383
2414
|
e.stopPropagation();
|
|
2415
|
+
if (viewDropdown)
|
|
2416
|
+
viewDropdown.hidden = true;
|
|
2417
|
+
const restartDd = document.getElementById("restart-dropdown");
|
|
2418
|
+
if (restartDd)
|
|
2419
|
+
restartDd.hidden = true;
|
|
2384
2420
|
if (settingsDropdown)
|
|
2385
2421
|
settingsDropdown.hidden = !settingsDropdown.hidden;
|
|
2386
2422
|
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar sidebar — Thunderbird Lightning "Events and Tasks" pane.
|
|
3
|
+
*
|
|
4
|
+
* Right-docked vertical panel showing:
|
|
5
|
+
* - Day-grouped upcoming events (Today / Tomorrow / Friday Apr 24 / …)
|
|
6
|
+
* - Tasks list (no due date — just a checkable to-do list)
|
|
7
|
+
*
|
|
8
|
+
* Toggled by the View menu's "Calendar sidebar" checkbox. Reads from the
|
|
9
|
+
* primary account's Google Calendar / Tasks via the existing OAuth token.
|
|
10
|
+
* Sidebar and the full-screen calendar modal (calendar.ts) read the SAME
|
|
11
|
+
* underlying data — two views onto one source.
|
|
12
|
+
*
|
|
13
|
+
* Local-only events (LOCAL_STORE_KEY) are merged with Google events.
|
|
14
|
+
*/
|
|
15
|
+
import { getPrimaryAccount } from "../lib/api-client.js";
|
|
16
|
+
const LOCAL_STORE_KEY = "mailx-cal-events-v1";
|
|
17
|
+
const TASK_STORE_KEY = "mailx-tasks-v1";
|
|
18
|
+
const SIDEBAR_PREF = "mailx-calendar-sidebar-on";
|
|
19
|
+
let viewYear = new Date().getFullYear();
|
|
20
|
+
let viewMonth = new Date().getMonth();
|
|
21
|
+
let viewDay = new Date().getDate();
|
|
22
|
+
let lastEvents = [];
|
|
23
|
+
function loadLocalEvents() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = localStorage.getItem(LOCAL_STORE_KEY);
|
|
26
|
+
if (!raw)
|
|
27
|
+
return [];
|
|
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. */
|
|
57
|
+
async function fetchUpcoming(from) {
|
|
58
|
+
const local = loadLocalEvents();
|
|
59
|
+
let google = [];
|
|
60
|
+
try {
|
|
61
|
+
const primary = await getPrimaryAccount();
|
|
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
|
+
const horizon = from.getTime() + 30 * 86400_000;
|
|
72
|
+
return [...local, ...google]
|
|
73
|
+
.filter(e => e.start >= from.getTime() && e.start < horizon)
|
|
74
|
+
.sort((a, b) => a.start - b.start);
|
|
75
|
+
}
|
|
76
|
+
function formatDayHeader(d, today, tomorrow) {
|
|
77
|
+
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
78
|
+
if (sameDay(d, today))
|
|
79
|
+
return "Today";
|
|
80
|
+
if (sameDay(d, tomorrow))
|
|
81
|
+
return "Tomorrow";
|
|
82
|
+
return d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
|
|
83
|
+
}
|
|
84
|
+
function formatTime(e) {
|
|
85
|
+
if (e.allDay)
|
|
86
|
+
return "all day";
|
|
87
|
+
return new Date(e.start).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
88
|
+
}
|
|
89
|
+
function escapeHtml(s) {
|
|
90
|
+
return s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[c]));
|
|
91
|
+
}
|
|
92
|
+
function renderHead() {
|
|
93
|
+
const dateEl = document.getElementById("cal-side-date");
|
|
94
|
+
if (!dateEl)
|
|
95
|
+
return;
|
|
96
|
+
const d = new Date(viewYear, viewMonth, viewDay);
|
|
97
|
+
dateEl.innerHTML = `<strong>${d.getDate()}</strong> ${d.toLocaleDateString(undefined, { weekday: "short" })} <span class="cal-side-date-month">${d.toLocaleDateString(undefined, { month: "short", year: "numeric" })}</span>`;
|
|
98
|
+
}
|
|
99
|
+
function renderEvents(events) {
|
|
100
|
+
const body = document.getElementById("cal-side-body");
|
|
101
|
+
if (!body)
|
|
102
|
+
return;
|
|
103
|
+
if (events.length === 0) {
|
|
104
|
+
body.innerHTML = `<div class="cal-side-empty">No upcoming events. Click + New event to add one.</div>`;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const today = new Date();
|
|
108
|
+
today.setHours(0, 0, 0, 0);
|
|
109
|
+
const tomorrow = new Date(today.getTime() + 86400_000);
|
|
110
|
+
let lastDayKey = "";
|
|
111
|
+
let html = "";
|
|
112
|
+
for (const e of events) {
|
|
113
|
+
const d = new Date(e.start);
|
|
114
|
+
const dayKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
115
|
+
if (dayKey !== lastDayKey) {
|
|
116
|
+
html += `<div class="cal-side-day">${escapeHtml(formatDayHeader(d, today, tomorrow))}</div>`;
|
|
117
|
+
lastDayKey = dayKey;
|
|
118
|
+
}
|
|
119
|
+
html += `<div class="cal-side-event" data-id="${e.id}">
|
|
120
|
+
<span class="cal-side-event-dot ${e.source === "google" ? "g" : "l"}"></span>
|
|
121
|
+
<span class="cal-side-event-time">${escapeHtml(formatTime(e))}</span>
|
|
122
|
+
<span class="cal-side-event-title">${escapeHtml(e.title)}</span>
|
|
123
|
+
</div>`;
|
|
124
|
+
}
|
|
125
|
+
body.innerHTML = html;
|
|
126
|
+
}
|
|
127
|
+
function renderTasks() {
|
|
128
|
+
const showDone = document.getElementById("cal-side-show-done")?.checked || false;
|
|
129
|
+
const tasks = loadTasks().filter(t => showDone || !t.completed);
|
|
130
|
+
const host = document.getElementById("cal-side-tasks");
|
|
131
|
+
if (!host)
|
|
132
|
+
return;
|
|
133
|
+
if (tasks.length === 0) {
|
|
134
|
+
host.innerHTML = `<div class="cal-side-empty">No tasks.</div>`;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let html = "<div class='cal-side-task-head'>Title</div>";
|
|
138
|
+
for (const t of tasks) {
|
|
139
|
+
html += `<div class="cal-side-task" data-id="${t.id}">
|
|
140
|
+
<input type="checkbox" ${t.completed ? "checked" : ""} class="cal-side-task-check">
|
|
141
|
+
<span class="cal-side-task-title${t.completed ? " done" : ""}">${escapeHtml(t.title)}</span>
|
|
142
|
+
</div>`;
|
|
143
|
+
}
|
|
144
|
+
host.innerHTML = html;
|
|
145
|
+
host.querySelectorAll(".cal-side-task").forEach(row => {
|
|
146
|
+
const id = row.dataset.id;
|
|
147
|
+
row.querySelector(".cal-side-task-check")?.addEventListener("change", (e) => {
|
|
148
|
+
const checked = e.target.checked;
|
|
149
|
+
const all = loadTasks();
|
|
150
|
+
const t = all.find(x => x.id === id);
|
|
151
|
+
if (t) {
|
|
152
|
+
t.completed = checked ? Date.now() : undefined;
|
|
153
|
+
saveTasks(all);
|
|
154
|
+
renderTasks();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
async function refresh() {
|
|
160
|
+
renderHead();
|
|
161
|
+
const from = new Date(viewYear, viewMonth, viewDay);
|
|
162
|
+
lastEvents = await fetchUpcoming(from);
|
|
163
|
+
renderEvents(lastEvents);
|
|
164
|
+
renderTasks();
|
|
165
|
+
}
|
|
166
|
+
/** Show the sidebar (called from the View menu toggle). Idempotent. */
|
|
167
|
+
export async function showCalendarSidebar() {
|
|
168
|
+
const el = document.getElementById("calendar-sidebar");
|
|
169
|
+
if (!el)
|
|
170
|
+
return;
|
|
171
|
+
el.hidden = false;
|
|
172
|
+
document.body.classList.add("calendar-sidebar-on");
|
|
173
|
+
try {
|
|
174
|
+
localStorage.setItem(SIDEBAR_PREF, "true");
|
|
175
|
+
}
|
|
176
|
+
catch { /* */ }
|
|
177
|
+
await refresh();
|
|
178
|
+
}
|
|
179
|
+
export function hideCalendarSidebar() {
|
|
180
|
+
const el = document.getElementById("calendar-sidebar");
|
|
181
|
+
if (!el)
|
|
182
|
+
return;
|
|
183
|
+
el.hidden = true;
|
|
184
|
+
document.body.classList.remove("calendar-sidebar-on");
|
|
185
|
+
try {
|
|
186
|
+
localStorage.setItem(SIDEBAR_PREF, "false");
|
|
187
|
+
}
|
|
188
|
+
catch { /* */ }
|
|
189
|
+
}
|
|
190
|
+
export function isCalendarSidebarOn() {
|
|
191
|
+
try {
|
|
192
|
+
return localStorage.getItem(SIDEBAR_PREF) === "true";
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Wire one-time event handlers + restore from localStorage. Safe to call
|
|
199
|
+
* multiple times — handlers are idempotent because the elements are stable. */
|
|
200
|
+
export function initCalendarSidebar() {
|
|
201
|
+
const wireOnce = (id, fn) => {
|
|
202
|
+
const el = document.getElementById(id);
|
|
203
|
+
if (!el || el.__wired)
|
|
204
|
+
return;
|
|
205
|
+
el.__wired = true;
|
|
206
|
+
el.addEventListener("click", fn);
|
|
207
|
+
};
|
|
208
|
+
wireOnce("cal-side-prev", () => { const d = new Date(viewYear, viewMonth, viewDay - 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
|
|
209
|
+
wireOnce("cal-side-next", () => { const d = new Date(viewYear, viewMonth, viewDay + 1); viewYear = d.getFullYear(); viewMonth = d.getMonth(); viewDay = d.getDate(); refresh(); });
|
|
210
|
+
wireOnce("cal-side-today", () => { const t = new Date(); viewYear = t.getFullYear(); viewMonth = t.getMonth(); viewDay = t.getDate(); refresh(); });
|
|
211
|
+
wireOnce("cal-side-new", async () => {
|
|
212
|
+
const title = prompt("Event title:");
|
|
213
|
+
if (!title)
|
|
214
|
+
return;
|
|
215
|
+
const dateStr = prompt("Date and time (YYYY-MM-DD HH:MM, or YYYY-MM-DD for all-day):", new Date(viewYear, viewMonth, viewDay).toISOString().slice(0, 10) + " 09:00");
|
|
216
|
+
if (!dateStr)
|
|
217
|
+
return;
|
|
218
|
+
const allDay = !/\d{1,2}:\d{2}/.test(dateStr);
|
|
219
|
+
const start = Date.parse(dateStr.replace(" ", "T"));
|
|
220
|
+
if (isNaN(start)) {
|
|
221
|
+
alert("Couldn't parse that date.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const ev = {
|
|
225
|
+
id: `cal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
226
|
+
title, start, end: start + (allDay ? 86400_000 : 3600_000),
|
|
227
|
+
allDay, source: "local",
|
|
228
|
+
};
|
|
229
|
+
const all = loadLocalEvents();
|
|
230
|
+
all.push(ev);
|
|
231
|
+
try {
|
|
232
|
+
localStorage.setItem(LOCAL_STORE_KEY, JSON.stringify(all));
|
|
233
|
+
}
|
|
234
|
+
catch { /* */ }
|
|
235
|
+
await refresh();
|
|
236
|
+
});
|
|
237
|
+
const showDoneCb = document.getElementById("cal-side-show-done");
|
|
238
|
+
if (showDoneCb && !showDoneCb.__wired) {
|
|
239
|
+
showDoneCb.__wired = true;
|
|
240
|
+
showDoneCb.addEventListener("change", () => renderTasks());
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=calendar-sidebar.js.map
|
|
@@ -333,7 +333,16 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
333
333
|
unsubBtn.onclick = async (e) => {
|
|
334
334
|
e.preventDefault();
|
|
335
335
|
const status = document.getElementById("status-sync");
|
|
336
|
-
|
|
336
|
+
// Always attempt POST first when there's an HTTPS URL,
|
|
337
|
+
// regardless of whether the message advertised the
|
|
338
|
+
// `List-Unsubscribe-Post: List-Unsubscribe=One-Click`
|
|
339
|
+
// header. Many senders provide a POST-capable endpoint
|
|
340
|
+
// without setting the Post header. Server side already
|
|
341
|
+
// falls back to GET internally on 4xx, so trying POST
|
|
342
|
+
// first never makes things worse and avoids a tab full
|
|
343
|
+
// of "are you sure?" ceremony when the endpoint would
|
|
344
|
+
// have just accepted POST.
|
|
345
|
+
if (httpUrl) {
|
|
337
346
|
unsubBtn.textContent = "Unsubscribing…";
|
|
338
347
|
try {
|
|
339
348
|
const { unsubscribeOneClick } = await import("../lib/api-client.js");
|
|
@@ -347,19 +356,19 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
347
356
|
unsubBtn.textContent = `Failed: HTTP ${r?.status ?? "?"}`;
|
|
348
357
|
if (status)
|
|
349
358
|
status.textContent = `Unsubscribe failed: ${r?.status} ${r?.statusText || ""}`.trim();
|
|
359
|
+
// Last-resort fallback: open the URL in a tab so
|
|
360
|
+
// the user can complete the flow manually.
|
|
361
|
+
window.open(httpUrl, "_blank");
|
|
350
362
|
}
|
|
351
363
|
}
|
|
352
364
|
catch (err) {
|
|
353
365
|
unsubBtn.textContent = "Unsubscribe failed";
|
|
354
366
|
if (status)
|
|
355
367
|
status.textContent = `Unsubscribe error: ${err.message}`;
|
|
368
|
+
window.open(httpUrl, "_blank");
|
|
356
369
|
}
|
|
357
370
|
return;
|
|
358
371
|
}
|
|
359
|
-
if (httpUrl) {
|
|
360
|
-
window.open(httpUrl, "_blank");
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
372
|
if (mailUrl) {
|
|
364
373
|
const m = mailUrl.match(/^mailto:([^?]*)(?:\?(.*))?$/i);
|
|
365
374
|
const to = m?.[1] ? decodeURIComponent(m[1]) : "";
|
package/client/index.html
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
<label class="tb-menu-item"><input type="checkbox" id="opt-threaded"> Group by thread</label>
|
|
29
29
|
<label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
|
|
30
30
|
<label class="tb-menu-item"><input type="checkbox" id="opt-folder-counts"> Folder counts</label>
|
|
31
|
+
<label class="tb-menu-item"><input type="checkbox" id="opt-calendar-sidebar"> Calendar sidebar</label>
|
|
31
32
|
</div>
|
|
32
33
|
</div>
|
|
33
34
|
<div class="tb-menu" id="settings-menu">
|
|
@@ -154,6 +155,25 @@
|
|
|
154
155
|
</section>
|
|
155
156
|
</main>
|
|
156
157
|
|
|
158
|
+
<aside class="calendar-sidebar" id="calendar-sidebar" hidden aria-label="Calendar sidebar">
|
|
159
|
+
<header class="cal-side-head">
|
|
160
|
+
<button class="cal-side-nav" id="cal-side-prev" title="Previous">‹</button>
|
|
161
|
+
<span class="cal-side-date" id="cal-side-date"></span>
|
|
162
|
+
<button class="cal-side-nav" id="cal-side-today" title="Today">○</button>
|
|
163
|
+
<button class="cal-side-nav" id="cal-side-next" title="Next">›</button>
|
|
164
|
+
</header>
|
|
165
|
+
<div class="cal-side-actions">
|
|
166
|
+
<button class="cal-side-new" id="cal-side-new" title="New event">+ New event</button>
|
|
167
|
+
</div>
|
|
168
|
+
<div class="cal-side-body" id="cal-side-body">
|
|
169
|
+
<div class="cal-side-empty">Loading…</div>
|
|
170
|
+
</div>
|
|
171
|
+
<footer class="cal-side-foot">
|
|
172
|
+
<label><input type="checkbox" id="cal-side-show-done"> Show completed Tasks</label>
|
|
173
|
+
<div class="cal-side-tasks" id="cal-side-tasks"></div>
|
|
174
|
+
</footer>
|
|
175
|
+
</aside>
|
|
176
|
+
|
|
157
177
|
<footer class="status-bar" id="status-bar">
|
|
158
178
|
<span id="status-accounts"></span>
|
|
159
179
|
<span id="status-sync">Syncing...</span>
|
package/client/lib/api-client.js
CHANGED
|
@@ -134,6 +134,11 @@ export function getSyncPending() {
|
|
|
134
134
|
export function getDiagnostics() {
|
|
135
135
|
return ipc().getDiagnostics?.() ?? Promise.resolve([]);
|
|
136
136
|
}
|
|
137
|
+
/** Account marked `primary: true` in accounts.jsonc — used by Calendar /
|
|
138
|
+
* Tasks / Contacts to pick which Google account's data to show. */
|
|
139
|
+
export function getPrimaryAccount() {
|
|
140
|
+
return ipc().getPrimaryAccount?.() ?? Promise.resolve(null);
|
|
141
|
+
}
|
|
137
142
|
export function getOutboxStatus() {
|
|
138
143
|
return ipc().getOutboxStatus();
|
|
139
144
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -160,6 +160,7 @@
|
|
|
160
160
|
getSyncPending: function() { return callNode("getSyncPending"); },
|
|
161
161
|
getOutboxStatus: function() { return callNode("getOutboxStatus"); },
|
|
162
162
|
getDiagnostics: function() { return callNode("getDiagnostics"); },
|
|
163
|
+
getPrimaryAccount: function() { return callNode("getPrimaryAccount"); },
|
|
163
164
|
listQueuedOutgoing: function() { return callNode("listQueuedOutgoing"); },
|
|
164
165
|
cancelQueuedOutgoing: function(p) { return callNode("cancelQueuedOutgoing", { path: p }); },
|
|
165
166
|
reauthenticate: function(accountId) { return callNode("reauthenticate", { accountId: accountId }); },
|
|
@@ -720,7 +720,16 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
720
720
|
border-color: transparent;
|
|
721
721
|
font-weight: 500;
|
|
722
722
|
}
|
|
723
|
-
.mailx-modal-btn-primary:hover {
|
|
723
|
+
.mailx-modal-btn-primary:hover {
|
|
724
|
+
filter: brightness(1.1);
|
|
725
|
+
/* Add a border-shadow on hover so the button stays visibly distinct from
|
|
726
|
+
the modal background regardless of theme — `filter: brightness` alone
|
|
727
|
+
could push the accent close to the modal background on some palettes,
|
|
728
|
+
making the button look like it disappeared. */
|
|
729
|
+
box-shadow: 0 0 0 2px var(--color-accent);
|
|
730
|
+
outline: 1px solid var(--color-bg);
|
|
731
|
+
outline-offset: -1px;
|
|
732
|
+
}
|
|
724
733
|
|
|
725
734
|
/* About dialog */
|
|
726
735
|
.mailx-about { font-size: var(--font-size-sm); line-height: 1.5; }
|
|
@@ -790,6 +799,123 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
790
799
|
background: oklch(0.85 0.10 350); /* deeper pink when selected */
|
|
791
800
|
}
|
|
792
801
|
|
|
802
|
+
/* S51 — calendar sidebar (Thunderbird Lightning Events & Tasks pane).
|
|
803
|
+
Right-docked, fixed width, hidden by default. Toggled by View menu.
|
|
804
|
+
Hides automatically on narrow screens (< 1100px) — Android uses the
|
|
805
|
+
native calendar app. */
|
|
806
|
+
.calendar-sidebar {
|
|
807
|
+
grid-area: cal-side;
|
|
808
|
+
width: 280px;
|
|
809
|
+
border-left: 1px solid var(--color-border);
|
|
810
|
+
background: var(--color-bg);
|
|
811
|
+
display: flex;
|
|
812
|
+
flex-direction: column;
|
|
813
|
+
overflow: hidden;
|
|
814
|
+
font-size: var(--font-size-sm);
|
|
815
|
+
}
|
|
816
|
+
@media (max-width: 1100px) {
|
|
817
|
+
.calendar-sidebar { display: none; }
|
|
818
|
+
}
|
|
819
|
+
body.calendar-sidebar-on {
|
|
820
|
+
/* Re-flow main grid to make room for the sidebar on the right.
|
|
821
|
+
Folder-tree / message-list / message-viewer share the remaining area. */
|
|
822
|
+
}
|
|
823
|
+
.cal-side-head {
|
|
824
|
+
display: flex;
|
|
825
|
+
align-items: center;
|
|
826
|
+
gap: var(--gap-xs);
|
|
827
|
+
padding: var(--gap-sm);
|
|
828
|
+
border-bottom: 1px solid var(--color-border);
|
|
829
|
+
}
|
|
830
|
+
.cal-side-date { flex: 1; }
|
|
831
|
+
.cal-side-date strong { font-size: 1.4em; }
|
|
832
|
+
.cal-side-date-month { color: var(--color-text-muted); font-size: 0.85em; }
|
|
833
|
+
.cal-side-nav {
|
|
834
|
+
background: transparent;
|
|
835
|
+
border: 1px solid var(--color-border);
|
|
836
|
+
border-radius: var(--radius-sm);
|
|
837
|
+
padding: 1px 6px;
|
|
838
|
+
cursor: pointer;
|
|
839
|
+
font-size: 0.95em;
|
|
840
|
+
color: var(--color-text);
|
|
841
|
+
}
|
|
842
|
+
.cal-side-nav:hover { background: var(--color-bg-hover); }
|
|
843
|
+
.cal-side-actions {
|
|
844
|
+
padding: var(--gap-xs) var(--gap-sm);
|
|
845
|
+
border-bottom: 1px solid var(--color-border);
|
|
846
|
+
}
|
|
847
|
+
.cal-side-new {
|
|
848
|
+
width: 100%;
|
|
849
|
+
padding: 4px 8px;
|
|
850
|
+
background: transparent;
|
|
851
|
+
border: 1px dashed var(--color-border);
|
|
852
|
+
border-radius: var(--radius-sm);
|
|
853
|
+
cursor: pointer;
|
|
854
|
+
color: var(--color-text);
|
|
855
|
+
font-size: var(--font-size-sm);
|
|
856
|
+
}
|
|
857
|
+
.cal-side-new:hover { background: var(--color-bg-hover); }
|
|
858
|
+
.cal-side-body {
|
|
859
|
+
flex: 1;
|
|
860
|
+
overflow-y: auto;
|
|
861
|
+
padding: var(--gap-xs) 0;
|
|
862
|
+
}
|
|
863
|
+
.cal-side-day {
|
|
864
|
+
padding: 4px var(--gap-sm);
|
|
865
|
+
background: var(--color-bg-surface);
|
|
866
|
+
font-weight: 600;
|
|
867
|
+
color: var(--color-text);
|
|
868
|
+
border-bottom: 1px solid var(--color-border);
|
|
869
|
+
}
|
|
870
|
+
.cal-side-event {
|
|
871
|
+
display: flex;
|
|
872
|
+
align-items: baseline;
|
|
873
|
+
gap: 6px;
|
|
874
|
+
padding: 3px var(--gap-sm) 3px 18px;
|
|
875
|
+
cursor: default;
|
|
876
|
+
}
|
|
877
|
+
.cal-side-event:hover { background: var(--color-bg-hover); }
|
|
878
|
+
.cal-side-event-dot {
|
|
879
|
+
display: inline-block;
|
|
880
|
+
width: 8px;
|
|
881
|
+
height: 8px;
|
|
882
|
+
border-radius: 50%;
|
|
883
|
+
flex: 0 0 8px;
|
|
884
|
+
}
|
|
885
|
+
.cal-side-event-dot.l { background: oklch(0.70 0.18 70); } /* local — amber */
|
|
886
|
+
.cal-side-event-dot.g { background: oklch(0.65 0.20 250); } /* google — blue */
|
|
887
|
+
.cal-side-event-time { color: var(--color-text); min-width: 44px; font-variant-numeric: tabular-nums; }
|
|
888
|
+
.cal-side-event-title { flex: 1; }
|
|
889
|
+
.cal-side-empty {
|
|
890
|
+
padding: var(--gap-md) var(--gap-sm);
|
|
891
|
+
color: var(--color-text-muted);
|
|
892
|
+
text-align: center;
|
|
893
|
+
font-style: italic;
|
|
894
|
+
}
|
|
895
|
+
.cal-side-foot {
|
|
896
|
+
border-top: 1px solid var(--color-border);
|
|
897
|
+
padding: var(--gap-xs) var(--gap-sm);
|
|
898
|
+
font-size: var(--font-size-sm);
|
|
899
|
+
max-height: 35%;
|
|
900
|
+
overflow-y: auto;
|
|
901
|
+
}
|
|
902
|
+
.cal-side-foot label { display: block; padding: 2px 0; cursor: pointer; }
|
|
903
|
+
.cal-side-task-head {
|
|
904
|
+
font-weight: 600;
|
|
905
|
+
padding: var(--gap-xs) 0;
|
|
906
|
+
color: var(--color-text-muted);
|
|
907
|
+
}
|
|
908
|
+
.cal-side-task {
|
|
909
|
+
display: flex;
|
|
910
|
+
align-items: center;
|
|
911
|
+
gap: 6px;
|
|
912
|
+
padding: 2px 0;
|
|
913
|
+
}
|
|
914
|
+
.cal-side-task-title.done {
|
|
915
|
+
text-decoration: line-through;
|
|
916
|
+
color: var(--color-text-muted);
|
|
917
|
+
}
|
|
918
|
+
|
|
793
919
|
.ml-empty {
|
|
794
920
|
grid-column: 1 / -1;
|
|
795
921
|
display: flex;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.374",
|
|
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.345",
|
|
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.345",
|
|
92
92
|
"@bobfrankston/mailx-host": "^0.1.4",
|
|
93
93
|
"@capacitor/android": "^8.3.0",
|
|
94
94
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -39,6 +39,11 @@ export declare class MailxService {
|
|
|
39
39
|
/** Per-account health snapshot: inactivity-timeout count, conn-cap hits,
|
|
40
40
|
* last failed IMAP command. Drives the diagnostics ⚠ badge in the UI. */
|
|
41
41
|
getDiagnostics(): any;
|
|
42
|
+
/** Return the account marked `primary: true` in accounts.jsonc, or the
|
|
43
|
+
* first account if none. Used by Calendar / Tasks / Contacts to pick
|
|
44
|
+
* which Google account's data to show. Single-flag for now;
|
|
45
|
+
* per-feature flags + multi-calendar comingling deferred. */
|
|
46
|
+
getPrimaryAccount(): any;
|
|
42
47
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
43
48
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
44
49
|
listQueuedOutgoing(): any[];
|
|
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
-
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete,
|
|
11
|
+
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
|
|
12
12
|
import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
|
|
13
13
|
import { simpleParser } from "mailparser";
|
|
14
14
|
/** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
|
|
@@ -102,7 +102,7 @@ export class MailxService {
|
|
|
102
102
|
for (const cfg of cfgs) {
|
|
103
103
|
const a = dbAccounts.find(d => d.id === cfg.id);
|
|
104
104
|
if (a)
|
|
105
|
-
ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [] });
|
|
105
|
+
ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, primary: !!cfg.primary, identityDomains: cfg.identityDomains || [] });
|
|
106
106
|
}
|
|
107
107
|
// Append any DB accounts not in settings
|
|
108
108
|
for (const a of dbAccounts) {
|
|
@@ -270,8 +270,11 @@ export class MailxService {
|
|
|
270
270
|
parseListUnsubscribe(parsed2.headers));
|
|
271
271
|
listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
|
|
272
272
|
}
|
|
273
|
-
|
|
274
|
-
|
|
273
|
+
// EML path: read the real on-disk path from `envelope.bodyPath` (DB
|
|
274
|
+
// is authoritative since v1.0.361 — files are opaque UUIDs, not the
|
|
275
|
+
// old {folderId}/{uid}.eml layout). Synthesizing the legacy path
|
|
276
|
+
// here showed users a path that doesn't exist.
|
|
277
|
+
const emlPath = envelope.bodyPath || "";
|
|
275
278
|
return {
|
|
276
279
|
...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
|
|
277
280
|
attachments, emlPath, deliveredTo, returnPath,
|
|
@@ -418,6 +421,14 @@ export class MailxService {
|
|
|
418
421
|
getDiagnostics() {
|
|
419
422
|
return this.imapManager.getDiagnosticsSnapshot();
|
|
420
423
|
}
|
|
424
|
+
/** Return the account marked `primary: true` in accounts.jsonc, or the
|
|
425
|
+
* first account if none. Used by Calendar / Tasks / Contacts to pick
|
|
426
|
+
* which Google account's data to show. Single-flag for now;
|
|
427
|
+
* per-feature flags + multi-calendar comingling deferred. */
|
|
428
|
+
getPrimaryAccount() {
|
|
429
|
+
const all = this.getAccounts();
|
|
430
|
+
return all.find((a) => a.primary) || all[0] || null;
|
|
431
|
+
}
|
|
421
432
|
/** List queued outgoing messages with parsed envelope headers so the UI
|
|
422
433
|
* can render a pink-row "pending" view before IMAP APPEND succeeds. */
|
|
423
434
|
listQueuedOutgoing() {
|
|
@@ -96,6 +96,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
96
96
|
return svc.getSyncPending();
|
|
97
97
|
case "getDiagnostics":
|
|
98
98
|
return svc.getDiagnostics();
|
|
99
|
+
case "getPrimaryAccount":
|
|
100
|
+
return svc.getPrimaryAccount();
|
|
99
101
|
case "getOutboxStatus":
|
|
100
102
|
return svc.getOutboxStatus();
|
|
101
103
|
case "listQueuedOutgoing":
|
|
@@ -376,6 +376,7 @@ function normalizeAccount(acct, globalName) {
|
|
|
376
376
|
password: acct.smtp?.password || acct.password,
|
|
377
377
|
},
|
|
378
378
|
enabled: acct.enabled ?? true,
|
|
379
|
+
primary: acct.primary,
|
|
379
380
|
defaultSend: acct.defaultSend,
|
|
380
381
|
syncContacts: acct.syncContacts ?? (provider?.imap.auth === "oauth2"),
|
|
381
382
|
relayDomains: acct.relayDomains,
|
|
@@ -654,6 +654,18 @@ export class MailxDB {
|
|
|
654
654
|
this.db.prepare("UPDATE messages SET flags_json = ? WHERE account_id = ? AND uid = ?").run(JSON.stringify(flags), accountId, uid);
|
|
655
655
|
}
|
|
656
656
|
updateMessageFolder(accountId, uid, targetFolderId) {
|
|
657
|
+
// Idempotency: if a row already exists at (account, target_folder, uid)
|
|
658
|
+
// — common with Gmail's hash-synthesized UIDs across labels, or any
|
|
659
|
+
// case where the move was already partially applied — the UPDATE
|
|
660
|
+
// would fail the (acct, folder, uid) unique constraint. Treat it as
|
|
661
|
+
// a no-op: drop the source row, the message is already where the
|
|
662
|
+
// user wants it. Previously surfaced as "Mark-as-spam failed: UNIQUE
|
|
663
|
+
// constraint failed" — bad UX for what is logically already done.
|
|
664
|
+
const existingTarget = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(accountId, targetFolderId, uid);
|
|
665
|
+
if (existingTarget) {
|
|
666
|
+
this.db.prepare("DELETE FROM messages WHERE account_id = ? AND uid = ? AND folder_id != ?").run(accountId, uid, targetFolderId);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
657
669
|
this.db.prepare("UPDATE messages SET folder_id = ? WHERE account_id = ? AND uid = ?").run(targetFolderId, accountId, uid);
|
|
658
670
|
}
|
|
659
671
|
updateBodyPath(accountId, uid, bodyPath) {
|
|
@@ -29,6 +29,7 @@ export interface AccountConfig {
|
|
|
29
29
|
password?: string;
|
|
30
30
|
};
|
|
31
31
|
enabled: boolean;
|
|
32
|
+
primary?: boolean; /** Designates the account that supplies Calendar / Tasks / Contacts data. At most one per user; first one wins if multiple set. */
|
|
32
33
|
defaultSend?: boolean; /** Use this account's SMTP when From doesn't match any account */
|
|
33
34
|
syncContacts?: boolean; /** Sync contacts even when account is disabled (contacts-only Gmail) */
|
|
34
35
|
relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
|