@bobfrankston/mailx 1.0.340 → 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,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">&times;</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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[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">&times;</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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[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 (init.bodyHtml) {
361
- editor.setHtml(init.bodyHtml);
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
- document.title = init.subject ? `${init.subject} - Compose` : "Compose - mailx";
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
- document.getElementById("btn-send")?.addEventListener("click", async () => {
529
- const btn = document.getElementById("btn-send");
530
- btn.disabled = true;
531
- btn.textContent = "Sending…";
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,51 +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
  };
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
+ }
543
597
  console.log(`[compose] Send clicked: from=${body.from} to=${JSON.stringify(body.to)} subject="${body.subject}" attachments=${body.attachments.length}`);
544
- // Live countdown so the user sees the IPC is alive. Hard timeout is 120s.
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.
545
605
  const sendStart = Date.now();
546
- const sendTick = setInterval(() => {
547
- const sec = Math.floor((Date.now() - sendStart) / 1000);
548
- if (sec < 2)
549
- btn.textContent = "Sending…";
550
- else if (sec < 10)
551
- btn.textContent = `Queueing… (${sec}s)`;
552
- else
553
- btn.textContent = `Still working… (${sec}s of 120s)`;
554
- }, 500);
555
- try {
556
- await sendMessage(body);
557
- clearInterval(sendTick);
606
+ sendMessage(body)
607
+ .then(() => {
558
608
  console.log(`[compose] Send IPC returned OK in ${Date.now() - sendStart}ms`);
559
- if (draftTimer) {
560
- clearInterval(draftTimer);
561
- draftTimer = null;
562
- }
563
609
  if (draftUid || draftId) {
564
610
  deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
565
611
  }
566
- closeCompose();
567
- }
568
- catch (e) {
569
- clearInterval(sendTick);
612
+ })
613
+ .catch((e) => {
570
614
  const msg = e?.message || String(e);
571
615
  console.error(`[compose] Send IPC failed after ${Date.now() - sendStart}ms: ${msg}`);
572
- btn.disabled = false;
573
- btn.textContent = "Send";
574
- if (msg.startsWith("mailxapi timeout")) {
575
- // Disk-first queueing means the .ltr file should be on disk even
576
- // if the IPC reply itself was lost. Tell the user where to look.
577
- alert(`Send IPC timed out after 120s.\n\n` +
578
- `If the disk-first queue was reached, the message is in:\n` +
579
- `~/.mailx/outbox/${getFromAccountId()}/*.ltr\n\n` +
580
- `Don't click Send again — it could double-send. Check that ` +
581
- `directory first (and the log) before deciding.\n\n` +
582
- `Your draft is preserved either way.`);
583
- }
584
- else {
585
- alert(`Send failed: ${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 }, "*");
586
620
  }
587
- }
621
+ catch { /* */ }
622
+ });
623
+ closeCompose();
588
624
  });
589
625
  // ── Close handling ──
590
626
  /** True if the compose has anything worth asking about. */
@@ -703,6 +739,10 @@ function setBccVisible(visible) {
703
739
  }
704
740
  toggleCcBtn?.addEventListener("click", () => setCcVisible(ccRow.hidden));
705
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.
706
746
  // ── Attachments ──
707
747
  const fileInput = document.getElementById("compose-file");
708
748
  const attEl = document.getElementById("compose-attachments");
@@ -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) {