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,403 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // PORTED VERBATIM (pure, no-DB) from the RadMail main app:
3
+ // /Users/GreenLife/Documents/CODE/RadMail/src/lib/importance/signals.ts
4
+ // Behavioral-signal dependency of the importance scorer. Recovered into
5
+ // radmail-mcp so the MCP sandbox runs the SAME deterministic math as production.
6
+ // Keep byte-for-byte with source.
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+
9
+ // Importance BEHAVIORAL SIGNALS — pure-fn lib. Ported AS-IS from the source
10
+ // engine's buyer-inbox-importance-signals.ts (architecture §4). No tenant
11
+ // surgery needed — this layer keys on per-sender stats (loaded per-org by the
12
+ // caller), not on a vendor/store FK, so the math ports byte-for-byte.
13
+ //
14
+ // WHY THIS LAYER EXISTS: the content scorer (score.ts) is a pure additive sum of
15
+ // CONTENT signals (class, $, due-date) with NO behavioral signal — so a chatty
16
+ // counterparty email the owner REPLIED to, a paid invoice that got `processed`,
17
+ // or a recurring correspondent whose mail always matters all sink to 'low'.
18
+ // Gmail Priority Inbox's lesson: importance = predicted probability the user
19
+ // ACTS, learned from sender behavior, with a global prior to beat cold-start and
20
+ // a per-(org,sender) correction layer, optimized so FALSE-NEGATIVES (a buried
21
+ // important email) are rare. This file computes that behavioral layer — every
22
+ // signal is a named number you can read. No black box.
23
+ //
24
+ // CAN'T RUN AWAY: (1) the learned per-sender score is BLENDED toward, gated by
25
+ // action-count, so a brand-new sender can't swing the score; (2) the total
26
+ // behavioral contribution is HARD-CAPPED (BEHAVIOR_MAX_POINTS) — behavior can
27
+ // lift a buried-but-real email out of 'low' but can never manufacture a
28
+ // 'critical' or bury a recall; (3) the EW update step is bounded + recency-
29
+ // weighted. PURE. No DB, no network, no clock-of-its-own. Fully unit-pinned.
30
+ const DAY_MS = 24 * 60 * 60 * 1000;
31
+
32
+ // ─── Global category priors (cold-start, research brief §2.3 step 1) ──────
33
+ // The "global model": a hand-set behavioral prior per class on a 0–100 scale,
34
+ // representing "how likely is a typical email of this class to be acted on."
35
+ // This gives sane behavior on day one before any per-sender history exists.
36
+ // These are LEARNED-LAYER priors (P(act)), distinct from the content scorer's
37
+ // point values — they seed the per-sender learned score, they don't replace
38
+ // the regulatory content scoring.
39
+ export const CATEGORY_PRIORS: Record<string, number> = {
40
+ recall: 95,
41
+ wslcb_notice: 95,
42
+ invoice: 85,
43
+ po_confirmation: 70,
44
+ coa: 65,
45
+ shipping: 45,
46
+ other: 40,
47
+ unclassified: 40,
48
+ meeting: 35,
49
+ availability: 18,
50
+ sample: 15,
51
+ marketing: 10,
52
+ system: 10,
53
+ };
54
+ export const DEFAULT_CATEGORY_PRIOR = 40;
55
+
56
+ export function categoryPrior(classification: string | null): number {
57
+ if (!classification) return DEFAULT_CATEGORY_PRIOR;
58
+ return CATEGORY_PRIORS[classification.toLowerCase()] ?? DEFAULT_CATEGORY_PRIOR;
59
+ }
60
+
61
+ // ─── Per-(store,sender) behavioral stats ──────────────────────────────────
62
+ // Aggregated from history (buyer_inbox_sender_stats row, migration 0517). The
63
+ // caller loads this for the email's sender; absent = a never-seen sender.
64
+ export interface SenderStats {
65
+ /** Distinct inbound emails seen from this sender (the denominator). */
66
+ emailCount: number;
67
+ /** How many the user replied to. */
68
+ replyCount: number;
69
+ /** How many the user opened (firstViewed). */
70
+ openCount: number;
71
+ /** How many were archived without ever being opened (negative signal). */
72
+ archivedUnreadCount: number;
73
+ /** Times the user pulled this sender's mail OUT of the digest (strong +). */
74
+ pullbackCount: number;
75
+ /** The online-EW learned action-likelihood score in [0,100]. */
76
+ learnedScore: number;
77
+ /** ISO/Date of the most recent inbound we corresponded around, or null. */
78
+ lastCorrespondedAt: string | Date | null;
79
+ }
80
+
81
+ /** A fresh, never-seen sender (cold start). */
82
+ export function emptySenderStats(): SenderStats {
83
+ return {
84
+ emailCount: 0,
85
+ replyCount: 0,
86
+ openCount: 0,
87
+ archivedUnreadCount: 0,
88
+ pullbackCount: 0,
89
+ learnedScore: 0,
90
+ lastCorrespondedAt: null,
91
+ };
92
+ }
93
+
94
+ // ─── Thread features (research brief §2.2 "thread family") ────────────────
95
+ export interface ThreadSignal {
96
+ /** The user has replied IN this thread before (participation). */
97
+ userInThread: boolean;
98
+ /** This inbound is a reply to a user's outbound — closes a waiting-on. */
99
+ isReplyToUserOutbound: boolean;
100
+ /** How deep the thread is (more back-and-forth = more invested). */
101
+ threadDepth: number;
102
+ }
103
+
104
+ export function emptyThreadSignal(): ThreadSignal {
105
+ return { userInThread: false, isReplyToUserOutbound: false, threadDepth: 0 };
106
+ }
107
+
108
+ // ─── Tuning constants (PIN-TESTED) ────────────────────────────────────────
109
+ /** Action-count at which the learned per-sender score is fully trusted. */
110
+ export const LEARN_KNEE = 5;
111
+ /** Recency: behavior within this many days is "fresh" (full strength). */
112
+ export const RECENCY_FULL_DAYS = 14;
113
+ /** Recency fully decays to half-strength by this horizon. */
114
+ export const RECENCY_HALF_DAYS = 60;
115
+ /** Hard cap on total points behavior can ADD to the content score. The cap is
116
+ * generous enough that a reliably-replied, recent correspondent (the buried-
117
+ * recall case) clears the 'high' band off a near-zero content base, but small
118
+ * enough that behavior can never manufacture a 'critical' (≥80) on its own. */
119
+ export const BEHAVIOR_MAX_POINTS = 42;
120
+ /** EW update: weight of the newest action vs. the running score. */
121
+ export const EW_ALPHA = 0.2;
122
+
123
+ function clamp(n: number, lo: number, hi: number): number {
124
+ return Math.max(lo, Math.min(hi, n));
125
+ }
126
+ function round1(n: number): number {
127
+ return Math.round(n * 10) / 10;
128
+ }
129
+ function toDate(v: string | Date | null | undefined): Date | null {
130
+ if (v == null) return null;
131
+ if (v instanceof Date) return isNaN(v.getTime()) ? null : v;
132
+ const d = new Date(v);
133
+ return isNaN(d.getTime()) ? null : d;
134
+ }
135
+
136
+ // ─── The behavioral feature vector (explainable) ──────────────────────────
137
+ export interface BehavioralSignalInput {
138
+ classification: string | null;
139
+ /** From buyer_inbox_sender_stats for this email's sender. */
140
+ senderStats?: SenderStats;
141
+ /** From buyer_inbox_threads for this email's thread. */
142
+ thread?: ThreadSignal;
143
+ /** Operator's explicit thumbs (buyer_inbox_importance_feedback), or null. */
144
+ explicitLabel?: "important" | "not" | null;
145
+ }
146
+
147
+ export interface BehavioralSignals {
148
+ /** Blend of category prior + per-sender learned score, in [0,100]. */
149
+ blendedScore: number;
150
+ /** The cold-start global prior used. */
151
+ prior: number;
152
+ /** The learned per-sender score (0 if cold). */
153
+ learnedScore: number;
154
+ /** How much weight the learned score got (0..1), grows with action count. */
155
+ learnedWeight: number;
156
+ /** Recency strength of the last correspondence (0..1). */
157
+ recencyStrength: number;
158
+ /** Derived sender reply rate (0..1) — strongest social signal. */
159
+ replyRate: number;
160
+ /** The named contributions actually applied, for explainability. */
161
+ contributions: BehavioralContribution[];
162
+ /** Final bounded point bonus the content scorer should ADD. */
163
+ pointBonus: number;
164
+ /** Did an explicit operator override fire? */
165
+ explicitOverride: "important" | "not" | null;
166
+ }
167
+
168
+ export interface BehavioralContribution {
169
+ signal: string;
170
+ /** Plain-English driver, e.g. "Replies to this sender 80% of the time". */
171
+ label: string;
172
+ /** Points contributed to pointBonus (can be negative). */
173
+ points: number;
174
+ }
175
+
176
+ /**
177
+ * Recency strength of last correspondence: 1.0 if within RECENCY_FULL_DAYS,
178
+ * decaying linearly to 0.5 at RECENCY_HALF_DAYS, then 0.5→0 out to 2×. A
179
+ * recent correspondent matters more than a year-stale one.
180
+ */
181
+ export function recencyStrength(
182
+ lastCorrespondedAt: string | Date | null,
183
+ now: Date,
184
+ ): number {
185
+ const last = toDate(lastCorrespondedAt);
186
+ if (!last) return 0;
187
+ const ageDays = (now.getTime() - last.getTime()) / DAY_MS;
188
+ if (ageDays <= RECENCY_FULL_DAYS) return 1;
189
+ if (ageDays <= RECENCY_HALF_DAYS) {
190
+ const span = RECENCY_HALF_DAYS - RECENCY_FULL_DAYS;
191
+ return clamp(1 - 0.5 * ((ageDays - RECENCY_FULL_DAYS) / span), 0.5, 1);
192
+ }
193
+ const span = RECENCY_HALF_DAYS; // 60→120 days fades the last half to 0
194
+ return clamp(0.5 - 0.5 * ((ageDays - RECENCY_HALF_DAYS) / span), 0, 0.5);
195
+ }
196
+
197
+ /**
198
+ * Blend the global category prior with the per-sender learned score, weighted
199
+ * by how much history we have (the n-gated cold-start discipline). With 0
200
+ * actions the prior fully dominates; at LEARN_KNEE actions the learned score
201
+ * is fully trusted. This IS the Gmail global-prior + per-user-correction blend.
202
+ */
203
+ export function blendLearnedScore(
204
+ prior: number,
205
+ stats: SenderStats,
206
+ ): { blended: number; learnedWeight: number } {
207
+ const actions = stats.replyCount + stats.openCount + stats.pullbackCount
208
+ + stats.archivedUnreadCount;
209
+ const learnedWeight = clamp(actions / LEARN_KNEE, 0, 1);
210
+ const blended = prior * (1 - learnedWeight) + stats.learnedScore * learnedWeight;
211
+ return { blended: round1(clamp(blended, 0, 100)), learnedWeight: round1(learnedWeight) };
212
+ }
213
+
214
+ /**
215
+ * Compute the explainable behavioral signal bundle for one email. The caller
216
+ * passes the per-sender stats + thread features it loaded; this is pure math.
217
+ *
218
+ * The point bonus is the bounded amount the content scorer should ADD so a
219
+ * behaviorally-important-but-content-quiet email (the replied-to chatty vendor)
220
+ * can climb out of 'low'. It is HARD-CAPPED at BEHAVIOR_MAX_POINTS and never
221
+ * touches the regulatory floor (recall/WSLCB are scored before behavior).
222
+ */
223
+ export function computeBehavioralSignals(
224
+ e: BehavioralSignalInput,
225
+ now: Date,
226
+ ): BehavioralSignals {
227
+ const stats = e.senderStats ?? emptySenderStats();
228
+ const thread = e.thread ?? emptyThreadSignal();
229
+ const prior = categoryPrior(e.classification);
230
+ const { blended, learnedWeight } = blendLearnedScore(prior, stats);
231
+ const rec = round1(recencyStrength(stats.lastCorrespondedAt, now));
232
+ const replyRate = stats.emailCount > 0
233
+ ? round1(stats.replyCount / stats.emailCount)
234
+ : 0;
235
+
236
+ const contributions: BehavioralContribution[] = [];
237
+ let bonus = 0;
238
+
239
+ // ── Social: sender reply-rate × recency (strongest signal). A sender Doug
240
+ // reliably replies to, recently, is one whose mail should surface. ──
241
+ if (stats.emailCount > 0 && replyRate > 0) {
242
+ const pts = round1(replyRate * rec * 24);
243
+ if (pts > 0) {
244
+ bonus += pts;
245
+ contributions.push({
246
+ signal: "sender_reply_rate",
247
+ label: `You reply to this sender ${Math.round(replyRate * 100)}% of the time`,
248
+ points: pts,
249
+ });
250
+ }
251
+ }
252
+
253
+ // ── Social: pull-backs from the digest are an explicit "you were wrong,
254
+ // this matters" correction — the strongest behavioral positive. ──
255
+ if (stats.pullbackCount > 0) {
256
+ const pts = round1(Math.min(stats.pullbackCount, 3) * 4 * rec);
257
+ if (pts > 0) {
258
+ bonus += pts;
259
+ contributions.push({
260
+ signal: "sender_pullback",
261
+ label: `You've rescued this sender from the digest ${stats.pullbackCount}×`,
262
+ points: pts,
263
+ });
264
+ }
265
+ }
266
+
267
+ // ── Social: archived-unread rate is a negative signal — a sender whose mail
268
+ // you sweep away unread should NOT be lifted. ──
269
+ if (stats.emailCount >= LEARN_KNEE) {
270
+ const archiveRate = stats.archivedUnreadCount / stats.emailCount;
271
+ if (archiveRate >= 0.6) {
272
+ const pts = -round1(archiveRate * 8);
273
+ bonus += pts;
274
+ contributions.push({
275
+ signal: "sender_archive_unread",
276
+ label: `You usually sweep this sender away unread (${Math.round(archiveRate * 100)}%)`,
277
+ points: pts,
278
+ });
279
+ }
280
+ }
281
+
282
+ // ── Thread: replying to a user's outbound closes a waiting-on item — high
283
+ // importance (the vendor finally answered). ──
284
+ if (thread.isReplyToUserOutbound) {
285
+ const pts = 16;
286
+ bonus += pts;
287
+ contributions.push({
288
+ signal: "reply_to_outbound",
289
+ label: "Replies to a message you sent (closes a waiting-on)",
290
+ points: pts,
291
+ });
292
+ } else if (thread.userInThread) {
293
+ const pts = 7;
294
+ bonus += pts;
295
+ contributions.push({
296
+ signal: "user_in_thread",
297
+ label: "You're already part of this thread",
298
+ points: pts,
299
+ });
300
+ }
301
+
302
+ // ── Learned per-sender lift: when history says this sender's mail gets
303
+ // acted on ABOVE its class prior, lift toward the learned score —
304
+ // weighted by how much history we trust. ──
305
+ if (learnedWeight > 0 && blended > prior) {
306
+ const pts = round1((blended - prior) / 100 * 22 * learnedWeight);
307
+ if (pts > 0) {
308
+ bonus += pts;
309
+ contributions.push({
310
+ signal: "learned_sender_score",
311
+ label: `History says this sender's mail matters (learned ${Math.round(
312
+ stats.learnedScore,
313
+ )} vs prior ${Math.round(prior)})`,
314
+ points: pts,
315
+ });
316
+ }
317
+ }
318
+
319
+ // ── Hard cap: behavior can lift, never run away. ──
320
+ const pointBonus = round1(clamp(bonus, -BEHAVIOR_MAX_POINTS, BEHAVIOR_MAX_POINTS));
321
+
322
+ return {
323
+ blendedScore: blended,
324
+ prior,
325
+ learnedScore: round1(stats.learnedScore),
326
+ learnedWeight,
327
+ recencyStrength: rec,
328
+ replyRate,
329
+ contributions,
330
+ pointBonus,
331
+ explicitOverride: e.explicitLabel ?? null,
332
+ };
333
+ }
334
+
335
+ // ─── The online EW update (research brief §2.3 step 2) ────────────────────
336
+ // One operator action on one of this sender's emails updates the running
337
+ // learned_score + the count aggregates. Pure: takes the prior stats + the
338
+ // action, returns the next stats. The cron / action handler persists the
339
+ // result to buyer_inbox_sender_stats. Recency-weighted, bounded step.
340
+
341
+ export type SenderAction =
342
+ | "reply"
343
+ | "open"
344
+ | "pullback"
345
+ | "do"
346
+ | "flag"
347
+ | "archive_unread"
348
+ | "digest_without_open"
349
+ | "delete";
350
+
351
+ /** The target action-likelihood each action implies (0..100). */
352
+ const ACTION_TARGET: Record<SenderAction, number> = {
353
+ reply: 100,
354
+ do: 100,
355
+ pullback: 95,
356
+ flag: 90,
357
+ open: 65,
358
+ archive_unread: 5,
359
+ digest_without_open: 10,
360
+ delete: 0,
361
+ };
362
+
363
+ /**
364
+ * Apply one operator action to a sender's running stats. EW-updates the
365
+ * learned_score toward the action's implied target, increments the relevant
366
+ * counter, and stamps recency. Bounded step (EW_ALPHA) so no single action
367
+ * lurches the score; recent behavior dominates over time.
368
+ *
369
+ * Cold start: the FIRST action seeds the learned_score from the category prior
370
+ * (passed in) so the running score never starts at a meaningless 0.
371
+ */
372
+ export function updateSenderStats(
373
+ prev: SenderStats,
374
+ action: SenderAction,
375
+ classification: string | null,
376
+ at: string | Date,
377
+ alpha: number = EW_ALPHA,
378
+ ): SenderStats {
379
+ const seeded = prev.emailCount === 0 && prev.learnedScore === 0
380
+ ? categoryPrior(classification)
381
+ : prev.learnedScore;
382
+ const target = ACTION_TARGET[action];
383
+ const nextLearned = round1(clamp(seeded * (1 - alpha) + target * alpha, 0, 100));
384
+
385
+ const next: SenderStats = {
386
+ ...prev,
387
+ learnedScore: nextLearned,
388
+ lastCorrespondedAt: at,
389
+ };
390
+
391
+ // Each action implies an inbound email was processed → bump the denominator
392
+ // exactly once per action (an action is "what happened to one email").
393
+ next.emailCount = prev.emailCount + 1;
394
+ if (action === "reply" || action === "do") next.replyCount = prev.replyCount + 1;
395
+ if (action === "open" || action === "reply" || action === "do" || action === "flag") {
396
+ next.openCount = prev.openCount + 1;
397
+ }
398
+ if (action === "pullback") next.pullbackCount = prev.pullbackCount + 1;
399
+ if (action === "archive_unread" || action === "digest_without_open") {
400
+ next.archivedUnreadCount = prev.archivedUnreadCount + 1;
401
+ }
402
+ return next;
403
+ }
@@ -0,0 +1,73 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // PORTED VERBATIM (pure, no-DB) from the RadMail main app:
3
+ // /Users/GreenLife/Documents/CODE/RadMail/src/server/commitments/types.ts
4
+ // Recovered into radmail-mcp so the MCP sandbox runs the SAME deterministic
5
+ // commitment enums as production. Do not diverge — keep byte-for-byte with source.
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+
8
+ // Commitment engine — shared types (Wave 7). The closed enums are load-bearing:
9
+ // they drive the SLA table, the draft style, the send-disposition gate, and the
10
+ // escalation tier. Keep them CLOSED — an unknown value coerces to a safe default.
11
+ //
12
+ // scope: RADMAIL_SCOPE_AUTONOMOUS_FOLLOWUP_2026_06_19.md §1.2, §2.1, §2.2.
13
+
14
+ // ─── Direction (who owes the action) ──────────────────────────────────
15
+ export const COMMITMENT_DIRECTIONS = ["owed_by_us", "owed_to_us"] as const;
16
+ export type CommitmentDirection = (typeof COMMITMENT_DIRECTIONS)[number];
17
+
18
+ // ─── Action type (CLOSED enum — the spine of every downstream decision) ─
19
+ // send_deliverable | follow_up | contact_third_party | answer_question | other
20
+ // Plus the scope's two HARD-STOP classes (payment/decision) carried so the
21
+ // disposition gate can permanently refuse them (BEC defense).
22
+ export const COMMITMENT_ACTION_TYPES = [
23
+ "send_deliverable",
24
+ "follow_up",
25
+ "contact_third_party",
26
+ "answer_question",
27
+ "payment",
28
+ "decision",
29
+ "other",
30
+ ] as const;
31
+ export type CommitmentActionType = (typeof COMMITMENT_ACTION_TYPES)[number];
32
+
33
+ export function isCommitmentActionType(v: string): v is CommitmentActionType {
34
+ return (COMMITMENT_ACTION_TYPES as readonly string[]).includes(v);
35
+ }
36
+
37
+ /** Coerce an arbitrary LLM-returned action_type to the closed enum (unknown → 'other'). */
38
+ export function coerceActionType(v: unknown): CommitmentActionType {
39
+ const s = String(v ?? "").toLowerCase().trim();
40
+ return isCommitmentActionType(s) ? s : "other";
41
+ }
42
+
43
+ // ─── State machine ────────────────────────────────────────────────────
44
+ export const COMMITMENT_STATES = [
45
+ "detected",
46
+ "confirmed",
47
+ "needs_due_date",
48
+ "scheduled",
49
+ "drafted",
50
+ "sent",
51
+ "done",
52
+ "overdue",
53
+ "escalated",
54
+ "dismissed",
55
+ "snoozed",
56
+ "cancelled",
57
+ ] as const;
58
+ export type CommitmentState = (typeof COMMITMENT_STATES)[number];
59
+
60
+ // ─── SLA basis — how the due date was determined ──────────────────────
61
+ export const SLA_BASES = ["explicit", "relative", "default_by_type", "manual"] as const;
62
+ export type SlaBasis = (typeof SLA_BASES)[number];
63
+
64
+ // ─── The extracted-commitment shape (post-coercion, pre-DB) ───────────
65
+ export type ExtractedCommitment = {
66
+ direction: CommitmentDirection;
67
+ party: string;
68
+ action: string; // ≤140 chars
69
+ actionType: CommitmentActionType;
70
+ duePhrase: string | null;
71
+ confidence: number; // 0..1, SOFT (presentation, never authorization)
72
+ evidenceSpan: string; // exact sentence — mandatory; no span → dropped
73
+ };
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ // Local stdio entry — `npx radmail-mcp` or `node dist/index.js`. Speaks the MCP
3
+ // stdio transport so a desktop agent host (Claude Desktop, etc.) can run RadMail
4
+ // locally. The Vercel deployment uses api/mcp.ts (streamable-HTTP) instead.
5
+
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { createServer } from "./server.js";
8
+
9
+ async function main(): Promise<void> {
10
+ const server = createServer();
11
+ const transport = new StdioServerTransport();
12
+ await server.connect(transport);
13
+ // eslint-disable-next-line no-console
14
+ console.error("radmail-mcp (sandbox engine) running on stdio");
15
+ }
16
+
17
+ main().catch((err) => {
18
+ // eslint-disable-next-line no-console
19
+ console.error("radmail-mcp failed to start:", err);
20
+ process.exit(1);
21
+ });
@@ -0,0 +1,143 @@
1
+ // Lightweight commitment + due-date extraction for the sandbox engine.
2
+ //
3
+ // This is a HEURISTIC extractor (the sandbox is heuristic by design — the
4
+ // production "99%" engine is launch-gated). It pulls a single most-salient
5
+ // commitment sentence out of a message body and resolves a relative due phrase
6
+ // to an absolute YYYY-MM-DD, so the importance scorer + the firewall have a
7
+ // deterministic deadline to reason over.
8
+ //
9
+ // EVERYTHING here reads an attacker-controllable email body — its outputs are
10
+ // tainted by the caller (see lib/taint.ts). It performs NO action; it only
11
+ // extracts text + a date.
12
+
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+ const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
15
+
16
+ function iso(d: Date): string {
17
+ return d.toISOString().slice(0, 10);
18
+ }
19
+
20
+ /** Resolve a relative due phrase ("by Thursday", "EOD", "tomorrow", "in 3 days",
21
+ * an explicit date) to an absolute YYYY-MM-DD, relative to `now`. null if none. */
22
+ export function resolveDuePhrase(text: string, now: Date): { phrase: string; date: string } | null {
23
+ const t = text.toLowerCase();
24
+
25
+ // Explicit ISO / numeric dates first.
26
+ const isoM = t.match(/\b(\d{4}-\d{2}-\d{2})\b/);
27
+ if (isoM) {
28
+ const d = new Date(isoM[1]);
29
+ if (!isNaN(d.getTime())) return { phrase: isoM[1], date: iso(d) };
30
+ }
31
+ const mdM = t.match(/\b(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\b/);
32
+ if (mdM) {
33
+ const month = parseInt(mdM[1], 10) - 1;
34
+ const day = parseInt(mdM[2], 10);
35
+ let year = mdM[3] ? parseInt(mdM[3], 10) : now.getUTCFullYear();
36
+ if (year < 100) year += 2000;
37
+ const d = new Date(Date.UTC(year, month, day));
38
+ if (!isNaN(d.getTime())) return { phrase: mdM[0], date: iso(d) };
39
+ }
40
+
41
+ if (/\b(today|eod|end of day|cob|by close of business)\b/.test(t)) {
42
+ return { phrase: "today", date: iso(now) };
43
+ }
44
+ if (/\btomorrow\b/.test(t)) {
45
+ return { phrase: "tomorrow", date: iso(new Date(now.getTime() + DAY_MS)) };
46
+ }
47
+ const inDays = t.match(/\bin\s+(\d{1,2})\s+(day|business day|week)s?\b/);
48
+ if (inDays) {
49
+ const n = parseInt(inDays[1], 10);
50
+ const mult = inDays[2].startsWith("week") ? 7 : 1;
51
+ return { phrase: inDays[0], date: iso(new Date(now.getTime() + n * mult * DAY_MS)) };
52
+ }
53
+ if (/\bnext week\b/.test(t)) {
54
+ return { phrase: "next week", date: iso(new Date(now.getTime() + 7 * DAY_MS)) };
55
+ }
56
+ if (/\bend of (the )?week\b/.test(t)) {
57
+ // Friday of the current week.
58
+ const dow = now.getUTCDay();
59
+ const delta = (5 - dow + 7) % 7;
60
+ return { phrase: "end of week", date: iso(new Date(now.getTime() + delta * DAY_MS)) };
61
+ }
62
+
63
+ // Named weekday → the NEXT occurrence (today counts only if "by today" matched above).
64
+ for (let i = 0; i < WEEKDAYS.length; i++) {
65
+ const re = new RegExp(`\\b(?:by|before|on|this|next|due)?\\s*${WEEKDAYS[i]}\\b`);
66
+ if (re.test(t)) {
67
+ const dow = now.getUTCDay();
68
+ let delta = (i - dow + 7) % 7;
69
+ if (delta === 0) delta = 7; // "Thursday" said on a Thursday = next Thursday
70
+ return { phrase: WEEKDAYS[i], date: iso(new Date(now.getTime() + delta * DAY_MS)) };
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ const ASK_RE =
77
+ /\b(can you|could you|please|would you|need|let me know|send (?:me|over|us)|get (?:me|us|back)|follow up|circle back|by\s|deadline|due|confirm|review|provide|share|update me|waiting on|expect)\b/i;
78
+
79
+ function splitSentences(body: string): string[] {
80
+ return body
81
+ .replace(/\s+/g, " ")
82
+ .split(/(?<=[.!?])\s+|\n+/)
83
+ .map((s) => s.trim())
84
+ .filter(Boolean);
85
+ }
86
+
87
+ export interface ExtractedCommitmentLite {
88
+ /** verbatim sentence from the body — TAINTED at the call site. */
89
+ what: string;
90
+ owedTo: string;
91
+ owedBy: string | null;
92
+ status: "open" | "due" | "overdue";
93
+ duePhrase: string | null;
94
+ /** who is being asked: 'us' (we owe a deliverable/answer) vs 'them'. */
95
+ direction: "owed_by_us" | "owed_to_us";
96
+ isQuestion: boolean;
97
+ hasAsk: boolean;
98
+ }
99
+
100
+ /** Pull the single most-salient commitment out of a message. null if none found. */
101
+ export function extractCommitment(
102
+ msg: { from: string; subject?: string | null; body: string },
103
+ now: Date,
104
+ ): ExtractedCommitmentLite | null {
105
+ const sentences = splitSentences(msg.body);
106
+ if (sentences.length === 0) return null;
107
+
108
+ // Score each sentence: a question or an ask-verb wins; a due phrase boosts.
109
+ let best: { s: string; score: number } | null = null;
110
+ for (const s of sentences) {
111
+ let score = 0;
112
+ const isQ = s.includes("?");
113
+ if (isQ) score += 3;
114
+ if (ASK_RE.test(s)) score += 2;
115
+ if (resolveDuePhrase(s, now)) score += 2;
116
+ if (score > 0 && (!best || score > best.score)) best = { s, score };
117
+ }
118
+ if (!best) return null;
119
+
120
+ const what = best.s.length > 200 ? best.s.slice(0, 197) + "…" : best.s;
121
+ const due = resolveDuePhrase(best.s, now) ?? resolveDuePhrase(msg.body, now);
122
+ let status: "open" | "due" | "overdue" = "open";
123
+ if (due) {
124
+ const days = Math.floor((new Date(due.date).getTime() - now.getTime()) / DAY_MS);
125
+ status = days < 0 ? "overdue" : days <= 1 ? "due" : "open";
126
+ }
127
+
128
+ // Direction: an inbound asking us to do/send/answer = we owe them (owed_by_us);
129
+ // an inbound saying "I'll send you X" = they owe us (owed_to_us).
130
+ const owedToUs = /\b(i'?ll|we'?ll|i will|we will|i'?m going to|let me get|i can send|on my way)\b/i.test(
131
+ best.s,
132
+ );
133
+ return {
134
+ what,
135
+ owedTo: msg.from,
136
+ owedBy: due?.date ?? null,
137
+ status,
138
+ duePhrase: due?.phrase ?? null,
139
+ direction: owedToUs ? "owed_to_us" : "owed_by_us",
140
+ isQuestion: best.s.includes("?"),
141
+ hasAsk: ASK_RE.test(best.s),
142
+ };
143
+ }