@bobfrankston/mailx 1.0.278 → 1.0.284
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/README.md +12 -0
- package/client/.msger-window.json +1 -1
- package/client/android.html +1 -0
- package/client/app.js +54 -1
- package/client/components/folder-tree.js +7 -3
- package/client/components/message-viewer.js +18 -8
- package/client/index.html +1 -0
- package/client/lib/api-client.js +3 -0
- package/client/lib/mailxapi.js +3 -0
- package/client/styles/components.css +65 -0
- package/package.json +13 -13
- package/packages/mailx-api/index.js +10 -0
- package/packages/mailx-core/index.d.ts +1 -0
- package/packages/mailx-imap/index.js +7 -2
- package/packages/mailx-service/index.d.ts +6 -0
- package/packages/mailx-service/index.js +28 -2
- package/packages/mailx-service/jsonrpc.js +2 -0
- package/packages/mailx-store/db.d.ts +1 -0
- package/packages/mailx-store/db.js +16 -5
- package/packages/mailx-types/index.d.ts +2 -0
package/README.md
CHANGED
|
@@ -290,6 +290,7 @@ For known providers, only `email` is required -- IMAP/SMTP settings fill automat
|
|
|
290
290
|
"email": "you@example.com",
|
|
291
291
|
"name": "Your Name",
|
|
292
292
|
"label": "Work",
|
|
293
|
+
"spam": "_spam", // Optional: enables the ⚠ Spam button in the viewer toolbar
|
|
293
294
|
"imap": { "host": "imap.example.com", "port": 993, "tls": true, "user": "you", "password": "..." },
|
|
294
295
|
"smtp": { "host": "smtp.example.com", "port": 587, "tls": true, "user": "you", "password": "..." }
|
|
295
296
|
}
|
|
@@ -297,6 +298,17 @@ For known providers, only `email` is required -- IMAP/SMTP settings fill automat
|
|
|
297
298
|
}
|
|
298
299
|
```
|
|
299
300
|
|
|
301
|
+
**Optional per-account fields:**
|
|
302
|
+
|
|
303
|
+
| Field | Type | Purpose |
|
|
304
|
+
|-------|------|---------|
|
|
305
|
+
| `spam` | string | IMAP folder path to send messages when the **Spam** (⚠) button is pressed. The button is hidden until this is set. Use the exact folder path on the server (e.g., `"_spam"`, `"INBOX/Spam"`, `"[Gmail]/Spam"`). |
|
|
306
|
+
| `label` | string | Display name in the folder tree (overrides auto-detected). |
|
|
307
|
+
| `defaultSend` | bool | Use this account's SMTP when From doesn't match any account. |
|
|
308
|
+
| `relayDomains` | string[] | Domains to skip in Delivered-To chain (e.g., `["m.connectivity.xyz"]`). |
|
|
309
|
+
| `deliveredToPrefix` | string[] | Strip these prefixes from Delivered-To to recover the clean alias (e.g., `["bobf-ma-", "bobf-"]` — order matters, longest first). |
|
|
310
|
+
| `identityDomains` | string[] | Domains where Delivered-To becomes the reply From (e.g., `["bob.ma"]`). |
|
|
311
|
+
|
|
300
312
|
**Auto-detected providers:**
|
|
301
313
|
|
|
302
314
|
| Domain | IMAP | SMTP | Auth | Label |
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"height":
|
|
1
|
+
{"height":1421,"width":2151,"x":228,"y":93}
|
package/client/android.html
CHANGED
|
@@ -148,6 +148,7 @@
|
|
|
148
148
|
<button class="tb-btn" id="btn-reply-all" title="Reply All">↩↩</button>
|
|
149
149
|
<button class="tb-btn" id="btn-forward" title="Forward">→</button>
|
|
150
150
|
<button class="tb-btn" id="btn-delete" title="Delete">🗑</button>
|
|
151
|
+
<button class="tb-btn" id="btn-spam" title="Mark as spam" hidden>⚠</button>
|
|
151
152
|
<button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
|
|
152
153
|
<span style="flex:1"></span>
|
|
153
154
|
<button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
|
package/client/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { initFolderTree, refreshFolderTree, updateFolderCounts, setFolderSynced, getFolderSynced } from "./components/folder-tree.js";
|
|
6
6
|
import { initMessageList, loadMessages, loadUnifiedInbox, loadSearchResults, reloadCurrentFolder, getSelectedMessages, markBodiesCached } from "./components/message-list.js";
|
|
7
7
|
import { showMessage, getCurrentMessage, initViewer } from "./components/message-viewer.js";
|
|
8
|
-
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags } from "./lib/api-client.js";
|
|
8
|
+
import { connectWebSocket, onWsEvent, triggerSync, syncAccount, reauthenticate, getAccounts, getFolders, deleteMessages, undeleteMessage, restartServer, getSyncPending, getVersion, getSettings, saveSettings, getAutocompleteSettings, saveAutocompleteSettings, repairAccounts, updateFlags, markAsSpamMessages } from "./lib/api-client.js";
|
|
9
9
|
import * as messageState from "./lib/message-state.js";
|
|
10
10
|
// ── New message badge (favicon + title) ──
|
|
11
11
|
let baseTitle = "mailx";
|
|
@@ -203,6 +203,7 @@ initFolderTree(folderTree, (accountId, folderId, folderName, specialUse) => {
|
|
|
203
203
|
loadMessages(accountId, folderId, 1, specialUse);
|
|
204
204
|
setTitle(`mailx - ${folderName}`);
|
|
205
205
|
setNarrowFolderTitle(folderName);
|
|
206
|
+
document.dispatchEvent(new CustomEvent("mailx-folder-changed", { detail: { accountId, folderId } }));
|
|
206
207
|
}, () => {
|
|
207
208
|
// Unified inbox handler
|
|
208
209
|
currentFolderSpecialUse = "inbox";
|
|
@@ -690,6 +691,58 @@ document.addEventListener("mailx-moved", (e) => {
|
|
|
690
691
|
undoTimeout = setTimeout(() => { lastMoved = null; }, 60000);
|
|
691
692
|
});
|
|
692
693
|
document.getElementById("btn-delete")?.addEventListener("click", deleteSelectedMessages);
|
|
694
|
+
async function spamSelectedMessages() {
|
|
695
|
+
const selected = getSelectedMessages();
|
|
696
|
+
if (selected.length === 0) {
|
|
697
|
+
const current = getCurrentMessage();
|
|
698
|
+
if (!current)
|
|
699
|
+
return;
|
|
700
|
+
selected.push({ accountId: current.accountId, uid: current.message.uid, folderId: current.message.folderId });
|
|
701
|
+
}
|
|
702
|
+
const statusSync = document.getElementById("status-sync");
|
|
703
|
+
try {
|
|
704
|
+
const byAccount = new Map();
|
|
705
|
+
for (const msg of selected) {
|
|
706
|
+
const uids = byAccount.get(msg.accountId) || [];
|
|
707
|
+
uids.push(msg.uid);
|
|
708
|
+
byAccount.set(msg.accountId, uids);
|
|
709
|
+
}
|
|
710
|
+
for (const [accountId, uids] of byAccount) {
|
|
711
|
+
await markAsSpamMessages(accountId, uids);
|
|
712
|
+
}
|
|
713
|
+
if (statusSync)
|
|
714
|
+
statusSync.textContent = `Marked ${selected.length} as spam`;
|
|
715
|
+
messageState.removeMessages(selected);
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
if (statusSync)
|
|
719
|
+
statusSync.textContent = `Spam failed: ${e.message}`;
|
|
720
|
+
console.error(`Spam failed: ${e.message}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
document.getElementById("btn-spam")?.addEventListener("click", spamSelectedMessages);
|
|
724
|
+
/** Show/hide the Spam button based on whether the current account has "spam" configured. */
|
|
725
|
+
async function refreshSpamButtonVisibility() {
|
|
726
|
+
const btn = document.getElementById("btn-spam");
|
|
727
|
+
if (!btn)
|
|
728
|
+
return;
|
|
729
|
+
const current = getCurrentMessage();
|
|
730
|
+
const accountId = current?.accountId || currentAccountId;
|
|
731
|
+
if (!accountId) {
|
|
732
|
+
btn.hidden = true;
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const accounts = await getAccounts();
|
|
737
|
+
const acct = accounts.find((a) => a.id === accountId);
|
|
738
|
+
btn.hidden = !acct?.spam;
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
btn.hidden = true;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
|
|
745
|
+
document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
|
|
693
746
|
document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
|
|
694
747
|
document.getElementById("btn-reply")?.addEventListener("click", () => openCompose("reply"));
|
|
695
748
|
document.getElementById("btn-reply-all")?.addEventListener("click", () => openCompose("replyAll"));
|
|
@@ -642,11 +642,15 @@ async function loadFolderTree(container) {
|
|
|
642
642
|
const savedScroll = container.scrollTop;
|
|
643
643
|
// Build entire new tree into a DocumentFragment (off-screen, no reflows)
|
|
644
644
|
const fragment = document.createDocumentFragment();
|
|
645
|
-
// Unified Inbox
|
|
646
|
-
|
|
645
|
+
// Unified Inbox — always shown so startup auto-selects it consistently
|
|
646
|
+
// (with one account it's effectively that account's INBOX, but the UI
|
|
647
|
+
// stays uniform so the auto-select path doesn't fork on account count)
|
|
648
|
+
if (accounts.length >= 1) {
|
|
647
649
|
const unifiedEl = document.createElement("div");
|
|
648
650
|
unifiedEl.className = "ft-folder ft-unified";
|
|
649
|
-
unifiedEl.title =
|
|
651
|
+
unifiedEl.title = accounts.length > 1
|
|
652
|
+
? "Merged inbox view of all accounts — click to see messages from every account's INBOX sorted by date"
|
|
653
|
+
: "Inbox view across all your accounts";
|
|
650
654
|
unifiedEl.innerHTML = `<span class="ft-toggle"> </span><span class="ft-folder-name">All Inboxes</span>`;
|
|
651
655
|
unifiedEl.addEventListener("click", () => {
|
|
652
656
|
if (selectedElement)
|
|
@@ -189,11 +189,21 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
189
189
|
const fromEl = headerEl.querySelector(".mv-from");
|
|
190
190
|
const toEl = headerEl.querySelector(".mv-to");
|
|
191
191
|
fromEl.textContent = formatAddr(msg.from);
|
|
192
|
-
|
|
193
|
-
if (msg.cc?.length)
|
|
194
|
-
|
|
192
|
+
let toLine = `To: ${msg.to.map(formatAddr).join(", ")}`;
|
|
193
|
+
if (msg.cc?.length)
|
|
194
|
+
toLine += ` Cc: ${msg.cc.map(formatAddr).join(", ")}`;
|
|
195
|
+
// Always-visible Delivered-To line — shown when present and not already
|
|
196
|
+
// covered by the To/Cc list. Critical for accounts with multiple aliases
|
|
197
|
+
// where you need to see which one received the message at a glance.
|
|
198
|
+
const toAddrs = (msg.to || []).map((a) => a.address.toLowerCase());
|
|
199
|
+
const ccAddrs = (msg.cc || []).map((a) => a.address.toLowerCase());
|
|
200
|
+
const dt = (msg.deliveredTo || "").toLowerCase();
|
|
201
|
+
if (msg.deliveredTo && !toAddrs.includes(dt) && !ccAddrs.includes(dt)) {
|
|
202
|
+
toLine += ` Delivered-To: ${msg.deliveredTo}`;
|
|
195
203
|
}
|
|
204
|
+
toEl.textContent = toLine;
|
|
196
205
|
headerEl.querySelector(".mv-subject").textContent = msg.subject;
|
|
206
|
+
document.dispatchEvent(new CustomEvent("mailx-message-shown", { detail: { accountId } }));
|
|
197
207
|
// Right-click on email addresses in header: copy name, copy address,
|
|
198
208
|
// copy both, add to contacts, plus reply actions for the whole message.
|
|
199
209
|
for (const el of [fromEl, toEl]) {
|
|
@@ -431,12 +441,12 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
|
|
|
431
441
|
const err = String(msg.bodyError);
|
|
432
442
|
const isTransient = !!msg.bodyErrorTransient;
|
|
433
443
|
const errBanner = document.createElement("div");
|
|
434
|
-
errBanner.className = "mv-error
|
|
435
|
-
errBanner.style.cssText = "margin:1rem;padding:0.75rem 1rem;border:1px solid var(--color-border);border-left:3px solid #d33;background:var(--color-bg-surface);border-radius:4px;font-size:var(--font-size-sm)";
|
|
444
|
+
errBanner.className = "mv-system-message mv-system-error";
|
|
436
445
|
errBanner.innerHTML = `
|
|
437
|
-
<div
|
|
438
|
-
<div
|
|
439
|
-
|
|
446
|
+
<div class="mv-system-tag">mailx</div>
|
|
447
|
+
<div class="mv-system-title">Body unavailable</div>
|
|
448
|
+
<div class="mv-system-body">${err.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c] || c))}</div>
|
|
449
|
+
${isTransient ? `<div class="mv-system-actions"><button id="btn-retry-body" class="mv-system-btn">Retry</button></div>` : ""}
|
|
440
450
|
`;
|
|
441
451
|
bodyEl.appendChild(errBanner);
|
|
442
452
|
if (isTransient) {
|
package/client/index.html
CHANGED
|
@@ -108,6 +108,7 @@
|
|
|
108
108
|
<button class="tb-btn" id="btn-reply-all" title="Reply All (Ctrl+Shift+R)">↩↩</button>
|
|
109
109
|
<button class="tb-btn" id="btn-forward" title="Forward">→</button>
|
|
110
110
|
<button class="tb-btn" id="btn-delete" title="Delete (Del)">🗑</button>
|
|
111
|
+
<button class="tb-btn" id="btn-spam" title="Mark as spam — move to configured spam folder" hidden>⚠</button>
|
|
111
112
|
<button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
|
|
112
113
|
<span style="flex:1"></span>
|
|
113
114
|
<button class="mv-action mv-action-primary" id="mv-edit-draft" hidden>Edit & Send</button>
|
package/client/lib/api-client.js
CHANGED
|
@@ -81,6 +81,9 @@ export function moveMessages(accountId, uids, targetFolderId, targetAccountId) {
|
|
|
81
81
|
return moveMessage(accountId, uids[0], targetFolderId, targetAccountId);
|
|
82
82
|
return ipc().moveMessages?.(accountId, uids, targetFolderId, targetAccountId);
|
|
83
83
|
}
|
|
84
|
+
export function markAsSpamMessages(accountId, uids) {
|
|
85
|
+
return ipc().markAsSpamMessages?.(accountId, uids);
|
|
86
|
+
}
|
|
84
87
|
export function undeleteMessage(accountId, uid, folderId) {
|
|
85
88
|
return ipc().undeleteMessage?.(accountId, uid, folderId);
|
|
86
89
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -129,6 +129,9 @@
|
|
|
129
129
|
moveMessages: function(accountId, uids, targetFolderId) {
|
|
130
130
|
return callNode("moveMessages", { accountId: accountId, uids: uids, targetFolderId: targetFolderId });
|
|
131
131
|
},
|
|
132
|
+
markAsSpamMessages: function(accountId, uids) {
|
|
133
|
+
return callNode("markAsSpamMessages", { accountId: accountId, uids: uids });
|
|
134
|
+
},
|
|
132
135
|
markFolderRead: function(accountId, folderId) {
|
|
133
136
|
return callNode("markFolderRead", { folderId: folderId });
|
|
134
137
|
},
|
|
@@ -637,6 +637,71 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
637
637
|
font-size: var(--font-size-base);
|
|
638
638
|
}
|
|
639
639
|
|
|
640
|
+
/* ── System-generated message plaque ──
|
|
641
|
+
* Visually distinguished from email content so the user knows the message is
|
|
642
|
+
* from mailx itself (errors, status notices), not part of the rendered email.
|
|
643
|
+
* Inset card with a "mailx" tag chip in the corner. */
|
|
644
|
+
.mv-system-message {
|
|
645
|
+
position: relative;
|
|
646
|
+
margin: var(--gap-lg);
|
|
647
|
+
padding: var(--gap-lg) var(--gap-lg) var(--gap-md);
|
|
648
|
+
background: var(--color-bg-surface);
|
|
649
|
+
border: 1px dashed var(--color-border);
|
|
650
|
+
border-radius: var(--radius-md);
|
|
651
|
+
font-family: var(--font-family-base);
|
|
652
|
+
font-size: var(--font-size-sm);
|
|
653
|
+
color: var(--color-text);
|
|
654
|
+
max-width: 640px;
|
|
655
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
656
|
+
|
|
657
|
+
.mv-system-tag {
|
|
658
|
+
position: absolute;
|
|
659
|
+
top: -0.6em;
|
|
660
|
+
left: var(--gap-md);
|
|
661
|
+
padding: 0.1em 0.6em;
|
|
662
|
+
background: var(--color-bg);
|
|
663
|
+
border: 1px solid var(--color-border);
|
|
664
|
+
border-radius: var(--radius-sm);
|
|
665
|
+
font-size: 0.7rem;
|
|
666
|
+
font-weight: 600;
|
|
667
|
+
text-transform: uppercase;
|
|
668
|
+
letter-spacing: 0.06em;
|
|
669
|
+
color: var(--color-text-muted);
|
|
670
|
+
}
|
|
671
|
+
.mv-system-title {
|
|
672
|
+
font-weight: 600;
|
|
673
|
+
margin-bottom: var(--gap-xs);
|
|
674
|
+
color: var(--color-text);
|
|
675
|
+
}
|
|
676
|
+
.mv-system-body {
|
|
677
|
+
color: var(--color-text-muted);
|
|
678
|
+
white-space: pre-wrap;
|
|
679
|
+
word-break: break-word;
|
|
680
|
+
line-height: 1.4;
|
|
681
|
+
}
|
|
682
|
+
.mv-system-actions {
|
|
683
|
+
margin-top: var(--gap-sm);
|
|
684
|
+
display: flex;
|
|
685
|
+
gap: var(--gap-sm);
|
|
686
|
+
}
|
|
687
|
+
.mv-system-btn {
|
|
688
|
+
padding: 0.3em 0.9em;
|
|
689
|
+
border: 1px solid var(--color-border);
|
|
690
|
+
border-radius: var(--radius-sm);
|
|
691
|
+
background: var(--color-bg);
|
|
692
|
+
color: var(--color-text);
|
|
693
|
+
cursor: pointer;
|
|
694
|
+
font-size: var(--font-size-sm);
|
|
695
|
+
&:hover { background: var(--color-bg-hover); }
|
|
696
|
+
}
|
|
697
|
+
/* Error variant — red accent on tag, title, and left edge */
|
|
698
|
+
&.mv-system-error {
|
|
699
|
+
border-left: 3px solid oklch(0.55 0.22 25);
|
|
700
|
+
.mv-system-tag { color: oklch(0.55 0.22 25); border-color: oklch(0.55 0.22 25); }
|
|
701
|
+
.mv-system-title { color: oklch(0.55 0.22 25); }
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
640
705
|
/* ── Message Viewer ── */
|
|
641
706
|
|
|
642
707
|
.message-viewer {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobfrankston/mailx",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.284",
|
|
4
4
|
"description": "Local-first email client with IMAP sync and standalone native app",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/mailx.js",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
"postinstall": "node bin/postinstall.js"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
24
|
-
"@bobfrankston/iflow-node": "^0.1.
|
|
23
|
+
"@bobfrankston/iflow-direct": "^0.1.23",
|
|
24
|
+
"@bobfrankston/iflow-node": "^0.1.7",
|
|
25
25
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
26
26
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
27
|
-
"@bobfrankston/msger": "^0.1.
|
|
27
|
+
"@bobfrankston/msger": "^0.1.321",
|
|
28
28
|
"@capacitor/android": "^8.3.0",
|
|
29
29
|
"@capacitor/cli": "^8.3.0",
|
|
30
30
|
"@capacitor/core": "^8.3.0",
|
|
@@ -36,9 +36,9 @@
|
|
|
36
36
|
"ws": "^8.18.0",
|
|
37
37
|
"sql.js": "^1.14.1",
|
|
38
38
|
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
39
|
-
"@bobfrankston/node-tcp-transport": "^0.1.
|
|
40
|
-
"@bobfrankston/smtp-direct": "^0.1.
|
|
41
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
39
|
+
"@bobfrankston/node-tcp-transport": "^0.1.4",
|
|
40
|
+
"@bobfrankston/smtp-direct": "^0.1.4",
|
|
41
|
+
"@bobfrankston/mailx-sync": "^0.1.7"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@types/mailparser": "^3.4.6"
|
|
@@ -82,11 +82,11 @@
|
|
|
82
82
|
},
|
|
83
83
|
".transformedSnapshot": {
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@bobfrankston/iflow-direct": "^0.1.
|
|
86
|
-
"@bobfrankston/iflow-node": "^0.1.
|
|
85
|
+
"@bobfrankston/iflow-direct": "^0.1.23",
|
|
86
|
+
"@bobfrankston/iflow-node": "^0.1.7",
|
|
87
87
|
"@bobfrankston/miscinfo": "^1.0.8",
|
|
88
88
|
"@bobfrankston/oauthsupport": "^1.0.24",
|
|
89
|
-
"@bobfrankston/msger": "^0.1.
|
|
89
|
+
"@bobfrankston/msger": "^0.1.321",
|
|
90
90
|
"@capacitor/android": "^8.3.0",
|
|
91
91
|
"@capacitor/cli": "^8.3.0",
|
|
92
92
|
"@capacitor/core": "^8.3.0",
|
|
@@ -98,9 +98,9 @@
|
|
|
98
98
|
"ws": "^8.18.0",
|
|
99
99
|
"sql.js": "^1.14.1",
|
|
100
100
|
"@bobfrankston/tcp-transport": "^0.1.4",
|
|
101
|
-
"@bobfrankston/node-tcp-transport": "^0.1.
|
|
102
|
-
"@bobfrankston/smtp-direct": "^0.1.
|
|
103
|
-
"@bobfrankston/mailx-sync": "^0.1.
|
|
101
|
+
"@bobfrankston/node-tcp-transport": "^0.1.4",
|
|
102
|
+
"@bobfrankston/smtp-direct": "^0.1.4",
|
|
103
|
+
"@bobfrankston/mailx-sync": "^0.1.7"
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
}
|
|
@@ -168,6 +168,16 @@ export function createApiRouter(db, imapManager) {
|
|
|
168
168
|
res.status(500).json({ error: e.message });
|
|
169
169
|
}
|
|
170
170
|
});
|
|
171
|
+
router.post("/messages/spam", async (req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const { accountId, uids } = req.body;
|
|
174
|
+
const result = await svc.markAsSpamMessages(accountId, uids);
|
|
175
|
+
res.json({ ok: true, ...result });
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
res.status(500).json({ error: e.message });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
171
181
|
// ── Delete (single — kept for backward compat) ──
|
|
172
182
|
router.delete("/message/:accountId/:uid", async (req, res) => {
|
|
173
183
|
try {
|
|
@@ -1172,7 +1172,8 @@ export class ImapManager extends EventEmitter {
|
|
|
1172
1172
|
from: toEmailAddress(msg.from?.[0] || {}),
|
|
1173
1173
|
to: toEmailAddresses(msg.to || []),
|
|
1174
1174
|
cc: toEmailAddresses(msg.cc || []),
|
|
1175
|
-
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath: ""
|
|
1175
|
+
flags, size: msg.size || 0, hasAttachments: false, preview: "", bodyPath: "",
|
|
1176
|
+
providerId: msg.providerId || "",
|
|
1176
1177
|
});
|
|
1177
1178
|
stored++;
|
|
1178
1179
|
}
|
|
@@ -1578,7 +1579,11 @@ export class ImapManager extends EventEmitter {
|
|
|
1578
1579
|
async fetchMessageBodyViaApi(accountId, folderId, uid, folderPath) {
|
|
1579
1580
|
try {
|
|
1580
1581
|
const api = this.getGmailProvider(accountId);
|
|
1581
|
-
|
|
1582
|
+
// Read provider_id from the local row so fetchOne can skip the
|
|
1583
|
+
// listMessageIds pagination (the dominant Gmail rate-limit cost).
|
|
1584
|
+
const env = this.db.getMessageByUid(accountId, uid, folderId);
|
|
1585
|
+
const providerId = env?.providerId;
|
|
1586
|
+
const msg = await api.fetchOne(folderPath, uid, { source: true, providerId });
|
|
1582
1587
|
await api.close();
|
|
1583
1588
|
if (!msg) {
|
|
1584
1589
|
// fetchOne returned null — message doesn't exist on the server anymore
|
|
@@ -31,6 +31,12 @@ export declare class MailxService {
|
|
|
31
31
|
deleteMessages(accountId: string, uids: number[]): Promise<void>;
|
|
32
32
|
moveMessage(accountId: string, uid: number, targetFolderId: number, targetAccountId?: string): Promise<void>;
|
|
33
33
|
moveMessages(accountId: string, uids: number[], targetFolderId: number): Promise<void>;
|
|
34
|
+
/** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
|
|
35
|
+
* Throws if the account has no spam folder configured or the folder doesn't exist locally. */
|
|
36
|
+
markAsSpamMessages(accountId: string, uids: number[]): Promise<{
|
|
37
|
+
targetFolderId: number;
|
|
38
|
+
moved: number;
|
|
39
|
+
}>;
|
|
34
40
|
undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
|
|
35
41
|
deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
|
|
36
42
|
createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
|
|
@@ -50,7 +50,7 @@ export class MailxService {
|
|
|
50
50
|
for (const cfg of settings.accounts) {
|
|
51
51
|
const a = dbAccounts.find(d => d.id === cfg.id);
|
|
52
52
|
if (a)
|
|
53
|
-
ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [] });
|
|
53
|
+
ordered.push({ ...a, label: cfg.label, defaultSend: cfg.defaultSend || false, identityDomains: cfg.identityDomains || [], spam: cfg.spam || "" });
|
|
54
54
|
}
|
|
55
55
|
// Append any DB accounts not in settings
|
|
56
56
|
for (const a of dbAccounts) {
|
|
@@ -78,9 +78,18 @@ export class MailxService {
|
|
|
78
78
|
let bodyText = "";
|
|
79
79
|
let hasRemoteContent = false;
|
|
80
80
|
let attachments = [];
|
|
81
|
+
// Wall-clock cap on the server-side body fetch. Without this, a Gmail
|
|
82
|
+
// rate-limit cooldown (shared across providers via module-level state)
|
|
83
|
+
// can park the request indefinitely — the user sees an infinite
|
|
84
|
+
// "Fetching message body..." spinner with no way to recover. The cap
|
|
85
|
+
// surfaces a transient error so the viewer can show a retry banner.
|
|
86
|
+
const BODY_FETCH_TIMEOUT_MS = 45_000;
|
|
81
87
|
let raw = null;
|
|
82
88
|
try {
|
|
83
|
-
raw = await
|
|
89
|
+
raw = await Promise.race([
|
|
90
|
+
this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid),
|
|
91
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("body fetch timeout (45s) — server is slow or rate-limited")), BODY_FETCH_TIMEOUT_MS)),
|
|
92
|
+
]);
|
|
84
93
|
}
|
|
85
94
|
catch (fetchErr) {
|
|
86
95
|
// Message was deleted from the server (another device, expunge, etc.) —
|
|
@@ -453,6 +462,23 @@ export class MailxService {
|
|
|
453
462
|
// Sync to server in background
|
|
454
463
|
this.imapManager.moveMessages(accountId, messages, targetFolderId).catch(e => console.error(` Move sync failed: ${e.message}`));
|
|
455
464
|
}
|
|
465
|
+
/** Move messages to the account's configured spam folder (accounts.jsonc "spam" path).
|
|
466
|
+
* Throws if the account has no spam folder configured or the folder doesn't exist locally. */
|
|
467
|
+
async markAsSpamMessages(accountId, uids) {
|
|
468
|
+
const settings = loadSettings();
|
|
469
|
+
const account = settings.accounts.find(a => a.id === accountId);
|
|
470
|
+
if (!account)
|
|
471
|
+
throw new Error(`Account ${accountId} not found`);
|
|
472
|
+
const spamPath = account.spam;
|
|
473
|
+
if (!spamPath)
|
|
474
|
+
throw new Error(`Account ${accountId} has no "spam" folder configured`);
|
|
475
|
+
const folders = this.db.getFolders(accountId);
|
|
476
|
+
const target = folders.find(f => f.path.toLowerCase() === spamPath.toLowerCase());
|
|
477
|
+
if (!target)
|
|
478
|
+
throw new Error(`Spam folder "${spamPath}" not found in ${accountId}`);
|
|
479
|
+
await this.moveMessages(accountId, uids, target.id);
|
|
480
|
+
return { targetFolderId: target.id, moved: uids.length };
|
|
481
|
+
}
|
|
456
482
|
async undeleteMessage(accountId, uid, folderId) {
|
|
457
483
|
await this.imapManager.undeleteMessage(accountId, uid, folderId);
|
|
458
484
|
}
|
|
@@ -53,6 +53,8 @@ async function dispatchAction(svc, action, p) {
|
|
|
53
53
|
case "moveMessages":
|
|
54
54
|
await svc.moveMessages(p.accountId, p.uids, p.targetFolderId);
|
|
55
55
|
return { ok: true, count: p.uids.length };
|
|
56
|
+
case "markAsSpamMessages":
|
|
57
|
+
return await svc.markAsSpamMessages(p.accountId, p.uids);
|
|
56
58
|
// Folders
|
|
57
59
|
case "markFolderRead":
|
|
58
60
|
svc.markFolderRead(p.folderId);
|
|
@@ -66,6 +66,7 @@ export declare class MailxDB {
|
|
|
66
66
|
hasAttachments: boolean;
|
|
67
67
|
preview: string;
|
|
68
68
|
bodyPath: string;
|
|
69
|
+
providerId?: string;
|
|
69
70
|
}): number;
|
|
70
71
|
getMessages(query: MessageQuery): PagedResult<MessageEnvelope>;
|
|
71
72
|
/** Unified inbox: all inbox folders across accounts, sorted by date, paginated in SQL */
|
|
@@ -146,6 +146,11 @@ export class MailxDB {
|
|
|
146
146
|
this.db.exec("CREATE INDEX IF NOT EXISTS idx_messages_thread_id ON messages(account_id, thread_id)");
|
|
147
147
|
}
|
|
148
148
|
catch { /* already exists */ }
|
|
149
|
+
// provider_id: native server-side id for API-backed providers (Gmail
|
|
150
|
+
// hex id, Outlook Graph id, etc.). Lets fetchOne look up the message
|
|
151
|
+
// directly instead of paginating listMessageIds for every body fetch
|
|
152
|
+
// — a UID-only path costs 2-3 rate-limited API calls per message.
|
|
153
|
+
this.addColumnIfMissing("messages", "provider_id", "TEXT");
|
|
149
154
|
}
|
|
150
155
|
// ── Sent-log (dedup) ──
|
|
151
156
|
/** Has this Message-ID already been sent? Used to prevent the outbox from
|
|
@@ -301,8 +306,13 @@ export class MailxDB {
|
|
|
301
306
|
}
|
|
302
307
|
// ── Messages ──
|
|
303
308
|
upsertMessage(msg) {
|
|
304
|
-
const existing = this.db.prepare("SELECT id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
|
|
309
|
+
const existing = this.db.prepare("SELECT id, provider_id FROM messages WHERE account_id = ? AND folder_id = ? AND uid = ?").get(msg.accountId, msg.folderId, msg.uid);
|
|
305
310
|
if (existing) {
|
|
311
|
+
// Backfill provider_id on existing rows that predate this column —
|
|
312
|
+
// critical for body fetch to bypass listMessageIds pagination.
|
|
313
|
+
if (msg.providerId && !existing.provider_id) {
|
|
314
|
+
this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
|
|
315
|
+
}
|
|
306
316
|
this.db.prepare(`
|
|
307
317
|
UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
|
|
308
318
|
WHERE id = ?
|
|
@@ -320,9 +330,9 @@ export class MailxDB {
|
|
|
320
330
|
INSERT INTO messages (
|
|
321
331
|
account_id, folder_id, uid, message_id, in_reply_to, refs, thread_id,
|
|
322
332
|
date, subject, from_address, from_name, to_json, cc_json,
|
|
323
|
-
flags_json, size, has_attachments, preview, body_path, cached_at
|
|
324
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
325
|
-
`).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now());
|
|
333
|
+
flags_json, size, has_attachments, preview, body_path, cached_at, provider_id
|
|
334
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
335
|
+
`).run(msg.accountId, msg.folderId, msg.uid, msg.messageId, msg.inReplyTo, JSON.stringify(msg.references), threadId, msg.date, msg.subject, msg.from.address, msg.from.name, JSON.stringify(msg.to), JSON.stringify(msg.cc), JSON.stringify(msg.flags), msg.size, msg.hasAttachments ? 1 : 0, msg.preview, msg.bodyPath, Date.now(), msg.providerId || null);
|
|
326
336
|
const rowId = Number(result.lastInsertRowid);
|
|
327
337
|
// Index for full-text search
|
|
328
338
|
try {
|
|
@@ -433,7 +443,8 @@ export class MailxDB {
|
|
|
433
443
|
flags: JSON.parse(r.flags_json),
|
|
434
444
|
size: r.size,
|
|
435
445
|
hasAttachments: !!r.has_attachments,
|
|
436
|
-
preview: r.preview
|
|
446
|
+
preview: r.preview,
|
|
447
|
+
providerId: r.provider_id || undefined,
|
|
437
448
|
};
|
|
438
449
|
}
|
|
439
450
|
getMessageBodyPath(accountId, uid) {
|
|
@@ -34,6 +34,7 @@ export interface AccountConfig {
|
|
|
34
34
|
relayDomains?: string[]; /** Domains to skip in Delivered-To chain (e.g., ["m.connectivity.xyz"]) */
|
|
35
35
|
deliveredToPrefix?: string[]; /** Prefixes to strip from Delivered-To to get clean alias (e.g., ["bobf-ma-", "bobf-"]) — order matters, longest first */
|
|
36
36
|
identityDomains?: string[]; /** Domains where Delivered-To address should become the reply From (e.g., ["bob.ma", "bobf.frankston.com"]) */
|
|
37
|
+
spam?: string; /** IMAP folder path for "Mark as spam" button (e.g., "_spam"). Button hidden when not set. */
|
|
37
38
|
}
|
|
38
39
|
/** Standard IMAP special-use folder types */
|
|
39
40
|
export type SpecialUse = "inbox" | "sent" | "drafts" | "trash" | "junk" | "archive" | "all";
|
|
@@ -75,6 +76,7 @@ export interface MessageEnvelope {
|
|
|
75
76
|
hasAttachments: boolean;
|
|
76
77
|
preview: string; /** First ~200 chars of body text */
|
|
77
78
|
bodyPath?: string; /** Local body location: "idb:..." or "gmail:<id>" */
|
|
79
|
+
providerId?: string; /** Native server id (Gmail hex id, Outlook Graph id) — bypasses UID→id pagination on body fetch */
|
|
78
80
|
}
|
|
79
81
|
/** Full message with body content */
|
|
80
82
|
export interface Message extends MessageEnvelope {
|