@bobfrankston/mailx 1.0.436 → 1.0.438
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 +42 -7
- package/client/components/message-viewer.js +63 -12
- package/client/compose/compose.html +1 -0
- package/client/compose/compose.js +49 -1
- package/client/index.html +5 -0
- package/client/lib/api-client.js +9 -0
- package/client/lib/mailxapi.js +9 -0
- package/client/styles/components.css +11 -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 +27 -0
- package/packages/mailx-service/index.js +186 -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/packages/mailx-store/db.js +22 -7
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
|
@@ -633,21 +633,27 @@ async function openCompose(mode) {
|
|
|
633
633
|
? explicitDomains
|
|
634
634
|
: (accountDomain ? [accountDomain] : []);
|
|
635
635
|
function detectReplyFrom() {
|
|
636
|
-
if (!msg
|
|
636
|
+
if (!msg)
|
|
637
|
+
return undefined;
|
|
638
|
+
// Delivered-To is set by the receiving server — it IS an identity at this
|
|
639
|
+
// account, by definition. Trust it unconditionally when present (after
|
|
640
|
+
// deliveredToPrefix stripping in the service). Fall back to To/Cc only
|
|
641
|
+
// when their domain matches the account's identityDomains, since To/Cc
|
|
642
|
+
// can be set by the sender and aren't authoritative.
|
|
643
|
+
if (msg.deliveredTo) {
|
|
644
|
+
console.log(`[compose] reply From → ${msg.deliveredTo} (Delivered-To)`);
|
|
645
|
+
return msg.deliveredTo;
|
|
646
|
+
}
|
|
647
|
+
if (identityDomains.length === 0)
|
|
637
648
|
return undefined;
|
|
638
|
-
// Prefer Delivered-To header (the address the server actually delivered
|
|
639
|
-
// to, which is the alias the message arrived at). Fall back to To, then
|
|
640
|
-
// Cc, in order. Bcc isn't visible to recipients so skipped.
|
|
641
649
|
const candidates = [
|
|
642
|
-
msg.deliveredTo,
|
|
643
650
|
...((msg.to || []).map((a) => a.address)),
|
|
644
651
|
...((msg.cc || []).map((a) => a.address)),
|
|
645
652
|
].filter(Boolean);
|
|
646
|
-
console.log(`[compose] detectReplyFrom: deliveredTo=${msg.deliveredTo}, to=${msg.to?.map((a) => a.address)}, cc=${msg.cc?.map((a) => a.address)}, identityDomains=${identityDomains}, accountEmail=${account?.email}`);
|
|
647
653
|
for (const addr of candidates) {
|
|
648
654
|
const domain = addr.split("@")[1]?.toLowerCase();
|
|
649
655
|
if (domain && identityDomains.some(d => domain === d || domain.endsWith(`.${d}`))) {
|
|
650
|
-
console.log(`[compose] reply From → ${addr}`);
|
|
656
|
+
console.log(`[compose] reply From → ${addr} (To/Cc match)`);
|
|
651
657
|
return addr;
|
|
652
658
|
}
|
|
653
659
|
}
|
|
@@ -2232,6 +2238,7 @@ async function openJsoncEditor(initialFile) {
|
|
|
2232
2238
|
<label class="mailx-modal-label">File
|
|
2233
2239
|
<select class="mailx-modal-input" id="jsonc-file">
|
|
2234
2240
|
<option value="accounts.jsonc">accounts.jsonc — accounts (shared via Google Drive)</option>
|
|
2241
|
+
<option value="contacts.jsonc">contacts.jsonc — preferred + denylist + discovered (shared)</option>
|
|
2235
2242
|
<option value="allowlist.jsonc">allowlist.jsonc — remote-content allowlist (shared)</option>
|
|
2236
2243
|
<option value="clients.jsonc">clients.jsonc — per-device registrations (shared)</option>
|
|
2237
2244
|
<option value="config.jsonc">config.jsonc — local per-machine overrides (not synced)</option>
|
|
@@ -2806,6 +2813,34 @@ optEditorTiptap?.addEventListener("change", () => {
|
|
|
2806
2813
|
if (optEditorTiptap.checked)
|
|
2807
2814
|
saveEditorSetting("tiptap");
|
|
2808
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"); });
|
|
2809
2844
|
// ── AI feature toggles ──
|
|
2810
2845
|
// One umbrella settings record (AutocompleteSettings) holds the provider config
|
|
2811
2846
|
// + per-feature on/off flags. All features default OFF — user must opt into
|
|
@@ -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 } 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) */
|
|
@@ -358,17 +358,13 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
358
358
|
}
|
|
359
359
|
headerEl.querySelector(".mv-date").textContent = new Date(msg.date).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false });
|
|
360
360
|
// Unsubscribe button (upper right of header).
|
|
361
|
-
// -
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// a tad" (CSS :active) but no visible feedback. User preference:
|
|
365
|
-
// just open the link, the way the working right-click does.
|
|
366
|
-
// - mailto: URL: open a pre-filled compose window so the reply gets
|
|
367
|
-
// sent from the correct mailx account, not the OS default mail
|
|
368
|
-
// handler.
|
|
361
|
+
// - One-Click (RFC 8058): POST via service; show result in status bar.
|
|
362
|
+
// - Plain HTTPS URL: open externally for user confirmation.
|
|
363
|
+
// - mailto: open a pre-filled compose so the reply uses the right account.
|
|
369
364
|
const unsubBtn = document.getElementById("mv-unsubscribe");
|
|
370
365
|
const httpUrl = msg.listUnsubscribeHttp || "";
|
|
371
366
|
const mailUrl = msg.listUnsubscribeMail || "";
|
|
367
|
+
const oneClick = !!msg.listUnsubscribeOneClick;
|
|
372
368
|
const anyUrl = httpUrl || mailUrl || msg.listUnsubscribe || "";
|
|
373
369
|
if (unsubBtn) {
|
|
374
370
|
if (anyUrl) {
|
|
@@ -376,8 +372,26 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
376
372
|
unsubBtn.textContent = "Unsubscribe";
|
|
377
373
|
unsubBtn.removeAttribute("title");
|
|
378
374
|
unsubBtn.href = httpUrl || mailUrl || "#";
|
|
379
|
-
unsubBtn.onclick = (e) => {
|
|
375
|
+
unsubBtn.onclick = async (e) => {
|
|
380
376
|
e.preventDefault();
|
|
377
|
+
const status = document.getElementById("status-sync");
|
|
378
|
+
if (httpUrl && oneClick) {
|
|
379
|
+
if (status)
|
|
380
|
+
status.textContent = "Unsubscribing…";
|
|
381
|
+
try {
|
|
382
|
+
const result = await unsubscribeOneClick(httpUrl);
|
|
383
|
+
if (status) {
|
|
384
|
+
status.textContent = result.ok
|
|
385
|
+
? `Unsubscribed (${result.status} ${result.statusText})`
|
|
386
|
+
: `Unsubscribe failed: ${result.status} ${result.statusText}`;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
if (status)
|
|
391
|
+
status.textContent = `Unsubscribe error: ${err?.message || err}`;
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
381
395
|
if (httpUrl) {
|
|
382
396
|
const api = window.mailxapi;
|
|
383
397
|
if (api?.openExternal)
|
|
@@ -533,10 +547,14 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
533
547
|
const deliveredTo = msg.deliveredTo || "";
|
|
534
548
|
const toAddr = msg.to?.[0]?.address || "";
|
|
535
549
|
const returnPath = msg.returnPath || "";
|
|
550
|
+
const isFlagged = !!msg.isFlagged;
|
|
536
551
|
const banner = document.createElement("div");
|
|
537
|
-
banner.className = "mv-remote-banner";
|
|
552
|
+
banner.className = "mv-remote-banner" + (isFlagged ? " mv-remote-banner-flagged" : "");
|
|
538
553
|
banner.innerHTML =
|
|
539
|
-
|
|
554
|
+
(isFlagged
|
|
555
|
+
? `<div class="mv-rb-flagged">⚠ FLAGGED: this sender or domain is on your flagged list</div>`
|
|
556
|
+
: "") +
|
|
557
|
+
`<div class="mv-rb-summary">` +
|
|
540
558
|
`<span class="mv-rb-toggle">▸</span>` +
|
|
541
559
|
`<span>Remote content blocked</span>` +
|
|
542
560
|
`<span class="mv-rb-buttons">` +
|
|
@@ -553,6 +571,10 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
553
571
|
(returnPath && returnPath !== senderAddr ? `<div><span class="mv-rb-label">Return-Path:</span> ${escapeText(returnPath)}</div>` : "") +
|
|
554
572
|
`</div>` +
|
|
555
573
|
(deliveredTo || toAddr ? `<div class="mv-rb-actions"><button id="btn-allow-to">Always allow to: ${escapeText(deliveredTo || toAddr)}</button></div>` : "") +
|
|
574
|
+
`<div class="mv-rb-actions">` +
|
|
575
|
+
`<button id="btn-flag-sender" class="mv-rb-flag-btn" title="${escapeText(senderAddr)}">${isFlagged ? "Unflag" : "Flag"} sender</button>` +
|
|
576
|
+
(senderDomain ? `<button id="btn-flag-domain" class="mv-rb-flag-btn" title="*@${escapeText(senderDomain)}">Flag domain *@${escapeText(senderDomain)}</button>` : "") +
|
|
577
|
+
`</div>` +
|
|
556
578
|
`<div class="mv-rb-actions"><button id="btn-edit-allowlist" title="View / edit the full allowlist">Edit allowlist…</button></div>` +
|
|
557
579
|
`</div>`;
|
|
558
580
|
bodyEl.appendChild(banner);
|
|
@@ -595,6 +617,35 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
595
617
|
await allowRemoteContent("recipient", addr);
|
|
596
618
|
loadRemote();
|
|
597
619
|
});
|
|
620
|
+
// Flag (or unflag) sender / domain — toggles the allowlist's
|
|
621
|
+
// flaggedSenders / flaggedDomains lists. Subsequent messages
|
|
622
|
+
// from this sender or domain show a red FLAGGED warning at the
|
|
623
|
+
// top of this banner. Doesn't load remote content; this is a
|
|
624
|
+
// signal-to-the-user feature, orthogonal to allow/block.
|
|
625
|
+
const onFlagToggle = async (type, value) => {
|
|
626
|
+
if (!value)
|
|
627
|
+
return;
|
|
628
|
+
try {
|
|
629
|
+
const result = await flagSenderOrDomain(type, value);
|
|
630
|
+
const status = document.getElementById("status-sync");
|
|
631
|
+
if (status)
|
|
632
|
+
status.textContent = result.flagged
|
|
633
|
+
? `Flagged ${type}: ${value}`
|
|
634
|
+
: `Unflagged ${type}: ${value}`;
|
|
635
|
+
// Re-render this message so the banner picks up the new
|
|
636
|
+
// flagged state without the user having to reselect.
|
|
637
|
+
if (currentMessage) {
|
|
638
|
+
showMessage(currentAccountId, currentMessage.uid, currentMessage.folderId, specialUse).catch(() => { });
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
const status = document.getElementById("status-sync");
|
|
643
|
+
if (status)
|
|
644
|
+
status.textContent = `Flag failed: ${e?.message || e}`;
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
banner.querySelector("#btn-flag-sender")?.addEventListener("click", () => onFlagToggle("sender", senderAddr));
|
|
648
|
+
banner.querySelector("#btn-flag-domain")?.addEventListener("click", () => onFlagToggle("domain", senderDomain));
|
|
598
649
|
// "Edit allowlist…" — fires a document-level event that app.ts
|
|
599
650
|
// listens for and opens the JSONC editor pre-selected to
|
|
600
651
|
// 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,6 +47,11 @@
|
|
|
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>
|
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,17 @@ 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
|
+
|
|
1528
1539
|
.mv-rb-summary {
|
|
1529
1540
|
display: flex;
|
|
1530
1541
|
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.438",
|
|
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;
|