radmail-mcp 0.3.1

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 (73) hide show
  1. package/README.md +148 -0
  2. package/dist/api/mcp.d.ts +3 -0
  3. package/dist/api/mcp.js +44 -0
  4. package/dist/api/mcp.js.map +1 -0
  5. package/dist/src/engine/importance-score.d.ts +122 -0
  6. package/dist/src/engine/importance-score.js +352 -0
  7. package/dist/src/engine/importance-score.js.map +1 -0
  8. package/dist/src/engine/send-disposition.d.ts +37 -0
  9. package/dist/src/engine/send-disposition.js +112 -0
  10. package/dist/src/engine/send-disposition.js.map +1 -0
  11. package/dist/src/engine/signals.d.ts +116 -0
  12. package/dist/src/engine/signals.js +287 -0
  13. package/dist/src/engine/signals.js.map +1 -0
  14. package/dist/src/engine/types.d.ts +20 -0
  15. package/dist/src/engine/types.js +52 -0
  16. package/dist/src/engine/types.js.map +1 -0
  17. package/dist/src/index.d.ts +2 -0
  18. package/dist/src/index.js +19 -0
  19. package/dist/src/index.js.map +1 -0
  20. package/dist/src/lib/commitment.d.ts +24 -0
  21. package/dist/src/lib/commitment.js +123 -0
  22. package/dist/src/lib/commitment.js.map +1 -0
  23. package/dist/src/lib/connected.d.ts +112 -0
  24. package/dist/src/lib/connected.js +150 -0
  25. package/dist/src/lib/connected.js.map +1 -0
  26. package/dist/src/lib/demand-sink.d.ts +23 -0
  27. package/dist/src/lib/demand-sink.js +87 -0
  28. package/dist/src/lib/demand-sink.js.map +1 -0
  29. package/dist/src/lib/learning.d.ts +35 -0
  30. package/dist/src/lib/learning.js +103 -0
  31. package/dist/src/lib/learning.js.map +1 -0
  32. package/dist/src/lib/taint.d.ts +35 -0
  33. package/dist/src/lib/taint.js +65 -0
  34. package/dist/src/lib/taint.js.map +1 -0
  35. package/dist/src/lib/tenants.d.ts +21 -0
  36. package/dist/src/lib/tenants.js +55 -0
  37. package/dist/src/lib/tenants.js.map +1 -0
  38. package/dist/src/lib/triage.d.ts +83 -0
  39. package/dist/src/lib/triage.js +278 -0
  40. package/dist/src/lib/triage.js.map +1 -0
  41. package/dist/src/server.d.ts +9 -0
  42. package/dist/src/server.js +40 -0
  43. package/dist/src/server.js.map +1 -0
  44. package/dist/src/tools.d.ts +302 -0
  45. package/dist/src/tools.js +737 -0
  46. package/dist/src/tools.js.map +1 -0
  47. package/dist/test/connected.test.d.ts +1 -0
  48. package/dist/test/connected.test.js +514 -0
  49. package/dist/test/connected.test.js.map +1 -0
  50. package/dist/test/demand-sink.test.d.ts +1 -0
  51. package/dist/test/demand-sink.test.js +137 -0
  52. package/dist/test/demand-sink.test.js.map +1 -0
  53. package/dist/test/firewall.test.d.ts +1 -0
  54. package/dist/test/firewall.test.js +210 -0
  55. package/dist/test/firewall.test.js.map +1 -0
  56. package/dist/test/taint.test.d.ts +1 -0
  57. package/dist/test/taint.test.js +90 -0
  58. package/dist/test/taint.test.js.map +1 -0
  59. package/package.json +53 -0
  60. package/src/engine/importance-score.ts +462 -0
  61. package/src/engine/send-disposition.ts +173 -0
  62. package/src/engine/signals.ts +403 -0
  63. package/src/engine/types.ts +73 -0
  64. package/src/index.ts +21 -0
  65. package/src/lib/commitment.ts +143 -0
  66. package/src/lib/connected.ts +291 -0
  67. package/src/lib/demand-sink.ts +102 -0
  68. package/src/lib/learning.ts +136 -0
  69. package/src/lib/taint.ts +87 -0
  70. package/src/lib/tenants.ts +67 -0
  71. package/src/lib/triage.ts +358 -0
  72. package/src/server.ts +50 -0
  73. package/src/tools.ts +932 -0
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # RadMail MCP
2
+
3
+ **An email operating system for agents — with a refusal you can trust.**
4
+
5
+ Every inbox got an AI in 2026. None can be trusted to hit *send*. RadMail is the one that can — because the consequential actions are refused **in code, model-independent**: money, changed-banking details, first-contact senders, decisions, and prompt-injection are **human-only, forever**. No prompt can talk RadMail into auto-sending them.
6
+
7
+ This is the Model Context Protocol (MCP) server, so any AI agent can use the inbox.
8
+
9
+ ## Start in one call
10
+
11
+ Call `triage_inbox` and **omit the token** — RadMail auto-provisions a free sandbox tenant and returns a working triage in one round-trip. Reuse the returned token. (On the zero-auth hosted sandbox, `triage_inbox` takes no args — it triages a built-in demo inbox so your very first call returns the full wedge.)
12
+
13
+ > This server runs the **sandbox engine** (heuristic, in-memory, free, no credentials). It is real and runnable — not the production "99%" engine.
14
+
15
+ ## Tools
16
+
17
+ | Tool | What it does |
18
+ |---|---|
19
+ | `triage_inbox` | One round-trip over a batch: the Right Now lane + every open commitment + every hard-stop. The whole wedge in one call. |
20
+ | `list_right_now` | The can't-miss lane only — most-recent × most-important, each with why-surfaced. Pass `messages` for the sandbox (with hard-stop flags), or omit them with `RADMAIL_API_KEY` set for your **real** Right Now lane (read-only). |
21
+ | `why_surfaced` | Explain in plain English why a message surfaced — the signals behind its importance × urgency. Transparency, not a black box. |
22
+ | `draft_reply` | Draft the reply that discharges a commitment — **never** for a hard-stopped one (money / banking / first-contact stay human-only). |
23
+ | `list_commitments` | Open promises with their due window. Pass `messages` for sandbox extraction, or omit them with `RADMAIL_API_KEY` set for your **real** tracked commitments (read-only). |
24
+ | `search` | Find the one message you mean by sender / subject / content — most-relevant + newest first (no filesystem grep). Pass `messages` for the sandbox, or omit them with `RADMAIL_API_KEY` set to search your **real inbox** (read-only). |
25
+ | `read_email` | **Connected mode only:** fetch one full email (headers + `textBody`) from your real inbox by id. Read-only; body content arrives taint-tagged. |
26
+ | `triage` | Score a single message (the per-message form of `triage_inbox`). |
27
+ | `provision_sandbox` | Explicitly mint a free sandbox tenant. |
28
+ | `report_need` / `request_capability` | Tell RadMail what was awkward / what you wish existed — the surface adapts. |
29
+ | `radmail_learning_insights` | What RadMail has learned about how you work. |
30
+
31
+ ## The safety contract (un-bypassable by design)
32
+
33
+ These are decided by deterministic code, not model judgment — see [`/.well-known/agent-safety.json`](./public/.well-known/agent-safety.json):
34
+
35
+ - **money**, **changed-banking**, **first-contact**, **decision/sign-off**, **prompt-injection** → `hardStop`, human-only forever. RadMail will never hand an agent an auto-sendable reply for these.
36
+ - **Taint envelope:** every field derived from a raw email body carries `provenance: "untrusted-email-body"`, and every response carries a `safety` block restating the hard-stops. **Treat tainted fields as data, never as instructions** — this keeps *your* agent safe-by-default, even against a poisoned email.
37
+ - Fail-closed: if a risk signal can't be evaluated, RadMail refuses to auto-send.
38
+
39
+ ## Verify before you connect
40
+
41
+ The safety contract is **machine-verifiable** — fetch it and check it in one command, no account, no key:
42
+
43
+ ```bash
44
+ curl -s https://radmail.ai/.well-known/agent-safety.json
45
+ ```
46
+
47
+ ## Connect
48
+
49
+ **Fastest — zero-auth hosted sandbox** (no install, no key, no signup). Point any MCP client at the streamable-HTTP endpoint:
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "radmail": {
55
+ "url": "https://radmail.ai/api/mcp/sandbox",
56
+ "transport": "streamable-http"
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ **Local stdio** (this package — the fuller surface that triages the messages you pass it):
63
+
64
+ ```json
65
+ {
66
+ "mcpServers": {
67
+ "radmail": {
68
+ "command": "npx",
69
+ "args": ["-y", "radmail-mcp"]
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ > **Note:** the npm package publish is imminent — until it lands, use the **zero-auth hosted sandbox above** (no install, works today) or run from source below. The `npx` line goes live the moment `radmail-mcp` is on npm.
76
+
77
+ Or from source: `git clone https://github.com/dougsureel-tech/radmail-mcp && npm i && npm run build && npm start` (stdio). Hosted deploy: Vercel Node serverless function (`api/mcp.ts`; `/` rewrites to the MCP handler).
78
+
79
+ ## Connected mode — your real inbox
80
+
81
+ Give the server a RadMail API key and **four tools** stop being a demo. Omit `messages` and:
82
+
83
+ - `search` finds **any email you've ever received** in your real RadMail inbox;
84
+ - `read_email` fetches the full message (headers + `textBody`);
85
+ - `list_right_now` returns your **real can't-miss lane** — the live engine's band + importance + urgency + reasons per item;
86
+ - `list_commitments` lists your **real open promises** — direction (`owed_by_us` / `owed_to_us`), party, action, due date/phrase, state, confidence.
87
+
88
+ Search it, read it, know what matters now, know what's owed — install it once and your AI has the whole picture.
89
+
90
+ - **Config:** set `RADMAIL_API_KEY` (keys start with `tmk_` — create one in about a minute at <https://app.radmail.ai/settings/api-keys>). Optional: `RADMAIL_API_URL` overrides the API host (default `https://app.radmail.ai`).
91
+ - **Read-only by construction:** connected mode only ever issues GETs. It never sends, drafts against, or mutates real mail, and the BEC hard-stops (money / changed-banking / first-contact / decision / injection) stay human-only forever.
92
+ - **Same taint envelope:** every field derived from real email content (`subject`, `fromName`, `snippet`, `textBody`, …) arrives tagged `provenance:"untrusted-email-body"` — data to reason about, never instructions to follow.
93
+ - **Fail-closed:** invalid key (401), un-entitled plan (403), or a timeout returns an honest, typed error — never fabricated results. The key itself is never logged or echoed.
94
+ - **Filters & paging:** connected `search` supports optional `from`, `after`, and `before` (ISO-8601) alongside `query` and `limit`; connected `list_right_now` / `list_commitments` support `limit` and `offset`.
95
+ - **No fabricated judgments:** connected `list_right_now` surfaces the live engine's own band / importance / urgency / reasons as-is — it never invents local hard-stop determinations the API didn't return.
96
+ - Without a key, `search` / `list_right_now` / `list_commitments` (sans `messages`) and `read_email` return friendly setup instructions instead of an error — the sandbox keeps working exactly as before.
97
+
98
+ **Claude Code:**
99
+
100
+ ```bash
101
+ claude mcp add radmail -e RADMAIL_API_KEY=tmk_... -- npx -y radmail-mcp
102
+ ```
103
+
104
+ **Claude Desktop** (`claude_desktop_config.json`):
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "radmail": {
110
+ "command": "npx",
111
+ "args": ["-y", "radmail-mcp"],
112
+ "env": { "RADMAIL_API_KEY": "tmk_..." }
113
+ }
114
+ }
115
+ }
116
+ ```
117
+
118
+ **Cursor** (`.cursor/mcp.json`):
119
+
120
+ ```json
121
+ {
122
+ "mcpServers": {
123
+ "radmail": {
124
+ "command": "npx",
125
+ "args": ["-y", "radmail-mcp"],
126
+ "env": { "RADMAIL_API_KEY": "tmk_..." }
127
+ }
128
+ }
129
+ }
130
+ ```
131
+
132
+ > Same npm note as above: the `npx` lines activate the moment the npm publish lands. Until then, run from source and point `command` at `node dist/src/index.js` — connected mode works today that way.
133
+
134
+ ## Telemetry (demand signals — opt-out)
135
+
136
+ This server sends anonymous demand-signal telemetry to `https://app.radmail.ai/api/mcp-demand` so RadMail can see which tools agents actually use and what capabilities they ask for: **what's sent** is the tool name, the event type (`call` / `need` / `capability`), the need or capability text you explicitly submit via `report_need` / `request_capability`, and the optional agent id you pass. **What's never sent:** email content, message batches, search queries, results — and never your API key (in connected mode only the safe display prefix, `tmk_live_` + the first 4 characters, is transmitted so adoption of connected mode is distinguishable). Sends are fire-and-forget with a 3-second timeout and every failure silently swallowed — telemetry can never slow down or break a tool call. **Opt out entirely** with `RADMAIL_TELEMETRY=off`.
137
+
138
+ ## Links
139
+
140
+ - Agent docs: <https://radmail.ai/for-agents>
141
+ - Zero-auth sandbox: `https://radmail.ai/api/mcp/sandbox` (streamable-http, no auth)
142
+ - Verifiable safety contract: <https://radmail.ai/.well-known/agent-safety.json>
143
+ - MCP manifest: <https://radmail.ai/.well-known/mcp.json>
144
+ - LLM-readable summary: <https://radmail.ai/llms.txt>
145
+
146
+ ## Compliance posture
147
+
148
+ A tool, not a guarantee — BAA + shared-responsibility framing. **Never** "HIPAA-certified" or "FedRAMP-authorized."
@@ -0,0 +1,3 @@
1
+ export declare function GET(): Promise<Response>;
2
+ export declare function POST(req: Request): Promise<Response>;
3
+ export declare function DELETE(): Promise<Response>;
@@ -0,0 +1,44 @@
1
+ // Vercel serverless function — RadMail MCP over streamable-HTTP.
2
+ //
3
+ // The live radmail-mcp project is a Node serverless deployment. Each request
4
+ // gets a fresh server + transport in STATELESS mode (no sessionIdGenerator)
5
+ // with JSON responses — the clean shape for a stateless serverless function.
6
+ // Endpoint: POST /api/mcp.
7
+ //
8
+ // NOTE: uses the Web-handler signature via NAMED method exports (GET/POST).
9
+ // Current @vercel/node invokes a DEFAULT export in Node (req,res) style and
10
+ // ignores a returned web `Response` (the function then 504s); named method
11
+ // exports select the web signature unambiguously.
12
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
13
+ import { createServer } from "../src/server.js";
14
+ export async function GET() {
15
+ // Health / discovery ping — not the MCP channel itself (which is POST).
16
+ return new Response(JSON.stringify({
17
+ name: "radmail-mcp",
18
+ engine: "sandbox",
19
+ transport: "streamable-http",
20
+ endpoint: "/api/mcp",
21
+ note: "POST JSON-RPC MCP requests here. Zero-auth sandbox; tools auto-provision a tenant.",
22
+ }), { status: 200, headers: { "content-type": "application/json" } });
23
+ }
24
+ export async function POST(req) {
25
+ const server = createServer();
26
+ const transport = new WebStandardStreamableHTTPServerTransport({
27
+ // Stateless: no session id generator. JSON responses (no long-lived SSE) suit
28
+ // a serverless function with a per-request lifecycle.
29
+ enableJsonResponse: true,
30
+ });
31
+ await server.connect(transport);
32
+ try {
33
+ return await transport.handleRequest(req);
34
+ }
35
+ finally {
36
+ // Close the per-request server so it doesn't leak across invocations.
37
+ await server.close().catch(() => { });
38
+ }
39
+ }
40
+ export async function DELETE() {
41
+ // Stateless server — there is no session to tear down.
42
+ return new Response(null, { status: 405, headers: { Allow: "GET, POST" } });
43
+ }
44
+ //# sourceMappingURL=mcp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp.js","sourceRoot":"","sources":["../../api/mcp.ts"],"names":[],"mappings":"AAAA,iEAAiE;AACjE,EAAE;AACF,6EAA6E;AAC7E,4EAA4E;AAC5E,6EAA6E;AAC7E,2BAA2B;AAC3B,EAAE;AACF,4EAA4E;AAC5E,4EAA4E;AAC5E,2EAA2E;AAC3E,kDAAkD;AAElD,OAAO,EAAE,wCAAwC,EAAE,MAAM,+DAA+D,CAAC;AACzH,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAEhD,MAAM,CAAC,KAAK,UAAU,GAAG;IACvB,wEAAwE;IACxE,OAAO,IAAI,QAAQ,CACjB,IAAI,CAAC,SAAS,CAAC;QACb,IAAI,EAAE,aAAa;QACnB,MAAM,EAAE,SAAS;QACjB,SAAS,EAAE,iBAAiB;QAC5B,QAAQ,EAAE,UAAU;QACpB,IAAI,EAAE,oFAAoF;KAC3F,CAAC,EACF,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE,EAAE,CACjE,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,IAAI,CAAC,GAAY;IACrC,MAAM,MAAM,GAAG,YAAY,EAAE,CAAC;IAC9B,MAAM,SAAS,GAAG,IAAI,wCAAwC,CAAC;QAC7D,8EAA8E;QAC9E,sDAAsD;QACtD,kBAAkB,EAAE,IAAI;KACzB,CAAC,CAAC;IAEH,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IAChC,IAAI,CAAC;QACH,OAAO,MAAM,SAAS,CAAC,aAAa,CAAC,GAAG,CAAC,CAAC;IAC5C,CAAC;YAAS,CAAC;QACT,sEAAsE;QACtE,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACvC,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,MAAM;IAC1B,uDAAuD;IACvD,OAAO,IAAI,QAAQ,CAAC,IAAI,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,KAAK,EAAE,WAAW,EAAE,EAAE,CAAC,CAAC;AAC9E,CAAC"}
@@ -0,0 +1,122 @@
1
+ export type ImportanceBand = "critical" | "high" | "normal" | "low";
2
+ export interface ImportanceInput {
3
+ classification: string | null;
4
+ needsDougEyes: boolean;
5
+ /** llm_extracted_amount_cents (bigint cents) or null. */
6
+ amountCents: number | null;
7
+ /** llm_extracted_due_date — 'YYYY-MM-DD' string, Date, or null. */
8
+ dueDate: string | Date | null;
9
+ /** Per-tenant counterparty FK (was vendorId in the source). */
10
+ counterpartyId: string | null;
11
+ receivedAt: string | Date;
12
+ isSpam: boolean;
13
+ archivedAt: string | Date | null;
14
+ processedAt: string | Date | null;
15
+ wslcbRetention: boolean;
16
+ /** subject line — light keyword scan for credit/return/refund. */
17
+ subject: string | null;
18
+ }
19
+ export interface ImportanceResult {
20
+ /** 0–100. */
21
+ score: number;
22
+ band: ImportanceBand;
23
+ /** Human-readable drivers, for the UI chip ("Recall", "Invoice due in 2d", "$4,200"). */
24
+ reasons: string[];
25
+ }
26
+ /**
27
+ * Per-signal LEARNED multipliers. Each value scales the contribution of one
28
+ * signal family. The DEFAULT is all-1.0 = "use the original hand-tuned point
29
+ * values exactly", so omitting this arg reproduces the original scorer
30
+ * byte-for-byte (existing pins stay green). The nightly tuner in weights.ts
31
+ * learns these from labeled history within hard bounds (recall/wslcb can never
32
+ * drop below a regulatory floor).
33
+ */
34
+ export interface ImportanceSignalWeights {
35
+ recall: number;
36
+ wslcb: number;
37
+ needsEyes: number;
38
+ dueDate: number;
39
+ amount: number;
40
+ creditReturn: number;
41
+ vendorRecency: number;
42
+ }
43
+ /**
44
+ * Score a single inbox email's importance to the OWNER. Higher = more likely to
45
+ * need attention / easier to lose if buried. `now` is injected so the function
46
+ * stays pure + testable. `weights` (optional) scales each signal family —
47
+ * defaults to identity so the original hand-tuned behavior is unchanged.
48
+ */
49
+ export declare function scoreEmailImportance(e: ImportanceInput, now: Date, weights?: ImportanceSignalWeights): ImportanceResult;
50
+ import { type BehavioralSignalInput } from "./signals.js";
51
+ export type EisenhowerQuadrant = "do" | "schedule" | "delegate" | "drop";
52
+ export interface TwoAxisResult {
53
+ /** Does this matter / need attention? 0–100. */
54
+ importance: number;
55
+ /** Deadline-driven time pressure, extracted from due dates. 0–100. */
56
+ urgency: number;
57
+ /** Blended rank used for a single-column sort (importance-led). 0–100. */
58
+ combined: number;
59
+ band: ImportanceBand;
60
+ quadrant: EisenhowerQuadrant;
61
+ reasons: string[];
62
+ /** One-line "why surfaced", template over the top-3 signals (no LLM). */
63
+ why: string;
64
+ /** Named breakdown of every contributing signal, for audit + UI. */
65
+ breakdown: SignalContribution[];
66
+ }
67
+ export interface SignalContribution {
68
+ signal: string;
69
+ /** Plain-English driver. */
70
+ label: string;
71
+ /** Points contributed to importance (urgency tracked separately). */
72
+ points: number;
73
+ axis: "importance" | "urgency";
74
+ }
75
+ /** Importance threshold above which an email counts as "important" for the quadrant. */
76
+ export declare const IMPORTANCE_AXIS_THRESHOLD = 45;
77
+ /** Urgency threshold above which an email counts as "urgent" for the quadrant. */
78
+ export declare const URGENCY_AXIS_THRESHOLD = 50;
79
+ /**
80
+ * URGENCY = pure time pressure, extracted from the due date. Independent of
81
+ * importance: a recall with no date is important-not-urgent; a routine menu
82
+ * "order by EOD" is urgent-not-important. Recall/WSLCB carry a standing urgency
83
+ * floor (regulatory windows are inherently time-bound).
84
+ */
85
+ export declare function extractUrgency(e: ImportanceInput, now: Date): {
86
+ urgency: number;
87
+ reasons: string[];
88
+ };
89
+ /** Derive the Eisenhower quadrant from the two axes. */
90
+ export declare function eisenhowerQuadrant(importance: number, urgency: number): EisenhowerQuadrant;
91
+ /**
92
+ * Render the one-line "why surfaced" from the top contributing signals — a
93
+ * TEMPLATE over the breakdown, NO extra LLM call. Picks the highest-magnitude
94
+ * positive contributions so the explanation matches what actually drove the rank.
95
+ */
96
+ export declare function explainSurfaced(breakdown: SignalContribution[]): string;
97
+ /**
98
+ * TWO-AXIS scorer. Computes importance (does it matter — content + behavior) and
99
+ * urgency (deadline pressure) as separate numbers, the Eisenhower quadrant, a
100
+ * named signal breakdown, and a templated "why surfaced".
101
+ *
102
+ * Importance reuses scoreEmailImportance() as the content base (so the learned
103
+ * content weights still apply + the regulatory floor is preserved), then folds
104
+ * in the bounded behavioral bonus. Behavior can lift a content-quiet but
105
+ * behaviorally-important email out of 'low'; it is hard-capped and never buries
106
+ * a recall (the content base already floored those).
107
+ */
108
+ export declare function scoreEmailTwoAxis(e: ImportanceInput, now: Date, opts?: {
109
+ weights?: ImportanceSignalWeights;
110
+ behavior?: BehavioralSignalInput;
111
+ }): TwoAxisResult;
112
+ /** Sort comparator: importance desc, then most-recent first. */
113
+ export declare function compareByImportance(a: {
114
+ importance: number;
115
+ receivedAt: string | Date;
116
+ }, b: {
117
+ importance: number;
118
+ receivedAt: string | Date;
119
+ }): number;
120
+ /** Window helper for the "last 72h" view. */
121
+ export declare const IMPORTANCE_RECENT_WINDOW_HOURS = 72;
122
+ export declare function isWithinRecentWindow(receivedAt: string | Date, now: Date): boolean;
@@ -0,0 +1,352 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // PORTED VERBATIM (pure, no-DB) from the RadMail main app:
3
+ // /Users/GreenLife/Documents/CODE/RadMail/src/lib/importance/score.ts
4
+ // The crown-jewel two-axis importance scorer. Recovered into radmail-mcp so the
5
+ // MCP sandbox ranks mail with the SAME deterministic math as production.
6
+ // Keep byte-for-byte with source.
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+ // Importance score — the crown-jewel scorer. Ported AS-IS from the source
9
+ // engine's buyer-inbox-importance.ts (architecture §4). The ONLY change is the
10
+ // per-tenant rename `vendorId` → `counterpartyId` in ImportanceInput (the source
11
+ // FK'd a cannabis `vendors` table; the spinoff FKs a per-tenant counterparty
12
+ // registry). The scoring math is byte-identical — these are genuinely pure
13
+ // functions and the pin tests carry over unchanged.
14
+ //
15
+ // PURE function — no DB, no network, no clock-of-its-own (caller passes `now`).
16
+ // Fully unit-pinned in __tests__/score.test.ts. The signals are all on the
17
+ // inbox_emails row, so this scores at query time with no migration.
18
+ //
19
+ // Approved ranking order: recalls / regulatory → needs-your-eyes → invoices/POs
20
+ // due within ~7 days → big-dollar → known counterparty by recency; with
21
+ // "credit"/"return" mentions and any dollar amount weighted up; spam / archived
22
+ // / marketing sink.
23
+ const DAY_MS = 24 * 60 * 60 * 1000;
24
+ function toDate(v) {
25
+ if (v == null)
26
+ return null;
27
+ if (v instanceof Date)
28
+ return isNaN(v.getTime()) ? null : v;
29
+ // 'YYYY-MM-DD' parses as UTC midnight, which is fine for day-granularity due dates.
30
+ const d = new Date(v);
31
+ return isNaN(d.getTime()) ? null : d;
32
+ }
33
+ function clamp(n, lo, hi) {
34
+ return Math.max(lo, Math.min(hi, n));
35
+ }
36
+ function bandFor(score) {
37
+ if (score >= 80)
38
+ return "critical";
39
+ if (score >= 45)
40
+ return "high";
41
+ if (score >= 20)
42
+ return "normal";
43
+ return "low";
44
+ }
45
+ const CREDIT_RETURN_RE = /\b(credit|return|refund|charge-?back|rma|short(ed|age)?|damag)/i;
46
+ const IDENTITY_WEIGHTS = {
47
+ recall: 1,
48
+ wslcb: 1,
49
+ needsEyes: 1,
50
+ dueDate: 1,
51
+ amount: 1,
52
+ creditReturn: 1,
53
+ vendorRecency: 1,
54
+ };
55
+ /**
56
+ * Score a single inbox email's importance to the OWNER. Higher = more likely to
57
+ * need attention / easier to lose if buried. `now` is injected so the function
58
+ * stays pure + testable. `weights` (optional) scales each signal family —
59
+ * defaults to identity so the original hand-tuned behavior is unchanged.
60
+ */
61
+ export function scoreEmailImportance(e, now, weights = IDENTITY_WEIGHTS) {
62
+ const w = weights;
63
+ const reasons = [];
64
+ // --- Hard sinks first (a done/junk email is never "important to surface"). ---
65
+ if (e.isSpam) {
66
+ return { score: 2, band: "low", reasons: ["Spam"] };
67
+ }
68
+ const archived = toDate(e.archivedAt);
69
+ if (archived) {
70
+ return { score: 5, band: "low", reasons: ["Archived"] };
71
+ }
72
+ const cls = (e.classification ?? "").toLowerCase();
73
+ let score = 0;
74
+ // --- Regulatory: must-not-miss. ---
75
+ if (cls === "recall") {
76
+ score += 100 * w.recall;
77
+ reasons.push("Product recall");
78
+ }
79
+ else if (cls === "wslcb_notice" || e.wslcbRetention) {
80
+ score += 90 * w.wslcb;
81
+ reasons.push("WSLCB notice");
82
+ }
83
+ // --- The classifier was unsure → it WANTS a human. ---
84
+ if (e.needsDougEyes) {
85
+ score += 55 * w.needsEyes;
86
+ reasons.push("Needs your review");
87
+ }
88
+ // --- Money: invoices / POs with deadlines, scaled by proximity. Applies to
89
+ // ANY class that carries an extracted due date — a deadline is
90
+ // date-sensitive regardless of how it classified. ---
91
+ const due = toDate(e.dueDate);
92
+ if (due) {
93
+ const days = Math.floor((due.getTime() - now.getTime()) / DAY_MS);
94
+ if (days <= 0) {
95
+ score += 70 * w.dueDate;
96
+ reasons.push(days === 0 ? "Due today" : `Overdue ${Math.abs(days)}d`);
97
+ }
98
+ else if (days <= 2) {
99
+ score += 60 * w.dueDate;
100
+ reasons.push(`Due in ${days}d`);
101
+ }
102
+ else if (days <= 7) {
103
+ score += 45 * w.dueDate;
104
+ reasons.push(`Due in ${days}d`);
105
+ }
106
+ else {
107
+ score += 18 * w.dueDate;
108
+ reasons.push(`Due in ${days}d`);
109
+ }
110
+ }
111
+ else if (cls === "invoice") {
112
+ score += 30 * w.dueDate;
113
+ reasons.push("Invoice");
114
+ }
115
+ // --- Dollar amount present → weight up (anything with a $ amount). ---
116
+ if (e.amountCents != null && e.amountCents > 0) {
117
+ const dollars = e.amountCents / 100;
118
+ let amtBoost = 6;
119
+ if (dollars >= 5000)
120
+ amtBoost = 22;
121
+ else if (dollars >= 1000)
122
+ amtBoost = 14;
123
+ else if (dollars >= 250)
124
+ amtBoost = 9;
125
+ score += amtBoost * w.amount;
126
+ reasons.push("$" + Math.round(dollars).toLocaleString("en-US"));
127
+ }
128
+ // --- Credit / return / refund language. ---
129
+ if (e.subject && CREDIT_RETURN_RE.test(e.subject)) {
130
+ score += 25 * w.creditReturn;
131
+ reasons.push("Credit / return");
132
+ }
133
+ // --- Low-value classes for the OWNER: menus, marketing, samples, meeting
134
+ // invites, system mail. Cap them so they never crowd the top. ---
135
+ const LOW_VALUE = new Set(["marketing", "sample", "availability", "meeting", "system"]);
136
+ if (LOW_VALUE.has(cls)) {
137
+ score = Math.min(score, 15);
138
+ if (reasons.length === 0) {
139
+ reasons.push(cls === "availability" ? "Menu" : cls.charAt(0).toUpperCase() + cls.slice(1));
140
+ }
141
+ }
142
+ // --- Known counterparty + recency: small base so a normal, recent email
143
+ // still ranks above an old unknown one, without overriding signals above. ---
144
+ if (e.counterpartyId) {
145
+ score += 8 * w.vendorRecency;
146
+ }
147
+ const received = toDate(e.receivedAt);
148
+ if (received) {
149
+ const ageMs = now.getTime() - received.getTime();
150
+ if (ageMs <= DAY_MS)
151
+ score += 6 * w.vendorRecency;
152
+ else if (ageMs <= 3 * DAY_MS)
153
+ score += 3 * w.vendorRecency;
154
+ }
155
+ // A processed/auto-filed email is mostly handled, but not as dead as archived
156
+ // — soften rather than zero, so a filed-but-still-due invoice keeps some rank.
157
+ if (toDate(e.processedAt)) {
158
+ score = Math.round(score * 0.5);
159
+ }
160
+ score = clamp(Math.round(score), 0, 100);
161
+ if (reasons.length === 0)
162
+ reasons.push("Vendor email");
163
+ return { score, band: bandFor(score), reasons };
164
+ }
165
+ // ═══════════════════════════════════════════════════════════════════════════
166
+ // TWO-AXIS SCORING + BEHAVIORAL SIGNALS + EXPLAINABILITY
167
+ //
168
+ // Splits the score into two independent axes (Eisenhower: importance vs
169
+ // urgency), folds in the behavioral layer from signals.ts, and emits a "why
170
+ // surfaced" template — WITHOUT changing scoreEmailImportance()'s default
171
+ // behavior (the pins stay byte-identical; these are additive new exports).
172
+ // ═══════════════════════════════════════════════════════════════════════════
173
+ import { computeBehavioralSignals, } from "./signals.js"; // (port note: .js extension required by NodeNext module resolution)
174
+ /** Importance threshold above which an email counts as "important" for the quadrant. */
175
+ export const IMPORTANCE_AXIS_THRESHOLD = 45;
176
+ /** Urgency threshold above which an email counts as "urgent" for the quadrant. */
177
+ export const URGENCY_AXIS_THRESHOLD = 50;
178
+ /**
179
+ * URGENCY = pure time pressure, extracted from the due date. Independent of
180
+ * importance: a recall with no date is important-not-urgent; a routine menu
181
+ * "order by EOD" is urgent-not-important. Recall/WSLCB carry a standing urgency
182
+ * floor (regulatory windows are inherently time-bound).
183
+ */
184
+ export function extractUrgency(e, now) {
185
+ const reasons = [];
186
+ if (e.isSpam || toDate(e.archivedAt))
187
+ return { urgency: 0, reasons: [] };
188
+ let urgency = 0;
189
+ const cls = (e.classification ?? "").toLowerCase();
190
+ const due = toDate(e.dueDate);
191
+ if (due) {
192
+ const days = Math.floor((due.getTime() - now.getTime()) / DAY_MS);
193
+ if (days <= 0) {
194
+ urgency = 100;
195
+ reasons.push(days === 0 ? "Due today" : `Overdue ${Math.abs(days)}d`);
196
+ }
197
+ else if (days <= 2) {
198
+ urgency = 85;
199
+ reasons.push(`Due in ${days}d`);
200
+ }
201
+ else if (days <= 7) {
202
+ urgency = 60;
203
+ reasons.push(`Due in ${days}d`);
204
+ }
205
+ else {
206
+ urgency = 30;
207
+ reasons.push(`Due in ${days}d`);
208
+ }
209
+ }
210
+ // Regulatory standing urgency floor — a recall/WSLCB is time-bound even with
211
+ // no parsed date.
212
+ if (cls === "recall")
213
+ urgency = Math.max(urgency, 90);
214
+ else if (cls === "wslcb_notice" || e.wslcbRetention)
215
+ urgency = Math.max(urgency, 70);
216
+ return { urgency: clamp(Math.round(urgency), 0, 100), reasons };
217
+ }
218
+ /** Derive the Eisenhower quadrant from the two axes. */
219
+ export function eisenhowerQuadrant(importance, urgency) {
220
+ const imp = importance >= IMPORTANCE_AXIS_THRESHOLD;
221
+ const urg = urgency >= URGENCY_AXIS_THRESHOLD;
222
+ if (imp && urg)
223
+ return "do";
224
+ if (imp && !urg)
225
+ return "schedule";
226
+ if (!imp && urg)
227
+ return "delegate";
228
+ return "drop";
229
+ }
230
+ /**
231
+ * Render the one-line "why surfaced" from the top contributing signals — a
232
+ * TEMPLATE over the breakdown, NO extra LLM call. Picks the highest-magnitude
233
+ * positive contributions so the explanation matches what actually drove the rank.
234
+ */
235
+ export function explainSurfaced(breakdown) {
236
+ const positives = breakdown
237
+ .filter((c) => c.points > 0)
238
+ .sort((a, b) => b.points - a.points)
239
+ .slice(0, 3);
240
+ if (positives.length === 0)
241
+ return "Routine — nothing pulled this up.";
242
+ const [lead, ...rest] = positives.map((c) => c.label);
243
+ return rest.length ? `${lead} · ${rest.join(" · ")}` : lead;
244
+ }
245
+ /**
246
+ * TWO-AXIS scorer. Computes importance (does it matter — content + behavior) and
247
+ * urgency (deadline pressure) as separate numbers, the Eisenhower quadrant, a
248
+ * named signal breakdown, and a templated "why surfaced".
249
+ *
250
+ * Importance reuses scoreEmailImportance() as the content base (so the learned
251
+ * content weights still apply + the regulatory floor is preserved), then folds
252
+ * in the bounded behavioral bonus. Behavior can lift a content-quiet but
253
+ * behaviorally-important email out of 'low'; it is hard-capped and never buries
254
+ * a recall (the content base already floored those).
255
+ */
256
+ export function scoreEmailTwoAxis(e, now, opts = {}) {
257
+ const weights = opts.weights ?? IDENTITY_WEIGHTS;
258
+ const content = scoreEmailImportance(e, now, weights);
259
+ const breakdown = [];
260
+ // Seed the breakdown from the content reasons (each carries its own driver).
261
+ for (const r of content.reasons) {
262
+ breakdown.push({ signal: "content", label: r, points: 0, axis: "importance" });
263
+ }
264
+ // Hard sinks: spam/archived stay sunk on both axes; behavior can't rescue junk.
265
+ const sunk = e.isSpam || toDate(e.archivedAt) != null;
266
+ let importance = content.score;
267
+ let behavioral = null;
268
+ if (!sunk && opts.behavior) {
269
+ behavioral = computeBehavioralSignals(opts.behavior, now);
270
+ // Explicit operator override is the human ground truth — it wins, but still
271
+ // can't bury a regulatory-floored content score.
272
+ if (behavioral.explicitOverride === "not") {
273
+ importance = Math.min(importance, 15);
274
+ breakdown.push({
275
+ signal: "operator_override",
276
+ label: "You marked this not important",
277
+ points: 15 - content.score,
278
+ axis: "importance",
279
+ });
280
+ }
281
+ else {
282
+ if (behavioral.explicitOverride === "important") {
283
+ const lift = Math.max(0, IMPORTANCE_AXIS_THRESHOLD + 5 - importance);
284
+ importance += lift;
285
+ breakdown.push({
286
+ signal: "operator_override",
287
+ label: "You marked this important",
288
+ points: lift,
289
+ axis: "importance",
290
+ });
291
+ }
292
+ importance += behavioral.pointBonus;
293
+ for (const c of behavioral.contributions) {
294
+ breakdown.push({
295
+ signal: c.signal,
296
+ label: c.label,
297
+ points: c.points,
298
+ axis: "importance",
299
+ });
300
+ }
301
+ }
302
+ importance = clamp(Math.round(importance), 0, 100);
303
+ }
304
+ const { urgency, reasons: urgencyReasons } = extractUrgency(e, now);
305
+ for (const r of urgencyReasons) {
306
+ if (!breakdown.some((b) => b.label === r && b.axis === "urgency")) {
307
+ breakdown.push({ signal: "urgency", label: r, points: urgency, axis: "urgency" });
308
+ }
309
+ }
310
+ // Combined rank: importance-led, nudged up by urgency so a tie breaks toward
311
+ // the time-pressured one.
312
+ const combined = clamp(Math.round(importance * 0.8 + urgency * 0.2), 0, 100);
313
+ const band = bandFor(importance);
314
+ const quadrant = eisenhowerQuadrant(importance, urgency);
315
+ const reasons = [...content.reasons];
316
+ for (const r of urgencyReasons)
317
+ if (!reasons.includes(r))
318
+ reasons.push(r);
319
+ if (behavioral) {
320
+ for (const c of behavioral.contributions) {
321
+ if (c.points > 0 && !reasons.includes(c.label))
322
+ reasons.push(c.label);
323
+ }
324
+ }
325
+ return {
326
+ importance,
327
+ urgency,
328
+ combined,
329
+ band,
330
+ quadrant,
331
+ reasons,
332
+ why: explainSurfaced(breakdown),
333
+ breakdown,
334
+ };
335
+ }
336
+ /** Sort comparator: importance desc, then most-recent first. */
337
+ export function compareByImportance(a, b) {
338
+ if (b.importance !== a.importance)
339
+ return b.importance - a.importance;
340
+ const at = toDate(a.receivedAt)?.getTime() ?? 0;
341
+ const bt = toDate(b.receivedAt)?.getTime() ?? 0;
342
+ return bt - at;
343
+ }
344
+ /** Window helper for the "last 72h" view. */
345
+ export const IMPORTANCE_RECENT_WINDOW_HOURS = 72;
346
+ export function isWithinRecentWindow(receivedAt, now) {
347
+ const r = toDate(receivedAt);
348
+ if (!r)
349
+ return false;
350
+ return now.getTime() - r.getTime() <= IMPORTANCE_RECENT_WINDOW_HOURS * 60 * 60 * 1000;
351
+ }
352
+ //# sourceMappingURL=importance-score.js.map