@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,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,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
- try {
544
- await sendMessage(body);
545
- // Delete draft after successful send stop auto-save first so it can't
546
- // append a fresh copy after we delete. Use the stored draftId as a fallback
547
- // so any orphaned drafts with the same stable ID are cleaned up too.
548
- if (draftTimer) {
549
- clearInterval(draftTimer);
550
- draftTimer = null;
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
- closeCompose();
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
- // Distinguish the IPC-timeout case from real send failures. The
562
- // service-side send() queues the message to the local DB synchronously
563
- // before attempting any IMAP/SMTP work so if the IPC reached Node at
564
- // all, the message is queued and the background worker will retry it
565
- // with backoff (X-Mailx-Retry header, 60s settling delay, up to 10
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");
@@ -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) {