@bobfrankston/mailx 1.0.216 → 1.0.218

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.
@@ -335,6 +335,15 @@ function appendMessages(body, accountId, items) {
335
335
  tag.title = msgAccountId;
336
336
  from.prepend(tag);
337
337
  }
338
+ // Search/cross-folder results carry folderName — show a tag so the user
339
+ // can tell which folder each hit lives in.
340
+ if (msg.folderName) {
341
+ const folderTag = document.createElement("span");
342
+ folderTag.className = "ml-folder-tag";
343
+ folderTag.textContent = msg.folderName;
344
+ folderTag.title = `In folder: ${msg.folderName}`;
345
+ from.prepend(folderTag);
346
+ }
338
347
  const subject = document.createElement("span");
339
348
  subject.className = "ml-subject";
340
349
  subject.innerHTML = escapeHtml(msg.subject);
@@ -75,6 +75,22 @@ body {
75
75
  background: var(--color-bg-hover);
76
76
  }
77
77
 
78
+ .compose-status {
79
+ margin-left: auto;
80
+ align-self: center;
81
+ font-size: var(--font-size-xs);
82
+ color: var(--color-text-muted);
83
+ padding: 0 var(--gap-md);
84
+ white-space: nowrap;
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
87
+ max-width: 20em;
88
+ }
89
+ .compose-status.compose-status-error {
90
+ color: oklch(0.65 0.2 25);
91
+ font-weight: 500;
92
+ }
93
+
78
94
  #btn-send {
79
95
  background: var(--color-accent);
80
96
  color: white;
@@ -38,6 +38,7 @@
38
38
  <button class="tb-btn" id="btn-send">Send</button>
39
39
  <button class="tb-btn" id="btn-attach">Attach</button>
40
40
  <button class="tb-btn" id="btn-discard">Discard</button>
41
+ <span id="compose-status" class="compose-status"></span>
41
42
  </div>
42
43
  <div id="compose-editor"></div>
43
44
  </body>
@@ -292,30 +292,23 @@ function applyInit(init) {
292
292
  else
293
293
  editor.focus();
294
294
  }
295
- // ── Load init data from sessionStorage, then ensure From is populated ──
296
- const stored = sessionStorage.getItem("composeInit");
297
- if (stored) {
298
- sessionStorage.removeItem("composeInit");
299
- applyInit(JSON.parse(stored));
300
- }
301
- else {
302
- // New compose — focus To
303
- toInput.focus();
304
- }
305
- // If From dropdown is empty (new compose without init, or init had no accounts), fetch from API
306
- if (fromSelect.options.length === 0) {
307
- getAccounts()
308
- .then((accounts) => {
309
- populateFromSelect(accounts);
310
- })
311
- .catch((e) => console.error("Failed to load accounts:", e));
312
- }
313
- // ── Auto-save drafts every 5 seconds ──
295
+ // ── Compose state (declared before init so the async IIFE can reference them) ──
296
+ const DRAFT_INPUT_DEBOUNCE_MS = 1500; // save ~1.5s after the last keystroke
297
+ const DRAFT_INTERVAL_MS = 5000; // safety-net interval save
314
298
  let draftUid = null;
315
299
  let draftId = null; // stable ID for dedup when APPENDUID unavailable
316
- let draftTimer;
300
+ let draftTimer = null;
301
+ let draftDebounceTimer = null;
317
302
  let lastDraftContent = "";
318
303
  let draftSaving = false; // prevent concurrent saves
304
+ let draftSaveFailed = false; // surfaced in the compose status tag
305
+ function showDraftStatus(text, isError) {
306
+ const status = document.getElementById("compose-status");
307
+ if (!status)
308
+ return;
309
+ status.textContent = text;
310
+ status.classList.toggle("compose-status-error", isError);
311
+ }
319
312
  async function saveDraft() {
320
313
  if (draftSaving)
321
314
  return; // previous save still in flight
@@ -341,13 +334,76 @@ async function saveDraft() {
341
334
  draftUid = data.draftUid;
342
335
  if (data?.draftId)
343
336
  draftId = data.draftId;
337
+ if (draftSaveFailed) {
338
+ draftSaveFailed = false;
339
+ showDraftStatus("Draft saved", false);
340
+ }
341
+ else
342
+ showDraftStatus(`Draft saved ${new Date().toLocaleTimeString()}`, false);
343
+ }
344
+ catch (e) {
345
+ // Surface the error — silent failures are how drafts get lost on IMAP hiccups.
346
+ // The local editing/ checkpoint already exists server-side regardless.
347
+ console.error("[draft] save failed:", e);
348
+ draftSaveFailed = true;
349
+ showDraftStatus(`Draft save failed: ${e?.message || e}`, true);
350
+ // Clear lastDraftContent so the next tick retries the same content
351
+ lastDraftContent = "";
344
352
  }
345
- catch { /* ignore draft save errors */ }
346
353
  finally {
347
354
  draftSaving = false;
348
355
  }
349
356
  }
350
- draftTimer = setInterval(saveDraft, 5000);
357
+ /** Schedule a debounced save on user input — fires ~1.5s after the last keystroke. */
358
+ function scheduleDraftSave() {
359
+ if (draftDebounceTimer)
360
+ clearTimeout(draftDebounceTimer);
361
+ draftDebounceTimer = setTimeout(() => { draftDebounceTimer = null; saveDraft(); }, DRAFT_INPUT_DEBOUNCE_MS);
362
+ }
363
+ // ── Initialize: always fetch real accounts from the API before applying init, then
364
+ // start the auto-save timer. Callers like message-viewer's Edit Draft pass
365
+ // init.accounts=[], so we can't trust what's in the init blob. ──
366
+ (async () => {
367
+ let accounts = [];
368
+ try {
369
+ accounts = await getAccounts();
370
+ }
371
+ catch (e) {
372
+ console.error("Failed to load accounts:", e);
373
+ }
374
+ const stored = sessionStorage.getItem("composeInit");
375
+ if (stored) {
376
+ sessionStorage.removeItem("composeInit");
377
+ const init = JSON.parse(stored);
378
+ if (!init.accounts || init.accounts.length === 0)
379
+ init.accounts = accounts;
380
+ applyInit(init);
381
+ }
382
+ else {
383
+ populateFromSelect(accounts);
384
+ toInput.focus();
385
+ }
386
+ // Wire debounced saves to input events — checkpoint ~1.5s after the last
387
+ // keystroke instead of waiting up to 5s for the interval tick.
388
+ toInput.addEventListener("input", scheduleDraftSave);
389
+ ccInput.addEventListener("input", scheduleDraftSave);
390
+ bccInput.addEventListener("input", scheduleDraftSave);
391
+ subjectInput.addEventListener("input", scheduleDraftSave);
392
+ editor.onContentChange(scheduleDraftSave);
393
+ // Safety-net interval: even with no user input, catch any edge cases.
394
+ draftTimer = setInterval(saveDraft, DRAFT_INTERVAL_MS);
395
+ // Flush the draft on window close so the last-typed content lands in
396
+ // editing/ even if the interval tick hasn't fired yet. navigator.sendBeacon
397
+ // is synchronous enough to survive unload; callNode IPC would be dropped.
398
+ window.addEventListener("beforeunload", () => {
399
+ if (draftDebounceTimer) {
400
+ clearTimeout(draftDebounceTimer);
401
+ draftDebounceTimer = null;
402
+ }
403
+ // fire-and-forget — can't await during unload
404
+ saveDraft();
405
+ });
406
+ })();
351
407
  // ── Send ──
352
408
  document.getElementById("btn-send")?.addEventListener("click", async () => {
353
409
  const btn = document.getElementById("btn-send");
@@ -365,10 +421,15 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
365
421
  };
366
422
  try {
367
423
  await sendMessage(body);
368
- // Delete draft after successful send
369
- clearInterval(draftTimer);
370
- if (draftUid) {
371
- deleteDraft(getFromAccountId(), draftUid).catch(() => { });
424
+ // Delete draft after successful send — stop auto-save first so it can't
425
+ // append a fresh copy after we delete. Use the stored draftId as a fallback
426
+ // so any orphaned drafts with the same stable ID are cleaned up too.
427
+ if (draftTimer) {
428
+ clearInterval(draftTimer);
429
+ draftTimer = null;
430
+ }
431
+ if (draftUid || draftId) {
432
+ deleteDraft(getFromAccountId(), draftUid || 0, draftId || "").catch(() => { });
372
433
  }
373
434
  closeCompose();
374
435
  }
@@ -3,6 +3,79 @@
3
3
  * The compose window loads this module and calls createEditor() based on the user's setting.
4
4
  */
5
5
  function createQuillEditor(container) {
6
+ // Extra keybindings for formatting that Quill doesn't wire up by default.
7
+ // Ctrl+K (insert link) is the one most users expect; we also add shortcuts
8
+ // for strikethrough, lists, indent, color, and clear-formatting.
9
+ const extraBindings = {
10
+ insertLink: {
11
+ key: "K", shortKey: true,
12
+ handler: function (range) {
13
+ if (!range)
14
+ return true;
15
+ const current = this.quill.getFormat(range).link || "";
16
+ const url = prompt("URL (leave blank to remove link):", current);
17
+ if (url === null)
18
+ return;
19
+ if (url === "")
20
+ this.quill.format("link", false);
21
+ else
22
+ this.quill.format("link", url);
23
+ },
24
+ },
25
+ removeLink: {
26
+ key: "K", shortKey: true, shiftKey: true,
27
+ handler: function () { this.quill.format("link", false); },
28
+ },
29
+ strike: {
30
+ key: "X", shortKey: true, shiftKey: true,
31
+ handler: function (range) {
32
+ if (!range)
33
+ return true;
34
+ const cur = this.quill.getFormat(range).strike;
35
+ this.quill.format("strike", !cur);
36
+ },
37
+ },
38
+ orderedList: {
39
+ key: "7", shortKey: true, shiftKey: true,
40
+ handler: function () { this.quill.format("list", "ordered"); },
41
+ },
42
+ bulletList: {
43
+ key: "8", shortKey: true, shiftKey: true,
44
+ handler: function () { this.quill.format("list", "bullet"); },
45
+ },
46
+ indent: {
47
+ key: "]", shortKey: true,
48
+ handler: function (range, context) {
49
+ this.quill.format("indent", (context.format.indent || 0) + 1);
50
+ },
51
+ },
52
+ outdent: {
53
+ key: "[", shortKey: true,
54
+ handler: function (range, context) {
55
+ this.quill.format("indent", Math.max(0, (context.format.indent || 0) - 1));
56
+ },
57
+ },
58
+ color: {
59
+ key: "C", shortKey: true, shiftKey: true,
60
+ handler: function (range) {
61
+ if (!range)
62
+ return true;
63
+ const current = this.quill.getFormat(range).color || "";
64
+ const color = prompt("Text color (name or #hex, blank to clear):", current);
65
+ if (color === null)
66
+ return;
67
+ this.quill.format("color", color || false);
68
+ },
69
+ },
70
+ clearFormat: {
71
+ key: "\\", shortKey: true,
72
+ handler: function (range) {
73
+ if (!range)
74
+ return true;
75
+ this.quill.removeFormat(range.index, range.length || 0);
76
+ },
77
+ },
78
+ };
6
79
  const q = new Quill(container, {
7
80
  theme: "snow",
8
81
  placeholder: "Write your message...",
@@ -16,7 +89,8 @@ function createQuillEditor(container) {
16
89
  [{ align: [] }],
17
90
  ["blockquote", "link", "image"],
18
91
  ["clean"]
19
- ]
92
+ ],
93
+ keyboard: { bindings: extraBindings },
20
94
  }
21
95
  });
22
96
  // Make toolbar buttons non-tabbable so Tab goes straight to editor body
@@ -143,8 +143,8 @@ export function saveSettings(settings) {
143
143
  export function repairAccounts() {
144
144
  return ipc().repairAccounts?.();
145
145
  }
146
- export function deleteDraft(accountId, draftUid) {
147
- return ipc().deleteDraft?.(accountId, draftUid);
146
+ export function deleteDraft(accountId, draftUid, draftId) {
147
+ return ipc().deleteDraft?.(accountId, draftUid, draftId);
148
148
  }
149
149
  export function setupAccount(name, email, password) {
150
150
  return ipc().setupAccount?.(name, email, password);
@@ -89,8 +89,8 @@
89
89
  // Compose
90
90
  sendMessage: function(msg) { return callNode("sendMessage", msg); },
91
91
  saveDraft: function(params) { return callNode("saveDraft", params); },
92
- deleteDraft: function(accountId, draftUid) {
93
- return callNode("deleteDraft", { accountId: accountId, draftUid: draftUid });
92
+ deleteDraft: function(accountId, draftUid, draftId) {
93
+ return callNode("deleteDraft", { accountId: accountId, draftUid: draftUid, draftId: draftId });
94
94
  },
95
95
 
96
96
  // Search
@@ -385,6 +385,20 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
385
385
  }
386
386
  .ml-account-tag[title="gmail"] { background: #d93025; }
387
387
  .ml-account-tag[title="bobma"] { background: #1a73e8; }
388
+ .ml-folder-tag {
389
+ display: inline-block;
390
+ padding: 0 6px;
391
+ font-size: 0.72rem;
392
+ font-weight: 500;
393
+ border-radius: 3px;
394
+ margin-right: 4px;
395
+ color: var(--color-text);
396
+ background: color-mix(in oklch, var(--color-accent) 18%, transparent);
397
+ vertical-align: baseline;
398
+ max-width: 10em;
399
+ overflow: hidden;
400
+ text-overflow: ellipsis;
401
+ }
388
402
  .ml-subject {
389
403
  overflow: hidden;
390
404
  text-overflow: ellipsis;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.216",
3
+ "version": "1.0.218",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -24,7 +24,7 @@
24
24
  "@bobfrankston/iflow-node": "^0.1.2",
25
25
  "@bobfrankston/miscinfo": "^1.0.8",
26
26
  "@bobfrankston/oauthsupport": "^1.0.22",
27
- "@bobfrankston/msger": "^0.1.278",
27
+ "@bobfrankston/msger": "^0.1.280",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -78,7 +78,7 @@
78
78
  "@bobfrankston/iflow-node": "^0.1.2",
79
79
  "@bobfrankston/miscinfo": "^1.0.8",
80
80
  "@bobfrankston/oauthsupport": "^1.0.22",
81
- "@bobfrankston/msger": "^0.1.278",
81
+ "@bobfrankston/msger": "^0.1.280",
82
82
  "@capacitor/android": "^8.3.0",
83
83
  "@capacitor/cli": "^8.3.0",
84
84
  "@capacitor/core": "^8.3.0",
@@ -274,7 +274,7 @@ export function createApiRouter(db, imapManager) {
274
274
  try {
275
275
  const { accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId } = req.body;
276
276
  const result = await svc.saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId);
277
- res.json({ ok: true, draftUid: result.uid, draftId: result.draftId });
277
+ res.json({ ok: true, draftUid: result.draftUid, draftId: result.draftId });
278
278
  }
279
279
  catch (e) {
280
280
  res.status(500).json({ error: e.message });
@@ -282,8 +282,8 @@ export function createApiRouter(db, imapManager) {
282
282
  });
283
283
  router.delete("/draft", async (req, res) => {
284
284
  try {
285
- if (req.body.accountId && req.body.draftUid)
286
- await svc.deleteDraft(req.body.accountId, req.body.draftUid);
285
+ if (req.body.accountId && (req.body.draftUid || req.body.draftId))
286
+ await svc.deleteDraft(req.body.accountId, req.body.draftUid || 0, req.body.draftId);
287
287
  res.json({ ok: true });
288
288
  }
289
289
  catch (e) {
@@ -51,6 +51,7 @@ export declare function getMessage(params: {
51
51
  id: number;
52
52
  accountId: string;
53
53
  folderId: number;
54
+ folderName?: string;
54
55
  uid: number;
55
56
  messageId: string;
56
57
  inReplyTo: string;
@@ -169,8 +169,10 @@ export declare class ImapManager extends EventEmitter {
169
169
  /** Save a draft to the Drafts folder via IMAP APPEND.
170
170
  * Returns the UID of the saved draft (for replacing on next save). */
171
171
  saveDraft(accountId: string, rawMessage: string | Buffer, previousDraftUid?: number, draftId?: string): Promise<number | null>;
172
- /** Delete a draft after successful send */
173
- deleteDraft(accountId: string, draftUid: number): Promise<void>;
172
+ /** Delete a draft (or all drafts with a stable X-Mailx-Draft-ID) after successful send.
173
+ * Tries the specific UID first, then falls back to searchByHeader so orphaned copies
174
+ * from earlier failed autosaves are cleaned up at the same time. */
175
+ deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
174
176
  /** Queue outgoing message locally — never fails, worker handles IMAP+SMTP */
175
177
  queueOutgoingLocal(accountId: string, rawMessage: string): void;
176
178
  /** Guard against concurrent processSendActions for the same account */
@@ -186,7 +188,9 @@ export declare class ImapManager extends EventEmitter {
186
188
  private saveSendingCopy;
187
189
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
188
190
  queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
189
- /** Process local file queue — send from outbox/ and sending/queued/ */
191
+ /** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
192
+ * Do NOT scan sending/<acct>/queued/ — that was causing every sent message to be
193
+ * re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
190
194
  private processLocalQueue;
191
195
  /** Send a raw RFC 2822 message via SMTP for a given account */
192
196
  private sendRawViaSMTP;
@@ -1558,24 +1558,32 @@ export class ImapManager extends EventEmitter {
1558
1558
  }
1559
1559
  const client = this.createClient(accountId);
1560
1560
  try {
1561
- // Delete previous draft — by UID if we have it, otherwise by X-Mailx-Draft-ID header
1561
+ // Delete previous draft — try UID first (fast path), and ALWAYS also try
1562
+ // searchByHeader(X-Mailx-Draft-ID) as a safety net. Running both catches
1563
+ // orphans from a crash-mid-save or a UID delete that failed silently.
1562
1564
  if (previousDraftUid) {
1563
1565
  try {
1564
1566
  await client.deleteMessageByUid(drafts.path, previousDraftUid);
1565
1567
  }
1566
- catch { /* previous draft may already be gone */ }
1568
+ catch (e) {
1569
+ console.error(` [drafts] Delete prev UID ${previousDraftUid} failed: ${e.message}`);
1570
+ }
1567
1571
  }
1568
- else if (draftId) {
1569
- // Search Drafts for our draft ID and delete it
1572
+ if (draftId) {
1570
1573
  try {
1571
1574
  const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
1572
1575
  for (const uid of uids) {
1573
- await client.deleteMessageByUid(drafts.path, uid);
1576
+ try {
1577
+ await client.deleteMessageByUid(drafts.path, uid);
1578
+ }
1579
+ catch { /* next */ }
1574
1580
  }
1575
1581
  if (uids.length > 0)
1576
- console.log(` [drafts] Deleted ${uids.length} previous draft(s) by ID ${draftId}`);
1582
+ console.log(` [drafts] Deleted ${uids.length} stale draft(s) by ID ${draftId}`);
1583
+ }
1584
+ catch (e) {
1585
+ console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
1577
1586
  }
1578
- catch { /* search not supported or failed — tolerate duplicate */ }
1579
1587
  }
1580
1588
  // Append new draft
1581
1589
  const result = await client.appendMessage(drafts.path, rawMessage, ["\\Draft", "\\Seen"]);
@@ -1590,18 +1598,42 @@ export class ImapManager extends EventEmitter {
1590
1598
  catch { /* ignore */ }
1591
1599
  }
1592
1600
  }
1593
- /** Delete a draft after successful send */
1594
- async deleteDraft(accountId, draftUid) {
1601
+ /** Delete a draft (or all drafts with a stable X-Mailx-Draft-ID) after successful send.
1602
+ * Tries the specific UID first, then falls back to searchByHeader so orphaned copies
1603
+ * from earlier failed autosaves are cleaned up at the same time. */
1604
+ async deleteDraft(accountId, draftUid, draftId) {
1595
1605
  const drafts = this.findFolder(accountId, "drafts");
1596
- if (!drafts || !draftUid)
1606
+ if (!drafts)
1607
+ return;
1608
+ if (!draftUid && !draftId)
1597
1609
  return;
1598
1610
  const client = this.createClient(accountId);
1599
1611
  try {
1600
- await client.deleteMessageByUid(drafts.path, draftUid);
1601
- console.log(` [drafts] Deleted draft UID ${draftUid}`);
1602
- }
1603
- catch (e) {
1604
- console.error(` [drafts] Delete error: ${e.message}`);
1612
+ if (draftUid) {
1613
+ try {
1614
+ await client.deleteMessageByUid(drafts.path, draftUid);
1615
+ console.log(` [drafts] Deleted draft UID ${draftUid}`);
1616
+ }
1617
+ catch (e) {
1618
+ console.error(` [drafts] Delete by UID ${draftUid} failed: ${e.message}`);
1619
+ }
1620
+ }
1621
+ if (draftId) {
1622
+ try {
1623
+ const uids = await client.searchByHeader(drafts.path, "X-Mailx-Draft-ID", draftId);
1624
+ for (const uid of uids) {
1625
+ try {
1626
+ await client.deleteMessageByUid(drafts.path, uid);
1627
+ }
1628
+ catch { /* next */ }
1629
+ }
1630
+ if (uids.length > 0)
1631
+ console.log(` [drafts] Deleted ${uids.length} draft(s) by ID ${draftId}`);
1632
+ }
1633
+ catch (e) {
1634
+ console.error(` [drafts] searchByHeader for ${draftId} failed: ${e.message}`);
1635
+ }
1636
+ }
1605
1637
  }
1606
1638
  finally {
1607
1639
  try {
@@ -1709,8 +1741,13 @@ export class ImapManager extends EventEmitter {
1709
1741
  }
1710
1742
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
1711
1743
  async queueOutgoing(accountId, rawMessage) {
1712
- // Always save a debug copy
1713
- this.saveSendingCopy(accountId, rawMessage, "queued");
1744
+ // IMPORTANT: do NOT save a "debug copy" to sending/<acct>/queued/ here.
1745
+ // processLocalQueue also scans sending/<acct>/queued/, so writing there
1746
+ // on every send caused the same message to be re-APPENDed to the IMAP
1747
+ // Outbox on the next outbox tick — resulting in a duplicate send.
1748
+ // The only two legitimate queue locations are:
1749
+ // - IMAP Outbox (primary, populated by APPEND below)
1750
+ // - ~/.mailx/outbox/<acct>/*.ltr (fallback when IMAP is unreachable)
1714
1751
  try {
1715
1752
  const outboxPath = await this.ensureOutbox(accountId);
1716
1753
  const client = this.createClient(accountId);
@@ -1734,7 +1771,7 @@ export class ImapManager extends EventEmitter {
1734
1771
  catch (e) {
1735
1772
  console.error(` [outbox] IMAP queue failed: ${e.message} — saving locally`);
1736
1773
  }
1737
- // Fallback: save to local file queue
1774
+ // Fallback: save to local file queue (processLocalQueue picks these up)
1738
1775
  const localQueue = path.join(getConfigDir(), "outbox", accountId);
1739
1776
  fs.mkdirSync(localQueue, { recursive: true });
1740
1777
  const now = new Date();
@@ -1743,17 +1780,15 @@ export class ImapManager extends EventEmitter {
1743
1780
  fs.writeFileSync(path.join(localQueue, filename), rawMessage);
1744
1781
  console.log(` [outbox] Saved locally: ${filename}`);
1745
1782
  }
1746
- /** Process local file queue — send from outbox/ and sending/queued/ */
1783
+ /** Process local file queue — send from outbox/ only (IMAP-unreachable fallback).
1784
+ * Do NOT scan sending/<acct>/queued/ — that was causing every sent message to be
1785
+ * re-APPENDed to the IMAP Outbox on the next tick and delivered twice. */
1747
1786
  async processLocalQueue(accountId) {
1748
- // Collect files from both outbox/ (legacy .ltr) and sending/queued/ (drop-in)
1749
1787
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
1750
- const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
1751
1788
  const filesToSend = [];
1752
- for (const dir of [outboxDir, queuedDir]) {
1753
- if (!fs.existsSync(dir))
1754
- continue;
1755
- for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
1756
- filesToSend.push({ dir, file });
1789
+ if (fs.existsSync(outboxDir)) {
1790
+ for (const file of fs.readdirSync(outboxDir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
1791
+ filesToSend.push({ dir: outboxDir, file });
1757
1792
  }
1758
1793
  }
1759
1794
  if (filesToSend.length === 0)
@@ -48,10 +48,10 @@ export declare class MailxService {
48
48
  filename: string;
49
49
  }>;
50
50
  saveDraft(accountId: string, subject: string, bodyHtml: string, bodyText: string, to?: string, cc?: string, previousDraftUid?: number, draftId?: string): Promise<{
51
- uid: number | null;
51
+ draftUid: number | null;
52
52
  draftId: string;
53
53
  }>;
54
- deleteDraft(accountId: string, draftUid: number): Promise<void>;
54
+ deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
55
55
  searchContacts(query: string): any[];
56
56
  syncGoogleContacts(): Promise<void>;
57
57
  seedContacts(): number;
@@ -572,11 +572,11 @@ export class MailxService {
572
572
  }
573
573
  }
574
574
  catch { /* ignore */ }
575
- const uid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
576
- return { uid, draftId: id };
575
+ const draftUid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
576
+ return { draftUid, draftId: id };
577
577
  }
578
- async deleteDraft(accountId, draftUid) {
579
- await this.imapManager.deleteDraft(accountId, draftUid);
578
+ async deleteDraft(accountId, draftUid, draftId) {
579
+ await this.imapManager.deleteDraft(accountId, draftUid, draftId);
580
580
  }
581
581
  // ── Contacts ──
582
582
  searchContacts(query) {
@@ -76,7 +76,7 @@ async function dispatchAction(svc, action, p) {
76
76
  case "saveDraft":
77
77
  return svc.saveDraft(p.accountId, p.subject, p.bodyHtml, p.bodyText, p.to, p.cc, p.previousDraftUid, p.draftId);
78
78
  case "deleteDraft":
79
- await svc.deleteDraft(p.accountId, p.draftUid);
79
+ await svc.deleteDraft(p.accountId, p.draftUid, p.draftId);
80
80
  return { ok: true };
81
81
  // Sync
82
82
  case "syncAll":
@@ -448,8 +448,9 @@ export class MailxDB {
448
448
  }
449
449
  const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM messages m JOIN messages_fts fts ON m.id = fts.rowid WHERE messages_fts MATCH ?${scopeWhere}`).get(ftsQuery, ...scopeParams);
450
450
  const total = countRow?.cnt || 0;
451
- const rows = this.db.prepare(`SELECT m.* FROM messages m
451
+ const rows = this.db.prepare(`SELECT m.*, f.name AS folder_name FROM messages m
452
452
  JOIN messages_fts fts ON m.id = fts.rowid
453
+ LEFT JOIN folders f ON f.id = m.folder_id AND f.account_id = m.account_id
453
454
  WHERE messages_fts MATCH ?${scopeWhere}
454
455
  ORDER BY m.date DESC
455
456
  LIMIT ? OFFSET ?`).all(ftsQuery, ...scopeParams, pageSize, offset);
@@ -457,6 +458,7 @@ export class MailxDB {
457
458
  id: r.id,
458
459
  accountId: r.account_id,
459
460
  folderId: r.folder_id,
461
+ folderName: r.folder_name || "",
460
462
  uid: r.uid,
461
463
  messageId: r.message_id || "",
462
464
  inReplyTo: r.in_reply_to || "",
@@ -59,6 +59,7 @@ export interface MessageEnvelope {
59
59
  id: number; /** Local store ID */
60
60
  accountId: string;
61
61
  folderId: number;
62
+ folderName?: string; /** Leaf folder name; populated by cross-folder search so the UI can tag each hit */
62
63
  uid: number; /** IMAP UID */
63
64
  messageId: string; /** RFC Message-ID header */
64
65
  inReplyTo: string; /** For threading */