@jini-ai/platform 0.6.12 → 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.
@@ -85,7 +85,9 @@ export function createNodeDirectoryWorkspace({ homeDir, defaultFolder, envName,
85
85
  const oldHome = legacy.state === "ok" ? legacy.value?.home : path.join(homeDir, userConfig.legacyDefaultFolder ?? defaultFolder);
86
86
  if (typeof oldHome !== "string")
87
87
  throw new Error("Legacy directory setting has an invalid home.");
88
- return [...new Set([oldHome, ...config.legacyHomes({}), home()])].map((root) => createDirectoryRegistry({ file: fileAt(path.join(root, registryName)), canonicalPath }));
88
+ // Legacy settings use the same home shorthand contract; current overrides must not redirect the import.
89
+ const expandedHome = resolveDirectoryHome({ homeDir, defaultFolder, envName, env: {}, setting: oldHome }, { cwd, ...directoryOptions });
90
+ return [...new Set([expandedHome, ...config.legacyHomes({}), home()])].map((root) => createDirectoryRegistry({ file: fileAt(path.join(root, registryName)), canonicalPath }));
89
91
  };
90
92
  const importOldCatalog = () => {
91
93
  const current = atHome();
@@ -1,60 +1,32 @@
1
1
  import nodeFs from "node:fs";
2
- /** Refusal of a malformed, denied, escaped, or over-limit file operation. */
3
- export declare class FsFilePathError extends Error {
4
- constructor({ message }: {
5
- message: string;
6
- });
7
- }
8
- export interface DenyRules {
9
- segments: ReadonlySet<string>;
10
- filenamePatterns: readonly RegExp[];
11
- }
12
- export interface GuardedReaderLimits {
13
- maxFileBytes: number;
14
- binarySniffBytes: number;
15
- maxListedFiles: number;
16
- maxWalkDepth: number;
17
- maxWalkEntries: number;
18
- }
19
- export type GuardedReaderFilesystem = Pick<typeof nodeFs, "existsSync" | "lstatSync" | "readdirSync" | "realpathSync" | "statSync" | "openSync" | "readSync" | "closeSync" | "createReadStream">;
2
+ import { type DenyRules, type GuardedFileReader, type GuardedReaderLimits } from '@jini-ai/core/filesystem';
3
+ export { FsFilePathError, type DenyRules, type GuardedFileReader, type GuardedReaderLimits } from '@jini-ai/core/filesystem';
4
+ /** Optional capabilities needed only when descriptor reads are requested. */
5
+ export type DescriptorCapabilities = Partial<Pick<typeof nodeFs, "fstatSync" | "constants">>;
6
+ export type DescriptorReadPolicy = {
7
+ /** Require exact permission bits, e.g. 0o600; no subset/superset matching. */
8
+ exactMode?: number;
9
+ /** Reject growth beyond the descriptor's initial size even below the byte cap. */
10
+ rejectGrowth?: boolean;
11
+ };
12
+ /** Additive descriptor policy in the guarded reader's existing options object. */
13
+ export type AddedReaderOptions = {
14
+ descriptorRead?: DescriptorReadPolicy;
15
+ };
16
+ export type GuardedReaderFilesystem = Pick<typeof nodeFs, "existsSync" | "lstatSync" | "readdirSync" | "realpathSync" | "statSync" | "openSync" | "readSync" | "closeSync" | "createReadStream"> & DescriptorCapabilities;
20
17
  /** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
21
18
  export declare function createNodeGuardedReaderFilesystem(_required: Record<string, never>): GuardedReaderFilesystem;
22
- export interface GuardedFileReader {
23
- resolve(required: {
24
- relativePath: string;
25
- }): string;
26
- read(required: {
27
- relativePath: string;
28
- }): {
29
- content: string;
30
- bytes: number;
31
- };
32
- readBytes(required: {
33
- relativePath: string;
34
- maxBytes: number;
35
- }): Promise<Uint8Array>;
36
- list(required: Record<string, never>, optional?: {
37
- relativePath?: string;
38
- }): {
39
- files: string[];
40
- truncated: boolean;
41
- };
42
- isDeniedFileName(required: {
43
- fileName: string;
44
- }): boolean;
45
- isDeniedPathSegment(required: {
46
- segmentName: string;
47
- }): boolean;
48
- }
49
19
  /** Contains both requested and resolved paths, including symlink aliases and nonexistent descendants.
50
20
  * Deny rules and traversal/read limits are host policy and have no implicit defaults.
21
+ * Opt-in descriptor reads fail closed without fstat/no-follow/nonblocking capabilities. No-follow
22
+ * protects the final component, not concurrent ancestor-directory replacement.
51
23
  */
52
24
  export declare function createGuardedFileReader({ rootPath, denyRules, limits, filesystem: fs }: {
53
25
  rootPath: string;
54
26
  denyRules: DenyRules;
55
27
  limits: GuardedReaderLimits;
56
28
  filesystem: GuardedReaderFilesystem;
57
- }, { excludedListingDirs }?: {
29
+ }, { excludedListingDirs, descriptorRead }?: {
58
30
  excludedListingDirs?: ReadonlySet<string>;
59
- }): GuardedFileReader;
31
+ } & AddedReaderOptions): GuardedFileReader;
60
32
  //# sourceMappingURL=guarded-reader.d.ts.map
@@ -1,22 +1,21 @@
1
1
  import nodeFs from "node:fs";
2
2
  import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
- // A distinct refusal class lets tool adapters treat rejected paths/content as actionable input
4
- // problems instead of redacting them as an internal crash.
5
- /** Refusal of a malformed, denied, escaped, or over-limit file operation. */
6
- export class FsFilePathError extends Error {
7
- constructor({ message }) { super(message); this.name = "FsFilePathError"; }
8
- }
3
+ import { FsFilePathError } from '@jini-ai/core/filesystem';
4
+ export { FsFilePathError } from '@jini-ai/core/filesystem';
9
5
  /** Native filesystem adapter; callers can replace individual operations for a virtual filesystem. */
10
6
  export function createNodeGuardedReaderFilesystem(_required) { return nodeFs; }
11
7
  /** Contains both requested and resolved paths, including symlink aliases and nonexistent descendants.
12
8
  * Deny rules and traversal/read limits are host policy and have no implicit defaults.
9
+ * Opt-in descriptor reads fail closed without fstat/no-follow/nonblocking capabilities. No-follow
10
+ * protects the final component, not concurrent ancestor-directory replacement.
13
11
  */
14
- export function createGuardedFileReader({ rootPath, denyRules, limits, filesystem: fs }, { excludedListingDirs = new Set(["node_modules", ".git", "dist"]) } = {}) {
12
+ export function createGuardedFileReader({ rootPath, denyRules, limits, filesystem: fs }, { excludedListingDirs = new Set(["node_modules", ".git", "dist"]), descriptorRead } = {}) {
15
13
  if (!rootPath)
16
14
  throw new TypeError("rootPath is required");
17
15
  for (const value of Object.values(limits))
18
16
  if (!Number.isSafeInteger(value) || value <= 0)
19
17
  throw new TypeError("reader limits must be positive safe integers");
18
+ const descriptorFlags = descriptorRead === undefined ? undefined : descriptorOpenFlags({ filesystem: fs, policy: descriptorRead });
20
19
  const { existsSync, lstatSync, readdirSync, realpathSync, statSync } = fs;
21
20
  const MAX_FS_FILE_BYTES = limits.maxFileBytes, BINARY_SNIFF_BYTES = limits.binarySniffBytes;
22
21
  const MAX_LISTED_FILES = limits.maxListedFiles, MAX_WALK_DEPTH = limits.maxWalkDepth, MAX_WALK_ENTRIES = limits.maxWalkEntries;
@@ -250,9 +249,11 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
250
249
  }
251
250
  // Read one byte past the cap so a file that grew after stat is refused rather than clipped.
252
251
  function readBoundedSync(target, relativePath) {
253
- const fd = fs.openSync(target, "r");
252
+ const { fd, maxBytes } = descriptorFlags === undefined
253
+ ? { fd: fs.openSync(target, "r"), maxBytes: MAX_FS_FILE_BYTES }
254
+ : openValidatedDescriptor({ target, relativePath, maxBytes: MAX_FS_FILE_BYTES });
254
255
  try {
255
- const bounded = Buffer.alloc(MAX_FS_FILE_BYTES + 1);
256
+ const bounded = Buffer.alloc(maxBytes + 1);
256
257
  let count = 0;
257
258
  while (count < bounded.length) {
258
259
  const read = fs.readSync(fd, bounded, count, bounded.length - count, null);
@@ -260,14 +261,30 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
260
261
  break;
261
262
  count += read;
262
263
  }
263
- if (count > MAX_FS_FILE_BYTES)
264
- throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${MAX_FS_FILE_BYTES}-byte readable limit` });
264
+ if (count > maxBytes)
265
+ throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${maxBytes}-byte readable limit` });
265
266
  return bounded.subarray(0, count);
266
267
  }
267
268
  finally {
268
269
  fs.closeSync(fd);
269
270
  }
270
271
  }
272
+ /** Owns the descriptor until validated; every refusal closes it before propagating. */
273
+ function openValidatedDescriptor({ target, relativePath, maxBytes }) {
274
+ const fd = openNoFollow({ target, relativePath });
275
+ try {
276
+ const stat = fs.fstatSync(fd);
277
+ assertDescriptorStat({ stat, relativePath, maxBytes }, descriptorRead);
278
+ return { fd, maxBytes: descriptorRead?.rejectGrowth ? Math.min(maxBytes, stat.size) : maxBytes };
279
+ }
280
+ catch (error) {
281
+ try {
282
+ fs.closeSync(fd);
283
+ }
284
+ catch { /* Preserve the validation error. */ }
285
+ throw error;
286
+ }
287
+ }
271
288
  // A stat-only size check can be bypassed by a growing file: bound the stream independently and
272
289
  // reject rather than return a clipped image/video. Destroy the stream on success or failure.
273
290
  async function openFsFileForRead(required) {
@@ -283,10 +300,11 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
283
300
  return readBoundedStream(target, { relativePath, maxBytes });
284
301
  }
285
302
  async function readBoundedStream(target, { relativePath, maxBytes }) {
286
- const stream = fs.createReadStream(target, { highWaterMark: Math.min(64 * 1024, maxBytes + 1) });
303
+ const opened = createBoundedStream({ target, relativePath, maxBytes });
304
+ const { stream } = opened;
287
305
  try {
288
306
  // Own refusals pass through; any other stream failure becomes this reader's refusal class.
289
- return await collectBoundedChunks(stream, { relativePath, maxBytes }).catch((error) => Promise.reject(error instanceof FsFilePathError
307
+ return await collectBoundedChunks(stream, { relativePath, maxBytes: opened.maxBytes }).catch((error) => Promise.reject(error instanceof FsFilePathError
290
308
  ? error
291
309
  : new FsFilePathError({ message: `path '${relativePath}' could not be read: ${errorText(error)}` })));
292
310
  }
@@ -294,6 +312,36 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
294
312
  stream.destroy();
295
313
  }
296
314
  }
315
+ /** O_NOFOLLOW refusals (ELOOP) and other open failures carry absolute host paths; surface only
316
+ * the caller's relative path, like every other refusal from this reader. */
317
+ function openNoFollow({ target, relativePath }) {
318
+ try {
319
+ return fs.openSync(target, descriptorFlags);
320
+ }
321
+ catch {
322
+ throw new FsFilePathError({ message: `path '${relativePath}' is a symbolic link or could not be opened` });
323
+ }
324
+ }
325
+ /** Transfer ownership to the stream only after construction; it alone closes on end/error. */
326
+ function createBoundedStream({ target, relativePath, maxBytes }) {
327
+ const highWaterMark = Math.min(64 * 1024, maxBytes + 1);
328
+ if (descriptorFlags === undefined)
329
+ return { stream: fs.createReadStream(target, { highWaterMark }), maxBytes };
330
+ const opened = openValidatedDescriptor({ target, relativePath, maxBytes });
331
+ try {
332
+ return {
333
+ stream: fs.createReadStream(target, { fd: opened.fd, autoClose: true, highWaterMark: Math.min(highWaterMark, opened.maxBytes + 1) }),
334
+ maxBytes: opened.maxBytes,
335
+ };
336
+ }
337
+ catch (error) {
338
+ try {
339
+ fs.closeSync(opened.fd);
340
+ }
341
+ catch { /* Preserve the construction error. */ }
342
+ throw error;
343
+ }
344
+ }
297
345
  return {
298
346
  resolve: ({ relativePath }) => resolveFsFilePath({ rootPath, relativePath }),
299
347
  read: ({ relativePath }) => readFsFile({ rootPath, relativePath }),
@@ -303,6 +351,27 @@ export function createGuardedFileReader({ rootPath, denyRules, limits, filesyste
303
351
  isDeniedPathSegment: ({ segmentName }) => isDeniedFsPathSegment(segmentName),
304
352
  };
305
353
  }
354
+ /** Validate opt-in capability support without weakening legacy filesystem adapters. */
355
+ function descriptorOpenFlags({ filesystem, policy }) {
356
+ if (!filesystem.fstatSync || !filesystem.constants)
357
+ throw new TypeError("descriptorRead requires fstatSync and filesystem constants");
358
+ const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = filesystem.constants;
359
+ if (!O_NOFOLLOW || !O_NONBLOCK || !Number.isInteger(O_RDONLY))
360
+ throw new TypeError("descriptorRead requires supported O_RDONLY, O_NOFOLLOW and O_NONBLOCK flags");
361
+ if (policy.exactMode !== undefined && (!Number.isInteger(policy.exactMode) || policy.exactMode < 0 || policy.exactMode > 0o7777))
362
+ throw new TypeError("exactMode must contain only permission bits (0o0000 through 0o7777)");
363
+ return O_RDONLY | O_NOFOLLOW | O_NONBLOCK;
364
+ }
365
+ /** Both read paths validate the opened object, independent of earlier pathname metadata. */
366
+ function assertDescriptorStat({ stat, relativePath, maxBytes }, { exactMode } = {}) {
367
+ if (!stat.isFile())
368
+ throw new FsFilePathError({ message: `path '${relativePath}' is not a regular file` });
369
+ if (!Number.isSafeInteger(stat.size) || stat.size < 0 || stat.size > maxBytes)
370
+ throw new FsFilePathError({ message: `file '${relativePath}' exceeds the ${maxBytes}-byte descriptor limit` });
371
+ // Include setuid/setgid/sticky: "exact" 0o600 must refuse a 0o4600 file.
372
+ if (exactMode !== undefined && (stat.mode & 0o7777) !== exactMode)
373
+ throw new FsFilePathError({ message: `file '${relativePath}' does not have exact mode ${exactMode.toString(8)}` });
374
+ }
306
375
  function errorText(error) {
307
376
  return error instanceof Error ? error.message : String(error);
308
377
  }
@@ -0,0 +1,15 @@
1
+ import { type ProviderRegistry } from "@jini-ai/core/provider-registry";
2
+ import { type EmailSender, type EmailSenderContext, type EmailSourceSummary } from "./email-sender.js";
3
+ /** Shares the existing keyed-registry admission rules; no plugin contribution category is added. */
4
+ export type EmailSenderRegistry = ProviderRegistry<EmailSender>;
5
+ /** Creates an isolated registry, throwing on invalid ids or duplicate built-in senders.
6
+ * @complexity O(s) registrations for s senders; keyed lookup remains O(1). */
7
+ export declare function createEmailSenderRegistry(required: {
8
+ readonly senders: readonly EmailSender[];
9
+ }, _optional?: Record<string, never>): EmailSenderRegistry;
10
+ /** Lists configurable events across all registered senders, with each sender enforcing access.
11
+ * Refuses a sender claiming another sender's sources. @complexity One list call per sender. */
12
+ export declare function listEmailSources(required: EmailSenderContext & {
13
+ readonly registry: EmailSenderRegistry;
14
+ }, _optional?: Record<string, never>): Promise<readonly EmailSourceSummary[]>;
15
+ //# sourceMappingURL=email-sender-registry.d.ts.map
@@ -0,0 +1,22 @@
1
+ import { createProviderRegistry } from "@jini-ai/core/provider-registry";
2
+ import { EmailSenderError } from "./email-sender.js";
3
+ /** Creates an isolated registry, throwing on invalid ids or duplicate built-in senders.
4
+ * @complexity O(s) registrations for s senders; keyed lookup remains O(1). */
5
+ export function createEmailSenderRegistry(required, _optional = {}) {
6
+ return createProviderRegistry({ contract: "email_sender" }, {
7
+ seed: required.senders.map(sender => ({ id: sender.id, label: sender.label, provider: sender })),
8
+ });
9
+ }
10
+ /** Lists configurable events across all registered senders, with each sender enforcing access.
11
+ * Refuses a sender claiming another sender's sources. @complexity One list call per sender. */
12
+ export async function listEmailSources(required, _optional = {}) {
13
+ const sources = [];
14
+ for (const { id, provider } of required.registry.list({})) {
15
+ const listed = await provider.listSources({ workspaceId: required.workspaceId, principalId: required.principalId });
16
+ if (listed.some(source => source.senderId !== id))
17
+ throw new EmailSenderError({ code: "ownership" });
18
+ sources.push(...listed);
19
+ }
20
+ return sources;
21
+ }
22
+ //# sourceMappingURL=email-sender-registry.js.map
@@ -0,0 +1,109 @@
1
+ /**
2
+ * MOVE: Event data and recipient authority belong to the originating feature, independently of
3
+ * delivery providers. Hosts bind authorization, templates and persistence; this surface sends nothing.
4
+ */
5
+ import type { EmailAddress, MailerSendOptions } from "./types.js";
6
+ /** Explicit caller scope, including for background jobs running as a host service principal. */
7
+ export interface EmailSenderContext {
8
+ readonly workspaceId: string;
9
+ readonly principalId: string;
10
+ }
11
+ /** A concrete event source; the same event name may occur for several sources or senders. */
12
+ export interface EmailSourceRef {
13
+ readonly senderId: string;
14
+ readonly sourceId: string;
15
+ }
16
+ export interface EmailSourceSummary extends EmailSourceRef {
17
+ readonly label: string;
18
+ readonly eventName: string;
19
+ }
20
+ /** Token is the exact placeholder name, without braces. Labels are presentation only. */
21
+ export interface EmailSourceField {
22
+ readonly token: string;
23
+ readonly label: string;
24
+ readonly required: boolean;
25
+ }
26
+ /** Recipient ids are source-owned choices, never caller-supplied addresses or expressions. */
27
+ export interface EmailRecipientChoice {
28
+ readonly id: string;
29
+ readonly label: string;
30
+ }
31
+ export interface EmailReadiness {
32
+ readonly ready: boolean;
33
+ readonly blockers: readonly string[];
34
+ }
35
+ /** Persisted by the sender's existing write owner, never by a template-side relationship. */
36
+ export interface EmailBindingInput {
37
+ readonly bindingId: string;
38
+ readonly enabled: boolean;
39
+ readonly templateId: string;
40
+ readonly recipientId: string;
41
+ readonly replyTo: string;
42
+ }
43
+ export interface EmailBinding extends EmailBindingInput {
44
+ readonly activatedAt: string | null;
45
+ }
46
+ export interface EmailSourceDescription extends EmailSourceSummary {
47
+ readonly fields: readonly EmailSourceField[];
48
+ readonly permittedRecipients: readonly EmailRecipientChoice[];
49
+ readonly purpose: NonNullable<MailerSendOptions["purpose"]>;
50
+ readonly lane: NonNullable<MailerSendOptions["lane"]>;
51
+ readonly binding: EmailBinding | null;
52
+ readonly readiness: EmailReadiness;
53
+ }
54
+ /** Only allowlisted event values and recipients cross this boundary; no stored event row. */
55
+ export interface ResolvedEmailEvent {
56
+ /** Authoritative ISO event time, compared with the binding's latest activation to prevent backfill. */
57
+ readonly occurredAt: string;
58
+ readonly values: Readonly<Record<string, string>>;
59
+ readonly recipients: readonly {
60
+ readonly id: string;
61
+ readonly address: EmailAddress;
62
+ }[];
63
+ }
64
+ export type EmailSourceRequest = EmailSenderContext & {
65
+ readonly sourceId: string;
66
+ };
67
+ /** Every operation must authorize its own scope. Resolution also verifies event/source ownership.
68
+ * No example-listing API: hosts may offer an authorized latest submission or a labeled sample. */
69
+ export interface EmailSender {
70
+ readonly id: string;
71
+ readonly label: string;
72
+ listSources(required: EmailSenderContext, optional?: Record<string, never>): Promise<readonly EmailSourceSummary[]>;
73
+ describeSource(required: EmailSourceRequest, optional?: Record<string, never>): Promise<EmailSourceDescription>;
74
+ resolveEvent(required: EmailSourceRequest & {
75
+ readonly eventId: string;
76
+ }, optional?: Record<string, never>): Promise<ResolvedEmailEvent>;
77
+ configureEmail(required: EmailSourceRequest & {
78
+ readonly binding: EmailBindingInput;
79
+ }, optional?: Record<string, never>): Promise<EmailBinding>;
80
+ }
81
+ /** Describe must report all placeholders, including those absent from the template declarations.
82
+ * Render must use send-safe rendering, supplied values only, and refuse a changed version. */
83
+ export interface EmailTemplatePort {
84
+ describe(required: EmailSenderContext & {
85
+ readonly templateId: string;
86
+ }, optional?: Record<string, never>): Promise<{
87
+ readonly version: string;
88
+ readonly fields: readonly string[];
89
+ readonly placeholders: readonly string[];
90
+ }>;
91
+ render(required: EmailSenderContext & {
92
+ readonly templateId: string;
93
+ readonly version: string;
94
+ readonly values: Readonly<Record<string, string>>;
95
+ }, optional?: Record<string, never>): Promise<{
96
+ readonly subject: string;
97
+ readonly html: string;
98
+ readonly text: string;
99
+ readonly missingFields: readonly string[];
100
+ }>;
101
+ }
102
+ /** Caller-safe failures never include event values, recipients or credentials. */
103
+ export declare class EmailSenderError extends Error {
104
+ readonly code: "forbidden" | "not_found" | "ownership" | "configuration" | "not_ready" | "incompatible" | "missing_value";
105
+ constructor(required: {
106
+ readonly code: EmailSenderError["code"];
107
+ }, _optional?: Record<string, never>);
108
+ }
109
+ //# sourceMappingURL=email-sender.d.ts.map
@@ -0,0 +1,10 @@
1
+ /** Caller-safe failures never include event values, recipients or credentials. */
2
+ export class EmailSenderError extends Error {
3
+ code;
4
+ constructor(required, _optional = {}) {
5
+ super(`Email sender: ${required.code}`);
6
+ this.code = required.code;
7
+ this.name = "EmailSenderError";
8
+ }
9
+ }
10
+ //# sourceMappingURL=email-sender.js.map
@@ -1,8 +1,9 @@
1
1
  export type * from "./types.js";
2
2
  export type * from "./ports.js";
3
3
  export type * from "./adapter-module.js";
4
- export { wrapMailerWithPurposeGate } from "./purpose-scoped-mailer.js";
5
- export type { WrapMailerWithPurposeGateOptions } from "./purpose-scoped-mailer.js";
4
+ export type * from "./engine-contracts.js";
5
+ export { wrapMailerWithPurposeGate, isPurposeGateOpen } from "./purpose-scoped-mailer.js";
6
+ export type { WrapMailerWithPurposeGateOptions, PurposeGateRequired } from "./purpose-scoped-mailer.js";
6
7
  export { isMailDeliveryAvailable } from "./delivery-availability.js";
7
8
  export { createMailSenderIdentityResolver, NON_DELIVERING_MAIL_DRIVERS, SenderIdentityNotConfiguredError } from "./sender-identity.js";
8
9
  export type { ConfiguredMailSender, CreateMailSenderIdentityResolverOptional, MailSenderIdentityPort } from "./sender-identity.js";
@@ -15,4 +16,16 @@ export type { MailDeliveryPolicy, MailDeliveryPolicyDeps, MailDeliveryPolicyOpti
15
16
  export { createMailFeedbackIngest, suppressionScopeFor } from "./feedback.js";
16
17
  export type { MailFeedbackInboxPort, MailFeedbackIngestDeps, MailFeedbackIngestOptions, MailFeedbackResponse } from "./feedback.js";
17
18
  export { createSmtpMailProvider, createConsoleMailProvider, parseSmtpEndpoint } from "./builtin-providers.js";
19
+ export { safeMailProviderText, credentialSecrets } from "./provider-error.js";
20
+ export { readMailDomain, isDomainReadPermissionRefused } from "./domain-read.js";
21
+ export type { MailDomainReadCredentialPort } from "./domain-read.js";
22
+ export { createSenderDomainVerification } from "./sender-domain-verification.js";
23
+ export type { SenderDomainVerificationDeps } from "./sender-domain-verification.js";
24
+ export { createMailPolicyWrapper } from "./delivery-wrapper.js";
25
+ export type { MailPolicyWrapperDeps, MailPolicyWrapperOptions, MailPolicyWrapperInput } from "./delivery-wrapper.js";
26
+ export { createResolvedMailer } from "./resolve-mailer.js";
27
+ export type { ResolveMailerDeps, ResolveMailerOptions, ResolvedMailer } from "./resolve-mailer.js";
28
+ export { createTtlCache, MAIL_STATUS_TTL_MS } from "./mail-status-cache.js";
29
+ export type { TtlCache } from "./mail-status-cache.js";
30
+ export * from "./senders.js";
18
31
  //# sourceMappingURL=index.d.ts.map
@@ -1,4 +1,4 @@
1
- export { wrapMailerWithPurposeGate } from "./purpose-scoped-mailer.js";
1
+ export { wrapMailerWithPurposeGate, isPurposeGateOpen } from "./purpose-scoped-mailer.js";
2
2
  export { isMailDeliveryAvailable } from "./delivery-availability.js";
3
3
  export { createMailSenderIdentityResolver, NON_DELIVERING_MAIL_DRIVERS, SenderIdentityNotConfiguredError } from "./sender-identity.js";
4
4
  export { createMailProviderRegistry, validateMailProvider, initialMailProviderSetting, computeMailStatus, MAIL_PROVIDER_SETTING_KEY } from "./provider.js";
@@ -6,4 +6,11 @@ export { createMailLaneLimiter } from "./rate-limit.js";
6
6
  export { createMailDeliveryPolicy, createPolicyMailer, laneForPurpose, hasOneClickUnsubscribe, mailPayloadHash, MAIL_RATE_LIMITED, isMailRateLimited } from "./policy.js";
7
7
  export { createMailFeedbackIngest, suppressionScopeFor } from "./feedback.js";
8
8
  export { createSmtpMailProvider, createConsoleMailProvider, parseSmtpEndpoint } from "./builtin-providers.js";
9
+ export { safeMailProviderText, credentialSecrets } from "./provider-error.js";
10
+ export { readMailDomain, isDomainReadPermissionRefused } from "./domain-read.js";
11
+ export { createSenderDomainVerification } from "./sender-domain-verification.js";
12
+ export { createMailPolicyWrapper } from "./delivery-wrapper.js";
13
+ export { createResolvedMailer } from "./resolve-mailer.js";
14
+ export { createTtlCache, MAIL_STATUS_TTL_MS } from "./mail-status-cache.js";
15
+ export * from "./senders.js";
9
16
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @file A short-TTL, single-flight cache for the mail status report.
3
+ * Building the report can call the provider (domain status) and decrypt keys, which took 4–9 s per
4
+ * admin page load; within the TTL every reader shares one result. `refresh` requests a fresh build,
5
+ * subject to the optional minimum refresh interval.
6
+ * `invalidate` drops the cached one (a provider switch or a key save). A failed build is never
7
+ * cached, and a build that started before an invalidation is not stored over it.
8
+ */
9
+ /** Default freshness window for mail status. */
10
+ export declare const MAIL_STATUS_TTL_MS = 30000;
11
+ export interface TtlCache<T> {
12
+ get(optional?: {
13
+ refresh?: boolean;
14
+ }): Promise<T>;
15
+ invalidate(): void;
16
+ }
17
+ /**
18
+ * @param required.load - Builds a fresh value; its rejection reaches every concurrent caller.
19
+ * @param required.ttlMs - How long a built value is served.
20
+ * @param required.nowMs - Injected clock.
21
+ * @param optional.minRefreshIntervalMs - Ignore forced refresh inside this interval after a build
22
+ * starts; zero preserves unrestricted superseding refreshes. Invalidation resets the interval.
23
+ * @complexity O(1) per call; ordinary reads share one pending load. Explicit refresh may supersede it.
24
+ * @example const cache = createTtlCache({ load, ttlMs: MAIL_STATUS_TTL_MS, nowMs }); await cache.get();
25
+ */
26
+ export declare function createTtlCache<T>(required: {
27
+ load: () => Promise<T>;
28
+ ttlMs: number;
29
+ nowMs: () => number;
30
+ }, optional?: {
31
+ minRefreshIntervalMs?: number;
32
+ }): TtlCache<T>;
33
+ //# sourceMappingURL=mail-status-cache.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @file A short-TTL, single-flight cache for the mail status report.
3
+ * Building the report can call the provider (domain status) and decrypt keys, which took 4–9 s per
4
+ * admin page load; within the TTL every reader shares one result. `refresh` requests a fresh build,
5
+ * subject to the optional minimum refresh interval.
6
+ * `invalidate` drops the cached one (a provider switch or a key save). A failed build is never
7
+ * cached, and a build that started before an invalidation is not stored over it.
8
+ */
9
+ /** Default freshness window for mail status. */
10
+ export const MAIL_STATUS_TTL_MS = 30_000;
11
+ /**
12
+ * @param required.load - Builds a fresh value; its rejection reaches every concurrent caller.
13
+ * @param required.ttlMs - How long a built value is served.
14
+ * @param required.nowMs - Injected clock.
15
+ * @param optional.minRefreshIntervalMs - Ignore forced refresh inside this interval after a build
16
+ * starts; zero preserves unrestricted superseding refreshes. Invalidation resets the interval.
17
+ * @complexity O(1) per call; ordinary reads share one pending load. Explicit refresh may supersede it.
18
+ * @example const cache = createTtlCache({ load, ttlMs: MAIL_STATUS_TTL_MS, nowMs }); await cache.get();
19
+ */
20
+ export function createTtlCache(required, optional = {}) {
21
+ let cached;
22
+ let inFlight;
23
+ let generation = 0;
24
+ let lastBuildAt = -Infinity;
25
+ const start = () => {
26
+ lastBuildAt = required.nowMs();
27
+ const startedIn = generation;
28
+ const started = required.load().then((value) => {
29
+ if (startedIn === generation)
30
+ cached = { value, expiresAt: required.nowMs() + required.ttlMs };
31
+ return value;
32
+ }).finally(() => { if (inFlight === started)
33
+ inFlight = undefined; });
34
+ inFlight = started;
35
+ return started;
36
+ };
37
+ const minRefreshIntervalMs = optional.minRefreshIntervalMs ?? 0;
38
+ return {
39
+ get: async (optional = {}) => {
40
+ if (optional.refresh && required.nowMs() - lastBuildAt >= minRefreshIntervalMs) {
41
+ generation += 1;
42
+ return start();
43
+ }
44
+ if (cached && required.nowMs() < cached.expiresAt)
45
+ return cached.value;
46
+ return inFlight ?? start();
47
+ },
48
+ invalidate: () => {
49
+ generation += 1;
50
+ cached = undefined;
51
+ inFlight = undefined;
52
+ lastBuildAt = -Infinity;
53
+ },
54
+ };
55
+ }
56
+ //# sourceMappingURL=mail-status-cache.js.map
@@ -0,0 +1,35 @@
1
+ import { type EmailSender, type EmailSourceDescription, type EmailSourceField, type EmailSourceRequest, type EmailTemplatePort } from "./email-sender.js";
2
+ import type { EmailAddress } from "./types.js";
3
+ /** Refuses unsupported declarations and undeclared placeholders before activation or rendering.
4
+ * @complexity O(f + d + p) source fields, declarations and placeholders; no I/O. */
5
+ export declare function validateEmailSourceTemplate(required: {
6
+ readonly fields: readonly EmailSourceField[];
7
+ readonly template: {
8
+ readonly fields: readonly string[];
9
+ readonly placeholders: readonly string[];
10
+ };
11
+ }, _optional?: Record<string, never>): void;
12
+ /** A preparation result is suitable for freezing in the sender's existing job, never a new queue. */
13
+ export interface PreparedSenderEmail {
14
+ readonly bindingId: string;
15
+ readonly templateId: string;
16
+ readonly templateVersion: string;
17
+ readonly to: EmailAddress;
18
+ readonly replyTo: EmailAddress;
19
+ readonly subject: string;
20
+ readonly html: string;
21
+ readonly text: string;
22
+ readonly purpose: EmailSourceDescription["purpose"];
23
+ readonly lane: EmailSourceDescription["lane"];
24
+ readonly warnings: readonly string[];
25
+ }
26
+ /** Resolves authoritative event data and the saved binding, then calls the existing renderer.
27
+ * No preview samples, envelope overrides or delivery effects enter this operation.
28
+ * @throws EmailSenderError for ownership, inactive/pre-activation events, readiness, compatibility or missing data; port errors propagate.
29
+ * @complexity O(fields + declarations + placeholders); one describe, resolve and render per event. */
30
+ export declare function prepareSenderEmail(required: EmailSourceRequest & {
31
+ readonly sender: EmailSender;
32
+ readonly templates: EmailTemplatePort;
33
+ readonly eventId: string;
34
+ }, _optional?: Record<string, never>): Promise<PreparedSenderEmail>;
35
+ //# sourceMappingURL=prepare-sender-email.d.ts.map