@bobfrankston/mailx 1.0.383 → 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.383",
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",
@@ -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":