@bobfrankston/mailx 1.0.382 → 1.0.384

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/client/app.js CHANGED
@@ -971,6 +971,36 @@ async function refreshSpamButtonVisibility() {
971
971
  }
972
972
  document.addEventListener("mailx-message-shown", refreshSpamButtonVisibility);
973
973
  document.addEventListener("mailx-folder-changed", refreshSpamButtonVisibility);
974
+ // Q100 placeholder — append a row to ~/.mailx/spam.csv for later analysis.
975
+ // No folder move, no flag change, no auto-delete. Button is always visible
976
+ // (no configuration required; unlike btn-spam which needs a junk folder).
977
+ document.getElementById("btn-spam-report")?.addEventListener("click", async () => {
978
+ const current = getCurrentMessage();
979
+ const msg = current?.message;
980
+ const accountId = current?.accountId;
981
+ if (!msg || !accountId)
982
+ return;
983
+ const btn = document.getElementById("btn-spam-report");
984
+ const originalLabel = btn.textContent;
985
+ btn.disabled = true;
986
+ btn.textContent = "…";
987
+ try {
988
+ const { recordSpamReport } = await import("./lib/api-client.js");
989
+ await recordSpamReport(accountId, msg.uid, msg.folderId);
990
+ btn.textContent = "✓";
991
+ const status = document.getElementById("status-sync");
992
+ if (status)
993
+ status.textContent = "Logged to ~/.mailx/spam.csv";
994
+ setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
995
+ }
996
+ catch (e) {
997
+ btn.textContent = "✗";
998
+ const status = document.getElementById("status-sync");
999
+ if (status)
1000
+ status.textContent = `Spam log failed: ${e?.message || e}`;
1001
+ setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 2500);
1002
+ }
1003
+ });
974
1004
  document.getElementById("btn-compose")?.addEventListener("click", () => openCompose("new"));
975
1005
  document.getElementById("btn-mark-unread")?.addEventListener("click", () => {
976
1006
  // Toggle \Seen on the currently-selected message. Mirrors the R
package/client/index.html CHANGED
@@ -35,6 +35,11 @@
35
35
  <div class="tb-menu" id="settings-menu">
36
36
  <button class="tb-btn" id="btn-settings">Settings</button>
37
37
  <div class="tb-menu-dropdown" id="settings-dropdown" hidden>
38
+ <span class="tb-menu-label">Theme</span>
39
+ <label class="tb-menu-item"><input type="radio" name="opt-theme" value="system" id="opt-theme-system"> System</label>
40
+ <label class="tb-menu-item"><input type="radio" name="opt-theme" value="light" id="opt-theme-light"> Light</label>
41
+ <label class="tb-menu-item"><input type="radio" name="opt-theme" value="dark" id="opt-theme-dark"> Dark</label>
42
+ <hr class="tb-menu-sep">
38
43
  <span class="tb-menu-label">Editor</span>
39
44
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="quill" id="opt-editor-quill" checked> Quill</label>
40
45
  <label class="tb-menu-item"><input type="radio" name="opt-editor" value="tiptap" id="opt-editor-tiptap"> tiptap</label>
@@ -133,6 +138,7 @@
133
138
  <button class="tb-btn" id="btn-forward" title="Forward">→</button>
134
139
  <button class="tb-btn" id="btn-delete" title="Delete (Del)">🗑</button>
135
140
  <button class="tb-btn" id="btn-spam" title="Mark as spam — move to configured spam folder" hidden>⚠</button>
141
+ <button class="tb-btn" id="btn-spam-report" title="Report as spam (append to ~/.mailx/spam.csv for later analysis)">🚫</button>
136
142
  <button class="tb-btn" id="btn-flag" title="Flag">⚑</button>
137
143
  <button class="tb-btn" id="btn-mark-unread" title="Mark unread (R)">◉</button>
138
144
  <button class="tb-btn" id="mv-view-thread" title="View thread (conversation)" hidden>💬</button>
@@ -170,6 +170,12 @@ export function deleteTask(uuid) {
170
170
  export function drainStoreSync() {
171
171
  return ipc().drainStoreSync?.();
172
172
  }
173
+ /** Report the currently-viewed message as spam → appends a row to
174
+ * `~/.mailx/spam.csv`. Placeholder: no folder move, no flag change, no
175
+ * auto-delete. Training data for a smarter pass later. */
176
+ export function recordSpamReport(accountId, uid, folderId) {
177
+ return ipc().recordSpamReport?.(accountId, uid, folderId);
178
+ }
173
179
  export function getOutboxStatus() {
174
180
  return ipc().getOutboxStatus();
175
181
  }
@@ -122,6 +122,9 @@
122
122
  updateTask: function(uuid, patch) { return callNode("updateTask", { uuid: uuid, patch: patch }); },
123
123
  deleteTask: function(uuid) { return callNode("deleteTask", { uuid: uuid }); },
124
124
  drainStoreSync: function() { return callNode("drainStoreSync"); },
125
+ recordSpamReport: function(accountId, uid, folderId) {
126
+ return callNode("recordSpamReport", { accountId: accountId, uid: uid, folderId: folderId });
127
+ },
125
128
  readJsoncFile: function(name) {
126
129
  return callNode("readJsoncFile", { name: name });
127
130
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx",
3
- "version": "1.0.382",
3
+ "version": "1.0.384",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -39,7 +39,7 @@
39
39
  "@bobfrankston/tcp-transport": "^0.1.4",
40
40
  "@bobfrankston/node-tcp-transport": "^0.1.4",
41
41
  "@bobfrankston/smtp-direct": "^0.1.4",
42
- "@bobfrankston/mailx-sync": "^0.1.8"
42
+ "@bobfrankston/mailx-sync": "^0.1.9"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@types/mailparser": "^3.4.6"
@@ -103,7 +103,7 @@
103
103
  "@bobfrankston/tcp-transport": "^0.1.4",
104
104
  "@bobfrankston/node-tcp-transport": "^0.1.4",
105
105
  "@bobfrankston/smtp-direct": "^0.1.4",
106
- "@bobfrankston/mailx-sync": "^0.1.8"
106
+ "@bobfrankston/mailx-sync": "^0.1.9"
107
107
  }
108
108
  }
109
109
  }
@@ -227,6 +227,20 @@ export declare class ImapManager extends EventEmitter {
227
227
  * prefetch session alongside any still in flight, blowing through Gmail's
228
228
  * per-minute quota and racing on disk writes. One prefetch per account. */
229
229
  private prefetchingAccounts;
230
+ /** Per-folder error cooldowns — `accountId:folderPath` → [timestamps].
231
+ * Used to skip folders that repeatedly time out (Dovecot on slow shared
232
+ * hosting SELECTs big `_Spam` / archive folders at 300s+ latency; the
233
+ * prefetch error budget was burning out on a handful of bad folders
234
+ * before the INBOX could finish). A folder with 2+ errors in the last
235
+ * 15 minutes is skipped until the cooldown passes. User-reported via
236
+ * log analysis 2026-04-23: bobma prefetch timing out on Added2.organizations,
237
+ * Added2.technews, _Spam, "Prefirst.Jerry's Retreat", Added2.zines. */
238
+ private folderErrorCooldown;
239
+ private readonly FOLDER_ERROR_WINDOW_MS;
240
+ private readonly FOLDER_ERROR_THRESHOLD;
241
+ private shouldSkipFolder;
242
+ private recordFolderError;
243
+ private clearFolderErrors;
230
244
  private prefetchBodies;
231
245
  private _prefetchBodies;
232
246
  /** Get the body store for direct access */
@@ -1925,6 +1925,34 @@ export class ImapManager extends EventEmitter {
1925
1925
  * prefetch session alongside any still in flight, blowing through Gmail's
1926
1926
  * per-minute quota and racing on disk writes. One prefetch per account. */
1927
1927
  prefetchingAccounts = new Set();
1928
+ /** Per-folder error cooldowns — `accountId:folderPath` → [timestamps].
1929
+ * Used to skip folders that repeatedly time out (Dovecot on slow shared
1930
+ * hosting SELECTs big `_Spam` / archive folders at 300s+ latency; the
1931
+ * prefetch error budget was burning out on a handful of bad folders
1932
+ * before the INBOX could finish). A folder with 2+ errors in the last
1933
+ * 15 minutes is skipped until the cooldown passes. User-reported via
1934
+ * log analysis 2026-04-23: bobma prefetch timing out on Added2.organizations,
1935
+ * Added2.technews, _Spam, "Prefirst.Jerry's Retreat", Added2.zines. */
1936
+ folderErrorCooldown = new Map();
1937
+ FOLDER_ERROR_WINDOW_MS = 15 * 60_000;
1938
+ FOLDER_ERROR_THRESHOLD = 2;
1939
+ shouldSkipFolder(accountId, folderPath) {
1940
+ const key = `${accountId}:${folderPath}`;
1941
+ const now = Date.now();
1942
+ const errors = (this.folderErrorCooldown.get(key) || [])
1943
+ .filter(t => now - t < this.FOLDER_ERROR_WINDOW_MS);
1944
+ this.folderErrorCooldown.set(key, errors);
1945
+ return errors.length >= this.FOLDER_ERROR_THRESHOLD;
1946
+ }
1947
+ recordFolderError(accountId, folderPath) {
1948
+ const key = `${accountId}:${folderPath}`;
1949
+ const arr = this.folderErrorCooldown.get(key) || [];
1950
+ arr.push(Date.now());
1951
+ this.folderErrorCooldown.set(key, arr);
1952
+ }
1953
+ clearFolderErrors(accountId, folderPath) {
1954
+ this.folderErrorCooldown.delete(`${accountId}:${folderPath}`);
1955
+ }
1928
1956
  async prefetchBodies(accountId) {
1929
1957
  if (this.prefetchingAccounts.has(accountId))
1930
1958
  return;
@@ -2061,10 +2089,26 @@ export class ImapManager extends EventEmitter {
2061
2089
  let client = null;
2062
2090
  try {
2063
2091
  client = await this.createClientWithLimit(accountId);
2064
- for (const [folderId, uids] of byFolder) {
2092
+ // INBOX-first ordering so the folder the user actually looks at
2093
+ // gets its bodies even if a later folder eats the error budget.
2094
+ const orderedFolders = Array.from(byFolder.entries()).sort(([aid], [bid]) => {
2095
+ const af = folders.find(f => f.id === aid);
2096
+ const bf = folders.find(f => f.id === bid);
2097
+ const ai = af?.specialUse === "inbox" ? 0 : 1;
2098
+ const bi = bf?.specialUse === "inbox" ? 0 : 1;
2099
+ return ai - bi;
2100
+ });
2101
+ for (const [folderId, uids] of orderedFolders) {
2065
2102
  const folder = folders.find(f => f.id === folderId);
2066
2103
  if (!folder)
2067
2104
  continue;
2105
+ // Skip folders that have repeatedly timed out — keeps one
2106
+ // slow folder from burning the whole error budget and
2107
+ // starving the folder the user is actually looking at.
2108
+ if (this.shouldSkipFolder(accountId, folder.path)) {
2109
+ console.log(` [prefetch] ${accountId}: skipping ${folder.path} (recent timeouts — cooling down)`);
2110
+ continue;
2111
+ }
2068
2112
  const received = new Set();
2069
2113
  // onBody fires synchronously as each message streams in from the server.
2070
2114
  // Disk/DB writes are kicked off fire-and-forget; we await them after the
@@ -2091,10 +2135,13 @@ export class ImapManager extends EventEmitter {
2091
2135
  })());
2092
2136
  });
2093
2137
  batchSucceeded = true;
2138
+ // Folder responded — clear its error history.
2139
+ this.clearFolderErrors(accountId, folder.path);
2094
2140
  }
2095
2141
  catch (e) {
2096
2142
  console.error(` [prefetch] ${accountId} folder ${folder.path}: batch fetch failed: ${e.message}`);
2097
2143
  counters.errors++;
2144
+ this.recordFolderError(accountId, folder.path);
2098
2145
  if (counters.errors >= ERROR_BUDGET)
2099
2146
  break;
2100
2147
  }
@@ -109,6 +109,18 @@ export declare class MailxService {
109
109
  targetFolderId: number;
110
110
  moved: number;
111
111
  }>;
112
+ /** Append a spam report row to `~/.mailx/spam.csv` — placeholder mechanism
113
+ * per user 2026-04-23 ("let's make it smart later; no auto-delete until
114
+ * safety issues are addressed"). One row per click. Columns: timestamp
115
+ * (ms since epoch), ISO date, ISO time, accountId, Delivered-To, From
116
+ * address, Subject, eml file path. CSV fields RFC 4180-quoted so commas
117
+ * and quotes in subjects survive. No move, no flag change, no server
118
+ * hit — just the log. Useful as training data for a future classifier.
119
+ */
120
+ recordSpamReport(accountId: string, uid: number, folderId: number): Promise<{
121
+ ok: true;
122
+ row: string;
123
+ }>;
112
124
  undeleteMessage(accountId: string, uid: number, folderId: number): Promise<void>;
113
125
  deleteOnServer(accountId: string, folderPath: string, uid: number): Promise<void>;
114
126
  createFolder(accountId: string, parentPath: string, name: string): Promise<void>;
@@ -943,6 +943,56 @@ export class MailxService {
943
943
  await this.moveMessages(accountId, uids, target.id);
944
944
  return { targetFolderId: target.id, moved: uids.length };
945
945
  }
946
+ /** Append a spam report row to `~/.mailx/spam.csv` — placeholder mechanism
947
+ * per user 2026-04-23 ("let's make it smart later; no auto-delete until
948
+ * safety issues are addressed"). One row per click. Columns: timestamp
949
+ * (ms since epoch), ISO date, ISO time, accountId, Delivered-To, From
950
+ * address, Subject, eml file path. CSV fields RFC 4180-quoted so commas
951
+ * and quotes in subjects survive. No move, no flag change, no server
952
+ * hit — just the log. Useful as training data for a future classifier.
953
+ */
954
+ async recordSpamReport(accountId, uid, folderId) {
955
+ const env = this.db.getMessageByUid(accountId, uid, folderId);
956
+ if (!env)
957
+ throw new Error(`Message not found: ${accountId}/${uid}`);
958
+ const bodyPath = env.bodyPath || "";
959
+ // Prefer `body_path` (authoritative). `Delivered-To` isn't in the
960
+ // envelope struct, so parse from the cached `.eml` if available.
961
+ let deliveredTo = "";
962
+ if (bodyPath) {
963
+ try {
964
+ const raw = fs.readFileSync(bodyPath, "utf-8").slice(0, 4096);
965
+ const m = raw.match(/^Delivered-To:\s*(.+)$/mi);
966
+ if (m)
967
+ deliveredTo = m[1].trim();
968
+ }
969
+ catch { /* not fatal — leave blank */ }
970
+ }
971
+ const now = new Date();
972
+ const isoDate = now.toISOString().slice(0, 10);
973
+ const isoTime = now.toISOString().slice(11, 19);
974
+ const fromAddr = env.from?.address || "";
975
+ const subject = env.subject || "";
976
+ const csvEscape = (s) => `"${String(s).replace(/"/g, '""')}"`;
977
+ const row = [
978
+ String(now.getTime()),
979
+ csvEscape(isoDate),
980
+ csvEscape(isoTime),
981
+ csvEscape(accountId),
982
+ csvEscape(deliveredTo),
983
+ csvEscape(fromAddr),
984
+ csvEscape(subject),
985
+ csvEscape(bodyPath),
986
+ ].join(",") + "\n";
987
+ const spamCsvPath = path.join(getConfigDir(), "spam.csv");
988
+ // Write a header if the file doesn't exist yet so the CSV is self-describing.
989
+ if (!fs.existsSync(spamCsvPath)) {
990
+ fs.writeFileSync(spamCsvPath, "timestamp_ms,date,time,account,delivered_to,from,subject,eml_path\n", "utf-8");
991
+ }
992
+ fs.appendFileSync(spamCsvPath, row, "utf-8");
993
+ console.log(` [spam] reported ${accountId}/${uid} → spam.csv`);
994
+ return { ok: true, row };
995
+ }
946
996
  async undeleteMessage(accountId, uid, folderId) {
947
997
  // Clear the tombstone first so a subsequent sync can re-import if
948
998
  // the server still has the row. Messages with no Message-ID just
@@ -123,6 +123,8 @@ async function dispatchAction(svc, action, p) {
123
123
  case "drainStoreSync":
124
124
  await svc.drainStoreSync();
125
125
  return { ok: true };
126
+ case "recordSpamReport":
127
+ return await svc.recordSpamReport(p.accountId, p.uid, p.folderId);
126
128
  case "getOutboxStatus":
127
129
  return svc.getOutboxStatus();
128
130
  case "listQueuedOutgoing":