@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 +103 -41
- package/client/compose/compose.css +66 -0
- package/client/compose/compose.html +1 -2
- package/client/compose/compose.js +59 -21
- package/client/compose/editor.js +160 -0
- package/client/index.html +8 -0
- package/client/styles/components.css +2 -0
- package/package.json +1 -1
- package/packages/mailx-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +164 -83
- package/packages/mailx-settings/index.d.ts +1 -0
- package/packages/mailx-settings/index.js +1 -0
- package/packages/mailx-types/index.d.ts +1 -0
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
|
|
294
|
-
const
|
|
295
|
-
if
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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("
|
|
334
|
+
if (statusSync?.textContent?.includes("undo"))
|
|
311
335
|
statusSync.textContent = "";
|
|
312
336
|
}, 30000);
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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",
|
|
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 = `
|
|
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
|
-
//
|
|
530
|
-
|
|
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+
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
//
|
|
21
|
-
|
|
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.
|
|
218
|
-
editor.
|
|
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.
|
|
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.
|
|
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.
|
|
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">•</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">“</button>
|
|
66
|
+
<button class="tt-btn" data-cmd="link" title="Link" tabindex="-1">🔗</button>
|
|
67
|
+
<button class="tt-btn" data-cmd="clearFormat" title="Clear formatting" tabindex="-1">⌧</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
|
@@ -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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
426
|
-
const
|
|
427
|
-
|
|
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("
|
|
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()}`);
|
|
@@ -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;
|