@bobfrankston/mailx 1.0.327 → 1.0.336
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/bin/mailx.js +20 -8
- package/client/android.html +1 -0
- package/client/app.js +29 -2
- package/client/compose/compose.js +39 -0
- package/client/index.html +1 -0
- package/client/styles/components.css +32 -3
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +18 -3
- package/packages/mailx-imap/index.js +135 -54
- package/packages/mailx-service/index.d.ts +3 -0
- package/packages/mailx-service/index.js +110 -24
- package/packages/mailx-settings/index.js +15 -3
package/bin/mailx.js
CHANGED
|
@@ -964,7 +964,13 @@ async function main() {
|
|
|
964
964
|
// version-mismatch auto-upgrade actually transparent to the user.
|
|
965
965
|
writeInstanceFile(process.pid);
|
|
966
966
|
const __cleanupInstance = () => {
|
|
967
|
-
|
|
967
|
+
// Only clear if WE are still the registered instance. Prevents the
|
|
968
|
+
// restart-daemon sequence (clear → spawn → new daemon writes its
|
|
969
|
+
// own entry → we exit) from deleting the replacement's claim on
|
|
970
|
+
// the way out.
|
|
971
|
+
const inst = readInstanceFile();
|
|
972
|
+
if (inst && inst.pid === process.pid)
|
|
973
|
+
clearInstanceFile();
|
|
968
974
|
try {
|
|
969
975
|
handle.close();
|
|
970
976
|
}
|
|
@@ -1004,20 +1010,26 @@ async function main() {
|
|
|
1004
1010
|
handle.send({ _cbid: req._cbid, result: { ok: true } });
|
|
1005
1011
|
return;
|
|
1006
1012
|
}
|
|
1007
|
-
// Restart the daemon in-place without npm install.
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1013
|
+
// Restart the daemon in-place without npm install. Subtle: the new
|
|
1014
|
+
// mailx's startup-time instance check sees the instance.json we
|
|
1015
|
+
// wrote and bails with "already running" if versions match —
|
|
1016
|
+
// skipping the new process entirely. Clear the instance file
|
|
1017
|
+
// FIRST so the replacement can claim the slot, THEN spawn, THEN
|
|
1018
|
+
// gracefully shut this process down. The exit handler guards
|
|
1019
|
+
// against clobbering the replacement's entry (see __cleanupInstance
|
|
1020
|
+
// below — only clears if instance.json's PID still matches ours).
|
|
1014
1021
|
if (req._action === "restartDaemon") {
|
|
1015
1022
|
handle.send({ _cbid: req._cbid, ok: true, status: "restarting" });
|
|
1016
1023
|
try {
|
|
1024
|
+
clearInstanceFile();
|
|
1017
1025
|
const { spawn: spawnChild } = await import("child_process");
|
|
1018
1026
|
const child = spawnChild("mailx", [], { detached: true, stdio: "ignore", shell: true });
|
|
1019
1027
|
child.unref();
|
|
1020
1028
|
console.log(" [restart] Spawned fresh daemon; shutting down current");
|
|
1029
|
+
// Give the spawn a moment to take hold before we start
|
|
1030
|
+
// tearing things down — otherwise IMAP disconnects could
|
|
1031
|
+
// race with the new process's startup handshake.
|
|
1032
|
+
await new Promise(r => setTimeout(r, 800));
|
|
1021
1033
|
}
|
|
1022
1034
|
catch (e) {
|
|
1023
1035
|
console.error(` [restart] Spawn failed: ${e.message}`);
|
package/client/android.html
CHANGED
|
@@ -150,6 +150,7 @@
|
|
|
150
150
|
<button class="tb-btn" id="btn-delete" title="Delete">🗑</button>
|
|
151
151
|
<button class="tb-btn" id="btn-spam" title="Mark as spam" hidden>⚠</button>
|
|
152
152
|
<button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
|
|
153
|
+
<button class="tb-btn" id="btn-mark-unread" title="Mark unread">◉</button>
|
|
153
154
|
<span style="flex:1"></span>
|
|
154
155
|
<button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
|
|
155
156
|
<a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
|
package/client/app.js
CHANGED
|
@@ -175,7 +175,11 @@ alertDismiss?.addEventListener("click", hideAlert);
|
|
|
175
175
|
function showRestartForConfigBanner() {
|
|
176
176
|
if (!alertBanner || !alertText)
|
|
177
177
|
return;
|
|
178
|
-
|
|
178
|
+
// Timestamp in the banner so repeated / spurious fires are visually
|
|
179
|
+
// distinguishable (and the user can see when the change actually
|
|
180
|
+
// happened, useful for debugging false triggers).
|
|
181
|
+
const ts = new Date().toLocaleTimeString([], { hour12: false });
|
|
182
|
+
alertText.textContent = `[${ts}] accounts.jsonc changed — restart to apply.`;
|
|
179
183
|
alertBanner.hidden = false;
|
|
180
184
|
alertBanner.dataset.key = "config-restart";
|
|
181
185
|
// Avoid duplicate buttons across repeat changes.
|
|
@@ -899,6 +903,25 @@ async function refreshSpamButtonVisibility() {
|
|
|
899
903
|
document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
|
|
900
904
|
document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
|
|
901
905
|
document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
|
|
906
|
+
document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
|
|
907
|
+
// Toggle \Seen on the currently-selected message. Mirrors the R
|
|
908
|
+
// keyboard shortcut and the right-click "Mark unread" menu item, but
|
|
909
|
+
// as a visible toolbar button so users discover the behavior.
|
|
910
|
+
const sel = messageState.getSelected();
|
|
911
|
+
if (!sel)
|
|
912
|
+
return;
|
|
913
|
+
const isSeen = sel.flags.includes("\\Seen");
|
|
914
|
+
const newFlags = isSeen
|
|
915
|
+
? sel.flags.filter((f) => f !== "\\Seen")
|
|
916
|
+
: [...sel.flags, "\\Seen"];
|
|
917
|
+
updateFlags(sel.accountId, sel.uid, newFlags).then(() => {
|
|
918
|
+
sel.flags = newFlags;
|
|
919
|
+
messageState.updateMessageFlags(sel.accountId, sel.uid, newFlags);
|
|
920
|
+
const row = document.querySelector(`.ml-row[data-uid="${sel.uid}"][data-account-id="${sel.accountId}"]`);
|
|
921
|
+
if (row)
|
|
922
|
+
row.classList.toggle("unread", !newFlags.includes("\\Seen"));
|
|
923
|
+
}).catch(() => { });
|
|
924
|
+
});
|
|
902
925
|
document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
|
|
903
926
|
document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
|
|
904
927
|
document.getElementById("btn-forward")?.addEventListener("click", () => openCompose("forward"));
|
|
@@ -1589,7 +1612,10 @@ async function openJsoncEditor(initialFile) {
|
|
|
1589
1612
|
const panel = document.createElement("div");
|
|
1590
1613
|
panel.className = "mailx-modal mailx-modal-wide";
|
|
1591
1614
|
panel.innerHTML = `
|
|
1592
|
-
<div class="mailx-modal-title">
|
|
1615
|
+
<div class="mailx-modal-title">
|
|
1616
|
+
<span class="mailx-modal-title-text">Edit config file</span>
|
|
1617
|
+
<button type="button" class="mailx-modal-close" id="jsonc-close" title="Close (Esc)" aria-label="Close">×</button>
|
|
1618
|
+
</div>
|
|
1593
1619
|
<label class="mailx-modal-label">File
|
|
1594
1620
|
<select class="mailx-modal-input" id="jsonc-file">
|
|
1595
1621
|
<option value="accounts.jsonc">accounts.jsonc — accounts (shared via Google Drive)</option>
|
|
@@ -1701,6 +1727,7 @@ async function openJsoncEditor(initialFile) {
|
|
|
1701
1727
|
}
|
|
1702
1728
|
};
|
|
1703
1729
|
document.addEventListener("keydown", onKey, true);
|
|
1730
|
+
panel.querySelector("#jsonc-close").addEventListener("click", close);
|
|
1704
1731
|
panel.querySelectorAll(".mailx-modal-btn").forEach(btn => {
|
|
1705
1732
|
btn.addEventListener("click", async () => {
|
|
1706
1733
|
const action = btn.dataset.action;
|
|
@@ -80,6 +80,45 @@ await loadEditorAssets(editorType);
|
|
|
80
80
|
const container = document.getElementById("compose-editor");
|
|
81
81
|
container.classList.add(editorType === "tiptap" ? "editor-tiptap" : "editor-quill");
|
|
82
82
|
const editor = await createEditor(container, editorType);
|
|
83
|
+
// Ctrl+scroll / Ctrl+= / Ctrl+- / Ctrl+0 zoom for the compose editor body.
|
|
84
|
+
// Persists per-session in localStorage so zoom survives window pop/close cycles.
|
|
85
|
+
(() => {
|
|
86
|
+
const STORAGE_KEY = "mailx.compose.zoom";
|
|
87
|
+
const MIN = 0.5, MAX = 3, STEP = 0.1;
|
|
88
|
+
let zoom = parseFloat(localStorage.getItem(STORAGE_KEY) || "1") || 1;
|
|
89
|
+
const applyZoom = () => {
|
|
90
|
+
container.style.fontSize = `${zoom}em`;
|
|
91
|
+
localStorage.setItem(STORAGE_KEY, String(zoom));
|
|
92
|
+
};
|
|
93
|
+
applyZoom();
|
|
94
|
+
container.addEventListener("wheel", (e) => {
|
|
95
|
+
if (!e.ctrlKey)
|
|
96
|
+
return;
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
const delta = e.deltaY < 0 ? STEP : -STEP;
|
|
99
|
+
zoom = Math.min(MAX, Math.max(MIN, Math.round((zoom + delta) * 10) / 10));
|
|
100
|
+
applyZoom();
|
|
101
|
+
}, { passive: false });
|
|
102
|
+
document.addEventListener("keydown", (e) => {
|
|
103
|
+
if (!(e.ctrlKey || e.metaKey))
|
|
104
|
+
return;
|
|
105
|
+
if (e.key === "=" || e.key === "+") {
|
|
106
|
+
zoom = Math.min(MAX, zoom + STEP);
|
|
107
|
+
applyZoom();
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
}
|
|
110
|
+
else if (e.key === "-") {
|
|
111
|
+
zoom = Math.max(MIN, zoom - STEP);
|
|
112
|
+
applyZoom();
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
}
|
|
115
|
+
else if (e.key === "0") {
|
|
116
|
+
zoom = 1;
|
|
117
|
+
applyZoom();
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
})();
|
|
83
122
|
// ── Populate from init data ──
|
|
84
123
|
// From field is a free-text input with a <datalist> of known accounts. The
|
|
85
124
|
// user can pick a preset or type an arbitrary "Name <addr@domain>" — no
|
package/client/index.html
CHANGED
|
@@ -130,6 +130,7 @@
|
|
|
130
130
|
<button class="tb-btn" id="btn-delete" title="Delete (Del)">🗑</button>
|
|
131
131
|
<button class="tb-btn" id="btn-spam" title="Mark as spam — move to configured spam folder" hidden>⚠</button>
|
|
132
132
|
<button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
|
|
133
|
+
<button class="tb-btn" id="btn-mark-unread" title="Mark unread (R)">◉</button>
|
|
133
134
|
<span style="flex:1"></span>
|
|
134
135
|
<button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
|
|
135
136
|
<a class="mv-unsubscribe" id="mv-unsubscribe" hidden>Unsubscribe</a>
|
|
@@ -544,12 +544,39 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
544
544
|
}
|
|
545
545
|
.mailx-modal-wide {
|
|
546
546
|
width: 80vw;
|
|
547
|
-
max-width:
|
|
548
|
-
max-height:
|
|
547
|
+
max-width: 95vw;
|
|
548
|
+
max-height: 95vh;
|
|
549
|
+
height: 85vh;
|
|
550
|
+
resize: both;
|
|
551
|
+
overflow: auto;
|
|
552
|
+
min-width: 480px;
|
|
553
|
+
min-height: 320px;
|
|
549
554
|
}
|
|
550
555
|
.mailx-modal-title {
|
|
551
556
|
font-size: var(--font-size-lg);
|
|
552
557
|
font-weight: 600;
|
|
558
|
+
display: flex;
|
|
559
|
+
align-items: center;
|
|
560
|
+
gap: var(--gap-sm);
|
|
561
|
+
}
|
|
562
|
+
.mailx-modal-title-text {
|
|
563
|
+
flex: 1;
|
|
564
|
+
}
|
|
565
|
+
.mailx-modal-close {
|
|
566
|
+
appearance: none;
|
|
567
|
+
background: transparent;
|
|
568
|
+
border: none;
|
|
569
|
+
color: var(--color-text-muted);
|
|
570
|
+
font-size: 20px;
|
|
571
|
+
line-height: 1;
|
|
572
|
+
cursor: pointer;
|
|
573
|
+
padding: 2px 8px;
|
|
574
|
+
border-radius: var(--radius-sm);
|
|
575
|
+
|
|
576
|
+
&:hover {
|
|
577
|
+
background: var(--color-bg-surface);
|
|
578
|
+
color: var(--color-text);
|
|
579
|
+
}
|
|
553
580
|
}
|
|
554
581
|
.mailx-modal-label {
|
|
555
582
|
display: flex;
|
|
@@ -568,7 +595,8 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
568
595
|
font-size: var(--font-size-base);
|
|
569
596
|
}
|
|
570
597
|
.mailx-modal-textarea {
|
|
571
|
-
|
|
598
|
+
flex: 1;
|
|
599
|
+
min-height: 200px;
|
|
572
600
|
resize: vertical;
|
|
573
601
|
font-family: var(--font-mono);
|
|
574
602
|
font-size: 13px;
|
|
@@ -594,6 +622,7 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
594
622
|
display: grid;
|
|
595
623
|
grid-template-columns: minmax(0, 1fr) minmax(240px, 360px);
|
|
596
624
|
gap: var(--gap-md);
|
|
625
|
+
flex: 1;
|
|
597
626
|
min-height: 0;
|
|
598
627
|
|
|
599
628
|
&:has(.mailx-help-collapsed) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.336",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
40
40
|
"@bobfrankston/node-tcp-transport": "^0.1.4",
|
|
41
41
|
"@bobfrankston/smtp-direct": "^0.1.4",
|
|
42
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
42
|
+
"@bobfrankston/mailx-sync": "^0.1.8"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/mailparser": "^3.4.6"
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
104
104
|
"@bobfrankston/node-tcp-transport": "^0.1.4",
|
|
105
105
|
"@bobfrankston/smtp-direct": "^0.1.4",
|
|
106
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
106
|
+
"@bobfrankston/mailx-sync": "^0.1.8"
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
@@ -53,6 +53,10 @@ export declare class ImapManager extends EventEmitter {
|
|
|
53
53
|
deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
|
|
54
54
|
/** Search messages on the IMAP server — returns matching UIDs */
|
|
55
55
|
searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
|
|
56
|
+
/** Server-side search that also materializes any UIDs we don't yet have
|
|
57
|
+
* locally. Returns the full result after upsert, so the caller can
|
|
58
|
+
* render hits that fall outside the history window. */
|
|
59
|
+
searchAndFetchOnServer(accountId: string, folderId: number, mailboxPath: string, criteria: any): Promise<number[]>;
|
|
56
60
|
/** Create a fresh IMAP client for an account (public access for API endpoints) */
|
|
57
61
|
createPublicClient(accountId: string): any;
|
|
58
62
|
/** Persistent operational connections — one per account, reused for all operations */
|
|
@@ -183,7 +187,11 @@ export declare class ImapManager extends EventEmitter {
|
|
|
183
187
|
uid: number;
|
|
184
188
|
folderId: number;
|
|
185
189
|
}[]): Promise<void>;
|
|
186
|
-
/** Bulk move messages —
|
|
190
|
+
/** Bulk move messages — queues the IMAP action only. The service layer
|
|
191
|
+
* (MailxService.moveMessages) owns the local DB mutation via
|
|
192
|
+
* updateMessageFolder; this method used to ALSO deleteMessage here,
|
|
193
|
+
* which wiped the row the service just updated — the message vanished
|
|
194
|
+
* on the next reconcile and "spam folder empty" was the symptom. */
|
|
187
195
|
moveMessages(accountId: string, messages: {
|
|
188
196
|
uid: number;
|
|
189
197
|
folderId: number;
|
|
@@ -193,7 +201,8 @@ export declare class ImapManager extends EventEmitter {
|
|
|
193
201
|
private debounceSyncActions;
|
|
194
202
|
/** Move a message to Trash (delete) — local-first, queues IMAP sync */
|
|
195
203
|
trashMessage(accountId: string, folderId: number, uid: number): Promise<void>;
|
|
196
|
-
/** Move a message between folders —
|
|
204
|
+
/** Move a message between folders — queues IMAP sync only. Service
|
|
205
|
+
* layer owns the local DB update (see MailxService.moveMessage). */
|
|
197
206
|
moveMessage(accountId: string, uid: number, fromFolderId: number, toFolderId: number): Promise<void>;
|
|
198
207
|
/** Move message across accounts using iflow's moveMessageToServer */
|
|
199
208
|
moveMessageCrossAccount(fromAccountId: string, uid: number, fromFolderId: number, toAccountId: string, toFolderId: number): Promise<void>;
|
|
@@ -214,7 +223,13 @@ export declare class ImapManager extends EventEmitter {
|
|
|
214
223
|
* Tries the specific UID first, then falls back to searchByHeader so orphaned copies
|
|
215
224
|
* from earlier failed autosaves are cleaned up at the same time. */
|
|
216
225
|
deleteDraft(accountId: string, draftUid: number, draftId?: string): Promise<void>;
|
|
217
|
-
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP
|
|
226
|
+
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
|
|
227
|
+
* Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
|
|
228
|
+
* kick processLocalQueue. The file IS the queue — durable across crashes,
|
|
229
|
+
* visible in the filesystem, consumed by the existing outbox worker that
|
|
230
|
+
* handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
|
|
231
|
+
* sync_actions "send" branch was removed because it duplicated the same
|
|
232
|
+
* work and risked double-send when both paths fired on the same message. */
|
|
218
233
|
queueOutgoingLocal(accountId: string, rawMessage: string): void;
|
|
219
234
|
/** Guard against concurrent processSendActions for the same account */
|
|
220
235
|
private sendingAccounts;
|
|
@@ -241,6 +241,41 @@ export class ImapManager extends EventEmitter {
|
|
|
241
241
|
catch { /* ignore */ }
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
|
+
/** Server-side search that also materializes any UIDs we don't yet have
|
|
245
|
+
* locally. Returns the full result after upsert, so the caller can
|
|
246
|
+
* render hits that fall outside the history window. */
|
|
247
|
+
async searchAndFetchOnServer(accountId, folderId, mailboxPath, criteria) {
|
|
248
|
+
const client = this.createClient(accountId);
|
|
249
|
+
try {
|
|
250
|
+
const uids = await client.searchMessages(mailboxPath, criteria);
|
|
251
|
+
if (uids.length === 0)
|
|
252
|
+
return [];
|
|
253
|
+
const have = new Set(this.db.getUidsForFolder(accountId, folderId));
|
|
254
|
+
const missing = uids.filter(u => !have.has(u));
|
|
255
|
+
if (missing.length > 0) {
|
|
256
|
+
// Fetch in chunks so a large hit-set doesn't over-long a single command.
|
|
257
|
+
const folder = this.db.getFolders(accountId).find(f => f.id === folderId);
|
|
258
|
+
if (folder) {
|
|
259
|
+
const CHUNK = 500;
|
|
260
|
+
for (let i = 0; i < missing.length; i += CHUNK) {
|
|
261
|
+
const range = missing.slice(i, i + CHUNK).join(",");
|
|
262
|
+
const fetched = await client.fetchMessages(mailboxPath, range, { source: false });
|
|
263
|
+
if (fetched?.length) {
|
|
264
|
+
await this.storeMessages(accountId, folderId, folder, fetched, 0);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
this.db.recalcFolderCounts(folderId);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return uids;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
try {
|
|
274
|
+
await client.logout();
|
|
275
|
+
}
|
|
276
|
+
catch { /* ignore */ }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
244
279
|
/** Create a fresh IMAP client for an account (public access for API endpoints) */
|
|
245
280
|
createPublicClient(accountId) {
|
|
246
281
|
return this.createClient(accountId);
|
|
@@ -694,16 +729,11 @@ export class ImapManager extends EventEmitter {
|
|
|
694
729
|
let messages;
|
|
695
730
|
const firstSync = highestUid === 0;
|
|
696
731
|
const historyDays = getHistoryDays(accountId);
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
// scratch so the UI isn't empty for minutes.
|
|
702
|
-
const isGmail = this.isGmailAccount(accountId);
|
|
703
|
-
const MAX_IMAP_DAYS = 90;
|
|
732
|
+
// historyDays=0 means "all". On first sync we still cap at 30 days
|
|
733
|
+
// so the UI isn't empty for minutes while SEARCH SINCE 1970 runs
|
|
734
|
+
// through a years-old mailbox. Once we have any local messages, the
|
|
735
|
+
// backfill below extends the window in 90-day chunks per sync cycle.
|
|
704
736
|
let effectiveDays = historyDays;
|
|
705
|
-
if (historyDays === 0 && !isGmail)
|
|
706
|
-
effectiveDays = MAX_IMAP_DAYS;
|
|
707
737
|
if (effectiveDays === 0 && firstSync)
|
|
708
738
|
effectiveDays = 30;
|
|
709
739
|
const startDate = effectiveDays > 0
|
|
@@ -747,15 +777,25 @@ export class ImapManager extends EventEmitter {
|
|
|
747
777
|
console.error(` ${folder.path}: gap detection failed: ${e.message}`);
|
|
748
778
|
}
|
|
749
779
|
}
|
|
750
|
-
// Backfill: if
|
|
780
|
+
// Backfill: if the history window reaches further back than our
|
|
781
|
+
// oldest local message, fetch the gap. Chunk 90 days per sync
|
|
782
|
+
// cycle so historyDays=0 catches up incrementally instead of
|
|
783
|
+
// asking Dovecot for SEARCH SINCE 1970 in one go.
|
|
751
784
|
const oldestDate = this.db.getOldestDate(accountId, folderId);
|
|
752
785
|
if (oldestDate > 0 && startDate.getTime() < oldestDate) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
786
|
+
try {
|
|
787
|
+
const CHUNK_MS = 90 * 86400000;
|
|
788
|
+
const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
|
|
789
|
+
const existingUids = new Set(this.db.getUidsForFolder(accountId, folderId));
|
|
790
|
+
const backfill = await client.fetchMessageByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
|
|
791
|
+
const newBackfill = backfill.filter((m) => !existingUids.has(m.uid));
|
|
792
|
+
if (newBackfill.length > 0) {
|
|
793
|
+
console.log(` ${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0, 10)} → ${new Date(oldestDate).toISOString().slice(0, 10)})`);
|
|
794
|
+
messages.push(...newBackfill);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch (e) {
|
|
798
|
+
console.error(` ${folder.path}: backfill failed: ${e.message}`);
|
|
759
799
|
}
|
|
760
800
|
}
|
|
761
801
|
}
|
|
@@ -1120,9 +1160,13 @@ export class ImapManager extends EventEmitter {
|
|
|
1120
1160
|
// Incremental: fetch messages since last known UID.
|
|
1121
1161
|
// Gmail "UIDs" are hashed (not chronological), so fetchSince
|
|
1122
1162
|
// returns messages in hash order — they can be from ANY date.
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
|
|
1163
|
+
// Pass the date window so the provider can page the whole range
|
|
1164
|
+
// (otherwise Gmail's default 200-id cap truncates high-volume
|
|
1165
|
+
// inboxes to ~10 days regardless of historyDays).
|
|
1166
|
+
const fetchOpts = { source: false };
|
|
1167
|
+
if (effectiveDays > 0)
|
|
1168
|
+
fetchOpts.since = startDate;
|
|
1169
|
+
messages = await api.fetchSince(folder.path, highestUid, fetchOpts);
|
|
1126
1170
|
if (effectiveDays > 0) {
|
|
1127
1171
|
const cutoff = startDate.getTime();
|
|
1128
1172
|
const before = messages.length;
|
|
@@ -1131,6 +1175,29 @@ export class ImapManager extends EventEmitter {
|
|
|
1131
1175
|
console.log(` [api] ${accountId}/${folder.path}: filtered ${before - messages.length} messages older than ${effectiveDays}d`);
|
|
1132
1176
|
}
|
|
1133
1177
|
}
|
|
1178
|
+
// Backfill: if the history window reaches further back than our
|
|
1179
|
+
// oldest local message, fetch the gap. Mirrors the IMAP path —
|
|
1180
|
+
// otherwise a user who started with historyDays=30 and later
|
|
1181
|
+
// sets it to 0 (or 365) never actually sees older mail. Cap
|
|
1182
|
+
// each sync cycle at 90 days so unlimited-history accounts
|
|
1183
|
+
// catch up incrementally instead of paging the whole mailbox.
|
|
1184
|
+
const oldestDate = this.db.getOldestDate(accountId, folder.id);
|
|
1185
|
+
if (oldestDate > 0 && startDate.getTime() < oldestDate) {
|
|
1186
|
+
try {
|
|
1187
|
+
const CHUNK_MS = 90 * 86400000;
|
|
1188
|
+
const chunkStart = new Date(Math.max(startDate.getTime(), oldestDate - CHUNK_MS));
|
|
1189
|
+
const existingUids = new Set(this.db.getUidsForFolder(accountId, folder.id));
|
|
1190
|
+
const backfill = await api.fetchByDate(folder.path, chunkStart, new Date(oldestDate), { source: false });
|
|
1191
|
+
const newBackfill = backfill.filter(m => !existingUids.has(m.uid));
|
|
1192
|
+
if (newBackfill.length > 0) {
|
|
1193
|
+
console.log(` [api] ${accountId}/${folder.path}: backfilling ${newBackfill.length} older messages (${chunkStart.toISOString().slice(0, 10)} → ${new Date(oldestDate).toISOString().slice(0, 10)})`);
|
|
1194
|
+
messages.push(...newBackfill);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
catch (e) {
|
|
1198
|
+
console.error(` [api] ${accountId}/${folder.path}: backfill failed: ${e.message}`);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1134
1201
|
}
|
|
1135
1202
|
else {
|
|
1136
1203
|
// First sync: fetch by date range
|
|
@@ -1985,27 +2052,18 @@ export class ImapManager extends EventEmitter {
|
|
|
1985
2052
|
// Process all queued actions in one IMAP session
|
|
1986
2053
|
this.debounceSyncActions(accountId);
|
|
1987
2054
|
}
|
|
1988
|
-
/** Bulk move messages —
|
|
2055
|
+
/** Bulk move messages — queues the IMAP action only. The service layer
|
|
2056
|
+
* (MailxService.moveMessages) owns the local DB mutation via
|
|
2057
|
+
* updateMessageFolder; this method used to ALSO deleteMessage here,
|
|
2058
|
+
* which wiped the row the service just updated — the message vanished
|
|
2059
|
+
* on the next reconcile and "spam folder empty" was the symptom. */
|
|
1989
2060
|
async moveMessages(accountId, messages, targetFolderId) {
|
|
1990
2061
|
if (messages.length === 0)
|
|
1991
2062
|
return;
|
|
1992
|
-
// Local first
|
|
1993
|
-
for (const msg of messages) {
|
|
1994
|
-
this.db.deleteMessage(accountId, msg.uid);
|
|
1995
|
-
}
|
|
1996
|
-
console.log(` Moved ${messages.length} messages locally (→ folder ${targetFolderId})`);
|
|
1997
|
-
// Queue IMAP actions
|
|
1998
2063
|
for (const msg of messages) {
|
|
1999
2064
|
this.db.queueSyncAction(accountId, "move", msg.uid, msg.folderId, { targetFolderId });
|
|
2000
2065
|
}
|
|
2001
|
-
|
|
2002
|
-
// badge updates immediately.
|
|
2003
|
-
const sourceFolderIds = new Set(messages.map(m => m.folderId));
|
|
2004
|
-
for (const fid of sourceFolderIds)
|
|
2005
|
-
this.db.recalcFolderCounts(fid);
|
|
2006
|
-
this.db.recalcFolderCounts(targetFolderId);
|
|
2007
|
-
this.emit("folderCountsChanged", accountId, {});
|
|
2008
|
-
// Process all queued actions in one IMAP session
|
|
2066
|
+
console.log(` [move] ${accountId}: queued IMAP MOVE for ${messages.length} message(s) → folder ${targetFolderId}`);
|
|
2009
2067
|
this.debounceSyncActions(accountId);
|
|
2010
2068
|
}
|
|
2011
2069
|
/** Debounced sync actions — batches rapid local changes into one IMAP operation */
|
|
@@ -2025,25 +2083,25 @@ export class ImapManager extends EventEmitter {
|
|
|
2025
2083
|
// Local first — remove from DB immediately
|
|
2026
2084
|
this.db.deleteMessage(accountId, uid);
|
|
2027
2085
|
this.bodyStore.deleteMessage(accountId, folderId, uid).catch(() => { });
|
|
2028
|
-
|
|
2029
|
-
//
|
|
2086
|
+
// Queue IMAP action + log the resolution so "I deleted a message and
|
|
2087
|
+
// now it's in neither trash nor deleted" is diagnosable from the log.
|
|
2030
2088
|
if (trash && trash.id !== folderId) {
|
|
2089
|
+
const trashFolder = this.db.getFolders(accountId).find(f => f.id === trash.id);
|
|
2031
2090
|
this.db.queueSyncAction(accountId, "move", uid, folderId, { targetFolderId: trash.id });
|
|
2091
|
+
console.log(` [trash] ${accountId} UID ${uid}: queued MOVE to "${trashFolder?.path || trash.path}" (id=${trash.id}, specialUse=trash)`);
|
|
2032
2092
|
}
|
|
2033
2093
|
else {
|
|
2034
2094
|
this.db.queueSyncAction(accountId, "delete", uid, folderId);
|
|
2095
|
+
console.log(` [trash] ${accountId} UID ${uid}: queued EXPUNGE in folder ${folderId} (already in trash or no trash configured)`);
|
|
2035
2096
|
}
|
|
2036
2097
|
// Debounced sync — batches multiple deletes into one IMAP session
|
|
2037
2098
|
this.debounceSyncActions(accountId);
|
|
2038
2099
|
}
|
|
2039
|
-
/** Move a message between folders —
|
|
2100
|
+
/** Move a message between folders — queues IMAP sync only. Service
|
|
2101
|
+
* layer owns the local DB update (see MailxService.moveMessage). */
|
|
2040
2102
|
async moveMessage(accountId, uid, fromFolderId, toFolderId) {
|
|
2041
|
-
// Local first
|
|
2042
|
-
this.db.deleteMessage(accountId, uid);
|
|
2043
|
-
console.log(` Moved UID ${uid} locally (folder ${fromFolderId} → ${toFolderId})`);
|
|
2044
|
-
// Queue IMAP action
|
|
2045
2103
|
this.db.queueSyncAction(accountId, "move", uid, fromFolderId, { targetFolderId: toFolderId });
|
|
2046
|
-
|
|
2104
|
+
console.log(` [move] ${accountId}: queued IMAP MOVE UID ${uid} folder ${fromFolderId} → ${toFolderId}`);
|
|
2047
2105
|
this.debounceSyncActions(accountId);
|
|
2048
2106
|
}
|
|
2049
2107
|
/** Move message across accounts using iflow's moveMessageToServer */
|
|
@@ -2154,13 +2212,26 @@ export class ImapManager extends EventEmitter {
|
|
|
2154
2212
|
break;
|
|
2155
2213
|
case "move": {
|
|
2156
2214
|
const target = folders.find(f => f.id === action.targetFolderId);
|
|
2157
|
-
if (target) {
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
}
|
|
2215
|
+
if (!target) {
|
|
2216
|
+
// Target folder gone — treat as permanent failure so the
|
|
2217
|
+
// action doesn't loop forever. User must re-delete manually.
|
|
2218
|
+
console.error(` [sync] Move target folder ${action.targetFolderId} missing — dropping action UID ${action.uid}`);
|
|
2219
|
+
throw new Error(`move target folder ${action.targetFolderId} not found`);
|
|
2163
2220
|
}
|
|
2221
|
+
const msg = await client.fetchMessageByUid(folder.path, action.uid, { source: false });
|
|
2222
|
+
if (!msg) {
|
|
2223
|
+
// Message no longer in source folder. Two real cases:
|
|
2224
|
+
// (a) another client already moved/deleted it — nothing to do,
|
|
2225
|
+
// just mark the action done.
|
|
2226
|
+
// (b) the server is lying (transient SELECT miss) — the retry
|
|
2227
|
+
// will pick it up. We can't tell these apart from one fetch,
|
|
2228
|
+
// so log loud and treat as (a) after the first failure; the
|
|
2229
|
+
// attempts counter handles (b) via the failSyncAction path.
|
|
2230
|
+
console.log(` [sync] Move UID ${action.uid} in ${folder.path}: message gone (attempt ${action.attempts + 1}); dropping action`);
|
|
2231
|
+
break;
|
|
2232
|
+
}
|
|
2233
|
+
await client.moveMessage(msg, folder.path, target.path);
|
|
2234
|
+
console.log(` [sync] Moved UID ${action.uid}: ${folder.path} → ${target.path}`);
|
|
2164
2235
|
break;
|
|
2165
2236
|
}
|
|
2166
2237
|
case "flags":
|
|
@@ -2342,13 +2413,23 @@ export class ImapManager extends EventEmitter {
|
|
|
2342
2413
|
catch { /* ignore */ }
|
|
2343
2414
|
}
|
|
2344
2415
|
}
|
|
2345
|
-
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP
|
|
2416
|
+
/** Queue outgoing message locally — never fails, worker handles IMAP+SMTP.
|
|
2417
|
+
* Single path: write `~/.mailx/outbox/<acct>/*.ltr` synchronously, then
|
|
2418
|
+
* kick processLocalQueue. The file IS the queue — durable across crashes,
|
|
2419
|
+
* visible in the filesystem, consumed by the existing outbox worker that
|
|
2420
|
+
* handles both IMAP-APPEND (non-Gmail) and direct SMTP (Gmail). The old
|
|
2421
|
+
* sync_actions "send" branch was removed because it duplicated the same
|
|
2422
|
+
* work and risked double-send when both paths fired on the same message. */
|
|
2346
2423
|
queueOutgoingLocal(accountId, rawMessage) {
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2424
|
+
const outboxDir = path.join(getConfigDir(), "outbox", accountId);
|
|
2425
|
+
fs.mkdirSync(outboxDir, { recursive: true });
|
|
2426
|
+
const now = new Date();
|
|
2427
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
2428
|
+
const filename = `${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}_${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}-${String(Math.floor(Math.random() * 10000)).padStart(4, "0")}.ltr`;
|
|
2429
|
+
const filePath = path.join(outboxDir, filename);
|
|
2430
|
+
fs.writeFileSync(filePath, rawMessage);
|
|
2431
|
+
console.log(` [outbox] Queued ${filePath}`);
|
|
2432
|
+
this.processLocalQueue(accountId).catch((e) => console.error(` [outbox] processLocalQueue error: ${e?.message || e}`));
|
|
2352
2433
|
}
|
|
2353
2434
|
/** Guard against concurrent processSendActions for the same account */
|
|
2354
2435
|
sendingAccounts = new Set();
|
|
@@ -9,7 +9,10 @@ import type { Folder, AutocompleteRequest, AutocompleteResponse, AutocompleteSet
|
|
|
9
9
|
export declare class MailxService {
|
|
10
10
|
private db;
|
|
11
11
|
private imapManager;
|
|
12
|
+
private _accountsCache;
|
|
12
13
|
constructor(db: MailxDB, imapManager: ImapManager);
|
|
14
|
+
/** Return accounts from cache — load once, reuse until configChanged. */
|
|
15
|
+
private getCachedAccounts;
|
|
13
16
|
getAccounts(): any[];
|
|
14
17
|
getFolders(accountId: string): Folder[];
|
|
15
18
|
getUnifiedInbox(page?: number, pageSize?: number): any;
|
|
@@ -74,17 +74,32 @@ async function detectEmailProvider(domain) {
|
|
|
74
74
|
export class MailxService {
|
|
75
75
|
db;
|
|
76
76
|
imapManager;
|
|
77
|
+
// Cached accounts — loadSettings() reads from the cloud-mounted
|
|
78
|
+
// accounts.jsonc, which can stall on a flaky GDrive File Stream.
|
|
79
|
+
// Refresh on configChanged (fs.watch) so edits still land.
|
|
80
|
+
_accountsCache = null;
|
|
77
81
|
constructor(db, imapManager) {
|
|
78
82
|
this.db = db;
|
|
79
83
|
this.imapManager = imapManager;
|
|
84
|
+
// Invalidate account cache when accounts.jsonc changes on disk or GDrive.
|
|
85
|
+
this.imapManager.on?.("configChanged", (filename) => {
|
|
86
|
+
if (filename === "accounts.jsonc")
|
|
87
|
+
this._accountsCache = null;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Return accounts from cache — load once, reuse until configChanged. */
|
|
91
|
+
getCachedAccounts() {
|
|
92
|
+
if (!this._accountsCache)
|
|
93
|
+
this._accountsCache = loadAccounts();
|
|
94
|
+
return this._accountsCache;
|
|
80
95
|
}
|
|
81
96
|
// ── Accounts ──
|
|
82
97
|
getAccounts() {
|
|
83
98
|
const dbAccounts = this.db.getAccounts();
|
|
84
|
-
const
|
|
99
|
+
const cfgs = this.getCachedAccounts();
|
|
85
100
|
// Order by settings (accounts.jsonc is the source of truth for order)
|
|
86
101
|
const ordered = [];
|
|
87
|
-
for (const cfg of
|
|
102
|
+
for (const cfg of cfgs) {
|
|
88
103
|
const a = dbAccounts.find(d => d.id === cfg.id);
|
|
89
104
|
if (a)
|
|
90
105
|
ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [], spam: cfg.spam || "" });
|
|
@@ -270,11 +285,37 @@ export class MailxService {
|
|
|
270
285
|
async unsubscribeOneClick(url) {
|
|
271
286
|
if (!/^https:\/\//i.test(url))
|
|
272
287
|
throw new Error("one-click unsubscribe requires an https URL");
|
|
273
|
-
|
|
288
|
+
// RFC 8058 POST with List-Unsubscribe=One-Click body. A User-Agent
|
|
289
|
+
// header appeases servers that reject anonymous clients as "malformed".
|
|
290
|
+
const headers = {
|
|
291
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
292
|
+
"User-Agent": "mailx/1.0 (https://github.com/BobFrankston/mailx)",
|
|
293
|
+
};
|
|
294
|
+
let resp = await fetch(url, {
|
|
274
295
|
method: "POST",
|
|
275
|
-
headers
|
|
296
|
+
headers,
|
|
276
297
|
body: "List-Unsubscribe=One-Click",
|
|
298
|
+
redirect: "follow",
|
|
277
299
|
});
|
|
300
|
+
// Some mailers advertise List-Unsubscribe-Post but their endpoint
|
|
301
|
+
// actually only handles GET (older RFC 2369 style). Fall back once
|
|
302
|
+
// on 4xx so the user doesn't have to open the URL manually.
|
|
303
|
+
if (!resp.ok && resp.status >= 400 && resp.status < 500) {
|
|
304
|
+
const body = await resp.text().catch(() => "");
|
|
305
|
+
console.log(` [unsub] POST ${url} → ${resp.status} ${resp.statusText}; body: ${body.slice(0, 200)}`);
|
|
306
|
+
try {
|
|
307
|
+
const fallback = await fetch(url, { method: "GET", headers, redirect: "follow" });
|
|
308
|
+
if (fallback.ok) {
|
|
309
|
+
return { ok: true, status: fallback.status, statusText: `${fallback.statusText} (via GET)` };
|
|
310
|
+
}
|
|
311
|
+
const fbody = await fallback.text().catch(() => "");
|
|
312
|
+
console.log(` [unsub] GET ${url} → ${fallback.status} ${fallback.statusText}; body: ${fbody.slice(0, 200)}`);
|
|
313
|
+
// Surface the server's own error so the UI shows the real reason.
|
|
314
|
+
return { ok: false, status: fallback.status, statusText: (fbody.trim().split("\n")[0] || fallback.statusText).slice(0, 200) };
|
|
315
|
+
}
|
|
316
|
+
catch { /* fall through to POST error */ }
|
|
317
|
+
return { ok: false, status: resp.status, statusText: (body.trim().split("\n")[0] || resp.statusText).slice(0, 200) };
|
|
318
|
+
}
|
|
278
319
|
return { ok: resp.ok, status: resp.status, statusText: resp.statusText };
|
|
279
320
|
}
|
|
280
321
|
async updateFlags(accountId, uid, flags) {
|
|
@@ -301,11 +342,8 @@ export class MailxService {
|
|
|
301
342
|
async search(q, page = 1, pageSize = 50, scope = "all", accountId, folderId) {
|
|
302
343
|
if (!q.trim())
|
|
303
344
|
return { items: [], total: 0, page, pageSize };
|
|
304
|
-
if (scope === "server"
|
|
305
|
-
|
|
306
|
-
const folder = folderId ? folders.find(f => f.id === folderId) : folders.find(f => f.specialUse === "inbox");
|
|
307
|
-
if (!folder)
|
|
308
|
-
return { items: [], total: 0, page, pageSize };
|
|
345
|
+
if (scope === "server") {
|
|
346
|
+
// Parse qualifiers once; SEARCH runs per folder.
|
|
309
347
|
const criteria = {};
|
|
310
348
|
const fromMatch = q.match(/from:(\S+)/i);
|
|
311
349
|
const toMatch = q.match(/to:(\S+)/i);
|
|
@@ -319,11 +357,41 @@ export class MailxService {
|
|
|
319
357
|
criteria.subject = subjectMatch[1].trim();
|
|
320
358
|
if (bodyText)
|
|
321
359
|
criteria.body = bodyText;
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
360
|
+
// Server search spans every selectable folder on every enabled
|
|
361
|
+
// account — otherwise a message that got moved / was in Sent /
|
|
362
|
+
// only exists in an archive folder silently fails to turn up.
|
|
363
|
+
// Each folder runs as its own SEARCH; we dedupe by messageId.
|
|
364
|
+
const dbAccounts = accountId
|
|
365
|
+
? [{ id: accountId }]
|
|
366
|
+
: this.db.getAccounts();
|
|
367
|
+
const seen = new Set();
|
|
368
|
+
const items = [];
|
|
369
|
+
let total = 0;
|
|
370
|
+
for (const acct of dbAccounts) {
|
|
371
|
+
const folders = this.db.getFolders(acct.id)
|
|
372
|
+
.filter((f) => !(f.flags || []).some((x) => /noselect/i.test(x)));
|
|
373
|
+
const results = await Promise.allSettled(folders.map(f => this.imapManager.searchAndFetchOnServer(acct.id, f.id, f.path, criteria)
|
|
374
|
+
.then(uids => ({ folderId: f.id, uids }))));
|
|
375
|
+
for (const r of results) {
|
|
376
|
+
if (r.status !== "fulfilled")
|
|
377
|
+
continue;
|
|
378
|
+
for (const uid of r.value.uids) {
|
|
379
|
+
const msg = this.db.getMessageByUid(acct.id, uid, r.value.folderId);
|
|
380
|
+
if (!msg)
|
|
381
|
+
continue;
|
|
382
|
+
const key = msg.messageId || `${acct.id}:${r.value.folderId}:${uid}`;
|
|
383
|
+
if (seen.has(key))
|
|
384
|
+
continue;
|
|
385
|
+
seen.add(key);
|
|
386
|
+
items.push(msg);
|
|
387
|
+
total++;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Newest first, then paginate.
|
|
392
|
+
items.sort((a, b) => (b.date?.getTime?.() || 0) - (a.date?.getTime?.() || 0));
|
|
393
|
+
const sliced = items.slice((page - 1) * pageSize, page * pageSize);
|
|
394
|
+
return { items: sliced, total, page, pageSize };
|
|
327
395
|
}
|
|
328
396
|
else if (scope === "current" && accountId && folderId) {
|
|
329
397
|
return this.db.searchMessages(q, page, pageSize, accountId, folderId);
|
|
@@ -368,8 +436,17 @@ export class MailxService {
|
|
|
368
436
|
}
|
|
369
437
|
// ── Send ──
|
|
370
438
|
async send(msg) {
|
|
371
|
-
|
|
372
|
-
|
|
439
|
+
// Local-first: the critical path is validate → build raw → queue
|
|
440
|
+
// locally. Everything else (contacts recording, IMAP APPEND,
|
|
441
|
+
// SMTP) happens after the IPC ACK. Settings come from cache so
|
|
442
|
+
// a stalled GDrive mount doesn't block the send.
|
|
443
|
+
const accounts = this.getCachedAccounts();
|
|
444
|
+
let account = accounts.find(a => a.id === msg.from);
|
|
445
|
+
if (!account) {
|
|
446
|
+
// Cache miss — invalidate and try one authoritative read.
|
|
447
|
+
this._accountsCache = null;
|
|
448
|
+
account = this.getCachedAccounts().find(a => a.id === msg.from);
|
|
449
|
+
}
|
|
373
450
|
if (!account)
|
|
374
451
|
throw new Error(`Unknown account: ${msg.from}`);
|
|
375
452
|
// Vet every recipient address — refuse to send if any field contains a
|
|
@@ -456,14 +533,23 @@ export class MailxService {
|
|
|
456
533
|
}
|
|
457
534
|
this.imapManager.queueOutgoingLocal(account.id, rawMessage);
|
|
458
535
|
console.log(` Queued locally: ${msg.subject} via ${account.id} from ${fromHeader}`);
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
536
|
+
// Contacts recording is off the critical path — deferred until after
|
|
537
|
+
// the IPC ACK so a slow DB write can't stall the send.
|
|
538
|
+
setImmediate(() => {
|
|
539
|
+
try {
|
|
540
|
+
for (const addr of msg.to)
|
|
541
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
542
|
+
if (msg.cc)
|
|
543
|
+
for (const addr of msg.cc)
|
|
544
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
545
|
+
if (msg.bcc)
|
|
546
|
+
for (const addr of msg.bcc)
|
|
547
|
+
this.db.recordSentAddress(addr.name, addr.address);
|
|
548
|
+
}
|
|
549
|
+
catch (e) {
|
|
550
|
+
console.error(` recordSentAddress failed: ${e?.message || e}`);
|
|
551
|
+
}
|
|
552
|
+
});
|
|
467
553
|
}
|
|
468
554
|
// ── Delete / Move / Undelete ──
|
|
469
555
|
async deleteMessage(accountId, uid) {
|
|
@@ -440,11 +440,23 @@ export function loadAccounts() {
|
|
|
440
440
|
if (!accounts)
|
|
441
441
|
accounts = readJsonc(localPath);
|
|
442
442
|
if (accounts?.accounts || Array.isArray(accounts)) {
|
|
443
|
-
// Cache shared to local for offline fallback
|
|
443
|
+
// Cache shared to local for offline fallback — but ONLY if the
|
|
444
|
+
// content actually differs. Unconditionally writing on every load
|
|
445
|
+
// retriggers fs.watch on the local copy, which fires the config-
|
|
446
|
+
// changed banner and cloud-poll cycle even when nothing changed.
|
|
447
|
+
// Result: "accounts.jsonc changed" notification firing constantly.
|
|
444
448
|
if (sharedDir !== LOCAL_DIR && fs.existsSync(sharedPath)) {
|
|
445
449
|
try {
|
|
446
|
-
fs.
|
|
447
|
-
|
|
450
|
+
const sharedContent = fs.readFileSync(sharedPath, "utf-8");
|
|
451
|
+
let localContent = "";
|
|
452
|
+
try {
|
|
453
|
+
localContent = fs.readFileSync(localPath, "utf-8");
|
|
454
|
+
}
|
|
455
|
+
catch { /* missing */ }
|
|
456
|
+
if (sharedContent !== localContent) {
|
|
457
|
+
fs.mkdirSync(LOCAL_DIR, { recursive: true });
|
|
458
|
+
fs.writeFileSync(localPath, sharedContent);
|
|
459
|
+
}
|
|
448
460
|
}
|
|
449
461
|
catch { /* ignore */ }
|
|
450
462
|
}
|