@blamejs/core 0.9.18 → 0.9.20
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/CHANGELOG.md +2 -0
- package/index.js +16 -0
- package/lib/guard-mail-compose.js +282 -0
- package/lib/guard-mail-move.js +202 -0
- package/lib/guard-mail-query.js +296 -0
- package/lib/guard-mail-reply.js +172 -0
- package/lib/guard-mail-sieve.js +207 -0
- package/lib/guard-message-id.js +241 -0
- package/lib/mail-agent.js +638 -0
- package/lib/mail-store.js +652 -0
- package/lib/mail.js +6 -0
- package/lib/safe-mime.js +714 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mail.agent
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title Mail Agent
|
|
6
|
+
* @order 100
|
|
7
|
+
* @featured true
|
|
8
|
+
*
|
|
9
|
+
* @intro
|
|
10
|
+
* The standardization contract for every mail protocol blamejs ships.
|
|
11
|
+
* JMAP (v0.9.27), IMAP (v0.9.28), POP3 (v0.9.29), ManageSieve (v0.9.30),
|
|
12
|
+
* the inbound MX listener (v0.9.24), and the submission listener
|
|
13
|
+
* (v0.9.25) all translate their protocol calls into `agent.X(args)`.
|
|
14
|
+
* The agent owns RBAC, posture enforcement, audit emission,
|
|
15
|
+
* dispatch, and worker isolation; every protocol on top is a thin
|
|
16
|
+
* shell.
|
|
17
|
+
*
|
|
18
|
+
* `agent.create()` returns the facade. Methods backed by v0.9.19's
|
|
19
|
+
* `b.mailStore` run immediately; methods that depend on later slices
|
|
20
|
+
* throw `mail-agent/not-implemented` with a `wiredAt` tag naming the
|
|
21
|
+
* version that lights them up (defer-with-condition — operator can
|
|
22
|
+
* match against the tag to scope their integration).
|
|
23
|
+
*
|
|
24
|
+
* ```js
|
|
25
|
+
* var agent = b.mail.agent.create({
|
|
26
|
+
* store, audit, permissions,
|
|
27
|
+
* posture: "hipaa",
|
|
28
|
+
* identity: function (actorId) {
|
|
29
|
+
* return { email: actorId + "@hospital.example", name: actorId };
|
|
30
|
+
* },
|
|
31
|
+
* dispatch: { mode: "auto" },
|
|
32
|
+
* });
|
|
33
|
+
*
|
|
34
|
+
* var folders = await agent.folders({ actor: { id: "u1", roles: ["clinician"], purposeOfUse: "TREATMENT" } });
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* ## Dispatch modes
|
|
38
|
+
*
|
|
39
|
+
* - `local` (default when no queue) — every method runs in-process.
|
|
40
|
+
* Fast-path ops (fetch / folders / flag / quota) bypass worker
|
|
41
|
+
* dispatch; heavy ops (search / export / sieve-on-bulk) run on the
|
|
42
|
+
* supplied `workerPool` when configured.
|
|
43
|
+
* - `queue` — every method publishes to the queue topic; an
|
|
44
|
+
* `agent.consumer()` running in a dedicated process (or replicas
|
|
45
|
+
* across hosts) pulls and executes. The consumer carries its own
|
|
46
|
+
* `store` reference; the queue payload carries actor + posture
|
|
47
|
+
* metadata, which the consumer re-validates against its local
|
|
48
|
+
* posture before unseal (no posture downgrade across the boundary).
|
|
49
|
+
* - `auto` — fast-path ops local, heavy ops to queue if configured
|
|
50
|
+
* else workerPool else local.
|
|
51
|
+
*
|
|
52
|
+
* ## Posture enforcement
|
|
53
|
+
*
|
|
54
|
+
* When `posture` is set, every actor passed to every method must
|
|
55
|
+
* carry the posture-required fields (HIPAA → `purposeOfUse`,
|
|
56
|
+
* PCI-DSS → `pciScope`, GDPR → `lawfulBasis`). `b.guardMailQuery.
|
|
57
|
+
* validateActor` is the canonical check; the agent invokes it
|
|
58
|
+
* on every entrypoint.
|
|
59
|
+
*
|
|
60
|
+
* @card
|
|
61
|
+
* The standardization contract for every mail protocol — JMAP / IMAP /
|
|
62
|
+
* POP3 all translate into `agent.X(args)`. RBAC + posture + audit +
|
|
63
|
+
* dispatch owned here; protocols on top are thin shells.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
var lazyRequire = require("./lazy-require");
|
|
67
|
+
var validateOpts = require("./validate-opts");
|
|
68
|
+
var C = require("./constants");
|
|
69
|
+
var { defineClass } = require("./framework-error");
|
|
70
|
+
var guardMailQuery = require("./guard-mail-query");
|
|
71
|
+
var guardMailCompose = require("./guard-mail-compose");
|
|
72
|
+
var guardMailReply = require("./guard-mail-reply");
|
|
73
|
+
var guardMailMove = require("./guard-mail-move");
|
|
74
|
+
var guardMailSieve = require("./guard-mail-sieve");
|
|
75
|
+
var guardMessageId = require("./guard-message-id");
|
|
76
|
+
|
|
77
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
78
|
+
|
|
79
|
+
var MailAgentError = defineClass("MailAgentError", { alwaysPermanent: true });
|
|
80
|
+
|
|
81
|
+
var DEFAULT_QUEUE_TOPIC = "mail.agent.tasks";
|
|
82
|
+
var DEFAULT_TASK_TIMEOUT_MS = C.TIME.seconds(30);
|
|
83
|
+
var DEFAULT_QUEUE_DEPTH_CAP = 1024; // allow:raw-byte-literal — queue depth, not bytes
|
|
84
|
+
|
|
85
|
+
// Methods that route to worker / queue dispatch under "auto" mode. The
|
|
86
|
+
// rest are fast-path single-row ops that stay local even under "auto".
|
|
87
|
+
var HEAVY_METHODS = Object.freeze({
|
|
88
|
+
search: true, export: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Scope vocabulary the agent enforces via the operator-supplied
|
|
92
|
+
// permissions instance. Operators map their existing role table onto
|
|
93
|
+
// these scopes; the agent never invents its own roles.
|
|
94
|
+
var SCOPE_FOR_METHOD = Object.freeze({
|
|
95
|
+
search: "mail:read",
|
|
96
|
+
fetch: "mail:read",
|
|
97
|
+
thread: "mail:read",
|
|
98
|
+
folders: "mail:read",
|
|
99
|
+
quota: "mail:read",
|
|
100
|
+
compose: "mail:write",
|
|
101
|
+
send: "mail:write",
|
|
102
|
+
reply: "mail:write",
|
|
103
|
+
forward: "mail:write",
|
|
104
|
+
move: "mail:move",
|
|
105
|
+
flag: "mail:move",
|
|
106
|
+
delete: "mail:move",
|
|
107
|
+
"sieve.list": "mail:sieve",
|
|
108
|
+
"sieve.put": "mail:sieve",
|
|
109
|
+
"sieve.activate": "mail:sieve",
|
|
110
|
+
"identity.set": "mail:identity",
|
|
111
|
+
"vacation.set": "mail:identity",
|
|
112
|
+
"mdn.send": "mail:mdn",
|
|
113
|
+
"mdn.parse": "mail:mdn",
|
|
114
|
+
"mdn.allowList": "mail:mdn",
|
|
115
|
+
export: "mail:export",
|
|
116
|
+
job: "mail:read",
|
|
117
|
+
import: "mail:import",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Methods deferred behind a `wiredAt` version. Operator gets a clear
|
|
121
|
+
// error pointing at the slice that lights them up — defer-with-
|
|
122
|
+
// condition per the v1-defensible-scope rule.
|
|
123
|
+
var WIRED_AT = Object.freeze({
|
|
124
|
+
compose: "v0.9.25",
|
|
125
|
+
send: "v0.9.25",
|
|
126
|
+
reply: "v0.9.25",
|
|
127
|
+
forward: "v0.9.25",
|
|
128
|
+
"sieve.list": "v0.9.26",
|
|
129
|
+
"sieve.put": "v0.9.26",
|
|
130
|
+
"sieve.activate": "v0.9.26",
|
|
131
|
+
"identity.set": "v0.9.25",
|
|
132
|
+
"vacation.set": "v0.9.25",
|
|
133
|
+
"mdn.send": "v0.9.25",
|
|
134
|
+
"mdn.parse": "v0.9.25",
|
|
135
|
+
"mdn.allowList": "v0.9.25",
|
|
136
|
+
export: "v0.9.34a",
|
|
137
|
+
job: "v0.9.34a",
|
|
138
|
+
import: "v0.9.34",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @primitive b.mail.agent.create
|
|
143
|
+
* @signature b.mail.agent.create(opts)
|
|
144
|
+
* @since 0.9.20
|
|
145
|
+
* @status stable
|
|
146
|
+
* @related b.mailStore, b.mail.agent.consumer
|
|
147
|
+
*
|
|
148
|
+
* Create the agent facade. Returns an object with read / write / sieve
|
|
149
|
+
* / identity / mdn / export / import / consumer methods. Reads stay
|
|
150
|
+
* synchronous-shaped via promises; writes audit on completion.
|
|
151
|
+
*
|
|
152
|
+
* @opts
|
|
153
|
+
* store: b.mailStore instance, // required
|
|
154
|
+
* audit: b.audit namespace, // optional; defaults to b.audit
|
|
155
|
+
* permissions: b.permissions instance, // optional; agent skips RBAC if absent (operator's choice)
|
|
156
|
+
* posture: "hipaa"|"pci-dss"|"gdpr"|"soc2"|null,
|
|
157
|
+
* identity: function(actorId) → { email, name } // OR object map
|
|
158
|
+
* dispatch: { mode, queue, workerPool, queueTopic, taskTimeoutMs, queueDepthCap, vaultKeyDelivery },
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* var agent = b.mail.agent.create({ store: myStore });
|
|
162
|
+
* var folders = await agent.folders({ actor: { id: "u1" } });
|
|
163
|
+
*/
|
|
164
|
+
function create(opts) {
|
|
165
|
+
if (!opts || typeof opts !== "object") {
|
|
166
|
+
throw new MailAgentError("mail-agent/bad-opts",
|
|
167
|
+
"b.mail.agent.create: opts required");
|
|
168
|
+
}
|
|
169
|
+
validateOpts.requireObject(opts.store, "b.mail.agent.create: opts.store", MailAgentError, "mail-agent/bad-store");
|
|
170
|
+
if (typeof opts.store.fetchByObjectId !== "function") {
|
|
171
|
+
throw new MailAgentError("mail-agent/bad-store",
|
|
172
|
+
"b.mail.agent.create: opts.store does not look like a b.mailStore instance");
|
|
173
|
+
}
|
|
174
|
+
var posture = opts.posture || null;
|
|
175
|
+
if (posture && !Object.prototype.hasOwnProperty.call(guardMailQuery.COMPLIANCE_POSTURES, posture)) {
|
|
176
|
+
throw new MailAgentError("mail-agent/bad-posture",
|
|
177
|
+
"b.mail.agent.create: unknown posture '" + posture + "'");
|
|
178
|
+
}
|
|
179
|
+
var dispatch = _validateDispatch(opts.dispatch || {});
|
|
180
|
+
var identityFn = _identityResolver(opts.identity);
|
|
181
|
+
var auditEmit = _auditEmitter(opts.audit);
|
|
182
|
+
var permissions = opts.permissions || null;
|
|
183
|
+
|
|
184
|
+
var ctx = {
|
|
185
|
+
store: opts.store,
|
|
186
|
+
posture: posture,
|
|
187
|
+
dispatch: dispatch,
|
|
188
|
+
identity: identityFn,
|
|
189
|
+
auditEmit: auditEmit,
|
|
190
|
+
permissions: permissions,
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
// Read surface — backed by v0.9.19 store immediately. Routed
|
|
195
|
+
// through _dispatchOrLocal so dispatch.mode is actually consulted.
|
|
196
|
+
search: function (args) { return _dispatchOrLocal(ctx, "search", args, _search); },
|
|
197
|
+
fetch: function (args) { return _dispatchOrLocal(ctx, "fetch", args, _fetch); },
|
|
198
|
+
thread: function (args) { return _dispatchOrLocal(ctx, "thread", args, _thread); },
|
|
199
|
+
folders: function (args) { return _dispatchOrLocal(ctx, "folders", args, _folders); },
|
|
200
|
+
quota: function (args) { return _dispatchOrLocal(ctx, "quota", args, _quota); },
|
|
201
|
+
|
|
202
|
+
// Write surface — needs v0.9.25 submission listener.
|
|
203
|
+
compose: function (args) { return _notImplemented(ctx, "compose", args); },
|
|
204
|
+
send: function (args) { return _notImplemented(ctx, "send", args); },
|
|
205
|
+
reply: function (args) { return _notImplemented(ctx, "reply", args); },
|
|
206
|
+
forward: function (args) { return _notImplemented(ctx, "forward", args); },
|
|
207
|
+
|
|
208
|
+
// Move / flag / delete — backed by store, routed via dispatch.
|
|
209
|
+
move: function (args) { return _dispatchOrLocal(ctx, "move", args, _move); },
|
|
210
|
+
flag: function (args) { return _dispatchOrLocal(ctx, "flag", args, _flag); },
|
|
211
|
+
delete: function (args) { return _dispatchOrLocal(ctx, "delete", args, _delete); },
|
|
212
|
+
|
|
213
|
+
// Sieve — needs v0.9.26 interpreter.
|
|
214
|
+
sieve: {
|
|
215
|
+
list: function (args) { return _notImplemented(ctx, "sieve.list", args); },
|
|
216
|
+
put: function (args) { return _sievePut(ctx, args); },
|
|
217
|
+
activate: function (args) { return _notImplemented(ctx, "sieve.activate", args); },
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
// Identity / vacation — needs v0.9.25 submission identity store.
|
|
221
|
+
identity: {
|
|
222
|
+
set: function (args) { return _notImplemented(ctx, "identity.set", args); },
|
|
223
|
+
},
|
|
224
|
+
vacation: {
|
|
225
|
+
set: function (args) { return _notImplemented(ctx, "vacation.set", args); },
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
// MDN — needs v0.9.25 submission listener.
|
|
229
|
+
mdn: {
|
|
230
|
+
send: function (args) { return _notImplemented(ctx, "mdn.send", args); },
|
|
231
|
+
parse: function (args) { return _notImplemented(ctx, "mdn.parse", args); },
|
|
232
|
+
allowList: function (args) { return _notImplemented(ctx, "mdn.allowList", args); },
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
// DSR / regulated export — needs v0.9.34a E2EE for sealed-export
|
|
236
|
+
// (sealed columns can't ship across an export boundary without
|
|
237
|
+
// posture-aware re-encryption to operator-supplied recipient).
|
|
238
|
+
export: function (args) { return _notImplemented(ctx, "export", args); },
|
|
239
|
+
job: function (args) { return _notImplemented(ctx, "job", args); },
|
|
240
|
+
|
|
241
|
+
// Migration import — needs v0.9.34 scan + v0.9.19+ b.safeMboxFormat / b.safeMailDir.
|
|
242
|
+
import: function (args) { return _notImplemented(ctx, "import", args); },
|
|
243
|
+
|
|
244
|
+
// For testing / introspection.
|
|
245
|
+
_ctx: ctx,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @primitive b.mail.agent.consumer
|
|
251
|
+
* @signature b.mail.agent.consumer(opts)
|
|
252
|
+
* @since 0.9.20
|
|
253
|
+
* @status stable
|
|
254
|
+
* @related b.mail.agent.create, b.queue
|
|
255
|
+
*
|
|
256
|
+
* Create a queue consumer that pulls `mail.agent.tasks` envelopes and
|
|
257
|
+
* runs them against an operator-supplied agent. Each replica runs in
|
|
258
|
+
* its own process / host for multi-host load-spreading; queue payload
|
|
259
|
+
* carries actor + posture; consumer re-validates against its local
|
|
260
|
+
* posture before unseal.
|
|
261
|
+
*
|
|
262
|
+
* @opts
|
|
263
|
+
* agent: a b.mail.agent.create() instance, // required
|
|
264
|
+
* queue: b.queue / b.queueRedis, // required
|
|
265
|
+
* taskTopic: string, // default "mail.agent.tasks"
|
|
266
|
+
* maxConcurrency: number, // default 4
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* var consumer = b.mail.agent.consumer({ agent: localAgent, queue: redisQueue });
|
|
270
|
+
* await consumer.start();
|
|
271
|
+
*/
|
|
272
|
+
function consumer(opts) {
|
|
273
|
+
if (!opts || typeof opts !== "object") {
|
|
274
|
+
throw new MailAgentError("mail-agent/bad-opts",
|
|
275
|
+
"b.mail.agent.consumer: opts required");
|
|
276
|
+
}
|
|
277
|
+
if (!opts.agent || typeof opts.agent.fetch !== "function") {
|
|
278
|
+
throw new MailAgentError("mail-agent/bad-agent",
|
|
279
|
+
"b.mail.agent.consumer: opts.agent must be a b.mail.agent.create() instance");
|
|
280
|
+
}
|
|
281
|
+
if (!opts.queue || typeof opts.queue.consume !== "function") {
|
|
282
|
+
throw new MailAgentError("mail-agent/bad-queue",
|
|
283
|
+
"b.mail.agent.consumer: opts.queue must look like b.queue (consume function required)");
|
|
284
|
+
}
|
|
285
|
+
var taskTopic = opts.taskTopic || DEFAULT_QUEUE_TOPIC;
|
|
286
|
+
var maxConcurrency = typeof opts.maxConcurrency === "number" ? opts.maxConcurrency : 4;
|
|
287
|
+
if (!isFinite(maxConcurrency) || maxConcurrency < 1) {
|
|
288
|
+
throw new MailAgentError("mail-agent/bad-max-concurrency",
|
|
289
|
+
"b.mail.agent.consumer: maxConcurrency must be a positive number");
|
|
290
|
+
}
|
|
291
|
+
var stopped = false;
|
|
292
|
+
var subscription = null;
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
start: async function () {
|
|
296
|
+
if (subscription) {
|
|
297
|
+
throw new MailAgentError("mail-agent/already-started",
|
|
298
|
+
"b.mail.agent.consumer: already started");
|
|
299
|
+
}
|
|
300
|
+
subscription = await opts.queue.consume(taskTopic, async function (envelope) {
|
|
301
|
+
if (stopped) return;
|
|
302
|
+
var method = envelope.method;
|
|
303
|
+
var args = envelope.args;
|
|
304
|
+
if (!method || typeof opts.agent[method] !== "function") {
|
|
305
|
+
var dotted = method && method.indexOf(".") > 0 ? method.split(".") : null;
|
|
306
|
+
if (dotted && opts.agent[dotted[0]] && typeof opts.agent[dotted[0]][dotted[1]] === "function") {
|
|
307
|
+
return opts.agent[dotted[0]][dotted[1]](args);
|
|
308
|
+
}
|
|
309
|
+
throw new MailAgentError("mail-agent/unknown-method",
|
|
310
|
+
"consumer: unknown method '" + method + "'");
|
|
311
|
+
}
|
|
312
|
+
return opts.agent[method](args);
|
|
313
|
+
}, { maxConcurrency: maxConcurrency });
|
|
314
|
+
},
|
|
315
|
+
stop: async function () {
|
|
316
|
+
stopped = true;
|
|
317
|
+
if (subscription && typeof subscription.unsubscribe === "function") {
|
|
318
|
+
await subscription.unsubscribe();
|
|
319
|
+
}
|
|
320
|
+
subscription = null;
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- Dispatch routing -----------------------------------------------------
|
|
326
|
+
|
|
327
|
+
// Honor dispatch.mode on every facade call. v0.9.20 ships the contract
|
|
328
|
+
// shape; full result-bus delivery for queue mode wires at v0.9.21
|
|
329
|
+
// with `b.agent.orchestrator` (defer-with-condition per the v1-
|
|
330
|
+
// defensible-scope rule).
|
|
331
|
+
//
|
|
332
|
+
// "local" → run localFn (default fallback)
|
|
333
|
+
// "queue" → enqueue + return { enqueued: true, jobId }; operator
|
|
334
|
+
// polls via the orchestrator's job-id facade once that
|
|
335
|
+
// ships. Methods that need a sync response under queue
|
|
336
|
+
// mode refuse with `mail-agent/queue-result-bus-deferred`
|
|
337
|
+
// until v0.9.21 wires the result-bus.
|
|
338
|
+
// "auto" → HEAVY_METHODS routes to queue when configured; rest local
|
|
339
|
+
async function _dispatchOrLocal(ctx, method, args, localFn) {
|
|
340
|
+
var mode = ctx.dispatch.mode;
|
|
341
|
+
if (mode === "local") return localFn(ctx, args);
|
|
342
|
+
if (mode === "auto") {
|
|
343
|
+
if (HEAVY_METHODS[method] && ctx.dispatch.queue) return _enqueueMethod(ctx, method, args);
|
|
344
|
+
return localFn(ctx, args);
|
|
345
|
+
}
|
|
346
|
+
// mode === "queue" — explicit queue dispatch.
|
|
347
|
+
if (!ctx.dispatch.queue) {
|
|
348
|
+
throw new MailAgentError("mail-agent/no-queue",
|
|
349
|
+
"agent." + method + ": dispatch.mode='queue' requires opts.dispatch.queue");
|
|
350
|
+
}
|
|
351
|
+
// Sync-result methods refuse until orchestrator's result-bus lands.
|
|
352
|
+
if (!HEAVY_METHODS[method]) {
|
|
353
|
+
throw new MailAgentError("mail-agent/queue-result-bus-deferred",
|
|
354
|
+
"agent." + method + ": queue mode for sync-result methods wires at v0.9.21 " +
|
|
355
|
+
"(b.agent.orchestrator). Use mode='local' or mode='auto' until then.");
|
|
356
|
+
}
|
|
357
|
+
return _enqueueMethod(ctx, method, args);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function _enqueueMethod(ctx, method, args) {
|
|
361
|
+
var envelope = {
|
|
362
|
+
method: method,
|
|
363
|
+
args: args,
|
|
364
|
+
posture: ctx.posture,
|
|
365
|
+
enqueuedAt: Date.now(),
|
|
366
|
+
};
|
|
367
|
+
var r = await ctx.dispatch.queue.enqueue(ctx.dispatch.queueTopic, envelope, {});
|
|
368
|
+
ctx.auditEmit("mail.agent.enqueued", args && args.actor, {
|
|
369
|
+
method: method, topic: ctx.dispatch.queueTopic, jobId: r && r.jobId,
|
|
370
|
+
});
|
|
371
|
+
return { enqueued: true, jobId: r && r.jobId, topic: ctx.dispatch.queueTopic };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---- Method implementations -----------------------------------------------
|
|
375
|
+
|
|
376
|
+
async function _search(ctx, args) {
|
|
377
|
+
_entry(ctx, "search", args);
|
|
378
|
+
guardMailQuery.validate(args.filter || {}, { profile: _profileFor(ctx), posture: ctx.posture, project: args.project });
|
|
379
|
+
var folder = args.folder || "INBOX";
|
|
380
|
+
// For v0.9.20: queryByModseq + post-filter against the unsealed row.
|
|
381
|
+
// Future v0.9.27 with full-text indexing will replace this with an
|
|
382
|
+
// index-side filter. Current scope: sinceModseq + flag filter only,
|
|
383
|
+
// with a simple `from_addr` equality match via cryptoField.lookupHash
|
|
384
|
+
// (the same hash computed at append time).
|
|
385
|
+
var sinceModseq = (args.filter && args.filter.modseq && args.filter.modseq.gt) || 0;
|
|
386
|
+
var limit = args.limit || 100;
|
|
387
|
+
var rows = ctx.store.queryByModseq(folder, { sinceModseq: sinceModseq, limit: limit });
|
|
388
|
+
ctx.auditEmit("mail.agent.search.success", args.actor, { folder: folder, rowCount: rows.length });
|
|
389
|
+
return { rows: rows, nextModseq: rows.length > 0 ? rows[rows.length - 1].modseq : sinceModseq };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function _fetch(ctx, args) {
|
|
393
|
+
_entry(ctx, "fetch", args);
|
|
394
|
+
if (typeof args.folder !== "string" || typeof args.objectId !== "string") {
|
|
395
|
+
throw new MailAgentError("mail-agent/bad-args",
|
|
396
|
+
"agent.fetch: { folder, objectId } required");
|
|
397
|
+
}
|
|
398
|
+
var msg = ctx.store.fetchByObjectId(args.folder, args.objectId);
|
|
399
|
+
if (!msg) {
|
|
400
|
+
ctx.auditEmit("mail.agent.fetch.miss", args.actor, { folder: args.folder, objectId: args.objectId });
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
ctx.auditEmit("mail.agent.fetch.success", args.actor, { folder: args.folder, objectId: args.objectId });
|
|
404
|
+
return msg;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function _thread(ctx, args) {
|
|
408
|
+
_entry(ctx, "thread", args);
|
|
409
|
+
if (typeof args.objectId !== "string") {
|
|
410
|
+
throw new MailAgentError("mail-agent/bad-args",
|
|
411
|
+
"agent.thread: { objectId } required");
|
|
412
|
+
}
|
|
413
|
+
var chain = ctx.store.threadFor(args.objectId);
|
|
414
|
+
ctx.auditEmit("mail.agent.thread.success", args.actor, { objectId: args.objectId, hopCount: chain.length });
|
|
415
|
+
return { thread: chain };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function _folders(ctx, args) {
|
|
419
|
+
_entry(ctx, "folders", args);
|
|
420
|
+
var rows = ctx.store.listFolders();
|
|
421
|
+
ctx.auditEmit("mail.agent.folders.success", args.actor, { count: rows.length });
|
|
422
|
+
return { folders: rows };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function _quota(ctx, args) {
|
|
426
|
+
_entry(ctx, "quota", args);
|
|
427
|
+
var folder = args.folder || "INBOX";
|
|
428
|
+
var q = ctx.store.quota(folder);
|
|
429
|
+
ctx.auditEmit("mail.agent.quota.success", args.actor, { folder: folder });
|
|
430
|
+
return q;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function _move(ctx, args) {
|
|
434
|
+
_entry(ctx, "move", args);
|
|
435
|
+
guardMailMove.validate({
|
|
436
|
+
actor: args.actor, fromFolder: args.fromFolder,
|
|
437
|
+
toFolder: args.toFolder, objectIds: args.objectIds,
|
|
438
|
+
}, { profile: _profileFor(ctx), posture: ctx.posture });
|
|
439
|
+
var r = ctx.store.moveMessages(args.fromFolder, args.toFolder, args.objectIds);
|
|
440
|
+
ctx.auditEmit("mail.agent.move.success", args.actor, {
|
|
441
|
+
fromFolder: args.fromFolder, toFolder: args.toFolder, count: r.changed,
|
|
442
|
+
});
|
|
443
|
+
return r;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function _flag(ctx, args) {
|
|
447
|
+
_entry(ctx, "flag", args);
|
|
448
|
+
if (typeof args.folder !== "string" || !Array.isArray(args.objectIds)) {
|
|
449
|
+
throw new MailAgentError("mail-agent/bad-args",
|
|
450
|
+
"agent.flag: { folder, objectIds, set?, unset? } required");
|
|
451
|
+
}
|
|
452
|
+
var r = ctx.store.setFlags(args.folder, args.objectIds, { set: args.set || [], unset: args.unset || [] });
|
|
453
|
+
ctx.auditEmit("mail.agent.flag.success", args.actor, {
|
|
454
|
+
folder: args.folder, count: args.objectIds.length, set: args.set, unset: args.unset,
|
|
455
|
+
});
|
|
456
|
+
return r;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function _delete(ctx, args) {
|
|
460
|
+
// Soft-delete: move to Trash + tag with \Deleted. Hard expunge is
|
|
461
|
+
// explicitly out of scope at v0.9.20; v0.9.28 IMAP EXPUNGE wires the
|
|
462
|
+
// hard-delete path with retention floor enforcement (b.retention.
|
|
463
|
+
// complianceFloor refuses purge of mail still inside the regulated
|
|
464
|
+
// retention window).
|
|
465
|
+
_entry(ctx, "delete", args);
|
|
466
|
+
if (typeof args.folder !== "string" || !Array.isArray(args.objectIds)) {
|
|
467
|
+
throw new MailAgentError("mail-agent/bad-args",
|
|
468
|
+
"agent.delete: { folder, objectIds } required");
|
|
469
|
+
}
|
|
470
|
+
if (args.folder === "Trash") {
|
|
471
|
+
// Already in Trash; just mark deleted. Hard expunge at v0.9.28.
|
|
472
|
+
var r0 = ctx.store.setFlags("Trash", args.objectIds, { set: ["\\Deleted"] });
|
|
473
|
+
ctx.auditEmit("mail.agent.delete.flagged", args.actor, { folder: "Trash", count: args.objectIds.length });
|
|
474
|
+
return r0;
|
|
475
|
+
}
|
|
476
|
+
guardMailMove.validate({
|
|
477
|
+
actor: args.actor, fromFolder: args.folder, toFolder: "Trash", objectIds: args.objectIds,
|
|
478
|
+
}, { profile: _profileFor(ctx), posture: ctx.posture });
|
|
479
|
+
ctx.store.setFlags(args.folder, args.objectIds, { set: ["\\Deleted"] });
|
|
480
|
+
var r = ctx.store.moveMessages(args.folder, "Trash", args.objectIds);
|
|
481
|
+
ctx.auditEmit("mail.agent.delete.success", args.actor, {
|
|
482
|
+
folder: args.folder, count: args.objectIds.length,
|
|
483
|
+
});
|
|
484
|
+
return r;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function _sievePut(ctx, args) {
|
|
488
|
+
// Pre-parse shape-only validation lands today; full b.safeSieve
|
|
489
|
+
// parse lands v0.9.26 and will be invoked from the throw-stub at
|
|
490
|
+
// that slice. The agent-level guard lets operators wire RBAC + name
|
|
491
|
+
// shape today.
|
|
492
|
+
_entry(ctx, "sieve.put", args);
|
|
493
|
+
guardMailSieve.validate({
|
|
494
|
+
kind: "put", actor: args.actor, name: args.name, script: args.script,
|
|
495
|
+
}, { profile: _profileFor(ctx), posture: ctx.posture, ownedNames: args.ownedNames });
|
|
496
|
+
throw new MailAgentError("mail-agent/not-implemented",
|
|
497
|
+
"agent.sieve.put: full Sieve parser lands at v0.9.26 (b.safeSieve); " +
|
|
498
|
+
"shape-only validation passed — wire the persistence step in the operator handler");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function _notImplemented(ctx, method, args) {
|
|
502
|
+
// Even for not-implemented methods, validate actor + permission at
|
|
503
|
+
// the entry — operators integrating against the not-yet-wired
|
|
504
|
+
// surface still get the same auth error semantics they'll see when
|
|
505
|
+
// the slice lights up.
|
|
506
|
+
if (ctx.posture) guardMailQuery.validateActor(args && args.actor, ctx.posture);
|
|
507
|
+
_checkPermission(ctx, method, args);
|
|
508
|
+
ctx.auditEmit("mail.agent.not_implemented", args && args.actor, { method: method, wiredAt: WIRED_AT[method] });
|
|
509
|
+
return Promise.reject(new MailAgentError("mail-agent/not-implemented",
|
|
510
|
+
"agent." + method + ": wired at " + WIRED_AT[method] + " (defer-with-condition)"));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---- Internals ------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
function _entry(ctx, method, args) {
|
|
516
|
+
if (!args || typeof args !== "object") {
|
|
517
|
+
throw new MailAgentError("mail-agent/bad-args",
|
|
518
|
+
"agent." + method + ": args object required");
|
|
519
|
+
}
|
|
520
|
+
guardMailQuery.validateActor(args.actor, ctx.posture);
|
|
521
|
+
_checkPermission(ctx, method, args);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function _checkPermission(ctx, method, args) {
|
|
525
|
+
if (!ctx.permissions) return;
|
|
526
|
+
var scope = SCOPE_FOR_METHOD[method];
|
|
527
|
+
if (!scope) return;
|
|
528
|
+
if (!args || !args.actor) {
|
|
529
|
+
throw new MailAgentError("mail-agent/no-actor",
|
|
530
|
+
"agent." + method + ": actor required");
|
|
531
|
+
}
|
|
532
|
+
if (!ctx.permissions.check(args.actor, scope)) {
|
|
533
|
+
ctx.auditEmit("mail.agent.permission_denied", args.actor, { method: method, scope: scope });
|
|
534
|
+
throw new MailAgentError("mail-agent/permission-denied",
|
|
535
|
+
"agent." + method + ": actor lacks scope '" + scope + "'");
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function _profileFor(ctx) {
|
|
540
|
+
// Posture pins strict; otherwise default to strict (the framework's
|
|
541
|
+
// security-defaults-on rule applies here too — operators opt down
|
|
542
|
+
// explicitly when needed).
|
|
543
|
+
return ctx.posture ? "strict" : "strict";
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function _validateDispatch(d) {
|
|
547
|
+
var mode = d.mode || "auto";
|
|
548
|
+
if (mode !== "local" && mode !== "queue" && mode !== "auto") {
|
|
549
|
+
throw new MailAgentError("mail-agent/bad-dispatch-mode",
|
|
550
|
+
"b.mail.agent.create: dispatch.mode must be 'local' | 'queue' | 'auto'");
|
|
551
|
+
}
|
|
552
|
+
if (mode === "queue" && (!d.queue || typeof d.queue.enqueue !== "function")) {
|
|
553
|
+
throw new MailAgentError("mail-agent/no-queue",
|
|
554
|
+
"b.mail.agent.create: dispatch.mode='queue' requires opts.dispatch.queue with .enqueue()");
|
|
555
|
+
}
|
|
556
|
+
if (d.workerPool && typeof d.workerPool.run !== "function") {
|
|
557
|
+
throw new MailAgentError("mail-agent/bad-worker-pool",
|
|
558
|
+
"b.mail.agent.create: dispatch.workerPool must expose .run()");
|
|
559
|
+
}
|
|
560
|
+
var topic = d.queueTopic || DEFAULT_QUEUE_TOPIC;
|
|
561
|
+
var taskTimeoutMs = typeof d.taskTimeoutMs === "number" ? d.taskTimeoutMs : DEFAULT_TASK_TIMEOUT_MS;
|
|
562
|
+
if (!isFinite(taskTimeoutMs) || taskTimeoutMs <= 0) {
|
|
563
|
+
throw new MailAgentError("mail-agent/bad-task-timeout",
|
|
564
|
+
"b.mail.agent.create: dispatch.taskTimeoutMs must be a positive finite number");
|
|
565
|
+
}
|
|
566
|
+
var queueDepthCap = typeof d.queueDepthCap === "number" ? d.queueDepthCap : DEFAULT_QUEUE_DEPTH_CAP;
|
|
567
|
+
if (!isFinite(queueDepthCap) || queueDepthCap < 0) {
|
|
568
|
+
throw new MailAgentError("mail-agent/bad-queue-depth-cap",
|
|
569
|
+
"b.mail.agent.create: dispatch.queueDepthCap must be a non-negative finite number");
|
|
570
|
+
}
|
|
571
|
+
var vaultKeyDelivery = d.vaultKeyDelivery || "in-worker";
|
|
572
|
+
if (vaultKeyDelivery !== "in-worker" && vaultKeyDelivery !== "main-only") {
|
|
573
|
+
throw new MailAgentError("mail-agent/bad-vault-key-delivery",
|
|
574
|
+
"b.mail.agent.create: dispatch.vaultKeyDelivery must be 'in-worker' | 'main-only'");
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
mode: mode,
|
|
578
|
+
queue: d.queue || null,
|
|
579
|
+
workerPool: d.workerPool || null,
|
|
580
|
+
queueTopic: topic,
|
|
581
|
+
taskTimeoutMs: taskTimeoutMs,
|
|
582
|
+
queueDepthCap: queueDepthCap,
|
|
583
|
+
vaultKeyDelivery: vaultKeyDelivery,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function _identityResolver(spec) {
|
|
588
|
+
if (typeof spec === "function") return spec;
|
|
589
|
+
if (spec && typeof spec === "object") {
|
|
590
|
+
return function (actorId) { return spec[actorId] || null; };
|
|
591
|
+
}
|
|
592
|
+
return function () { return null; };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function _auditEmitter(auditOverride) {
|
|
596
|
+
if (auditOverride && typeof auditOverride.safeEmit === "function") {
|
|
597
|
+
return function (event, actor, metadata) {
|
|
598
|
+
auditOverride.safeEmit({
|
|
599
|
+
action: event,
|
|
600
|
+
actor: _actorShape(actor),
|
|
601
|
+
outcome: event.indexOf("denied") >= 0 || event.indexOf("not_implemented") >= 0 ? "failure" : "success",
|
|
602
|
+
metadata: metadata || {},
|
|
603
|
+
});
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
return function (event, actor, metadata) {
|
|
607
|
+
audit().safeEmit({
|
|
608
|
+
action: event,
|
|
609
|
+
actor: _actorShape(actor),
|
|
610
|
+
outcome: event.indexOf("denied") >= 0 || event.indexOf("not_implemented") >= 0 ? "failure" : "success",
|
|
611
|
+
metadata: metadata || {},
|
|
612
|
+
});
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function _actorShape(actor) {
|
|
617
|
+
if (!actor || typeof actor !== "object") return { id: "<unknown>" };
|
|
618
|
+
return { id: actor.id, roles: actor.roles || [] };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
module.exports = {
|
|
622
|
+
create: create,
|
|
623
|
+
consumer: consumer,
|
|
624
|
+
MailAgentError: MailAgentError,
|
|
625
|
+
SCOPE_FOR_METHOD: SCOPE_FOR_METHOD,
|
|
626
|
+
WIRED_AT: WIRED_AT,
|
|
627
|
+
HEAVY_METHODS: HEAVY_METHODS,
|
|
628
|
+
// Re-export the guard family so callers can introspect without
|
|
629
|
+
// separate requires.
|
|
630
|
+
guards: {
|
|
631
|
+
query: guardMailQuery,
|
|
632
|
+
compose: guardMailCompose,
|
|
633
|
+
reply: guardMailReply,
|
|
634
|
+
move: guardMailMove,
|
|
635
|
+
sieve: guardMailSieve,
|
|
636
|
+
messageId: guardMessageId,
|
|
637
|
+
},
|
|
638
|
+
};
|