@jini-ai/platform 0.6.11 → 0.6.15-rc.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 (54) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +52 -0
  3. package/dist/asset-cache.js +27 -13
  4. package/dist/command.d.ts +1 -1
  5. package/dist/command.js +11 -8
  6. package/dist/download.js +26 -32
  7. package/dist/fetch-with-timeout.d.ts +54 -0
  8. package/dist/fetch-with-timeout.js +129 -2
  9. package/dist/fs/directory-user-config.js +2 -1
  10. package/dist/fs/directory-workspace.js +3 -1
  11. package/dist/fs/guarded-reader.d.ts +19 -47
  12. package/dist/fs/guarded-reader.js +82 -13
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/mail/adapter-module.d.ts +6 -0
  16. package/dist/mail/delivery-wrapper.d.ts +46 -0
  17. package/dist/mail/delivery-wrapper.js +109 -0
  18. package/dist/mail/domain-read.d.ts +21 -0
  19. package/dist/mail/domain-read.js +17 -0
  20. package/dist/mail/email-sender-registry.d.ts +15 -0
  21. package/dist/mail/email-sender-registry.js +22 -0
  22. package/dist/mail/email-sender.d.ts +109 -0
  23. package/dist/mail/email-sender.js +10 -0
  24. package/dist/mail/engine-contracts.d.ts +129 -0
  25. package/dist/mail/engine-contracts.js +2 -0
  26. package/dist/mail/index.d.ts +15 -2
  27. package/dist/mail/index.js +8 -1
  28. package/dist/mail/mail-status-cache.d.ts +33 -0
  29. package/dist/mail/mail-status-cache.js +56 -0
  30. package/dist/mail/prepare-sender-email.d.ts +35 -0
  31. package/dist/mail/prepare-sender-email.js +68 -0
  32. package/dist/mail/provider-error.d.ts +18 -0
  33. package/dist/mail/provider-error.js +24 -0
  34. package/dist/mail/purpose-scoped-mailer.d.ts +12 -1
  35. package/dist/mail/purpose-scoped-mailer.js +23 -12
  36. package/dist/mail/resolve-mailer.d.ts +95 -0
  37. package/dist/mail/resolve-mailer.js +348 -0
  38. package/dist/mail/sender-domain-verification.d.ts +40 -0
  39. package/dist/mail/sender-domain-verification.js +71 -0
  40. package/dist/mail/senders.d.ts +5 -0
  41. package/dist/mail/senders.js +5 -0
  42. package/dist/mail/types.d.ts +19 -0
  43. package/dist/process.d.ts +27 -2
  44. package/dist/process.js +85 -2
  45. package/dist/secrets/credential-sets/vault.repo.memory.d.ts +1 -4
  46. package/dist/secrets/credential-sets/vault.repo.memory.js +3 -1
  47. package/dist/secrets/credential-sets/vault.service.d.ts +17 -9
  48. package/dist/secrets/credential-sets/vault.service.js +18 -9
  49. package/dist/secrets/credential-sets/vault.types.d.ts +10 -4
  50. package/dist/secrets/index.d.ts +2 -0
  51. package/dist/secrets/index.js +1 -0
  52. package/dist/secrets/passphrase-envelope.d.ts +26 -0
  53. package/dist/secrets/passphrase-envelope.js +125 -0
  54. package/package.json +8 -2
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Explicit lane selection, live credential rotation and cached provider status.
3
+ * Saved keys never select a provider. Local fallback uses the catalog's console provider;
4
+ * production refuses. Host ports retain trusted loading, credential copy and configuration DTOs.
5
+ * capabilities() is synchronous and reports console until transactional resolution settles;
6
+ * sends always await their own lane. Invalidation detaches obsolete work without cancelling it.
7
+ */
8
+ import { ToolInputError } from "@jini-ai/core";
9
+ import { readMailDomain } from "./domain-read.js";
10
+ import { createTtlCache, MAIL_STATUS_TTL_MS } from "./mail-status-cache.js";
11
+ import { credentialSecrets, safeMailProviderText } from "./provider-error.js";
12
+ import { computeMailStatus, initialMailProviderSetting } from "./provider.js";
13
+ import { laneForPurpose } from "./policy.js";
14
+ import { createSenderDomainVerification } from "./sender-domain-verification.js";
15
+ const CONSOLE_CAPABILITIES = { driver: "console", supportsIdempotencyKey: false, supportsWebhookFeedback: false, maxBatchSize: 1, supportsAttachments: true };
16
+ /** Metadata identifies the exact saved version without decryption. @complexity O(1). */
17
+ function rowFingerprint(record) {
18
+ return record ? `${record.id}:${record.vaultRevision ?? ""}:${record.updatedAt}` : "absent";
19
+ }
20
+ /** Any failed recipient wins; only a wholly accepted, nonempty operation clears a send failure.
21
+ * Results and exceptions pass through unchanged. @complexity O(messages) time, O(1) space. */
22
+ function observeFailures(required) {
23
+ const { mailer, providerId, record, accepted } = required;
24
+ const seen = (results) => {
25
+ const failure = results.find(result => !result.ok);
26
+ if (failure && !failure.ok) {
27
+ record({ providerId, source: "send", code: failure.errorCode, message: failure.message });
28
+ return;
29
+ }
30
+ if (results.length > 0)
31
+ accepted();
32
+ };
33
+ return {
34
+ capabilities: () => mailer.capabilities({}),
35
+ send: async (input, options) => { const result = await mailer.send(input, options); seen([result]); return result; },
36
+ sendBatch: async (input, options) => { const results = await mailer.sendBatch(input, options); seen(results); return results; },
37
+ };
38
+ }
39
+ /** Refusals report console so availability checks never mistake them for delivery. @complexity O(messages) for batches. */
40
+ function refusingMailer(required) {
41
+ const result = { ok: false, retryable: false, errorCode: required.errorCode, message: required.message };
42
+ return { capabilities: () => CONSOLE_CAPABILITIES, send: async () => result, sendBatch: async ({ messages }) => messages.map(() => result) };
43
+ }
44
+ /** Diagnostic sinks cannot throw across the mail boundary; production warns once per reason. */
45
+ function warnOnce({ state, key, message }) {
46
+ if (state.deps.mode !== "production" || state.warned.has(key))
47
+ return;
48
+ state.warned.add(key);
49
+ try {
50
+ state.warn(message);
51
+ }
52
+ catch { /* Diagnostics cannot reject a send. */ }
53
+ }
54
+ /** Never retain provider/body details. Repeated checks must not hide a failed send. */
55
+ function recordError({ state, error }) {
56
+ const { providerId, source } = error;
57
+ if (source === "check" && state.lastError?.source === "send")
58
+ return;
59
+ state.lastError = { providerId, source, message: source === "send" ? "The provider did not confirm acceptance. Check provider configuration." : "The provider configuration check could not complete.", at: new Date(state.nowMs()).toISOString() };
60
+ }
61
+ /** First boot remains unconfigured even with saved keys; failed persistence is retried on a later read. */
62
+ async function initialize(state) {
63
+ const setting = initialMailProviderSetting({});
64
+ await state.deps.providerSetting.write({ setting }).catch(() => warnOnce({ state, key: "initialization", message: "[mail] the mail provider setting could not be saved; retrying on the next send" }));
65
+ return setting;
66
+ }
67
+ /** Only initialization is single-flight: every call still reads the current selection first. */
68
+ async function currentSetting(state) {
69
+ const stored = await state.deps.providerSetting.read({});
70
+ if (stored)
71
+ return stored;
72
+ state.initialization ??= initialize(state).finally(() => { state.initialization = undefined; });
73
+ return state.initialization;
74
+ }
75
+ /** Keyless providers never touch the credential metadata store. */
76
+ async function fingerprintFor({ state, label }) {
77
+ if (label === undefined)
78
+ return "";
79
+ return rowFingerprint(await state.deps.credentials.readFact({ label }));
80
+ }
81
+ /** The host catalog guarantees the built-in console provider. Its mailer shares the policy wrapper. */
82
+ async function consoleMailer({ state, catalog }) {
83
+ const provider = catalog.registry.get({ id: "console" }).provider;
84
+ const mailer = state.wrap({ providerId: "console", provider, mailer: state.deps.createProviderMailer({ provider, credential: { token: "" } }) });
85
+ state.resolved.set("console", { mailer, fingerprint: "" });
86
+ return mailer;
87
+ }
88
+ /** Local mode keeps working through console; production refuses and logs safe host copy once. */
89
+ async function fallback(required) {
90
+ const { state } = required;
91
+ if (state.deps.mode !== "production")
92
+ return state.resolved.get("console")?.mailer ?? consoleMailer(required);
93
+ warnOnce({ state, key: required.errorCode + required.message, message: required.message });
94
+ return refusingMailer(required);
95
+ }
96
+ /** A missing key or failed decrypt uses the host's established caller-safe refusal wording. */
97
+ async function credentialOrReason({ state, catalog, id, label }) {
98
+ try {
99
+ const credential = await state.deps.credentials.resolve({ catalog, id });
100
+ if (credential)
101
+ return { credential };
102
+ }
103
+ catch (err) {
104
+ return { reason: state.deps.credentialFailureReason({ label, err }) };
105
+ }
106
+ return { reason: state.deps.missingCredentialReason({ label }) };
107
+ }
108
+ /** A catalog label means a saved key is required; keyless providers receive an empty token. */
109
+ function providerCredential({ state, catalog, id }) {
110
+ const label = catalog.credentialLabelFor({ id });
111
+ return label === undefined ? Promise.resolve({ credential: { token: "" } }) : credentialOrReason({ state, catalog, id, label });
112
+ }
113
+ /** Preserve provider selection refusals, including the catalog's trusted plugin-load diagnostics. */
114
+ function unavailable(required) {
115
+ return fallback({ ...required, errorCode: "MAIL_PROVIDER_UNAVAILABLE", message: `[mail] the selected mail provider '${required.id}' ${required.detail}, so outbound mail is NOT being sent` });
116
+ }
117
+ /** A successful operation only clears a failure from the same provider. */
118
+ function acceptProvider({ state, id }) {
119
+ if (state.lastError?.source === "send" && state.lastError.providerId === id)
120
+ state.lastError = undefined;
121
+ }
122
+ /** Construct only after metadata/key/metadata agree; a generation change prevents caching obsolete work.
123
+ * @complexity One catalog load, two metadata reads and one decrypt per attempt; races retry in order. */
124
+ async function resolveProvider({ state, id, startedIn }) {
125
+ const catalog = await state.deps.loadCatalog({});
126
+ const entry = catalog.registry.get({ id });
127
+ if (!entry) {
128
+ const refusals = catalog.refusals.length > 0 ? ` (${catalog.refusals.join("; ")})` : "";
129
+ return unavailable({ state, catalog, id, detail: `is not installed${refusals}` });
130
+ }
131
+ // Fingerprint BEFORE reading the key: a rotation racing this read then re-resolves next send.
132
+ // An unreadable store leaves key lookup to report why; "unknown" re-resolves once it reads.
133
+ const label = catalog.credentialLabelFor({ id });
134
+ const fingerprint = await fingerprintFor({ state, label }).catch(() => "unknown");
135
+ const found = await providerCredential({ state, catalog, id });
136
+ if (!found.credential)
137
+ return unavailable({ state, catalog, id, detail: found.reason });
138
+ const credential = found.credential;
139
+ const latest = await fingerprintFor({ state, label }).catch(() => fingerprint);
140
+ if (latest !== fingerprint)
141
+ return resolveProvider({ state, id, startedIn: state.generation });
142
+ const observed = observeFailures({ mailer: state.deps.createProviderMailer({ provider: entry.provider, credential }), providerId: id,
143
+ record: error => recordError({ state, error }), accepted: () => acceptProvider({ state, id }) });
144
+ const mailer = state.wrap({ providerId: id, provider: entry.provider, mailer: observed });
145
+ if (startedIn === state.generation)
146
+ state.resolved.set(id, { mailer, fingerprint, ...(label === undefined ? {} : { label }) });
147
+ return mailer;
148
+ }
149
+ /** Boot and early sends share one resolution per provider. Old completions rejoin the current generation. */
150
+ function resolveOnce({ state, id }) {
151
+ const existing = state.inFlight.get(id);
152
+ if (existing)
153
+ return existing;
154
+ const startedIn = state.generation;
155
+ const started = resolveProvider({ state, id, startedIn }).then(mailer => startedIn === state.generation ? mailer : resolveOnce({ state, id }))
156
+ .finally(() => { if (state.inFlight.get(id) === started)
157
+ state.inFlight.delete(id); });
158
+ state.inFlight.set(id, started);
159
+ return started;
160
+ }
161
+ /** Reuse unchanged rows; metadata faults keep a usable cached mailer. @complexity One metadata read per send. */
162
+ async function currentMailer({ state, id }) {
163
+ const entry = state.resolved.get(id);
164
+ if (!entry)
165
+ return resolveOnce({ state, id });
166
+ if (entry.label === undefined)
167
+ return entry.mailer;
168
+ const fingerprint = await fingerprintFor({ state, label: entry.label }).catch(() => entry.fingerprint);
169
+ if (fingerprint === entry.fingerprint)
170
+ return entry.mailer;
171
+ if (state.resolved.get(id) === entry)
172
+ state.resolved.delete(id);
173
+ return resolveOnce({ state, id });
174
+ }
175
+ /** Fresh lane selection precedes every resolution and updates the synchronous transactional projection. */
176
+ async function mailerForLane({ state, lane }) {
177
+ const id = (await currentSetting(state))[lane];
178
+ const mailer = id === null
179
+ ? await fallback({ state, catalog: await state.deps.loadCatalog({}), errorCode: "MAIL_UNCONFIGURED", message: state.deps.unconfiguredMessage({ lane }) })
180
+ : await currentMailer({ state, id });
181
+ if (lane === "transactional")
182
+ state.lastTransactional = mailer;
183
+ return mailer;
184
+ }
185
+ /** Settings/catalog faults become a refusal rather than throwing across the routing boundary. */
186
+ function forLane(required) {
187
+ return mailerForLane(required).catch(() => refusingMailer({ errorCode: "MAIL_PROVIDER_UNAVAILABLE", message: "Mail provider selection failed; check configuration." }));
188
+ }
189
+ /** The public mailer preserves synchronous capability reporting and awaits the proper lane for sends. */
190
+ function createRouter(state) {
191
+ return {
192
+ capabilities: () => {
193
+ if (state.lastTransactional === undefined)
194
+ void forLane({ state, lane: "transactional" });
195
+ return state.lastTransactional?.capabilities({}) ?? CONSOLE_CAPABILITIES;
196
+ },
197
+ send: async (input, options = {}) => (await forLane({ state, lane: laneForPurpose({ purpose: options.purpose }) })).send(input, options),
198
+ sendBatch: async (input, options = {}) => (await forLane({ state, lane: laneForPurpose({ purpose: options.purpose }) })).sendBatch(input, options),
199
+ };
200
+ }
201
+ /** Sender settings errors count as unset; parsing retains the original lowercase/trim behavior. */
202
+ async function readSenderDomain(state) {
203
+ const email = await state.options.readSenderEmail?.().catch(() => undefined);
204
+ return email?.split("@")[1]?.trim().toLowerCase() || undefined;
205
+ }
206
+ /** Domain status shares selection/credentials with delivery, while keeping its own ordered reads. */
207
+ function createDomainVerification({ state, resolveDomainReadCredential }) {
208
+ return createSenderDomainVerification({
209
+ workspaceId: state.deps.workspaceId, httpClient: state.deps.httpClient,
210
+ findProvider: async ({ id }) => (await state.deps.loadCatalog({})).registry.get({ id })?.provider,
211
+ resolveCredential: async ({ id }) => state.deps.credentials.resolve({ catalog: await state.deps.loadCatalog({}), id }),
212
+ resolveDomainReadCredential,
213
+ readProviderId: async () => (await currentSetting(state)).transactional,
214
+ readSenderEmail: async () => state.options.readSenderEmail ? state.options.readSenderEmail() : undefined,
215
+ });
216
+ }
217
+ /** Separate requirements read with permission-only fallback; deadline covers provider calls, not setup.
218
+ * Registry validation guarantees domainRequirements when domainVerification is true.
219
+ * @complexity At most two provider calls; diagnostic output is capped at 1000 characters. */
220
+ async function requestDomainRequirements(required, optional = {}) {
221
+ const { ctx, providerId, provider, domain } = required;
222
+ let timer;
223
+ const timeout = new Promise((_resolve, reject) => {
224
+ timer = setTimeout(() => reject(new Error("The mail provider could not list its required DNS records.")), optional.timeoutMs ?? 5_000);
225
+ });
226
+ try {
227
+ const result = await Promise.race([readMailDomain({ ctx, providerId, read: context => provider.domainRequirements(context, { domain }), resolveDomainReadCredential: required.resolveDomainReadCredential }), timeout]);
228
+ if (result.status === "ok")
229
+ return result.value;
230
+ throw new ToolInputError({ message: safeMailProviderText({ text: `${result.error.code}: ${result.error.message}` }, { secrets: credentialSecrets({ credential: ctx.credential }) }) });
231
+ }
232
+ finally {
233
+ clearTimeout(timer);
234
+ }
235
+ }
236
+ /** Resolve requirements only for this workspace and its active transactional provider. */
237
+ async function domainRequirements(required) {
238
+ const { state, resolveDomainReadCredential } = required;
239
+ if (required.workspaceId !== state.deps.workspaceId)
240
+ return null;
241
+ const [setting, catalog, domain] = await Promise.all([currentSetting(state), state.deps.loadCatalog({}), readSenderDomain(state)]);
242
+ const id = setting.transactional;
243
+ const provider = id === null ? undefined : catalog.registry.get({ id })?.provider;
244
+ if (!domain || !provider?.capabilities.domainVerification)
245
+ return null;
246
+ const { credential } = await providerCredential({ state, catalog, id: id });
247
+ const ctx = credential ? { workspaceId: state.deps.workspaceId, mode: "live", credential, httpClient: state.deps.httpClient } : null;
248
+ return ctx ? requestDomainRequirements({ ctx, providerId: id, provider, domain, resolveDomainReadCredential }, { timeoutMs: state.options.domainRequirementsTimeoutMs }) : null;
249
+ }
250
+ /** Unique lane keys resolve in parallel; each failed lookup records a safe check note. @complexity O(lanes). */
251
+ async function laneCredentials(required) {
252
+ const { state, catalog, setting } = required;
253
+ const ids = [...new Set([setting.transactional, setting.bulk].filter((id) => id !== null))];
254
+ const lookup = (id) => state.deps.credentials.resolve({ catalog, id }).catch((err) => {
255
+ // Only a provider with a label reaches the store, so a thrown lookup has a defined label.
256
+ recordError({ state, error: { providerId: id, source: "check", message: state.deps.credentialFailureReason({ label: catalog.credentialLabelFor({ id }), err }) } });
257
+ return null;
258
+ });
259
+ const found = await Promise.all(ids.map(async (id) => [id, await lookup(id)]));
260
+ return new Map(found.filter((pair) => pair[1] !== null));
261
+ }
262
+ /** Row-list faults affect presence facts but do not prevent independent key resolution. */
263
+ function statusCredentialFacts({ state, deps }) {
264
+ return deps.listCredentialFacts({}).catch((err) => {
265
+ recordError({ state, error: { providerId: null, source: "check", message: `the saved mail credentials could not be listed (${err instanceof Error ? err.message : String(err)})` } });
266
+ return [];
267
+ });
268
+ }
269
+ /** Unanswered/unsupported checks produce no sender warning; only no_sender means unset. */
270
+ function statusDomainState(state) {
271
+ if (state === "no_sender")
272
+ return null;
273
+ return ["verified", "pending", "failed", "not_found"].includes(state) ? state : undefined;
274
+ }
275
+ /** Start independent reads together, then await lane keys and domain verification in that order.
276
+ * Null means no sender; an unanswered check stays undefined and must not claim a sender is missing.
277
+ * @complexity O(c + p) credential facts/providers, O(lanes) decrypts; domain work uses its own deadline. */
278
+ async function computeStatusReport(required) {
279
+ const { state, deps, senderDomainVerification } = required;
280
+ const checkedAt = new Date(state.nowMs()).toISOString();
281
+ const records = statusCredentialFacts({ state, deps });
282
+ const [setting, catalog, rows] = await Promise.all([currentSetting(state), state.deps.loadCatalog({}), records, readSenderDomain(state)]);
283
+ const credentials = await laneCredentials({ state, catalog, setting });
284
+ const verdict = await senderDomainVerification();
285
+ const domainState = statusDomainState(verdict.state);
286
+ if (verdict.state === "unknown")
287
+ recordError({ state, error: { providerId: verdict.providerId, source: "check", message: "Provider domain check unavailable." } });
288
+ const status = computeMailStatus({ setting, registry: catalog.registry, hasCredential: id => credentials.has(id) }, domainState === undefined ? {} : { senderDomainState: domainState });
289
+ const { providers } = deps.describeProviders({ catalog, records: rows, setting });
290
+ return { ...status, providers, refusals: catalog.refusals, checkedAt, senderDomainVerification: verdict };
291
+ }
292
+ /** Failed sends overlay cached status immediately; configuration alone never proves sending. */
293
+ function statusWithLastError({ state, report }) {
294
+ const sendFailed = state.lastError?.source === "send" && report.lanes.some(lane => lane.providerId === state.lastError?.providerId);
295
+ return state.lastError ? { ...report, state: sendFailed && report.state === "ready" ? "degraded" : report.state, lastError: state.lastError } : report;
296
+ }
297
+ /** Host descriptions receive fresh, parallel setting/catalog/metadata reads. Failures propagate. */
298
+ async function describeProviders({ state, deps }) {
299
+ const [setting, catalog, records] = await Promise.all([currentSetting(state), state.deps.loadCatalog({}), deps.listCredentialFacts({})]);
300
+ return deps.describeProviders({ catalog, records, setting });
301
+ }
302
+ /** Selection checks require a hosted provider and a usable key; they send nothing. */
303
+ async function checkProvider({ state, id }) {
304
+ const catalog = await state.deps.loadCatalog({});
305
+ const provider = catalog.registry.get({ id })?.provider;
306
+ if (!provider)
307
+ return { ok: false, reason: `'${id}' is not an installed mail provider (installed: ${catalog.registry.list().map(entry => entry.id).join(", ")})` };
308
+ if (!provider.capabilities.hosted)
309
+ return { ok: false, reason: `'${id}' only prints mail locally and never delivers it` };
310
+ const found = await providerCredential({ state, catalog, id });
311
+ return found.credential ? { ok: true, label: provider.label } : { ok: false, reason: `'${id}' ${found.reason}` };
312
+ }
313
+ /** Detach pending work and clear all provider/capability projections; errors retain their original precedence. */
314
+ function invalidate(state) {
315
+ state.generation += 1;
316
+ state.inFlight.clear();
317
+ state.resolved.clear();
318
+ state.lastTransactional = undefined;
319
+ }
320
+ /**
321
+ * Build a lane router and its control surface. The host binds catalogs, secrets, safe copy and DTOs.
322
+ * Each send reads selection and metadata; boot starts resolution synchronously. Selection faults
323
+ * refuse safely; status/description setup faults propagate and are never cached.
324
+ * @complexity O(providers) cached mailers/flights; status holds one report for its TTL.
325
+ * @example const control = createResolvedMailer(hostPorts, { clock }); await control.ready;
326
+ */
327
+ export function createResolvedMailer(required, optional = {}) {
328
+ const state = { deps: required, options: optional, nowMs: () => (optional.clock ?? { nowMs: () => Date.now() }).nowMs(),
329
+ warn: optional.warn ?? (message => console.warn(message)),
330
+ wrap: optional.wrapProviderMailer ?? (({ mailer }) => mailer), resolved: new Map(), inFlight: new Map(), warned: new Set(), generation: 0,
331
+ initialization: undefined, lastTransactional: undefined, lastError: undefined };
332
+ const ready = forLane({ state, lane: "transactional" }).then(() => undefined);
333
+ const mailer = createRouter(state);
334
+ const resolveDomainReadCredential = input => optional.resolveDomainReadCredential?.(input) ?? Promise.resolve(null);
335
+ const senderDomainVerification = createDomainVerification({ state, resolveDomainReadCredential });
336
+ const statusCache = createTtlCache({ load: () => computeStatusReport({ state, deps: required, senderDomainVerification }), ttlMs: optional.statusTtlMs ?? MAIL_STATUS_TTL_MS, nowMs: state.nowMs }, { minRefreshIntervalMs: 5_000 });
337
+ return {
338
+ mailer, ready, senderDomainVerification,
339
+ settle: async () => { await forLane({ state, lane: "transactional" }); },
340
+ status: async (options = {}) => statusWithLastError({ state, report: await statusCache.get(options) }),
341
+ describeProviders: () => describeProviders({ state, deps: required }),
342
+ invalidate: () => { invalidate(state); statusCache.invalidate(); },
343
+ laneReady: async ({ lane }) => (await forLane({ state, lane })).capabilities({}).driver !== "console",
344
+ checkProvider: input => checkProvider({ state, ...input }),
345
+ domainRequirements: input => domainRequirements({ state, ...input, resolveDomainReadCredential }),
346
+ };
347
+ }
348
+ //# sourceMappingURL=resolve-mailer.js.map
@@ -0,0 +1,40 @@
1
+ import type { MailAdapterCredential } from "./adapter-module.js";
2
+ import type { MailProviderModule } from "./provider.js";
3
+ import type { HttpClientPort } from "@jini-ai/core/primitives";
4
+ import type { SenderDomainVerificationPort } from "./engine-contracts.js";
5
+ import { type MailDomainReadCredentialPort } from "./domain-read.js";
6
+ /**
7
+ * @file The sender domain's state at the active transactional provider (its own `domainStatus`),
8
+ * for callers that need the provider verdict. Kept apart from the DNS
9
+ * check on purpose: a provider can report `pending` after every record resolves, and DNS can match
10
+ * while the provider was never asked. Each reason the provider cannot be asked is named apart, so
11
+ * "DNS verification unavailable for this provider" is a state of its own, not a failure.
12
+ */
13
+ export interface SenderDomainVerificationDeps {
14
+ workspaceId: string;
15
+ /** The host's guarded outbound-HTTP seam, handed through to the provider. */
16
+ httpClient: HttpClientPort;
17
+ /** The installed providers, read fresh (host catalog). */
18
+ findProvider: (required: {
19
+ id: string;
20
+ }) => Promise<MailProviderModule | undefined>;
21
+ /** The provider's saved key, or `null` when none is saved (host credential resolver). */
22
+ resolveCredential: (required: {
23
+ id: string;
24
+ }) => Promise<MailAdapterCredential | null>;
25
+ resolveDomainReadCredential?: MailDomainReadCredentialPort;
26
+ /** The configured transactional lane. */
27
+ readProviderId: () => Promise<string | null>;
28
+ /** The configured sender address. */
29
+ readSenderEmail: () => Promise<string | undefined>;
30
+ }
31
+ /**
32
+ * Build the never-throwing provider-verdict port. Lookup failures become explicit verdicts;
33
+ * the deadline covers the whole check and clears its timer without cancelling provider work.
34
+ * @complexity Per call: two settings reads, one catalog load, at most two key lookups and two provider calls; O(1) space.
35
+ * @example createSenderDomainVerification(deps)()
36
+ */
37
+ export declare function createSenderDomainVerification(required: SenderDomainVerificationDeps, options?: {
38
+ timeoutMs?: number;
39
+ }): SenderDomainVerificationPort;
40
+ //# sourceMappingURL=sender-domain-verification.d.ts.map
@@ -0,0 +1,71 @@
1
+ import { credentialSecrets, safeMailProviderText } from "./provider-error.js";
2
+ import { isDomainReadPermissionRefused, readMailDomain } from "./domain-read.js";
3
+ /** The configured sender's domain, lower-cased; undefined when unset or unreadable.
4
+ * @complexity O(n) time and space in the sender address length; one settings read. */
5
+ async function senderDomain(deps) {
6
+ const email = await deps.readSenderEmail().catch(() => undefined);
7
+ return email?.split("@")[1]?.trim().toLowerCase() || undefined;
8
+ }
9
+ /** Read domain status, trying the plugin's account on permission refusal; failures stay `unknown`.
10
+ * @complexity At most two provider reads and two key lookups; diagnostic output is capped at 1000 characters. */
11
+ async function askProvider(required) {
12
+ const { deps, provider, providerId, domain } = required;
13
+ const credential = await deps.resolveCredential({ id: providerId }).catch(() => null);
14
+ if (!credential)
15
+ return { state: "unknown", domain, providerId, reason: "the provider's key is not saved" };
16
+ // Same call context `resolve-mailer.ts` builds for a provider call: this workspace, live, its key.
17
+ const ctx = { workspaceId: deps.workspaceId, mode: "live", credential, httpClient: deps.httpClient };
18
+ const result = await readMailDomain({ ctx, providerId, read: context => provider.domainStatus(context, { domain }), ...(deps.resolveDomainReadCredential ? { resolveDomainReadCredential: deps.resolveDomainReadCredential } : {}) }).catch(() => null);
19
+ if (result?.status === "ok")
20
+ return { state: result.value.state, domain, providerId };
21
+ return { state: "unknown", domain, providerId, reason: providerReadFailureReason({ result, credential }) };
22
+ }
23
+ /** A refusal both keys hit is named plainly; any other provider answer keeps its code+message,
24
+ * secret-redacted (the sending key's exact values plus Jini's patterns) and capped.
25
+ * @complexity O(n) diagnostic text plus credential values; output is capped at 1000 characters.
26
+ */
27
+ function providerReadFailureReason(required) {
28
+ const { result, credential } = required;
29
+ if (!result)
30
+ return "the provider did not answer the domain check";
31
+ if (isDomainReadPermissionRefused({ result }))
32
+ return "the sending key can't check domains, and no connected account could check it either";
33
+ return safeMailProviderText({ text: `${result.error.code}: ${result.error.message}` }, { secrets: credentialSecrets({ credential }) });
34
+ }
35
+ /** Resolve the configured sender against its provider, independently of DNS answers.
36
+ * Settings start together; catalog and credential reads follow in their original await order.
37
+ * @complexity Two settings reads, one catalog lookup and at most two provider reads; O(1) space.
38
+ */
39
+ async function checkSenderDomain(deps) {
40
+ const [providerId, domain] = await Promise.all([deps.readProviderId().catch(() => null), senderDomain(deps)]);
41
+ if (!domain)
42
+ return { state: "no_sender", providerId };
43
+ const provider = providerId === null ? undefined : await deps.findProvider({ id: providerId }).catch(() => undefined);
44
+ if (!provider)
45
+ return { state: "no_provider", domain, providerId };
46
+ const id = providerId;
47
+ if (!provider.domainStatus)
48
+ return { state: "unsupported", domain, providerId: id };
49
+ return askProvider({ deps, provider, providerId: id, domain });
50
+ }
51
+ /**
52
+ * Build the never-throwing provider-verdict port. Lookup failures become explicit verdicts;
53
+ * the deadline covers the whole check and clears its timer without cancelling provider work.
54
+ * @complexity Per call: two settings reads, one catalog load, at most two key lookups and two provider calls; O(1) space.
55
+ * @example createSenderDomainVerification(deps)()
56
+ */
57
+ export function createSenderDomainVerification(required, options = {}) {
58
+ return async () => {
59
+ let timer;
60
+ const timeout = new Promise(resolve => {
61
+ timer = setTimeout(() => resolve({ state: "unknown", providerId: null, reason: "provider_domain_check_timed_out" }), options.timeoutMs ?? 5_000);
62
+ });
63
+ try {
64
+ return await Promise.race([checkSenderDomain(required), timeout]);
65
+ }
66
+ finally {
67
+ clearTimeout(timer);
68
+ }
69
+ };
70
+ }
71
+ //# sourceMappingURL=sender-domain-verification.js.map
@@ -0,0 +1,5 @@
1
+ /** Universal sender surface: importing it never loads SMTP, delivery policy or Node crypto. */
2
+ export * from "./email-sender.js";
3
+ export * from "./email-sender-registry.js";
4
+ export * from "./prepare-sender-email.js";
5
+ //# sourceMappingURL=senders.d.ts.map
@@ -0,0 +1,5 @@
1
+ /** Universal sender surface: importing it never loads SMTP, delivery policy or Node crypto. */
2
+ export * from "./email-sender.js";
3
+ export * from "./email-sender-registry.js";
4
+ export * from "./prepare-sender-email.js";
5
+ //# sourceMappingURL=senders.js.map
@@ -39,6 +39,8 @@ export interface MailerSendOptions {
39
39
  * distinguish them. Unmapped/absent lanes resolve to notification rather than opening the
40
40
  * interactive bypass; the caller-facing facade applies that fail-closed allowlist. */
41
41
  lane?: "interactive" | "notification";
42
+ /** Configuration facts approved by the caller, rechecked immediately before delivery. */
43
+ mailTestApproval?: MailTestApproval;
42
44
  }
43
45
  /** Serializable outcomes cross the adapter ABI; live thrown objects cannot be carried as results. */
44
46
  export type MailerSendResult = {
@@ -89,4 +91,21 @@ export type MailerSendRequired = MailerDelivery & {
89
91
  export type MailerBatchRequired = MailerDelivery & {
90
92
  messages: readonly OutboundEmail[];
91
93
  };
94
+ /** Persisted test-mode shape; capture availability is decided separately by the delivery wrapper. */
95
+ export type MailTestModeSetting = {
96
+ mode: "off";
97
+ } | {
98
+ mode: "redirect";
99
+ to: string;
100
+ } | {
101
+ mode: "capture";
102
+ };
103
+ /** Preserve field order when constructing/comparing approvals: approval bytes are caller-owned. */
104
+ export interface MailTestApproval {
105
+ providerId: string | null;
106
+ driver: string;
107
+ from: OutboundEmail["from"];
108
+ mode: MailTestModeSetting["mode"];
109
+ effectiveRecipient: string;
110
+ }
92
111
  //# sourceMappingURL=types.d.ts.map
package/dist/process.d.ts CHANGED
@@ -7,11 +7,12 @@
7
7
  * Windows `Get-CimInstance`), walk a process tree, and stop a set of PIDs with
8
8
  * SIGTERM-then-SIGKILL escalation.
9
9
  *
10
- * Depends on the `command` module for invocation construction; keeps a private
11
- * `errorCode` copy so it owns no cross-module runtime surface.
10
+ * Uses `command` for invocation construction and `shell`'s direct buffered
11
+ * executor for bounded usage samples; keeps a private `errorCode` copy.
12
12
  */
13
13
  import { type ChildProcess } from "node:child_process";
14
14
  import { type CommandInvocationRequest } from "./command.js";
15
+ import { execFileBuffered } from "./shell.js";
15
16
  export type ProcessStampShape = object;
16
17
  export type ProcessStampField<TStamp extends ProcessStampShape> = Extract<keyof TStamp, string>;
17
18
  export type ProcessStampContract<TStamp extends ProcessStampShape, TCriteria extends Partial<TStamp> = Partial<TStamp>> = {
@@ -32,6 +33,22 @@ export type ProcessSnapshot = {
32
33
  pid: number;
33
34
  ppid: number;
34
35
  };
36
+ /** Display-only usage: RSS is bytes; CPU is cumulative seconds across all cores. */
37
+ export type ProcessUsage = {
38
+ pid: number;
39
+ ppid: number;
40
+ rssBytes: number;
41
+ cpuSeconds: number;
42
+ /** Executable basename only; empty when the OS provides no name. */
43
+ name: string;
44
+ };
45
+ export type ProcessUsagePorts = {
46
+ platform?: NodeJS.Platform;
47
+ run?: typeof execFileBuffered;
48
+ };
49
+ export type ProcessUsageOptions = {
50
+ timeoutMs?: number;
51
+ };
35
52
  export type StampedProcessMatchCriteria<TStamp extends ProcessStampShape> = Partial<TStamp>;
36
53
  export type StopProcessesResult = {
37
54
  alreadyStopped: boolean;
@@ -139,6 +156,14 @@ export declare function waitForProcessExit(pid: number | null | undefined, timeo
139
156
  * @returns The current process snapshots (empty on error).
140
157
  */
141
158
  export declare function listProcessSnapshots(): Promise<ProcessSnapshot[]>;
159
+ /**
160
+ * Sample process RSS and cumulative CPU without reading command lines. Returns
161
+ * [] on command, timeout, buffer or JSON failure. RSS totals may count shared
162
+ * pages more than once; CPU percentages require deltas between samples.
163
+ * @param input - Optional host platform and bounded command-execution port.
164
+ * @param options - Execution timeout (default 5000ms); output is capped at 8MiB.
165
+ */
166
+ export declare function listProcessUsage(input?: ProcessUsagePorts, options?: ProcessUsageOptions): Promise<ProcessUsage[]>;
142
167
  /**
143
168
  * Collect the transitive set of descendant PIDs (including the roots) from a
144
169
  * process snapshot list, returned sorted by numeric PID descending for deterministic ordering.