@bobfrankston/mailx 1.0.217 → 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.
@@ -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>
@@ -293,11 +293,22 @@ function applyInit(init) {
293
293
  editor.focus();
294
294
  }
295
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
296
298
  let draftUid = null;
297
299
  let draftId = null; // stable ID for dedup when APPENDUID unavailable
298
300
  let draftTimer = null;
301
+ let draftDebounceTimer = null;
299
302
  let lastDraftContent = "";
300
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
+ }
301
312
  async function saveDraft() {
302
313
  if (draftSaving)
303
314
  return; // previous save still in flight
@@ -323,12 +334,32 @@ async function saveDraft() {
323
334
  draftUid = data.draftUid;
324
335
  if (data?.draftId)
325
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 = "";
326
352
  }
327
- catch { /* ignore draft save errors */ }
328
353
  finally {
329
354
  draftSaving = false;
330
355
  }
331
356
  }
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
+ }
332
363
  // ── Initialize: always fetch real accounts from the API before applying init, then
333
364
  // start the auto-save timer. Callers like message-viewer's Edit Draft pass
334
365
  // init.accounts=[], so we can't trust what's in the init blob. ──
@@ -352,9 +383,26 @@ async function saveDraft() {
352
383
  populateFromSelect(accounts);
353
384
  toInput.focus();
354
385
  }
355
- // Start auto-save after init so draftUid/draftId from the edit-draft flow are
356
- // already in place when the first save fires.
357
- draftTimer = setInterval(saveDraft, 5000);
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
+ });
358
406
  })();
359
407
  // ── Send ──
360
408
  document.getElementById("btn-send")?.addEventListener("click", async () => {
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.217",
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.279",
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.279",
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",
@@ -188,7 +188,9 @@ export declare class ImapManager extends EventEmitter {
188
188
  private saveSendingCopy;
189
189
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
190
190
  queueOutgoing(accountId: string, rawMessage: string | Buffer): Promise<void>;
191
- /** 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. */
192
194
  private processLocalQueue;
193
195
  /** Send a raw RFC 2822 message via SMTP for a given account */
194
196
  private sendRawViaSMTP;
@@ -1741,8 +1741,13 @@ export class ImapManager extends EventEmitter {
1741
1741
  }
1742
1742
  /** Queue a message for sending. Tries IMAP Outbox, falls back to local file. */
1743
1743
  async queueOutgoing(accountId, rawMessage) {
1744
- // Always save a debug copy
1745
- 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)
1746
1751
  try {
1747
1752
  const outboxPath = await this.ensureOutbox(accountId);
1748
1753
  const client = this.createClient(accountId);
@@ -1766,7 +1771,7 @@ export class ImapManager extends EventEmitter {
1766
1771
  catch (e) {
1767
1772
  console.error(` [outbox] IMAP queue failed: ${e.message} — saving locally`);
1768
1773
  }
1769
- // Fallback: save to local file queue
1774
+ // Fallback: save to local file queue (processLocalQueue picks these up)
1770
1775
  const localQueue = path.join(getConfigDir(), "outbox", accountId);
1771
1776
  fs.mkdirSync(localQueue, { recursive: true });
1772
1777
  const now = new Date();
@@ -1775,17 +1780,15 @@ export class ImapManager extends EventEmitter {
1775
1780
  fs.writeFileSync(path.join(localQueue, filename), rawMessage);
1776
1781
  console.log(` [outbox] Saved locally: ${filename}`);
1777
1782
  }
1778
- /** 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. */
1779
1786
  async processLocalQueue(accountId) {
1780
- // Collect files from both outbox/ (legacy .ltr) and sending/queued/ (drop-in)
1781
1787
  const outboxDir = path.join(getConfigDir(), "outbox", accountId);
1782
- const queuedDir = path.join(getConfigDir(), "sending", accountId, "queued");
1783
1788
  const filesToSend = [];
1784
- for (const dir of [outboxDir, queuedDir]) {
1785
- if (!fs.existsSync(dir))
1786
- continue;
1787
- for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".ltr") || f.endsWith(".eml"))) {
1788
- 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 });
1789
1792
  }
1790
1793
  }
1791
1794
  if (filesToSend.length === 0)