@forgezero/providers 0.1.0

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.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * A blockchain node, behind the same failover every other service gets.
3
+ *
4
+ * Reading a chain is a PROVIDER problem, not a runtime one, and the distinction
5
+ * is not bookkeeping. A node needs a credential, it rate-limits, it goes down,
6
+ * and the answer when it does is to ask a different one — which is precisely
7
+ * what the registry already does for email and storage. Deriving an address
8
+ * needs none of that and lives in `runtime/finance/custody`.
9
+ *
10
+ * ## Why several nodes is the normal case, not a luxury
11
+ *
12
+ * Public RPC endpoints are rate-limited and unreliable, and paid ones have
13
+ * outages like anything else. A deposit scanner that stops when one endpoint is
14
+ * down stops CREDITING DEPOSITS, and the user's money is on the chain the whole
15
+ * time. Priority and health belong here so a scanner never has to know which
16
+ * node answered.
17
+ *
18
+ * ## The classification is the load-bearing part
19
+ *
20
+ * terminal our request is malformed, or the chain rejected the transaction
21
+ * on its merits. Every node will say the same thing, and asking
22
+ * three of them buries the real reason under two duplicates.
23
+ * backoff rate limited. The node is healthy and we are asking too fast;
24
+ * striking it would punish the one that is working.
25
+ * retryable this node is behind, broken, or its key is bad. The next may
26
+ * be fine.
27
+ *
28
+ * "Already known" and "nonce too low" are the interesting cases. Both mean the
29
+ * transaction is ALREADY IN FLIGHT, so they are terminal rather than retryable:
30
+ * rebroadcasting through another node cannot help, and treating them as failure
31
+ * is how a sweep gets sent twice.
32
+ */
33
+ export type ChainCall = {
34
+ op: 'head';
35
+ } | {
36
+ op: 'balance';
37
+ address: string;
38
+ block?: string;
39
+ } | {
40
+ op: 'call';
41
+ to: string;
42
+ data: string;
43
+ block?: string;
44
+ } | {
45
+ op: 'logs';
46
+ fromBlock: number;
47
+ toBlock: number;
48
+ address?: string;
49
+ topics?: (string | string[] | null)[];
50
+ } | {
51
+ op: 'nonce';
52
+ address: string;
53
+ block?: 'pending' | 'latest';
54
+ } | {
55
+ op: 'send';
56
+ raw: string;
57
+ } | {
58
+ op: 'receipt';
59
+ hash: string;
60
+ };
61
+ export interface ChainLog {
62
+ transactionHash: string;
63
+ logIndex: number;
64
+ blockNumber: number;
65
+ address: string;
66
+ topics: string[];
67
+ data: string;
68
+ }
69
+ export interface ChainReceipt {
70
+ hash: string;
71
+ blockNumber: number;
72
+ /** True only when the chain executed it successfully. */
73
+ success: boolean;
74
+ }
75
+ export type ChainResult = {
76
+ op: 'head';
77
+ block: number;
78
+ } | {
79
+ op: 'balance';
80
+ wei: bigint;
81
+ } | {
82
+ op: 'call';
83
+ data: string;
84
+ } | {
85
+ op: 'logs';
86
+ logs: ChainLog[];
87
+ } | {
88
+ op: 'nonce';
89
+ nonce: bigint;
90
+ } | {
91
+ op: 'send';
92
+ hash: string;
93
+ }
94
+ /** `null` when the transaction has not mined yet — not an error. */
95
+ | {
96
+ op: 'receipt';
97
+ receipt: ChainReceipt | null;
98
+ };
99
+ export declare const evmRpc: import("./index").ProviderSpec<ChainCall, ChainResult>;
package/dist/chain.js ADDED
@@ -0,0 +1,279 @@
1
+ // src/index.ts
2
+ class ProviderError extends Error {
3
+ code;
4
+ details;
5
+ constructor(code, message, details) {
6
+ super(message);
7
+ this.code = code;
8
+ this.details = details;
9
+ this.name = "ProviderError";
10
+ }
11
+ }
12
+ function envCredentials(env) {
13
+ return {
14
+ name: "env",
15
+ async get(reference, field) {
16
+ const key = `${reference}_${field}`.replace(/[.-]/g, "_").toUpperCase();
17
+ const value = env[key];
18
+ if (value === undefined) {
19
+ throw new ProviderError("CREDENTIAL_MISSING", `Set ${key} in the environment.`);
20
+ }
21
+ return value;
22
+ }
23
+ };
24
+ }
25
+ function chainCredentials(...sources) {
26
+ return {
27
+ name: sources.map((source) => source.name).join("+"),
28
+ async get(reference, field) {
29
+ let last;
30
+ for (const source of sources) {
31
+ try {
32
+ return await source.get(reference, field);
33
+ } catch (error) {
34
+ last = error;
35
+ }
36
+ }
37
+ throw last instanceof Error ? last : new ProviderError("CREDENTIAL_MISSING", `No source held ${reference}.${field}.`);
38
+ }
39
+ };
40
+ }
41
+ function staticConfig(services) {
42
+ const health = new Map;
43
+ return {
44
+ name: "static",
45
+ async list(serviceKey) {
46
+ return (services[serviceKey] ?? []).map((provider) => ({
47
+ ...provider,
48
+ health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
49
+ }));
50
+ },
51
+ async recordHealth(serviceKey, providerId, next) {
52
+ health.set(`${serviceKey}:${providerId}`, next);
53
+ }
54
+ };
55
+ }
56
+ function defineProvider(spec) {
57
+ return spec;
58
+ }
59
+ var STRIKES_TO_OFFLINE = 3;
60
+ function nextHealth(current, kind) {
61
+ if (kind === "success")
62
+ return { strikes: 0, status: "ok" };
63
+ if (kind === "backoff")
64
+ return current ?? { strikes: 0, status: "ok" };
65
+ const strikes = (current?.strikes ?? 0) + 1;
66
+ return {
67
+ strikes,
68
+ status: strikes >= STRIKES_TO_OFFLINE ? "offline" : "degraded",
69
+ lastFailureAtTs: Date.now()
70
+ };
71
+ }
72
+ function createRegistry(options) {
73
+ const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
74
+ async function call(serviceKey, args) {
75
+ const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
76
+ const attempts = [];
77
+ for (const entry of configured) {
78
+ const spec = byId.get(entry.providerId);
79
+ if (!spec) {
80
+ attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
81
+ continue;
82
+ }
83
+ if (entry.health?.status === "offline") {
84
+ attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
85
+ continue;
86
+ }
87
+ options.before?.({ service: serviceKey, provider: entry.providerId });
88
+ try {
89
+ const result = await spec.invoke({
90
+ config: entry.config,
91
+ secret: (field) => options.credentials.get(entry.secretRef, field)
92
+ }, args);
93
+ attempts.push({ providerId: entry.providerId, outcome: "sent" });
94
+ await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
95
+ const sent = { ok: true, result, provider: entry.providerId, attempts };
96
+ options.after?.(sent);
97
+ return sent;
98
+ } catch (error) {
99
+ const kind = spec.classify(error);
100
+ attempts.push({
101
+ providerId: entry.providerId,
102
+ outcome: "failed",
103
+ kind,
104
+ error: error instanceof Error ? error.message : String(error)
105
+ });
106
+ await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
107
+ if (kind === "terminal") {
108
+ const refused = {
109
+ ok: false,
110
+ attempts,
111
+ error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
112
+ };
113
+ options.after?.(refused);
114
+ return refused;
115
+ }
116
+ }
117
+ }
118
+ const failed = {
119
+ ok: false,
120
+ attempts,
121
+ error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
122
+ };
123
+ options.after?.(failed);
124
+ return failed;
125
+ }
126
+ return { call };
127
+ }
128
+ var VERSION = "0.1.0";
129
+
130
+ // src/chain.ts
131
+ var hexToNumber = (value) => Number(BigInt(value));
132
+ var TERMINAL_PATTERNS = [
133
+ "already known",
134
+ "nonce too low",
135
+ "already imported",
136
+ "replacement transaction underpriced",
137
+ "intrinsic gas too low",
138
+ "insufficient funds",
139
+ "exceeds block gas limit",
140
+ "invalid sender",
141
+ "execution reverted"
142
+ ];
143
+ var evmRpc = defineProvider({
144
+ id: "evm-rpc",
145
+ service: "chain",
146
+ label: "EVM JSON-RPC node",
147
+ multiInstance: true,
148
+ credentials: {
149
+ type: "object",
150
+ properties: {
151
+ url: { type: "string", writeOnly: true },
152
+ bearer: { type: "string", writeOnly: true }
153
+ },
154
+ required: ["url"],
155
+ additionalProperties: false
156
+ },
157
+ config: {
158
+ type: "object",
159
+ properties: {
160
+ chainId: { type: "integer" },
161
+ maxLogRange: { type: "integer" },
162
+ timeoutMs: { type: "integer" }
163
+ },
164
+ required: ["chainId"],
165
+ additionalProperties: false
166
+ },
167
+ async invoke(context, call) {
168
+ const config = context.config;
169
+ const url = await context.secret("url");
170
+ const bearer = await context.secret("bearer").catch(() => {
171
+ return;
172
+ });
173
+ const rpc = async (method, params) => {
174
+ const response = await fetch(url, {
175
+ method: "POST",
176
+ headers: {
177
+ "content-type": "application/json",
178
+ ...bearer ? { authorization: `Bearer ${bearer}` } : {}
179
+ },
180
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
181
+ signal: context.signal ?? AbortSignal.timeout(config.timeoutMs ?? 15000)
182
+ });
183
+ if (!response.ok) {
184
+ throw new ProviderError("RPC_HTTP", `${method}: HTTP ${response.status}`, {
185
+ status: response.status
186
+ });
187
+ }
188
+ const body = await response.json();
189
+ if (body.error) {
190
+ throw new ProviderError("RPC_ERROR", `${method}: ${body.error.message ?? "error"}`, {
191
+ code: body.error.code
192
+ });
193
+ }
194
+ if (body.result === undefined) {
195
+ throw new ProviderError("RPC_EMPTY", `${method}: no result`);
196
+ }
197
+ return body.result;
198
+ };
199
+ switch (call.op) {
200
+ case "head":
201
+ return { op: "head", block: hexToNumber(await rpc("eth_blockNumber", [])) };
202
+ case "balance":
203
+ return {
204
+ op: "balance",
205
+ wei: BigInt(await rpc("eth_getBalance", [call.address, call.block ?? "latest"]))
206
+ };
207
+ case "call":
208
+ return {
209
+ op: "call",
210
+ data: await rpc("eth_call", [
211
+ { to: call.to, data: call.data },
212
+ call.block ?? "latest"
213
+ ])
214
+ };
215
+ case "logs": {
216
+ const span = call.toBlock - call.fromBlock + 1;
217
+ const cap = config.maxLogRange ?? 2000;
218
+ if (span > cap) {
219
+ throw new ProviderError("RPC_RANGE_TOO_WIDE", `Asked for ${span} blocks; this node allows ${cap}. Split the range.`);
220
+ }
221
+ const logs = await rpc("eth_getLogs", [
222
+ {
223
+ fromBlock: `0x${call.fromBlock.toString(16)}`,
224
+ toBlock: `0x${call.toBlock.toString(16)}`,
225
+ ...call.address ? { address: call.address } : {},
226
+ ...call.topics ? { topics: call.topics } : {}
227
+ }
228
+ ]);
229
+ return {
230
+ op: "logs",
231
+ logs: logs.map((log) => ({
232
+ transactionHash: log.transactionHash,
233
+ logIndex: hexToNumber(log.logIndex),
234
+ blockNumber: hexToNumber(log.blockNumber),
235
+ address: log.address,
236
+ topics: log.topics,
237
+ data: log.data
238
+ }))
239
+ };
240
+ }
241
+ case "nonce":
242
+ return {
243
+ op: "nonce",
244
+ nonce: BigInt(await rpc("eth_getTransactionCount", [call.address, call.block ?? "pending"]))
245
+ };
246
+ case "send":
247
+ return { op: "send", hash: await rpc("eth_sendRawTransaction", [call.raw]) };
248
+ case "receipt": {
249
+ const receipt = await rpc("eth_getTransactionReceipt", [call.hash]);
250
+ if (!receipt)
251
+ return { op: "receipt", receipt: null };
252
+ return {
253
+ op: "receipt",
254
+ receipt: {
255
+ hash: call.hash,
256
+ blockNumber: hexToNumber(receipt.blockNumber ?? "0x0"),
257
+ success: receipt.status === "0x1"
258
+ }
259
+ };
260
+ }
261
+ }
262
+ },
263
+ classify(error) {
264
+ const message = String(error?.message ?? "").toLowerCase();
265
+ const details = error.details;
266
+ if (TERMINAL_PATTERNS.some((pattern) => message.includes(pattern)))
267
+ return "terminal";
268
+ if (message.includes("split the range"))
269
+ return "terminal";
270
+ if (details?.code === -32602)
271
+ return "terminal";
272
+ if (details?.status === 429 || message.includes("rate limit") || message.includes("too many"))
273
+ return "backoff";
274
+ return "retryable";
275
+ }
276
+ });
277
+ export {
278
+ evmRpc
279
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Database providers — ArangoDB, with the same priority and health machinery
3
+ * every other service gets.
4
+ *
5
+ * ## Why a database belongs in the registry at all
6
+ *
7
+ * Not for failover between databases — that is what a cluster is for, and
8
+ * silently writing to a different database because the first was slow is data
9
+ * loss with extra steps. It belongs here for two other reasons:
10
+ *
11
+ * CREDENTIALS the password lives in the same place as every other secret,
12
+ * so rotating it is a vault write rather than a redeploy
13
+ * HEALTH three failed connections mark it degraded, and the same admin
14
+ * screen that shows email relays shows the database
15
+ *
16
+ * `failover: false` is therefore the default and a deliberate one. Set it true
17
+ * only for read replicas, where answering from a stale replica beats not
18
+ * answering.
19
+ */
20
+ /** Just enough of an ArangoDB client that this package imports nothing. */
21
+ export interface ArangoLike {
22
+ query<T = unknown>(query: unknown, bindVars?: Record<string, unknown>): Promise<{
23
+ all(): Promise<T[]>;
24
+ }>;
25
+ version?(): Promise<{
26
+ version: string;
27
+ }>;
28
+ }
29
+ export interface ArangoConfig {
30
+ url: string;
31
+ database: string;
32
+ username: string;
33
+ /** Built by the host, because a connection pool must outlive one call. */
34
+ connect?: (options: {
35
+ url: string;
36
+ database: string;
37
+ username: string;
38
+ password: string;
39
+ }) => ArangoLike;
40
+ failover?: boolean;
41
+ }
42
+ export interface Query {
43
+ query: unknown;
44
+ bindVars?: Record<string, unknown>;
45
+ }
46
+ export declare const arangodb: import("./index").ProviderSpec<Query, unknown[]>;
47
+ /** Drop memoised connections. Tests, and after a credential rotation. */
48
+ export declare function resetPools(): void;
49
+ export declare const databaseProviders: readonly [import("./index").ProviderSpec<Query, unknown[]>];
@@ -0,0 +1,209 @@
1
+ // src/index.ts
2
+ class ProviderError extends Error {
3
+ code;
4
+ details;
5
+ constructor(code, message, details) {
6
+ super(message);
7
+ this.code = code;
8
+ this.details = details;
9
+ this.name = "ProviderError";
10
+ }
11
+ }
12
+ function envCredentials(env) {
13
+ return {
14
+ name: "env",
15
+ async get(reference, field) {
16
+ const key = `${reference}_${field}`.replace(/[.-]/g, "_").toUpperCase();
17
+ const value = env[key];
18
+ if (value === undefined) {
19
+ throw new ProviderError("CREDENTIAL_MISSING", `Set ${key} in the environment.`);
20
+ }
21
+ return value;
22
+ }
23
+ };
24
+ }
25
+ function chainCredentials(...sources) {
26
+ return {
27
+ name: sources.map((source) => source.name).join("+"),
28
+ async get(reference, field) {
29
+ let last;
30
+ for (const source of sources) {
31
+ try {
32
+ return await source.get(reference, field);
33
+ } catch (error) {
34
+ last = error;
35
+ }
36
+ }
37
+ throw last instanceof Error ? last : new ProviderError("CREDENTIAL_MISSING", `No source held ${reference}.${field}.`);
38
+ }
39
+ };
40
+ }
41
+ function staticConfig(services) {
42
+ const health = new Map;
43
+ return {
44
+ name: "static",
45
+ async list(serviceKey) {
46
+ return (services[serviceKey] ?? []).map((provider) => ({
47
+ ...provider,
48
+ health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
49
+ }));
50
+ },
51
+ async recordHealth(serviceKey, providerId, next) {
52
+ health.set(`${serviceKey}:${providerId}`, next);
53
+ }
54
+ };
55
+ }
56
+ function defineProvider(spec) {
57
+ return spec;
58
+ }
59
+ var STRIKES_TO_OFFLINE = 3;
60
+ function nextHealth(current, kind) {
61
+ if (kind === "success")
62
+ return { strikes: 0, status: "ok" };
63
+ if (kind === "backoff")
64
+ return current ?? { strikes: 0, status: "ok" };
65
+ const strikes = (current?.strikes ?? 0) + 1;
66
+ return {
67
+ strikes,
68
+ status: strikes >= STRIKES_TO_OFFLINE ? "offline" : "degraded",
69
+ lastFailureAtTs: Date.now()
70
+ };
71
+ }
72
+ function createRegistry(options) {
73
+ const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
74
+ async function call(serviceKey, args) {
75
+ const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
76
+ const attempts = [];
77
+ for (const entry of configured) {
78
+ const spec = byId.get(entry.providerId);
79
+ if (!spec) {
80
+ attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
81
+ continue;
82
+ }
83
+ if (entry.health?.status === "offline") {
84
+ attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
85
+ continue;
86
+ }
87
+ options.before?.({ service: serviceKey, provider: entry.providerId });
88
+ try {
89
+ const result = await spec.invoke({
90
+ config: entry.config,
91
+ secret: (field) => options.credentials.get(entry.secretRef, field)
92
+ }, args);
93
+ attempts.push({ providerId: entry.providerId, outcome: "sent" });
94
+ await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
95
+ const sent = { ok: true, result, provider: entry.providerId, attempts };
96
+ options.after?.(sent);
97
+ return sent;
98
+ } catch (error) {
99
+ const kind = spec.classify(error);
100
+ attempts.push({
101
+ providerId: entry.providerId,
102
+ outcome: "failed",
103
+ kind,
104
+ error: error instanceof Error ? error.message : String(error)
105
+ });
106
+ await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
107
+ if (kind === "terminal") {
108
+ const refused = {
109
+ ok: false,
110
+ attempts,
111
+ error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
112
+ };
113
+ options.after?.(refused);
114
+ return refused;
115
+ }
116
+ }
117
+ }
118
+ const failed = {
119
+ ok: false,
120
+ attempts,
121
+ error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
122
+ };
123
+ options.after?.(failed);
124
+ return failed;
125
+ }
126
+ return { call };
127
+ }
128
+ var VERSION = "0.1.0";
129
+
130
+ // src/database.ts
131
+ var pools = new Map;
132
+ var arangodb = defineProvider({
133
+ id: "arangodb",
134
+ service: "database",
135
+ label: "ArangoDB",
136
+ multiInstance: true,
137
+ credentials: {
138
+ type: "object",
139
+ additionalProperties: false,
140
+ required: ["password"],
141
+ properties: {
142
+ password: { type: "string", title: "Password", writeOnly: true }
143
+ }
144
+ },
145
+ config: {
146
+ type: "object",
147
+ additionalProperties: false,
148
+ required: ["url", "database", "username"],
149
+ properties: {
150
+ url: {
151
+ type: "string",
152
+ title: "URL",
153
+ description: "Bind to loopback. A database reachable from the internet is a database that will be."
154
+ },
155
+ database: { type: "string", title: "Database" },
156
+ username: { type: "string", title: "Username" },
157
+ failover: {
158
+ type: "boolean",
159
+ default: false,
160
+ title: "Allow failover",
161
+ description: "Off by default. Writing to a different database because the first was slow is data loss with extra steps. Enable only for read replicas."
162
+ }
163
+ }
164
+ },
165
+ async invoke(context, request) {
166
+ const config = context.config;
167
+ if (!config.connect) {
168
+ throw new ProviderError("ARANGO_NO_CONNECT", "Provide a connect function in this provider's config — arangojs on a server; a database is not reachable from an edge runtime.");
169
+ }
170
+ const key = `${config.url}/${config.database}/${config.username}`;
171
+ let client = pools.get(key);
172
+ if (!client) {
173
+ client = config.connect({
174
+ url: config.url,
175
+ database: config.database,
176
+ username: config.username,
177
+ password: await context.secret("password")
178
+ });
179
+ pools.set(key, client);
180
+ }
181
+ const cursor = await client.query(request.query, request.bindVars);
182
+ return cursor.all();
183
+ },
184
+ classify(error) {
185
+ const arango = error;
186
+ if (arango.errorNum === 1210)
187
+ return "terminal";
188
+ if (arango.errorNum === 1202)
189
+ return "terminal";
190
+ if (arango.errorNum === 1501)
191
+ return "terminal";
192
+ if (arango.code === 400 || arango.code === 404 || arango.code === 409)
193
+ return "terminal";
194
+ if (arango.code === 401 || arango.code === 403)
195
+ return "retryable";
196
+ if (arango.code === 503)
197
+ return "backoff";
198
+ return "retryable";
199
+ }
200
+ });
201
+ function resetPools() {
202
+ pools.clear();
203
+ }
204
+ var databaseProviders = [arangodb];
205
+ export {
206
+ resetPools,
207
+ databaseProviders,
208
+ arangodb
209
+ };
@@ -0,0 +1,68 @@
1
+ import { type EmailMessage } from './index';
2
+ export interface JetEmailConfig {
3
+ /** EU residency endpoint. */
4
+ eu?: boolean;
5
+ endpoint?: string;
6
+ fetch?: typeof globalThis.fetch;
7
+ }
8
+ /**
9
+ * Status handling, from the published API:
10
+ *
11
+ * 201 sent 202 scheduled
12
+ * 400 bad input → TERMINAL: our payload is malformed and no relay will
13
+ * accept it, so trying the next one wastes a round trip
14
+ * and buries the real error
15
+ * 401 bad key → RETRYABLE: THIS key is wrong; the next provider's may
16
+ * be fine. This is the case people get wrong by treating
17
+ * any 4xx as fatal
18
+ * 409 idempotency → TERMINAL: the message already exists. Re-sending via
19
+ * another provider would deliver it twice
20
+ * 429 rate limited → BACKOFF: it is working, we are asking too fast
21
+ */
22
+ export declare const jetemail: import("./index").ProviderSpec<EmailMessage, {
23
+ id: string;
24
+ }>;
25
+ /**
26
+ * A transport, so this package holds no socket code and no nodemailer.
27
+ *
28
+ * Supplied by the host: on Bun or Node that is a nodemailer transport, in a
29
+ * Worker it is an HTTP relay. The provider is the same either way, which is
30
+ * what lets one config work in both places.
31
+ */
32
+ export interface SmtpTransport {
33
+ send(envelope: {
34
+ host: string;
35
+ port: number;
36
+ secure: boolean;
37
+ user?: string;
38
+ password?: string;
39
+ from: string;
40
+ to: readonly string[];
41
+ subject: string;
42
+ html?: string;
43
+ text?: string;
44
+ replyTo?: string;
45
+ }): Promise<{
46
+ messageId: string;
47
+ }>;
48
+ }
49
+ /**
50
+ * SMTP is `multiInstance`: a deployment may hold several named relays, each its
51
+ * own priority slot — a primary, a backup with a different vendor, and a
52
+ * loopback for development.
53
+ *
54
+ * SMTP reply codes carry the same distinction as HTTP status:
55
+ *
56
+ * 5xx permanent → TERMINAL for 550/553 (bad recipient — every relay agrees)
57
+ * RETRYABLE for 535 (auth failed — THIS relay's credentials)
58
+ * 4xx transient → RETRYABLE, and 421/450/451 specifically mean try later
59
+ */
60
+ export declare const smtp: import("./index").ProviderSpec<EmailMessage, {
61
+ id: string;
62
+ }>;
63
+ /** Ready to register. Order here is irrelevant; the config decides priority. */
64
+ export declare const emailProviders: readonly [import("./index").ProviderSpec<EmailMessage, {
65
+ id: string;
66
+ }>, import("./index").ProviderSpec<EmailMessage, {
67
+ id: string;
68
+ }>];