@aglyn/shared-util-email 1.0.0-beta.217 → 1.0.0-beta.219
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/README.md +58 -19
- package/package.json +3 -3
- package/src/index.d.ts +2 -0
- package/src/index.js +9 -1
- package/src/index.js.map +1 -1
- package/src/lib/email-deliverability.d.ts +9 -2
- package/src/lib/email-deliverability.js.map +1 -1
- package/src/lib/email-delivery-events.d.ts +41 -48
- package/src/lib/email-delivery-events.js +18 -247
- package/src/lib/email-delivery-events.js.map +1 -1
- package/src/lib/email-health.d.ts +30 -43
- package/src/lib/email-health.js +45 -141
- package/src/lib/email-health.js.map +1 -1
- package/src/lib/mail-provider-resend.d.ts +194 -0
- package/src/lib/mail-provider-resend.js +649 -0
- package/src/lib/mail-provider-resend.js.map +1 -0
- package/src/lib/mail-provider-webhook.d.ts +80 -0
- package/src/lib/mail-provider-webhook.js +156 -0
- package/src/lib/mail-provider-webhook.js.map +1 -0
- package/src/lib/mail-provider.d.ts +148 -0
- package/src/lib/mail-provider.js +84 -0
- package/src/lib/mail-provider.js.map +1 -0
- package/src/lib/mail-providers.d.ts +99 -0
- package/src/lib/mail-providers.js +182 -0
- package/src/lib/mail-providers.js.map +1 -0
- package/src/lib/received-email.d.ts +7 -44
- package/src/lib/received-email.js +8 -104
- package/src/lib/received-email.js.map +1 -1
- package/src/lib/risk-notice-catalog.js +7 -1
- package/src/lib/risk-notice-catalog.js.map +1 -1
- package/src/lib/send-ceilings.d.ts +0 -57
- package/src/lib/send-ceilings.js +0 -47
- package/src/lib/send-ceilings.js.map +1 -1
- package/src/lib/send-email.d.ts +36 -58
- package/src/lib/send-email.js +102 -133
- package/src/lib/send-email.js.map +1 -1
- package/src/lib/send-rate.js +4 -3
- package/src/lib/send-rate.js.map +1 -1
- package/src/lib/system-email-catalog.js +3 -3
- package/src/lib/system-email-catalog.js.map +1 -1
- package/src/lib/tenant-email-catalog.d.ts +7 -0
- package/src/lib/tenant-email-catalog.js +10 -591
- package/src/lib/tenant-email-catalog.js.map +1 -1
- package/src/lib/tenant-emails.generated.d.ts +11 -0
- package/src/lib/tenant-emails.generated.js +594 -0
- package/src/lib/tenant-emails.generated.js.map +1 -0
package/src/lib/email-health.js
CHANGED
|
@@ -15,42 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { getEmailConfig } from "./send-email.js";
|
|
18
|
-
|
|
19
|
-
* Where the credential probe asks its question.
|
|
20
|
-
*
|
|
21
|
-
* Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send
|
|
22
|
-
* attempt however empty its body is: it consumes an API call, and Resend
|
|
23
|
-
* records it in the account's logs as a `422` on `POST /emails` with no
|
|
24
|
-
* recipient, no subject and nothing identifying the caller — a line an
|
|
25
|
-
* operator reading that dashboard has to treat as failed mail. A domain read
|
|
26
|
-
* cannot create a message and cannot be mistaken for one.
|
|
27
|
-
*/ export const RESEND_DOMAINS_ENDPOINT = 'https://api.resend.com/domains';
|
|
28
|
-
/**
|
|
29
|
-
* Resend error names that mean the key itself was not accepted, as opposed to
|
|
30
|
-
* a key that authenticated and merely lacks read scope. Matched by NAME, not
|
|
31
|
-
* status: `401` and `403` each cover both meanings.
|
|
32
|
-
*/ const REFUSED_KEY_ERRORS = new Set([
|
|
33
|
-
'missing_api_key',
|
|
34
|
-
'validation_error',
|
|
35
|
-
'suspended_api_key'
|
|
36
|
-
]);
|
|
37
|
-
/**
|
|
38
|
-
* Resend error names that mean the key authenticated and was then denied this
|
|
39
|
-
* particular read. A sending-scoped key — the shape Aglyn provisions — always
|
|
40
|
-
* lands here, and reaching this answer at all required Resend to recognize
|
|
41
|
-
* the credential, which is exactly what the probe is asking.
|
|
42
|
-
*/ const AUTHENTICATED_BUT_UNSCOPED_ERRORS = new Set([
|
|
43
|
-
'restricted_api_key',
|
|
44
|
-
'invalid_permission'
|
|
45
|
-
]);
|
|
46
|
-
/** The `name` Resend puts on an error body, or `''` for anything else. */ function resendErrorName(body) {
|
|
47
|
-
try {
|
|
48
|
-
const parsed = JSON.parse(body);
|
|
49
|
-
return typeof (parsed == null ? void 0 : parsed.name) === 'string' ? parsed.name : '';
|
|
50
|
-
} catch (unused) {
|
|
51
|
-
return '';
|
|
52
|
-
}
|
|
53
|
-
}
|
|
18
|
+
import { mailProvider, mailProviderProblem, mailProviderReads } from "./mail-providers.js";
|
|
54
19
|
/**
|
|
55
20
|
* Describes the email configuration without revealing the API key.
|
|
56
21
|
*
|
|
@@ -58,83 +23,50 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
58
23
|
* otherwise only answerable by emailing a real person and waiting.
|
|
59
24
|
*/ export function describeEmailConfig() {
|
|
60
25
|
var _ref, _ref1;
|
|
61
|
-
const {
|
|
26
|
+
const { provider, missingSettings, from } = getEmailConfig();
|
|
62
27
|
const match = from == null ? void 0 : from.match(/<([^>]+)>/);
|
|
63
28
|
const address = ((_ref = (_ref1 = match == null ? void 0 : match[1]) != null ? _ref1 : from) != null ? _ref : '').trim();
|
|
64
29
|
const domain = address.includes('@') ? address.split('@').pop() : null;
|
|
65
30
|
return {
|
|
66
|
-
configured:
|
|
67
|
-
|
|
31
|
+
configured: !missingSettings.length && Boolean(from),
|
|
32
|
+
provider,
|
|
33
|
+
missingSettings,
|
|
34
|
+
providerProblem: mailProviderProblem(),
|
|
35
|
+
hasApiKey: !missingSettings.length,
|
|
68
36
|
hasFrom: Boolean(from),
|
|
69
37
|
from: from != null ? from : null,
|
|
70
38
|
fromDomain: domain
|
|
71
39
|
};
|
|
72
40
|
}
|
|
73
41
|
/**
|
|
74
|
-
*
|
|
75
|
-
* sending anything to anybody, and without leaving anything behind that
|
|
76
|
-
* as failed mail.
|
|
77
|
-
*
|
|
78
|
-
* How: a `GET` of the domains collection. The question is only ever "does
|
|
79
|
-
* Resend recognize this credential", so the probe reads the ERROR NAME rather
|
|
80
|
-
* than the status, because `401` and `403` each carry both meanings:
|
|
81
|
-
*
|
|
82
|
-
* - `2xx` — the key is accepted and has read scope → `ok`
|
|
83
|
-
* - `restricted_api_key` / `invalid_permission` — Resend authenticated the
|
|
84
|
-
* key and then denied it this read. A sending-scoped key, which is what
|
|
85
|
-
* Aglyn provisions, always answers this way, and getting the answer proves
|
|
86
|
-
* the credential works → `ok`
|
|
87
|
-
* - `missing_api_key` / `validation_error` / `suspended_api_key` — the key
|
|
88
|
-
* itself was refused → `invalid-key`
|
|
89
|
-
* - anything else → `unknown`
|
|
42
|
+
* Whether the provider accepts this deployment's credential — asked without
|
|
43
|
+
* sending anything to anybody, and without leaving anything behind that
|
|
44
|
+
* reads as failed mail. How it asks is the provider's own (see its
|
|
45
|
+
* `checkCredentials`).
|
|
90
46
|
*
|
|
91
|
-
* An
|
|
47
|
+
* An answer this cannot get is `unknown`, never `invalid-key`: this feeds a
|
|
92
48
|
* staff diagnostics screen whose whole value is that a red line means
|
|
93
|
-
* something
|
|
94
|
-
* working key is broken.
|
|
49
|
+
* something. A provider with no probe is `unknown` for the same reason.
|
|
95
50
|
*
|
|
96
51
|
* It cannot confirm that *domain verification* has completed — only a real
|
|
97
52
|
* send does that.
|
|
98
|
-
*/ export async function checkEmailCredentials() {
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
53
|
+
*/ export async function checkEmailCredentials(provider = mailProvider()) {
|
|
54
|
+
const problem = mailProviderProblem(provider);
|
|
55
|
+
if (problem) return {
|
|
56
|
+
status: 'unconfigured',
|
|
57
|
+
detail: problem
|
|
58
|
+
};
|
|
59
|
+
if (provider.missingSettings().length) return {
|
|
101
60
|
status: 'unconfigured'
|
|
102
61
|
};
|
|
103
|
-
|
|
104
|
-
const response = await fetch(RESEND_DOMAINS_ENDPOINT, {
|
|
105
|
-
method: 'GET',
|
|
106
|
-
headers: {
|
|
107
|
-
Authorization: `Bearer ${apiKey}`
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
const detail = (await response.text().catch(()=>'')).slice(0, 300);
|
|
111
|
-
if (response.status >= 200 && response.status < 300) {
|
|
112
|
-
return {
|
|
113
|
-
status: 'ok',
|
|
114
|
-
probeStatus: response.status
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
if (response.status === 401 || response.status === 403) {
|
|
118
|
-
const name = resendErrorName(detail);
|
|
119
|
-
if (AUTHENTICATED_BUT_UNSCOPED_ERRORS.has(name)) {
|
|
120
|
-
return {
|
|
121
|
-
status: 'ok',
|
|
122
|
-
probeStatus: response.status
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
if (REFUSED_KEY_ERRORS.has(name)) {
|
|
126
|
-
return {
|
|
127
|
-
status: 'invalid-key',
|
|
128
|
-
probeStatus: response.status,
|
|
129
|
-
detail
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
}
|
|
62
|
+
if (!provider.checkCredentials) {
|
|
133
63
|
return {
|
|
134
64
|
status: 'unknown',
|
|
135
|
-
|
|
136
|
-
detail
|
|
65
|
+
detail: `The "${provider.id}" mail provider has no credential probe; a test ` + 'send is the way to know it delivers.'
|
|
137
66
|
};
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return await provider.checkCredentials();
|
|
138
70
|
} catch (error) {
|
|
139
71
|
var _ref;
|
|
140
72
|
return {
|
|
@@ -152,19 +84,19 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
152
84
|
* site's transactional mail already failing — which is why the caller treats
|
|
153
85
|
* this as a blocker rather than a note.
|
|
154
86
|
*
|
|
155
|
-
* Read
|
|
156
|
-
*
|
|
157
|
-
* yields an authorization error that says nothing about the
|
|
158
|
-
* exactly how a pool the key could not send from reported
|
|
159
|
-
*
|
|
160
|
-
* pass: the caller must not treat "I could not look"
|
|
161
|
-
* fine".
|
|
87
|
+
* Read through the provider's READS, deliberately, and never with the
|
|
88
|
+
* sending credential. A sending-scoped key has no read permission, so asking
|
|
89
|
+
* it about domains yields an authorization error that says nothing about the
|
|
90
|
+
* domains — which is exactly how a pool the key could not send from reported
|
|
91
|
+
* healthy. A provider that cannot read answers `unreadable`, which is the
|
|
92
|
+
* honest answer and not a pass: the caller must not treat "I could not look"
|
|
93
|
+
* as "I looked and it was fine".
|
|
162
94
|
*
|
|
163
95
|
* `not-applicable` covers the self-host shape. The pool is a property of the
|
|
164
96
|
* Aglyn platform; an operator running their own deployment sends from their
|
|
165
97
|
* own domain and has no pool to be degraded.
|
|
166
98
|
*/ export async function checkSharedSendingPool(options) {
|
|
167
|
-
var
|
|
99
|
+
var _options_provider;
|
|
168
100
|
const pool = options.pool.filter(Boolean);
|
|
169
101
|
if (!pool.length) {
|
|
170
102
|
return {
|
|
@@ -174,50 +106,22 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
174
106
|
untracked: []
|
|
175
107
|
};
|
|
176
108
|
}
|
|
177
|
-
const
|
|
178
|
-
|
|
109
|
+
const reads = mailProviderReads((_options_provider = options.provider) != null ? _options_provider : mailProvider());
|
|
110
|
+
const unmet = reads.unmet();
|
|
111
|
+
if (unmet) {
|
|
179
112
|
return {
|
|
180
113
|
status: 'unreadable',
|
|
181
114
|
domains: [],
|
|
182
115
|
unusable: [],
|
|
183
116
|
untracked: [],
|
|
184
|
-
detail:
|
|
117
|
+
detail: `${unmet} The shared pool cannot be inspected without it.`
|
|
185
118
|
};
|
|
186
119
|
}
|
|
187
120
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
Authorization: `Bearer ${key}`
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
if (response.status < 200 || response.status >= 300) {
|
|
196
|
-
return {
|
|
197
|
-
status: 'unreadable',
|
|
198
|
-
domains: [],
|
|
199
|
-
unusable: [],
|
|
200
|
-
untracked: [],
|
|
201
|
-
detail: `The provider answered ${response.status} to the domain read.`
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
const payload = await response.json().catch(()=>null);
|
|
205
|
-
/**
|
|
206
|
-
* A tri-state, and the third state matters: a listing that does not carry
|
|
207
|
-
* the field must not be reported as the field being false. Off is a fault
|
|
208
|
-
* somebody should fix; unknown is a question this probe could not ask.
|
|
209
|
-
*/ const flag = (value)=>typeof value === 'boolean' ? value : null;
|
|
210
|
-
const byName = new Map();
|
|
211
|
-
for (const row of (_ref = payload == null ? void 0 : payload.data) != null ? _ref : []){
|
|
212
|
-
var _ref1, _ref2;
|
|
213
|
-
const name = String((_ref1 = row == null ? void 0 : row.name) != null ? _ref1 : '').toLowerCase();
|
|
214
|
-
if (!name) continue;
|
|
215
|
-
byName.set(name, {
|
|
216
|
-
status: String((_ref2 = row == null ? void 0 : row.status) != null ? _ref2 : 'unknown'),
|
|
217
|
-
click: flag(row == null ? void 0 : row.click_tracking),
|
|
218
|
-
open: flag(row == null ? void 0 : row.open_tracking)
|
|
219
|
-
});
|
|
220
|
-
}
|
|
121
|
+
const byName = new Map((await reads.sendingDomains()).map((row)=>[
|
|
122
|
+
row.name.toLowerCase(),
|
|
123
|
+
row
|
|
124
|
+
]));
|
|
221
125
|
const domains = pool.map((domain)=>{
|
|
222
126
|
var _ref, _ref1, _ref2;
|
|
223
127
|
const row = byName.get(domain.toLowerCase());
|
|
@@ -225,8 +129,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
225
129
|
domain,
|
|
226
130
|
status: (_ref = row == null ? void 0 : row.status) != null ? _ref : 'absent',
|
|
227
131
|
present: row !== undefined,
|
|
228
|
-
clickTracking: (_ref1 = row == null ? void 0 : row.
|
|
229
|
-
openTracking: (_ref2 = row == null ? void 0 : row.
|
|
132
|
+
clickTracking: (_ref1 = row == null ? void 0 : row.clickTracking) != null ? _ref1 : null,
|
|
133
|
+
openTracking: (_ref2 = row == null ? void 0 : row.openTracking) != null ? _ref2 : null
|
|
230
134
|
};
|
|
231
135
|
});
|
|
232
136
|
const unusable = domains.filter((entry)=>entry.status !== 'verified').map((entry)=>entry.domain);
|
|
@@ -250,13 +154,13 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
250
154
|
detail: `Click tracking is off for ${untracked.join(', ')}. Mail from ` + 'these domains delivers normally and reports a click rate of ' + 'exactly 0%, because the provider rewrites links only on a ' + 'domain that has a verified tracking subdomain.'
|
|
251
155
|
} : {});
|
|
252
156
|
} catch (error) {
|
|
253
|
-
var
|
|
157
|
+
var _ref;
|
|
254
158
|
return {
|
|
255
159
|
status: 'unreadable',
|
|
256
160
|
domains: [],
|
|
257
161
|
unusable: [],
|
|
258
162
|
untracked: [],
|
|
259
|
-
detail: String((
|
|
163
|
+
detail: String((_ref = error == null ? void 0 : error.message) != null ? _ref : error).slice(0, 300)
|
|
260
164
|
};
|
|
261
165
|
}
|
|
262
166
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-health.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getEmailConfig } from './send-email'\n\n/**\n * Where the credential probe asks its question.\n *\n * Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send\n * attempt however empty its body is: it consumes an API call, and Resend\n * records it in the account's logs as a `422` on `POST /emails` with no\n * recipient, no subject and nothing identifying the caller — a line an\n * operator reading that dashboard has to treat as failed mail. A domain read\n * cannot create a message and cannot be mistaken for one.\n */\nexport const RESEND_DOMAINS_ENDPOINT = 'https://api.resend.com/domains'\n\n/**\n * Resend error names that mean the key itself was not accepted, as opposed to\n * a key that authenticated and merely lacks read scope. Matched by NAME, not\n * status: `401` and `403` each cover both meanings.\n */\nconst REFUSED_KEY_ERRORS = new Set([\n 'missing_api_key',\n 'validation_error',\n 'suspended_api_key',\n])\n\n/**\n * Resend error names that mean the key authenticated and was then denied this\n * particular read. A sending-scoped key — the shape Aglyn provisions — always\n * lands here, and reaching this answer at all required Resend to recognize\n * the credential, which is exactly what the probe is asking.\n */\nconst AUTHENTICATED_BUT_UNSCOPED_ERRORS = new Set([\n 'restricted_api_key',\n 'invalid_permission',\n])\n\n/** The `name` Resend puts on an error body, or `''` for anything else. */\nfunction resendErrorName(body: string): string {\n try {\n const parsed = JSON.parse(body) as { name?: unknown }\n return typeof parsed?.name === 'string' ? parsed.name : ''\n } catch {\n return ''\n }\n}\n\nexport interface EmailConfigReport {\n /** Both env vars present — mail will at least be attempted. */\n configured: boolean\n hasApiKey: boolean\n hasFrom: boolean\n /**\n * The configured sender, e.g. `Aglyn <noreply@aglyn.com>`. Not a secret —\n * it appears in the headers of every message we send.\n */\n from: string | null\n /** Domain part of the sender, which is what must be verified in Resend. */\n fromDomain: string | null\n}\n\n/**\n * Describes the email configuration without revealing the API key.\n *\n * Answers \"is this environment able to send mail?\" — the question that is\n * otherwise only answerable by emailing a real person and waiting.\n */\nexport function describeEmailConfig(): EmailConfigReport {\n const { apiKey, from } = getEmailConfig()\n const match = from?.match(/<([^>]+)>/)\n const address = (match?.[1] ?? from ?? '').trim()\n const domain = address.includes('@') ? address.split('@').pop()! : null\n return {\n configured: Boolean(apiKey && from),\n hasApiKey: Boolean(apiKey),\n hasFrom: Boolean(from),\n from: from ?? null,\n fromDomain: domain,\n }\n}\n\nexport type EmailCredentialStatus =\n | 'ok'\n | 'unconfigured'\n | 'invalid-key'\n | 'unknown'\n\nexport interface EmailCredentialReport {\n status: EmailCredentialStatus\n /** HTTP status Resend answered the probe with, when it answered. */\n probeStatus?: number\n detail?: string\n}\n\n/**\n * Checks whether `RESEND_API_KEY` is actually accepted by Resend — without\n * sending anything to anybody, and without leaving anything behind that reads\n * as failed mail.\n *\n * How: a `GET` of the domains collection. The question is only ever \"does\n * Resend recognize this credential\", so the probe reads the ERROR NAME rather\n * than the status, because `401` and `403` each carry both meanings:\n *\n * - `2xx` — the key is accepted and has read scope → `ok`\n * - `restricted_api_key` / `invalid_permission` — Resend authenticated the\n * key and then denied it this read. A sending-scoped key, which is what\n * Aglyn provisions, always answers this way, and getting the answer proves\n * the credential works → `ok`\n * - `missing_api_key` / `validation_error` / `suspended_api_key` — the key\n * itself was refused → `invalid-key`\n * - anything else → `unknown`\n *\n * An unrecognized rejection is `unknown`, never `invalid-key`: this feeds a\n * staff diagnostics screen whose whole value is that a red line means\n * something, and a shape we have not seen before is not evidence that a\n * working key is broken.\n *\n * It cannot confirm that *domain verification* has completed — only a real\n * send does that.\n */\nexport async function checkEmailCredentials(): Promise<EmailCredentialReport> {\n const { apiKey } = getEmailConfig()\n if (!apiKey) return { status: 'unconfigured' }\n\n try {\n const response = await fetch(RESEND_DOMAINS_ENDPOINT, {\n method: 'GET',\n headers: { Authorization: `Bearer ${apiKey}` },\n })\n const detail = (await response.text().catch(() => '')).slice(0, 300)\n\n if (response.status >= 200 && response.status < 300) {\n return { status: 'ok', probeStatus: response.status }\n }\n if (response.status === 401 || response.status === 403) {\n const name = resendErrorName(detail)\n if (AUTHENTICATED_BUT_UNSCOPED_ERRORS.has(name)) {\n return { status: 'ok', probeStatus: response.status }\n }\n if (REFUSED_KEY_ERRORS.has(name)) {\n return { status: 'invalid-key', probeStatus: response.status, detail }\n }\n }\n return { status: 'unknown', probeStatus: response.status, detail }\n } catch (error) {\n return {\n status: 'unknown',\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n\n/** One pool member, as the provider reports it. */\nexport interface SharedPoolDomainReport {\n domain: string\n /** `verified` when the provider will accept mail on it. */\n status: string\n /** False when the provider has never heard of it. */\n present: boolean\n /**\n * Whether the provider will rewrite links on this domain to measure clicks.\n *\n * Reported because the symptom of it being off is INVISIBLE. A provider\n * counts a click by rewriting every `<a href>` to a tracking host, so a\n * domain without one produces a click rate of exactly 0% — which reads on a\n * dashboard as an audience that does not click, not as a domain that cannot\n * count. The last time this was wrong nobody found it by looking at the\n * numbers; it was found by reading a delivered message's source.\n *\n * `null` when the provider's listing does not say, so \"it did not tell us\"\n * is never reported as \"it is off\".\n */\n clickTracking: boolean | null\n /** The same, for the open pixel. */\n openTracking: boolean | null\n}\n\nexport type SharedPoolStatus =\n /** No platform pool applies to this deployment. */\n | 'not-applicable'\n /** No read credential, so the pool cannot be inspected. */\n | 'unreadable'\n | 'ok'\n | 'degraded'\n\nexport interface SharedPoolReport {\n status: SharedPoolStatus\n domains: SharedPoolDomainReport[]\n /** Members the provider will not accept mail on right now. */\n unusable: string[]\n /**\n * Members that will carry mail but cannot measure a click on it.\n *\n * Reported APART from {@link unusable} because the two are different\n * severities and conflating them would be wrong in both directions: a\n * member here is delivering perfectly, and a member in `unusable` is not\n * delivering at all. This one does not degrade the pool — see\n * {@link SharedPoolReport.status} — it is a measurement fault, and stopping\n * mail over it would be the control causing the outage.\n */\n untracked: string[]\n detail?: string\n}\n\n/**\n * Whether the shared platform pool can actually carry mail.\n *\n * The pool is the delivery floor: a site with no domain of its own sends its\n * receipts, password resets and booking confirmations from a member of it. So\n * a degraded pool is not a warning about a future problem, it is every such\n * site's transactional mail already failing — which is why the caller treats\n * this as a blocker rather than a note.\n *\n * Read with `RESEND_READ_API_KEY`, deliberately, and never with the sending\n * key. A sending-scoped key has no read permission, so asking it about domains\n * yields an authorization error that says nothing about the domains — which is\n * exactly how a pool the key could not send from reported healthy. Without a\n * read key this answers `unreadable`, which is the honest answer and not a\n * pass: the caller must not treat \"I could not look\" as \"I looked and it was\n * fine\".\n *\n * `not-applicable` covers the self-host shape. The pool is a property of the\n * Aglyn platform; an operator running their own deployment sends from their\n * own domain and has no pool to be degraded.\n */\nexport async function checkSharedSendingPool(options: {\n pool: string[]\n readApiKey?: string\n endpoint?: string\n}): Promise<SharedPoolReport> {\n const pool = options.pool.filter(Boolean)\n if (!pool.length) {\n return {\n status: 'not-applicable',\n domains: [],\n unusable: [],\n untracked: [],\n }\n }\n\n const key = String(options.readApiKey ?? '').trim()\n if (!key) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail:\n 'RESEND_READ_API_KEY is not set, so the shared pool cannot be ' +\n 'inspected. The sending key has no read permission and would report ' +\n 'an authorization error rather than the state of the domains.',\n }\n }\n\n try {\n const response = await fetch(options.endpoint ?? RESEND_DOMAINS_ENDPOINT, {\n method: 'GET',\n headers: { Authorization: `Bearer ${key}` },\n })\n if (response.status < 200 || response.status >= 300) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: `The provider answered ${response.status} to the domain read.`,\n }\n }\n const payload = (await response.json().catch(() => null)) as\n | {\n data?: Array<{\n name?: unknown\n status?: unknown\n click_tracking?: unknown\n open_tracking?: unknown\n }>\n }\n | null\n /**\n * A tri-state, and the third state matters: a listing that does not carry\n * the field must not be reported as the field being false. Off is a fault\n * somebody should fix; unknown is a question this probe could not ask.\n */\n const flag = (value: unknown): boolean | null =>\n typeof value === 'boolean' ? value : null\n const byName = new Map<\n string,\n { status: string; click: boolean | null; open: boolean | null }\n >()\n for (const row of payload?.data ?? []) {\n const name = String(row?.name ?? '').toLowerCase()\n if (!name) continue\n byName.set(name, {\n status: String(row?.status ?? 'unknown'),\n click: flag(row?.click_tracking),\n open: flag(row?.open_tracking),\n })\n }\n const domains = pool.map((domain) => {\n const row = byName.get(domain.toLowerCase())\n return {\n domain,\n status: row?.status ?? 'absent',\n present: row !== undefined,\n clickTracking: row?.click ?? null,\n openTracking: row?.open ?? null,\n }\n })\n const unusable = domains\n .filter((entry) => entry.status !== 'verified')\n .map((entry) => entry.domain)\n /*\n * Only a definite `false` counts. A member the provider did not describe\n * is already reported by `present`, and listing it here as well would\n * send an operator to fix a setting they cannot see.\n */\n const untracked = domains\n .filter((entry) => entry.clickTracking === false)\n .map((entry) => entry.domain)\n return {\n /*\n * `untracked` deliberately does NOT degrade the pool. `degraded` is\n * read as \"transactional mail is failing right now\" and is treated as a\n * blocker; a domain that delivers but does not count clicks is neither.\n * Folding the two together would either raise a false alarm about\n * delivery or teach an operator to ignore the real one.\n */\n status: unusable.length ? 'degraded' : 'ok',\n domains,\n unusable,\n untracked,\n ...(untracked.length\n ? {\n detail:\n `Click tracking is off for ${untracked.join(', ')}. Mail from ` +\n 'these domains delivers normally and reports a click rate of ' +\n 'exactly 0%, because the provider rewrites links only on a ' +\n 'domain that has a verified tracking subdomain.',\n }\n : {}),\n }\n } catch (error) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n"],"names":["getEmailConfig","RESEND_DOMAINS_ENDPOINT","REFUSED_KEY_ERRORS","Set","AUTHENTICATED_BUT_UNSCOPED_ERRORS","resendErrorName","body","parsed","JSON","parse","name","describeEmailConfig","match","apiKey","from","address","trim","domain","includes","split","pop","configured","Boolean","hasApiKey","hasFrom","fromDomain","checkEmailCredentials","status","response","fetch","method","headers","Authorization","detail","text","catch","slice","probeStatus","has","error","String","message","checkSharedSendingPool","options","pool","filter","length","domains","unusable","untracked","key","readApiKey","endpoint","payload","json","flag","value","byName","Map","row","data","toLowerCase","set","click","click_tracking","open","open_tracking","map","get","present","undefined","clickTracking","openTracking","entry","join"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/shared/util/email/src/lib/email-health.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getEmailConfig } from './send-email'\nimport type { MailProvider } from './mail-provider'\nimport {\n mailProvider,\n mailProviderProblem,\n mailProviderReads,\n} from './mail-providers'\n\nexport interface EmailConfigReport {\n /** The provider has its settings and a sender is set — mail will at least be attempted. */\n configured: boolean\n /** The id of the provider mail is handed to — see `mail-providers.ts`. */\n provider: string\n /** Settings that provider still needs before it can send, as an operator names them. */\n missingSettings: string[]\n /**\n * Set when the operator chose a provider this process does not have: why\n * no mail is sent. Not the same as a missing key — see `mailProviderProblem`.\n */\n providerProblem: string | null\n /** The provider has what it needs to send. */\n hasApiKey: boolean\n hasFrom: boolean\n /**\n * The configured sender, e.g. `Aglyn <noreply@aglyn.com>`. Not a secret —\n * it appears in the headers of every message we send.\n */\n from: string | null\n /** Domain part of the sender, which is what must be verified with the provider. */\n fromDomain: string | null\n}\n\n/**\n * Describes the email configuration without revealing the API key.\n *\n * Answers \"is this environment able to send mail?\" — the question that is\n * otherwise only answerable by emailing a real person and waiting.\n */\nexport function describeEmailConfig(): EmailConfigReport {\n const { provider, missingSettings, from } = getEmailConfig()\n const match = from?.match(/<([^>]+)>/)\n const address = (match?.[1] ?? from ?? '').trim()\n const domain = address.includes('@') ? address.split('@').pop()! : null\n return {\n configured: !missingSettings.length && Boolean(from),\n provider,\n missingSettings,\n providerProblem: mailProviderProblem(),\n hasApiKey: !missingSettings.length,\n hasFrom: Boolean(from),\n from: from ?? null,\n fromDomain: domain,\n }\n}\n\nexport type EmailCredentialStatus =\n | 'ok'\n | 'unconfigured'\n | 'invalid-key'\n | 'unknown'\n\nexport interface EmailCredentialReport {\n status: EmailCredentialStatus\n /** HTTP status the provider answered the probe with, when it answered. */\n probeStatus?: number\n detail?: string\n}\n\n/**\n * Whether the provider accepts this deployment's credential — asked without\n * sending anything to anybody, and without leaving anything behind that\n * reads as failed mail. How it asks is the provider's own (see its\n * `checkCredentials`).\n *\n * An answer this cannot get is `unknown`, never `invalid-key`: this feeds a\n * staff diagnostics screen whose whole value is that a red line means\n * something. A provider with no probe is `unknown` for the same reason.\n *\n * It cannot confirm that *domain verification* has completed — only a real\n * send does that.\n */\nexport async function checkEmailCredentials(\n provider: MailProvider = mailProvider(),\n): Promise<EmailCredentialReport> {\n const problem = mailProviderProblem(provider)\n if (problem) return { status: 'unconfigured', detail: problem }\n if (provider.missingSettings().length) return { status: 'unconfigured' }\n if (!provider.checkCredentials) {\n return {\n status: 'unknown',\n detail:\n `The \"${provider.id}\" mail provider has no credential probe; a test ` +\n 'send is the way to know it delivers.',\n }\n }\n try {\n return await provider.checkCredentials()\n } catch (error) {\n return {\n status: 'unknown',\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n\n/** One pool member, as the provider reports it. */\nexport interface SharedPoolDomainReport {\n domain: string\n /** `verified` when the provider will accept mail on it. */\n status: string\n /** False when the provider has never heard of it. */\n present: boolean\n /**\n * Whether the provider will rewrite links on this domain to measure clicks.\n *\n * Reported because the symptom of it being off is INVISIBLE. A provider\n * counts a click by rewriting every `<a href>` to a tracking host, so a\n * domain without one produces a click rate of exactly 0% — which reads on a\n * dashboard as an audience that does not click, not as a domain that cannot\n * count. The last time this was wrong nobody found it by looking at the\n * numbers; it was found by reading a delivered message's source.\n *\n * `null` when the provider's listing does not say, so \"it did not tell us\"\n * is never reported as \"it is off\".\n */\n clickTracking: boolean | null\n /** The same, for the open pixel. */\n openTracking: boolean | null\n}\n\nexport type SharedPoolStatus =\n /** No platform pool applies to this deployment. */\n | 'not-applicable'\n /** No read credential, so the pool cannot be inspected. */\n | 'unreadable'\n | 'ok'\n | 'degraded'\n\nexport interface SharedPoolReport {\n status: SharedPoolStatus\n domains: SharedPoolDomainReport[]\n /** Members the provider will not accept mail on right now. */\n unusable: string[]\n /**\n * Members that will carry mail but cannot measure a click on it.\n *\n * Reported APART from {@link unusable} because the two are different\n * severities and conflating them would be wrong in both directions: a\n * member here is delivering perfectly, and a member in `unusable` is not\n * delivering at all. This one does not degrade the pool — see\n * {@link SharedPoolReport.status} — it is a measurement fault, and stopping\n * mail over it would be the control causing the outage.\n */\n untracked: string[]\n detail?: string\n}\n\n/**\n * Whether the shared platform pool can actually carry mail.\n *\n * The pool is the delivery floor: a site with no domain of its own sends its\n * receipts, password resets and booking confirmations from a member of it. So\n * a degraded pool is not a warning about a future problem, it is every such\n * site's transactional mail already failing — which is why the caller treats\n * this as a blocker rather than a note.\n *\n * Read through the provider's READS, deliberately, and never with the\n * sending credential. A sending-scoped key has no read permission, so asking\n * it about domains yields an authorization error that says nothing about the\n * domains — which is exactly how a pool the key could not send from reported\n * healthy. A provider that cannot read answers `unreadable`, which is the\n * honest answer and not a pass: the caller must not treat \"I could not look\"\n * as \"I looked and it was fine\".\n *\n * `not-applicable` covers the self-host shape. The pool is a property of the\n * Aglyn platform; an operator running their own deployment sends from their\n * own domain and has no pool to be degraded.\n */\nexport async function checkSharedSendingPool(options: {\n pool: string[]\n /** The provider to ask; the deployment's own by default. */\n provider?: MailProvider\n}): Promise<SharedPoolReport> {\n const pool = options.pool.filter(Boolean)\n if (!pool.length) {\n return {\n status: 'not-applicable',\n domains: [],\n unusable: [],\n untracked: [],\n }\n }\n\n const reads = mailProviderReads(options.provider ?? mailProvider())\n const unmet = reads.unmet()\n if (unmet) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: `${unmet} The shared pool cannot be inspected without it.`,\n }\n }\n\n try {\n const byName = new Map(\n (await reads.sendingDomains()).map((row) => [row.name.toLowerCase(), row]),\n )\n const domains = pool.map((domain) => {\n const row = byName.get(domain.toLowerCase())\n return {\n domain,\n status: row?.status ?? 'absent',\n present: row !== undefined,\n clickTracking: row?.clickTracking ?? null,\n openTracking: row?.openTracking ?? null,\n }\n })\n const unusable = domains\n .filter((entry) => entry.status !== 'verified')\n .map((entry) => entry.domain)\n /*\n * Only a definite `false` counts. A member the provider did not describe\n * is already reported by `present`, and listing it here as well would\n * send an operator to fix a setting they cannot see.\n */\n const untracked = domains\n .filter((entry) => entry.clickTracking === false)\n .map((entry) => entry.domain)\n return {\n /*\n * `untracked` deliberately does NOT degrade the pool. `degraded` is\n * read as \"transactional mail is failing right now\" and is treated as a\n * blocker; a domain that delivers but does not count clicks is neither.\n * Folding the two together would either raise a false alarm about\n * delivery or teach an operator to ignore the real one.\n */\n status: unusable.length ? 'degraded' : 'ok',\n domains,\n unusable,\n untracked,\n ...(untracked.length\n ? {\n detail:\n `Click tracking is off for ${untracked.join(', ')}. Mail from ` +\n 'these domains delivers normally and reports a click rate of ' +\n 'exactly 0%, because the provider rewrites links only on a ' +\n 'domain that has a verified tracking subdomain.',\n }\n : {}),\n }\n } catch (error) {\n return {\n status: 'unreadable',\n domains: [],\n unusable: [],\n untracked: [],\n detail: String((error as Error)?.message ?? error).slice(0, 300),\n }\n }\n}\n"],"names":["getEmailConfig","mailProvider","mailProviderProblem","mailProviderReads","describeEmailConfig","match","provider","missingSettings","from","address","trim","domain","includes","split","pop","configured","length","Boolean","providerProblem","hasApiKey","hasFrom","fromDomain","checkEmailCredentials","problem","status","detail","checkCredentials","id","error","String","message","slice","checkSharedSendingPool","options","pool","filter","domains","unusable","untracked","reads","unmet","byName","Map","sendingDomains","map","row","name","toLowerCase","get","present","undefined","clickTracking","openTracking","entry","join"],"mappings":"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"}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* RESEND, as one {@link MailProvider} — the only module in the platform that
|
|
19
|
+
* knows Resend's endpoints, payloads, event names and error names.
|
|
20
|
+
*
|
|
21
|
+
* It is the provider a deployment gets when it names none, which is how
|
|
22
|
+
* every deployment that set `RESEND_API_KEY` before the contract existed
|
|
23
|
+
* keeps sending exactly as it did. Two credentials, on purpose:
|
|
24
|
+
*
|
|
25
|
+
* - `RESEND_API_KEY` sends. It is provisioned sending-scoped, so a leaked
|
|
26
|
+
* copy cannot enumerate everyone the platform ever wrote to.
|
|
27
|
+
* - `RESEND_READ_API_KEY` is full-access and only ever reads: history, one
|
|
28
|
+
* message's body, received mail and the account's sending domains. A
|
|
29
|
+
* sending-scoped key answers every such read `401 restricted_api_key`.
|
|
30
|
+
*
|
|
31
|
+
* Pure but for the `fetch` calls, each inside a function that must be called
|
|
32
|
+
* to do anything, so a client component that reaches this module through the
|
|
33
|
+
* library's barrel executes nothing on load.
|
|
34
|
+
*/
|
|
35
|
+
import { type EmailDeliveryEvent, type EmailDeliveryHistorySource, type EmailDeliveryMessage, type EmailDeliveryMessageSource, type EmailDeliverySnapshot } from './email-delivery-events';
|
|
36
|
+
import type { EmailCredentialReport } from './email-health';
|
|
37
|
+
import { type MailInboundEvent, type MailProvider, type MailProviderMessage, type MailSendingDomain } from './mail-provider';
|
|
38
|
+
import type { ReceivedEmail, ReceivedEmailSource } from './received-email';
|
|
39
|
+
/** The slug stored on every delivery record this provider produces. */
|
|
40
|
+
export declare const RESEND_PROVIDER_ID = "resend";
|
|
41
|
+
/** The sending key's environment variable. */
|
|
42
|
+
export declare const RESEND_SEND_KEY_SETTING = "RESEND_API_KEY";
|
|
43
|
+
/** The full-access read key's environment variable. */
|
|
44
|
+
export declare const RESEND_READ_KEY_SETTING = "RESEND_READ_API_KEY";
|
|
45
|
+
type Env = Record<string, string | undefined>;
|
|
46
|
+
/** The send endpoint. */
|
|
47
|
+
export declare const RESEND_SEND_ENDPOINT = "https://api.resend.com/emails";
|
|
48
|
+
/** A Resend send payload in the provider's own wire shape. */
|
|
49
|
+
export interface ResendSendPayload {
|
|
50
|
+
to?: unknown;
|
|
51
|
+
from?: unknown;
|
|
52
|
+
subject?: unknown;
|
|
53
|
+
[field: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The one place that POSTs to Resend's send endpoint, and the last thing
|
|
57
|
+
* standing between a payload and the network.
|
|
58
|
+
*
|
|
59
|
+
* A payload carrying no recipient cannot become a message. Resend answers it
|
|
60
|
+
* `422 missing_required_field`, which costs an API call and then shows up in
|
|
61
|
+
* the vendor dashboard as a red line indistinguishable from mail that
|
|
62
|
+
* genuinely failed to deliver — carrying no subject, no recipient and nothing
|
|
63
|
+
* naming the code that produced it. Diagnosing that means reading a log
|
|
64
|
+
* outside the deployment and guessing. So the refusal happens here, before
|
|
65
|
+
* the fetch, and names the caller's `context`.
|
|
66
|
+
*
|
|
67
|
+
* It throws rather than returning a result: this is a programming error,
|
|
68
|
+
* not a delivery outcome. `sendEmail` filters recipients well before it
|
|
69
|
+
* reaches this call, so nothing on the ordinary path can trip it. The guard
|
|
70
|
+
* exists because `RESEND_SEND_ENDPOINT` is exported and any module can
|
|
71
|
+
* therefore reach the send endpoint on its own, bypassing every check
|
|
72
|
+
* `sendEmail` owns.
|
|
73
|
+
*/
|
|
74
|
+
export declare function postResendEmail(apiKey: string, payload: ResendSendPayload, context?: string): Promise<Response>;
|
|
75
|
+
/** One message as Resend's send endpoint takes it. */
|
|
76
|
+
export declare function resendSendPayload(message: MailProviderMessage): ResendSendPayload;
|
|
77
|
+
/**
|
|
78
|
+
* Where the credential probe and the domain read ask their questions.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately NOT the send endpoint. A probe aimed at `/emails` is a send
|
|
81
|
+
* attempt however empty its body is: it consumes an API call, and Resend
|
|
82
|
+
* records it in the account's logs as a `422` on `POST /emails` with no
|
|
83
|
+
* recipient, no subject and nothing identifying the caller — a line an
|
|
84
|
+
* operator reading that dashboard has to treat as failed mail. A domain read
|
|
85
|
+
* cannot create a message and cannot be mistaken for one.
|
|
86
|
+
*/
|
|
87
|
+
export declare const RESEND_DOMAINS_ENDPOINT = "https://api.resend.com/domains";
|
|
88
|
+
/**
|
|
89
|
+
* Whether the sending key is accepted — without sending anything to anybody,
|
|
90
|
+
* and without leaving anything behind that reads as failed mail.
|
|
91
|
+
*
|
|
92
|
+
* How: a `GET` of the domains collection. The question is only ever "does
|
|
93
|
+
* Resend recognize this credential", so the probe reads the ERROR NAME rather
|
|
94
|
+
* than the status, because `401` and `403` each carry both meanings:
|
|
95
|
+
*
|
|
96
|
+
* - `2xx` — the key is accepted and has read scope → `ok`
|
|
97
|
+
* - `restricted_api_key` / `invalid_permission` — Resend authenticated the
|
|
98
|
+
* key and then denied it this read. A sending-scoped key always answers
|
|
99
|
+
* this way, and getting the answer proves the credential works → `ok`
|
|
100
|
+
* - `missing_api_key` / `validation_error` / `suspended_api_key` — the key
|
|
101
|
+
* itself was refused → `invalid-key`
|
|
102
|
+
* - anything else → `unknown`
|
|
103
|
+
*
|
|
104
|
+
* An unrecognized rejection is `unknown`, never `invalid-key`: this feeds a
|
|
105
|
+
* staff diagnostics screen whose whole value is that a red line means
|
|
106
|
+
* something, and a shape we have not seen before is not evidence that a
|
|
107
|
+
* working key is broken.
|
|
108
|
+
*/
|
|
109
|
+
export declare function checkResendCredentials(apiKey: string): Promise<EmailCredentialReport>;
|
|
110
|
+
/**
|
|
111
|
+
* Every sending domain on the account, with its verification and tracking
|
|
112
|
+
* state. Throws with the status when Resend does not answer 2xx, so a caller
|
|
113
|
+
* never reads "I could not look" as "there is nothing there".
|
|
114
|
+
*/
|
|
115
|
+
export declare function readResendSendingDomains(apiKey: string, endpoint?: string): Promise<MailSendingDomain[]>;
|
|
116
|
+
/**
|
|
117
|
+
* Turns one Resend webhook payload into zero or more of our events.
|
|
118
|
+
*
|
|
119
|
+
* One event per recipient, not per message: a send addressed to three people
|
|
120
|
+
* produces one webhook, and a staff view keyed on a person has to be able to
|
|
121
|
+
* find it under each of them.
|
|
122
|
+
*
|
|
123
|
+
* Returns an empty array for anything unrecognised — a contact or domain
|
|
124
|
+
* event, an inbound `email.received`, a type added after this was written.
|
|
125
|
+
* Silence rather than a throw, because a webhook handler that 500s on an
|
|
126
|
+
* unfamiliar event teaches the provider to retry it forever.
|
|
127
|
+
*
|
|
128
|
+
* @param payload The parsed webhook body.
|
|
129
|
+
* @param receivedAtMs Fallback timestamp for a payload that carries none.
|
|
130
|
+
*/
|
|
131
|
+
export declare function normalizeResendDeliveryEvents(payload: unknown, receivedAtMs: number): EmailDeliveryEvent[];
|
|
132
|
+
/**
|
|
133
|
+
* Turns one entry from Resend's `GET /emails` list into zero or more
|
|
134
|
+
* snapshots — one per recipient, for the same reason the event adapter fans
|
|
135
|
+
* out: the staff view is keyed on a person.
|
|
136
|
+
*
|
|
137
|
+
* An unrecognised `last_event` falls back to `sent` rather than being
|
|
138
|
+
* dropped. The message demonstrably exists and was addressed to somebody, and
|
|
139
|
+
* "we sent this and cannot characterise what happened next" is a far more
|
|
140
|
+
* useful row than no row — which is the state that sent a staffer to the
|
|
141
|
+
* vendor dashboard in the first place.
|
|
142
|
+
*/
|
|
143
|
+
export declare function normalizeResendSentEmails(raw: unknown): EmailDeliverySnapshot[];
|
|
144
|
+
/** The list endpoint. Paginates with `after=<id>`; caps at 100. */
|
|
145
|
+
export declare const RESEND_EMAILS_ENDPOINT = "https://api.resend.com/emails";
|
|
146
|
+
/**
|
|
147
|
+
* {@link EmailDeliveryHistorySource} for Resend. Needs the full-access key.
|
|
148
|
+
*
|
|
149
|
+
* The list endpoint takes `limit`, `after` and `before` and no recipient
|
|
150
|
+
* filter, which is why the source is a cursor-paged sweep.
|
|
151
|
+
*/
|
|
152
|
+
export declare function resendDeliveryHistorySource(apiKey: string): EmailDeliveryHistorySource;
|
|
153
|
+
/** Resend's `GET /emails/:id` payload, in our vocabulary. */
|
|
154
|
+
export declare function normalizeResendMessage(raw: unknown): EmailDeliveryMessage | null;
|
|
155
|
+
/** Resend's single-message endpoint. Needs the full-access key. */
|
|
156
|
+
export declare function resendDeliveryMessageSource(apiKey: string): EmailDeliveryMessageSource;
|
|
157
|
+
/** The webhook event type a received message arrives as. */
|
|
158
|
+
export declare const RESEND_RECEIVED_EVENT = "email.received";
|
|
159
|
+
/** `GET {endpoint}/{id}` — one received message with its text and headers. */
|
|
160
|
+
export declare const RESEND_RECEIVING_ENDPOINT = "https://api.resend.com/emails/receiving";
|
|
161
|
+
/**
|
|
162
|
+
* The provider's id of the message an `email.received` event announces,
|
|
163
|
+
* or `null` for any other event — including a malformed one, which is
|
|
164
|
+
* acknowledged and dropped rather than retried forever.
|
|
165
|
+
*/
|
|
166
|
+
export declare function resendReceivedEventId(event: unknown): string | null;
|
|
167
|
+
/**
|
|
168
|
+
* Every recipient the event names — To, Cc, Bcc and the addresses the
|
|
169
|
+
* message was received for — so a route can find what it is looking for in
|
|
170
|
+
* the metadata before it spends a read on the body.
|
|
171
|
+
*/
|
|
172
|
+
export declare function resendReceivedEventRecipients(event: unknown): string[];
|
|
173
|
+
/**
|
|
174
|
+
* One received message as the receiving API answers it, in the neutral
|
|
175
|
+
* shape. `null` for a payload with no id, which is not a message.
|
|
176
|
+
*/
|
|
177
|
+
export declare function normalizeResendReceivedEmail(raw: unknown, nowMs?: number): ReceivedEmail | null;
|
|
178
|
+
/**
|
|
179
|
+
* {@link ReceivedEmailSource} for Resend. Needs the full-access key. A `404`
|
|
180
|
+
* is `null` — the message expired or never existed — and anything else
|
|
181
|
+
* throws with the status, so the caller decides whether the provider should
|
|
182
|
+
* retry.
|
|
183
|
+
*/
|
|
184
|
+
export declare function resendReceivedEmailSource(apiKey: string, fetchImpl?: typeof fetch): ReceivedEmailSource;
|
|
185
|
+
/** A received-mail notification, or `null` for any other event. */
|
|
186
|
+
export declare function resendInboundEvent(event: unknown): MailInboundEvent | null;
|
|
187
|
+
/**
|
|
188
|
+
* Resend as a {@link MailProvider}, reading its two keys from `env` at each
|
|
189
|
+
* call — never at module load, because these run in serverless handlers
|
|
190
|
+
* whose module may be evaluated during a build, long before the runtime
|
|
191
|
+
* environment exists.
|
|
192
|
+
*/
|
|
193
|
+
export declare function resendMailProvider(env?: Env): MailProvider;
|
|
194
|
+
export {};
|