@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 +30 -0
- package/client/index.html +6 -0
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +3 -0
- package/package.json +3 -3
- package/packages/mailx-imap/index.d.ts +14 -0
- package/packages/mailx-imap/index.js +48 -1
- package/packages/mailx-service/index.d.ts +12 -0
- package/packages/mailx-service/index.js +50 -0
- package/packages/mailx-service/jsonrpc.js +2 -0
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>
|
package/client/lib/api-client.js
CHANGED
|
@@ -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
|
}
|
package/client/lib/mailxapi.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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":
|