@primitivedotdev/sdk 1.26.0 → 1.27.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.
- package/README.md +58 -1
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +4 -4
- package/dist/{api-BXlPoOdE.js → api-D4xf448r.js} +506 -3
- package/dist/contract/index.d.ts +1 -1
- package/dist/contract/index.js +2 -1
- package/dist/{errors-BgI9FKHK.d.ts → errors-CCJX3I_T.d.ts} +11 -5
- package/dist/{index-fS1gVNuX.d.ts → index-DCLGz6IH.d.ts} +5 -37
- package/dist/{index-BGyYkqRn.d.ts → index-DwP3ul-y.d.ts} +175 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/openapi/index.js +1 -1
- package/dist/{operations.generated-CPcHOHgd.js → operations.generated-CoVn-Rb6.js} +236 -4
- package/dist/parser/address.js +1 -1
- package/dist/parser/index.js +1 -1
- package/dist/payloads/index.js +1 -1
- package/dist/{webhook-BtyuJtUF.js → trust-DBOnyTHV.js} +671 -2147
- package/dist/webhook/index.d.ts +2 -2
- package/dist/webhook/index.js +2 -2
- package/dist/webhook-B72YFapb.js +2132 -0
- package/package.json +3 -3
- package/dist/errors-Bo0f7eId.js +0 -673
- /package/dist/{address-parser-C2JJzLdA.js → address-parser-BxXv4vx0.js} +0 -0
- /package/dist/{payloads-BfJAtqW9.js → payloads-BMngnJlE.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitivedotdev/sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Official Primitive Node.js SDK: webhook, api, openapi, contract, and parser runtime modules.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -71,8 +71,8 @@
|
|
|
71
71
|
"test:coverage": "vitest run --coverage",
|
|
72
72
|
"test:watch": "vitest",
|
|
73
73
|
"typecheck": "pnpm generate && tsc --noEmit -p tsconfig.typecheck.json",
|
|
74
|
-
"lint": "biome check --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/x402 src/openapi/index.ts src/payloads src/interactions tests/",
|
|
75
|
-
"lint:fix": "biome check --write --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/x402 src/openapi/index.ts src/payloads src/interactions tests/",
|
|
74
|
+
"lint": "biome check --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/api/events.ts src/api/event-transport.ts src/x402 src/openapi/index.ts src/payloads src/interactions tests/",
|
|
75
|
+
"lint:fix": "biome check --write --error-on-warnings src/index.ts src/validation.ts src/types.ts src/webhook src/contract src/parser src/api/index.ts src/api/events.ts src/api/event-transport.ts src/x402 src/openapi/index.ts src/payloads src/interactions tests/",
|
|
76
76
|
"prepublishOnly": "pnpm build"
|
|
77
77
|
},
|
|
78
78
|
"keywords": [
|
package/dist/errors-Bo0f7eId.js
DELETED
|
@@ -1,673 +0,0 @@
|
|
|
1
|
-
import { n as parseFromHeaderLoose, t as parseFromHeader } from "./address-parser-C2JJzLdA.js";
|
|
2
|
-
import isEmail from "validator/lib/isEmail.js";
|
|
3
|
-
//#region src/webhook/received-email.ts
|
|
4
|
-
const REPLY_PREFIX_RE = /^re\s*:/i;
|
|
5
|
-
const FORWARD_PREFIX_RE = /^(fwd?|fw)\s*:/i;
|
|
6
|
-
function normalizeReceivedEmail(event) {
|
|
7
|
-
const receivedBy = event.email.smtp.rcpt_to[0];
|
|
8
|
-
if (!receivedBy) throw new Error("email.smtp.rcpt_to must contain at least one recipient");
|
|
9
|
-
const sender = parseHeaderAddress(event.email.headers.from) ?? {
|
|
10
|
-
address: event.email.smtp.mail_from.trim().toLowerCase(),
|
|
11
|
-
name: null
|
|
12
|
-
};
|
|
13
|
-
const replyTarget = firstStructuredAddress(event.email.parsed.reply_to) ?? sender;
|
|
14
|
-
const subject = event.email.headers.subject ?? null;
|
|
15
|
-
const references = event.email.parsed.references ?? [];
|
|
16
|
-
const messageId = event.email.headers.message_id ?? null;
|
|
17
|
-
return {
|
|
18
|
-
id: event.email.id,
|
|
19
|
-
eventId: event.id,
|
|
20
|
-
receivedAt: event.email.received_at,
|
|
21
|
-
sender,
|
|
22
|
-
replyTarget,
|
|
23
|
-
receivedBy,
|
|
24
|
-
receivedByAll: [...event.email.smtp.rcpt_to],
|
|
25
|
-
subject,
|
|
26
|
-
replySubject: buildReplySubject(subject),
|
|
27
|
-
forwardSubject: buildForwardSubject(subject),
|
|
28
|
-
text: event.email.parsed.body_text ?? null,
|
|
29
|
-
thread: {
|
|
30
|
-
messageId,
|
|
31
|
-
inReplyTo: event.email.parsed.in_reply_to ?? [],
|
|
32
|
-
references
|
|
33
|
-
},
|
|
34
|
-
attachments: event.email.parsed.attachments ?? [],
|
|
35
|
-
auth: event.email.auth,
|
|
36
|
-
analysis: event.email.analysis,
|
|
37
|
-
raw: event
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
function buildReplySubject(subject) {
|
|
41
|
-
const trimmed = subject?.trim() ?? "";
|
|
42
|
-
if (trimmed.length === 0) return "Re:";
|
|
43
|
-
return REPLY_PREFIX_RE.test(trimmed) ? trimmed : `Re: ${trimmed}`;
|
|
44
|
-
}
|
|
45
|
-
function buildForwardSubject(subject) {
|
|
46
|
-
const trimmed = subject?.trim() ?? "";
|
|
47
|
-
if (trimmed.length === 0) return "Fwd:";
|
|
48
|
-
return FORWARD_PREFIX_RE.test(trimmed) ? trimmed : `Fwd: ${trimmed}`;
|
|
49
|
-
}
|
|
50
|
-
function formatAddress(address) {
|
|
51
|
-
return address.name ? `${address.name} <${address.address}>` : address.address;
|
|
52
|
-
}
|
|
53
|
-
function firstStructuredAddress(addresses) {
|
|
54
|
-
const address = addresses?.[0];
|
|
55
|
-
if (!address) return null;
|
|
56
|
-
return {
|
|
57
|
-
address: address.address.trim().toLowerCase(),
|
|
58
|
-
name: address.name ?? null
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
function parseHeaderAddress(value) {
|
|
62
|
-
const parsed = parseFromHeaderLoose(value);
|
|
63
|
-
if (!parsed) return null;
|
|
64
|
-
return {
|
|
65
|
-
address: parsed.address,
|
|
66
|
-
name: parsed.name?.trim() || null
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
//#endregion
|
|
70
|
-
//#region src/types.ts
|
|
71
|
-
const EventType = {
|
|
72
|
-
EmailReceived: "email.received",
|
|
73
|
-
EmailBounced: "email.bounced",
|
|
74
|
-
EmailTlsReport: "email.tls_report",
|
|
75
|
-
EmailDmarcReport: "email.dmarc_report",
|
|
76
|
-
EmailDmarcFailure: "email.dmarc_failure"
|
|
77
|
-
};
|
|
78
|
-
const ParsedStatus = {
|
|
79
|
-
Complete: "complete",
|
|
80
|
-
Failed: "failed"
|
|
81
|
-
};
|
|
82
|
-
const ForwardVerdict = {
|
|
83
|
-
Legit: "legit",
|
|
84
|
-
Unknown: "unknown"
|
|
85
|
-
};
|
|
86
|
-
const SpfResult = {
|
|
87
|
-
Pass: "pass",
|
|
88
|
-
Fail: "fail",
|
|
89
|
-
Softfail: "softfail",
|
|
90
|
-
Neutral: "neutral",
|
|
91
|
-
None: "none",
|
|
92
|
-
Temperror: "temperror",
|
|
93
|
-
Permerror: "permerror"
|
|
94
|
-
};
|
|
95
|
-
const DmarcResult = {
|
|
96
|
-
Pass: "pass",
|
|
97
|
-
Fail: "fail",
|
|
98
|
-
None: "none",
|
|
99
|
-
Temperror: "temperror",
|
|
100
|
-
Permerror: "permerror"
|
|
101
|
-
};
|
|
102
|
-
const DmarcPolicy = {
|
|
103
|
-
Reject: "reject",
|
|
104
|
-
Quarantine: "quarantine",
|
|
105
|
-
None: "none"
|
|
106
|
-
};
|
|
107
|
-
const DkimResult = {
|
|
108
|
-
Pass: "pass",
|
|
109
|
-
Fail: "fail",
|
|
110
|
-
Temperror: "temperror",
|
|
111
|
-
Permerror: "permerror"
|
|
112
|
-
};
|
|
113
|
-
const AuthConfidence = {
|
|
114
|
-
High: "high",
|
|
115
|
-
Medium: "medium",
|
|
116
|
-
Low: "low"
|
|
117
|
-
};
|
|
118
|
-
const AuthVerdict = {
|
|
119
|
-
Legit: "legit",
|
|
120
|
-
Suspicious: "suspicious",
|
|
121
|
-
Unknown: "unknown"
|
|
122
|
-
};
|
|
123
|
-
//#endregion
|
|
124
|
-
//#region src/webhook/auth.ts
|
|
125
|
-
/**
|
|
126
|
-
* Minimum DKIM key size considered acceptable.
|
|
127
|
-
*
|
|
128
|
-
* 1024-bit RSA keys are cryptographically weak by modern standards (NIST
|
|
129
|
-
* deprecated them in 2013), but they remain extremely common in email due to:
|
|
130
|
-
* - DNS TXT record size limits (255 bytes per string)
|
|
131
|
-
* - Legacy infrastructure constraints
|
|
132
|
-
* - Major ESPs like Amazon SES and Resend still use 1024-bit keys
|
|
133
|
-
*
|
|
134
|
-
* We flag keys <1024 bits as weak (these are truly dangerous), while accepting
|
|
135
|
-
* >=1024 bits to avoid false positives against legitimate senders. For maximum
|
|
136
|
-
* security, domain owners should use 2048+ bit keys where possible.
|
|
137
|
-
*/
|
|
138
|
-
const MIN_SECURE_KEY_BITS = 1024;
|
|
139
|
-
/**
|
|
140
|
-
* Validate email authentication and compute a verdict.
|
|
141
|
-
*
|
|
142
|
-
* This function analyzes SPF, DKIM, and DMARC results to determine
|
|
143
|
-
* whether an email is likely authentic ("legit"), potentially spoofed
|
|
144
|
-
* ("suspicious"), or indeterminate ("unknown").
|
|
145
|
-
*
|
|
146
|
-
* ## Verdict Logic
|
|
147
|
-
*
|
|
148
|
-
* **Legit (high confidence):**
|
|
149
|
-
* - DMARC pass with DKIM alignment (cryptographic proof of authenticity)
|
|
150
|
-
*
|
|
151
|
-
* **Legit (medium confidence):**
|
|
152
|
-
* - DMARC pass with SPF alignment only (no DKIM)
|
|
153
|
-
* - Note: SPF can break through forwarding, but DMARC pass is still meaningful
|
|
154
|
-
*
|
|
155
|
-
* **Suspicious (high confidence):**
|
|
156
|
-
* - DMARC fail when domain has `reject` or `quarantine` policy
|
|
157
|
-
* - The domain owner explicitly says to distrust failing emails
|
|
158
|
-
* - SPF explicitly fails (IP not authorized by sender)
|
|
159
|
-
*
|
|
160
|
-
* **Suspicious (low confidence):**
|
|
161
|
-
* - DMARC fail when domain has `none` policy (monitoring mode)
|
|
162
|
-
* - No DMARC record but SPF/DKIM fail
|
|
163
|
-
*
|
|
164
|
-
* **Unknown:**
|
|
165
|
-
* - No DMARC record and no clear pass/fail
|
|
166
|
-
* - Temporary errors during authentication
|
|
167
|
-
* - No authentication data available
|
|
168
|
-
*
|
|
169
|
-
* A `legit` verdict means the email authenticated as its own From
|
|
170
|
-
* domain, not as any particular domain you trust. For authorization
|
|
171
|
-
* decisions, pair the verdict with a domain anchor via
|
|
172
|
-
* {@link isTrustedSender} instead of checking the verdict alone.
|
|
173
|
-
*
|
|
174
|
-
* @param auth - Email authentication results from the webhook
|
|
175
|
-
* @returns Verdict, confidence level, and explanatory reasons
|
|
176
|
-
*
|
|
177
|
-
* @example
|
|
178
|
-
* ```typescript
|
|
179
|
-
* const result = validateEmailAuth({
|
|
180
|
-
* spf: 'pass',
|
|
181
|
-
* dmarc: 'pass',
|
|
182
|
-
* dmarcPolicy: 'reject',
|
|
183
|
-
* dmarcFromDomain: 'example.com',
|
|
184
|
-
* dmarcSpfAligned: true,
|
|
185
|
-
* dmarcDkimAligned: true,
|
|
186
|
-
* dmarcSpfStrict: false,
|
|
187
|
-
* dmarcDkimStrict: false,
|
|
188
|
-
* dkimSignatures: [{
|
|
189
|
-
* domain: 'example.com',
|
|
190
|
-
* selector: 'default',
|
|
191
|
-
* result: 'pass',
|
|
192
|
-
* aligned: true,
|
|
193
|
-
* keyBits: 2048,
|
|
194
|
-
* algo: 'rsa-sha256',
|
|
195
|
-
* }],
|
|
196
|
-
* });
|
|
197
|
-
*
|
|
198
|
-
* // result.verdict === 'legit'
|
|
199
|
-
* // result.confidence === 'high'
|
|
200
|
-
* // result.reasons === ['DMARC passed with DKIM alignment']
|
|
201
|
-
* ```
|
|
202
|
-
*/
|
|
203
|
-
function validateEmailAuth(auth) {
|
|
204
|
-
const reasons = [];
|
|
205
|
-
let verdict;
|
|
206
|
-
let confidence;
|
|
207
|
-
if (auth.dmarc === "temperror" || auth.dmarc === "permerror") return {
|
|
208
|
-
verdict: "unknown",
|
|
209
|
-
confidence: "low",
|
|
210
|
-
reasons: [`DMARC verification error (${auth.dmarc})`, "Cannot determine email authenticity due to DNS or policy errors"]
|
|
211
|
-
};
|
|
212
|
-
if (auth.spf === "temperror" || auth.spf === "permerror") reasons.push(`SPF verification error (${auth.spf})`);
|
|
213
|
-
const weakKeySignatures = auth.dkimSignatures.filter((sig) => sig.keyBits != null && sig.keyBits < MIN_SECURE_KEY_BITS);
|
|
214
|
-
if (weakKeySignatures.length > 0) for (const sig of weakKeySignatures) reasons.push(`Weak DKIM key (${sig.keyBits} bits) for ${sig.domain} - minimum ${MIN_SECURE_KEY_BITS} bits recommended`);
|
|
215
|
-
if (auth.dmarc === "pass") {
|
|
216
|
-
const alignedSigs = auth.dkimSignatures.filter((sig) => sig.result === "pass" && sig.aligned);
|
|
217
|
-
if (auth.dmarcDkimAligned && alignedSigs.length > 0) {
|
|
218
|
-
const domains = alignedSigs.map((sig) => sig.domain).join(", ");
|
|
219
|
-
reasons.unshift(`DMARC passed with DKIM alignment (${domains})`);
|
|
220
|
-
verdict = "legit";
|
|
221
|
-
confidence = weakKeySignatures.length > 0 ? "medium" : "high";
|
|
222
|
-
return {
|
|
223
|
-
verdict,
|
|
224
|
-
confidence,
|
|
225
|
-
reasons
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
if (auth.dmarcSpfAligned && auth.spf === "pass") {
|
|
229
|
-
reasons.unshift("DMARC passed with SPF alignment");
|
|
230
|
-
reasons.push("No aligned DKIM signature (SPF can break through forwarding)");
|
|
231
|
-
return {
|
|
232
|
-
verdict: "legit",
|
|
233
|
-
confidence: "medium",
|
|
234
|
-
reasons
|
|
235
|
-
};
|
|
236
|
-
}
|
|
237
|
-
reasons.unshift("DMARC passed");
|
|
238
|
-
return {
|
|
239
|
-
verdict: "legit",
|
|
240
|
-
confidence: "medium",
|
|
241
|
-
reasons
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
if (auth.dmarc === "fail") {
|
|
245
|
-
if (auth.dmarcPolicy === "reject") {
|
|
246
|
-
reasons.unshift("DMARC failed and domain has reject policy");
|
|
247
|
-
reasons.push("The sender's domain explicitly rejects emails that fail authentication");
|
|
248
|
-
return {
|
|
249
|
-
verdict: "suspicious",
|
|
250
|
-
confidence: "high",
|
|
251
|
-
reasons
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
if (auth.dmarcPolicy === "quarantine") {
|
|
255
|
-
reasons.unshift("DMARC failed and domain has quarantine policy");
|
|
256
|
-
reasons.push("The sender's domain marks failing emails as suspicious");
|
|
257
|
-
return {
|
|
258
|
-
verdict: "suspicious",
|
|
259
|
-
confidence: "high",
|
|
260
|
-
reasons
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
reasons.unshift("DMARC failed (domain is in monitoring mode)");
|
|
264
|
-
if (auth.spf === "fail") {
|
|
265
|
-
reasons.push("SPF failed - sending IP not authorized");
|
|
266
|
-
return {
|
|
267
|
-
verdict: "suspicious",
|
|
268
|
-
confidence: "medium",
|
|
269
|
-
reasons
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
return {
|
|
273
|
-
verdict: "suspicious",
|
|
274
|
-
confidence: "low",
|
|
275
|
-
reasons
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
if (auth.dmarc === "none") {
|
|
279
|
-
if (auth.spf === "fail") {
|
|
280
|
-
reasons.push("No DMARC record for sender domain");
|
|
281
|
-
reasons.push("SPF failed - sending IP not authorized");
|
|
282
|
-
return {
|
|
283
|
-
verdict: "suspicious",
|
|
284
|
-
confidence: "medium",
|
|
285
|
-
reasons
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
const passingDkim = auth.dkimSignatures.filter((sig) => sig.result === "pass");
|
|
289
|
-
if (passingDkim.length > 0) {
|
|
290
|
-
const domains = passingDkim.map((sig) => sig.domain).join(", ");
|
|
291
|
-
reasons.push("No DMARC record for sender domain");
|
|
292
|
-
reasons.push(`DKIM verified for: ${domains}`);
|
|
293
|
-
if (auth.spf === "pass") reasons.push("SPF passed");
|
|
294
|
-
return {
|
|
295
|
-
verdict: "unknown",
|
|
296
|
-
confidence: "low",
|
|
297
|
-
reasons
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
if (auth.spf === "pass") {
|
|
301
|
-
reasons.push("No DMARC record for sender domain");
|
|
302
|
-
reasons.push("No DKIM signatures present");
|
|
303
|
-
reasons.push("SPF passed (but SPF alone is weak authentication)");
|
|
304
|
-
return {
|
|
305
|
-
verdict: "unknown",
|
|
306
|
-
confidence: "low",
|
|
307
|
-
reasons
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
reasons.push("No DMARC record for sender domain");
|
|
311
|
-
reasons.push("No valid authentication found");
|
|
312
|
-
return {
|
|
313
|
-
verdict: "unknown",
|
|
314
|
-
confidence: "low",
|
|
315
|
-
reasons
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
return {
|
|
319
|
-
verdict: "unknown",
|
|
320
|
-
confidence: "low",
|
|
321
|
-
reasons: ["Unable to determine email authenticity"]
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
//#endregion
|
|
325
|
-
//#region src/webhook/trust.ts
|
|
326
|
-
/**
|
|
327
|
-
* Domain-Anchored Sender Trust
|
|
328
|
-
*
|
|
329
|
-
* `validateEmailAuth()` answers "was this email authenticated?" but not
|
|
330
|
-
* "authenticated AS WHOM?". A fully authenticated email from any domain
|
|
331
|
-
* returns a `legit` verdict, so a verdict check alone cannot gate actions
|
|
332
|
-
* on "this really came from our domain". `isTrustedSender()` closes that
|
|
333
|
-
* gap by anchoring the verdict to an expected From domain (and optionally
|
|
334
|
-
* an exact sender address).
|
|
335
|
-
*
|
|
336
|
-
* @example
|
|
337
|
-
* ```typescript
|
|
338
|
-
* import { isTrustedSender } from '@primitivedotdev/sdk/api';
|
|
339
|
-
*
|
|
340
|
-
* const trust = isTrustedSender(event, { domain: 'example.com' });
|
|
341
|
-
* if (trust.trusted) {
|
|
342
|
-
* // Authenticated mail whose From domain is example.com
|
|
343
|
-
* } else if (trust.retryable) {
|
|
344
|
-
* // Transient DNS failure during DMARC evaluation; return a 5xx so
|
|
345
|
-
* // webhook redelivery retries with fresh DNS.
|
|
346
|
-
* } else {
|
|
347
|
-
* console.warn('Untrusted email:', trust.reason, trust.auth.reasons);
|
|
348
|
-
* }
|
|
349
|
-
* ```
|
|
350
|
-
*
|
|
351
|
-
* @packageDocumentation
|
|
352
|
-
*/
|
|
353
|
-
const SENDER_OPTION_EMAIL_OPTIONS = {
|
|
354
|
-
allow_ip_domain: true,
|
|
355
|
-
require_tld: true,
|
|
356
|
-
allow_display_name: false,
|
|
357
|
-
allow_utf8_local_part: true
|
|
358
|
-
};
|
|
359
|
-
function untrusted(reason, auth, retryable = false) {
|
|
360
|
-
return {
|
|
361
|
-
trusted: false,
|
|
362
|
-
retryable,
|
|
363
|
-
reason,
|
|
364
|
-
auth
|
|
365
|
-
};
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Check whether an inbound email is authenticated as an expected domain
|
|
369
|
-
* (and optionally an exact sender address).
|
|
370
|
-
*
|
|
371
|
-
* `trusted` is true only when ALL of the following hold:
|
|
372
|
-
*
|
|
373
|
-
* 1. `validateEmailAuth(event.email.auth)` returns a `legit` verdict.
|
|
374
|
-
* 2. `event.email.auth.dmarcFromDomain` (the domain the server's DMARC
|
|
375
|
-
* evaluation ran against) equals `options.domain`.
|
|
376
|
-
* 3. The From header strict-parses to exactly one valid address whose
|
|
377
|
-
* domain equals `options.domain`.
|
|
378
|
-
* 4. When `options.sender` is given, the parsed From address equals it
|
|
379
|
-
* exactly (case-insensitive).
|
|
380
|
-
*
|
|
381
|
-
* ## Why the extra checks beyond the verdict
|
|
382
|
-
*
|
|
383
|
-
* The verdict alone says an email was authenticated, not which domain
|
|
384
|
-
* it was authenticated as: a fully authenticated email from an
|
|
385
|
-
* attacker-controlled domain is `legit`. Anchoring `dmarcFromDomain`
|
|
386
|
-
* closes that. The strict From parse defends the remaining gaps:
|
|
387
|
-
*
|
|
388
|
-
* - Naively regexing the raw From header is unsafe. A header like
|
|
389
|
-
* `From: "trusted@example.com" <x@evil.com>` plants an allowlisted
|
|
390
|
-
* address in the display name while DMARC evaluates (and passes for)
|
|
391
|
-
* `evil.com`. The strict parser extracts only the real addr-spec and
|
|
392
|
-
* rejects multi-address and group forms outright.
|
|
393
|
-
* - `normalizeReceivedEmail().sender` is NOT a safe anchor for
|
|
394
|
-
* authorization: it uses a lenient parser and falls back to the SMTP
|
|
395
|
-
* envelope sender (`smtp.mail_from`), which the sender fully
|
|
396
|
-
* controls. The same goes for Reply-To (`replyTarget`). This function
|
|
397
|
-
* never consults either.
|
|
398
|
-
* - An `unknown` verdict is not one thing: a DMARC temperror is
|
|
399
|
-
* transient (surfaced as `retryable: true`, respond 5xx and let
|
|
400
|
-
* webhook redelivery retry), while "no DMARC record" is permanent
|
|
401
|
-
* for the email and surfaced as non-retryable.
|
|
402
|
-
*
|
|
403
|
-
* Never throws for malformed event content; malformed input yields an
|
|
404
|
-
* untrusted result with a reason. Throws `TypeError` only for invalid
|
|
405
|
-
* `options` (programmer error).
|
|
406
|
-
*
|
|
407
|
-
* @param event - The verified `email.received` webhook event
|
|
408
|
-
* @param options - Expected domain and optional exact sender
|
|
409
|
-
* @returns Trust decision with a stable reason code and the underlying
|
|
410
|
-
* auth result
|
|
411
|
-
*/
|
|
412
|
-
function isTrustedSender(event, options) {
|
|
413
|
-
const { domain, sender } = normalizeOptions(options);
|
|
414
|
-
const auth = event?.email?.auth;
|
|
415
|
-
if (auth === null || typeof auth !== "object" || !Array.isArray(auth.dkimSignatures)) return untrusted("auth-missing", {
|
|
416
|
-
verdict: "unknown",
|
|
417
|
-
confidence: "low",
|
|
418
|
-
reasons: ["Missing or malformed email.auth on event"]
|
|
419
|
-
});
|
|
420
|
-
const authResult = validateEmailAuth(auth);
|
|
421
|
-
if (authResult.verdict === "suspicious") return untrusted("auth-suspicious", authResult);
|
|
422
|
-
if (authResult.verdict === "unknown") {
|
|
423
|
-
if (auth.dmarc === "temperror") return untrusted("dmarc-temperror", authResult, true);
|
|
424
|
-
return untrusted("auth-unknown", authResult);
|
|
425
|
-
}
|
|
426
|
-
const dmarcFromDomain = typeof auth.dmarcFromDomain === "string" ? auth.dmarcFromDomain.trim().toLowerCase() : "";
|
|
427
|
-
if (dmarcFromDomain === "" || dmarcFromDomain !== domain) return untrusted("dmarc-domain-mismatch", authResult);
|
|
428
|
-
const parsed = parseFromHeader(event.email?.headers?.from);
|
|
429
|
-
if (!parsed.ok) return untrusted(parsed.reason === "multiple_addresses" ? "from-header-multiple-addresses" : "from-header-invalid", authResult);
|
|
430
|
-
const fromAddress = parsed.value.address;
|
|
431
|
-
if (fromAddress.slice(fromAddress.lastIndexOf("@") + 1) !== domain) return untrusted("from-domain-mismatch", authResult);
|
|
432
|
-
if (sender !== void 0 && fromAddress !== sender) return untrusted("sender-mismatch", authResult);
|
|
433
|
-
return {
|
|
434
|
-
trusted: true,
|
|
435
|
-
retryable: false,
|
|
436
|
-
reason: "trusted",
|
|
437
|
-
auth: authResult
|
|
438
|
-
};
|
|
439
|
-
}
|
|
440
|
-
function normalizeOptions(options) {
|
|
441
|
-
if (typeof options?.domain !== "string") throw new TypeError("options.domain is required");
|
|
442
|
-
const domain = options.domain.trim().toLowerCase();
|
|
443
|
-
if (domain.length === 0) throw new TypeError("options.domain must be a non-empty domain name");
|
|
444
|
-
if (domain.includes("@") || /\s/.test(domain)) throw new TypeError("options.domain must be a bare domain name without @ or whitespace");
|
|
445
|
-
if (options.sender === void 0) return { domain };
|
|
446
|
-
if (typeof options.sender !== "string") throw new TypeError("options.sender must be a string when provided");
|
|
447
|
-
const sender = options.sender.trim().toLowerCase();
|
|
448
|
-
if (!isEmail(sender, SENDER_OPTION_EMAIL_OPTIONS)) throw new TypeError("options.sender must be a single bare email address (user@example.com)");
|
|
449
|
-
return {
|
|
450
|
-
domain,
|
|
451
|
-
sender
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
//#endregion
|
|
455
|
-
//#region src/webhook/errors.ts
|
|
456
|
-
/**
|
|
457
|
-
* Verification error definitions.
|
|
458
|
-
* Use these for documentation, dashboards, and i18n.
|
|
459
|
-
*/
|
|
460
|
-
const VERIFICATION_ERRORS = {
|
|
461
|
-
INVALID_SIGNATURE_HEADER: {
|
|
462
|
-
message: "Missing or malformed Primitive-Signature header",
|
|
463
|
-
suggestion: "Check that you're reading the correct header (Primitive-Signature) and it's being passed correctly from your web framework."
|
|
464
|
-
},
|
|
465
|
-
TIMESTAMP_OUT_OF_RANGE: {
|
|
466
|
-
message: "Timestamp is too old (possible replay attack)",
|
|
467
|
-
suggestion: "This could indicate a replay attack, network delay, or server clock drift. Check your server's time is synced."
|
|
468
|
-
},
|
|
469
|
-
SIGNATURE_MISMATCH: {
|
|
470
|
-
message: "Signature doesn't match expected value",
|
|
471
|
-
suggestion: "Verify the webhook secret matches and you're using the raw request body (not re-serialized JSON)."
|
|
472
|
-
},
|
|
473
|
-
MISSING_SECRET: {
|
|
474
|
-
message: "No webhook secret was provided",
|
|
475
|
-
suggestion: "Pass your webhook secret from the Primitive dashboard. Check that the environment variable is set."
|
|
476
|
-
}
|
|
477
|
-
};
|
|
478
|
-
/**
|
|
479
|
-
* Payload parsing error definitions.
|
|
480
|
-
* Use these for documentation, dashboards, and i18n.
|
|
481
|
-
*/
|
|
482
|
-
const PAYLOAD_ERRORS = {
|
|
483
|
-
PAYLOAD_NULL: {
|
|
484
|
-
message: "Webhook payload is null",
|
|
485
|
-
suggestion: "Ensure you're passing the parsed JSON body, not null. Check your framework's body parsing middleware."
|
|
486
|
-
},
|
|
487
|
-
PAYLOAD_UNDEFINED: {
|
|
488
|
-
message: "Webhook payload is undefined",
|
|
489
|
-
suggestion: "The payload was not provided. Make sure you're passing the request body to the handler."
|
|
490
|
-
},
|
|
491
|
-
PAYLOAD_WRONG_TYPE: {
|
|
492
|
-
message: "Webhook payload must be an object",
|
|
493
|
-
suggestion: "The payload should be a parsed JSON object. Check that you're not passing a string or other primitive."
|
|
494
|
-
},
|
|
495
|
-
PAYLOAD_IS_ARRAY: {
|
|
496
|
-
message: "Webhook payload is an array, expected object",
|
|
497
|
-
suggestion: "Primitive webhooks are single event objects, not arrays. Check the payload structure."
|
|
498
|
-
},
|
|
499
|
-
PAYLOAD_MISSING_EVENT: {
|
|
500
|
-
message: "Webhook payload missing 'event' field",
|
|
501
|
-
suggestion: "All webhook payloads must have an 'event' field. This may not be a valid Primitive webhook."
|
|
502
|
-
},
|
|
503
|
-
PAYLOAD_UNKNOWN_EVENT: {
|
|
504
|
-
message: "Unknown webhook event type",
|
|
505
|
-
suggestion: "This event type is not recognized. You may need to update your SDK or handle unknown events gracefully."
|
|
506
|
-
},
|
|
507
|
-
PAYLOAD_EMPTY_BODY: {
|
|
508
|
-
message: "Request body is empty",
|
|
509
|
-
suggestion: "The request body was empty. Ensure the webhook is sending data and your framework is parsing it correctly."
|
|
510
|
-
},
|
|
511
|
-
JSON_PARSE_FAILED: {
|
|
512
|
-
message: "Failed to parse JSON body",
|
|
513
|
-
suggestion: "The request body is not valid JSON. Check the raw body content and Content-Type header."
|
|
514
|
-
},
|
|
515
|
-
INVALID_ENCODING: {
|
|
516
|
-
message: "Invalid body encoding",
|
|
517
|
-
suggestion: "The request body encoding is not supported. Primitive webhooks use UTF-8 encoded JSON."
|
|
518
|
-
}
|
|
519
|
-
};
|
|
520
|
-
/**
|
|
521
|
-
* Raw email decode error definitions.
|
|
522
|
-
* Use these for documentation, dashboards, and i18n.
|
|
523
|
-
*/
|
|
524
|
-
const RAW_EMAIL_ERRORS = {
|
|
525
|
-
NOT_INCLUDED: {
|
|
526
|
-
message: "Raw email content not included inline",
|
|
527
|
-
suggestion: "Use the download URL at event.email.content.download.url to fetch the raw email."
|
|
528
|
-
},
|
|
529
|
-
INVALID_BASE64: {
|
|
530
|
-
message: "Raw email content is not valid base64",
|
|
531
|
-
suggestion: "The raw email data is malformed. Fetch the raw email from the download URL or regenerate the webhook payload."
|
|
532
|
-
},
|
|
533
|
-
HASH_MISMATCH: {
|
|
534
|
-
message: "SHA-256 hash verification failed",
|
|
535
|
-
suggestion: "The raw email data may be corrupted. Try downloading from the URL instead."
|
|
536
|
-
}
|
|
537
|
-
};
|
|
538
|
-
/**
|
|
539
|
-
* Base class for all Primitive webhook errors.
|
|
540
|
-
*
|
|
541
|
-
* Catch this to handle any error from the SDK in a single catch block.
|
|
542
|
-
*
|
|
543
|
-
* @example
|
|
544
|
-
* ```typescript
|
|
545
|
-
* import { handleWebhook, PrimitiveWebhookError } from '@primitivedotdev/sdk';
|
|
546
|
-
*
|
|
547
|
-
* try {
|
|
548
|
-
* const event = handleWebhook({ body, headers, secret });
|
|
549
|
-
* } catch (err) {
|
|
550
|
-
* if (err instanceof PrimitiveWebhookError) {
|
|
551
|
-
* console.error(`[${err.code}] ${err.message}`);
|
|
552
|
-
* return res.status(400).json({ error: err.code });
|
|
553
|
-
* }
|
|
554
|
-
* throw err;
|
|
555
|
-
* }
|
|
556
|
-
* ```
|
|
557
|
-
*/
|
|
558
|
-
var PrimitiveWebhookError = class extends Error {
|
|
559
|
-
/**
|
|
560
|
-
* Formats the error for logging/display.
|
|
561
|
-
*/
|
|
562
|
-
toString() {
|
|
563
|
-
return `${this.name} [${this.code}]: ${this.message}\n\nSuggestion: ${this.suggestion}`;
|
|
564
|
-
}
|
|
565
|
-
/**
|
|
566
|
-
* Serializes cleanly for structured logging (Datadog, CloudWatch, etc.)
|
|
567
|
-
*/
|
|
568
|
-
toJSON() {
|
|
569
|
-
return {
|
|
570
|
-
name: this.name,
|
|
571
|
-
code: this.code,
|
|
572
|
-
message: this.message,
|
|
573
|
-
suggestion: this.suggestion
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
};
|
|
577
|
-
/**
|
|
578
|
-
* Error thrown when webhook signature verification fails.
|
|
579
|
-
*
|
|
580
|
-
* Use the `code` property to programmatically handle specific error cases.
|
|
581
|
-
*/
|
|
582
|
-
var WebhookVerificationError = class extends PrimitiveWebhookError {
|
|
583
|
-
code;
|
|
584
|
-
suggestion;
|
|
585
|
-
constructor(code, message, suggestion) {
|
|
586
|
-
super(message ?? VERIFICATION_ERRORS[code].message);
|
|
587
|
-
this.name = "WebhookVerificationError";
|
|
588
|
-
this.code = code;
|
|
589
|
-
this.suggestion = suggestion ?? VERIFICATION_ERRORS[code].suggestion;
|
|
590
|
-
}
|
|
591
|
-
};
|
|
592
|
-
/**
|
|
593
|
-
* Error thrown when webhook payload parsing fails (lightweight parser).
|
|
594
|
-
*
|
|
595
|
-
* Use the `code` property for programmatic handling and monitoring.
|
|
596
|
-
* The `suggestion` property contains actionable guidance for fixing the issue.
|
|
597
|
-
*/
|
|
598
|
-
var WebhookPayloadError = class extends PrimitiveWebhookError {
|
|
599
|
-
code;
|
|
600
|
-
suggestion;
|
|
601
|
-
/** Original error if this wraps another error (e.g., JSON.parse failure) */
|
|
602
|
-
cause;
|
|
603
|
-
constructor(code, message, suggestion, cause) {
|
|
604
|
-
super(message ?? PAYLOAD_ERRORS[code].message);
|
|
605
|
-
this.name = "WebhookPayloadError";
|
|
606
|
-
this.code = code;
|
|
607
|
-
this.suggestion = suggestion ?? PAYLOAD_ERRORS[code].suggestion;
|
|
608
|
-
this.cause = cause;
|
|
609
|
-
}
|
|
610
|
-
};
|
|
611
|
-
/**
|
|
612
|
-
* Error thrown when schema validation fails.
|
|
613
|
-
*/
|
|
614
|
-
var WebhookValidationError = class extends PrimitiveWebhookError {
|
|
615
|
-
code = "SCHEMA_VALIDATION_FAILED";
|
|
616
|
-
suggestion;
|
|
617
|
-
/** The specific field path that failed (e.g., "email.headers.from") */
|
|
618
|
-
field;
|
|
619
|
-
/** Original schema validation errors for advanced debugging */
|
|
620
|
-
validationErrors;
|
|
621
|
-
/** Number of additional validation errors beyond the first */
|
|
622
|
-
additionalErrorCount;
|
|
623
|
-
constructor(field, message, suggestion, validationErrors) {
|
|
624
|
-
super(message);
|
|
625
|
-
this.name = "WebhookValidationError";
|
|
626
|
-
this.field = field;
|
|
627
|
-
this.suggestion = suggestion;
|
|
628
|
-
this.validationErrors = validationErrors;
|
|
629
|
-
this.additionalErrorCount = Math.max(0, validationErrors.length - 1);
|
|
630
|
-
}
|
|
631
|
-
/**
|
|
632
|
-
* Formats the error for logging/display.
|
|
633
|
-
* Includes error count and suggestion.
|
|
634
|
-
*/
|
|
635
|
-
toString() {
|
|
636
|
-
let output = `${this.name} [${this.code}]: ${this.message}`;
|
|
637
|
-
if (this.additionalErrorCount > 0) output += ` (and ${this.additionalErrorCount} more validation error${this.additionalErrorCount > 1 ? "s" : ""})`;
|
|
638
|
-
output += `\n\nSuggestion: ${this.suggestion}`;
|
|
639
|
-
return output;
|
|
640
|
-
}
|
|
641
|
-
/**
|
|
642
|
-
* Serializes cleanly for structured logging (Datadog, CloudWatch, etc.)
|
|
643
|
-
*/
|
|
644
|
-
toJSON() {
|
|
645
|
-
return {
|
|
646
|
-
name: this.name,
|
|
647
|
-
code: this.code,
|
|
648
|
-
field: this.field,
|
|
649
|
-
message: this.message,
|
|
650
|
-
suggestion: this.suggestion,
|
|
651
|
-
additionalErrorCount: this.additionalErrorCount
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
|
-
};
|
|
655
|
-
/**
|
|
656
|
-
* Error thrown when raw email decoding or verification fails.
|
|
657
|
-
*
|
|
658
|
-
* Use the `code` property to determine the failure reason:
|
|
659
|
-
* - `NOT_INCLUDED`: Raw email not inline, must download from URL
|
|
660
|
-
* - `HASH_MISMATCH`: SHA-256 verification failed, content may be corrupted
|
|
661
|
-
*/
|
|
662
|
-
var RawEmailDecodeError = class extends PrimitiveWebhookError {
|
|
663
|
-
code;
|
|
664
|
-
suggestion;
|
|
665
|
-
constructor(code, message) {
|
|
666
|
-
super(message ?? RAW_EMAIL_ERRORS[code].message);
|
|
667
|
-
this.name = "RawEmailDecodeError";
|
|
668
|
-
this.code = code;
|
|
669
|
-
this.suggestion = RAW_EMAIL_ERRORS[code].suggestion;
|
|
670
|
-
}
|
|
671
|
-
};
|
|
672
|
-
//#endregion
|
|
673
|
-
export { normalizeReceivedEmail as C, formatAddress as S, ForwardVerdict as _, VERIFICATION_ERRORS as a, buildForwardSubject as b, WebhookVerificationError as c, AuthConfidence as d, AuthVerdict as f, EventType as g, DmarcResult as h, RawEmailDecodeError as i, isTrustedSender as l, DmarcPolicy as m, PrimitiveWebhookError as n, WebhookPayloadError as o, DkimResult as p, RAW_EMAIL_ERRORS as r, WebhookValidationError as s, PAYLOAD_ERRORS as t, validateEmailAuth as u, ParsedStatus as v, parseHeaderAddress as w, buildReplySubject as x, SpfResult as y };
|
|
File without changes
|
|
File without changes
|