@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.
- package/bin/mailx.js +87 -7
- package/client/app.js +413 -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 +9 -0
- 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 +87 -39
- package/client/compose/editor.js +67 -0
- package/client/index.html +8 -6
- package/client/lib/api-client.js +21 -0
- package/client/lib/mailxapi.js +15 -0
- package/client/styles/components.css +354 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +24 -0
- package/packages/mailx-imap/index.js +132 -6
- package/packages/mailx-service/index.d.ts +25 -0
- package/packages/mailx-service/index.js +142 -5
- package/packages/mailx-service/jsonrpc.js +20 -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,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbox view modal — lists .ltr files currently queued on disk.
|
|
3
|
+
* Pink rows = local-only, not yet reconciled with the server.
|
|
4
|
+
* Clicking the status-bar queue pill opens this.
|
|
5
|
+
*/
|
|
6
|
+
import { listQueuedOutgoing, cancelQueuedOutgoing } from "../lib/api-client.js";
|
|
7
|
+
let isOpen = false;
|
|
8
|
+
export async function openOutboxView() {
|
|
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">Outbox — Local Queue</span>
|
|
19
|
+
<button type="button" class="mailx-modal-close" id="ob-close" title="Close (Esc)" aria-label="Close">×</button>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="ob-info">Messages waiting to be sent. Pink rows are local-only until the server accepts them. Click Cancel to drop a queued message.</div>
|
|
22
|
+
<div class="ob-list" id="ob-list">Loading…</div>
|
|
23
|
+
<div class="mailx-modal-buttons">
|
|
24
|
+
<span class="mailx-modal-spacer"></span>
|
|
25
|
+
<button type="button" class="mailx-modal-btn" data-action="refresh">Refresh</button>
|
|
26
|
+
<button type="button" class="mailx-modal-btn" data-action="close">Close</button>
|
|
27
|
+
</div>`;
|
|
28
|
+
backdrop.appendChild(panel);
|
|
29
|
+
document.body.appendChild(backdrop);
|
|
30
|
+
const listEl = panel.querySelector("#ob-list");
|
|
31
|
+
const renderList = (items) => {
|
|
32
|
+
if (items.length === 0) {
|
|
33
|
+
listEl.innerHTML = `<div class="ob-empty">Outbox is clean — no messages queued.</div>`;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const fmtDate = (ms) => new Date(ms).toLocaleString();
|
|
37
|
+
listEl.innerHTML = items.map((m, i) => `
|
|
38
|
+
<div class="ob-row ob-pink" data-idx="${i}">
|
|
39
|
+
<div class="ob-row-hdr">
|
|
40
|
+
<span class="ob-acct">${escapeHtml(m.accountId)}</span>
|
|
41
|
+
<span class="ob-subject">${escapeHtml(m.subject || "(no subject)")}</span>
|
|
42
|
+
<span class="ob-created">${fmtDate(m.createdAt)}</span>
|
|
43
|
+
${m.claimed ? `<span class="ob-badge ob-claimed" title="Sending now on this host">sending…</span>` : ""}
|
|
44
|
+
${m.attempts > 0 ? `<span class="ob-badge ob-retry" title="Retry attempts made so far">retry ×${m.attempts}</span>` : ""}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="ob-row-meta">
|
|
47
|
+
<span class="ob-from">${escapeHtml(m.from || "")}</span>
|
|
48
|
+
→ <span class="ob-to">${escapeHtml(m.to || "")}</span>
|
|
49
|
+
${m.cc ? ` · Cc: ${escapeHtml(m.cc)}` : ""}
|
|
50
|
+
<span class="ob-size">· ${(m.sizeBytes / 1024).toFixed(1)}kB</span>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="ob-row-path">${escapeHtml(m.path)}</div>
|
|
53
|
+
<div class="ob-row-actions">
|
|
54
|
+
<button type="button" class="ob-cancel" ${m.claimed ? "disabled title='Cannot cancel while in flight'" : ""}>Cancel</button>
|
|
55
|
+
</div>
|
|
56
|
+
</div>`).join("");
|
|
57
|
+
listEl.querySelectorAll(".ob-row").forEach((row, idx) => {
|
|
58
|
+
row.querySelector(".ob-cancel").addEventListener("click", async () => {
|
|
59
|
+
const m = items[idx];
|
|
60
|
+
if (!confirm(`Drop this queued message?\n\nTo: ${m.to}\nSubject: ${m.subject}`))
|
|
61
|
+
return;
|
|
62
|
+
try {
|
|
63
|
+
await cancelQueuedOutgoing(m.path);
|
|
64
|
+
await reload();
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
alert(`Cancel failed: ${e?.message || e}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
const reload = async () => {
|
|
73
|
+
try {
|
|
74
|
+
const items = await listQueuedOutgoing();
|
|
75
|
+
renderList(items || []);
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
listEl.innerHTML = `<div class="ob-empty">Load failed: ${escapeHtml(e?.message || String(e))}</div>`;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const close = () => {
|
|
82
|
+
backdrop.remove();
|
|
83
|
+
document.removeEventListener("keydown", onKey, true);
|
|
84
|
+
isOpen = false;
|
|
85
|
+
};
|
|
86
|
+
const onKey = (e) => {
|
|
87
|
+
if (e.key === "Escape") {
|
|
88
|
+
e.stopPropagation();
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
close();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
document.addEventListener("keydown", onKey, true);
|
|
94
|
+
panel.querySelector("#ob-close").addEventListener("click", close);
|
|
95
|
+
panel.querySelector('[data-action="close"]').addEventListener("click", close);
|
|
96
|
+
panel.querySelector('[data-action="refresh"]').addEventListener("click", reload);
|
|
97
|
+
backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
|
|
98
|
+
close(); });
|
|
99
|
+
await reload();
|
|
100
|
+
}
|
|
101
|
+
function escapeHtml(s) {
|
|
102
|
+
return s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[c]));
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=outbox-view.js.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tasks pane — goal is "better than Thunderbird" on tasks.
|
|
3
|
+
*
|
|
4
|
+
* Phase-1 features (this commit):
|
|
5
|
+
* - Local-only task store with priority, due-date, snooze, tags, notes
|
|
6
|
+
* - Inline edit, drag-to-reorder by priority, mark complete with strikethrough
|
|
7
|
+
* - Filter by status (active/completed/all), tag, due-soon
|
|
8
|
+
* - "Snooze until" (defer-to-date) — Thunderbird Lightning has nothing here
|
|
9
|
+
* - Quick add from email subject (composeInit-style, hooked from message viewer
|
|
10
|
+
* right-click later)
|
|
11
|
+
*
|
|
12
|
+
* Phase-2 (later):
|
|
13
|
+
* - Google Tasks sync (oauthsupport scope ready)
|
|
14
|
+
* - CalDAV VTODO interop
|
|
15
|
+
* - Task-from-email contextmenu
|
|
16
|
+
* - Reminders that share the calendar/mail alarm subsystem
|
|
17
|
+
*/
|
|
18
|
+
const STORE_KEY = "mailx-tasks-v1";
|
|
19
|
+
let isOpen = false;
|
|
20
|
+
function loadTasks() {
|
|
21
|
+
try {
|
|
22
|
+
const raw = localStorage.getItem(STORE_KEY);
|
|
23
|
+
if (!raw)
|
|
24
|
+
return [];
|
|
25
|
+
const arr = JSON.parse(raw);
|
|
26
|
+
return Array.isArray(arr) ? arr : [];
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function saveTasks(tasks) {
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(STORE_KEY, JSON.stringify(tasks));
|
|
35
|
+
}
|
|
36
|
+
catch { /* */ }
|
|
37
|
+
}
|
|
38
|
+
export async function openTasks() {
|
|
39
|
+
if (isOpen)
|
|
40
|
+
return;
|
|
41
|
+
isOpen = true;
|
|
42
|
+
const backdrop = document.createElement("div");
|
|
43
|
+
backdrop.className = "mailx-modal-backdrop";
|
|
44
|
+
const panel = document.createElement("div");
|
|
45
|
+
panel.className = "mailx-modal mailx-modal-wide";
|
|
46
|
+
panel.innerHTML = `
|
|
47
|
+
<div class="mailx-modal-title">
|
|
48
|
+
<span class="mailx-modal-title-text">Tasks</span>
|
|
49
|
+
<button type="button" class="mailx-modal-close" id="tk-close" title="Close (Esc)" aria-label="Close">×</button>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="tk-toolbar">
|
|
52
|
+
<input type="text" id="tk-quickadd" class="mailx-modal-input" placeholder="Add task — Enter to commit, prefix !!=high !=med ?=low, @tag, due:YYYY-MM-DD" autocomplete="off">
|
|
53
|
+
</div>
|
|
54
|
+
<div class="tk-filters">
|
|
55
|
+
<button type="button" class="tk-filter-btn" data-filter="active">Active</button>
|
|
56
|
+
<button type="button" class="tk-filter-btn" data-filter="due-soon">Due soon</button>
|
|
57
|
+
<button type="button" class="tk-filter-btn" data-filter="completed">Completed</button>
|
|
58
|
+
<button type="button" class="tk-filter-btn" data-filter="all">All</button>
|
|
59
|
+
<span class="tk-spacer"></span>
|
|
60
|
+
<span id="tk-count" class="tk-count"></span>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="tk-list" id="tk-list"></div>
|
|
63
|
+
<div class="mailx-modal-buttons">
|
|
64
|
+
<span class="mailx-modal-spacer"></span>
|
|
65
|
+
<span class="cal-source-note">Local-only · Google Tasks sync pending</span>
|
|
66
|
+
<button type="button" class="mailx-modal-btn" data-action="close">Close</button>
|
|
67
|
+
</div>`;
|
|
68
|
+
backdrop.appendChild(panel);
|
|
69
|
+
document.body.appendChild(backdrop);
|
|
70
|
+
const quickAdd = panel.querySelector("#tk-quickadd");
|
|
71
|
+
const listEl = panel.querySelector("#tk-list");
|
|
72
|
+
const countEl = panel.querySelector("#tk-count");
|
|
73
|
+
let tasks = loadTasks();
|
|
74
|
+
let filter = "active";
|
|
75
|
+
const setFilter = (f) => {
|
|
76
|
+
filter = f;
|
|
77
|
+
panel.querySelectorAll(".tk-filter-btn").forEach(b => b.classList.toggle("tk-filter-active", b.dataset.filter === f));
|
|
78
|
+
render();
|
|
79
|
+
};
|
|
80
|
+
panel.querySelectorAll(".tk-filter-btn").forEach(b => b.addEventListener("click", () => setFilter(b.dataset.filter)));
|
|
81
|
+
setFilter("active");
|
|
82
|
+
const parseQuickAdd = (line) => {
|
|
83
|
+
const t = {
|
|
84
|
+
id: `tk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
85
|
+
title: "", priority: 0, createdAt: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
const tags = [];
|
|
88
|
+
const words = line.split(/\s+/);
|
|
89
|
+
const titleParts = [];
|
|
90
|
+
for (const w of words) {
|
|
91
|
+
if (w === "!!") {
|
|
92
|
+
t.priority = 3;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (w === "!") {
|
|
96
|
+
t.priority = 2;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (w === "?") {
|
|
100
|
+
t.priority = 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (w.startsWith("@") && w.length > 1) {
|
|
104
|
+
tags.push(w.slice(1));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const dueMatch = w.match(/^due:(\S+)$/i);
|
|
108
|
+
if (dueMatch) {
|
|
109
|
+
const parsed = Date.parse(dueMatch[1]);
|
|
110
|
+
if (!isNaN(parsed))
|
|
111
|
+
t.due = parsed;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
titleParts.push(w);
|
|
115
|
+
}
|
|
116
|
+
t.title = titleParts.join(" ").trim();
|
|
117
|
+
if (tags.length)
|
|
118
|
+
t.tags = tags;
|
|
119
|
+
return t;
|
|
120
|
+
};
|
|
121
|
+
quickAdd.addEventListener("keydown", (e) => {
|
|
122
|
+
if (e.key !== "Enter")
|
|
123
|
+
return;
|
|
124
|
+
const v = quickAdd.value.trim();
|
|
125
|
+
if (!v)
|
|
126
|
+
return;
|
|
127
|
+
const t = parseQuickAdd(v);
|
|
128
|
+
if (!t.title) {
|
|
129
|
+
alert("Task needs a title.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
tasks.push(t);
|
|
133
|
+
saveTasks(tasks);
|
|
134
|
+
quickAdd.value = "";
|
|
135
|
+
render();
|
|
136
|
+
});
|
|
137
|
+
const render = () => {
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
let visible = tasks.slice();
|
|
140
|
+
if (filter === "active")
|
|
141
|
+
visible = visible.filter(t => !t.completed && (!t.snoozeUntil || t.snoozeUntil <= now));
|
|
142
|
+
else if (filter === "completed")
|
|
143
|
+
visible = visible.filter(t => !!t.completed);
|
|
144
|
+
else if (filter === "due-soon")
|
|
145
|
+
visible = visible.filter(t => !t.completed && t.due && t.due <= now + 7 * 86400_000);
|
|
146
|
+
// Sort: priority desc, then due asc (none last), then created asc.
|
|
147
|
+
visible.sort((a, b) => {
|
|
148
|
+
if ((b.priority || 0) !== (a.priority || 0))
|
|
149
|
+
return (b.priority || 0) - (a.priority || 0);
|
|
150
|
+
const ad = a.due || Number.MAX_SAFE_INTEGER;
|
|
151
|
+
const bd = b.due || Number.MAX_SAFE_INTEGER;
|
|
152
|
+
if (ad !== bd)
|
|
153
|
+
return ad - bd;
|
|
154
|
+
return a.createdAt - b.createdAt;
|
|
155
|
+
});
|
|
156
|
+
countEl.textContent = `${visible.length} of ${tasks.length}`;
|
|
157
|
+
if (visible.length === 0) {
|
|
158
|
+
listEl.innerHTML = `<div class="tk-empty">Nothing matches the ${filter} filter.</div>`;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const dueLabel = (ms) => {
|
|
162
|
+
if (!ms)
|
|
163
|
+
return "";
|
|
164
|
+
const d = new Date(ms);
|
|
165
|
+
const today = new Date();
|
|
166
|
+
today.setHours(0, 0, 0, 0);
|
|
167
|
+
const dt = new Date(ms);
|
|
168
|
+
dt.setHours(0, 0, 0, 0);
|
|
169
|
+
const days = Math.round((dt.getTime() - today.getTime()) / 86400_000);
|
|
170
|
+
if (days < 0)
|
|
171
|
+
return `${-days}d overdue`;
|
|
172
|
+
if (days === 0)
|
|
173
|
+
return "today";
|
|
174
|
+
if (days === 1)
|
|
175
|
+
return "tomorrow";
|
|
176
|
+
if (days < 7)
|
|
177
|
+
return `${days}d`;
|
|
178
|
+
return d.toLocaleDateString();
|
|
179
|
+
};
|
|
180
|
+
const prioLabel = (p) => p === 3 ? "‼" : p === 2 ? "!" : p === 1 ? "·" : "";
|
|
181
|
+
listEl.innerHTML = visible.map(t => `
|
|
182
|
+
<div class="tk-row${t.completed ? " tk-done" : ""}" data-id="${t.id}">
|
|
183
|
+
<input type="checkbox" class="tk-check" ${t.completed ? "checked" : ""}>
|
|
184
|
+
<span class="tk-prio tk-prio-${t.priority}">${prioLabel(t.priority)}</span>
|
|
185
|
+
<span class="tk-title" contenteditable="plaintext-only">${escapeHtml(t.title)}</span>
|
|
186
|
+
${t.tags?.length ? `<span class="tk-tags">${t.tags.map(x => `#${escapeHtml(x)}`).join(" ")}</span>` : ""}
|
|
187
|
+
<span class="tk-due${t.due && t.due < now && !t.completed ? " tk-overdue" : ""}">${dueLabel(t.due)}</span>
|
|
188
|
+
<button type="button" class="tk-snooze" title="Snooze 1d / 1w (right-click)">⏰</button>
|
|
189
|
+
<button type="button" class="tk-del" title="Delete">×</button>
|
|
190
|
+
</div>`).join("");
|
|
191
|
+
listEl.querySelectorAll(".tk-row").forEach(row => {
|
|
192
|
+
const id = row.dataset.id;
|
|
193
|
+
const t = tasks.find(x => x.id === id);
|
|
194
|
+
row.querySelector(".tk-check").addEventListener("change", (e) => {
|
|
195
|
+
const ck = e.target;
|
|
196
|
+
t.completed = ck.checked ? Date.now() : undefined;
|
|
197
|
+
saveTasks(tasks);
|
|
198
|
+
render();
|
|
199
|
+
});
|
|
200
|
+
row.querySelector(".tk-del").addEventListener("click", () => {
|
|
201
|
+
if (!confirm(`Delete task "${t.title}"?`))
|
|
202
|
+
return;
|
|
203
|
+
tasks = tasks.filter(x => x.id !== id);
|
|
204
|
+
saveTasks(tasks);
|
|
205
|
+
render();
|
|
206
|
+
});
|
|
207
|
+
row.querySelector(".tk-snooze").addEventListener("click", () => {
|
|
208
|
+
t.snoozeUntil = Date.now() + 86400_000; // 1d
|
|
209
|
+
saveTasks(tasks);
|
|
210
|
+
render();
|
|
211
|
+
});
|
|
212
|
+
row.querySelector(".tk-snooze").addEventListener("contextmenu", (e) => {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
t.snoozeUntil = Date.now() + 7 * 86400_000; // 1w
|
|
215
|
+
saveTasks(tasks);
|
|
216
|
+
render();
|
|
217
|
+
});
|
|
218
|
+
const titleEl = row.querySelector(".tk-title");
|
|
219
|
+
titleEl.addEventListener("blur", () => {
|
|
220
|
+
const v = (titleEl.textContent || "").trim();
|
|
221
|
+
if (v && v !== t.title) {
|
|
222
|
+
t.title = v;
|
|
223
|
+
saveTasks(tasks);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
titleEl.addEventListener("keydown", (e) => {
|
|
227
|
+
if (e.key === "Enter") {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
titleEl.blur();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const close = () => {
|
|
235
|
+
backdrop.remove();
|
|
236
|
+
document.removeEventListener("keydown", onKey, true);
|
|
237
|
+
isOpen = false;
|
|
238
|
+
};
|
|
239
|
+
const onKey = (e) => {
|
|
240
|
+
if (e.key === "Escape" && document.activeElement?.tagName !== "INPUT") {
|
|
241
|
+
e.stopPropagation();
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
close();
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
document.addEventListener("keydown", onKey, true);
|
|
247
|
+
panel.querySelector("#tk-close").addEventListener("click", close);
|
|
248
|
+
panel.querySelector('[data-action="close"]').addEventListener("click", close);
|
|
249
|
+
backdrop.addEventListener("mousedown", (e) => { if (e.target === backdrop)
|
|
250
|
+
close(); });
|
|
251
|
+
quickAdd.focus();
|
|
252
|
+
}
|
|
253
|
+
function escapeHtml(s) {
|
|
254
|
+
return s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[c]));
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=tasks.js.map
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
<label for="compose-to">To</label>
|
|
22
22
|
<input type="text" id="compose-to" autocomplete="off">
|
|
23
23
|
<span class="compose-recipient-toggle">
|
|
24
|
-
<button type="button" class="compose-toggle-btn" id="btn-toggle-cc" title="Show/hide Cc">Cc</button>
|
|
25
|
-
<button type="button" class="compose-toggle-btn" id="btn-toggle-bcc" title="Show/hide Bcc">Bcc</button>
|
|
24
|
+
<button type="button" class="compose-toggle-btn" id="btn-toggle-cc" tabindex="-1" title="Show/hide Cc">Cc</button>
|
|
25
|
+
<button type="button" class="compose-toggle-btn" id="btn-toggle-bcc" tabindex="-1" title="Show/hide Bcc">Bcc</button>
|
|
26
26
|
</span>
|
|
27
27
|
</div>
|
|
28
28
|
<div class="compose-field" id="compose-cc-row" hidden>
|
|
@@ -357,15 +357,28 @@ function applyInit(init) {
|
|
|
357
357
|
if (ccBtn)
|
|
358
358
|
ccBtn.classList.add("active");
|
|
359
359
|
}
|
|
360
|
-
if
|
|
361
|
-
|
|
360
|
+
// C42: append the account's signature (if configured) BEFORE rendering
|
|
361
|
+
// the body. For new mode: just signature. For reply/forward: appended
|
|
362
|
+
// after the quoted block. Drafts are skipped — the signature is already
|
|
363
|
+
// baked into the saved body. Editing existing draft also skipped.
|
|
364
|
+
let bodyToRender = init.bodyHtml || "";
|
|
365
|
+
const acct = init.accounts.find(a => a.id === init.accountId);
|
|
366
|
+
const sig = acct?.signature || "";
|
|
367
|
+
if (sig && init.mode !== "draft" && !init.draftUid) {
|
|
368
|
+
const sigBlock = `<br><br>--<br>${sig}`;
|
|
369
|
+
bodyToRender = init.mode === "reply" || init.mode === "replyAll" || init.mode === "forward"
|
|
370
|
+
? `<br>${sigBlock}<br>${bodyToRender}` // sig above the quote
|
|
371
|
+
: `${bodyToRender}${sigBlock}`; // sig at the end for new
|
|
372
|
+
}
|
|
373
|
+
if (bodyToRender) {
|
|
374
|
+
editor.setHtml(bodyToRender);
|
|
362
375
|
editor.setCursor(0);
|
|
363
376
|
}
|
|
364
377
|
// If resuming a draft, track its UID for deletion after send
|
|
365
378
|
if (init.draftUid) {
|
|
366
379
|
draftUid = init.draftUid;
|
|
367
380
|
}
|
|
368
|
-
|
|
381
|
+
setComposeTitle(init.subject || "");
|
|
369
382
|
// Focus first empty field: To → Subject → body
|
|
370
383
|
if (!toInput.value)
|
|
371
384
|
toInput.focus();
|
|
@@ -374,6 +387,24 @@ function applyInit(init) {
|
|
|
374
387
|
else
|
|
375
388
|
editor.focus();
|
|
376
389
|
}
|
|
390
|
+
// Q68: dirty marker (•) in the window title until the next successful save.
|
|
391
|
+
let composeDirty = false;
|
|
392
|
+
function setComposeTitle(subject) {
|
|
393
|
+
const base = subject ? `${subject} - Compose` : "Compose - mailx";
|
|
394
|
+
document.title = composeDirty ? `• ${base}` : base;
|
|
395
|
+
}
|
|
396
|
+
function markComposeDirty() {
|
|
397
|
+
if (composeDirty)
|
|
398
|
+
return;
|
|
399
|
+
composeDirty = true;
|
|
400
|
+
setComposeTitle(subjectInput?.value || "");
|
|
401
|
+
}
|
|
402
|
+
function markComposeClean() {
|
|
403
|
+
if (!composeDirty)
|
|
404
|
+
return;
|
|
405
|
+
composeDirty = false;
|
|
406
|
+
setComposeTitle(subjectInput?.value || "");
|
|
407
|
+
}
|
|
377
408
|
// ── Compose state (declared before init so the async IIFE can reference them) ──
|
|
378
409
|
const DRAFT_INPUT_DEBOUNCE_MS = 1500; // save ~1.5s after the last keystroke
|
|
379
410
|
const DRAFT_INTERVAL_MS = 5000; // safety-net interval save
|
|
@@ -400,6 +431,8 @@ async function saveDraft() {
|
|
|
400
431
|
return; // no changes
|
|
401
432
|
if (!editor.getText().trim() && !subjectInput.value && !toInput.value)
|
|
402
433
|
return; // empty
|
|
434
|
+
// Expose to window for blur-handler.
|
|
435
|
+
window.__mailxSaveDraft = saveDraft;
|
|
403
436
|
lastDraftContent = content;
|
|
404
437
|
draftSaving = true;
|
|
405
438
|
try {
|
|
@@ -423,6 +456,7 @@ async function saveDraft() {
|
|
|
423
456
|
}
|
|
424
457
|
else
|
|
425
458
|
showDraftStatus(`Draft saved ${new Date().toLocaleTimeString()}`, false);
|
|
459
|
+
markComposeClean();
|
|
426
460
|
}
|
|
427
461
|
catch (e) {
|
|
428
462
|
// Surface the error — silent failures are how drafts get lost on IMAP hiccups.
|
|
@@ -439,6 +473,7 @@ async function saveDraft() {
|
|
|
439
473
|
}
|
|
440
474
|
/** Schedule a debounced save on user input — fires ~1.5s after the last keystroke. */
|
|
441
475
|
function scheduleDraftSave() {
|
|
476
|
+
markComposeDirty();
|
|
442
477
|
if (draftDebounceTimer)
|
|
443
478
|
clearTimeout(draftDebounceTimer);
|
|
444
479
|
draftDebounceTimer = setTimeout(() => { draftDebounceTimer = null; saveDraft(); }, DRAFT_INPUT_DEBOUNCE_MS);
|
|
@@ -525,10 +560,22 @@ function scheduleDraftSave() {
|
|
|
525
560
|
});
|
|
526
561
|
})();
|
|
527
562
|
// ── Send ──
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
563
|
+
// Q55: Ctrl+Enter (or Cmd+Enter on macOS) anywhere in compose triggers send.
|
|
564
|
+
document.addEventListener("keydown", (e) => {
|
|
565
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
|
566
|
+
e.preventDefault();
|
|
567
|
+
document.getElementById("btn-send")?.click();
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
// Q59: autosave when the window loses focus (in addition to debounce + interval).
|
|
571
|
+
window.addEventListener("blur", () => {
|
|
572
|
+
// Use the same saveDraft path as the 5s interval.
|
|
573
|
+
try {
|
|
574
|
+
window.__mailxSaveDraft?.();
|
|
575
|
+
}
|
|
576
|
+
catch { /* */ }
|
|
577
|
+
});
|
|
578
|
+
document.getElementById("btn-send")?.addEventListener("click", () => {
|
|
532
579
|
const body = {
|
|
533
580
|
from: getFromAccountId(),
|
|
534
581
|
fromAddress: getFromAddress(),
|
|
@@ -540,43 +587,40 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
|
|
|
540
587
|
bodyText: editor.getText(),
|
|
541
588
|
attachments: attachments.map(a => ({ filename: a.filename, mimeType: a.mimeType, dataBase64: a.dataBase64 })),
|
|
542
589
|
};
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
590
|
+
// Local validity (one missing-To check) — must run before close so the
|
|
591
|
+
// user gets an inline error instead of silent loss. Anything else (real
|
|
592
|
+
// address validation, MIME assembly, disk write) happens server-side.
|
|
593
|
+
if (!body.to.length) {
|
|
594
|
+
alert("Please add at least one To recipient.");
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
|
|
598
|
+
// Stop autosave so it can't write a fresh draft after we close.
|
|
599
|
+
if (draftTimer) {
|
|
600
|
+
clearInterval(draftTimer);
|
|
601
|
+
draftTimer = null;
|
|
602
|
+
}
|
|
603
|
+
// Fire-and-forget. The disk-write is the durable commit; queue depth +
|
|
604
|
+
// retry status are visible in the parent status bar. No modal dialogs.
|
|
605
|
+
const sendStart = Date.now();
|
|
606
|
+
sendMessage(body)
|
|
607
|
+
.then(() => {
|
|
608
|
+
console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
|
|
552
609
|
if (draftUid || draftId) {
|
|
553
610
|
deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
|
|
554
611
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
catch (e) {
|
|
558
|
-
btn.disabled = false;
|
|
559
|
-
btn.textContent = "Send";
|
|
612
|
+
})
|
|
613
|
+
.catch((e) => {
|
|
560
614
|
const msg = e?.message || String(e);
|
|
561
|
-
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
// attempts). Treating that as a failure that demands a re-click leads
|
|
567
|
-
// to duplicate sends. Tell the user honestly: "probably queued, check
|
|
568
|
-
// Outbox before retrying."
|
|
569
|
-
if (msg.startsWith("mailxapi timeout")) {
|
|
570
|
-
alert("Send is taking longer than expected.\n\n" +
|
|
571
|
-
"The message has likely been queued and will be retried in the background. " +
|
|
572
|
-
"Check the Outbox folder before clicking Send again — clicking Send now may " +
|
|
573
|
-
"produce a duplicate.\n\n" +
|
|
574
|
-
"Your draft is preserved either way.");
|
|
575
|
-
}
|
|
576
|
-
else {
|
|
577
|
-
alert(`Send failed: ${msg}`);
|
|
615
|
+
console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
|
|
616
|
+
// Forward to parent so it surfaces in the status bar / banner — no
|
|
617
|
+
// alert here because the compose window is already closed.
|
|
618
|
+
try {
|
|
619
|
+
parent.postMessage({ type: "mailx-send-error", message: msg, accountId: body.from }, "*");
|
|
578
620
|
}
|
|
579
|
-
|
|
621
|
+
catch { /* */ }
|
|
622
|
+
});
|
|
623
|
+
closeCompose();
|
|
580
624
|
});
|
|
581
625
|
// ── Close handling ──
|
|
582
626
|
/** True if the compose has anything worth asking about. */
|
|
@@ -695,6 +739,10 @@ function setBccVisible(visible) {
|
|
|
695
739
|
}
|
|
696
740
|
toggleCcBtn?.addEventListener("click", () => setCcVisible(ccRow.hidden));
|
|
697
741
|
toggleBccBtn?.addEventListener("click", () => setBccVisible(bccRow.hidden));
|
|
742
|
+
// Q49 deferred: should be derived from the address book / sent-history DB,
|
|
743
|
+
// not a parallel localStorage store. Pending: extend contacts schema or
|
|
744
|
+
// query messages table on To-input change (debounced); auto-expand Cc/Bcc
|
|
745
|
+
// when this recipient's history shows ≥N past uses.
|
|
698
746
|
// ── Attachments ──
|
|
699
747
|
const fileInput = document.getElementById("compose-file");
|
|
700
748
|
const attEl = document.getElementById("compose-attachments");
|
package/client/compose/editor.js
CHANGED
|
@@ -239,10 +239,77 @@ function createQuillEditor(container) {
|
|
|
239
239
|
// - text/plain that's a URL: insert as a link, optionally wrapping any
|
|
240
240
|
// currently-selected text.
|
|
241
241
|
// - Anything else: default Quill behavior (verbatim plain or HTML).
|
|
242
|
+
// C36: AI proofread on right-click → "Proofread selection" item.
|
|
243
|
+
// Uses the existing aiTransform IPC. Gated by autocomplete.proofreadEnabled.
|
|
244
|
+
q.root.addEventListener("contextmenu", async (e) => {
|
|
245
|
+
try {
|
|
246
|
+
const sel = q.getSelection();
|
|
247
|
+
if (!sel || sel.length === 0)
|
|
248
|
+
return; // no selection — let native menu handle
|
|
249
|
+
const settingsRaw = localStorage.getItem("mailx-ai-proofread-enabled");
|
|
250
|
+
if (settingsRaw !== "true")
|
|
251
|
+
return; // feature off
|
|
252
|
+
const text = q.getText(sel.index, sel.length);
|
|
253
|
+
if (!text.trim())
|
|
254
|
+
return;
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
// Inline action menu next to the cursor
|
|
257
|
+
const menu = document.createElement("div");
|
|
258
|
+
menu.style.cssText = `position:fixed;z-index:2500;background:var(--color-bg);color:var(--color-text);border:1px solid var(--color-border);border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.2);padding:4px 0;font-size:13px;min-width:160px;`;
|
|
259
|
+
menu.style.left = `${e.clientX}px`;
|
|
260
|
+
menu.style.top = `${e.clientY}px`;
|
|
261
|
+
const item = document.createElement("div");
|
|
262
|
+
item.textContent = "Proofread selection";
|
|
263
|
+
item.style.cssText = `padding:6px 12px;cursor:pointer;`;
|
|
264
|
+
item.addEventListener("mouseenter", () => item.style.background = "var(--color-bg-hover)");
|
|
265
|
+
item.addEventListener("mouseleave", () => item.style.background = "");
|
|
266
|
+
item.addEventListener("click", async () => {
|
|
267
|
+
menu.remove();
|
|
268
|
+
try {
|
|
269
|
+
const { aiTransform } = await import("../lib/api-client.js");
|
|
270
|
+
const r = await aiTransform({ action: "proofread", text });
|
|
271
|
+
if (r?.text && r.text !== text) {
|
|
272
|
+
q.deleteText(sel.index, sel.length);
|
|
273
|
+
q.insertText(sel.index, r.text);
|
|
274
|
+
q.setSelection(sel.index, r.text.length);
|
|
275
|
+
}
|
|
276
|
+
else if (r?.reason) {
|
|
277
|
+
alert(`Proofread: ${r.reason}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
alert(`Proofread failed: ${err?.message || err}`);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
menu.appendChild(item);
|
|
285
|
+
document.body.appendChild(menu);
|
|
286
|
+
const dismiss = () => { menu.remove(); document.removeEventListener("mousedown", dismiss); };
|
|
287
|
+
setTimeout(() => document.addEventListener("mousedown", dismiss), 0);
|
|
288
|
+
}
|
|
289
|
+
catch { /* fall through to native menu */ }
|
|
290
|
+
});
|
|
242
291
|
q.root.addEventListener("paste", (e) => {
|
|
243
292
|
const cb = e.clipboardData;
|
|
244
293
|
if (!cb)
|
|
245
294
|
return;
|
|
295
|
+
// Q3: image-on-clipboard → inline as data: URL.
|
|
296
|
+
for (const item of Array.from(cb.items)) {
|
|
297
|
+
if (item.kind === "file" && item.type.startsWith("image/")) {
|
|
298
|
+
const file = item.getAsFile();
|
|
299
|
+
if (!file)
|
|
300
|
+
continue;
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
const reader = new FileReader();
|
|
303
|
+
reader.onload = () => {
|
|
304
|
+
const dataUrl = String(reader.result || "");
|
|
305
|
+
const range = q.getSelection(true) || { index: q.getLength(), length: 0 };
|
|
306
|
+
q.insertEmbed(range.index, "image", dataUrl);
|
|
307
|
+
q.setSelection(range.index + 1, 0);
|
|
308
|
+
};
|
|
309
|
+
reader.readAsDataURL(file);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
246
313
|
const html = cb.getData("text/html");
|
|
247
314
|
const plain = cb.getData("text/plain");
|
|
248
315
|
if (html) {
|