@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.
@@ -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
- if (client.deleteMailbox) {
591
- await client.deleteMailbox(folder.path);
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
- else {
594
- await client.withConnection(async () => {
595
- await client.client.mailboxDelete(folder.path);
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
- await client.logout();
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
- // Save local editing copy crash recovery, keep last 3
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 { /* ignore */ }
681
- const draftUid = await this.imapManager.saveDraft(accountId, raw, previousDraftUid, id);
682
- return { draftUid, draftId: id };
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>;