@bobfrankston/mailx 1.0.370 → 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 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
- // Toggle dropdown
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">About mailx</div>
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">&times;</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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[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
- if (oneClick && httpUrl) {
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>
@@ -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
  }
@@ -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 { filter: brightness(1.1); }
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.370",
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",
@@ -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, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
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
- const storePath = getStorePath();
274
- const emlPath = path.join(storePath, accountId, String(envelope.folderId), `${envelope.uid}.eml`);
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,
@@ -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"]) */