@bobfrankston/mailx 1.0.327 → 1.0.333
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/index.html +1 -0
- package/client/styles/components.css +32 -3
- package/package.json +1 -1
- package/packages/mailx-imap/index.d.ts +4 -0
- package/packages/mailx-imap/index.js +86 -19
- 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;
|
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
|
@@ -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 */
|
|
@@ -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
|
|
@@ -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
|
}
|