@bobfrankston/mailx 1.0.306 → 1.0.313
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 +15 -2
- package/bin/mailx.js +56 -1
- package/client/app.js +436 -21
- package/client/components/folder-picker.js +119 -0
- package/client/components/message-list.js +23 -1
- package/client/components/message-viewer.js +140 -22
- package/client/compose/compose.css +50 -0
- package/client/compose/compose.js +68 -12
- package/client/compose/editor.js +51 -7
- package/client/index.html +19 -0
- package/client/lib/api-client.js +12 -0
- package/client/lib/mailxapi.js +16 -7
- package/client/styles/components.css +115 -0
- package/client/styles/layout.css +64 -14
- package/client/styles/variables.css +1 -0
- package/package.json +8 -5
- package/packages/mailx-core/index.d.ts +3 -0
- package/packages/mailx-core/index.js +45 -7
- package/packages/mailx-host/index.d.ts +21 -0
- package/packages/mailx-host/index.js +31 -0
- package/packages/mailx-host/package.json +23 -0
- package/packages/mailx-imap/index.js +33 -0
- package/packages/mailx-service/index.d.ts +18 -1
- package/packages/mailx-service/index.js +198 -6
- package/packages/mailx-service/jsonrpc.js +8 -0
- package/packages/mailx-store/db.js +47 -5
- package/packages/mailx-store-web/android-bootstrap.js +91 -2
- package/packages/mailx-store-web/db.js +4 -1
- package/packages/mailx-store-web/main-thread-host.js +2 -2
- package/packages/mailx-types/index.d.ts +21 -0
- package/tempfix.cmd +77 -0
|
@@ -6,9 +6,46 @@
|
|
|
6
6
|
import * as dns from "node:dns/promises";
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, getStorePath, getStorageInfo, getConfigDir } from "@bobfrankston/mailx-settings";
|
|
10
12
|
import { sanitizeHtml, encodeQuotedPrintable } from "@bobfrankston/mailx-types";
|
|
11
13
|
import { simpleParser } from "mailparser";
|
|
14
|
+
/** Parse `List-Unsubscribe` (RFC 2369) and `List-Unsubscribe-Post` (RFC 8058).
|
|
15
|
+
* mailparser only exposes ONE of mail/url even when both are present, so we
|
|
16
|
+
* also scan the raw header text for the full set of angle-bracketed URIs. */
|
|
17
|
+
function parseListUnsubscribe(headers) {
|
|
18
|
+
let mail = "";
|
|
19
|
+
let http = "";
|
|
20
|
+
let oneClick = false;
|
|
21
|
+
const raw = headers.get("list-unsubscribe");
|
|
22
|
+
const rawStr = typeof raw === "string" ? raw : (raw && typeof raw.text === "string" ? raw.text : "");
|
|
23
|
+
if (rawStr) {
|
|
24
|
+
const matches = rawStr.match(/<([^>]+)>/g) || [];
|
|
25
|
+
for (const m of matches) {
|
|
26
|
+
const url = m.slice(1, -1).trim();
|
|
27
|
+
if (!mail && /^mailto:/i.test(url))
|
|
28
|
+
mail = url;
|
|
29
|
+
else if (!http && /^https?:/i.test(url))
|
|
30
|
+
http = url;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!mail && !http) {
|
|
34
|
+
const listHeaders = headers.get("list");
|
|
35
|
+
if (listHeaders?.unsubscribe) {
|
|
36
|
+
const unsub = listHeaders.unsubscribe;
|
|
37
|
+
if (unsub.url)
|
|
38
|
+
http = Array.isArray(unsub.url) ? unsub.url[0] : unsub.url;
|
|
39
|
+
if (unsub.mail)
|
|
40
|
+
mail = `mailto:${Array.isArray(unsub.mail) ? unsub.mail[0] : unsub.mail}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const post = headers.get("list-unsubscribe-post");
|
|
44
|
+
const postStr = typeof post === "string" ? post : (post && typeof post.text === "string" ? post.text : "");
|
|
45
|
+
if (postStr && /one-?click/i.test(postStr))
|
|
46
|
+
oneClick = true;
|
|
47
|
+
return { listUnsubscribeMail: mail, listUnsubscribeHttp: http, listUnsubscribeOneClick: oneClick };
|
|
48
|
+
}
|
|
12
49
|
// ── Email provider detection (MX-based) ──
|
|
13
50
|
const GOOGLE_DOMAINS = ["gmail.com", "googlemail.com"];
|
|
14
51
|
const MS_DOMAINS = ["outlook.com", "hotmail.com", "live.com"];
|
|
@@ -161,6 +198,9 @@ export class MailxService {
|
|
|
161
198
|
let deliveredTo = "";
|
|
162
199
|
let returnPath = "";
|
|
163
200
|
let listUnsubscribe = "";
|
|
201
|
+
let listUnsubscribeMail = "";
|
|
202
|
+
let listUnsubscribeHttp = "";
|
|
203
|
+
let listUnsubscribeOneClick = false;
|
|
164
204
|
if (raw) {
|
|
165
205
|
const parsed2 = await simpleParser(raw);
|
|
166
206
|
const hdr = (key) => {
|
|
@@ -211,19 +251,32 @@ export class MailxService {
|
|
|
211
251
|
}
|
|
212
252
|
}
|
|
213
253
|
returnPath = hdr("return-path").replace(/[<>]/g, "");
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
listUnsubscribe = unsub.url || (unsub.mail ? `mailto:${unsub.mail}` : "");
|
|
218
|
-
}
|
|
254
|
+
({ listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick } =
|
|
255
|
+
parseListUnsubscribe(parsed2.headers));
|
|
256
|
+
listUnsubscribe = listUnsubscribeHttp || listUnsubscribeMail;
|
|
219
257
|
}
|
|
220
258
|
const storePath = getStorePath();
|
|
221
259
|
const emlPath = path.join(storePath, accountId, String(envelope.folderId), `${envelope.uid}.eml`);
|
|
222
260
|
return {
|
|
223
261
|
...envelope, bodyHtml, bodyText, hasRemoteContent, remoteAllowed: allowRemote,
|
|
224
|
-
attachments, emlPath, deliveredTo, returnPath,
|
|
262
|
+
attachments, emlPath, deliveredTo, returnPath,
|
|
263
|
+
listUnsubscribe, listUnsubscribeMail, listUnsubscribeHttp, listUnsubscribeOneClick,
|
|
225
264
|
};
|
|
226
265
|
}
|
|
266
|
+
/** RFC 8058 one-click unsubscribe: POST `List-Unsubscribe=One-Click` to the
|
|
267
|
+
* HTTPS URL the message's List-Unsubscribe header advertised. Done server-
|
|
268
|
+
* side because the unsubscribe endpoint usually doesn't set CORS headers,
|
|
269
|
+
* so a browser-side fetch would be blocked. */
|
|
270
|
+
async unsubscribeOneClick(url) {
|
|
271
|
+
if (!/^https:\/\//i.test(url))
|
|
272
|
+
throw new Error("one-click unsubscribe requires an https URL");
|
|
273
|
+
const resp = await fetch(url, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
276
|
+
body: "List-Unsubscribe=One-Click",
|
|
277
|
+
});
|
|
278
|
+
return { ok: resp.ok, status: resp.status, statusText: resp.statusText };
|
|
279
|
+
}
|
|
227
280
|
async updateFlags(accountId, uid, flags) {
|
|
228
281
|
const envelope = this.db.getMessageByUid(accountId, uid);
|
|
229
282
|
await this.imapManager.updateFlagsLocal(accountId, uid, envelope?.folderId || 0, flags);
|
|
@@ -677,6 +730,45 @@ export class MailxService {
|
|
|
677
730
|
const { cloudRead } = await import("@bobfrankston/mailx-settings");
|
|
678
731
|
return cloudRead(name);
|
|
679
732
|
}
|
|
733
|
+
/** Return the help section for a named config file, extracted from docs/config-help.md.
|
|
734
|
+
* Matches a level-2 heading whose text equals the filename. Returns markdown. */
|
|
735
|
+
async readConfigHelp(name) {
|
|
736
|
+
const WHITELIST = ["accounts.jsonc", "allowlist.jsonc", "clients.jsonc", "config.jsonc"];
|
|
737
|
+
if (!WHITELIST.includes(name))
|
|
738
|
+
return "";
|
|
739
|
+
// Look in the repo root (dev) and in the installed package dir (production).
|
|
740
|
+
const candidates = [
|
|
741
|
+
path.join(__dirname, "..", "..", "docs", "config-help.md"),
|
|
742
|
+
path.join(__dirname, "config-help.md"),
|
|
743
|
+
];
|
|
744
|
+
let md = "";
|
|
745
|
+
for (const p of candidates) {
|
|
746
|
+
try {
|
|
747
|
+
md = fs.readFileSync(p, "utf-8");
|
|
748
|
+
break;
|
|
749
|
+
}
|
|
750
|
+
catch { /* try next */ }
|
|
751
|
+
}
|
|
752
|
+
if (!md)
|
|
753
|
+
return "";
|
|
754
|
+
const lines = md.split(/\r?\n/);
|
|
755
|
+
let inSection = false;
|
|
756
|
+
const out = [];
|
|
757
|
+
for (const line of lines) {
|
|
758
|
+
const h2 = /^##\s+(.+?)\s*$/.exec(line);
|
|
759
|
+
if (h2) {
|
|
760
|
+
if (inSection)
|
|
761
|
+
break; // next section — stop
|
|
762
|
+
if (h2[1].trim() === name) {
|
|
763
|
+
inSection = true;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (inSection)
|
|
768
|
+
out.push(line);
|
|
769
|
+
}
|
|
770
|
+
return out.join("\n").trim();
|
|
771
|
+
}
|
|
680
772
|
/** Write a JSONC config file. Validates that the content parses as JSONC
|
|
681
773
|
* (loosely — strips comments/trailing commas) before writing. */
|
|
682
774
|
async writeJsoncFile(name, content) {
|
|
@@ -913,6 +1005,106 @@ export class MailxService {
|
|
|
913
1005
|
}
|
|
914
1006
|
return { suggestion: "" };
|
|
915
1007
|
}
|
|
1008
|
+
/** Generic AI text transform — translate / proofread / summarize.
|
|
1009
|
+
* Shares the autocomplete provider config (provider, key, model). Each
|
|
1010
|
+
* feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
|
|
1011
|
+
* default false. Returns empty text + reason when disabled or on error. */
|
|
1012
|
+
async aiTransform(req) {
|
|
1013
|
+
const cfg = loadAutocomplete();
|
|
1014
|
+
if (cfg.provider === "off")
|
|
1015
|
+
return { text: "", reason: "AI provider not configured" };
|
|
1016
|
+
const featureGate = {
|
|
1017
|
+
translate: cfg.translateEnabled,
|
|
1018
|
+
proofread: cfg.proofreadEnabled,
|
|
1019
|
+
summarize: cfg.proofreadEnabled, // bundled with proofread for now
|
|
1020
|
+
};
|
|
1021
|
+
if (!featureGate[req.action])
|
|
1022
|
+
return { text: "", reason: `AI ${req.action} disabled in settings` };
|
|
1023
|
+
const text = (req.text || "").slice(0, 8000); // sanity cap
|
|
1024
|
+
if (!text.trim())
|
|
1025
|
+
return { text: "", reason: "no input" };
|
|
1026
|
+
const target = req.targetLang || "en";
|
|
1027
|
+
let systemPrompt;
|
|
1028
|
+
let userPrompt;
|
|
1029
|
+
switch (req.action) {
|
|
1030
|
+
case "translate":
|
|
1031
|
+
systemPrompt = `You are a translator. Render the user's text into ${target}. Preserve formatting (paragraphs, lists). Output ONLY the translation, no explanation.`;
|
|
1032
|
+
userPrompt = text;
|
|
1033
|
+
break;
|
|
1034
|
+
case "proofread":
|
|
1035
|
+
systemPrompt = `You are an editor. Return the user's text with grammar, spelling, and clarity fixed. Preserve voice and meaning. Output ONLY the corrected text, no explanation.`;
|
|
1036
|
+
userPrompt = text;
|
|
1037
|
+
break;
|
|
1038
|
+
case "summarize":
|
|
1039
|
+
systemPrompt = `You are a summarizer. Render the user's text as a short paragraph (2-4 sentences). Output ONLY the summary.`;
|
|
1040
|
+
userPrompt = text;
|
|
1041
|
+
break;
|
|
1042
|
+
}
|
|
1043
|
+
try {
|
|
1044
|
+
if (cfg.provider === "ollama") {
|
|
1045
|
+
const res = await fetch(`${cfg.ollamaUrl}/api/generate`, {
|
|
1046
|
+
method: "POST",
|
|
1047
|
+
headers: { "Content-Type": "application/json" },
|
|
1048
|
+
body: JSON.stringify({
|
|
1049
|
+
model: cfg.ollamaModel,
|
|
1050
|
+
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
|
1051
|
+
stream: false,
|
|
1052
|
+
options: { num_predict: 1024 },
|
|
1053
|
+
}),
|
|
1054
|
+
});
|
|
1055
|
+
if (!res.ok)
|
|
1056
|
+
return { text: "", reason: `ollama ${res.status}` };
|
|
1057
|
+
const data = await res.json();
|
|
1058
|
+
return { text: (data.response || "").trim() };
|
|
1059
|
+
}
|
|
1060
|
+
if (cfg.provider === "claude") {
|
|
1061
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
1062
|
+
method: "POST",
|
|
1063
|
+
headers: {
|
|
1064
|
+
"Content-Type": "application/json",
|
|
1065
|
+
"x-api-key": cfg.cloudApiKey,
|
|
1066
|
+
"anthropic-version": "2023-06-01",
|
|
1067
|
+
},
|
|
1068
|
+
body: JSON.stringify({
|
|
1069
|
+
model: cfg.cloudModel,
|
|
1070
|
+
max_tokens: 2048,
|
|
1071
|
+
system: systemPrompt,
|
|
1072
|
+
messages: [{ role: "user", content: userPrompt }],
|
|
1073
|
+
}),
|
|
1074
|
+
});
|
|
1075
|
+
if (!res.ok)
|
|
1076
|
+
return { text: "", reason: `claude ${res.status}` };
|
|
1077
|
+
const data = await res.json();
|
|
1078
|
+
return { text: (data.content?.[0]?.text || "").trim() };
|
|
1079
|
+
}
|
|
1080
|
+
if (cfg.provider === "openai") {
|
|
1081
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1082
|
+
method: "POST",
|
|
1083
|
+
headers: {
|
|
1084
|
+
"Content-Type": "application/json",
|
|
1085
|
+
"Authorization": `Bearer ${cfg.cloudApiKey}`,
|
|
1086
|
+
},
|
|
1087
|
+
body: JSON.stringify({
|
|
1088
|
+
model: cfg.cloudModel,
|
|
1089
|
+
max_tokens: 2048,
|
|
1090
|
+
messages: [
|
|
1091
|
+
{ role: "system", content: systemPrompt },
|
|
1092
|
+
{ role: "user", content: userPrompt },
|
|
1093
|
+
],
|
|
1094
|
+
}),
|
|
1095
|
+
});
|
|
1096
|
+
if (!res.ok)
|
|
1097
|
+
return { text: "", reason: `openai ${res.status}` };
|
|
1098
|
+
const data = await res.json();
|
|
1099
|
+
return { text: (data.choices?.[0]?.message?.content || "").trim() };
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
catch (e) {
|
|
1103
|
+
console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${e.message}`);
|
|
1104
|
+
return { text: "", reason: e.message };
|
|
1105
|
+
}
|
|
1106
|
+
return { text: "", reason: "no provider matched" };
|
|
1107
|
+
}
|
|
916
1108
|
}
|
|
917
1109
|
/** Trim suggestion: remove leading/trailing whitespace, cap at sentence boundary */
|
|
918
1110
|
function trimSuggestion(text) {
|
|
@@ -105,6 +105,10 @@ async function dispatchAction(svc, action, p) {
|
|
|
105
105
|
case "writeJsoncFile":
|
|
106
106
|
await svc.writeJsoncFile(p.name, p.content);
|
|
107
107
|
return { ok: true };
|
|
108
|
+
case "readConfigHelp":
|
|
109
|
+
return { content: await svc.readConfigHelp(p.name) };
|
|
110
|
+
case "unsubscribeOneClick":
|
|
111
|
+
return await svc.unsubscribeOneClick(p.url);
|
|
108
112
|
// Settings
|
|
109
113
|
case "getSettings":
|
|
110
114
|
return svc.getSettings();
|
|
@@ -133,6 +137,10 @@ async function dispatchAction(svc, action, p) {
|
|
|
133
137
|
case "saveAutocompleteSettings":
|
|
134
138
|
svc.saveAutocompleteSettings(p);
|
|
135
139
|
return { ok: true };
|
|
140
|
+
// AI transform (translate / proofread / summarize) — gated by per-feature
|
|
141
|
+
// toggles in autocomplete settings, both default false.
|
|
142
|
+
case "aiTransform":
|
|
143
|
+
return svc.aiTransform(p);
|
|
136
144
|
// Attachments
|
|
137
145
|
case "getAttachment": {
|
|
138
146
|
const att = await svc.getAttachment(p.accountId, p.uid, p.attachmentId, p.folderId);
|
|
@@ -270,7 +270,7 @@ export class MailxDB {
|
|
|
270
270
|
}
|
|
271
271
|
getFolders(accountId) {
|
|
272
272
|
const rows = this.db.prepare("SELECT * FROM folders WHERE account_id = ? ORDER BY path").all(accountId);
|
|
273
|
-
|
|
273
|
+
const folders = rows.map(r => ({
|
|
274
274
|
id: r.id,
|
|
275
275
|
accountId: r.account_id,
|
|
276
276
|
path: r.path,
|
|
@@ -281,6 +281,35 @@ export class MailxDB {
|
|
|
281
281
|
unreadCount: r.unread_count,
|
|
282
282
|
children: []
|
|
283
283
|
}));
|
|
284
|
+
// Sub-folder inheritance: a folder under Drafts/Sent/Trash/Junk/Archive
|
|
285
|
+
// inherits the parent's special role for UI purposes (column layout,
|
|
286
|
+
// open-in-compose, etc.). INBOX is intentionally excluded — its sub-
|
|
287
|
+
// folders are typically filtered mail and inheriting "inbox" would
|
|
288
|
+
// inflate All Inboxes. findFolder() still resolves to the canonical
|
|
289
|
+
// folder because rows are sorted by path and the parent sorts before
|
|
290
|
+
// its children.
|
|
291
|
+
const INHERITABLE = new Set(["sent", "drafts", "trash", "junk", "archive"]);
|
|
292
|
+
const roleByPath = new Map();
|
|
293
|
+
for (const f of folders) {
|
|
294
|
+
if (f.specialUse && INHERITABLE.has(f.specialUse)) {
|
|
295
|
+
roleByPath.set(f.path, f.specialUse);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const f of folders) {
|
|
299
|
+
if (f.specialUse)
|
|
300
|
+
continue;
|
|
301
|
+
const delim = f.delimiter || "/";
|
|
302
|
+
const parts = f.path.split(delim);
|
|
303
|
+
while (parts.length > 1) {
|
|
304
|
+
parts.pop();
|
|
305
|
+
const role = roleByPath.get(parts.join(delim));
|
|
306
|
+
if (role) {
|
|
307
|
+
f.specialUse = role;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return folders;
|
|
284
313
|
}
|
|
285
314
|
deleteFolder(folderId) {
|
|
286
315
|
this.db.prepare("DELETE FROM messages WHERE folder_id = ?").run(folderId);
|
|
@@ -313,10 +342,23 @@ export class MailxDB {
|
|
|
313
342
|
if (msg.providerId && !existing.provider_id) {
|
|
314
343
|
this.db.prepare("UPDATE messages SET provider_id = ? WHERE id = ?").run(msg.providerId, existing.id);
|
|
315
344
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
345
|
+
// Only overwrite body_path / preview when the caller actually has a
|
|
346
|
+
// body. Metadata-only syncs (Gmail API storeApiMessages, IMAP
|
|
347
|
+
// header-only fetches) pass bodyPath: "" and would otherwise wipe
|
|
348
|
+
// the path that prefetch just wrote, causing prefetch to re-download
|
|
349
|
+
// every message every cycle.
|
|
350
|
+
if (msg.bodyPath) {
|
|
351
|
+
this.db.prepare(`
|
|
352
|
+
UPDATE messages SET flags_json = ?, preview = ?, body_path = ?, cached_at = ?
|
|
353
|
+
WHERE id = ?
|
|
354
|
+
`).run(JSON.stringify(msg.flags), msg.preview, msg.bodyPath, Date.now(), existing.id);
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
this.db.prepare(`
|
|
358
|
+
UPDATE messages SET flags_json = ?, cached_at = ?
|
|
359
|
+
WHERE id = ?
|
|
360
|
+
`).run(JSON.stringify(msg.flags), Date.now(), existing.id);
|
|
361
|
+
}
|
|
320
362
|
return existing.id;
|
|
321
363
|
}
|
|
322
364
|
const toText = msg.to.map(a => `${a.name} ${a.address}`).join(" ");
|
|
@@ -33,8 +33,8 @@ function emitEvent(event) {
|
|
|
33
33
|
}
|
|
34
34
|
catch { /* ignore */ }
|
|
35
35
|
}
|
|
36
|
-
if (typeof window.
|
|
37
|
-
window.
|
|
36
|
+
if (typeof window._msgapiServiceEvent === "function") {
|
|
37
|
+
window._msgapiServiceEvent(event);
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
// ── Helpers ──
|
|
@@ -54,6 +54,10 @@ class AndroidSyncManager {
|
|
|
54
54
|
bodyStore;
|
|
55
55
|
providers = new Map();
|
|
56
56
|
tokenProviders = new Map();
|
|
57
|
+
// One prefetch session per account — prevents every syncAll tick from
|
|
58
|
+
// spawning parallel fetch loops that race on IndexedDB and blow through
|
|
59
|
+
// Gmail's per-user quota.
|
|
60
|
+
prefetchingAccounts = new Set();
|
|
57
61
|
constructor(db, bodyStore) {
|
|
58
62
|
this.db = db;
|
|
59
63
|
this.bodyStore = bodyStore;
|
|
@@ -150,6 +154,91 @@ class AndroidSyncManager {
|
|
|
150
154
|
emitEvent({ type: "syncError", accountId: account.id, error: e.message });
|
|
151
155
|
}
|
|
152
156
|
}
|
|
157
|
+
// Phase 3: background body prefetch. Fire-and-forget — sync itself is
|
|
158
|
+
// already done and the UI doesn't wait on this. Per-account guard means
|
|
159
|
+
// a slow account can't block a fast one.
|
|
160
|
+
for (const account of accounts) {
|
|
161
|
+
if (!this.providers.has(account.id))
|
|
162
|
+
continue;
|
|
163
|
+
this.prefetchBodies(account.id).catch(e => console.error(`[prefetch] ${account.id}: ${e.message}`));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Background body prefetch — download bodies for messages that don't have
|
|
167
|
+
* them yet, so tapping a message in the list opens instantly from cache. */
|
|
168
|
+
async prefetchBodies(accountId) {
|
|
169
|
+
if (this.prefetchingAccounts.has(accountId))
|
|
170
|
+
return;
|
|
171
|
+
this.prefetchingAccounts.add(accountId);
|
|
172
|
+
try {
|
|
173
|
+
const BATCH_SIZE = 20;
|
|
174
|
+
const THROTTLE_MS = 150;
|
|
175
|
+
const RATE_LIMIT_PAUSE_MS = 30000;
|
|
176
|
+
const ERROR_BUDGET = 10;
|
|
177
|
+
let totalFetched = 0;
|
|
178
|
+
let errors = 0;
|
|
179
|
+
let announced = false;
|
|
180
|
+
while (true) {
|
|
181
|
+
const missing = this.db.getMessagesWithoutBody(accountId, BATCH_SIZE);
|
|
182
|
+
if (missing.length === 0)
|
|
183
|
+
break;
|
|
184
|
+
if (!announced) {
|
|
185
|
+
console.log(`[prefetch] ${accountId}: ${missing.length}+ bodies to fetch`);
|
|
186
|
+
vlog(`prefetch ${accountId} start: ${missing.length}+ pending`);
|
|
187
|
+
announced = true;
|
|
188
|
+
}
|
|
189
|
+
let progressedThisBatch = false;
|
|
190
|
+
for (const m of missing) {
|
|
191
|
+
// Sync the DB path if the body is already in IndexedDB — common
|
|
192
|
+
// when upgrading from a build that didn't set body_path on cache.
|
|
193
|
+
if (await this.bodyStore.hasMessage(accountId, m.folderId, m.uid)) {
|
|
194
|
+
this.db.updateBodyPath(accountId, m.uid, `idb:${accountId}/${m.folderId}/${m.uid}`);
|
|
195
|
+
progressedThisBatch = true;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const result = await this.fetchMessageBody(accountId, m.folderId, m.uid);
|
|
200
|
+
if (result) {
|
|
201
|
+
totalFetched++;
|
|
202
|
+
progressedThisBatch = true;
|
|
203
|
+
emitEvent({ type: "bodyCached", accountId, uid: m.uid, folderId: m.folderId });
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
errors++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
errors++;
|
|
211
|
+
const msg = String(e?.message || "");
|
|
212
|
+
if (/429|rate|too many/i.test(msg)) {
|
|
213
|
+
console.log(`[prefetch] ${accountId}: rate-limited — pausing ${RATE_LIMIT_PAUSE_MS / 1000}s`);
|
|
214
|
+
await new Promise(r => setTimeout(r, RATE_LIMIT_PAUSE_MS));
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
console.error(`[prefetch] ${accountId}/${m.uid}: ${msg}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (errors >= ERROR_BUDGET) {
|
|
221
|
+
console.error(`[prefetch] ${accountId}: stopping after ${errors} errors (${totalFetched} cached)`);
|
|
222
|
+
vlog(`prefetch ${accountId} aborted: ${errors} errors, ${totalFetched} cached`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
await new Promise(r => setTimeout(r, THROTTLE_MS));
|
|
226
|
+
}
|
|
227
|
+
// If a full batch made no progress, bail out to avoid an infinite
|
|
228
|
+
// loop on messages the server can't deliver.
|
|
229
|
+
if (!progressedThisBatch) {
|
|
230
|
+
console.warn(`[prefetch] ${accountId}: batch made no progress, stopping`);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (totalFetched > 0) {
|
|
235
|
+
console.log(`[prefetch] ${accountId}: done — cached ${totalFetched} bodies`);
|
|
236
|
+
vlog(`prefetch ${accountId} done: ${totalFetched} cached`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
this.prefetchingAccounts.delete(accountId);
|
|
241
|
+
}
|
|
153
242
|
}
|
|
154
243
|
async syncFolders(accountId) {
|
|
155
244
|
const provider = this.getProvider(accountId);
|
|
@@ -368,7 +368,10 @@ export class WebMailxDB {
|
|
|
368
368
|
this.run("UPDATE messages SET body_path = ? WHERE account_id = ? AND uid = ?", [bodyPath, accountId, uid]);
|
|
369
369
|
}
|
|
370
370
|
getMessagesWithoutBody(accountId, limit = 50) {
|
|
371
|
-
|
|
371
|
+
// "idb:<acct>/<folder>/<uid>" means the body is cached in IndexedDB via
|
|
372
|
+
// WebMessageStore. Anything else (NULL, "", "gmail:<id>", legacy paths)
|
|
373
|
+
// still needs fetching.
|
|
374
|
+
return this.all("SELECT uid, folder_id as folderId FROM messages WHERE account_id = ? AND (body_path IS NULL OR body_path NOT LIKE 'idb:%') ORDER BY date DESC LIMIT ?", [accountId, limit]);
|
|
372
375
|
}
|
|
373
376
|
getHighestUid(accountId, folderId) {
|
|
374
377
|
const r = this.get("SELECT MAX(uid) as maxUid FROM messages WHERE account_id = ? AND folder_id = ?", [accountId, folderId]);
|
|
@@ -162,8 +162,8 @@ function handleWorkerEvent(event) {
|
|
|
162
162
|
}
|
|
163
163
|
catch { /* ignore */ }
|
|
164
164
|
}
|
|
165
|
-
// Also dispatch to the global
|
|
166
|
-
const cb = window.
|
|
165
|
+
// Also dispatch to the global host service-channel event callback
|
|
166
|
+
const cb = window._msgapiServiceEvent;
|
|
167
167
|
if (cb)
|
|
168
168
|
try {
|
|
169
169
|
cb(event);
|
|
@@ -225,6 +225,12 @@ export interface AutocompleteSettings {
|
|
|
225
225
|
cloudModel: string;
|
|
226
226
|
debounceMs: number;
|
|
227
227
|
maxTokens: number;
|
|
228
|
+
/** Per-feature opt-in for non-autocomplete AI helpers. All default false
|
|
229
|
+
* per user preference (2026-04-21): AI features should be controlled by
|
|
230
|
+
* a flag, initially OFF in settings. Provider config is shared with
|
|
231
|
+
* autocomplete (provider, cloudApiKey, cloudModel, etc.). */
|
|
232
|
+
translateEnabled?: boolean;
|
|
233
|
+
proofreadEnabled?: boolean;
|
|
228
234
|
}
|
|
229
235
|
export interface AutocompleteRequest {
|
|
230
236
|
subject: string;
|
|
@@ -235,6 +241,21 @@ export interface AutocompleteRequest {
|
|
|
235
241
|
export interface AutocompleteResponse {
|
|
236
242
|
suggestion: string;
|
|
237
243
|
}
|
|
244
|
+
export interface AiTransformRequest {
|
|
245
|
+
/** translate = render in `targetLang`; proofread = corrected version
|
|
246
|
+
* with grammar/spelling fixes; summarize = short paragraph summary. */
|
|
247
|
+
action: "translate" | "proofread" | "summarize";
|
|
248
|
+
text: string;
|
|
249
|
+
/** ISO-639-1 (or BCP-47) language code for translate. Defaults to "en". */
|
|
250
|
+
targetLang?: string;
|
|
251
|
+
}
|
|
252
|
+
export interface AiTransformResponse {
|
|
253
|
+
/** Transformed text. Empty when AI is disabled / provider error / feature
|
|
254
|
+
* not enabled — caller should treat empty as "no result". */
|
|
255
|
+
text: string;
|
|
256
|
+
/** Optional reason for empty result, surfaced to UI status bar. */
|
|
257
|
+
reason?: string;
|
|
258
|
+
}
|
|
238
259
|
/** Body storage backend interface -- implementations are swappable */
|
|
239
260
|
export interface MessageStore {
|
|
240
261
|
putMessage(accountId: string, folderId: number, uid: number, raw: Buffer): Promise<string>;
|
package/tempfix.cmd
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
rem --------------------------------------------------------------------
|
|
3
|
+
rem tempfix.cmd — recover Claude Code + msger build after session wedge
|
|
4
|
+
rem
|
|
5
|
+
rem Problem: Claude Code's Bash tool keeps throwing EEXIST on
|
|
6
|
+
rem C:\Users\Bob\.claude\session-env\<uuid> because the current session
|
|
7
|
+
rem left a stale dir and the tool's mkdir isn't recursive.
|
|
8
|
+
rem Separately: msger's Windows build and stable-hardlink update need
|
|
9
|
+
rem mailx NOT to be holding msgernative.exe when npmglobalize runs.
|
|
10
|
+
rem (Fixed in source via rename-aside, but we still want a clean slate
|
|
11
|
+
rem so the first rebuild doesn't have to juggle three generations of
|
|
12
|
+
rem .old-<ts> files on top of a running process.)
|
|
13
|
+
rem
|
|
14
|
+
rem What this script does, in order:
|
|
15
|
+
rem 1. Kill Claude Code so it releases its session-env lock.
|
|
16
|
+
rem 2. Kill mailx + any running msger window so msgernative.exe can be
|
|
17
|
+
rem replaced (strictly not required with the rename-aside fix, but
|
|
18
|
+
rem cleaner for this one-time recovery).
|
|
19
|
+
rem 3. Nuke the entire session-env dir so the NEXT Claude start gets
|
|
20
|
+
rem a fresh UUID subdir.
|
|
21
|
+
rem 4. Remind you of the next commands to run.
|
|
22
|
+
rem
|
|
23
|
+
rem Safe to re-run. Everything taskkill'd is optional — not-found is fine.
|
|
24
|
+
rem --------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
echo.
|
|
27
|
+
echo === 1/3 Killing Claude Code ===
|
|
28
|
+
taskkill /F /IM claude.exe 2>nul
|
|
29
|
+
if %errorlevel% equ 0 ( echo claude.exe terminated ) else ( echo claude.exe was not running )
|
|
30
|
+
|
|
31
|
+
echo.
|
|
32
|
+
echo === 2/3 Killing mailx + msger so msgernative.exe is free ===
|
|
33
|
+
rem mailx runs as node.exe but node.exe runs EVERYTHING on this box, so
|
|
34
|
+
rem we do NOT blanket-kill node. Instead kill the msger native window and
|
|
35
|
+
rem any mailx launcher by image name. mailx's own node process stays up
|
|
36
|
+
rem unless you want to close the UI — uncomment the last line to kill it.
|
|
37
|
+
taskkill /F /IM msgernative.exe 2>nul
|
|
38
|
+
if %errorlevel% equ 0 ( echo msgernative.exe terminated ) else ( echo msgernative.exe was not running )
|
|
39
|
+
rem To also stop mailx's node service, uncomment:
|
|
40
|
+
rem taskkill /F /FI "WINDOWTITLE eq mailx*" 2>nul
|
|
41
|
+
|
|
42
|
+
echo.
|
|
43
|
+
echo === 3/3 Removing stale Claude session-env dir ===
|
|
44
|
+
if exist "%USERPROFILE%\.claude\session-env" (
|
|
45
|
+
rmdir /s /q "%USERPROFILE%\.claude\session-env"
|
|
46
|
+
if exist "%USERPROFILE%\.claude\session-env" (
|
|
47
|
+
echo WARNING: session-env still exists — something was holding it open
|
|
48
|
+
) else (
|
|
49
|
+
echo session-env removed
|
|
50
|
+
)
|
|
51
|
+
) else (
|
|
52
|
+
echo session-env already gone
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
echo.
|
|
56
|
+
echo --------------------------------------------------------------------
|
|
57
|
+
echo Done. Next steps:
|
|
58
|
+
echo.
|
|
59
|
+
echo 1. Start Claude Code again. Your in-flight state is saved in memory
|
|
60
|
+
echo at C:\Users\Bob\.claude\projects\Y--dev-email-mailx\memory\
|
|
61
|
+
echo (see project_session_apr20_state.md for the full handoff).
|
|
62
|
+
echo.
|
|
63
|
+
echo 2. Rebuild msger so the new binary is installed:
|
|
64
|
+
echo cd /d y:\dev\utils\msgx\msger
|
|
65
|
+
echo npmglobalize
|
|
66
|
+
echo.
|
|
67
|
+
echo 3. Once that succeeds, test keyboard input:
|
|
68
|
+
echo msger y:\dev\projects\Cards\bbt\bbs.json
|
|
69
|
+
echo Try typing + Ctrl+C. If fixed, stale-binary theory was correct.
|
|
70
|
+
echo If still broken, tell Claude and we'll dig into focus/MoveFocus.
|
|
71
|
+
echo.
|
|
72
|
+
echo 4. In mailx root, wire up the new mailx-host workspace:
|
|
73
|
+
echo cd /d y:\dev\email\mailx
|
|
74
|
+
echo npm install
|
|
75
|
+
echo cd packages\mailx-host
|
|
76
|
+
echo tsc
|
|
77
|
+
echo --------------------------------------------------------------------
|