@bobfrankston/rmfmail 1.2.251 → 1.2.253

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.
Files changed (60) hide show
  1. package/bin/mailx.js +20 -0
  2. package/bin/mailx.js.map +1 -1
  3. package/bin/mailx.ts +21 -0
  4. package/client/android-bootstrap.bundle.js +27 -30
  5. package/client/android-bootstrap.bundle.js.map +2 -2
  6. package/client/app.bundle.js +33 -1
  7. package/client/app.bundle.js.map +3 -3
  8. package/client/app.js +42 -0
  9. package/client/app.js.map +1 -1
  10. package/client/app.ts +40 -0
  11. package/client/components/message-viewer.js +13 -2
  12. package/client/components/message-viewer.js.map +1 -1
  13. package/client/components/message-viewer.ts +13 -2
  14. package/client/compose/compose.bundle.js +37 -3
  15. package/client/compose/compose.bundle.js.map +3 -3
  16. package/client/compose/compose.css +13 -0
  17. package/client/compose/compose.js +41 -1
  18. package/client/compose/compose.js.map +1 -1
  19. package/client/compose/compose.ts +38 -1
  20. package/client/lib/api-client.js +7 -0
  21. package/client/lib/api-client.js.map +1 -1
  22. package/client/lib/api-client.ts +8 -0
  23. package/client/lib/mailxapi.js +10 -2
  24. package/package.json +3 -3
  25. package/packages/mailx-imap/package-lock.json +2 -2
  26. package/packages/mailx-imap/package.json +1 -1
  27. package/packages/mailx-service/ai-usage.d.ts +32 -0
  28. package/packages/mailx-service/ai-usage.d.ts.map +1 -0
  29. package/packages/mailx-service/ai-usage.js +98 -0
  30. package/packages/mailx-service/ai-usage.js.map +1 -0
  31. package/packages/mailx-service/ai-usage.ts +110 -0
  32. package/packages/mailx-service/index.d.ts +25 -1
  33. package/packages/mailx-service/index.d.ts.map +1 -1
  34. package/packages/mailx-service/index.js +91 -4
  35. package/packages/mailx-service/index.js.map +1 -1
  36. package/packages/mailx-service/index.ts +94 -4
  37. package/packages/mailx-service/jsonrpc.js +3 -1
  38. package/packages/mailx-service/jsonrpc.js.map +1 -1
  39. package/packages/mailx-service/jsonrpc.ts +3 -1
  40. package/packages/mailx-service/package.json +1 -1
  41. package/packages/mailx-settings/package.json +1 -1
  42. package/packages/mailx-store/package.json +1 -1
  43. package/packages/mailx-store-web/android-bootstrap.js +1 -1
  44. package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
  45. package/packages/mailx-store-web/android-bootstrap.ts +1 -1
  46. package/packages/mailx-store-web/package.json +1 -1
  47. package/packages/mailx-store-web/web-service.d.ts +5 -1
  48. package/packages/mailx-store-web/web-service.d.ts.map +1 -1
  49. package/packages/mailx-store-web/web-service.js +5 -1
  50. package/packages/mailx-store-web/web-service.js.map +1 -1
  51. package/packages/mailx-store-web/web-service.ts +5 -1
  52. package/packages/mailx-types/mailx-api.d.ts +17 -1
  53. package/packages/mailx-types/mailx-api.d.ts.map +1 -1
  54. package/packages/mailx-types/mailx-api.ts +10 -1
  55. package/packages/mailx-types/mime-build.d.ts.map +1 -1
  56. package/packages/mailx-types/mime-build.js +35 -30
  57. package/packages/mailx-types/mime-build.js.map +1 -1
  58. package/packages/mailx-types/mime-build.ts +44 -32
  59. package/packages/mailx-types/package.json +1 -1
  60. /package/packages/mailx-imap/{node_modules.npmglobalize-stash-83100 → node_modules.npmglobalize-stash-54924}/.package-lock.json +0 -0
@@ -97,6 +97,7 @@ import * as gsync from "./google-sync.js";
97
97
  import { SyncQueue } from "./sync-queue.js";
98
98
  import { Reconciler } from "./reconciler.js";
99
99
  import { spawnDbWorker } from "./db-worker-client.js";
100
+ import { priceCall, recordAiUsage, summarizeAiUsage } from "./ai-usage.js";
100
101
  export { spawnSyncWorker } from "./sync-worker-client.js";
101
102
  import { loadSettings, saveSettings, loadAccounts, loadAccountsAsync, saveAccounts, initCloudConfig, loadAllowlist, saveAllowlist, loadAutocomplete, saveAutocomplete, loadKeys, getStorePath, getStorageInfo, getConfigDir, loadUserDict, saveUserDict, loadReminderState, saveReminderState, cloudReadBinary, providerForDomain } from "@bobfrankston/mailx-settings";
102
103
  import { buildMimeMessage } from "@bobfrankston/mailx-types";
@@ -1082,6 +1083,13 @@ export class MailxService {
1082
1083
  : ((body.trim().split("\n")[0] || resp.statusText).slice(0, 200));
1083
1084
  return { ok, status: displayStatus, statusText };
1084
1085
  }
1086
+ /** AI spend since `sinceMs` (default: start of this month). Reads the
1087
+ * per-call log written by aiTransform. `unpricedCalls` are excluded from
1088
+ * `costUsd` — a model with no published price contributes tokens, not a
1089
+ * made-up dollar figure. */
1090
+ getAiUsage(sinceMs) {
1091
+ return summarizeAiUsage(sinceMs);
1092
+ }
1085
1093
  /** Fetch a remote image so the WebView can put it on the clipboard.
1086
1094
  *
1087
1095
  * The client cannot do this itself: an image hosted by a sender is
@@ -3740,6 +3748,25 @@ export class MailxService {
3740
3748
  * openInWord / openLocalPath). Cross-platform: start / open / xdg-open. */
3741
3749
  async openAttachment(accountId, uid, attachmentId, folderId, filename) {
3742
3750
  const att = await this.getAttachment(accountId, uid, attachmentId, folderId);
3751
+ return await this.stageAndOpenFile(filename || att.filename, att.contentType, att.content);
3752
+ }
3753
+ /** Open a file the user is composing WITH — an attachment sitting in the
3754
+ * compose window that has never been in a message. The bytes come from
3755
+ * the client (it already holds them for the send), so there is nothing
3756
+ * to fetch; everything after that is identical to opening a received
3757
+ * attachment, which is why both go through stageAndOpenFile. */
3758
+ async openPendingAttachment(filename, mimeType, dataBase64) {
3759
+ const content = Buffer.from(dataBase64 || "", "base64");
3760
+ if (content.length === 0)
3761
+ throw new Error(`"${filename}" has no content to open`);
3762
+ return await this.stageAndOpenFile(filename, mimeType, content);
3763
+ }
3764
+ /** Stage bytes to the attachment temp dir and hand them to the OS opener.
3765
+ * Shared by received attachments and compose attachments — the collision
3766
+ * handling and Mark-of-the-Web tagging below are subtle enough that a
3767
+ * second copy of them would drift. */
3768
+ async stageAndOpenFile(filename, contentType, content) {
3769
+ const att = { filename, contentType, content };
3743
3770
  const dir = getAttachmentStagingDir();
3744
3771
  fs.mkdirSync(dir, { recursive: true });
3745
3772
  // Prefer the filename the UI is showing — it comes from the sync-time
@@ -3750,7 +3777,7 @@ export class MailxService {
3750
3777
  // silently no-ops — the invite.ics that "did nothing" (2026-06-08).
3751
3778
  // basename() strips any path components so a crafted name can't escape
3752
3779
  // the temp dir; also drop newlines.
3753
- let safeName = (path.basename(filename || att.filename || "attachment").replace(/[\r\n]/g, "_")) || "attachment";
3780
+ let safeName = (path.basename(att.filename || "attachment").replace(/[\r\n]/g, "_")) || "attachment";
3754
3781
  // Guarantee an extension so the OS has something to associate. Derive
3755
3782
  // it from the MIME type when the name lacks one.
3756
3783
  if (!path.extname(safeName)) {
@@ -3879,7 +3906,7 @@ export class MailxService {
3879
3906
  }
3880
3907
  }
3881
3908
  // ── Drafts ──
3882
- async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId, bcc, from) {
3909
+ async saveDraft(accountId, subject, bodyHtml, bodyText, to, cc, previousDraftUid, draftId, bcc, from, attachments) {
3883
3910
  // Local-first: commit the draft to the local filesystem synchronously
3884
3911
  // and return immediately. The IMAP APPEND (and the previous-draft
3885
3912
  // delete) run in the background. Previously this method awaited IMAP
@@ -3908,11 +3935,17 @@ export class MailxService {
3908
3935
  // the account default — a reply to an alias must keep that alias
3909
3936
  // (Bob 2026-07-30); and Bcc belongs IN a draft, it is the SEND path
3910
3937
  // that strips it, so reopening a draft still shows the Bcc list.
3938
+ // Attachments belong IN the draft. Without them the saved draft is
3939
+ // body-only: close compose and the file is gone, and the list shows
3940
+ // the body-only size (Bob 2026-08-11: "65KB attachment, why is the
3941
+ // email size showing as 6K?" — the size was right, the draft was
3942
+ // wrong). The send path always passed them; only this one didn't.
3911
3943
  const raw = buildMimeMessage({
3912
3944
  from: from?.trim() || `${account.name} <${account.email}>`,
3913
3945
  to, cc, bcc,
3914
3946
  subject: subject || "(no subject)",
3915
3947
  bodyHtml, bodyText,
3948
+ attachments,
3916
3949
  domain: account.email.split("@")[1] || "mailx.local",
3917
3950
  extraHeaders: [`X-Mailx-Draft-ID: ${id}`],
3918
3951
  omitMessageId: true,
@@ -4701,6 +4734,8 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4701
4734
  // provider-call site so the three branches stay close together.
4702
4735
  let rawText = "";
4703
4736
  let reason = "";
4737
+ // Token usage for cost accounting, filled in by whichever branch runs.
4738
+ let usage = null;
4704
4739
  try {
4705
4740
  if (cfg.provider === "ollama") {
4706
4741
  const res = await fetch(`${cfg.ollamaUrl}/api/generate`, {
@@ -4717,6 +4752,9 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4717
4752
  return { text: "", reason: `ollama ${res.status}` };
4718
4753
  const data = await res.json();
4719
4754
  rawText = (data.response || "").trim();
4755
+ // Local model — free, but still counted so "$0.00" is measured
4756
+ // rather than merely absent.
4757
+ usage = { input: data.prompt_eval_count || 0, output: data.eval_count || 0, model: cfg.ollamaModel };
4720
4758
  }
4721
4759
  else {
4722
4760
  // Cloud providers: read API keys from accounts.jsonc,
@@ -4744,6 +4782,11 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4744
4782
  return { text: "", reason: `claude ${res.status}` };
4745
4783
  const data = await res.json();
4746
4784
  rawText = (data.content?.[0]?.text || "").trim();
4785
+ usage = {
4786
+ input: data.usage?.input_tokens || 0,
4787
+ output: data.usage?.output_tokens || 0,
4788
+ model: data.model || cfg.cloudModel,
4789
+ };
4747
4790
  }
4748
4791
  else if (cfg.provider === "openai") {
4749
4792
  const apiKey = aiKeys.openai || cfg.cloudApiKey;
@@ -4768,6 +4811,11 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4768
4811
  return { text: "", reason: `openai ${res.status}` };
4769
4812
  const data = await res.json();
4770
4813
  rawText = (data.choices?.[0]?.message?.content || "").trim();
4814
+ usage = {
4815
+ input: data.usage?.prompt_tokens || 0,
4816
+ output: data.usage?.completion_tokens || 0,
4817
+ model: data.model || cfg.cloudModel,
4818
+ };
4771
4819
  }
4772
4820
  else {
4773
4821
  return { text: "", reason: "no provider matched" };
@@ -4775,8 +4823,47 @@ Resolve relative dates ("tomorrow", "next Friday") against today's date. If no y
4775
4823
  }
4776
4824
  }
4777
4825
  catch (e) {
4778
- console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${e.message}`);
4779
- return { text: "", reason: e.message };
4826
+ // Node's fetch throws a bare `TypeError: fetch failed` and buries
4827
+ // the actual cause (ECONNREFUSED, ENOTFOUND, cert error) in
4828
+ // `e.cause`. Reporting only the message told Bob nothing — the
4829
+ // real answer was "Ollama isn't running" (2026-08-11). Unwrap it,
4830
+ // and name the endpoint we couldn't reach.
4831
+ const cause = e?.cause?.code || e?.cause?.message || "";
4832
+ const where = cfg.provider === "ollama" ? cfg.ollamaUrl
4833
+ : cfg.provider === "claude" ? "api.anthropic.com"
4834
+ : cfg.provider === "openai" ? "api.openai.com" : cfg.provider;
4835
+ const detail = cause ? `${e.message} (${cause})` : e.message;
4836
+ const reasonText = /fetch failed/i.test(e?.message || "")
4837
+ ? `can't reach ${cfg.provider} at ${where} — ${cause || "connection failed"}`
4838
+ : detail;
4839
+ console.error(` [aiTransform] ${cfg.provider} ${req.action} error: ${detail} [${where}]`);
4840
+ return { text: "", reason: reasonText };
4841
+ }
4842
+ // Cost accounting. Bob asked to monitor AI spend (2026-08-11); nothing
4843
+ // tracked it before. Recorded per call to ~/.rmfmail/logs/ai-usage.jsonl,
4844
+ // with the month-to-date total on the log line so spend is visible
4845
+ // without opening anything. A local model records $0, not "no data";
4846
+ // a model with no published price records tokens and a null cost
4847
+ // rather than a guessed figure.
4848
+ if (usage) {
4849
+ const costUsd = priceCall(cfg.provider, usage.model, usage.input, usage.output);
4850
+ recordAiUsage({
4851
+ ts: Date.now(),
4852
+ provider: cfg.provider,
4853
+ model: usage.model,
4854
+ action: req.action,
4855
+ inputTokens: usage.input,
4856
+ outputTokens: usage.output,
4857
+ costUsd,
4858
+ });
4859
+ const mtd = summarizeAiUsage();
4860
+ const costText = costUsd == null
4861
+ ? "cost unknown (no published price for this model)"
4862
+ : `$${costUsd.toFixed(4)}`;
4863
+ const mtdText = mtd.unpricedCalls > 0
4864
+ ? `$${mtd.costUsd.toFixed(2)} + ${mtd.unpricedCalls} unpriced call(s)`
4865
+ : `$${mtd.costUsd.toFixed(2)}`;
4866
+ console.log(` [ai-usage] ${cfg.provider}/${usage.model} ${req.action}: ${usage.input} in + ${usage.output} out = ${costText}; month-to-date ${mtdText} over ${mtd.calls} call(s)`);
4780
4867
  }
4781
4868
  if (req.action !== "extractEvent")
4782
4869
  return { text: rawText, reason };