@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 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":1344,"width":2151,"x":284,"y":38}
1
+ {"height":1421,"width":2151,"x":228,"y":93}
@@ -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 (if multiple accounts)
646
- if (accounts.length > 1) {
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 = "Merged inbox view of all accounts click to see messages from every account's INBOX sorted by date";
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
- toEl.textContent = `To: ${msg.to.map(formatAddr).join(", ")}`;
193
- if (msg.cc?.length) {
194
- toEl.textContent += ` Cc: ${msg.cc.map(formatAddr).join(", ")}`;
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-banner";
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 style="font-weight:600;margin-bottom:0.25rem;color:#d33">Body unavailable</div>
438
- <div style="color:var(--color-text-muted);white-space:pre-wrap;word-break:break-word">${err.replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c] || c))}</div>
439
- ${isTransient ? `<button id="btn-retry-body" style="margin-top:0.5rem;padding:0.25rem 0.75rem">Retry</button>` : ""}
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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>
@@ -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
  }
@@ -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.278",
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.22",
24
- "@bobfrankston/iflow-node": "^0.1.6",
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.320",
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.3",
40
- "@bobfrankston/smtp-direct": "^0.1.3",
41
- "@bobfrankston/mailx-sync": "^0.1.6"
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.22",
86
- "@bobfrankston/iflow-node": "^0.1.6",
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.320",
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.3",
102
- "@bobfrankston/smtp-direct": "^0.1.3",
103
- "@bobfrankston/mailx-sync": "^0.1.6"
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 {
@@ -68,6 +68,7 @@ export declare function getMessage(params: {
68
68
  hasAttachments: boolean;
69
69
  preview: string;
70
70
  bodyPath?: string;
71
+ providerId?: string;
71
72
  }>;
72
73
  export declare function updateFlags(params: {
73
74
  accountId: string;
@@ -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
- const msg = await api.fetchOne(folderPath, uid, { source: true });
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 this.imapManager.fetchMessageBody(accountId, envelope.folderId, envelope.uid);
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 {