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
@@ -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
+ }