@bobfrankston/mailx 1.0.340 → 1.0.349
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 +37 -7
- package/client/app.js +359 -32
- package/client/components/address-book.js +199 -0
- package/client/components/calendar.js +217 -0
- package/client/components/folder-tree.js +62 -16
- package/client/components/message-list.js +16 -2
- package/client/components/message-viewer.js +41 -8
- package/client/components/outbox-view.js +104 -0
- package/client/components/tasks.js +256 -0
- package/client/compose/compose.html +2 -2
- package/client/compose/compose.js +83 -43
- package/client/compose/editor.js +67 -0
- package/client/index.html +8 -6
- package/client/lib/api-client.js +18 -0
- package/client/lib/mailxapi.js +14 -0
- package/client/styles/components.css +354 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.js +19 -2
- package/packages/mailx-service/index.d.ts +23 -0
- package/packages/mailx-service/index.js +123 -14
- package/packages/mailx-service/jsonrpc.js +18 -1
- package/packages/mailx-settings/index.js +18 -3
- package/packages/mailx-store/db.d.ts +17 -0
- package/packages/mailx-store/db.js +122 -4
- package/packages/mailx-types/index.d.ts +1 -0
|
@@ -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">×</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 => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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">×</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 => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[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
|
-
//
|
|
718
|
+
// Right-click: full menu instead of plain toggle (Q54).
|
|
705
719
|
header.addEventListener("contextmenu", (e) => {
|
|
706
720
|
e.preventDefault();
|
|
707
|
-
|
|
708
|
-
const
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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) {
|
|
@@ -266,8 +266,13 @@ export async function loadSearchResults(query, scope = "all", accountId = "", fo
|
|
|
266
266
|
export async function loadMessages(accountId, folderId, page = 1, specialUse = "", autoSelect = true) {
|
|
267
267
|
searchMode = false;
|
|
268
268
|
unifiedMode = false;
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
// specialUse is either the DB tag ("sent"/"drafts"/"outbox") or the
|
|
270
|
+
// folder path lowercased (folder-tree fallback when tag is missing — common
|
|
271
|
+
// on Dovecot which doesn't advertise \Sent). Match both cases.
|
|
272
|
+
const su = (specialUse || "").toLowerCase();
|
|
273
|
+
showToInsteadOfFrom = su === "sent" || su === "drafts" || su === "outbox"
|
|
274
|
+
|| su.endsWith("sent") || su.endsWith("drafts") || su.endsWith("outbox")
|
|
275
|
+
|| su === "sent items" || su === "sent mail" || su.endsWith("/sent items") || su.endsWith(".sent items");
|
|
271
276
|
currentAccountId = accountId;
|
|
272
277
|
currentFolderId = folderId;
|
|
273
278
|
currentPage = 1;
|
|
@@ -551,6 +556,15 @@ function appendMessages(body, accountId, items) {
|
|
|
551
556
|
state.select(msg);
|
|
552
557
|
onMessageSelect(msgAccountId, msg.uid, msg.folderId);
|
|
553
558
|
});
|
|
559
|
+
// Q64: double-click → pop out the message in a floating overlay so
|
|
560
|
+
// the user can read it without losing the selected list context.
|
|
561
|
+
row.addEventListener("dblclick", (e) => {
|
|
562
|
+
e.preventDefault();
|
|
563
|
+
e.stopPropagation();
|
|
564
|
+
document.dispatchEvent(new CustomEvent("mailx-popout-message", {
|
|
565
|
+
detail: { accountId: msgAccountId, uid: msg.uid, folderId: msg.folderId, subject: msg.subject }
|
|
566
|
+
}));
|
|
567
|
+
});
|
|
554
568
|
row.addEventListener("dragstart", (e) => {
|
|
555
569
|
if (!row.classList.contains("selected")) {
|
|
556
570
|
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, """)}" title="Copy">⧉</button></div>`;
|
|
435
438
|
const lines = [];
|
|
436
439
|
if (msg.deliveredTo)
|
|
437
|
-
lines.push(
|
|
440
|
+
lines.push(row("Delivered-To", msg.deliveredTo));
|
|
438
441
|
if (msg.returnPath)
|
|
439
|
-
lines.push(
|
|
442
|
+
lines.push(row("Return-Path", msg.returnPath));
|
|
440
443
|
if (msg.messageId)
|
|
441
|
-
lines.push(
|
|
444
|
+
lines.push(row("Message-ID", msg.messageId));
|
|
442
445
|
if (msg.listUnsubscribe)
|
|
443
|
-
lines.push(
|
|
446
|
+
lines.push(row("Unsubscribe", msg.listUnsubscribe));
|
|
444
447
|
if (msg.emlPath)
|
|
445
|
-
lines.push(
|
|
446
|
-
lines.push(
|
|
447
|
-
lines.push(
|
|
448
|
-
detailsEl.innerHTML = lines.join("
|
|
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
|