@bobfrankston/mailx 1.0.339 → 1.0.348

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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Address book modal — list / search / edit / delete contacts.
3
+ * Sources: 'sent' (added on outgoing), 'received' (seeded from message senders),
4
+ * 'manual' (added directly here), 'google' (Google Contacts sync).
5
+ */
6
+ import { listContacts, upsertContact, deleteContact } from "../lib/api-client.js";
7
+ let isOpen = false;
8
+ export async function openAddressBook() {
9
+ if (isOpen)
10
+ return;
11
+ isOpen = true;
12
+ const backdrop = document.createElement("div");
13
+ backdrop.className = "mailx-modal-backdrop";
14
+ const panel = document.createElement("div");
15
+ panel.className = "mailx-modal mailx-modal-wide";
16
+ panel.innerHTML = `
17
+ <div class="mailx-modal-title">
18
+ <span class="mailx-modal-title-text">Address Book</span>
19
+ <button type="button" class="mailx-modal-close" id="ab-close" title="Close (Esc)" aria-label="Close">&times;</button>
20
+ </div>
21
+ <div class="ab-toolbar">
22
+ <input type="search" id="ab-search" class="mailx-modal-input" placeholder="Search name or email…" autocomplete="off">
23
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" id="ab-new">+ New contact</button>
24
+ </div>
25
+ <div class="ab-count" id="ab-count"></div>
26
+ <div class="ab-list" id="ab-list">Loading…</div>
27
+ <div class="mailx-modal-buttons">
28
+ <span class="mailx-modal-spacer"></span>
29
+ <button type="button" class="mailx-modal-btn" data-action="close">Close</button>
30
+ </div>`;
31
+ backdrop.appendChild(panel);
32
+ document.body.appendChild(backdrop);
33
+ const searchInput = panel.querySelector("#ab-search");
34
+ const listEl = panel.querySelector("#ab-list");
35
+ const countEl = panel.querySelector("#ab-count");
36
+ let editingEmail = null;
37
+ const render = (items, total) => {
38
+ countEl.textContent = total === items.length
39
+ ? `${total} contact${total === 1 ? "" : "s"}`
40
+ : `Showing ${items.length} of ${total}`;
41
+ if (items.length === 0) {
42
+ listEl.innerHTML = `<div class="ab-empty">No contacts match.</div>`;
43
+ return;
44
+ }
45
+ const fmtDate = (ms) => {
46
+ if (!ms)
47
+ return "";
48
+ const d = new Date(ms);
49
+ const now = new Date();
50
+ return d.getFullYear() === now.getFullYear()
51
+ ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
52
+ : d.toLocaleDateString();
53
+ };
54
+ listEl.innerHTML = `
55
+ <div class="ab-row ab-header">
56
+ <span class="ab-name">Name</span>
57
+ <span class="ab-email">Email</span>
58
+ <span class="ab-source">Source</span>
59
+ <span class="ab-count-cell" title="Times sent to">×</span>
60
+ <span class="ab-last">Last</span>
61
+ <span class="ab-actions"></span>
62
+ </div>` + items.map(c => `
63
+ <div class="ab-row" data-email="${escapeAttr(c.email)}">
64
+ <span class="ab-name">${escapeHtml(c.name || "")}</span>
65
+ <span class="ab-email">${escapeHtml(c.email)}</span>
66
+ <span class="ab-source">${escapeHtml(c.source)}</span>
67
+ <span class="ab-count-cell">${c.useCount || 0}</span>
68
+ <span class="ab-last">${fmtDate(c.lastUsed)}</span>
69
+ <span class="ab-actions">
70
+ <button type="button" class="ab-edit" title="Edit name">✎</button>
71
+ <button type="button" class="ab-del" title="Delete">🗑</button>
72
+ <button type="button" class="ab-mail" title="Compose to">✉</button>
73
+ </span>
74
+ </div>`).join("");
75
+ listEl.querySelectorAll(".ab-row[data-email]").forEach(row => {
76
+ const email = row.dataset.email;
77
+ const c = items.find(x => x.email === email);
78
+ row.querySelector(".ab-edit")?.addEventListener("click", () => beginEdit(row, c));
79
+ row.querySelector(".ab-del")?.addEventListener("click", () => doDelete(c));
80
+ row.querySelector(".ab-mail")?.addEventListener("click", () => composeTo(c));
81
+ });
82
+ };
83
+ const beginEdit = (row, c) => {
84
+ if (editingEmail === c.email)
85
+ return;
86
+ editingEmail = c.email;
87
+ const nameSpan = row.querySelector(".ab-name");
88
+ const original = c.name || "";
89
+ nameSpan.innerHTML = `<input type="text" value="${escapeAttr(original)}" class="ab-name-input">`;
90
+ const inp = nameSpan.querySelector("input");
91
+ inp.focus();
92
+ inp.select();
93
+ const commit = async () => {
94
+ const newName = inp.value.trim();
95
+ if (newName !== original) {
96
+ try {
97
+ await upsertContact(newName, c.email);
98
+ c.name = newName;
99
+ }
100
+ catch (e) {
101
+ alert(`Update failed: ${e?.message || e}`);
102
+ }
103
+ }
104
+ nameSpan.textContent = c.name || "";
105
+ editingEmail = null;
106
+ };
107
+ inp.addEventListener("blur", commit);
108
+ inp.addEventListener("keydown", (e) => {
109
+ if (e.key === "Enter") {
110
+ e.preventDefault();
111
+ inp.blur();
112
+ }
113
+ else if (e.key === "Escape") {
114
+ e.preventDefault();
115
+ inp.value = original;
116
+ inp.blur();
117
+ }
118
+ });
119
+ };
120
+ const doDelete = async (c) => {
121
+ if (!confirm(`Delete contact "${c.name || c.email}"?`))
122
+ return;
123
+ try {
124
+ await deleteContact(c.email);
125
+ await reload();
126
+ }
127
+ catch (e) {
128
+ alert(`Delete failed: ${e?.message || e}`);
129
+ }
130
+ };
131
+ const composeTo = (c) => {
132
+ const init = {
133
+ mode: "new",
134
+ to: [{ name: c.name || "", address: c.email }],
135
+ cc: [], bcc: [], subject: "", bodyHtml: "",
136
+ inReplyTo: "", references: [], accounts: [],
137
+ };
138
+ try {
139
+ sessionStorage.setItem("composeInit", JSON.stringify(init));
140
+ }
141
+ catch { /* */ }
142
+ document.dispatchEvent(new CustomEvent("mailx-compose", { detail: { mode: "new" } }));
143
+ close();
144
+ };
145
+ let reloadDebounce;
146
+ const reload = async () => {
147
+ try {
148
+ const r = await listContacts(searchInput.value, 1, 200);
149
+ render(r.items, r.total);
150
+ }
151
+ catch (e) {
152
+ listEl.innerHTML = `<div class="ab-empty">Load failed: ${escapeHtml(e?.message || String(e))}</div>`;
153
+ }
154
+ };
155
+ const scheduleReload = () => {
156
+ if (reloadDebounce)
157
+ window.clearTimeout(reloadDebounce);
158
+ reloadDebounce = window.setTimeout(reload, 200);
159
+ };
160
+ panel.querySelector("#ab-new")?.addEventListener("click", async () => {
161
+ const email = prompt("Email address:");
162
+ if (!email)
163
+ return;
164
+ const name = prompt("Display name (optional):") || "";
165
+ try {
166
+ await upsertContact(name, email.trim());
167
+ await reload();
168
+ }
169
+ catch (e) {
170
+ alert(`Add failed: ${e?.message || e}`);
171
+ }
172
+ });
173
+ searchInput.addEventListener("input", scheduleReload);
174
+ const close = () => {
175
+ if (reloadDebounce)
176
+ window.clearTimeout(reloadDebounce);
177
+ backdrop.remove();
178
+ document.removeEventListener("keydown", onKey, true);
179
+ isOpen = false;
180
+ };
181
+ const onKey = (e) => {
182
+ if (e.key === "Escape") {
183
+ e.stopPropagation();
184
+ e.preventDefault();
185
+ close();
186
+ }
187
+ };
188
+ document.addEventListener("keydown", onKey, true);
189
+ panel.querySelector("#ab-close").addEventListener("click", close);
190
+ panel.querySelector('[data-action="close"]').addEventListener("click", close);
191
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
192
+ close(); });
193
+ await reload();
194
+ }
195
+ function escapeHtml(s) {
196
+ return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
197
+ }
198
+ function escapeAttr(s) { return escapeHtml(s); }
199
+ //# sourceMappingURL=address-book.js.map
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Calendar pane — Thunderbird Lightning "Today Pane" style.
3
+ *
4
+ * Phase-1 scope (this commit): a usable mini-month + upcoming-events list
5
+ * sourced from a local-only event store. Google Calendar sync is a separate
6
+ * step (oauthsupport scope is already wired; the dispatcher branch + provider
7
+ * are owed). UI lands first so the data layer can be slotted in without
8
+ * UX rework.
9
+ */
10
+ const LOCAL_STORE_KEY = "mailx-cal-events-v1";
11
+ let isOpen = false;
12
+ function loadEvents() {
13
+ try {
14
+ const raw = localStorage.getItem(LOCAL_STORE_KEY);
15
+ if (!raw)
16
+ return [];
17
+ const arr = JSON.parse(raw);
18
+ return Array.isArray(arr) ? arr : [];
19
+ }
20
+ catch {
21
+ return [];
22
+ }
23
+ }
24
+ function saveEvents(events) {
25
+ try {
26
+ localStorage.setItem(LOCAL_STORE_KEY, JSON.stringify(events));
27
+ }
28
+ catch { /* */ }
29
+ }
30
+ export async function openCalendar() {
31
+ if (isOpen)
32
+ return;
33
+ isOpen = true;
34
+ const backdrop = document.createElement("div");
35
+ backdrop.className = "mailx-modal-backdrop";
36
+ const panel = document.createElement("div");
37
+ panel.className = "mailx-modal mailx-modal-wide";
38
+ panel.innerHTML = `
39
+ <div class="mailx-modal-title">
40
+ <span class="mailx-modal-title-text">Calendar</span>
41
+ <button type="button" class="mailx-modal-close" id="cal-close" title="Close (Esc)" aria-label="Close">&times;</button>
42
+ </div>
43
+ <div class="cal-grid">
44
+ <div class="cal-month" id="cal-month"></div>
45
+ <div class="cal-upcoming">
46
+ <div class="cal-section-title">Upcoming</div>
47
+ <div id="cal-events"></div>
48
+ <button type="button" class="mailx-modal-btn mailx-modal-btn-primary" id="cal-new" style="margin-top:12px;">+ New event</button>
49
+ </div>
50
+ </div>
51
+ <div class="mailx-modal-buttons">
52
+ <span class="mailx-modal-spacer"></span>
53
+ <span class="cal-source-note">Local-only · Google Calendar sync pending</span>
54
+ <button type="button" class="mailx-modal-btn" data-action="close">Close</button>
55
+ </div>`;
56
+ backdrop.appendChild(panel);
57
+ document.body.appendChild(backdrop);
58
+ const monthEl = panel.querySelector("#cal-month");
59
+ const eventsEl = panel.querySelector("#cal-events");
60
+ let viewYear = new Date().getFullYear();
61
+ let viewMonth = new Date().getMonth();
62
+ let selectedDay = new Date();
63
+ let events = loadEvents();
64
+ const renderMonth = () => {
65
+ const first = new Date(viewYear, viewMonth, 1);
66
+ const lastDay = new Date(viewYear, viewMonth + 1, 0).getDate();
67
+ const firstWeekday = first.getDay();
68
+ const monthName = first.toLocaleDateString(undefined, { month: "long", year: "numeric" });
69
+ const today = new Date();
70
+ const isToday = (y, m, d) => y === today.getFullYear() && m === today.getMonth() && d === today.getDate();
71
+ const eventsByDay = new Map();
72
+ for (const e of events) {
73
+ const d = new Date(e.start);
74
+ const k = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
75
+ const arr = eventsByDay.get(k) || [];
76
+ arr.push(e);
77
+ eventsByDay.set(k, arr);
78
+ }
79
+ let html = `
80
+ <div class="cal-nav">
81
+ <button type="button" class="cal-nav-btn" data-nav="prev">‹</button>
82
+ <span class="cal-nav-title">${monthName}</span>
83
+ <button type="button" class="cal-nav-btn" data-nav="today">Today</button>
84
+ <button type="button" class="cal-nav-btn" data-nav="next">›</button>
85
+ </div>
86
+ <div class="cal-month-grid">
87
+ ${["S", "M", "T", "W", "T", "F", "S"].map(d => `<span class="cal-dow">${d}</span>`).join("")}`;
88
+ for (let i = 0; i < firstWeekday; i++)
89
+ html += `<span class="cal-day cal-day-blank"></span>`;
90
+ for (let d = 1; d <= lastDay; d++) {
91
+ const k = `${viewYear}-${viewMonth}-${d}`;
92
+ const hasEv = eventsByDay.has(k);
93
+ const sel = selectedDay.getFullYear() === viewYear && selectedDay.getMonth() === viewMonth && selectedDay.getDate() === d;
94
+ const cls = ["cal-day"];
95
+ if (isToday(viewYear, viewMonth, d))
96
+ cls.push("cal-day-today");
97
+ if (sel)
98
+ cls.push("cal-day-selected");
99
+ if (hasEv)
100
+ cls.push("cal-day-has-event");
101
+ html += `<button type="button" class="${cls.join(" ")}" data-day="${d}">${d}</button>`;
102
+ }
103
+ html += `</div>`;
104
+ monthEl.innerHTML = html;
105
+ monthEl.querySelectorAll("[data-day]").forEach(b => {
106
+ b.addEventListener("click", () => {
107
+ selectedDay = new Date(viewYear, viewMonth, +b.dataset.day);
108
+ renderMonth();
109
+ renderEvents();
110
+ });
111
+ });
112
+ monthEl.querySelectorAll(".cal-nav-btn").forEach(b => {
113
+ b.addEventListener("click", () => {
114
+ const nav = b.dataset.nav;
115
+ if (nav === "prev") {
116
+ if (--viewMonth < 0) {
117
+ viewMonth = 11;
118
+ viewYear--;
119
+ }
120
+ }
121
+ else if (nav === "next") {
122
+ if (++viewMonth > 11) {
123
+ viewMonth = 0;
124
+ viewYear++;
125
+ }
126
+ }
127
+ else if (nav === "today") {
128
+ const t = new Date();
129
+ viewYear = t.getFullYear();
130
+ viewMonth = t.getMonth();
131
+ selectedDay = t;
132
+ }
133
+ renderMonth();
134
+ renderEvents();
135
+ });
136
+ });
137
+ };
138
+ const renderEvents = () => {
139
+ const dayStart = new Date(selectedDay.getFullYear(), selectedDay.getMonth(), selectedDay.getDate()).getTime();
140
+ const horizon = dayStart + 30 * 86400_000;
141
+ const upcoming = events
142
+ .filter(e => e.start >= dayStart && e.start < horizon)
143
+ .sort((a, b) => a.start - b.start);
144
+ if (upcoming.length === 0) {
145
+ eventsEl.innerHTML = `<div class="cal-empty">No events in the next 30 days from ${selectedDay.toLocaleDateString()}.</div>`;
146
+ return;
147
+ }
148
+ const fmt = (e) => {
149
+ const d = new Date(e.start);
150
+ const date = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
151
+ const time = e.allDay ? "all day" : d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
152
+ return `${date} · ${time}`;
153
+ };
154
+ eventsEl.innerHTML = upcoming.map(e => `
155
+ <div class="cal-event" data-id="${e.id}">
156
+ <div class="cal-event-time">${fmt(e)}</div>
157
+ <div class="cal-event-title">${escapeHtml(e.title)}</div>
158
+ ${e.location ? `<div class="cal-event-loc">${escapeHtml(e.location)}</div>` : ""}
159
+ <button type="button" class="cal-event-del" title="Delete">×</button>
160
+ </div>`).join("");
161
+ eventsEl.querySelectorAll(".cal-event").forEach(row => {
162
+ row.querySelector(".cal-event-del")?.addEventListener("click", () => {
163
+ const id = row.dataset.id;
164
+ events = events.filter(e => e.id !== id);
165
+ saveEvents(events);
166
+ renderEvents();
167
+ renderMonth();
168
+ });
169
+ });
170
+ };
171
+ panel.querySelector("#cal-new")?.addEventListener("click", () => {
172
+ const title = prompt("Event title:");
173
+ if (!title)
174
+ return;
175
+ const dateStr = prompt("Date and time (YYYY-MM-DD HH:MM, or YYYY-MM-DD for all-day):", selectedDay.toISOString().slice(0, 10) + " 09:00");
176
+ if (!dateStr)
177
+ return;
178
+ const allDay = !/\d{1,2}:\d{2}/.test(dateStr);
179
+ const start = Date.parse(dateStr.replace(" ", "T"));
180
+ if (isNaN(start)) {
181
+ alert("Couldn't parse that date.");
182
+ return;
183
+ }
184
+ const ev = {
185
+ id: `cal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
186
+ title, start, end: start + (allDay ? 86400_000 : 3600_000),
187
+ allDay, source: "local",
188
+ };
189
+ events.push(ev);
190
+ saveEvents(events);
191
+ renderMonth();
192
+ renderEvents();
193
+ });
194
+ const close = () => {
195
+ backdrop.remove();
196
+ document.removeEventListener("keydown", onKey, true);
197
+ isOpen = false;
198
+ };
199
+ const onKey = (e) => {
200
+ if (e.key === "Escape") {
201
+ e.stopPropagation();
202
+ e.preventDefault();
203
+ close();
204
+ }
205
+ };
206
+ document.addEventListener("keydown", onKey, true);
207
+ panel.querySelector("#cal-close").addEventListener("click", close);
208
+ panel.querySelector('[data-action="close"]').addEventListener("click", close);
209
+ backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
210
+ close(); });
211
+ renderMonth();
212
+ renderEvents();
213
+ }
214
+ function escapeHtml(s) {
215
+ return s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[c]));
216
+ }
217
+ //# sourceMappingURL=calendar.js.map
@@ -2,7 +2,7 @@
2
2
  * Folder tree component -- renders account folders with hierarchy,
3
3
  * expand/collapse, and optional unified inbox.
4
4
  */
5
- import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder, setupAccount, getDeviceAccounts, getVersion } from "../lib/api-client.js";
5
+ import { getAccounts, getFolders, moveMessage, moveMessages, markFolderRead, createFolder, renameFolder, deleteFolder, emptyFolder, setupAccount, getDeviceAccounts, getVersion, syncAccount } from "../lib/api-client.js";
6
6
  import { showContextMenu } from "./context-menu.js";
7
7
  let onFolderSelect;
8
8
  let onUnifiedInbox = null;
@@ -292,6 +292,20 @@ function renderNode(node, container, depth) {
292
292
  alert(`Failed: ${err.message}`);
293
293
  }
294
294
  }, disabled: !!node.specialUse },
295
+ { label: "", action: () => { }, separator: true },
296
+ // Q57: copy IMAP path so user can paste into accounts.jsonc as
297
+ // a spam/sent/drafts/trash hint without retyping case-sensitively.
298
+ { label: "Copy folder path", action: async () => {
299
+ try {
300
+ await navigator.clipboard.writeText(node.path);
301
+ const status = document.getElementById("status-sync");
302
+ if (status)
303
+ status.textContent = `Copied: ${node.path}`;
304
+ }
305
+ catch {
306
+ prompt("Folder path:", node.path);
307
+ }
308
+ } },
295
309
  ];
296
310
  if (isTrash || isJunk) {
297
311
  items.push({ label: "", action: () => { }, separator: true });
@@ -701,23 +715,55 @@ async function loadFolderTree(container) {
701
715
  if (treeContainer)
702
716
  loadFolderTree(treeContainer);
703
717
  });
704
- // Collapse all on right-click
718
+ // Right-click: full menu instead of plain toggle (Q54).
705
719
  header.addEventListener("contextmenu", (e) => {
706
720
  e.preventDefault();
707
- // Toggle all folders for this account
708
- const allExpanded = Object.entries(expandState)
709
- .filter(([k]) => k.startsWith(`${account.id}:`))
710
- .every(([, v]) => v);
711
- const allFolderKeys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
712
- // If some expanded, collapse all. If all collapsed, expand all.
713
- const newState = !allExpanded;
714
- for (const key of allFolderKeys)
715
- expandState[key] = newState;
716
- expandState[accountKey] = newState;
717
- saveExpandState();
718
- const treeContainer = document.getElementById("folder-tree");
719
- if (treeContainer)
720
- loadFolderTree(treeContainer);
721
+ e.stopPropagation();
722
+ const items = [
723
+ { label: "Mark all read (account)", action: async () => {
724
+ const folderRows = folders.slice();
725
+ for (const f of folderRows) {
726
+ try {
727
+ await markFolderRead(account.id, f.id);
728
+ }
729
+ catch { /* keep going */ }
730
+ }
731
+ const tc = document.getElementById("folder-tree");
732
+ if (tc)
733
+ loadFolderTree(tc);
734
+ } },
735
+ { label: "", action: () => { }, separator: true },
736
+ { label: "Expand all folders", action: () => {
737
+ const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
738
+ for (const k of keys)
739
+ expandState[k] = true;
740
+ expandState[accountKey] = true;
741
+ saveExpandState();
742
+ const tc = document.getElementById("folder-tree");
743
+ if (tc)
744
+ loadFolderTree(tc);
745
+ } },
746
+ { label: "Collapse all folders", action: () => {
747
+ const keys = Object.keys(expandState).filter(k => k.startsWith(`${account.id}:`));
748
+ for (const k of keys)
749
+ expandState[k] = false;
750
+ expandState[accountKey] = false;
751
+ saveExpandState();
752
+ const tc = document.getElementById("folder-tree");
753
+ if (tc)
754
+ loadFolderTree(tc);
755
+ } },
756
+ { label: "", action: () => { }, separator: true },
757
+ { label: "Sync this account now", action: async () => {
758
+ try {
759
+ await syncAccount(account.id);
760
+ }
761
+ catch (err) {
762
+ alert(`Sync failed: ${err?.message || err}`);
763
+ }
764
+ } },
765
+ ];
766
+ showContextMenu(e.clientX, e.clientY, items);
721
767
  });
722
768
  accountEl.appendChild(header);
723
769
  if (accountExpanded && folders.length > 0) {
@@ -551,6 +551,15 @@ function appendMessages(body, accountId, items) {
551
551
  state.select(msg);
552
552
  onMessageSelect(msgAccountId, msg.uid, msg.folderId);
553
553
  });
554
+ // Q64: double-click → pop out the message in a floating overlay so
555
+ // the user can read it without losing the selected list context.
556
+ row.addEventListener("dblclick", (e) => {
557
+ e.preventDefault();
558
+ e.stopPropagation();
559
+ document.dispatchEvent(new CustomEvent("mailx-popout-message", {
560
+ detail: { accountId: msgAccountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject }
561
+ }));
562
+ });
554
563
  row.addEventListener("dragstart", (e) => {
555
564
  if (!row.classList.contains("selected")) {
556
565
  clearSelection();
@@ -432,26 +432,44 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
432
432
  const detailsEl = document.getElementById("mv-details");
433
433
  const detailsBtn = document.getElementById("mv-toggle-details");
434
434
  if (detailsEl && detailsBtn) {
435
+ // Q56: every row gets a Copy button so paths / IDs can be pasted
436
+ // into accounts.jsonc hints or bug reports.
437
+ const row = (label, value) => `<div class="mv-details-row"><span class="mv-details-label">${label}:</span> <span class="mv-details-value">${escapeText(value)}</span> <button type="button" class="mv-details-copy" data-copy="${escapeText(value).replace(/"/g, "&quot;")}" title="Copy">⧉</button></div>`;
435
438
  const lines = [];
436
439
  if (msg.deliveredTo)
437
- lines.push(`<span class="mv-details-label">Delivered-To:</span> ${escapeText(msg.deliveredTo)}`);
440
+ lines.push(row("Delivered-To", msg.deliveredTo));
438
441
  if (msg.returnPath)
439
- lines.push(`<span class="mv-details-label">Return-Path:</span> ${escapeText(msg.returnPath)}`);
442
+ lines.push(row("Return-Path", msg.returnPath));
440
443
  if (msg.messageId)
441
- lines.push(`<span class="mv-details-label">Message-ID:</span> ${escapeText(msg.messageId)}`);
444
+ lines.push(row("Message-ID", msg.messageId));
442
445
  if (msg.listUnsubscribe)
443
- lines.push(`<span class="mv-details-label">Unsubscribe:</span> ${escapeText(msg.listUnsubscribe)}`);
446
+ lines.push(row("Unsubscribe", msg.listUnsubscribe));
444
447
  if (msg.emlPath)
445
- lines.push(`<span class="mv-details-label">EML file:</span> ${escapeText(msg.emlPath)}`);
446
- lines.push(`<span class="mv-details-label">Account:</span> ${escapeText(accountId)}`);
447
- lines.push(`<span class="mv-details-label">UID:</span> ${msg.uid} (folder ${msg.folderId})`);
448
- detailsEl.innerHTML = lines.join("<br>");
448
+ lines.push(row("EML file", msg.emlPath));
449
+ lines.push(row("Account", accountId));
450
+ lines.push(row("UID", `${msg.uid} (folder ${msg.folderId})`));
451
+ detailsEl.innerHTML = lines.join("");
449
452
  detailsEl.hidden = true;
450
453
  detailsBtn.textContent = "Details";
451
454
  detailsBtn.onclick = () => {
452
455
  detailsEl.hidden = !detailsEl.hidden;
453
456
  detailsBtn.textContent = detailsEl.hidden ? "Details" : "\u2713 Details";
454
457
  };
458
+ // Wire copy buttons.
459
+ detailsEl.querySelectorAll(".mv-details-copy").forEach(btn => {
460
+ btn.addEventListener("click", async (e) => {
461
+ e.stopPropagation();
462
+ const val = btn.dataset.copy || "";
463
+ try {
464
+ await navigator.clipboard.writeText(val);
465
+ btn.textContent = "✓";
466
+ setTimeout(() => { btn.textContent = "⧉"; }, 1500);
467
+ }
468
+ catch {
469
+ prompt("Copy:", val);
470
+ }
471
+ });
472
+ });
455
473
  }
456
474
  // Remote content banner (collapsible dropdown with sender/recipient details)
457
475
  bodyEl.innerHTML = "";
@@ -795,6 +813,21 @@ ${csp}
795
813
  window.parent.postMessage({ type: "linkHover", url: "" }, "*");
796
814
  }
797
815
  });
816
+ // C29: right-click on a link → ask parent for the Open/Save/Copy menu.
817
+ document.addEventListener("contextmenu", function (e) {
818
+ var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
819
+ if (!a) return; // let parent's body-context handler take over
820
+ e.preventDefault();
821
+ e.stopPropagation();
822
+ var rect = (e.target.getBoundingClientRect && e.target.getBoundingClientRect()) || { left: 0, top: 0 };
823
+ window.parent.postMessage({
824
+ type: "linkContextMenu",
825
+ url: a.href,
826
+ text: (a.textContent || "").slice(0, 100),
827
+ x: e.clientX, y: e.clientY,
828
+ iframeLeft: rect.left, iframeTop: rect.top
829
+ }, "*");
830
+ });
798
831
  // Key forwarding — Delete, Ctrl+D, arrow keys, etc. need to reach app.ts
799
832
  // even when focus is inside the sandboxed iframe. Parent-side
800
833
  // contentDocument listeners (see installPreviewControls) work on