@bobfrankston/mailx 1.0.265 → 1.0.283

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.
@@ -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) {
@@ -550,20 +560,22 @@ export async function showMessage(accountId, uid, folderId, specialUse, isRetry
550
560
  // "Message was deleted from the server" — the service already dropped
551
561
  // the local row. Remove it from the list so the UI advances to the next
552
562
  // message instead of sitting on a stale error banner.
553
- const isDeleted = /deleted from the server|isNotFound/.test(err);
554
- if (isDeleted) {
563
+ const isNotFound = /deleted from the server|isNotFound|not found|Not Found|404/.test(err);
564
+ if (isNotFound) {
565
+ // Drop the stale row so the list auto-advances to the next message
566
+ // (or clears the viewer). Leaves the user a way back on mobile where
567
+ // the viewer takes the whole screen.
555
568
  state.removeMessages([{ accountId, uid }]);
556
569
  return;
557
570
  }
558
- const isNotFound = err.includes("not found") || err.includes("Not Found") || err.includes("404");
559
- if (!isNotFound && retryCount < 3) {
571
+ if (retryCount < 3) {
560
572
  retryCount++;
561
573
  bodyEl.innerHTML = `<div class="mv-empty">Loading failed: ${err} — retrying (${retryCount}/3)...</div>`;
562
574
  setTimeout(() => { if (gen === showMessageGeneration)
563
575
  showMessage(accountId, uid, folderId, specialUse, true); }, 3000);
564
576
  }
565
577
  else {
566
- bodyEl.innerHTML = `<div class="mv-empty">${isNotFound ? "Message was moved or deleted" : `Failed to load: ${err}`}</div>`;
578
+ bodyEl.innerHTML = `<div class="mv-empty">Failed to load: ${err}</div>`;
567
579
  }
568
580
  }
569
581
  }
@@ -658,7 +670,28 @@ ${csp}
658
670
  }, true);
659
671
  document.addEventListener("mouseover", function (e) {
660
672
  var a = e.target && e.target.closest ? e.target.closest("a[href]") : null;
661
- window.parent.postMessage({ type: "linkHover", url: a ? a.href : "" }, "*");
673
+ if (a) {
674
+ var r = a.getBoundingClientRect();
675
+ window.parent.postMessage({ type: "linkHover", url: a.href, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } }, "*");
676
+ } else {
677
+ window.parent.postMessage({ type: "linkHover", url: "" }, "*");
678
+ }
679
+ });
680
+ // Key forwarding — Delete, Ctrl+D, arrow keys, etc. need to reach app.ts
681
+ // even when focus is inside the sandboxed iframe. Parent-side
682
+ // contentDocument listeners (see installPreviewControls) work on
683
+ // desktop WebView2 but not Android WebView, so we post every keydown
684
+ // that isn't plain typing.
685
+ document.addEventListener("keydown", function (e) {
686
+ var t = e.target;
687
+ if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
688
+ // Zoom keys handled by parent-side installPreviewControls; don't double-send.
689
+ if (e.ctrlKey && (e.key === "=" || e.key === "+" || e.key === "-" || e.key === "0")) return;
690
+ window.parent.postMessage({
691
+ type: "previewKey",
692
+ key: e.key, code: e.code,
693
+ ctrlKey: e.ctrlKey, shiftKey: e.shiftKey, altKey: e.altKey, metaKey: e.metaKey,
694
+ }, "*");
662
695
  });
663
696
  })();
664
697
  </script>
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>
@@ -33,9 +33,9 @@ export function getAccounts() {
33
33
  export function getFolders(accountId) {
34
34
  return ipc().getFolders(accountId);
35
35
  }
36
- export function getMessages(accountId, folderId, page = 1, pageSize = 50) {
36
+ export function getMessages(accountId, folderId, page = 1, pageSize = 50, flaggedOnly = false) {
37
37
  abortMessageListRequests();
38
- return ipc().getMessages(accountId, folderId, page, pageSize);
38
+ return ipc().getMessages(accountId, folderId, page, pageSize, undefined, undefined, undefined, flaggedOnly);
39
39
  }
40
40
  export function getUnifiedInbox(page = 1, pageSize = 50) {
41
41
  abortMessageListRequests();
@@ -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
  }
@@ -65,8 +65,8 @@
65
65
  getFolders: function(accountId) { return callNode("getFolders", { accountId: accountId }); },
66
66
 
67
67
  // Messages
68
- getMessages: function(accountId, folderId, page, pageSize) {
69
- return callNode("getMessages", { accountId: accountId, folderId: folderId, page: page, pageSize: pageSize });
68
+ getMessages: function(accountId, folderId, page, pageSize, sort, sortDir, search, flaggedOnly) {
69
+ return callNode("getMessages", { accountId: accountId, folderId: folderId, page: page, pageSize: pageSize, sort: sort, sortDir: sortDir, search: search, flaggedOnly: flaggedOnly });
70
70
  },
71
71
  getUnifiedInbox: function(page, pageSize) {
72
72
  return callNode("getUnifiedInbox", { page: page, pageSize: pageSize });
@@ -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.265",
3
+ "version": "1.0.283",
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.20",
24
- "@bobfrankston/iflow-node": "^0.1.5",
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.318",
27
+ "@bobfrankston/msger": "^0.1.320",
28
28
  "@capacitor/android": "^8.3.0",
29
29
  "@capacitor/cli": "^8.3.0",
30
30
  "@capacitor/core": "^8.3.0",
@@ -35,10 +35,10 @@
35
35
  "quill": "^2.0.3",
36
36
  "ws": "^8.18.0",
37
37
  "sql.js": "^1.14.1",
38
- "@bobfrankston/tcp-transport": "^0.1.3",
39
- "@bobfrankston/node-tcp-transport": "^0.1.1",
40
- "@bobfrankston/smtp-direct": "^0.1.2",
41
- "@bobfrankston/mailx-sync": "^0.1.4"
38
+ "@bobfrankston/tcp-transport": "^0.1.4",
39
+ "@bobfrankston/node-tcp-transport": "^0.1.4",
40
+ "@bobfrankston/smtp-direct": "^0.1.3",
41
+ "@bobfrankston/mailx-sync": "^0.1.6"
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.20",
86
- "@bobfrankston/iflow-node": "^0.1.5",
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.318",
89
+ "@bobfrankston/msger": "^0.1.320",
90
90
  "@capacitor/android": "^8.3.0",
91
91
  "@capacitor/cli": "^8.3.0",
92
92
  "@capacitor/core": "^8.3.0",
@@ -97,10 +97,10 @@
97
97
  "quill": "^2.0.3",
98
98
  "ws": "^8.18.0",
99
99
  "sql.js": "^1.14.1",
100
- "@bobfrankston/tcp-transport": "^0.1.3",
101
- "@bobfrankston/node-tcp-transport": "^0.1.1",
102
- "@bobfrankston/smtp-direct": "^0.1.2",
103
- "@bobfrankston/mailx-sync": "^0.1.4"
100
+ "@bobfrankston/tcp-transport": "^0.1.4",
101
+ "@bobfrankston/node-tcp-transport": "^0.1.4",
102
+ "@bobfrankston/smtp-direct": "^0.1.3",
103
+ "@bobfrankston/mailx-sync": "^0.1.6"
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 {
@@ -28,6 +28,7 @@ export declare function getMessages(params: {
28
28
  sort?: string;
29
29
  sortDir?: string;
30
30
  search?: string;
31
+ flaggedOnly?: boolean;
31
32
  }): import("@bobfrankston/mailx-types").PagedResult<import("@bobfrankston/mailx-types").MessageEnvelope>;
32
33
  export declare function getUnifiedInbox(params: {
33
34
  page?: number;
@@ -67,6 +68,7 @@ export declare function getMessage(params: {
67
68
  hasAttachments: boolean;
68
69
  preview: string;
69
70
  bodyPath?: string;
71
+ providerId?: string;
70
72
  }>;
71
73
  export declare function updateFlags(params: {
72
74
  accountId: string;
@@ -122,6 +122,7 @@ export function getMessages(params) {
122
122
  sort: params.sort || "date",
123
123
  sortDir: params.sortDir || "desc",
124
124
  search: params.search,
125
+ flaggedOnly: params.flaggedOnly,
125
126
  });
126
127
  }
127
128
  export function getUnifiedInbox(params) {
@@ -19,6 +19,9 @@ export interface ImapManagerEvents {
19
19
  }>) => void;
20
20
  accountError: (accountId: string, error: string, hint: string, isOAuth: boolean) => void;
21
21
  configChanged: (filename: string) => void;
22
+ /** Fired after a message body has been written to the local store — lets
23
+ * the UI flip a row's "not-downloaded" indicator without re-rendering. */
24
+ bodyCached: (accountId: string, uid: number) => void;
22
25
  }
23
26
  export declare class ImapManager extends EventEmitter {
24
27
  private configs;
@@ -55,13 +58,31 @@ export declare class ImapManager extends EventEmitter {
55
58
  private opsClients;
56
59
  /** Operation queues — ensures sequential access per account */
57
60
  private opsQueues;
61
+ /** Persistent body-fetch connections — separate from ops so on-demand
62
+ * body reads never queue behind a slow sync operation (bobma's IMAP
63
+ * SEARCH can sit idle for 300s during backfill). */
64
+ private bodyClients;
65
+ /** Per-account backoff after the IMAP server rejected a connection with
66
+ * the per-user+IP cap (Dovecot mail_max_userip_connections). Subsequent
67
+ * body fetches short-circuit until the timestamp passes. */
68
+ private bodyBackoff;
58
69
  /** Get (or create) the persistent operational connection for an account.
59
70
  * logout() is wrapped as a no-op so legacy callers don't close it. */
60
71
  private getOpsClient;
61
72
  /** Run an operation on the account's connection — queued, sequential, no concurrency */
62
73
  withConnection<T>(accountId: string, fn: (client: any) => Promise<T>): Promise<T>;
63
- /** Create a new IMAP client (internal callers use getOpsClient or withConnection) */
74
+ /** Open IMAP clients per account, used to trace who's opening sockets
75
+ * when we hit the Dovecot per-user+IP connection cap. */
76
+ private openClients;
77
+ /** Create a new IMAP client (internal — callers use getOpsClient or withConnection).
78
+ * `purpose` is a short tag printed alongside the `[conn+]` log so we can tell
79
+ * which code path (sync/idle/body/outbox/move/…) opened each connection. */
64
80
  private newClient;
81
+ /** Get (or lazily create) the persistent body-fetch client. Separate from
82
+ * the ops client so body reads never wait on a slow sync command. */
83
+ private getBodyClient;
84
+ /** Drop the body-fetch connection (e.g. after a socket error). */
85
+ private dropBodyClient;
65
86
  /** Disconnect the persistent operational connection for an account */
66
87
  disconnectOps(accountId: string): Promise<void>;
67
88
  /** Legacy API — callers that still create/destroy connections.