@bobfrankston/mailx 1.0.50 → 1.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/app.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Wires together all UI components and WebSocket connection.
4
4
  */
5
5
  import { initFolderTree, refreshFolderTree } from "./components/folder-tree.js";
6
- import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder } from "./components/message-list.js";
6
+ import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages } from "./components/message-list.js";
7
7
  import { showMessage, getCurrentMessage } from "./components/message-viewer.js";
8
8
  import { connectWebSocket, onWsEvent, triggerSync, getAccounts, getFolders, deleteMessage, undeleteMessage, restartServer, rebuildServer, getSyncPending } from "./lib/api-client.js";
9
9
  // ── New message badge (favicon + title) ──
@@ -290,46 +290,61 @@ function forwardBody(msg) {
290
290
  }
291
291
  let lastDeleted = null;
292
292
  let undoTimeout = null;
293
- async function deleteCurrentMessage() {
294
- const current = getCurrentMessage();
295
- if (!current)
296
- return;
297
- const { accountId, message } = current;
293
+ async function deleteSelectedMessages() {
294
+ const selected = getSelectedMessages();
295
+ // Fall back to single message from viewer if nothing selected in list
296
+ if (selected.length === 0) {
297
+ const current = getCurrentMessage();
298
+ if (!current)
299
+ return;
300
+ selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
301
+ }
302
+ const statusSync = document.getElementById("status-sync");
303
+ const mlBody = document.getElementById("ml-body");
298
304
  try {
299
- await deleteMessage(accountId, message.uid);
300
- lastDeleted = { accountId, uid: message.uid, folderId: message.folderId, subject: message.subject };
301
- // Show undo notification in status bar
302
- const statusSync = document.getElementById("status-sync");
303
- if (statusSync)
304
- statusSync.textContent = `Deleted "${message.subject.substring(0, 40)}" — Ctrl+Z to undo`;
305
- // Clear undo after 30 seconds
305
+ // Find the row after the last selected for re-selection
306
+ let nextRow = null;
307
+ if (mlBody) {
308
+ const lastRow = mlBody.querySelector(`.ml-row[data-uid="${selected[selected.length - 1].uid}"]`);
309
+ if (lastRow)
310
+ nextRow = (lastRow.nextElementSibling || lastRow.previousElementSibling);
311
+ }
312
+ // Delete all selected
313
+ for (const msg of selected) {
314
+ await deleteMessage(msg.accountId, msg.uid);
315
+ // Remove row from DOM
316
+ if (mlBody)
317
+ mlBody.querySelector(`.ml-row[data-uid="${msg.uid}"]`)?.remove();
318
+ }
319
+ // Undo supports the last batch
320
+ if (selected.length === 1) {
321
+ lastDeleted = { ...selected[0], subject: "" };
322
+ if (statusSync)
323
+ statusSync.textContent = `Deleted 1 message — Ctrl+Z to undo`;
324
+ }
325
+ else {
326
+ lastDeleted = null; // Multi-delete undo not supported yet
327
+ if (statusSync)
328
+ statusSync.textContent = `Deleted ${selected.length} messages`;
329
+ }
306
330
  if (undoTimeout)
307
331
  clearTimeout(undoTimeout);
308
332
  undoTimeout = setTimeout(() => {
309
333
  lastDeleted = null;
310
- if (statusSync?.textContent?.includes("Ctrl+Z"))
334
+ if (statusSync?.textContent?.includes("undo"))
311
335
  statusSync.textContent = "";
312
336
  }, 30000);
313
- // Remove the deleted row and select the next message
314
- const mlBody = document.getElementById("ml-body");
315
- if (mlBody) {
316
- const deletedRow = mlBody.querySelector(`.ml-row[data-uid="${message.uid}"]`);
317
- if (deletedRow) {
318
- const nextRow = (deletedRow.nextElementSibling || deletedRow.previousElementSibling);
319
- deletedRow.remove();
320
- if (nextRow?.classList.contains("ml-row")) {
321
- nextRow.click();
322
- }
323
- else {
324
- // No more messages — clear preview
325
- const bodyEl = document.getElementById("mv-body");
326
- const headerEl = document.getElementById("mv-header");
327
- if (bodyEl)
328
- bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
329
- if (headerEl)
330
- headerEl.hidden = true;
331
- }
332
- }
337
+ // Select next row or clear viewer
338
+ if (nextRow?.classList.contains("ml-row")) {
339
+ nextRow.click();
340
+ }
341
+ else {
342
+ const bodyEl = document.getElementById("mv-body");
343
+ const headerEl = document.getElementById("mv-header");
344
+ if (bodyEl)
345
+ bodyEl.innerHTML = `<div class="mv-empty">Select a message to read</div>`;
346
+ if (headerEl)
347
+ headerEl.hidden = true;
333
348
  }
334
349
  }
335
350
  catch (e) {
@@ -354,7 +369,7 @@ async function undoDelete() {
354
369
  console.error(`Undo failed: ${e.message}`);
355
370
  }
356
371
  }
357
- document.getElementById("btn-delete")?.addEventListener("click", deleteCurrentMessage);
372
+ document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
358
373
  document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
359
374
  document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
360
375
  document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
@@ -519,16 +534,15 @@ onWsEvent((event) => {
519
534
  break;
520
535
  case "syncProgress":
521
536
  if (statusSync)
522
- statusSync.textContent = `Sync: ${event.phase} ${event.progress}%`;
537
+ statusSync.textContent = `Syncing ${event.accountId}: ${event.phase} ${event.progress}%`;
523
538
  if (startupStatus)
524
539
  startupStatus.textContent = `Syncing ${event.accountId}: ${event.phase}`;
525
540
  break;
526
541
  case "folderCountsChanged": {
527
542
  refreshFolderTree();
528
543
  updateNewMessageCount();
529
- // Only reload message list if user isn't reading a message
530
- if (!getCurrentMessage())
531
- reloadCurrentFolder();
544
+ // Reload message list but keep current scroll position and selection
545
+ reloadCurrentFolder();
532
546
  // Sync finished — re-enable sync button
533
547
  const syncBtn = document.getElementById("btn-sync");
534
548
  if (syncBtn) {
@@ -566,10 +580,18 @@ document.addEventListener("keydown", (e) => {
566
580
  e.preventDefault();
567
581
  openCompose("replyAll");
568
582
  }
569
- // Ctrl+D or Delete = Delete message
583
+ // Ctrl+A = Select all visible messages
584
+ if (e.ctrlKey && e.key === "a") {
585
+ const mlBody = document.getElementById("ml-body");
586
+ if (mlBody && document.activeElement?.closest(".message-list, .ml-body, body")) {
587
+ e.preventDefault();
588
+ mlBody.querySelectorAll(".ml-row").forEach(r => r.classList.add("selected"));
589
+ }
590
+ }
591
+ // Ctrl+D or Delete = Delete selected messages
570
592
  if ((e.ctrlKey && e.key === "d") || e.key === "Delete") {
571
593
  e.preventDefault();
572
- deleteCurrentMessage();
594
+ deleteSelectedMessages();
573
595
  }
574
596
  // Ctrl+Z = Undo delete
575
597
  if (e.ctrlKey && e.key === "z") {
@@ -600,6 +622,8 @@ viewBtn?.addEventListener("click", (e) => {
600
622
  document.addEventListener("click", () => {
601
623
  if (viewDropdown)
602
624
  viewDropdown.hidden = true;
625
+ if (settingsDropdown)
626
+ settingsDropdown.hidden = true;
603
627
  });
604
628
  // Restore saved view settings
605
629
  const savedTwoLine = localStorage.getItem("mailx-two-line") === "true";
@@ -666,6 +690,44 @@ optFlagged?.addEventListener("change", () => {
666
690
  }
667
691
  localStorage.setItem("mailx-flagged", String(optFlagged.checked));
668
692
  });
693
+ // ── Settings menu ──
694
+ const settingsBtn = document.getElementById("btn-settings");
695
+ const settingsDropdown = document.getElementById("settings-dropdown");
696
+ const optEditorQuill = document.getElementById("opt-editor-quill");
697
+ const optEditorTiptap = document.getElementById("opt-editor-tiptap");
698
+ settingsBtn?.addEventListener("click", (e) => {
699
+ e.stopPropagation();
700
+ if (settingsDropdown)
701
+ settingsDropdown.hidden = !settingsDropdown.hidden;
702
+ });
703
+ // Close handled by the shared document click handler above
704
+ // Load current editor setting from server
705
+ fetch("/api/settings").then(r => r.json()).then(s => {
706
+ const ed = s.ui?.editor || "quill";
707
+ if (optEditorQuill)
708
+ optEditorQuill.checked = ed === "quill";
709
+ if (optEditorTiptap)
710
+ optEditorTiptap.checked = ed === "tiptap";
711
+ }).catch(() => { });
712
+ // Save editor choice to server settings
713
+ function saveEditorSetting(editor) {
714
+ fetch("/api/settings").then(r => r.json()).then(settings => {
715
+ settings.ui = { ...settings.ui, editor };
716
+ fetch("/api/settings", {
717
+ method: "PUT",
718
+ headers: { "Content-Type": "application/json" },
719
+ body: JSON.stringify(settings),
720
+ });
721
+ }).catch(() => { });
722
+ }
723
+ optEditorQuill?.addEventListener("change", () => {
724
+ if (optEditorQuill.checked)
725
+ saveEditorSetting("quill");
726
+ });
727
+ optEditorTiptap?.addEventListener("change", () => {
728
+ if (optEditorTiptap.checked)
729
+ saveEditorSetting("tiptap");
730
+ });
669
731
  const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
670
732
  fetch("/api/version").then(r => r.json()).then(d => {
671
733
  const el = document.getElementById("app-version");
@@ -188,6 +188,72 @@ body {
188
188
  font-size: 0.8em;
189
189
  }
190
190
 
191
+ /* ── tiptap editor ── */
192
+ .editor-tiptap {
193
+ display: flex;
194
+ flex-direction: column;
195
+ overflow: hidden;
196
+ }
197
+
198
+ .tt-toolbar {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 2px;
202
+ padding: var(--gap-xs) var(--gap-sm);
203
+ border-bottom: 1px solid var(--color-border);
204
+ background: var(--color-bg-toolbar);
205
+ flex-wrap: wrap;
206
+ }
207
+
208
+ .tt-btn {
209
+ border: none;
210
+ background: transparent;
211
+ color: var(--color-text-muted);
212
+ padding: 4px 8px;
213
+ border-radius: var(--radius-sm);
214
+ cursor: pointer;
215
+ font-size: var(--font-size-sm);
216
+ }
217
+
218
+ .tt-btn:hover { background: var(--color-bg-hover); color: var(--color-text); }
219
+
220
+ .tt-heading {
221
+ border: 1px solid var(--color-border);
222
+ background: var(--color-bg);
223
+ color: var(--color-text);
224
+ padding: 2px 4px;
225
+ border-radius: var(--radius-sm);
226
+ font-size: var(--font-size-sm);
227
+ }
228
+
229
+ .tt-content {
230
+ flex: 1;
231
+ overflow-y: auto;
232
+ }
233
+
234
+ .tt-content .tiptap {
235
+ padding: var(--gap-md);
236
+ min-height: 100%;
237
+ outline: none;
238
+ color: var(--color-text);
239
+ font-family: var(--font-ui);
240
+ font-size: var(--font-size-base);
241
+ }
242
+
243
+ .tt-content .tiptap p.is-editor-empty:first-child::before {
244
+ content: attr(data-placeholder);
245
+ color: var(--color-text-muted);
246
+ pointer-events: none;
247
+ float: left;
248
+ height: 0;
249
+ }
250
+
251
+ .tt-content .tiptap blockquote {
252
+ border-left: 3px solid var(--color-border);
253
+ padding-left: var(--gap-md);
254
+ color: var(--color-text-muted);
255
+ }
256
+
191
257
  /* Quoted original message in reply/forward */
192
258
  .reply {
193
259
  border-left: 3px solid var(--color-border);
@@ -6,9 +6,8 @@
6
6
  <title>Compose - mailx</title>
7
7
  <link rel="icon" type="image/svg+xml" href="../favicon.svg">
8
8
  <link rel="stylesheet" href="../styles/variables.css">
9
- <link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet">
10
9
  <link rel="stylesheet" href="compose.css">
11
- <script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
10
+ <!-- Editor CSS/JS loaded dynamically by compose.ts based on setting -->
12
11
  <script type="module" src="compose.js"></script>
13
12
  </head>
14
13
  <body>
@@ -3,22 +3,61 @@
3
3
  * Opened as a popup from the main mailx window.
4
4
  * Receives init data via window.opener.postMessage or URL params.
5
5
  */
6
- // ── Quill Editor ──
7
- const editor = new Quill("#compose-editor", {
8
- theme: "snow",
9
- placeholder: "Write your message...",
10
- modules: {
11
- toolbar: [
12
- [{ header: [1, 2, 3, false] }],
13
- ["bold", "italic", "underline", "strike"],
14
- [{ list: "ordered" }, { list: "bullet" }],
15
- ["blockquote", "link", "image"],
16
- ["clean"]
17
- ]
6
+ import { createEditor } from "./editor.js";
7
+ // ── Load editor scripts dynamically ──
8
+ function loadScript(src) {
9
+ return new Promise((resolve, reject) => {
10
+ const s = document.createElement("script");
11
+ s.src = src;
12
+ s.onload = () => resolve();
13
+ s.onerror = () => reject(new Error(`Failed to load ${src}`));
14
+ document.head.appendChild(s);
15
+ });
16
+ }
17
+ function loadCSS(href) {
18
+ const link = document.createElement("link");
19
+ link.rel = "stylesheet";
20
+ link.href = href;
21
+ document.head.appendChild(link);
22
+ }
23
+ async function loadEditorAssets(type) {
24
+ if (type === "tiptap") {
25
+ // tiptap UMD bundles from CDN
26
+ const cdn = "https://cdn.jsdelivr.net/npm";
27
+ await loadScript(`${cdn}/@tiptap/core@2/dist/index.umd.js`);
28
+ await Promise.all([
29
+ loadScript(`${cdn}/@tiptap/starter-kit@2/dist/index.umd.js`),
30
+ loadScript(`${cdn}/@tiptap/extension-link@2/dist/index.umd.js`),
31
+ loadScript(`${cdn}/@tiptap/extension-image@2/dist/index.umd.js`),
32
+ loadScript(`${cdn}/@tiptap/extension-underline@2/dist/index.umd.js`),
33
+ loadScript(`${cdn}/@tiptap/extension-placeholder@2/dist/index.umd.js`),
34
+ ]);
18
35
  }
19
- });
20
- // Make toolbar buttons non-tabbable so Tab goes straight to editor body
21
- document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
36
+ else {
37
+ // Quill
38
+ loadCSS("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css");
39
+ await loadScript("https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js");
40
+ }
41
+ }
42
+ // ── Determine editor type from settings ──
43
+ let editorType = "quill";
44
+ try {
45
+ const res = await fetch("/api/version");
46
+ if (res.ok) {
47
+ // Check settings for editor preference
48
+ const settingsRes = await fetch("/api/settings");
49
+ if (settingsRes.ok) {
50
+ const settings = await settingsRes.json();
51
+ if (settings.ui?.editor === "tiptap")
52
+ editorType = "tiptap";
53
+ }
54
+ }
55
+ }
56
+ catch { /* default to quill */ }
57
+ await loadEditorAssets(editorType);
58
+ const container = document.getElementById("compose-editor");
59
+ container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
60
+ const editor = await createEditor(container, editorType);
22
61
  // ── Populate from init data ──
23
62
  const fromSelect = document.getElementById("compose-from-select");
24
63
  const fromCustom = document.getElementById("compose-from-custom");
@@ -214,8 +253,8 @@ function applyInit(init) {
214
253
  ccInput.value = formatAddrs(init.cc);
215
254
  subjectInput.value = init.subject;
216
255
  if (init.bodyHtml) {
217
- editor.clipboard.dangerouslyPasteHTML(init.bodyHtml);
218
- editor.setSelection(0, 0);
256
+ editor.setHtml(init.bodyHtml);
257
+ editor.setCursor(0);
219
258
  }
220
259
  // If resuming a draft, track its UID for deletion after send
221
260
  if (init.draftUid) {
@@ -254,7 +293,7 @@ let draftUid = null;
254
293
  let draftTimer;
255
294
  let lastDraftContent = "";
256
295
  async function saveDraft() {
257
- const content = editor.root.innerHTML + subjectInput.value + toInput.value;
296
+ const content = editor.getHtml() + subjectInput.value + toInput.value;
258
297
  if (content === lastDraftContent)
259
298
  return; // no changes
260
299
  if (!editor.getText().trim() && !subjectInput.value && !toInput.value)
@@ -267,7 +306,7 @@ async function saveDraft() {
267
306
  body: JSON.stringify({
268
307
  accountId: getFromAccountId(),
269
308
  subject: subjectInput.value,
270
- bodyHtml: editor.root.innerHTML,
309
+ bodyHtml: editor.getHtml(),
271
310
  bodyText: editor.getText(),
272
311
  to: toInput.value,
273
312
  cc: ccInput.value,
@@ -292,7 +331,7 @@ document.getElementById("btn-send")?.addEventListener("click", async () => {
292
331
  cc: parseAddrs(ccInput.value),
293
332
  bcc: parseAddrs(bccInput.value),
294
333
  subject: subjectInput.value,
295
- bodyHtml: editor.root.innerHTML,
334
+ bodyHtml: editor.getHtml(),
296
335
  bodyText: editor.getText(),
297
336
  };
298
337
  try {
@@ -349,5 +388,4 @@ document.addEventListener("keydown", (e) => {
349
388
  target.dispatchEvent(new Event("input"));
350
389
  }
351
390
  });
352
- export {};
353
391
  //# sourceMappingURL=compose.js.map
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Editor abstraction — wraps Quill or tiptap behind a common interface.
3
+ * The compose window loads this module and calls createEditor() based on the user's setting.
4
+ */
5
+ function createQuillEditor(container) {
6
+ const q = new Quill(container, {
7
+ theme: "snow",
8
+ placeholder: "Write your message...",
9
+ modules: {
10
+ toolbar: [
11
+ [{ header: [1, 2, 3, false] }],
12
+ ["bold", "italic", "underline", "strike"],
13
+ [{ list: "ordered" }, { list: "bullet" }],
14
+ ["blockquote", "link", "image"],
15
+ ["clean"]
16
+ ]
17
+ }
18
+ });
19
+ // Make toolbar buttons non-tabbable so Tab goes straight to editor body
20
+ document.querySelectorAll(".ql-toolbar button, .ql-toolbar select, .ql-toolbar .ql-picker-label").forEach(el => el.setAttribute("tabindex", "-1"));
21
+ return {
22
+ setHtml(html) {
23
+ q.clipboard.dangerouslyPasteHTML(html);
24
+ },
25
+ getHtml() {
26
+ return q.root.innerHTML;
27
+ },
28
+ getText() {
29
+ return q.getText();
30
+ },
31
+ focus() {
32
+ q.focus();
33
+ },
34
+ setCursor(pos) {
35
+ q.setSelection(pos, 0);
36
+ },
37
+ root: q.root
38
+ };
39
+ }
40
+ // ── tiptap ──
41
+ async function createTiptapEditor(container) {
42
+ // tiptap loaded via CDN — use global UMD bundles
43
+ const { Editor } = window.tiptapCore;
44
+ const { StarterKit } = window.tiptapStarterKit;
45
+ const { Link } = window.tiptapExtensionLink;
46
+ const { Image } = window.tiptapExtensionImage;
47
+ const { Underline } = window.tiptapExtensionUnderline;
48
+ const { Placeholder } = window.tiptapExtensionPlaceholder;
49
+ // Build toolbar
50
+ const toolbar = document.createElement("div");
51
+ toolbar.className = "tt-toolbar";
52
+ toolbar.innerHTML = `
53
+ <select class="tt-heading" tabindex="-1">
54
+ <option value="p">Normal</option>
55
+ <option value="1">Heading 1</option>
56
+ <option value="2">Heading 2</option>
57
+ <option value="3">Heading 3</option>
58
+ </select>
59
+ <button class="tt-btn" data-cmd="bold" title="Bold" tabindex="-1"><b>B</b></button>
60
+ <button class="tt-btn" data-cmd="italic" title="Italic" tabindex="-1"><i>I</i></button>
61
+ <button class="tt-btn" data-cmd="underline" title="Underline" tabindex="-1"><u>U</u></button>
62
+ <button class="tt-btn" data-cmd="strike" title="Strikethrough" tabindex="-1"><s>S</s></button>
63
+ <button class="tt-btn" data-cmd="bulletList" title="Bullet list" tabindex="-1">&#8226;</button>
64
+ <button class="tt-btn" data-cmd="orderedList" title="Ordered list" tabindex="-1">1.</button>
65
+ <button class="tt-btn" data-cmd="blockquote" title="Blockquote" tabindex="-1">&ldquo;</button>
66
+ <button class="tt-btn" data-cmd="link" title="Link" tabindex="-1">&#128279;</button>
67
+ <button class="tt-btn" data-cmd="clearFormat" title="Clear formatting" tabindex="-1">&#8999;</button>
68
+ `;
69
+ // Content area
70
+ const content = document.createElement("div");
71
+ content.className = "tt-content";
72
+ container.appendChild(toolbar);
73
+ container.appendChild(content);
74
+ const ed = new Editor({
75
+ element: content,
76
+ extensions: [
77
+ StarterKit,
78
+ Link.configure({ openOnClick: false }),
79
+ Image,
80
+ Underline,
81
+ Placeholder.configure({ placeholder: "Write your message..." }),
82
+ ],
83
+ content: "",
84
+ });
85
+ // Wire toolbar buttons
86
+ toolbar.querySelectorAll(".tt-btn").forEach((btn) => {
87
+ btn.addEventListener("mousedown", (e) => {
88
+ e.preventDefault();
89
+ const cmd = btn.dataset.cmd;
90
+ switch (cmd) {
91
+ case "bold":
92
+ ed.chain().focus().toggleBold().run();
93
+ break;
94
+ case "italic":
95
+ ed.chain().focus().toggleItalic().run();
96
+ break;
97
+ case "underline":
98
+ ed.chain().focus().toggleUnderline().run();
99
+ break;
100
+ case "strike":
101
+ ed.chain().focus().toggleStrike().run();
102
+ break;
103
+ case "bulletList":
104
+ ed.chain().focus().toggleBulletList().run();
105
+ break;
106
+ case "orderedList":
107
+ ed.chain().focus().toggleOrderedList().run();
108
+ break;
109
+ case "blockquote":
110
+ ed.chain().focus().toggleBlockquote().run();
111
+ break;
112
+ case "link": {
113
+ const url = prompt("URL:");
114
+ if (url)
115
+ ed.chain().focus().setLink({ href: url }).run();
116
+ break;
117
+ }
118
+ case "clearFormat":
119
+ ed.chain().focus().clearNodes().unsetAllMarks().run();
120
+ break;
121
+ }
122
+ });
123
+ });
124
+ // Wire heading select
125
+ const headingSelect = toolbar.querySelector(".tt-heading");
126
+ headingSelect?.addEventListener("change", () => {
127
+ const val = headingSelect.value;
128
+ if (val === "p")
129
+ ed.chain().focus().setParagraph().run();
130
+ else
131
+ ed.chain().focus().toggleHeading({ level: parseInt(val) }).run();
132
+ });
133
+ const editorEl = content.querySelector(".tiptap") || content;
134
+ return {
135
+ setHtml(html) {
136
+ ed.commands.setContent(html);
137
+ },
138
+ getHtml() {
139
+ return ed.getHTML();
140
+ },
141
+ getText() {
142
+ return ed.getText();
143
+ },
144
+ focus() {
145
+ ed.commands.focus("start");
146
+ },
147
+ setCursor(pos) {
148
+ ed.commands.focus("start");
149
+ },
150
+ root: editorEl
151
+ };
152
+ }
153
+ // ── Factory ──
154
+ export async function createEditor(container, type) {
155
+ if (type === "tiptap") {
156
+ return createTiptapEditor(container);
157
+ }
158
+ return createQuillEditor(container);
159
+ }
160
+ //# sourceMappingURL=editor.js.map
package/client/index.html CHANGED
@@ -28,6 +28,14 @@
28
28
  <label class="tb-menu-item"><input type="checkbox" id="opt-flagged"> ★ Flagged only</label>
29
29
  </div>
30
30
  </div>
31
+ <div class="tb-menu" id="settings-menu">
32
+ <button class="tb-btn" id="btn-settings">Settings</button>
33
+ <div class="tb-menu-dropdown" id="settings-dropdown" hidden>
34
+ <span class="tb-menu-label">Editor</span>
35
+ <label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
36
+ <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
37
+ </div>
38
+ </div>
31
39
  <span id="app-version" class="app-version"></span>
32
40
  </div>
33
41
  <div class="toolbar-right">
@@ -107,6 +107,8 @@
107
107
  button.tb-menu-item { background: none; border: none; color: inherit; width: 100%; text-align: left; }
108
108
  .tb-menu-sep { border: none; border-top: 1px solid var(--color-border); margin: var(--gap-xs) 0; }
109
109
  .tb-menu-hint { display: block; padding: var(--gap-xs) var(--gap-md); font-size: 0.75rem; color: var(--color-text-muted); }
110
+ .tb-menu-label { display: block; padding: var(--gap-xs) var(--gap-md); font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
111
+ .tb-menu-item input[type="radio"] { accent-color: var(--color-accent); }
110
112
  .tb-sep { width: 1px; height: 1.2rem; background: var(--color-border); margin: 0 var(--gap-xs); }
111
113
 
112
114
  .search-bar {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.50",
3
+ "version": "1.0.55",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -49,6 +49,10 @@ export declare class ImapManager extends EventEmitter {
49
49
  private _syncAll;
50
50
  /** Sync just INBOX for each account (fast check for new mail) */
51
51
  syncInbox(): Promise<void>;
52
+ /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
53
+ * If message count changed, triggers a full inbox sync. */
54
+ private lastInboxCounts;
55
+ quickInboxCheck(): Promise<void>;
52
56
  /** Start periodic sync */
53
57
  startPeriodicSync(intervalMinutes: number): void;
54
58
  /** Stop periodic sync */
@@ -170,6 +170,7 @@ export class ImapManager extends EventEmitter {
170
170
  // Get the highest UID we already have for this folder
171
171
  const highestUid = this.db.getHighestUid(accountId, folderId);
172
172
  let messages;
173
+ const firstSync = highestUid === 0;
173
174
  if (highestUid > 0) {
174
175
  // Incremental: only fetch messages newer than what we have
175
176
  const fetched = await client.fetchMessagesSinceUid(folder.path, highestUid, { source: true });
@@ -184,18 +185,47 @@ export class ImapManager extends EventEmitter {
184
185
  ? new Date(Date.now() - historyDays * 86400000)
185
186
  : new Date(0);
186
187
  messages = await client.fetchMessageByDate(folder.path, startDate, new Date(), { source: true });
188
+ // Sort newest first so most recent messages appear in the UI immediately
189
+ messages.sort((a, b) => {
190
+ const da = a.date instanceof Date ? a.date.getTime() : (typeof a.date === "number" ? a.date : 0);
191
+ const db = b.date instanceof Date ? b.date.getTime() : (typeof b.date === "number" ? b.date : 0);
192
+ return db - da;
193
+ });
187
194
  }
188
195
  if (messages.length > 0)
189
196
  console.log(` ${folder.path}: ${messages.length} new messages`);
190
197
  let newCount = 0;
191
198
  const batchSize = 50;
192
- this.db.beginTransaction();
193
- try {
194
- for (let i = 0; i < messages.length; i++) {
195
- const msg = messages[i];
196
- // Skip if we already have this UID
197
- if (msg.uid <= highestUid) {
198
- // But update flags in case they changed
199
+ for (let batchStart = 0; batchStart < messages.length; batchStart += batchSize) {
200
+ const batchEnd = Math.min(batchStart + batchSize, messages.length);
201
+ this.db.beginTransaction();
202
+ try {
203
+ for (let i = batchStart; i < batchEnd; i++) {
204
+ const msg = messages[i];
205
+ // Skip if we already have this UID
206
+ if (msg.uid <= highestUid) {
207
+ // But update flags in case they changed
208
+ const flags = [];
209
+ if (msg.seen)
210
+ flags.push("\\Seen");
211
+ if (msg.flagged)
212
+ flags.push("\\Flagged");
213
+ if (msg.answered)
214
+ flags.push("\\Answered");
215
+ if (msg.draft)
216
+ flags.push("\\Draft");
217
+ this.db.updateMessageFlags(accountId, msg.uid, flags);
218
+ continue;
219
+ }
220
+ // Store body
221
+ const source = msg.source || "";
222
+ let bodyPath = "";
223
+ if (source) {
224
+ bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
225
+ }
226
+ // Parse for preview and attachment info
227
+ const parsed = await extractPreview(source);
228
+ // Build flags array
199
229
  const flags = [];
200
230
  if (msg.seen)
201
231
  flags.push("\\Seen");
@@ -205,61 +235,49 @@ export class ImapManager extends EventEmitter {
205
235
  flags.push("\\Answered");
206
236
  if (msg.draft)
207
237
  flags.push("\\Draft");
208
- this.db.updateMessageFlags(accountId, msg.uid, flags);
209
- continue;
210
- }
211
- // Store body
212
- const source = msg.source || "";
213
- let bodyPath = "";
214
- if (source) {
215
- bodyPath = await this.bodyStore.putMessage(accountId, folderId, msg.uid, Buffer.from(source, "utf-8"));
238
+ // Store metadata
239
+ this.db.upsertMessage({
240
+ accountId,
241
+ folderId,
242
+ uid: msg.uid,
243
+ messageId: msg.messageId || "",
244
+ inReplyTo: "",
245
+ references: [],
246
+ date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
247
+ subject: msg.subject || "",
248
+ from: toEmailAddress(msg.from?.[0] || {}),
249
+ to: toEmailAddresses(msg.to || []),
250
+ cc: toEmailAddresses(msg.cc || []),
251
+ flags,
252
+ size: msg.size || 0,
253
+ hasAttachments: parsed.hasAttachments,
254
+ preview: parsed.preview,
255
+ bodyPath
256
+ });
257
+ newCount++;
216
258
  }
217
- // Parse for preview and attachment info
218
- const parsed = await extractPreview(source);
219
- // Build flags array
220
- const flags = [];
221
- if (msg.seen)
222
- flags.push("\\Seen");
223
- if (msg.flagged)
224
- flags.push("\\Flagged");
225
- if (msg.answered)
226
- flags.push("\\Answered");
227
- if (msg.draft)
228
- flags.push("\\Draft");
229
- // Store metadata
230
- this.db.upsertMessage({
231
- accountId,
232
- folderId,
233
- uid: msg.uid,
234
- messageId: msg.messageId || "",
235
- inReplyTo: "",
236
- references: [],
237
- date: msg.date instanceof Date ? msg.date.getTime() : (typeof msg.date === "number" ? msg.date : Date.now()),
238
- subject: msg.subject || "",
239
- from: toEmailAddress(msg.from?.[0] || {}),
240
- to: toEmailAddresses(msg.to || []),
241
- cc: toEmailAddresses(msg.cc || []),
242
- flags,
243
- size: msg.size || 0,
244
- hasAttachments: parsed.hasAttachments,
245
- preview: parsed.preview,
246
- bodyPath
259
+ this.db.commitTransaction();
260
+ }
261
+ catch (e) {
262
+ console.error(` transaction error: ${e.message}`);
263
+ this.db.rollbackTransaction();
264
+ throw e;
265
+ }
266
+ // Emit progress and notify client after each batch
267
+ this.emit("syncProgress", accountId, `sync:${folder.path}`, Math.round((batchEnd / messages.length) * 100));
268
+ // On first sync, emit folderCountsChanged per batch so newest messages appear immediately
269
+ if (firstSync && newCount > 0) {
270
+ const total = newCount;
271
+ const unread = this.db.getMessages({ accountId, folderId, page: 1, pageSize: total })
272
+ .items.filter((m) => !m.flags.includes("\\Seen")).length;
273
+ this.db.updateFolderCounts(folderId, total, unread);
274
+ this.emit("folderCountsChanged", accountId, {
275
+ [folderId]: { total, unread }
247
276
  });
248
- newCount++;
249
- // Emit progress periodically
250
- if (i % batchSize === 0) {
251
- this.emit("syncProgress", accountId, `sync:${folder.path}`, Math.round((i / messages.length) * 100));
252
- }
253
277
  }
254
- this.db.commitTransaction();
255
- if (newCount > 0)
256
- console.log(` stored ${newCount} new messages`);
257
- }
258
- catch (e) {
259
- console.error(` transaction error: ${e.message}`);
260
- this.db.rollbackTransaction();
261
- throw e;
262
278
  }
279
+ if (newCount > 0)
280
+ console.log(` stored ${newCount} new messages`);
263
281
  // Remove messages deleted on the server
264
282
  let deletedCount = 0;
265
283
  try {
@@ -308,10 +326,12 @@ export class ImapManager extends EventEmitter {
308
326
  }
309
327
  }
310
328
  async _syncAll() {
329
+ // Phase 1: Sync folder lists and inboxes for ALL accounts first
330
+ // so every account has content visible quickly
331
+ const accountFolders = new Map();
311
332
  for (const [accountId] of this.configs) {
312
333
  let client = null;
313
334
  try {
314
- // Fresh client for folder list (30s timeout)
315
335
  client = this.createClient(accountId);
316
336
  const folders = await Promise.race([
317
337
  this.syncFolders(accountId, client),
@@ -319,20 +339,14 @@ export class ImapManager extends EventEmitter {
319
339
  ]);
320
340
  await client.logout();
321
341
  client = null;
322
- // INBOX first so it's available fastest
323
- folders.sort((a, b) => {
324
- if (a.specialUse === "inbox")
325
- return -1;
326
- if (b.specialUse === "inbox")
327
- return 1;
328
- return 0;
329
- });
330
- // Fresh client per folder with 60s timeout — IMAP connections can hang
331
- for (const folder of folders) {
342
+ accountFolders.set(accountId, folders);
343
+ // Sync inbox immediately
344
+ const inbox = folders.find(f => f.specialUse === "inbox");
345
+ if (inbox) {
332
346
  try {
333
347
  client = this.createClient(accountId);
334
348
  await Promise.race([
335
- this.syncFolder(accountId, folder.id, client),
349
+ this.syncFolder(accountId, inbox.id, client),
336
350
  new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
337
351
  ]);
338
352
  await client.logout();
@@ -346,16 +360,9 @@ export class ImapManager extends EventEmitter {
346
360
  catch { /* ignore */ }
347
361
  client = null;
348
362
  }
349
- if (e.responseText?.includes("doesn't exist")) {
350
- console.log(` Removing non-existent folder: ${folder.path}`);
351
- this.db.deleteFolder(folder.id);
352
- }
353
- else {
354
- console.error(` Skipping folder ${folder.path}: ${e.message}`);
355
- }
363
+ console.error(` Inbox sync error for ${accountId}: ${e.message}`);
356
364
  }
357
365
  }
358
- this.emit("syncComplete", accountId);
359
366
  }
360
367
  catch (e) {
361
368
  this.emit("syncError", accountId, e.message);
@@ -369,6 +376,40 @@ export class ImapManager extends EventEmitter {
369
376
  catch { /* ignore */ }
370
377
  }
371
378
  }
379
+ // Phase 2: Sync remaining folders for all accounts
380
+ for (const [accountId, folders] of accountFolders) {
381
+ let client = null;
382
+ for (const folder of folders) {
383
+ if (folder.specialUse === "inbox")
384
+ continue; // already synced
385
+ try {
386
+ client = this.createClient(accountId);
387
+ await Promise.race([
388
+ this.syncFolder(accountId, folder.id, client),
389
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Sync timeout (60s)")), 60000))
390
+ ]);
391
+ await client.logout();
392
+ client = null;
393
+ }
394
+ catch (e) {
395
+ if (client) {
396
+ try {
397
+ await client.logout();
398
+ }
399
+ catch { /* ignore */ }
400
+ client = null;
401
+ }
402
+ if (e.responseText?.includes("doesn't exist")) {
403
+ console.log(` Removing non-existent folder: ${folder.path}`);
404
+ this.db.deleteFolder(folder.id);
405
+ }
406
+ else {
407
+ console.error(` Skipping folder ${folder.path}: ${e.message}`);
408
+ }
409
+ }
410
+ }
411
+ this.emit("syncComplete", accountId);
412
+ }
372
413
  }
373
414
  /** Sync just INBOX for each account (fast check for new mail) */
374
415
  async syncInbox() {
@@ -419,19 +460,59 @@ export class ImapManager extends EventEmitter {
419
460
  this.inboxSyncing = false;
420
461
  }
421
462
  }
463
+ /** Quick inbox check — uses IMAP STATUS (single command, no mailbox open).
464
+ * If message count changed, triggers a full inbox sync. */
465
+ lastInboxCounts = new Map();
466
+ async quickInboxCheck() {
467
+ for (const [accountId] of this.configs) {
468
+ let client = null;
469
+ try {
470
+ const inbox = this.db.getFolders(accountId).find(f => f.specialUse === "inbox");
471
+ if (!inbox)
472
+ continue;
473
+ client = this.createClient(accountId);
474
+ const count = await client.getMessagesCount("INBOX");
475
+ await client.logout();
476
+ client = null;
477
+ const prev = this.lastInboxCounts.get(accountId) ?? count;
478
+ this.lastInboxCounts.set(accountId, count);
479
+ if (count !== prev) {
480
+ console.log(` [check] ${accountId} INBOX: ${prev} → ${count}`);
481
+ // New mail detected — do a full inbox sync
482
+ client = this.createClient(accountId);
483
+ await this.syncFolder(accountId, inbox.id, client);
484
+ await client.logout();
485
+ client = null;
486
+ }
487
+ }
488
+ catch {
489
+ // Lightweight check — silently ignore errors (full sync will catch up)
490
+ }
491
+ finally {
492
+ if (client)
493
+ try {
494
+ await client.logout();
495
+ }
496
+ catch { /* ignore */ }
497
+ }
498
+ }
499
+ }
422
500
  /** Start periodic sync */
423
501
  startPeriodicSync(intervalMinutes) {
424
502
  this.stopPeriodicSync();
425
- // INBOX poll + sync actions (IDLE handles instant, this catches gaps)
426
- const inboxInterval = setInterval(async () => {
427
- // Process pending local→IMAP sync actions (sends + flags/deletes/moves)
503
+ // Quick inbox check every 3 seconds lightweight STATUS command
504
+ const quickCheck = setInterval(() => {
505
+ this.quickInboxCheck().catch(() => { });
506
+ }, 3000);
507
+ this.syncIntervals.set("quick", quickCheck);
508
+ // Sync actions (sends + flags/deletes/moves) every 30 seconds
509
+ const actionsInterval = setInterval(async () => {
428
510
  for (const [accountId] of this.configs) {
429
511
  this.processSendActions(accountId).catch(() => { });
430
512
  this.processSyncActions(accountId).catch(() => { });
431
513
  }
432
- this.syncInbox().catch(e => console.error(` [inbox] error: ${e.message}`));
433
514
  }, 30000);
434
- this.syncIntervals.set("inbox", inboxInterval);
515
+ this.syncIntervals.set("actions", actionsInterval);
435
516
  // Full sync (all folders + IDLE restart) at configured interval
436
517
  const fullInterval = setInterval(async () => {
437
518
  console.log(` [periodic] Full sync at ${new Date().toLocaleTimeString()}`);
@@ -32,6 +32,7 @@ export declare function getStorageInfo(): {
32
32
  declare const DEFAULT_PREFERENCES: {
33
33
  ui: {
34
34
  theme: "system" | "dark" | "light";
35
+ editor: "quill" | "tiptap";
35
36
  folderWidth: number;
36
37
  listViewerSplit: number;
37
38
  fontSize: number;
@@ -310,6 +310,7 @@ const DEFAULT_ACCOUNTS = [];
310
310
  const DEFAULT_PREFERENCES = {
311
311
  ui: {
312
312
  theme: "system",
313
+ editor: "quill",
313
314
  folderWidth: 220,
314
315
  listViewerSplit: 40,
315
316
  fontSize: 15,
@@ -177,6 +177,7 @@ export interface MailxSettings {
177
177
  accounts: AccountConfig[];
178
178
  ui: {
179
179
  theme: "system" | "dark" | "light";
180
+ editor: "quill" | "tiptap";
180
181
  folderWidth: number;
181
182
  listViewerSplit: number; /** Percentage for message list height */
182
183
  fontSize: number;