@jini-ai/platform 0.5.8 → 0.5.10
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 +12 -0
- package/dist/asset-cache.js +26 -25
- package/dist/blob-storage.d.ts +7 -2
- package/dist/blob-storage.js +99 -59
- package/dist/child-environment.d.ts +36 -0
- package/dist/child-environment.js +68 -0
- package/dist/download.js +192 -148
- package/dist/fs/atomic-write.d.ts +25 -2
- package/dist/fs/atomic-write.js +162 -111
- package/dist/fs/boot-identity.d.ts +16 -0
- package/dist/fs/boot-identity.js +38 -17
- package/dist/fs/directory-home.js +6 -0
- package/dist/fs/directory-registry.d.ts +1 -1
- package/dist/fs/directory-registry.js +23 -16
- package/dist/fs/directory-running-lock.d.ts +1 -1
- package/dist/fs/directory-running-lock.js +18 -7
- package/dist/fs/directory-sync.d.ts +1 -1
- package/dist/fs/directory-sync.js +64 -51
- package/dist/fs/directory-user-config.js +108 -67
- package/dist/fs/directory-workspace.d.ts +11 -0
- package/dist/fs/directory-workspace.js +16 -11
- package/dist/fs/durable-json.d.ts +3 -1
- package/dist/fs/durable-json.js +55 -40
- package/dist/fs/file-lock.js +214 -149
- package/dist/fs/guarded-reader.js +47 -35
- package/dist/fs/user-data-directories.d.ts +1 -1
- package/dist/fs/user-data-directories.js +25 -9
- package/dist/fs/verified-directory-move.d.ts +14 -11
- package/dist/fs/verified-directory-move.js +106 -80
- package/dist/fs/verified-file-relocation.d.ts +2 -1
- package/dist/fs/verified-file-relocation.js +112 -100
- package/dist/http/guarded/client.d.ts +3 -4
- package/dist/http/guarded/client.js +50 -60
- package/dist/http/guarded/fetch-adapter.d.ts +2 -3
- package/dist/http/guarded/fetch-adapter.js +26 -15
- package/dist/mail/builtin-providers.d.ts +39 -0
- package/dist/mail/builtin-providers.js +60 -0
- package/dist/mail/feedback.d.ts +84 -0
- package/dist/mail/feedback.js +90 -0
- package/dist/mail/index.d.ts +11 -0
- package/dist/mail/index.js +6 -0
- package/dist/mail/policy.d.ts +77 -0
- package/dist/mail/policy.js +155 -0
- package/dist/mail/ports.d.ts +56 -7
- package/dist/mail/provider.d.ts +117 -0
- package/dist/mail/provider.js +74 -0
- package/dist/mail/rate-limit.d.ts +33 -0
- package/dist/mail/rate-limit.js +35 -0
- package/dist/mail/sender-identity.d.ts +68 -0
- package/dist/mail/sender-identity.js +50 -0
- package/dist/mail/types.d.ts +9 -0
- package/dist/net/address.d.ts +9 -23
- package/dist/net/address.js +28 -133
- package/dist/net/endpoint-policy.d.ts +26 -2
- package/dist/net/endpoint-policy.js +159 -56
- package/dist/object-store/index.d.ts +2 -0
- package/dist/object-store/index.js +2 -0
- package/dist/object-store/local-fs.d.ts +17 -0
- package/dist/object-store/local-fs.js +146 -0
- package/dist/process.js +2 -0
- package/dist/proxy-env.js +131 -135
- package/dist/secrets/credential-sets/contracts.d.ts +1 -0
- package/dist/secrets/credential-sets/contracts.js +1 -0
- package/dist/secrets/credential-sets/index.d.ts +1 -0
- package/dist/secrets/credential-sets/index.js +1 -0
- package/dist/secrets/credential-sets/provider-matches.d.ts +61 -0
- package/dist/secrets/credential-sets/provider-matches.js +61 -0
- package/dist/secrets/credential-sets/types.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.meta.js +58 -20
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.repo.memory.js +43 -36
- package/dist/secrets/credential-sets/vault.service.d.ts +9 -0
- package/dist/secrets/credential-sets/vault.service.js +131 -61
- package/dist/secrets/credential-sets/vault.types.d.ts +11 -3
- package/dist/secrets/credential-sets/vault.types.js +22 -8
- package/dist/secrets/keyring.env.js +14 -10
- package/dist/toolchain.js +119 -91
- package/dist/web/html-document.js +73 -69
- package/dist/web/html-to-markdown.js +127 -102
- package/dist/web/web-page.d.ts +1 -1
- package/dist/web/web-page.js +162 -81
- package/package.json +11 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Delivery policy in front of any mail provider: one place that refuses, dedups, rate-limits,
|
|
3
|
+
* and records outcomes, so no provider adapter has to re-implement (or forget) these rules.
|
|
4
|
+
*
|
|
5
|
+
* Order per recipient: attachments → bulk unsubscribe headers → suppression → test mode → rate
|
|
6
|
+
* limit → ledger reserve → send → ledger mark. Refusals before the reserve leave no ledger row, so a
|
|
7
|
+
* corrected retry under the same idempotency key is not mistaken for a duplicate.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
/**
|
|
11
|
+
* The one throttling code of the mail contract: the local token bucket's refusal and a provider's own
|
|
12
|
+
* throttling (e.g. an HTTP 429, which adapters normalize to it) are the same retryable back-pressure,
|
|
13
|
+
* with `retryAfterMs` as the hint. Callers defer such a recipient instead of recording a failure.
|
|
14
|
+
*/
|
|
15
|
+
export const MAIL_RATE_LIMITED = "MAIL_RATE_LIMITED";
|
|
16
|
+
/** Whether a send result is the typed rate-limited refusal. @complexity O(1). */
|
|
17
|
+
export function isMailRateLimited({ result }) {
|
|
18
|
+
return !result.ok && result.errorCode === MAIL_RATE_LIMITED;
|
|
19
|
+
}
|
|
20
|
+
/** Bulk lane only for `purpose: "bulk"`; everything else (incl. `feature:*`) is transactional. */
|
|
21
|
+
export function laneForPurpose({ purpose }) {
|
|
22
|
+
return purpose === "bulk" ? "bulk" : "transactional";
|
|
23
|
+
}
|
|
24
|
+
/** RFC 8058 one-click needs both headers; header names are case-insensitive. */
|
|
25
|
+
export function hasOneClickUnsubscribe({ headers }) {
|
|
26
|
+
const names = new Set(Object.keys(headers ?? {}).map((name) => name.toLowerCase()));
|
|
27
|
+
return names.has("list-unsubscribe") && names.has("list-unsubscribe-post");
|
|
28
|
+
}
|
|
29
|
+
function sha256(value) {
|
|
30
|
+
return createHash("sha256").update(value).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
function canonicalAddress(address) {
|
|
33
|
+
return address === undefined ? null : [address.email, address.name ?? null];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Stable per-recipient payload hash: a reused key with different content is a caller bug. It covers
|
|
37
|
+
* everything the provider delivers (addresses with display names, reply-to, subject, bodies, headers,
|
|
38
|
+
* attachment names, types and bytes), so a changed message under a sent key is a conflict rather than
|
|
39
|
+
* the earlier message's success. Attachment bytes enter as their own sha256 to keep the input small.
|
|
40
|
+
* @complexity O(message size).
|
|
41
|
+
*/
|
|
42
|
+
export function mailPayloadHash({ message }) {
|
|
43
|
+
const { subject, html, text, headers, from, to, replyTo, attachments } = message;
|
|
44
|
+
const files = (attachments ?? []).map((a) => [a.filename, a.contentType, sha256(a.contentBase64)]);
|
|
45
|
+
return sha256(JSON.stringify([canonicalAddress(from), canonicalAddress(to), canonicalAddress(replyTo), subject, html ?? null, text ?? null, Object.entries(headers ?? {}).sort(), files]));
|
|
46
|
+
}
|
|
47
|
+
function refuse(errorCode, message, extra = {}) {
|
|
48
|
+
return { ok: false, retryable: extra.retryable ?? false, errorCode, message, ...(extra.retryAfterMs === undefined ? {} : { retryAfterMs: extra.retryAfterMs }), ...(extra.unknown ? { unknown: true } : {}) };
|
|
49
|
+
}
|
|
50
|
+
/** Pre-dispatch refusals that never touch the ledger. @complexity O(headers). */
|
|
51
|
+
async function preflight(required) {
|
|
52
|
+
const { message, options, lane, deps } = required;
|
|
53
|
+
if ((message.attachments?.length ?? 0) > 0 && !deps.supportsAttachments) {
|
|
54
|
+
return refuse("MAIL_ATTACHMENTS_UNSUPPORTED", `provider ${deps.providerId} cannot send attachments`);
|
|
55
|
+
}
|
|
56
|
+
if (lane === "bulk" && !hasOneClickUnsubscribe({ headers: message.headers })) {
|
|
57
|
+
return refuse("MAIL_BULK_UNSUBSCRIBE_REQUIRED", "bulk mail needs List-Unsubscribe and List-Unsubscribe-Post (RFC 8058) headers");
|
|
58
|
+
}
|
|
59
|
+
const normalizedAddress = message.to.email.trim().toLowerCase();
|
|
60
|
+
if (await deps.suppression.isSuppressed({ workspaceId: options.workspaceId, normalizedAddress, lane })) {
|
|
61
|
+
return refuse("MAIL_RECIPIENT_SUPPRESSED", `recipient is suppressed for ${lane} mail`);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Maps an existing ledger row to the result a duplicate delivery returns. A resendable `unknown` row
|
|
67
|
+
* never gets here: the ledger reclaims it atomically in `reserve` (`reclaimUnknown`), so only the
|
|
68
|
+
* one caller that won that claim re-sends.
|
|
69
|
+
*/
|
|
70
|
+
function existingResult(required) {
|
|
71
|
+
const { existing, payloadHash } = required;
|
|
72
|
+
if (existing.payloadHash !== payloadHash)
|
|
73
|
+
return refuse("MAIL_IDEMPOTENCY_CONFLICT", "idempotency key reused with a different message");
|
|
74
|
+
if (existing.outcome === "sent")
|
|
75
|
+
return { ok: true, providerMessageId: existing.providerMessageId ?? "", acceptedAt: required.nowIso() };
|
|
76
|
+
// `reserved` = another worker is sending now, or a crash mid-send: the outcome is unknown.
|
|
77
|
+
return refuse("MAIL_OUTCOME_UNKNOWN", "an earlier attempt with this key has an unknown outcome", { unknown: true });
|
|
78
|
+
}
|
|
79
|
+
/** Provider call → ledger outcome; a throw is unknown (the provider may have accepted it). */
|
|
80
|
+
async function dispatch(required) {
|
|
81
|
+
try {
|
|
82
|
+
return await required.send({ message: required.message });
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
return refuse("MAIL_OUTCOME_UNKNOWN", err instanceof Error ? err.message : String(err), { unknown: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function outcomeOf(result) {
|
|
89
|
+
if (result.ok)
|
|
90
|
+
return "sent";
|
|
91
|
+
return result.unknown ? "unknown" : "failed";
|
|
92
|
+
}
|
|
93
|
+
/** Test mode: redirect rewrites the recipient; capture stores the message and sends nothing. */
|
|
94
|
+
async function applyTestMode(required, optional = {}) {
|
|
95
|
+
const { message, options, lane } = required;
|
|
96
|
+
const mode = optional.testMode;
|
|
97
|
+
if (mode === undefined)
|
|
98
|
+
return { message };
|
|
99
|
+
if (mode.kind === "redirect") {
|
|
100
|
+
return { message: { ...message, to: mode.to, headers: { ...message.headers, "X-Original-To": message.to.email } } };
|
|
101
|
+
}
|
|
102
|
+
const { id } = await mode.sink.capture({ message, lane, idempotencyKey: options.idempotencyKey });
|
|
103
|
+
return { result: { ok: true, providerMessageId: `captured:${id}`, acceptedAt: required.nowIso() } };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Builds the policy. @complexity O(1) ledger round-trips per recipient.
|
|
107
|
+
* @example createMailDeliveryPolicy({ providerId: "smtp", ledger, suppression, supportsAttachments: true })
|
|
108
|
+
*/
|
|
109
|
+
export function createMailDeliveryPolicy(required, optional = {}) {
|
|
110
|
+
const resendPolicy = optional.resendPolicy ?? (() => "on_unknown");
|
|
111
|
+
const nowIso = optional.nowIso ?? (() => new Date().toISOString());
|
|
112
|
+
return {
|
|
113
|
+
async deliver({ message, options, send }) {
|
|
114
|
+
const lane = laneForPurpose({ purpose: options.purpose });
|
|
115
|
+
const refused = await preflight({ message, options, lane, deps: required });
|
|
116
|
+
if (refused)
|
|
117
|
+
return refused;
|
|
118
|
+
const tested = await applyTestMode({ message, options, lane, nowIso }, optional.testMode ? { testMode: optional.testMode } : {});
|
|
119
|
+
if ("result" in tested)
|
|
120
|
+
return tested.result;
|
|
121
|
+
const taken = optional.limiter?.tryTake({ lane }) ?? { ok: true };
|
|
122
|
+
if (!taken.ok)
|
|
123
|
+
return refuse(MAIL_RATE_LIMITED, `${lane} lane is rate limited`, { retryable: true, retryAfterMs: taken.retryAfterMs });
|
|
124
|
+
const key = { workspaceId: options.workspaceId, idempotencyKey: options.idempotencyKey, recipient: message.to.email.trim().toLowerCase() };
|
|
125
|
+
const payloadHash = mailPayloadHash({ message });
|
|
126
|
+
const reclaimUnknown = resendPolicy({ purpose: options.purpose }) === "on_unknown";
|
|
127
|
+
const reserved = await required.ledger.reserve({ ...key, payloadHash, lane, providerId: required.providerId }, { reclaimUnknown });
|
|
128
|
+
if (reserved.status === "existing")
|
|
129
|
+
return existingResult({ existing: reserved, payloadHash, nowIso });
|
|
130
|
+
const result = await dispatch({ message: tested.message, send });
|
|
131
|
+
await required.ledger.mark({ ...key, outcome: outcomeOf(result), ...(result.ok ? { providerMessageId: result.providerMessageId } : { errorCode: result.errorCode }) });
|
|
132
|
+
return result;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Jini-shaped `MailerPort` decorator over the policy; batches go recipient by recipient. */
|
|
137
|
+
export function createPolicyMailer(required) {
|
|
138
|
+
const { inner, policy } = required;
|
|
139
|
+
const sendOne = (input, optional) => {
|
|
140
|
+
const { message, ...delivery } = input;
|
|
141
|
+
return policy.deliver({ message, options: { ...delivery, ...optional }, send: (call) => inner.send({ ...delivery, message: call.message }, optional) });
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
capabilities: (empty) => inner.capabilities(empty),
|
|
145
|
+
send: (input, optional = {}) => sendOne(input, optional),
|
|
146
|
+
async sendBatch({ messages, ...delivery }, optional = {}) {
|
|
147
|
+
// Per-recipient ledger rows need per-recipient dispatch; a provider batch would share one outcome.
|
|
148
|
+
const results = [];
|
|
149
|
+
for (const message of messages)
|
|
150
|
+
results.push(await sendOne({ ...delivery, message }, optional));
|
|
151
|
+
return results;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=policy.js.map
|
package/dist/mail/ports.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { UUID } from "@jini-ai/core/primitives";
|
|
2
|
-
import type { MailerCapabilities, MailerSendResult, MailerSendRequired, MailerBatchRequired, MailerOptional } from "./types.js";
|
|
2
|
+
import type { MailerCapabilities, MailerSendResult, OutboundEmail, MailerSendRequired, MailerBatchRequired, MailerOptional } from "./types.js";
|
|
3
3
|
export type { MailerCapabilities, MailerSendOptions, MailerSendResult, OutboundEmail } from "./types.js";
|
|
4
4
|
export interface MailerPort {
|
|
5
5
|
capabilities(_required: Record<string, never>): MailerCapabilities;
|
|
@@ -8,23 +8,62 @@ export interface MailerPort {
|
|
|
8
8
|
}
|
|
9
9
|
export type ConsoleMailerAdapter = MailerPort;
|
|
10
10
|
export type InMemoryMailerAdapter = MailerPort;
|
|
11
|
+
/** `all` blocks every send (hard bounce, manual); `bulk` blocks only bulk mail (complaints), so a
|
|
12
|
+
* member who marked a newsletter as spam can still receive their own sign-in link. */
|
|
13
|
+
export type MailSuppressionScope = "all" | "bulk";
|
|
11
14
|
export interface MailSuppressionRepoPort {
|
|
12
15
|
isSuppressed(required: {
|
|
13
16
|
workspaceId: UUID;
|
|
14
17
|
normalizedAddress: string;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
};
|
|
18
|
+
/** The lane being sent: `transactional` consults `all` only, `bulk` consults both scopes. */
|
|
19
|
+
lane: MailDeliveryLane;
|
|
18
20
|
}): Promise<boolean>;
|
|
19
21
|
suppress(required: {
|
|
20
22
|
workspaceId: UUID;
|
|
21
23
|
normalizedAddress: string;
|
|
22
|
-
scope:
|
|
23
|
-
module: string;
|
|
24
|
-
};
|
|
24
|
+
scope: MailSuppressionScope;
|
|
25
25
|
reason: "bounced" | "complained" | "manual";
|
|
26
26
|
}): Promise<void>;
|
|
27
27
|
}
|
|
28
|
+
/** Hard delivery lanes. Distinct from `MailerSendOptions.lane` (interactive/notification readiness). */
|
|
29
|
+
export type MailDeliveryLane = "transactional" | "bulk";
|
|
30
|
+
export type MailLedgerOutcome = "reserved" | "sent" | "failed" | "unknown";
|
|
31
|
+
export interface MailSendLedgerPort {
|
|
32
|
+
/**
|
|
33
|
+
* Atomically insert (workspaceId, idempotencyKey, recipient) as `reserved`. An existing row is
|
|
34
|
+
* returned as-is, except `failed`, which the port flips back to `reserved` in the same statement
|
|
35
|
+
* (a definitive failure means nothing was sent, so retrying under the same identity is safe).
|
|
36
|
+
* With `optional.reclaimUnknown`, an `unknown` row with the SAME payloadHash is flipped to
|
|
37
|
+
* `reserved` in that same statement too: the caller that wins the flip is the only one that
|
|
38
|
+
* re-sends, and a concurrent retry sees `reserved` (an unknown outcome) instead of also re-sending.
|
|
39
|
+
*/
|
|
40
|
+
reserve(required: {
|
|
41
|
+
workspaceId: UUID;
|
|
42
|
+
idempotencyKey: string;
|
|
43
|
+
recipient: string;
|
|
44
|
+
payloadHash: string;
|
|
45
|
+
lane: MailDeliveryLane;
|
|
46
|
+
providerId: string;
|
|
47
|
+
}, optional?: {
|
|
48
|
+
reclaimUnknown?: boolean;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
status: "reserved";
|
|
51
|
+
} | {
|
|
52
|
+
status: "existing";
|
|
53
|
+
outcome: Exclude<MailLedgerOutcome, "failed">;
|
|
54
|
+
payloadHash: string;
|
|
55
|
+
providerMessageId?: string;
|
|
56
|
+
}>;
|
|
57
|
+
mark(required: {
|
|
58
|
+
workspaceId: UUID;
|
|
59
|
+
idempotencyKey: string;
|
|
60
|
+
recipient: string;
|
|
61
|
+
outcome: Exclude<MailLedgerOutcome, "reserved">;
|
|
62
|
+
providerMessageId?: string;
|
|
63
|
+
errorCode?: string;
|
|
64
|
+
}): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
/** @deprecated Racy check-then-send pair; use {@link MailSendLedgerPort}. Kept for type consumers. */
|
|
28
67
|
export interface MailSendDedupRepoPort {
|
|
29
68
|
wasSent(required: {
|
|
30
69
|
workspaceId: UUID;
|
|
@@ -35,6 +74,16 @@ export interface MailSendDedupRepoPort {
|
|
|
35
74
|
idempotencyKey: string;
|
|
36
75
|
}): Promise<void>;
|
|
37
76
|
}
|
|
77
|
+
/** Test/capture mode sink: messages are stored for the operator instead of leaving the process. */
|
|
78
|
+
export interface MailCapturePort {
|
|
79
|
+
capture(required: {
|
|
80
|
+
message: OutboundEmail;
|
|
81
|
+
lane: MailDeliveryLane;
|
|
82
|
+
idempotencyKey: string;
|
|
83
|
+
}): Promise<{
|
|
84
|
+
id: string;
|
|
85
|
+
}>;
|
|
86
|
+
}
|
|
38
87
|
export interface DeliveryReadinessPolicy {
|
|
39
88
|
isReady(required: {
|
|
40
89
|
capabilityName: string;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Mail provider contract + keyed registry (email provider contract v1, 2026-10-21).
|
|
3
|
+
*
|
|
4
|
+
* A provider is a {@link MailAdapterModule} that also names itself, declares the credential fields
|
|
5
|
+
* it needs and its capabilities, and optionally parses delivery feedback and reports what a sending
|
|
6
|
+
* domain must publish (SPF/DKIM/DMARC). Selection is explicit: the host stores
|
|
7
|
+
* `mail.provider.{transactional,bulk}` and never picks "the first provider with a credential".
|
|
8
|
+
*/
|
|
9
|
+
import { type DomainRequirement, type ProviderContext, type ProviderRegistry, type ProviderResult } from "@jini-ai/core/provider-registry";
|
|
10
|
+
import type { HttpClientPort } from "@jini-ai/core/primitives";
|
|
11
|
+
import type { MailAdapterCredential, MailAdapterModule } from "./adapter-module.js";
|
|
12
|
+
import type { MailDeliveryLane } from "./ports.js";
|
|
13
|
+
import type { MailerFeedbackEvent } from "./types.js";
|
|
14
|
+
/** Provider token bucket: sustained rate plus burst. Absent = no provider-declared limit. */
|
|
15
|
+
export interface MailRateLimit {
|
|
16
|
+
readonly perSecond: number;
|
|
17
|
+
readonly burst: number;
|
|
18
|
+
}
|
|
19
|
+
export interface MailProviderCapabilities {
|
|
20
|
+
/** False for local drivers (console): they never count as real delivery. */
|
|
21
|
+
readonly hosted: boolean;
|
|
22
|
+
/** True exactly when the module ships a {@link MailFeedbackParser}. */
|
|
23
|
+
readonly feedback: boolean;
|
|
24
|
+
/** True exactly when the module implements domainRequirements + domainStatus. */
|
|
25
|
+
readonly domainVerification: boolean;
|
|
26
|
+
readonly rateLimit?: MailRateLimit;
|
|
27
|
+
}
|
|
28
|
+
export interface MailFeedbackParser {
|
|
29
|
+
/** Raw bytes in; never throws. Any of `secrets` may match so a webhook secret can rotate. */
|
|
30
|
+
verify(required: {
|
|
31
|
+
rawBody: Uint8Array;
|
|
32
|
+
headers: Readonly<Record<string, string>>;
|
|
33
|
+
secrets: readonly string[];
|
|
34
|
+
now: number;
|
|
35
|
+
}): boolean;
|
|
36
|
+
/** Headers are passed so a parser can key events on a delivery id (e.g. `svix-id`).
|
|
37
|
+
* Each event must carry eventId + recipient, or the ingest drops it (no dedup key, no suppression key). */
|
|
38
|
+
parse(required: {
|
|
39
|
+
rawBody: Uint8Array;
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
headers: Readonly<Record<string, string>>;
|
|
42
|
+
}): readonly MailerFeedbackEvent[] | {
|
|
43
|
+
error: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Per-call context: who the call acts for plus the host-resolved credential and guarded HTTP. */
|
|
47
|
+
export interface MailProviderCallContext extends ProviderContext {
|
|
48
|
+
readonly credential: MailAdapterCredential;
|
|
49
|
+
readonly httpClient: HttpClientPort;
|
|
50
|
+
}
|
|
51
|
+
export type MailDomainState = "verified" | "pending" | "failed" | "not_found";
|
|
52
|
+
export interface MailProviderModule extends MailAdapterModule {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly label: string;
|
|
55
|
+
/** Credential fields the host must resolve (e.g. `token`, `username`, `baseUrl`). */
|
|
56
|
+
readonly credentialKeys: readonly string[];
|
|
57
|
+
/** Separate vault key for the webhook signing secret, so it rotates independently of the API key. */
|
|
58
|
+
readonly webhookSecretKey?: string;
|
|
59
|
+
readonly capabilities: MailProviderCapabilities;
|
|
60
|
+
readonly feedback?: MailFeedbackParser;
|
|
61
|
+
/** DNS records the provider needs before it will send as `domain`; satisfied through the DNS port. */
|
|
62
|
+
domainRequirements?(ctx: MailProviderCallContext, required: {
|
|
63
|
+
domain: string;
|
|
64
|
+
}): Promise<ProviderResult<DomainRequirement>>;
|
|
65
|
+
domainStatus?(ctx: MailProviderCallContext, required: {
|
|
66
|
+
domain: string;
|
|
67
|
+
}): Promise<ProviderResult<{
|
|
68
|
+
domain: string;
|
|
69
|
+
state: MailDomainState;
|
|
70
|
+
}>>;
|
|
71
|
+
}
|
|
72
|
+
/** Shape check run by the registry for core and plugin registrations. @complexity O(k) keys. */
|
|
73
|
+
export declare function validateMailProvider({ provider }: {
|
|
74
|
+
provider: MailProviderModule;
|
|
75
|
+
}): string | undefined;
|
|
76
|
+
export type MailProviderRegistry = ProviderRegistry<MailProviderModule>;
|
|
77
|
+
/** Mail registry over the shared keyed registry; built-ins throw on a bad registration, plugins are refused. */
|
|
78
|
+
export declare function createMailProviderRegistry(_required?: Record<string, never>, optional?: {
|
|
79
|
+
builtins?: readonly MailProviderModule[];
|
|
80
|
+
}): MailProviderRegistry;
|
|
81
|
+
export declare const MAIL_PROVIDER_SETTING_KEY = "mail.provider";
|
|
82
|
+
/** One provider per lane; equal ids today, separable later without a data migration. null = unconfigured. */
|
|
83
|
+
export interface MailProviderSetting {
|
|
84
|
+
readonly transactional: string | null;
|
|
85
|
+
readonly bulk: string | null;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The value written on first boot: whatever the host was effectively using, so a live site never
|
|
89
|
+
* silently switches provider when selection becomes explicit. A local driver (console) or nothing
|
|
90
|
+
* becomes `null` — the explicit `unconfigured` state, not a fake "working" default.
|
|
91
|
+
*/
|
|
92
|
+
export declare function initialMailProviderSetting(_required: Record<string, never>, optional?: {
|
|
93
|
+
effectiveProviderId?: string | null;
|
|
94
|
+
registry?: MailProviderRegistry;
|
|
95
|
+
}): MailProviderSetting;
|
|
96
|
+
export type MailLaneState = "ready" | "unconfigured" | "unknown_provider" | "missing_credential" | "local_only";
|
|
97
|
+
export interface MailLaneStatus {
|
|
98
|
+
readonly lane: MailDeliveryLane;
|
|
99
|
+
readonly providerId: string | null;
|
|
100
|
+
readonly state: MailLaneState;
|
|
101
|
+
}
|
|
102
|
+
export type MailStatusWarning = "sender_unverified" | "sender_unknown";
|
|
103
|
+
export interface MailStatus {
|
|
104
|
+
/** `unconfigured` drives the blocking admin banner; owner sign-in must never depend on mail. */
|
|
105
|
+
readonly state: "ready" | "unconfigured" | "degraded";
|
|
106
|
+
readonly lanes: readonly MailLaneStatus[];
|
|
107
|
+
readonly warnings: readonly MailStatusWarning[];
|
|
108
|
+
}
|
|
109
|
+
/** Pure status projection for `mail_status` and the admin banner. @complexity O(lanes). */
|
|
110
|
+
export declare function computeMailStatus(required: {
|
|
111
|
+
setting: MailProviderSetting;
|
|
112
|
+
registry: MailProviderRegistry;
|
|
113
|
+
hasCredential: (id: string) => boolean;
|
|
114
|
+
}, optional?: {
|
|
115
|
+
senderDomainState?: MailDomainState | null;
|
|
116
|
+
}): MailStatus;
|
|
117
|
+
//# sourceMappingURL=provider.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Mail provider contract + keyed registry (email provider contract v1, 2026-10-21).
|
|
3
|
+
*
|
|
4
|
+
* A provider is a {@link MailAdapterModule} that also names itself, declares the credential fields
|
|
5
|
+
* it needs and its capabilities, and optionally parses delivery feedback and reports what a sending
|
|
6
|
+
* domain must publish (SPF/DKIM/DMARC). Selection is explicit: the host stores
|
|
7
|
+
* `mail.provider.{transactional,bulk}` and never picks "the first provider with a credential".
|
|
8
|
+
*/
|
|
9
|
+
import { createProviderRegistry, } from "@jini-ai/core/provider-registry";
|
|
10
|
+
/** Shape check run by the registry for core and plugin registrations. @complexity O(k) keys. */
|
|
11
|
+
export function validateMailProvider({ provider }) {
|
|
12
|
+
if (typeof provider !== "object" || provider === null)
|
|
13
|
+
return "not an object";
|
|
14
|
+
if (typeof provider.create !== "function")
|
|
15
|
+
return "create() is missing";
|
|
16
|
+
if (!Array.isArray(provider.credentialKeys) || !provider.credentialKeys.every((key) => typeof key === "string"))
|
|
17
|
+
return "credentialKeys must be a string array";
|
|
18
|
+
const caps = provider.capabilities;
|
|
19
|
+
if (typeof caps !== "object" || caps === null)
|
|
20
|
+
return "capabilities are missing";
|
|
21
|
+
// A capability claim without the code behind it is how Resend once advertised feedback with no route.
|
|
22
|
+
if (caps.feedback !== (provider.feedback !== undefined))
|
|
23
|
+
return "capabilities.feedback must match the presence of a feedback parser";
|
|
24
|
+
const hasDomain = typeof provider.domainRequirements === "function" && typeof provider.domainStatus === "function";
|
|
25
|
+
if (caps.domainVerification !== hasDomain)
|
|
26
|
+
return "capabilities.domainVerification must match domainRequirements + domainStatus";
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Mail registry over the shared keyed registry; built-ins throw on a bad registration, plugins are refused. */
|
|
30
|
+
export function createMailProviderRegistry(_required = {}, optional = {}) {
|
|
31
|
+
const seed = (optional.builtins ?? []).map((provider) => ({ id: provider.id, provider, label: provider.label }));
|
|
32
|
+
return createProviderRegistry({ contract: "mail" }, { validate: validateMailProvider, seed });
|
|
33
|
+
}
|
|
34
|
+
// ---------------------------------------------------------------------------------------------
|
|
35
|
+
// Selection setting + status.
|
|
36
|
+
// ---------------------------------------------------------------------------------------------
|
|
37
|
+
export const MAIL_PROVIDER_SETTING_KEY = "mail.provider";
|
|
38
|
+
/**
|
|
39
|
+
* The value written on first boot: whatever the host was effectively using, so a live site never
|
|
40
|
+
* silently switches provider when selection becomes explicit. A local driver (console) or nothing
|
|
41
|
+
* becomes `null` — the explicit `unconfigured` state, not a fake "working" default.
|
|
42
|
+
*/
|
|
43
|
+
export function initialMailProviderSetting(_required, optional = {}) {
|
|
44
|
+
const id = optional.effectiveProviderId ?? null;
|
|
45
|
+
const hosted = id !== null && optional.registry?.get({ id })?.provider.capabilities.hosted === true;
|
|
46
|
+
const value = hosted ? id : null;
|
|
47
|
+
return { transactional: value, bulk: value };
|
|
48
|
+
}
|
|
49
|
+
function laneStatus(required) {
|
|
50
|
+
const { lane, providerId, registry } = required;
|
|
51
|
+
if (providerId === null)
|
|
52
|
+
return { lane, providerId, state: "unconfigured" };
|
|
53
|
+
const entry = registry.get({ id: providerId });
|
|
54
|
+
if (!entry)
|
|
55
|
+
return { lane, providerId, state: "unknown_provider" };
|
|
56
|
+
if (!entry.provider.capabilities.hosted)
|
|
57
|
+
return { lane, providerId, state: "local_only" };
|
|
58
|
+
return { lane, providerId, state: required.hasCredential(providerId) ? "ready" : "missing_credential" };
|
|
59
|
+
}
|
|
60
|
+
/** Pure status projection for `mail_status` and the admin banner. @complexity O(lanes). */
|
|
61
|
+
export function computeMailStatus(required, optional = {}) {
|
|
62
|
+
const { setting, registry, hasCredential } = required;
|
|
63
|
+
const lanes = ["transactional", "bulk"].map((lane) => laneStatus({ lane, providerId: setting[lane], registry, hasCredential }));
|
|
64
|
+
const warnings = [];
|
|
65
|
+
const domain = optional.senderDomainState;
|
|
66
|
+
if (domain === null)
|
|
67
|
+
warnings.push("sender_unknown");
|
|
68
|
+
else if (domain !== undefined && domain !== "verified")
|
|
69
|
+
warnings.push("sender_unverified");
|
|
70
|
+
const ready = lanes.filter((status) => status.state === "ready").length;
|
|
71
|
+
const state = ready === lanes.length ? "ready" : lanes[0].state === "unconfigured" ? "unconfigured" : "degraded";
|
|
72
|
+
return { state, lanes, warnings };
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=provider.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Per-provider token bucket with capacity reserved for transactional mail.
|
|
3
|
+
*
|
|
4
|
+
* Bulk and transactional share one provider quota, so a newsletter run could otherwise starve a
|
|
5
|
+
* sign-in link for minutes. Bulk may only take tokens above `reservedForTransactional`; transactional
|
|
6
|
+
* may drain the bucket to zero.
|
|
7
|
+
*/
|
|
8
|
+
import type { MailRateLimit } from "./provider.js";
|
|
9
|
+
import type { MailDeliveryLane } from "./ports.js";
|
|
10
|
+
export interface MailLaneLimiter {
|
|
11
|
+
tryTake(required: {
|
|
12
|
+
lane: MailDeliveryLane;
|
|
13
|
+
}): {
|
|
14
|
+
ok: true;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
retryAfterMs: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* @param required.limit - Sustained rate and burst size (the bucket starts full).
|
|
22
|
+
* @param optional.reservedForTransactional - Tokens bulk may never consume; defaults to 20% of burst,
|
|
23
|
+
* at least 1 (never the whole burst).
|
|
24
|
+
* @param optional.now - Injected clock in ms.
|
|
25
|
+
* @complexity O(1) per take.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createMailLaneLimiter(required: {
|
|
28
|
+
limit: MailRateLimit;
|
|
29
|
+
}, optional?: {
|
|
30
|
+
reservedForTransactional?: number;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}): MailLaneLimiter;
|
|
33
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param required.limit - Sustained rate and burst size (the bucket starts full).
|
|
3
|
+
* @param optional.reservedForTransactional - Tokens bulk may never consume; defaults to 20% of burst,
|
|
4
|
+
* at least 1 (never the whole burst).
|
|
5
|
+
* @param optional.now - Injected clock in ms.
|
|
6
|
+
* @complexity O(1) per take.
|
|
7
|
+
*/
|
|
8
|
+
export function createMailLaneLimiter(required, optional = {}) {
|
|
9
|
+
const { perSecond, burst } = required.limit;
|
|
10
|
+
if (!(perSecond > 0) || !(burst >= 1))
|
|
11
|
+
throw new RangeError("mail rate limit needs perSecond > 0 and burst >= 1");
|
|
12
|
+
const now = optional.now ?? Date.now;
|
|
13
|
+
// At least one token whenever burst > 1: 20% of a small burst (Resend's 2) floors to 0, which let
|
|
14
|
+
// bulk drain the bucket and starve an interactive sign-in link. Never all of it (bulk must move).
|
|
15
|
+
const reserved = Math.min(optional.reservedForTransactional ?? Math.max(1, Math.floor(burst * 0.2)), burst - 1);
|
|
16
|
+
let tokens = burst;
|
|
17
|
+
let last = now();
|
|
18
|
+
const refill = () => {
|
|
19
|
+
const at = now();
|
|
20
|
+
tokens = Math.min(burst, tokens + ((at - last) / 1000) * perSecond);
|
|
21
|
+
last = at;
|
|
22
|
+
};
|
|
23
|
+
return {
|
|
24
|
+
tryTake({ lane }) {
|
|
25
|
+
refill();
|
|
26
|
+
const floor = lane === "bulk" ? reserved : 0;
|
|
27
|
+
if (tokens - 1 >= floor) {
|
|
28
|
+
tokens -= 1;
|
|
29
|
+
return { ok: true };
|
|
30
|
+
}
|
|
31
|
+
return { ok: false, retryAfterMs: Math.ceil(((floor + 1 - tokens) / perSecond) * 1000) };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { EmailAddress } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file The one place a system email's `From` is decided. Each caller passes its own `*.local`
|
|
4
|
+
* placeholder as the fallback; the placeholder is only ever used by a mailer that sends nothing,
|
|
5
|
+
* because real providers (hosted APIs, SMTP relays) reject a `.local` sender outright.
|
|
6
|
+
*
|
|
7
|
+
* Order: the site's configured sender wins whatever the mailer is. With no configured sender, a
|
|
8
|
+
* non-delivering mailer (Console, in-memory) keeps the placeholder, so local development still
|
|
9
|
+
* works with zero setup. A delivering mailer with no configured sender throws
|
|
10
|
+
* {@link SenderIdentityNotConfiguredError} instead of handing the provider a From it will refuse.
|
|
11
|
+
* Hosts supply the settings read, the boot-time settle and the operator-facing error copy.
|
|
12
|
+
*/
|
|
13
|
+
/** Drivers that never hand a message to a real provider, so a `.local` From cannot be rejected.
|
|
14
|
+
* `console` prints to stdout; `memory` is the in-process fake. Every other driver is treated as
|
|
15
|
+
* delivering: an unknown driver fails closed. */
|
|
16
|
+
export declare const NON_DELIVERING_MAIL_DRIVERS: ReadonlySet<string>;
|
|
17
|
+
/** Thrown when a delivering mailer is active but the site has no sender address configured. */
|
|
18
|
+
export declare class SenderIdentityNotConfiguredError extends Error {
|
|
19
|
+
readonly code = "mail_sender_not_configured";
|
|
20
|
+
/** @param optional.message host copy naming where the sender is configured. */
|
|
21
|
+
constructor(required: {
|
|
22
|
+
workspaceId: string;
|
|
23
|
+
driver: string;
|
|
24
|
+
}, optional?: {
|
|
25
|
+
message?: string;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** The site's saved sender, either part possibly absent. */
|
|
29
|
+
export interface ConfiguredMailSender {
|
|
30
|
+
email?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
}
|
|
33
|
+
/** Resolves the `From` for one system email. */
|
|
34
|
+
export interface MailSenderIdentityPort {
|
|
35
|
+
/**
|
|
36
|
+
* @param required.fallback The caller's placeholder sender; its `name` also fills in a missing
|
|
37
|
+
* configured display name.
|
|
38
|
+
* @throws SenderIdentityNotConfiguredError See this file's header.
|
|
39
|
+
*/
|
|
40
|
+
resolve(required: {
|
|
41
|
+
workspaceId: string;
|
|
42
|
+
fallback: EmailAddress;
|
|
43
|
+
}, optional?: Record<string, never>): Promise<EmailAddress>;
|
|
44
|
+
}
|
|
45
|
+
export interface CreateMailSenderIdentityResolverOptional {
|
|
46
|
+
/** Reads the site's saved sender. Absent: nothing is configured (hermetic test roots). */
|
|
47
|
+
readConfiguredSender?: (required: {
|
|
48
|
+
workspaceId: string;
|
|
49
|
+
}) => Promise<ConfiguredMailSender | null>;
|
|
50
|
+
/** Waits for the host's boot-time mailer swap. Without it, a read in the first moments after boot,
|
|
51
|
+
* or right after a key is saved, can still see the Console fallback and pick the placeholder for
|
|
52
|
+
* a send that then reaches the real provider. */
|
|
53
|
+
settle?: () => Promise<void>;
|
|
54
|
+
/** Builds the refusal; default {@link SenderIdentityNotConfiguredError} with neutral copy. */
|
|
55
|
+
notConfigured?: (required: {
|
|
56
|
+
workspaceId: string;
|
|
57
|
+
driver: string;
|
|
58
|
+
}) => Error;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Builds the resolver over the active mailer's driver. See this file's header for the decision order.
|
|
62
|
+
* @param required.readDriver the active mailer's driver, read after `settle`.
|
|
63
|
+
* @complexity One settle, at most one settings read, one driver read.
|
|
64
|
+
*/
|
|
65
|
+
export declare function createMailSenderIdentityResolver(required: {
|
|
66
|
+
readDriver: () => string;
|
|
67
|
+
}, optional?: CreateMailSenderIdentityResolverOptional): MailSenderIdentityPort;
|
|
68
|
+
//# sourceMappingURL=sender-identity.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The one place a system email's `From` is decided. Each caller passes its own `*.local`
|
|
3
|
+
* placeholder as the fallback; the placeholder is only ever used by a mailer that sends nothing,
|
|
4
|
+
* because real providers (hosted APIs, SMTP relays) reject a `.local` sender outright.
|
|
5
|
+
*
|
|
6
|
+
* Order: the site's configured sender wins whatever the mailer is. With no configured sender, a
|
|
7
|
+
* non-delivering mailer (Console, in-memory) keeps the placeholder, so local development still
|
|
8
|
+
* works with zero setup. A delivering mailer with no configured sender throws
|
|
9
|
+
* {@link SenderIdentityNotConfiguredError} instead of handing the provider a From it will refuse.
|
|
10
|
+
* Hosts supply the settings read, the boot-time settle and the operator-facing error copy.
|
|
11
|
+
*/
|
|
12
|
+
/** Drivers that never hand a message to a real provider, so a `.local` From cannot be rejected.
|
|
13
|
+
* `console` prints to stdout; `memory` is the in-process fake. Every other driver is treated as
|
|
14
|
+
* delivering: an unknown driver fails closed. */
|
|
15
|
+
export const NON_DELIVERING_MAIL_DRIVERS = new Set(["console", "memory"]);
|
|
16
|
+
/** Thrown when a delivering mailer is active but the site has no sender address configured. */
|
|
17
|
+
export class SenderIdentityNotConfiguredError extends Error {
|
|
18
|
+
code = "mail_sender_not_configured";
|
|
19
|
+
/** @param optional.message host copy naming where the sender is configured. */
|
|
20
|
+
constructor(required, optional = {}) {
|
|
21
|
+
super(optional.message ?? `No sender address is configured for workspace ${required.workspaceId}, so the "${required.driver}" mailer ` +
|
|
22
|
+
`cannot send: real mail providers reject the placeholder no-reply@*.local sender. Configure a sender address the provider accepts.`);
|
|
23
|
+
this.name = "SenderIdentityNotConfiguredError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** An address with its display name only when there is one (exact optional `name`). */
|
|
27
|
+
function withName(email, name) {
|
|
28
|
+
return name === undefined ? { email } : { email, name };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Builds the resolver over the active mailer's driver. See this file's header for the decision order.
|
|
32
|
+
* @param required.readDriver the active mailer's driver, read after `settle`.
|
|
33
|
+
* @complexity One settle, at most one settings read, one driver read.
|
|
34
|
+
*/
|
|
35
|
+
export function createMailSenderIdentityResolver(required, optional = {}) {
|
|
36
|
+
const { notConfigured = (details) => new SenderIdentityNotConfiguredError(details) } = optional;
|
|
37
|
+
return {
|
|
38
|
+
resolve: async ({ workspaceId, fallback }) => {
|
|
39
|
+
const configured = await optional.readConfiguredSender?.({ workspaceId });
|
|
40
|
+
if (configured?.email)
|
|
41
|
+
return withName(configured.email, configured.name ?? fallback.name);
|
|
42
|
+
await optional.settle?.();
|
|
43
|
+
const driver = required.readDriver();
|
|
44
|
+
if (NON_DELIVERING_MAIL_DRIVERS.has(driver))
|
|
45
|
+
return fallback;
|
|
46
|
+
throw notConfigured({ workspaceId, driver });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=sender-identity.js.map
|
package/dist/mail/types.d.ts
CHANGED
|
@@ -50,6 +50,11 @@ export type MailerSendResult = {
|
|
|
50
50
|
retryable: boolean;
|
|
51
51
|
errorCode: string;
|
|
52
52
|
message: string;
|
|
53
|
+
/** The provider may have accepted the message (timeout after submit, crash mid-send): never
|
|
54
|
+
* auto-retry under a new identity; the send ledger records the outcome as `unknown`. */
|
|
55
|
+
unknown?: boolean;
|
|
56
|
+
/** Rate-limit hint (provider 429 or the local token bucket); absent means "caller's backoff". */
|
|
57
|
+
retryAfterMs?: number;
|
|
53
58
|
};
|
|
54
59
|
export interface MailerCapabilities {
|
|
55
60
|
driver: string;
|
|
@@ -68,6 +73,10 @@ export interface MailerFeedbackEvent {
|
|
|
68
73
|
ref?: string;
|
|
69
74
|
};
|
|
70
75
|
kind: "delivered" | "bounced" | "complained";
|
|
76
|
+
/** Provider event id: the inbox dedups replays on (providerId, eventId). Optional for v1 parsers. */
|
|
77
|
+
eventId?: string;
|
|
78
|
+
/** Lower-cased recipient address; suppression needs it, provider message ids alone cannot key it. */
|
|
79
|
+
recipient?: string;
|
|
71
80
|
hardBounce: boolean;
|
|
72
81
|
occurredAt: ISODateTime;
|
|
73
82
|
}
|