@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.
- package/bin/mailx.js +20 -0
- package/bin/mailx.js.map +1 -1
- package/bin/mailx.ts +21 -0
- package/client/android-bootstrap.bundle.js +27 -30
- package/client/android-bootstrap.bundle.js.map +2 -2
- package/client/app.bundle.js +33 -1
- package/client/app.bundle.js.map +3 -3
- package/client/app.js +42 -0
- package/client/app.js.map +1 -1
- package/client/app.ts +40 -0
- package/client/components/message-viewer.js +13 -2
- package/client/components/message-viewer.js.map +1 -1
- package/client/components/message-viewer.ts +13 -2
- package/client/compose/compose.bundle.js +37 -3
- package/client/compose/compose.bundle.js.map +3 -3
- package/client/compose/compose.css +13 -0
- package/client/compose/compose.js +41 -1
- package/client/compose/compose.js.map +1 -1
- package/client/compose/compose.ts +38 -1
- package/client/lib/api-client.js +7 -0
- package/client/lib/api-client.js.map +1 -1
- package/client/lib/api-client.ts +8 -0
- package/client/lib/mailxapi.js +10 -2
- package/package.json +3 -3
- package/packages/mailx-imap/package-lock.json +2 -2
- package/packages/mailx-imap/package.json +1 -1
- package/packages/mailx-service/ai-usage.d.ts +32 -0
- package/packages/mailx-service/ai-usage.d.ts.map +1 -0
- package/packages/mailx-service/ai-usage.js +98 -0
- package/packages/mailx-service/ai-usage.js.map +1 -0
- package/packages/mailx-service/ai-usage.ts +110 -0
- package/packages/mailx-service/index.d.ts +25 -1
- package/packages/mailx-service/index.d.ts.map +1 -1
- package/packages/mailx-service/index.js +91 -4
- package/packages/mailx-service/index.js.map +1 -1
- package/packages/mailx-service/index.ts +94 -4
- package/packages/mailx-service/jsonrpc.js +3 -1
- package/packages/mailx-service/jsonrpc.js.map +1 -1
- package/packages/mailx-service/jsonrpc.ts +3 -1
- package/packages/mailx-service/package.json +1 -1
- package/packages/mailx-settings/package.json +1 -1
- package/packages/mailx-store/package.json +1 -1
- package/packages/mailx-store-web/android-bootstrap.js +1 -1
- package/packages/mailx-store-web/android-bootstrap.js.map +1 -1
- package/packages/mailx-store-web/android-bootstrap.ts +1 -1
- package/packages/mailx-store-web/package.json +1 -1
- package/packages/mailx-store-web/web-service.d.ts +5 -1
- package/packages/mailx-store-web/web-service.d.ts.map +1 -1
- package/packages/mailx-store-web/web-service.js +5 -1
- package/packages/mailx-store-web/web-service.js.map +1 -1
- package/packages/mailx-store-web/web-service.ts +5 -1
- package/packages/mailx-types/mailx-api.d.ts +17 -1
- package/packages/mailx-types/mailx-api.d.ts.map +1 -1
- package/packages/mailx-types/mailx-api.ts +10 -1
- package/packages/mailx-types/mime-build.d.ts.map +1 -1
- package/packages/mailx-types/mime-build.js +35 -30
- package/packages/mailx-types/mime-build.js.map +1 -1
- package/packages/mailx-types/mime-build.ts +44 -32
- package/packages/mailx-types/package.json +1 -1
- /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(
|
|
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
|
-
|
|
4779
|
-
|
|
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 };
|