@bobfrankston/mailx 1.0.437 → 1.0.439
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 +5 -0
- package/client/app.js +43 -0
- package/client/components/message-viewer.js +50 -3
- package/client/compose/compose.html +1 -0
- package/client/compose/compose.js +49 -1
- package/client/index.html +6 -0
- package/client/lib/api-client.js +9 -0
- package/client/lib/mailxapi.js +9 -0
- package/client/styles/components.css +23 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +79 -37
- package/packages/mailx-imap/index.js +356 -499
- package/packages/mailx-service/index.d.ts +64 -0
- package/packages/mailx-service/index.js +272 -4
- package/packages/mailx-service/jsonrpc.js +6 -0
- package/packages/mailx-settings/index.d.ts +2 -0
- package/packages/mailx-settings/index.js +10 -0
package/bin/mailx.js
CHANGED
|
@@ -1250,6 +1250,11 @@ RFC 5322 with CRLF line endings. Bodies are quoted-printable encoded (readable i
|
|
|
1250
1250
|
imapManager.on("syncActionFailed", (accountId, action, uid, error) => {
|
|
1251
1251
|
handle.send({ _event: "syncActionFailed", type: "syncActionFailed", accountId, action, uid, error });
|
|
1252
1252
|
});
|
|
1253
|
+
// External-edit (Word) save events. The service watches the temp file
|
|
1254
|
+
// and emits this when Word writes; compose.ts listens and reloads Quill.
|
|
1255
|
+
imapManager.on("wordEditUpdated", (payload) => {
|
|
1256
|
+
handle.send({ _event: "wordEditUpdated", type: "wordEditUpdated", ...payload });
|
|
1257
|
+
});
|
|
1253
1258
|
// Cloud-write/read failures from mailx-settings → push to UI as a banner so
|
|
1254
1259
|
// silent fall-back-to-local can no longer swallow Drive errors.
|
|
1255
1260
|
const { onCloudError } = await import("@bobfrankston/mailx-settings");
|
package/client/app.js
CHANGED
|
@@ -2813,6 +2813,34 @@ optEditorTiptap?.addEventListener("change", () => {
|
|
|
2813
2813
|
if (optEditorTiptap.checked)
|
|
2814
2814
|
saveEditorSetting("tiptap");
|
|
2815
2815
|
});
|
|
2816
|
+
// External editor preference (Edit-in-Word handoff target). Stored under
|
|
2817
|
+
// settings.externalEditor so the service can read it via loadSettings().
|
|
2818
|
+
// "auto" tries Word → LibreOffice → OS default; explicit values force
|
|
2819
|
+
// that editor (still falling back to OS default if it isn't installed).
|
|
2820
|
+
const optExtEditAuto = document.getElementById("opt-extedit-auto");
|
|
2821
|
+
const optExtEditWord = document.getElementById("opt-extedit-word");
|
|
2822
|
+
const optExtEditLibre = document.getElementById("opt-extedit-libre");
|
|
2823
|
+
getSettings().then((s) => {
|
|
2824
|
+
const v = s.externalEditor || "auto";
|
|
2825
|
+
if (optExtEditAuto)
|
|
2826
|
+
optExtEditAuto.checked = v === "auto";
|
|
2827
|
+
if (optExtEditWord)
|
|
2828
|
+
optExtEditWord.checked = v === "word";
|
|
2829
|
+
if (optExtEditLibre)
|
|
2830
|
+
optExtEditLibre.checked = v === "libreoffice";
|
|
2831
|
+
}).catch(() => { });
|
|
2832
|
+
function saveExtEditor(v) {
|
|
2833
|
+
getSettings().then((settings) => {
|
|
2834
|
+
settings.externalEditor = v;
|
|
2835
|
+
saveSettings(settings);
|
|
2836
|
+
}).catch(() => { });
|
|
2837
|
+
}
|
|
2838
|
+
optExtEditAuto?.addEventListener("change", () => { if (optExtEditAuto.checked)
|
|
2839
|
+
saveExtEditor("auto"); });
|
|
2840
|
+
optExtEditWord?.addEventListener("change", () => { if (optExtEditWord.checked)
|
|
2841
|
+
saveExtEditor("word"); });
|
|
2842
|
+
optExtEditLibre?.addEventListener("change", () => { if (optExtEditLibre.checked)
|
|
2843
|
+
saveExtEditor("libreoffice"); });
|
|
2816
2844
|
// ── AI feature toggles ──
|
|
2817
2845
|
// One umbrella settings record (AutocompleteSettings) holds the provider config
|
|
2818
2846
|
// + per-feature on/off flags. All features default OFF — user must opt into
|
|
@@ -2845,6 +2873,21 @@ optAiProofread?.addEventListener("change", () => {
|
|
|
2845
2873
|
}
|
|
2846
2874
|
catch { /* */ }
|
|
2847
2875
|
});
|
|
2876
|
+
// Sender reputation check (Spamhaus DBL). Stored at top-level settings so
|
|
2877
|
+
// the service can read it cheaply without going through autocomplete config.
|
|
2878
|
+
// Off by default — enabling it leaks read-recipient domains to Spamhaus's
|
|
2879
|
+
// DNS infra, which the user should opt into knowingly.
|
|
2880
|
+
const optCheckReputation = document.getElementById("opt-check-reputation");
|
|
2881
|
+
getSettings().then((s) => {
|
|
2882
|
+
if (optCheckReputation)
|
|
2883
|
+
optCheckReputation.checked = !!s.checkDomainReputation;
|
|
2884
|
+
}).catch(() => { });
|
|
2885
|
+
optCheckReputation?.addEventListener("change", () => {
|
|
2886
|
+
getSettings().then((settings) => {
|
|
2887
|
+
settings.checkDomainReputation = !!optCheckReputation.checked;
|
|
2888
|
+
saveSettings(settings);
|
|
2889
|
+
}).catch(() => { });
|
|
2890
|
+
});
|
|
2848
2891
|
const isApp = typeof mailxapi !== "undefined" && mailxapi?.isApp;
|
|
2849
2892
|
// Wait for server ready signal, then fetch version
|
|
2850
2893
|
const versionPromise = getVersion();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Message viewer component -- displays full message in sandboxed iframe.
|
|
3
3
|
* Subscribes to message-state: clears when selected becomes null.
|
|
4
4
|
*/
|
|
5
|
-
import { getMessage, updateFlags, allowRemoteContent, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick } from "../lib/api-client.js";
|
|
5
|
+
import { getMessage, updateFlags, allowRemoteContent, flagSenderOrDomain, getAttachment, addContact, listContacts, upsertContact, unsubscribeOneClick } from "../lib/api-client.js";
|
|
6
6
|
import { showContextMenu } from "./context-menu.js";
|
|
7
7
|
import * as state from "../lib/message-state.js";
|
|
8
8
|
/** Currently displayed message (for reply/forward) */
|
|
@@ -547,10 +547,24 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
547
547
|
const deliveredTo = msg.deliveredTo || "";
|
|
548
548
|
const toAddr = msg.to?.[0]?.address || "";
|
|
549
549
|
const returnPath = msg.returnPath || "";
|
|
550
|
+
const isFlagged = !!msg.isFlagged;
|
|
551
|
+
const reputation = msg.reputation;
|
|
552
|
+
const reputationFlagged = !!(reputation && reputation.flagged);
|
|
553
|
+
const reputationText = reputationFlagged
|
|
554
|
+
? `⚠ ${reputation.listedCount} of ${reputation.checkedCount} reputation services flag <strong>${escapeText(senderDomain)}</strong> as <strong>${escapeText(reputation.verdict)}</strong> (${escapeText(reputation.sources.map(s => s.service).join(", "))})`
|
|
555
|
+
: "";
|
|
550
556
|
const banner = document.createElement("div");
|
|
551
|
-
banner.className = "mv-remote-banner"
|
|
557
|
+
banner.className = "mv-remote-banner"
|
|
558
|
+
+ (isFlagged ? " mv-remote-banner-flagged" : "")
|
|
559
|
+
+ (reputationFlagged ? " mv-remote-banner-reputation" : "");
|
|
552
560
|
banner.innerHTML =
|
|
553
|
-
|
|
561
|
+
(isFlagged
|
|
562
|
+
? `<div class="mv-rb-flagged">⚠ FLAGGED: this sender or domain is on your flagged list</div>`
|
|
563
|
+
: "") +
|
|
564
|
+
(reputationFlagged
|
|
565
|
+
? `<div class="mv-rb-reputation">${reputationText}</div>`
|
|
566
|
+
: "") +
|
|
567
|
+
`<div class="mv-rb-summary">` +
|
|
554
568
|
`<span class="mv-rb-toggle">▸</span>` +
|
|
555
569
|
`<span>Remote content blocked</span>` +
|
|
556
570
|
`<span class="mv-rb-buttons">` +
|
|
@@ -567,6 +581,10 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
567
581
|
(returnPath && returnPath !== senderAddr ? `<div><span class="mv-rb-label">Return-Path:</span> ${escapeText(returnPath)}</div>` : "") +
|
|
568
582
|
`</div>` +
|
|
569
583
|
(deliveredTo || toAddr ? `<div class="mv-rb-actions"><button id="btn-allow-to">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : "") +
|
|
584
|
+
`<div class="mv-rb-actions">` +
|
|
585
|
+
`<button id="btn-flag-sender" class="mv-rb-flag-btn" title="${escapeText(senderAddr)}">${isFlagged ? "Unflag" : "Flag"} sender</button>` +
|
|
586
|
+
(senderDomain ? `<button id="btn-flag-domain" class="mv-rb-flag-btn" title="*@${escapeText(senderDomain)}">Flag domain *@${escapeText(senderDomain)}</button>` : "") +
|
|
587
|
+
`</div>` +
|
|
570
588
|
`<div class="mv-rb-actions"><button id="btn-edit-allowlist" title="View / edit the full allowlist">Edit allowlist…</button></div>` +
|
|
571
589
|
`</div>`;
|
|
572
590
|
bodyEl.appendChild(banner);
|
|
@@ -609,6 +627,35 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
609
627
|
await allowRemoteContent("recipient", addr);
|
|
610
628
|
loadRemote();
|
|
611
629
|
});
|
|
630
|
+
// Flag (or unflag) sender / domain — toggles the allowlist's
|
|
631
|
+
// flaggedSenders / flaggedDomains lists. Subsequent messages
|
|
632
|
+
// from this sender or domain show a red FLAGGED warning at the
|
|
633
|
+
// top of this banner. Doesn't load remote content; this is a
|
|
634
|
+
// signal-to-the-user feature, orthogonal to allow/block.
|
|
635
|
+
const onFlagToggle = async (type, value) => {
|
|
636
|
+
if (!value)
|
|
637
|
+
return;
|
|
638
|
+
try {
|
|
639
|
+
const result = await flagSenderOrDomain(type, value);
|
|
640
|
+
const status = document.getElementById("status-sync");
|
|
641
|
+
if (status)
|
|
642
|
+
status.textContent = result.flagged
|
|
643
|
+
? `Flagged ${type}: ${value}`
|
|
644
|
+
: `Unflagged ${type}: ${value}`;
|
|
645
|
+
// Re-render this message so the banner picks up the new
|
|
646
|
+
// flagged state without the user having to reselect.
|
|
647
|
+
if (currentMessage) {
|
|
648
|
+
showMessage(currentAccountId, currentMessage.uid, currentMessage.folderId, specialUse).catch(() => { });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
const status = document.getElementById("status-sync");
|
|
653
|
+
if (status)
|
|
654
|
+
status.textContent = `Flag failed: ${e?.message || e}`;
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
banner.querySelector("#btn-flag-sender")?.addEventListener("click", () => onFlagToggle("sender", senderAddr));
|
|
658
|
+
banner.querySelector("#btn-flag-domain")?.addEventListener("click", () => onFlagToggle("domain", senderDomain));
|
|
612
659
|
// "Edit allowlist…" — fires a document-level event that app.ts
|
|
613
660
|
// listens for and opens the JSONC editor pre-selected to
|
|
614
661
|
// allowlist.jsonc. Keeps message-viewer free of the editor import.
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
<button class="tb-btn" id="btn-send">Send</button>
|
|
43
43
|
<button class="tb-btn" id="btn-attach">Attach</button>
|
|
44
44
|
<input type="file" id="compose-file" multiple hidden>
|
|
45
|
+
<button class="tb-btn" id="btn-edit-in-word" title="Open the body in Microsoft Word — saves in Word reload back into the editor">Edit in Word</button>
|
|
45
46
|
<button class="tb-btn" id="btn-discard">Discard</button>
|
|
46
47
|
<span id="compose-status" class="compose-status"></span>
|
|
47
48
|
</div>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Receives init data via window.opener.postMessage or URL params.
|
|
5
5
|
*/
|
|
6
6
|
import { createEditor } from "./editor.js";
|
|
7
|
-
import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist } from "../lib/api-client.js";
|
|
7
|
+
import { getSettings, getAccounts, searchContacts, saveDraft as apiSaveDraft, deleteDraft, logClientEvent, addPreferredContact, addToDenylist, openInWord, closeWordEdit, onEvent } from "../lib/api-client.js";
|
|
8
8
|
import { showContextMenu } from "../components/context-menu.js";
|
|
9
9
|
// Very first line the iframe runs — if this doesn't reach Node, the iframe
|
|
10
10
|
// itself isn't loading or the bridge is completely broken.
|
|
@@ -1076,6 +1076,54 @@ document.getElementById("btn-attach")?.addEventListener("click", () => {
|
|
|
1076
1076
|
attachJustClicked = Date.now();
|
|
1077
1077
|
fileInput?.click();
|
|
1078
1078
|
});
|
|
1079
|
+
// ── Edit in Word (external editor handoff) ──
|
|
1080
|
+
//
|
|
1081
|
+
// Click writes the current body to a temp file, opens it in Word (or the
|
|
1082
|
+
// platform fallback), and watches the file. When Word saves, the service
|
|
1083
|
+
// emits `wordEditUpdated` and we replace the editor's HTML with the new
|
|
1084
|
+
// content. The editId is per-compose-window — closeWordEdit cleans up the
|
|
1085
|
+
// temp file when the window closes or the message is sent.
|
|
1086
|
+
let wordEditId = null;
|
|
1087
|
+
document.getElementById("btn-edit-in-word")?.addEventListener("click", async () => {
|
|
1088
|
+
if (!wordEditId)
|
|
1089
|
+
wordEditId = `compose-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1090
|
+
showDraftStatus("Opening in Word…", false);
|
|
1091
|
+
try {
|
|
1092
|
+
const result = await openInWord(wordEditId, editor.getHtml());
|
|
1093
|
+
if (!result.ok || result.opener === "none") {
|
|
1094
|
+
showDraftStatus("Couldn't launch an editor. Install Word, LibreOffice, or set a default for .html.", true);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const label = result.opener === "word" ? "Word" :
|
|
1098
|
+
result.opener === "libreoffice" ? "LibreOffice" :
|
|
1099
|
+
"your default editor";
|
|
1100
|
+
showDraftStatus(`Editing in ${label} — saves there will reload here.`, false);
|
|
1101
|
+
}
|
|
1102
|
+
catch (e) {
|
|
1103
|
+
showDraftStatus(`Edit-in-Word failed: ${e?.message || e}`, true);
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
// Listen for external-editor saves. Only react to events for this compose's
|
|
1107
|
+
// editId — multiple compose windows can be open and should not stomp each
|
|
1108
|
+
// other's bodies.
|
|
1109
|
+
onEvent((ev) => {
|
|
1110
|
+
if (ev?.type !== "wordEditUpdated")
|
|
1111
|
+
return;
|
|
1112
|
+
if (!wordEditId || ev.editId !== wordEditId)
|
|
1113
|
+
return;
|
|
1114
|
+
try {
|
|
1115
|
+
editor.setHtml(ev.html || "");
|
|
1116
|
+
showDraftStatus("Reloaded edits from external editor.", false);
|
|
1117
|
+
scheduleDraftSave();
|
|
1118
|
+
}
|
|
1119
|
+
catch (e) {
|
|
1120
|
+
showDraftStatus(`Reload failed: ${e?.message || e}`, true);
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
window.addEventListener("beforeunload", () => {
|
|
1124
|
+
if (wordEditId)
|
|
1125
|
+
closeWordEdit(wordEditId).catch(() => { });
|
|
1126
|
+
});
|
|
1079
1127
|
async function ingestFiles(files) {
|
|
1080
1128
|
for (const file of Array.from(files)) {
|
|
1081
1129
|
const buf = await file.arrayBuffer();
|
package/client/index.html
CHANGED
|
@@ -47,9 +47,15 @@
|
|
|
47
47
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
|
|
48
48
|
<label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
|
|
49
49
|
<hr class="tb-menu-sep">
|
|
50
|
+
<span class="tb-menu-label" title="Which app the compose 'Edit in Word' button hands the body off to. Auto = Word first, then LibreOffice, then OS default.">External editor</span>
|
|
51
|
+
<label class="tb-menu-item"><input type="radio" name="opt-extedit" value="auto" id="opt-extedit-auto" checked> Auto (Word → LibreOffice → default)</label>
|
|
52
|
+
<label class="tb-menu-item"><input type="radio" name="opt-extedit" value="word" id="opt-extedit-word"> Microsoft Word</label>
|
|
53
|
+
<label class="tb-menu-item"><input type="radio" name="opt-extedit" value="libreoffice" id="opt-extedit-libre"> LibreOffice Writer</label>
|
|
54
|
+
<hr class="tb-menu-sep">
|
|
50
55
|
<label class="tb-menu-item" title="Ghost-text completions while composing — Ollama / Claude / OpenAI back-end, off by default"><input type="checkbox" id="opt-autocomplete"> AI autocomplete</label>
|
|
51
56
|
<label class="tb-menu-item" title="Right-click in message body → Translate"><input type="checkbox" id="opt-ai-translate"> AI translate (off by default)</label>
|
|
52
57
|
<label class="tb-menu-item" title="Right-click in compose editor → Proofread (when wired)"><input type="checkbox" id="opt-ai-proofread"> AI proofread (off by default)</label>
|
|
58
|
+
<label class="tb-menu-item" title="When opening a message with remote content, look up the sender's domain in Spamhaus DBL. Domain leaks to Spamhaus's DNS. Off by default."><input type="checkbox" id="opt-check-reputation"> Check sender reputation (Spamhaus DBL)</label>
|
|
53
59
|
<hr class="tb-menu-sep">
|
|
54
60
|
<button class="tb-menu-item" id="btn-edit-jsonc" title="Edit accounts.jsonc / allowlist.jsonc">Edit config files...</button>
|
|
55
61
|
<button class="tb-menu-item" id="btn-open-mailx-dir" title="Open ~/.mailx in file explorer">Open mailx folder...</button>
|
package/client/lib/api-client.js
CHANGED
|
@@ -218,6 +218,9 @@ export function openLocalPath(which) {
|
|
|
218
218
|
export function allowRemoteContent(type, value) {
|
|
219
219
|
return ipc().allowRemoteContent(type, value);
|
|
220
220
|
}
|
|
221
|
+
export function flagSenderOrDomain(type, value) {
|
|
222
|
+
return ipc().flagSenderOrDomain?.(type, value) ?? Promise.resolve({ flagged: false });
|
|
223
|
+
}
|
|
221
224
|
export function deleteMessage(accountId, uid) {
|
|
222
225
|
return ipc().deleteMessage?.(accountId, uid);
|
|
223
226
|
}
|
|
@@ -348,6 +351,12 @@ export function readConfigHelp(name) {
|
|
|
348
351
|
export function unsubscribeOneClick(url) {
|
|
349
352
|
return ipc().unsubscribeOneClick?.(url);
|
|
350
353
|
}
|
|
354
|
+
export function openInWord(editId, html) {
|
|
355
|
+
return ipc().openInWord?.(editId, html) ?? Promise.resolve({ ok: false, path: "", opener: "none" });
|
|
356
|
+
}
|
|
357
|
+
export function closeWordEdit(editId) {
|
|
358
|
+
return ipc().closeWordEdit?.(editId) ?? Promise.resolve();
|
|
359
|
+
}
|
|
351
360
|
/** Run an AI text transform (translate / proofread / summarize). Returns
|
|
352
361
|
* empty `text` with a `reason` when the feature is disabled or the provider
|
|
353
362
|
* errors — caller should surface `reason` in a status bar, not throw. */
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -140,6 +140,12 @@
|
|
|
140
140
|
unsubscribeOneClick: function(url) {
|
|
141
141
|
return callNode("unsubscribeOneClick", { url: url });
|
|
142
142
|
},
|
|
143
|
+
openInWord: function(editId, html) {
|
|
144
|
+
return callNode("openInWord", { editId: editId, html: html });
|
|
145
|
+
},
|
|
146
|
+
closeWordEdit: function(editId) {
|
|
147
|
+
return callNode("closeWordEdit", { editId: editId });
|
|
148
|
+
},
|
|
143
149
|
aiTransform: function(req) {
|
|
144
150
|
return callNode("aiTransform", req);
|
|
145
151
|
},
|
|
@@ -265,6 +271,9 @@
|
|
|
265
271
|
allowRemoteContent: function(type, value) {
|
|
266
272
|
return callNode("allowRemoteContent", { type: type, value: value });
|
|
267
273
|
},
|
|
274
|
+
flagSenderOrDomain: function(type, value) {
|
|
275
|
+
return callNode("flagSenderOrDomain", { type: type, value: value });
|
|
276
|
+
},
|
|
268
277
|
getSettings: function() { return callNode("getSettings"); },
|
|
269
278
|
saveSettingsData: function(data) { return callNode("saveSettingsData", data); },
|
|
270
279
|
getVersion: function() { return callNode("getVersion"); },
|
|
@@ -1525,6 +1525,29 @@ body.calendar-sidebar-on .calendar-sidebar { display: flex; }
|
|
|
1525
1525
|
font-weight: 600;
|
|
1526
1526
|
}
|
|
1527
1527
|
|
|
1528
|
+
/* Flagged sender or domain — red strip above the normal banner. */
|
|
1529
|
+
.mv-rb-flagged {
|
|
1530
|
+
background: oklch(0.42 0.20 25);
|
|
1531
|
+
color: white;
|
|
1532
|
+
padding: var(--gap-xs) var(--gap-md);
|
|
1533
|
+
font-weight: 700;
|
|
1534
|
+
letter-spacing: 0.02em;
|
|
1535
|
+
}
|
|
1536
|
+
.mv-remote-banner-flagged { box-shadow: inset 0 0 0 2px oklch(0.55 0.22 25); }
|
|
1537
|
+
.mv-rb-flag-btn { background: oklch(0.42 0.20 25); }
|
|
1538
|
+
|
|
1539
|
+
/* External reputation hit (Spamhaus DBL etc) — orange strip, distinct from
|
|
1540
|
+
the user's manual red flag. Renders below the user-flag strip when both
|
|
1541
|
+
are present so the precedence is visible. */
|
|
1542
|
+
.mv-rb-reputation {
|
|
1543
|
+
background: oklch(0.55 0.18 50);
|
|
1544
|
+
color: white;
|
|
1545
|
+
padding: var(--gap-xs) var(--gap-md);
|
|
1546
|
+
font-weight: 600;
|
|
1547
|
+
letter-spacing: 0.02em;
|
|
1548
|
+
}
|
|
1549
|
+
.mv-remote-banner-reputation { box-shadow: inset 0 0 0 2px oklch(0.65 0.20 50); }
|
|
1550
|
+
|
|
1528
1551
|
.mv-rb-summary {
|
|
1529
1552
|
display: flex;
|
|
1530
1553
|
align-items: center;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.439",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@bobfrankston/iflow-node": "^0.1.8",
|
|
37
37
|
"@bobfrankston/miscinfo": "^1.0.10",
|
|
38
38
|
"@bobfrankston/oauthsupport": "^1.0.25",
|
|
39
|
-
"@bobfrankston/msger": "^0.1.
|
|
39
|
+
"@bobfrankston/msger": "^0.1.363",
|
|
40
40
|
"@bobfrankston/mailx-host": "^0.1.8",
|
|
41
41
|
"@capacitor/android": "^8.3.0",
|
|
42
42
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"@bobfrankston/iflow-node": "^0.1.8",
|
|
101
101
|
"@bobfrankston/miscinfo": "^1.0.10",
|
|
102
102
|
"@bobfrankston/oauthsupport": "^1.0.25",
|
|
103
|
-
"@bobfrankston/msger": "^0.1.
|
|
103
|
+
"@bobfrankston/msger": "^0.1.363",
|
|
104
104
|
"@bobfrankston/mailx-host": "^0.1.8",
|
|
105
105
|
"@capacitor/android": "^8.3.0",
|
|
106
106
|
"@capacitor/cli": "^8.3.0",
|
|
@@ -69,8 +69,6 @@ export declare class ImapManager extends EventEmitter {
|
|
|
69
69
|
private inboxSyncing;
|
|
70
70
|
/** Use native IMAP client instead of imapflow. Set to true to enable. */
|
|
71
71
|
useNativeClient: boolean;
|
|
72
|
-
/** Accounts hitting connection limits — back off until this time */
|
|
73
|
-
private connectionBackoff;
|
|
74
72
|
/** Per-account health counters. Incremented when the server misbehaves
|
|
75
73
|
* in ways that suggest a problem the user should know about (inactivity
|
|
76
74
|
* timeouts, connection-cap hits, rate-limit waits). Surfaced via a
|
|
@@ -100,50 +98,82 @@ export declare class ImapManager extends EventEmitter {
|
|
|
100
98
|
searchOnServer(accountId: string, mailboxPath: string, criteria: any): Promise<number[]>;
|
|
101
99
|
/** Server-side search that also materializes any UIDs we don't yet have
|
|
102
100
|
* locally. Returns the full result after upsert, so the caller can
|
|
103
|
-
* render hits that fall outside the history window.
|
|
101
|
+
* render hits that fall outside the history window. The fetch loop
|
|
102
|
+
* can be long for big hit-sets, so this runs on the slow lane and
|
|
103
|
+
* yields between chunks (each chunk is a separate withConnection)
|
|
104
|
+
* so an interactive body fetch can interleave. */
|
|
104
105
|
searchAndFetchOnServer(accountId: string, folderId: number, mailboxPath: string, criteria: any): Promise<number[]>;
|
|
105
106
|
/** Create a fresh IMAP client for an account (public access for API endpoints) */
|
|
106
|
-
createPublicClient(accountId: string): any
|
|
107
|
-
/** Persistent operational connections — one per account, reused for all operations
|
|
107
|
+
createPublicClient(accountId: string): Promise<any>;
|
|
108
|
+
/** Persistent operational connections — one per account, reused for all operations.
|
|
109
|
+
* Body fetch, sync, prefetch, outbox-append, flag/move all serialize through
|
|
110
|
+
* this single client per account via withConnection(). The priority lane
|
|
111
|
+
* in the queue lets interactive clicks jump ahead of background prefetch. */
|
|
108
112
|
private opsClients;
|
|
109
|
-
/**
|
|
113
|
+
/** Two-lane operation queue per account — interactive ops (body fetch on
|
|
114
|
+
* click, flag toggle) drain before background ops (sync, prefetch). FIFO
|
|
115
|
+
* within each lane. The single ops connection means there's never a race
|
|
116
|
+
* on which folder is SELECTed; commands run strictly sequentially. */
|
|
110
117
|
private opsQueues;
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
private bodyBackoff;
|
|
118
|
+
/** Per-host semaphore — caps simultaneous IMAP socket opens to one server.
|
|
119
|
+
* Defensive guardrail: with the single-ops-per-account model an individual
|
|
120
|
+
* user's mailx never hits more than (#accounts × 2) sockets per host, well
|
|
121
|
+
* under any reasonable server cap. Exists for the multi-account-on-one-host
|
|
122
|
+
* case (e.g. bobma + bobma2 both on imap.iecc.com). */
|
|
123
|
+
private hostSemaphores;
|
|
124
|
+
private static readonly HOST_PERMITS;
|
|
119
125
|
/** Get (or create) the persistent operational connection for an account.
|
|
120
126
|
* logout() is wrapped as a no-op so legacy callers don't close it. */
|
|
121
127
|
private getOpsClient;
|
|
122
128
|
/** Run an operation on the account's connection — queued, sequential, no concurrency */
|
|
123
|
-
|
|
129
|
+
/** Run an operation against the account's single ops connection. Tasks
|
|
130
|
+
* queue strictly sequentially per account — only one IMAP command in
|
|
131
|
+
* flight at a time. This eliminates the SELECT-races and "stale client
|
|
132
|
+
* recovery" paths the old multi-client design needed.
|
|
133
|
+
*
|
|
134
|
+
* Default lane is `fast` — covers virtually everything (body fetch,
|
|
135
|
+
* flag toggle, move, incremental sync). Pass `slow: true` only for
|
|
136
|
+
* operations the caller knows will take a long time and shouldn't
|
|
137
|
+
* block the user (multi-folder prefetch batches, large backfills).
|
|
138
|
+
* When both lanes have tasks, fast drains first.
|
|
139
|
+
*
|
|
140
|
+
* Within a lane, FIFO. The running task always finishes — IMAP can't
|
|
141
|
+
* preempt a command mid-flight. */
|
|
142
|
+
withConnection<T>(accountId: string, fn: (client: any) => Promise<T>, opts?: {
|
|
143
|
+
slow?: boolean;
|
|
144
|
+
}): Promise<T>;
|
|
145
|
+
/** Run the next queued task. Fast lane drains before slow.
|
|
146
|
+
* Idempotent — safe to call after each task completes; the running
|
|
147
|
+
* flag prevents reentrant draining. */
|
|
148
|
+
private drainOpsQueue;
|
|
149
|
+
/** Acquire one slot of the per-host connection semaphore. Returns a release
|
|
150
|
+
* function — call exactly once when the socket is closed. Used by
|
|
151
|
+
* newClient to cap simultaneous IMAP connections to a single server
|
|
152
|
+
* across all mailx accounts. */
|
|
153
|
+
private acquireHostSlot;
|
|
124
154
|
/** Open IMAP clients per account, used to trace who's opening sockets
|
|
125
155
|
* when we hit the Dovecot per-user+IP connection cap. */
|
|
126
156
|
private openClients;
|
|
127
157
|
/** Create a new IMAP client (internal — callers use getOpsClient or withConnection).
|
|
128
|
-
*
|
|
129
|
-
*
|
|
158
|
+
* Acquires one slot of the per-host semaphore before constructing the
|
|
159
|
+
* client; the slot is released when logout() or destroy() runs.
|
|
160
|
+
* `purpose` is a short tag printed alongside the `[conn+]` log so we can
|
|
161
|
+
* tell which code path (ops/idle/etc.) opened each connection. */
|
|
130
162
|
private newClient;
|
|
131
|
-
/**
|
|
132
|
-
*
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
private dropBodyClient;
|
|
136
|
-
/** Force-close every pooled client for an account — ops, body, any
|
|
137
|
-
* lingering ones in openClients. Used when the server reports its
|
|
138
|
-
* connection cap is hit so our slot count drops to zero on the
|
|
139
|
-
* server side before backoff expires. */
|
|
163
|
+
/** Force-close every IMAP socket for an account — ops + any lingering
|
|
164
|
+
* ones in openClients (e.g. an IDLE watcher in flight). Used during
|
|
165
|
+
* account removal and disconnectOps so the server's connection slots
|
|
166
|
+
* free immediately rather than waiting for socket idle timeouts. */
|
|
140
167
|
closeAllClients(accountId: string): Promise<void>;
|
|
141
168
|
/** Disconnect the persistent operational connection for an account */
|
|
142
169
|
disconnectOps(accountId: string): Promise<void>;
|
|
143
|
-
/** Legacy
|
|
144
|
-
*
|
|
145
|
-
*
|
|
170
|
+
/** Legacy entry: returns the shared persistent ops client. Most callers
|
|
171
|
+
* should be using `withConnection()` instead — that gives proper
|
|
172
|
+
* queueing and lets fast operations jump ahead of slow ones. */
|
|
146
173
|
createClientWithLimit(accountId: string): Promise<any>;
|
|
174
|
+
/** Disposable fresh client — only used by the IDLE watcher, which holds
|
|
175
|
+
* its own socket so the fast/slow ops queue isn't blocked by IDLE
|
|
176
|
+
* parking the connection in a wait-for-server state. */
|
|
147
177
|
private createClient;
|
|
148
178
|
private trackLogout;
|
|
149
179
|
/** Number of registered IMAP accounts */
|
|
@@ -175,7 +205,12 @@ export declare class ImapManager extends EventEmitter {
|
|
|
175
205
|
private storeApiMessages;
|
|
176
206
|
/** Kill and recreate the persistent ops connection */
|
|
177
207
|
private reconnectOps;
|
|
178
|
-
/** Handle sync errors — classify and emit appropriate UI events
|
|
208
|
+
/** Handle sync errors — classify and emit appropriate UI events.
|
|
209
|
+
* The connection-cap branch was removed: with the unified ops queue +
|
|
210
|
+
* per-host semaphore, mailx alone can't exceed the server cap. If the
|
|
211
|
+
* cap *is* hit, that means another client (Thunderbird, phone, sibling
|
|
212
|
+
* process) is holding slots — punishing mailx with a multi-minute
|
|
213
|
+
* blackout doesn't help the user, the next sync tick will retry. */
|
|
179
214
|
private handleSyncError;
|
|
180
215
|
/** Fetch ONLY new messages above highestUid for one account's INBOX —
|
|
181
216
|
* the IDLE callback's hot path. Skips gap detection, backfill, and the
|
|
@@ -211,17 +246,21 @@ export declare class ImapManager extends EventEmitter {
|
|
|
211
246
|
startWatching(): Promise<void>;
|
|
212
247
|
/** Stop all IDLE watchers */
|
|
213
248
|
stopWatching(): Promise<void>;
|
|
214
|
-
/** Per-account fetch queue — serializes body fetches so only one IMAP command runs at a time.
|
|
215
|
-
* The persistent fetchClient can only handle one command at a time (IMAP protocol limitation). */
|
|
216
|
-
private fetchQueues;
|
|
217
|
-
/** Serialize body fetch operations per account — prevents concurrent IMAP commands on same connection */
|
|
218
249
|
/** Unlink the on-disk body file for a message by reading its `body_path`
|
|
219
250
|
* from the DB. Safe to call either before or after `db.deleteMessage`
|
|
220
251
|
* — read body_path first, store it, then unlink whenever. */
|
|
221
252
|
private unlinkBodyFile;
|
|
222
|
-
private enqueueFetch;
|
|
223
253
|
/** Fetch a single message body on demand, caching in the store.
|
|
224
|
-
*
|
|
254
|
+
*
|
|
255
|
+
* Cache lookup is folder-agnostic: when a UID exists in multiple folders
|
|
256
|
+
* (Gmail labels, copy-instead-of-move) the prefetcher may have populated
|
|
257
|
+
* body_path on only one row. Looking up by (account, uid) without the
|
|
258
|
+
* folder filter finds the cached `.eml` regardless of which folder
|
|
259
|
+
* context the UI passed.
|
|
260
|
+
*
|
|
261
|
+
* Server fetch goes through the unified ops queue on the fast lane —
|
|
262
|
+
* the user clicked, they're waiting, this jumps ahead of any background
|
|
263
|
+
* prefetch sitting in the slow lane. */
|
|
225
264
|
fetchMessageBody(accountId: string, folderId: number, uid: number): Promise<Buffer | null>;
|
|
226
265
|
/** Fetch message body via Gmail/Outlook API.
|
|
227
266
|
* Throws `MessageNotFoundError` when the server says the message is gone
|
|
@@ -360,7 +399,10 @@ export declare class ImapManager extends EventEmitter {
|
|
|
360
399
|
* Uses @bobfrankston/smtp-direct with the same TransportFactory as IMAP —
|
|
361
400
|
* same TCP byte-stream interface, no nodemailer dependency. */
|
|
362
401
|
private sendRawViaSMTP;
|
|
363
|
-
/** Process Outbox — send pending messages with flag-based interlock
|
|
402
|
+
/** Process Outbox — send pending messages with flag-based interlock.
|
|
403
|
+
* Each per-UID step is its own withConnection({slow}) call so the queue
|
|
404
|
+
* yields between messages: a click-to-view body in the middle of a
|
|
405
|
+
* 10-message outbox drain doesn't wait for all 10 to finish. */
|
|
364
406
|
processOutbox(accountId: string): Promise<void>;
|
|
365
407
|
/** Start background Outbox worker — runs immediately then every 10 seconds */
|
|
366
408
|
private outboxBackoff;
|