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.
- package/README.md +148 -0
- package/dist/api/mcp.d.ts +3 -0
- package/dist/api/mcp.js +44 -0
- package/dist/api/mcp.js.map +1 -0
- package/dist/src/engine/importance-score.d.ts +122 -0
- package/dist/src/engine/importance-score.js +352 -0
- package/dist/src/engine/importance-score.js.map +1 -0
- package/dist/src/engine/send-disposition.d.ts +37 -0
- package/dist/src/engine/send-disposition.js +112 -0
- package/dist/src/engine/send-disposition.js.map +1 -0
- package/dist/src/engine/signals.d.ts +116 -0
- package/dist/src/engine/signals.js +287 -0
- package/dist/src/engine/signals.js.map +1 -0
- package/dist/src/engine/types.d.ts +20 -0
- package/dist/src/engine/types.js +52 -0
- package/dist/src/engine/types.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +19 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lib/commitment.d.ts +24 -0
- package/dist/src/lib/commitment.js +123 -0
- package/dist/src/lib/commitment.js.map +1 -0
- package/dist/src/lib/connected.d.ts +112 -0
- package/dist/src/lib/connected.js +150 -0
- package/dist/src/lib/connected.js.map +1 -0
- package/dist/src/lib/demand-sink.d.ts +23 -0
- package/dist/src/lib/demand-sink.js +87 -0
- package/dist/src/lib/demand-sink.js.map +1 -0
- package/dist/src/lib/learning.d.ts +35 -0
- package/dist/src/lib/learning.js +103 -0
- package/dist/src/lib/learning.js.map +1 -0
- package/dist/src/lib/taint.d.ts +35 -0
- package/dist/src/lib/taint.js +65 -0
- package/dist/src/lib/taint.js.map +1 -0
- package/dist/src/lib/tenants.d.ts +21 -0
- package/dist/src/lib/tenants.js +55 -0
- package/dist/src/lib/tenants.js.map +1 -0
- package/dist/src/lib/triage.d.ts +83 -0
- package/dist/src/lib/triage.js +278 -0
- package/dist/src/lib/triage.js.map +1 -0
- package/dist/src/server.d.ts +9 -0
- package/dist/src/server.js +40 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/tools.d.ts +302 -0
- package/dist/src/tools.js +737 -0
- package/dist/src/tools.js.map +1 -0
- package/dist/test/connected.test.d.ts +1 -0
- package/dist/test/connected.test.js +514 -0
- package/dist/test/connected.test.js.map +1 -0
- package/dist/test/demand-sink.test.d.ts +1 -0
- package/dist/test/demand-sink.test.js +137 -0
- package/dist/test/demand-sink.test.js.map +1 -0
- package/dist/test/firewall.test.d.ts +1 -0
- package/dist/test/firewall.test.js +210 -0
- package/dist/test/firewall.test.js.map +1 -0
- package/dist/test/taint.test.d.ts +1 -0
- package/dist/test/taint.test.js +90 -0
- package/dist/test/taint.test.js.map +1 -0
- package/package.json +53 -0
- package/src/engine/importance-score.ts +462 -0
- package/src/engine/send-disposition.ts +173 -0
- package/src/engine/signals.ts +403 -0
- package/src/engine/types.ts +73 -0
- package/src/index.ts +21 -0
- package/src/lib/commitment.ts +143 -0
- package/src/lib/connected.ts +291 -0
- package/src/lib/demand-sink.ts +102 -0
- package/src/lib/learning.ts +136 -0
- package/src/lib/taint.ts +87 -0
- package/src/lib/tenants.ts +67 -0
- package/src/lib/triage.ts +358 -0
- package/src/server.ts +50 -0
- package/src/tools.ts +932 -0
|
@@ -0,0 +1,462 @@
|
|
|
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
|
+
|
|
9
|
+
// Importance score — the crown-jewel scorer. Ported AS-IS from the source
|
|
10
|
+
// engine's buyer-inbox-importance.ts (architecture §4). The ONLY change is the
|
|
11
|
+
// per-tenant rename `vendorId` → `counterpartyId` in ImportanceInput (the source
|
|
12
|
+
// FK'd a cannabis `vendors` table; the spinoff FKs a per-tenant counterparty
|
|
13
|
+
// registry). The scoring math is byte-identical — these are genuinely pure
|
|
14
|
+
// functions and the pin tests carry over unchanged.
|
|
15
|
+
//
|
|
16
|
+
// PURE function — no DB, no network, no clock-of-its-own (caller passes `now`).
|
|
17
|
+
// Fully unit-pinned in __tests__/score.test.ts. The signals are all on the
|
|
18
|
+
// inbox_emails row, so this scores at query time with no migration.
|
|
19
|
+
//
|
|
20
|
+
// Approved ranking order: recalls / regulatory → needs-your-eyes → invoices/POs
|
|
21
|
+
// due within ~7 days → big-dollar → known counterparty by recency; with
|
|
22
|
+
// "credit"/"return" mentions and any dollar amount weighted up; spam / archived
|
|
23
|
+
// / marketing sink.
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export type ImportanceBand = "critical" | "high" | "normal" | "low";
|
|
28
|
+
|
|
29
|
+
export interface ImportanceInput {
|
|
30
|
+
classification: string | null;
|
|
31
|
+
needsDougEyes: boolean;
|
|
32
|
+
/** llm_extracted_amount_cents (bigint cents) or null. */
|
|
33
|
+
amountCents: number | null;
|
|
34
|
+
/** llm_extracted_due_date — 'YYYY-MM-DD' string, Date, or null. */
|
|
35
|
+
dueDate: string | Date | null;
|
|
36
|
+
/** Per-tenant counterparty FK (was vendorId in the source). */
|
|
37
|
+
counterpartyId: string | null;
|
|
38
|
+
receivedAt: string | Date;
|
|
39
|
+
isSpam: boolean;
|
|
40
|
+
archivedAt: string | Date | null;
|
|
41
|
+
processedAt: string | Date | null;
|
|
42
|
+
wslcbRetention: boolean;
|
|
43
|
+
/** subject line — light keyword scan for credit/return/refund. */
|
|
44
|
+
subject: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ImportanceResult {
|
|
48
|
+
/** 0–100. */
|
|
49
|
+
score: number;
|
|
50
|
+
band: ImportanceBand;
|
|
51
|
+
/** Human-readable drivers, for the UI chip ("Recall", "Invoice due in 2d", "$4,200"). */
|
|
52
|
+
reasons: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toDate(v: string | Date | null): Date | null {
|
|
56
|
+
if (v == null) return null;
|
|
57
|
+
if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
|
|
58
|
+
// 'YYYY-MM-DD' parses as UTC midnight, which is fine for day-granularity due dates.
|
|
59
|
+
const d = new Date(v);
|
|
60
|
+
return isNaN(d.getTime()) ? null : d;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clamp(n: number, lo: number, hi: number): number {
|
|
64
|
+
return Math.max(lo, Math.min(hi, n));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function bandFor(score: number): ImportanceBand {
|
|
68
|
+
if (score >= 80) return "critical";
|
|
69
|
+
if (score >= 45) return "high";
|
|
70
|
+
if (score >= 20) return "normal";
|
|
71
|
+
return "low";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const CREDIT_RETURN_RE = /\b(credit|return|refund|charge-?back|rma|short(ed|age)?|damag)/i;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Per-signal LEARNED multipliers. Each value scales the contribution of one
|
|
78
|
+
* signal family. The DEFAULT is all-1.0 = "use the original hand-tuned point
|
|
79
|
+
* values exactly", so omitting this arg reproduces the original scorer
|
|
80
|
+
* byte-for-byte (existing pins stay green). The nightly tuner in weights.ts
|
|
81
|
+
* learns these from labeled history within hard bounds (recall/wslcb can never
|
|
82
|
+
* drop below a regulatory floor).
|
|
83
|
+
*/
|
|
84
|
+
export interface ImportanceSignalWeights {
|
|
85
|
+
recall: number;
|
|
86
|
+
wslcb: number;
|
|
87
|
+
needsEyes: number;
|
|
88
|
+
dueDate: number;
|
|
89
|
+
amount: number;
|
|
90
|
+
creditReturn: number;
|
|
91
|
+
vendorRecency: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const IDENTITY_WEIGHTS: ImportanceSignalWeights = {
|
|
95
|
+
recall: 1,
|
|
96
|
+
wslcb: 1,
|
|
97
|
+
needsEyes: 1,
|
|
98
|
+
dueDate: 1,
|
|
99
|
+
amount: 1,
|
|
100
|
+
creditReturn: 1,
|
|
101
|
+
vendorRecency: 1,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Score a single inbox email's importance to the OWNER. Higher = more likely to
|
|
106
|
+
* need attention / easier to lose if buried. `now` is injected so the function
|
|
107
|
+
* stays pure + testable. `weights` (optional) scales each signal family —
|
|
108
|
+
* defaults to identity so the original hand-tuned behavior is unchanged.
|
|
109
|
+
*/
|
|
110
|
+
export function scoreEmailImportance(
|
|
111
|
+
e: ImportanceInput,
|
|
112
|
+
now: Date,
|
|
113
|
+
weights: ImportanceSignalWeights = IDENTITY_WEIGHTS,
|
|
114
|
+
): ImportanceResult {
|
|
115
|
+
const w = weights;
|
|
116
|
+
const reasons: string[] = [];
|
|
117
|
+
|
|
118
|
+
// --- Hard sinks first (a done/junk email is never "important to surface"). ---
|
|
119
|
+
if (e.isSpam) {
|
|
120
|
+
return { score: 2, band: "low", reasons: ["Spam"] };
|
|
121
|
+
}
|
|
122
|
+
const archived = toDate(e.archivedAt);
|
|
123
|
+
if (archived) {
|
|
124
|
+
return { score: 5, band: "low", reasons: ["Archived"] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cls = (e.classification ?? "").toLowerCase();
|
|
128
|
+
let score = 0;
|
|
129
|
+
|
|
130
|
+
// --- Regulatory: must-not-miss. ---
|
|
131
|
+
if (cls === "recall") {
|
|
132
|
+
score += 100 * w.recall;
|
|
133
|
+
reasons.push("Product recall");
|
|
134
|
+
} else if (cls === "wslcb_notice" || e.wslcbRetention) {
|
|
135
|
+
score += 90 * w.wslcb;
|
|
136
|
+
reasons.push("WSLCB notice");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- The classifier was unsure → it WANTS a human. ---
|
|
140
|
+
if (e.needsDougEyes) {
|
|
141
|
+
score += 55 * w.needsEyes;
|
|
142
|
+
reasons.push("Needs your review");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Money: invoices / POs with deadlines, scaled by proximity. Applies to
|
|
146
|
+
// ANY class that carries an extracted due date — a deadline is
|
|
147
|
+
// date-sensitive regardless of how it classified. ---
|
|
148
|
+
const due = toDate(e.dueDate);
|
|
149
|
+
if (due) {
|
|
150
|
+
const days = Math.floor((due.getTime() - now.getTime()) / DAY_MS);
|
|
151
|
+
if (days <= 0) {
|
|
152
|
+
score += 70 * w.dueDate;
|
|
153
|
+
reasons.push(days === 0 ? "Due today" : `Overdue ${Math.abs(days)}d`);
|
|
154
|
+
} else if (days <= 2) {
|
|
155
|
+
score += 60 * w.dueDate;
|
|
156
|
+
reasons.push(`Due in ${days}d`);
|
|
157
|
+
} else if (days <= 7) {
|
|
158
|
+
score += 45 * w.dueDate;
|
|
159
|
+
reasons.push(`Due in ${days}d`);
|
|
160
|
+
} else {
|
|
161
|
+
score += 18 * w.dueDate;
|
|
162
|
+
reasons.push(`Due in ${days}d`);
|
|
163
|
+
}
|
|
164
|
+
} else if (cls === "invoice") {
|
|
165
|
+
score += 30 * w.dueDate;
|
|
166
|
+
reasons.push("Invoice");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- Dollar amount present → weight up (anything with a $ amount). ---
|
|
170
|
+
if (e.amountCents != null && e.amountCents > 0) {
|
|
171
|
+
const dollars = e.amountCents / 100;
|
|
172
|
+
let amtBoost = 6;
|
|
173
|
+
if (dollars >= 5000) amtBoost = 22;
|
|
174
|
+
else if (dollars >= 1000) amtBoost = 14;
|
|
175
|
+
else if (dollars >= 250) amtBoost = 9;
|
|
176
|
+
score += amtBoost * w.amount;
|
|
177
|
+
reasons.push("$" + Math.round(dollars).toLocaleString("en-US"));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// --- Credit / return / refund language. ---
|
|
181
|
+
if (e.subject && CREDIT_RETURN_RE.test(e.subject)) {
|
|
182
|
+
score += 25 * w.creditReturn;
|
|
183
|
+
reasons.push("Credit / return");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Low-value classes for the OWNER: menus, marketing, samples, meeting
|
|
187
|
+
// invites, system mail. Cap them so they never crowd the top. ---
|
|
188
|
+
const LOW_VALUE = new Set(["marketing", "sample", "availability", "meeting", "system"]);
|
|
189
|
+
if (LOW_VALUE.has(cls)) {
|
|
190
|
+
score = Math.min(score, 15);
|
|
191
|
+
if (reasons.length === 0) {
|
|
192
|
+
reasons.push(cls === "availability" ? "Menu" : cls.charAt(0).toUpperCase() + cls.slice(1));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Known counterparty + recency: small base so a normal, recent email
|
|
197
|
+
// still ranks above an old unknown one, without overriding signals above. ---
|
|
198
|
+
if (e.counterpartyId) {
|
|
199
|
+
score += 8 * w.vendorRecency;
|
|
200
|
+
}
|
|
201
|
+
const received = toDate(e.receivedAt);
|
|
202
|
+
if (received) {
|
|
203
|
+
const ageMs = now.getTime() - received.getTime();
|
|
204
|
+
if (ageMs <= DAY_MS) score += 6 * w.vendorRecency;
|
|
205
|
+
else if (ageMs <= 3 * DAY_MS) score += 3 * w.vendorRecency;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A processed/auto-filed email is mostly handled, but not as dead as archived
|
|
209
|
+
// — soften rather than zero, so a filed-but-still-due invoice keeps some rank.
|
|
210
|
+
if (toDate(e.processedAt)) {
|
|
211
|
+
score = Math.round(score * 0.5);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
score = clamp(Math.round(score), 0, 100);
|
|
215
|
+
if (reasons.length === 0) reasons.push("Vendor email");
|
|
216
|
+
return { score, band: bandFor(score), reasons };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
220
|
+
// TWO-AXIS SCORING + BEHAVIORAL SIGNALS + EXPLAINABILITY
|
|
221
|
+
//
|
|
222
|
+
// Splits the score into two independent axes (Eisenhower: importance vs
|
|
223
|
+
// urgency), folds in the behavioral layer from signals.ts, and emits a "why
|
|
224
|
+
// surfaced" template — WITHOUT changing scoreEmailImportance()'s default
|
|
225
|
+
// behavior (the pins stay byte-identical; these are additive new exports).
|
|
226
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
227
|
+
|
|
228
|
+
import {
|
|
229
|
+
computeBehavioralSignals,
|
|
230
|
+
type BehavioralSignalInput,
|
|
231
|
+
type BehavioralSignals,
|
|
232
|
+
} from "./signals.js"; // (port note: .js extension required by NodeNext module resolution)
|
|
233
|
+
|
|
234
|
+
export type EisenhowerQuadrant =
|
|
235
|
+
| "do" // important + urgent
|
|
236
|
+
| "schedule" // important, not urgent
|
|
237
|
+
| "delegate" // urgent, not important
|
|
238
|
+
| "drop"; // neither
|
|
239
|
+
|
|
240
|
+
export interface TwoAxisResult {
|
|
241
|
+
/** Does this matter / need attention? 0–100. */
|
|
242
|
+
importance: number;
|
|
243
|
+
/** Deadline-driven time pressure, extracted from due dates. 0–100. */
|
|
244
|
+
urgency: number;
|
|
245
|
+
/** Blended rank used for a single-column sort (importance-led). 0–100. */
|
|
246
|
+
combined: number;
|
|
247
|
+
band: ImportanceBand;
|
|
248
|
+
quadrant: EisenhowerQuadrant;
|
|
249
|
+
reasons: string[];
|
|
250
|
+
/** One-line "why surfaced", template over the top-3 signals (no LLM). */
|
|
251
|
+
why: string;
|
|
252
|
+
/** Named breakdown of every contributing signal, for audit + UI. */
|
|
253
|
+
breakdown: SignalContribution[];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface SignalContribution {
|
|
257
|
+
signal: string;
|
|
258
|
+
/** Plain-English driver. */
|
|
259
|
+
label: string;
|
|
260
|
+
/** Points contributed to importance (urgency tracked separately). */
|
|
261
|
+
points: number;
|
|
262
|
+
axis: "importance" | "urgency";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Importance threshold above which an email counts as "important" for the quadrant. */
|
|
266
|
+
export const IMPORTANCE_AXIS_THRESHOLD = 45;
|
|
267
|
+
/** Urgency threshold above which an email counts as "urgent" for the quadrant. */
|
|
268
|
+
export const URGENCY_AXIS_THRESHOLD = 50;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* URGENCY = pure time pressure, extracted from the due date. Independent of
|
|
272
|
+
* importance: a recall with no date is important-not-urgent; a routine menu
|
|
273
|
+
* "order by EOD" is urgent-not-important. Recall/WSLCB carry a standing urgency
|
|
274
|
+
* floor (regulatory windows are inherently time-bound).
|
|
275
|
+
*/
|
|
276
|
+
export function extractUrgency(
|
|
277
|
+
e: ImportanceInput,
|
|
278
|
+
now: Date,
|
|
279
|
+
): { urgency: number; reasons: string[] } {
|
|
280
|
+
const reasons: string[] = [];
|
|
281
|
+
if (e.isSpam || toDate(e.archivedAt)) return { urgency: 0, reasons: [] };
|
|
282
|
+
|
|
283
|
+
let urgency = 0;
|
|
284
|
+
const cls = (e.classification ?? "").toLowerCase();
|
|
285
|
+
|
|
286
|
+
const due = toDate(e.dueDate);
|
|
287
|
+
if (due) {
|
|
288
|
+
const days = Math.floor((due.getTime() - now.getTime()) / DAY_MS);
|
|
289
|
+
if (days <= 0) {
|
|
290
|
+
urgency = 100;
|
|
291
|
+
reasons.push(days === 0 ? "Due today" : `Overdue ${Math.abs(days)}d`);
|
|
292
|
+
} else if (days <= 2) {
|
|
293
|
+
urgency = 85;
|
|
294
|
+
reasons.push(`Due in ${days}d`);
|
|
295
|
+
} else if (days <= 7) {
|
|
296
|
+
urgency = 60;
|
|
297
|
+
reasons.push(`Due in ${days}d`);
|
|
298
|
+
} else {
|
|
299
|
+
urgency = 30;
|
|
300
|
+
reasons.push(`Due in ${days}d`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Regulatory standing urgency floor — a recall/WSLCB is time-bound even with
|
|
305
|
+
// no parsed date.
|
|
306
|
+
if (cls === "recall") urgency = Math.max(urgency, 90);
|
|
307
|
+
else if (cls === "wslcb_notice" || e.wslcbRetention) urgency = Math.max(urgency, 70);
|
|
308
|
+
|
|
309
|
+
return { urgency: clamp(Math.round(urgency), 0, 100), reasons };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Derive the Eisenhower quadrant from the two axes. */
|
|
313
|
+
export function eisenhowerQuadrant(
|
|
314
|
+
importance: number,
|
|
315
|
+
urgency: number,
|
|
316
|
+
): EisenhowerQuadrant {
|
|
317
|
+
const imp = importance >= IMPORTANCE_AXIS_THRESHOLD;
|
|
318
|
+
const urg = urgency >= URGENCY_AXIS_THRESHOLD;
|
|
319
|
+
if (imp && urg) return "do";
|
|
320
|
+
if (imp && !urg) return "schedule";
|
|
321
|
+
if (!imp && urg) return "delegate";
|
|
322
|
+
return "drop";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Render the one-line "why surfaced" from the top contributing signals — a
|
|
327
|
+
* TEMPLATE over the breakdown, NO extra LLM call. Picks the highest-magnitude
|
|
328
|
+
* positive contributions so the explanation matches what actually drove the rank.
|
|
329
|
+
*/
|
|
330
|
+
export function explainSurfaced(breakdown: SignalContribution[]): string {
|
|
331
|
+
const positives = breakdown
|
|
332
|
+
.filter((c) => c.points > 0)
|
|
333
|
+
.sort((a, b) => b.points - a.points)
|
|
334
|
+
.slice(0, 3);
|
|
335
|
+
if (positives.length === 0) return "Routine — nothing pulled this up.";
|
|
336
|
+
const [lead, ...rest] = positives.map((c) => c.label);
|
|
337
|
+
return rest.length ? `${lead} · ${rest.join(" · ")}` : lead;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* TWO-AXIS scorer. Computes importance (does it matter — content + behavior) and
|
|
342
|
+
* urgency (deadline pressure) as separate numbers, the Eisenhower quadrant, a
|
|
343
|
+
* named signal breakdown, and a templated "why surfaced".
|
|
344
|
+
*
|
|
345
|
+
* Importance reuses scoreEmailImportance() as the content base (so the learned
|
|
346
|
+
* content weights still apply + the regulatory floor is preserved), then folds
|
|
347
|
+
* in the bounded behavioral bonus. Behavior can lift a content-quiet but
|
|
348
|
+
* behaviorally-important email out of 'low'; it is hard-capped and never buries
|
|
349
|
+
* a recall (the content base already floored those).
|
|
350
|
+
*/
|
|
351
|
+
export function scoreEmailTwoAxis(
|
|
352
|
+
e: ImportanceInput,
|
|
353
|
+
now: Date,
|
|
354
|
+
opts: {
|
|
355
|
+
weights?: ImportanceSignalWeights;
|
|
356
|
+
behavior?: BehavioralSignalInput;
|
|
357
|
+
} = {},
|
|
358
|
+
): TwoAxisResult {
|
|
359
|
+
const weights = opts.weights ?? IDENTITY_WEIGHTS;
|
|
360
|
+
const content = scoreEmailImportance(e, now, weights);
|
|
361
|
+
const breakdown: SignalContribution[] = [];
|
|
362
|
+
|
|
363
|
+
// Seed the breakdown from the content reasons (each carries its own driver).
|
|
364
|
+
for (const r of content.reasons) {
|
|
365
|
+
breakdown.push({ signal: "content", label: r, points: 0, axis: "importance" });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Hard sinks: spam/archived stay sunk on both axes; behavior can't rescue junk.
|
|
369
|
+
const sunk = e.isSpam || toDate(e.archivedAt) != null;
|
|
370
|
+
|
|
371
|
+
let importance = content.score;
|
|
372
|
+
let behavioral: BehavioralSignals | null = null;
|
|
373
|
+
|
|
374
|
+
if (!sunk && opts.behavior) {
|
|
375
|
+
behavioral = computeBehavioralSignals(opts.behavior, now);
|
|
376
|
+
|
|
377
|
+
// Explicit operator override is the human ground truth — it wins, but still
|
|
378
|
+
// can't bury a regulatory-floored content score.
|
|
379
|
+
if (behavioral.explicitOverride === "not") {
|
|
380
|
+
importance = Math.min(importance, 15);
|
|
381
|
+
breakdown.push({
|
|
382
|
+
signal: "operator_override",
|
|
383
|
+
label: "You marked this not important",
|
|
384
|
+
points: 15 - content.score,
|
|
385
|
+
axis: "importance",
|
|
386
|
+
});
|
|
387
|
+
} else {
|
|
388
|
+
if (behavioral.explicitOverride === "important") {
|
|
389
|
+
const lift = Math.max(0, IMPORTANCE_AXIS_THRESHOLD + 5 - importance);
|
|
390
|
+
importance += lift;
|
|
391
|
+
breakdown.push({
|
|
392
|
+
signal: "operator_override",
|
|
393
|
+
label: "You marked this important",
|
|
394
|
+
points: lift,
|
|
395
|
+
axis: "importance",
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
importance += behavioral.pointBonus;
|
|
399
|
+
for (const c of behavioral.contributions) {
|
|
400
|
+
breakdown.push({
|
|
401
|
+
signal: c.signal,
|
|
402
|
+
label: c.label,
|
|
403
|
+
points: c.points,
|
|
404
|
+
axis: "importance",
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
importance = clamp(Math.round(importance), 0, 100);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const { urgency, reasons: urgencyReasons } = extractUrgency(e, now);
|
|
412
|
+
for (const r of urgencyReasons) {
|
|
413
|
+
if (!breakdown.some((b) => b.label === r && b.axis === "urgency")) {
|
|
414
|
+
breakdown.push({ signal: "urgency", label: r, points: urgency, axis: "urgency" });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Combined rank: importance-led, nudged up by urgency so a tie breaks toward
|
|
419
|
+
// the time-pressured one.
|
|
420
|
+
const combined = clamp(Math.round(importance * 0.8 + urgency * 0.2), 0, 100);
|
|
421
|
+
const band = bandFor(importance);
|
|
422
|
+
const quadrant = eisenhowerQuadrant(importance, urgency);
|
|
423
|
+
|
|
424
|
+
const reasons = [...content.reasons];
|
|
425
|
+
for (const r of urgencyReasons) if (!reasons.includes(r)) reasons.push(r);
|
|
426
|
+
if (behavioral) {
|
|
427
|
+
for (const c of behavioral.contributions) {
|
|
428
|
+
if (c.points > 0 && !reasons.includes(c.label)) reasons.push(c.label);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
importance,
|
|
434
|
+
urgency,
|
|
435
|
+
combined,
|
|
436
|
+
band,
|
|
437
|
+
quadrant,
|
|
438
|
+
reasons,
|
|
439
|
+
why: explainSurfaced(breakdown),
|
|
440
|
+
breakdown,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Sort comparator: importance desc, then most-recent first. */
|
|
445
|
+
export function compareByImportance(
|
|
446
|
+
a: { importance: number; receivedAt: string | Date },
|
|
447
|
+
b: { importance: number; receivedAt: string | Date },
|
|
448
|
+
): number {
|
|
449
|
+
if (b.importance !== a.importance) return b.importance - a.importance;
|
|
450
|
+
const at = toDate(a.receivedAt)?.getTime() ?? 0;
|
|
451
|
+
const bt = toDate(b.receivedAt)?.getTime() ?? 0;
|
|
452
|
+
return bt - at;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Window helper for the "last 72h" view. */
|
|
456
|
+
export const IMPORTANCE_RECENT_WINDOW_HOURS = 72;
|
|
457
|
+
|
|
458
|
+
export function isWithinRecentWindow(receivedAt: string | Date, now: Date): boolean {
|
|
459
|
+
const r = toDate(receivedAt);
|
|
460
|
+
if (!r) return false;
|
|
461
|
+
return now.getTime() - r.getTime() <= IMPORTANCE_RECENT_WINDOW_HOURS * 60 * 60 * 1000;
|
|
462
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// PORTED VERBATIM (pure, no-DB) from the RadMail main app:
|
|
3
|
+
// /Users/GreenLife/Documents/CODE/RadMail/src/server/commitments/send-disposition.ts
|
|
4
|
+
// Recovered into radmail-mcp so the MCP sandbox runs the SAME deterministic BEC
|
|
5
|
+
// firewall as production. This is the sacred firewall — it may only be TIGHTENED,
|
|
6
|
+
// never loosened. Keep byte-for-byte with source.
|
|
7
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
// Auto-send disposition — the single authoritative multi-signal pure-fn (scope §4).
|
|
10
|
+
//
|
|
11
|
+
// NEVER a confidence threshold. `commitmentSendDisposition(ctx)` is the ONE
|
|
12
|
+
// predicate that answers auto_send | needs_approval | hard_stop. Decoupled-switch
|
|
13
|
+
// doctrine — no shadow switch anywhere; every caller asks THIS.
|
|
14
|
+
//
|
|
15
|
+
// AUTO-SEND only when ALL hold (AND-gate, not OR):
|
|
16
|
+
// · tenant opted into auto-send for this class
|
|
17
|
+
// · direction = owed_by_us AND action_type = follow_up (a self check-in, not a
|
|
18
|
+
// deliverable, not money)
|
|
19
|
+
// · counterparty KNOWN (counterparty_id present)
|
|
20
|
+
// · recipient domain allowlisted (passed in pre-checked)
|
|
21
|
+
// · regime scrubber clean (assertOutboundAllowed passed in pre-checked)
|
|
22
|
+
// · completion re-checked at the last millisecond → still open
|
|
23
|
+
// · not previously sent
|
|
24
|
+
// · entitlement: autonomous_followup (Pro+) — passed in pre-checked
|
|
25
|
+
//
|
|
26
|
+
// PERMANENT HARD-STOPS (never auto-anything, ever — BEC defense):
|
|
27
|
+
// action_type ∈ {payment, decision, contact_third_party, send_deliverable},
|
|
28
|
+
// OR any money / new-banking / decision signal in the source. These never
|
|
29
|
+
// become auto-send for ANY tenant at ANY rollout rung. Everything else that
|
|
30
|
+
// isn't a clean auto-send = human-gated draft (needs_approval).
|
|
31
|
+
//
|
|
32
|
+
// Pure, pin-tested (auto vs hold vs hard-stop).
|
|
33
|
+
|
|
34
|
+
import type { CommitmentDirection, CommitmentActionType } from "./types.js"; // (port note: .js extension required by NodeNext)
|
|
35
|
+
|
|
36
|
+
export type SendDispositionContext = {
|
|
37
|
+
direction: CommitmentDirection;
|
|
38
|
+
actionType: CommitmentActionType;
|
|
39
|
+
// ── Tenant + commercial gates (resolved by the caller, handed in) ──
|
|
40
|
+
tenantOptedInClass: boolean; // tenant flipped auto-send ON for this class
|
|
41
|
+
entitled: boolean; // autonomous_followup entitlement (Pro+)
|
|
42
|
+
// ── Safety preconditions (each resolved by the caller, handed in) ──
|
|
43
|
+
counterpartyKnown: boolean; // counterparty_id present
|
|
44
|
+
recipientDomainAllowed: boolean; // isRecipientDomainAllowed passed
|
|
45
|
+
scrubberClean: boolean; // assertOutboundAllowed passed on the FINAL body
|
|
46
|
+
completionRecheckedOpen: boolean; // isCommitmentLikelyFulfilled → not fulfilled
|
|
47
|
+
alreadySent: boolean; // a prior sent follow-up exists on this commitment
|
|
48
|
+
// ── BEC / risk signals from the source email. REQUIRED (not optional) so a
|
|
49
|
+
// caller that forgets to evaluate them fails the BUILD, not silently at runtime.
|
|
50
|
+
// Wire them with detectSourceRiskSignals() below (or a stronger extractor). ──
|
|
51
|
+
hasMoneySignal: boolean; // $ amounts / invoice / payment language
|
|
52
|
+
hasNewBankingSignal: boolean; // changed remittance / new account / wire details
|
|
53
|
+
hasDecisionSignal: boolean; // approval / sign-off / commit-the-tenant
|
|
54
|
+
injectionSignal: boolean; // prompt-injection / anomaly in the source
|
|
55
|
+
/** sla_basis: a guessed deadline ('default_by_type') never auto-sends. */
|
|
56
|
+
slaBasis?: "explicit" | "relative" | "default_by_type" | "manual";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type SendDisposition =
|
|
60
|
+
| { disposition: "auto_send" }
|
|
61
|
+
| { disposition: "needs_approval"; reason: string }
|
|
62
|
+
| { disposition: "hard_stop"; reason: string };
|
|
63
|
+
|
|
64
|
+
// The action types that may EVER auto-send (the narrowest class).
|
|
65
|
+
function isAutoSendableType(t: CommitmentActionType): boolean {
|
|
66
|
+
return t === "follow_up";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The action types that are PERMANENTLY human-only (hard-stop forever).
|
|
70
|
+
function isHardStopType(t: CommitmentActionType): boolean {
|
|
71
|
+
return t === "payment" || t === "decision" || t === "contact_third_party" || t === "send_deliverable";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Deterministic source-side BEC risk detection (pure, pin-tested). ──
|
|
75
|
+
// Doctrine: a false positive (one extra human review) is cheap; a false negative
|
|
76
|
+
// (auto-sending money / banking-change / decision mail) is catastrophic. These
|
|
77
|
+
// patterns fire READILY by design — when in doubt, hard-stop to a human.
|
|
78
|
+
const MONEY_RE =
|
|
79
|
+
/(\$\s?\d)|(\b\d{2,}\s?(usd|dollars|eur|gbp)\b)|\b(wire\s?transfer|wire|remit\w*|invoice|payment|deposit|ach|routing\s?number|swift|iban|amount\s?due|balance\s?due|past\s?due|payable|payout)\b/i;
|
|
80
|
+
const BANKING_RE =
|
|
81
|
+
/\b(new|updated?|chang(?:e|ed|ing)|revis\w+|different|switch(?:ed)?)\b[^.!?\n]{0,50}\b(bank\w*|account|remit\w*|wire|routing|payment\s+(?:details|info|information|instructions))\b/i;
|
|
82
|
+
const BANKING_RE2 =
|
|
83
|
+
/\b(bank\w*|account|routing|remit\w*|wire|payment\s+(?:details|info|information|instructions))\b[^.!?\n]{0,50}\b(chang(?:e|ed|ing)|updated?|new|revis\w+|different)\b/i;
|
|
84
|
+
const DECISION_RE =
|
|
85
|
+
/\b(approv\w+|sign[\s-]?off|signoff|authoriz\w+|go[\s-]?ahead|green[\s-]?light)\b|\b(please\s+)?confirm\b[^.!?\n]{0,30}\b(order|po|purchase|wire|payment|deal|contract|invoice)\b/i;
|
|
86
|
+
// Prompt-injection in inbound mail that could steer an autonomous reply. Expanded
|
|
87
|
+
// for the autonomy path — a single "ignore previous instructions" regex is too thin
|
|
88
|
+
// once mail can drive an action. Fires readily (false-positive bias = an extra human
|
|
89
|
+
// review). NOT a complete defense; the human hard-stop is the floor.
|
|
90
|
+
const INJECTION_RE =
|
|
91
|
+
/\b(ignore\s+(?:all\s+|the\s+|any\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|prompts?|messages?|context)|disregard\s+(?:your|the|all|any)\s+(?:instructions?|rules?|guidelines?)|system\s+prompt|developer\s+(?:message|prompt)|you\s+are\s+now\s+|new\s+instructions?\s*:|prior\s+instructions?\s+(?:no\s+longer|do\s+not)\s+apply|do\s+not\s+(?:tell|inform|mention\s+to)\s+(?:the\s+)?(?:user|human|operator)|instead\s+of\s+(?:replying|responding|your\s+task)|when\s+you\s+reply[, ].{0,40}\b(?:cc|bcc|forward|send\s+to)\b|<\s*(?:system|assistant)\s*>)\b/i;
|
|
92
|
+
|
|
93
|
+
/** Normalize text to defeat trivial injection obfuscation before scanning:
|
|
94
|
+
* strip zero-width / bidi unicode, and decode long base64 runs (appended so the
|
|
95
|
+
* decoded payload is also scanned). Pure + cheap. */
|
|
96
|
+
function normalizeForScan(text: string): string {
|
|
97
|
+
// Cap the scanned span — the real call sites pass short strings (subject + the
|
|
98
|
+
// ≤140-char action + the evidence sentence); the cap defends the public fn from
|
|
99
|
+
// a huge body causing regex backtracking / a giant match array.
|
|
100
|
+
const t = text.slice(0, 16_384).replace(/[--]/g, "");
|
|
101
|
+
const decoded: string[] = [];
|
|
102
|
+
// Bound each base64 run (24..512) and the number of decodes (≤16) for safety.
|
|
103
|
+
for (const m of (t.match(/[A-Za-z0-9+/]{24,512}={0,2}/g) ?? []).slice(0, 16)) {
|
|
104
|
+
try {
|
|
105
|
+
const d = Buffer.from(m, "base64").toString("utf8");
|
|
106
|
+
// Only keep decodes that look like readable text (avoid binary noise).
|
|
107
|
+
if (d && /[\x20-\x7e]{8,}/.test(d) && !/[\x00-\x08\x0e-\x1f]/.test(d)) decoded.push(d);
|
|
108
|
+
} catch {
|
|
109
|
+
/* not valid base64 — ignore */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return decoded.length ? `${t}\n${decoded.join("\n")}` : t;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type SourceRiskSignals = {
|
|
116
|
+
hasMoneySignal: boolean;
|
|
117
|
+
hasNewBankingSignal: boolean;
|
|
118
|
+
hasDecisionSignal: boolean;
|
|
119
|
+
injectionSignal: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Scan the SOURCE-side text (subject + commitment action + verbatim evidence span)
|
|
123
|
+
* for BEC risk. Pass every available source string; nulls are ignored. */
|
|
124
|
+
export function detectSourceRiskSignals(...parts: Array<string | null | undefined>): SourceRiskSignals {
|
|
125
|
+
const raw = parts.filter((p): p is string => !!p).join("\n");
|
|
126
|
+
// Injection scan runs over the de-obfuscated text (zero-width stripped, base64
|
|
127
|
+
// decoded); money/banking/decision scan over raw (those don't get obfuscated).
|
|
128
|
+
const scan = normalizeForScan(raw);
|
|
129
|
+
return {
|
|
130
|
+
hasMoneySignal: MONEY_RE.test(raw),
|
|
131
|
+
hasNewBankingSignal: BANKING_RE.test(raw) || BANKING_RE2.test(raw),
|
|
132
|
+
hasDecisionSignal: DECISION_RE.test(raw),
|
|
133
|
+
injectionSignal: INJECTION_RE.test(raw) || INJECTION_RE.test(scan),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function commitmentSendDisposition(ctx: SendDispositionContext): SendDisposition {
|
|
138
|
+
// ── FAIL-CLOSED: the four BEC risk signals MUST be evaluated by the caller.
|
|
139
|
+
// If any arrives non-boolean (detection was never wired), refuse to auto-send.
|
|
140
|
+
// This is the runtime backstop to the compile-time `required` contract above. ──
|
|
141
|
+
for (const k of ["hasMoneySignal", "hasNewBankingSignal", "hasDecisionSignal", "injectionSignal"] as const) {
|
|
142
|
+
if (typeof ctx[k] !== "boolean") {
|
|
143
|
+
return { disposition: "hard_stop", reason: `risk signal ${k} not evaluated — fail-closed (BEC defense)` };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── PERMANENT HARD-STOPS first — these can never be overridden. ──
|
|
148
|
+
if (isHardStopType(ctx.actionType)) {
|
|
149
|
+
return { disposition: "hard_stop", reason: `action_type=${ctx.actionType} is human-only forever` };
|
|
150
|
+
}
|
|
151
|
+
if (ctx.hasMoneySignal) return { disposition: "hard_stop", reason: "money signal — human-only (BEC defense)" };
|
|
152
|
+
if (ctx.hasNewBankingSignal) return { disposition: "hard_stop", reason: "new-banking signal — human-only (BEC defense)" };
|
|
153
|
+
if (ctx.hasDecisionSignal) return { disposition: "hard_stop", reason: "decision signal — human-only" };
|
|
154
|
+
if (ctx.injectionSignal) return { disposition: "hard_stop", reason: "injection/anomaly signal — escalate" };
|
|
155
|
+
|
|
156
|
+
// ── AUTO-SEND requires the FULL AND-gate. Any miss → needs_approval. ──
|
|
157
|
+
if (ctx.direction !== "owed_by_us") {
|
|
158
|
+
return { disposition: "needs_approval", reason: "owed_to_us — nudge starts human-gated" };
|
|
159
|
+
}
|
|
160
|
+
if (!isAutoSendableType(ctx.actionType)) {
|
|
161
|
+
return { disposition: "needs_approval", reason: `action_type=${ctx.actionType} is not an auto-sendable class` };
|
|
162
|
+
}
|
|
163
|
+
if (!ctx.tenantOptedInClass) return { disposition: "needs_approval", reason: "tenant has not opted into auto-send for this class" };
|
|
164
|
+
if (!ctx.entitled) return { disposition: "needs_approval", reason: "autonomous_followup not entitled (Pro+)" };
|
|
165
|
+
if (!ctx.counterpartyKnown) return { disposition: "needs_approval", reason: "counterparty not known" };
|
|
166
|
+
if (!ctx.recipientDomainAllowed) return { disposition: "needs_approval", reason: "recipient domain not allowlisted" };
|
|
167
|
+
if (!ctx.scrubberClean) return { disposition: "needs_approval", reason: "outbound scrubber not clean" };
|
|
168
|
+
if (!ctx.completionRecheckedOpen) return { disposition: "needs_approval", reason: "completion re-check did not confirm open" };
|
|
169
|
+
if (ctx.alreadySent) return { disposition: "needs_approval", reason: "a follow-up was already sent (single-send dedup)" };
|
|
170
|
+
if (ctx.slaBasis === "default_by_type") return { disposition: "needs_approval", reason: "due date was guessed (default_by_type) — never auto-send on a guessed deadline" };
|
|
171
|
+
|
|
172
|
+
return { disposition: "auto_send" };
|
|
173
|
+
}
|