@bobfrankston/mailx 1.0.310 → 1.0.317
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 +14 -2
- package/bin/mailx.js +56 -1
- package/client/app.js +100 -14
- package/client/components/folder-picker.js +119 -0
- package/client/components/message-list.js +23 -1
- package/client/components/message-viewer.js +64 -4
- package/client/compose/compose.js +97 -20
- package/client/compose/editor.js +51 -7
- package/client/index.html +18 -0
- package/client/lib/api-client.js +6 -0
- package/client/lib/mailxapi.js +3 -0
- package/client/styles/layout.css +64 -14
- package/client/styles/variables.css +1 -0
- package/package.json +7 -7
- package/packages/mailx-host/index.d.ts +1 -0
- package/packages/mailx-host/index.js +1 -0
- package/packages/mailx-host/package.json +4 -1
- package/packages/mailx-imap/index.js +66 -0
- package/packages/mailx-service/index.d.ts +6 -1
- package/packages/mailx-service/index.js +146 -11
- package/packages/mailx-service/jsonrpc.js +4 -0
- package/packages/mailx-types/index.d.ts +21 -0
|
@@ -587,16 +587,33 @@ export class MailxService {
|
|
|
587
587
|
throw new Error("Folder not found");
|
|
588
588
|
const client = this.imapManager.createPublicClient(accountId);
|
|
589
589
|
try {
|
|
590
|
-
|
|
591
|
-
|
|
590
|
+
try {
|
|
591
|
+
if (client.deleteMailbox) {
|
|
592
|
+
await client.deleteMailbox(folder.path);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
await client.withConnection(async () => {
|
|
596
|
+
await client.client.mailboxDelete(folder.path);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
592
599
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
600
|
+
catch (e) {
|
|
601
|
+
// Server already doesn't have this folder — common case when
|
|
602
|
+
// the user deleted / renamed it from another client and mailx
|
|
603
|
+
// is still showing the stale local row. Silently treat as
|
|
604
|
+
// success and proceed with local cleanup; the user's intent
|
|
605
|
+
// ("make this go away") is met either way.
|
|
606
|
+
const msg = String(e?.message || e || "").toLowerCase();
|
|
607
|
+
const alreadyGone = /nonexistent|does not exist|no such|not found|NO \[.*\] Mailbox|404/i.test(msg);
|
|
608
|
+
if (!alreadyGone)
|
|
609
|
+
throw e;
|
|
610
|
+
console.log(` [folder] ${accountId} delete "${folder.path}": server says already gone — cleaning local DB`);
|
|
597
611
|
}
|
|
598
612
|
this.db.deleteFolder(folderId);
|
|
599
|
-
|
|
613
|
+
try {
|
|
614
|
+
await client.logout();
|
|
615
|
+
}
|
|
616
|
+
catch { /* ignore */ }
|
|
600
617
|
}
|
|
601
618
|
finally {
|
|
602
619
|
try {
|
|
@@ -647,6 +664,16 @@ export class MailxService {
|
|
|
647
664
|
}
|
|
648
665
|
// ── Drafts ──
|
|
649
666
|
async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId) {
|
|
667
|
+
// Local-first: commit the draft to the local filesystem synchronously
|
|
668
|
+
// and return immediately. The IMAP APPEND (and the previous-draft
|
|
669
|
+
// delete) run in the background. Previously this method awaited IMAP
|
|
670
|
+
// inline, which produced the 30/120s `mailxapi timeout: saveDraft`
|
|
671
|
+
// the user reported — every IMAP stall (slow server, hung OAuth,
|
|
672
|
+
// maxed connection pool) froze autosave. The local `.eml` written
|
|
673
|
+
// below is the user's crash-safety net; IMAP is a sync target, not
|
|
674
|
+
// a prerequisite. X-Mailx-Draft-ID is carried in the MIME headers
|
|
675
|
+
// so the reconciler can de-duplicate on the server by header search
|
|
676
|
+
// even without the previousDraftUid round-trip.
|
|
650
677
|
const settings = loadSettings();
|
|
651
678
|
const account = settings.accounts.find(a => a.id === accountId);
|
|
652
679
|
if (!account)
|
|
@@ -663,7 +690,8 @@ export class MailxService {
|
|
|
663
690
|
`MIME-Version: 1.0`, `Content-Type: text/html; charset=UTF-8`, `Content-Transfer-Encoding: quoted-printable`,
|
|
664
691
|
].filter(h => h !== null).join("\r\n");
|
|
665
692
|
const raw = `${headers}\r\n\r\n${bodyEncoded}`;
|
|
666
|
-
//
|
|
693
|
+
// Local commit: write editing copy to disk. Crash recovery lives in
|
|
694
|
+
// the last 3 files. Synchronous fs (~ms) so the caller returns fast.
|
|
667
695
|
try {
|
|
668
696
|
const editingDir = path.join(getConfigDir(), "sending", accountId, "editing");
|
|
669
697
|
fs.mkdirSync(editingDir, { recursive: true });
|
|
@@ -677,9 +705,16 @@ export class MailxService {
|
|
|
677
705
|
fs.unlinkSync(path.join(editingDir, files.shift()));
|
|
678
706
|
}
|
|
679
707
|
}
|
|
680
|
-
catch { /*
|
|
681
|
-
|
|
682
|
-
|
|
708
|
+
catch { /* non-fatal — draft stays in memory at least */ }
|
|
709
|
+
// Background reconcile to server Drafts folder. Fire-and-forget —
|
|
710
|
+
// the ACK to the client is already on its way.
|
|
711
|
+
this.imapManager.saveDraft(accountId, raw, previousDraftUid, id).catch((e) => {
|
|
712
|
+
console.error(` [draft] background IMAP save failed for ${id}: ${e?.message || e}`);
|
|
713
|
+
// Surface as an event so the UI can show a status-bar hint without
|
|
714
|
+
// blocking the caller. Draft is preserved on disk regardless.
|
|
715
|
+
this.emit?.("draftSaveDeferred", { accountId, draftId: id, error: String(e?.message || e) });
|
|
716
|
+
});
|
|
717
|
+
return { draftUid: null, draftId: id };
|
|
683
718
|
}
|
|
684
719
|
async deleteDraft(accountId, draftUid, draftId) {
|
|
685
720
|
await this.imapManager.deleteDraft(accountId, draftUid, draftId);
|
|
@@ -1005,6 +1040,106 @@ export class MailxService {
|
|
|
1005
1040
|
}
|
|
1006
1041
|
return { suggestion: "" };
|
|
1007
1042
|
}
|
|
1043
|
+
/** Generic AI text transform — translate / proofread / summarize.
|
|
1044
|
+
* Shares the autocomplete provider config (provider, key, model). Each
|
|
1045
|
+
* feature has its own opt-in toggle (translateEnabled / proofreadEnabled),
|
|
1046
|
+
* default false. Returns empty text + reason when disabled or on error. */
|
|
1047
|
+
async aiTransform(req) {
|
|
1048
|
+
const cfg = loadAutocomplete();
|
|
1049
|
+
if (cfg.provider === "off")
|
|
1050
|
+
return { text: "", reason: "AI provider not configured" };
|
|
1051
|
+
const featureGate = {
|
|
1052
|
+
translate: cfg.translateEnabled,
|
|
1053
|
+
proofread: cfg.proofreadEnabled,
|
|
1054
|
+
summarize: cfg.proofreadEnabled, // bundled with proofread for now
|
|
1055
|
+
};
|
|
1056
|
+
if (!featureGate[req.action])
|
|
1057
|
+
return { text: "", reason: `AI ${req.action} disabled in settings` };
|
|
1058
|
+
const text = (req.text || "").slice(0, 8000); // sanity cap
|
|
1059
|
+
if (!text.trim())
|
|
1060
|
+
return { text: "", reason: "no input" };
|
|
1061
|
+
const target = req.targetLang || "en";
|
|
1062
|
+
let systemPrompt;
|
|
1063
|
+
let userPrompt;
|
|
1064
|
+
switch (req.action) {
|
|
1065
|
+
case "translate":
|
|
1066
|
+
systemPrompt = `You are a translator. Render the user's text into ${target}. Preserve formatting (paragraphs, lists). Output ONLY the translation, no explanation.`;
|
|
1067
|
+
userPrompt = text;
|
|
1068
|
+
break;
|
|
1069
|
+
case "proofread":
|
|
1070
|
+
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.`;
|
|
1071
|
+
userPrompt = text;
|
|
1072
|
+
break;
|
|
1073
|
+
case "summarize":
|
|
1074
|
+
systemPrompt = `You are a summarizer. Render the user's text as a short paragraph (2-4 sentences). Output ONLY the summary.`;
|
|
1075
|
+
userPrompt = text;
|
|
1076
|
+
break;
|
|
1077
|
+
}
|
|
1078
|
+
try {
|
|
1079
|
+
if (cfg.provider === "ollama") {
|
|
1080
|
+
const res = await fetch(`${cfg.ollamaUrl}/api/generate`, {
|
|
1081
|
+
method: "POST",
|
|
1082
|
+
headers: { "Content-Type": "application/json" },
|
|
1083
|
+
body: JSON.stringify({
|
|
1084
|
+
model: cfg.ollamaModel,
|
|
1085
|
+
prompt: `${systemPrompt}\n\n${userPrompt}`,
|
|
1086
|
+
stream: false,
|
|
1087
|
+
options: { num_predict: 1024 },
|
|
1088
|
+
}),
|
|
1089
|
+
});
|
|
1090
|
+
if (!res.ok)
|
|
1091
|
+
return { text: "", reason: `ollama ${res.status}` };
|
|
1092
|
+
const data = await res.json();
|
|
1093
|
+
return { text: (data.response || "").trim() };
|
|
1094
|
+
}
|
|
1095
|
+
if (cfg.provider === "claude") {
|
|
1096
|
+
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
|
1097
|
+
method: "POST",
|
|
1098
|
+
headers: {
|
|
1099
|
+
"Content-Type": "application/json",
|
|
1100
|
+
"x-api-key": cfg.cloudApiKey,
|
|
1101
|
+
"anthropic-version": "2023-06-01",
|
|
1102
|
+
},
|
|
1103
|
+
body: JSON.stringify({
|
|
1104
|
+
model: cfg.cloudModel,
|
|
1105
|
+
max_tokens: 2048,
|
|
1106
|
+
system: systemPrompt,
|
|
1107
|
+
messages: [{ role: "user", content: userPrompt }],
|
|
1108
|
+
}),
|
|
1109
|
+
});
|
|
1110
|
+
if (!res.ok)
|
|
1111
|
+
return { text: "", reason: `claude ${res.status}` };
|
|
1112
|
+
const data = await res.json();
|
|
1113
|
+
return { text: (data.content?.[0]?.text || "").trim() };
|
|
1114
|
+
}
|
|
1115
|
+
if (cfg.provider === "openai") {
|
|
1116
|
+
const res = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1117
|
+
method: "POST",
|
|
1118
|
+
headers: {
|
|
1119
|
+
"Content-Type": "application/json",
|
|
1120
|
+
"Authorization": `Bearer ${cfg.cloudApiKey}`,
|
|
1121
|
+
},
|
|
1122
|
+
body: JSON.stringify({
|
|
1123
|
+
model: cfg.cloudModel,
|
|
1124
|
+
max_tokens: 2048,
|
|
1125
|
+
messages: [
|
|
1126
|
+
{ role: "system", content: systemPrompt },
|
|
1127
|
+
{ role: "user", content: userPrompt },
|
|
1128
|
+
],
|
|
1129
|
+
}),
|
|
1130
|
+
});
|
|
1131
|
+
if (!res.ok)
|
|
1132
|
+
return { text: "", reason: `openai ${res.status}` };
|
|
1133
|
+
const data = await res.json();
|
|
1134
|
+
return { text: (data.choices?.[0]?.message?.content || "").trim() };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
catch (e) {
|
|
1138
|
+
console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${e.message}`);
|
|
1139
|
+
return { text: "", reason: e.message };
|
|
1140
|
+
}
|
|
1141
|
+
return { text: "", reason: "no provider matched" };
|
|
1142
|
+
}
|
|
1008
1143
|
}
|
|
1009
1144
|
/** Trim suggestion: remove leading/trailing whitespace, cap at sentence boundary */
|
|
1010
1145
|
function trimSuggestion(text) {
|
|
@@ -137,6 +137,10 @@ async function dispatchAction(svc, action, p) {
|
|
|
137
137
|
case "saveAutocompleteSettings":
|
|
138
138
|
svc.saveAutocompleteSettings(p);
|
|
139
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);
|
|
140
144
|
// Attachments
|
|
141
145
|
case "getAttachment": {
|
|
142
146
|
const att = await svc.getAttachment(p.accountId, p.uid, p.attachmentId, p.folderId);
|
|
@@ -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>;
|