@primitivedotdev/sdk 0.5.1 → 0.7.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/dist/contract/index.d.ts +1 -4
- package/dist/contract/index.js +2 -2
- package/dist/parser/index.d.ts +104 -2
- package/dist/parser/index.js +106 -1
- package/oclif.manifest.json +1 -1
- package/package.json +7 -3
package/dist/contract/index.d.ts
CHANGED
|
@@ -102,7 +102,7 @@ declare function generateEventId(endpoint_id: string, email_id: string): string;
|
|
|
102
102
|
* result against the generated JSON Schema before returning.
|
|
103
103
|
*
|
|
104
104
|
* @param input - Producer-side data for the webhook payload.
|
|
105
|
-
* @param options - Optional overrides for
|
|
105
|
+
* @param options - Optional overrides for the attempted-at timestamp.
|
|
106
106
|
* @returns A fully constructed, schema-valid `EmailReceivedEvent`.
|
|
107
107
|
*
|
|
108
108
|
* @example
|
|
@@ -142,8 +142,6 @@ declare function generateEventId(endpoint_id: string, email_id: string): string;
|
|
|
142
142
|
* ```
|
|
143
143
|
*/
|
|
144
144
|
declare function buildEmailReceivedEvent(input: EmailReceivedEventInput, options?: {
|
|
145
|
-
/** Override the generated event ID, typically for tests. */
|
|
146
|
-
event_id?: string;
|
|
147
145
|
/** Override the attempted-at timestamp, typically for tests. */
|
|
148
146
|
attempted_at?: string;
|
|
149
147
|
}): EmailReceivedEvent;
|
|
@@ -194,7 +192,6 @@ interface BuildEventFromParsedDataOptions {
|
|
|
194
192
|
dateHeader?: string | null;
|
|
195
193
|
/** Optional overrides forwarded to `buildEmailReceivedEvent`. */
|
|
196
194
|
buildOptions?: {
|
|
197
|
-
event_id?: string;
|
|
198
195
|
attempted_at?: string;
|
|
199
196
|
};
|
|
200
197
|
}
|
package/dist/contract/index.js
CHANGED
|
@@ -51,7 +51,7 @@ function generateEventId(endpoint_id, email_id) {
|
|
|
51
51
|
* result against the generated JSON Schema before returning.
|
|
52
52
|
*
|
|
53
53
|
* @param input - Producer-side data for the webhook payload.
|
|
54
|
-
* @param options - Optional overrides for
|
|
54
|
+
* @param options - Optional overrides for the attempted-at timestamp.
|
|
55
55
|
* @returns A fully constructed, schema-valid `EmailReceivedEvent`.
|
|
56
56
|
*
|
|
57
57
|
* @example
|
|
@@ -91,7 +91,7 @@ function generateEventId(endpoint_id, email_id) {
|
|
|
91
91
|
* ```
|
|
92
92
|
*/
|
|
93
93
|
function buildEmailReceivedEvent(input, options) {
|
|
94
|
-
const event_id =
|
|
94
|
+
const event_id = generateEventId(input.endpoint_id, input.email_id);
|
|
95
95
|
const attempted_at = options?.attempted_at ? validateTimestamp(options.attempted_at, "attempted_at") : new Date().toISOString();
|
|
96
96
|
const raw_size_bytes = input.raw_bytes.length;
|
|
97
97
|
if (input.raw_size_bytes !== raw_size_bytes) throw new Error(`[@primitivedotdev/sdk/contract] Invalid raw_size_bytes: ${input.raw_size_bytes}. Expected ${raw_size_bytes} based on raw_bytes length`);
|
package/dist/parser/index.d.ts
CHANGED
|
@@ -1,5 +1,105 @@
|
|
|
1
1
|
import { EmailAddress, ParsedDataComplete, WebhookAttachment } from "../types-CKFmgitP.js";
|
|
2
2
|
|
|
3
|
+
//#region src/parser/address-parser.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A validated RFC 5322 address. Returned by the strict parser, which
|
|
6
|
+
* deliberately does not expose a display name.
|
|
7
|
+
*
|
|
8
|
+
* `address` is normalized to lowercase. Both the local-part and the
|
|
9
|
+
* domain are lowercased: RFC 5321 §2.4 permits case-sensitive local-
|
|
10
|
+
* parts, but every consumer mailbox in practice treats them as
|
|
11
|
+
* case-insensitive, and a case-sensitive grant key would split
|
|
12
|
+
* `Bob@x.com` from `bob@x.com` into separate rows and defeat the
|
|
13
|
+
* primary-key index on lookup.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* A validated RFC 5322 address. Returned by the strict parser, which
|
|
17
|
+
* deliberately does not expose a display name.
|
|
18
|
+
*
|
|
19
|
+
* `address` is normalized to lowercase. Both the local-part and the
|
|
20
|
+
* domain are lowercased: RFC 5321 §2.4 permits case-sensitive local-
|
|
21
|
+
* parts, but every consumer mailbox in practice treats them as
|
|
22
|
+
* case-insensitive, and a case-sensitive grant key would split
|
|
23
|
+
* `Bob@x.com` from `bob@x.com` into separate rows and defeat the
|
|
24
|
+
* primary-key index on lookup.
|
|
25
|
+
*/
|
|
26
|
+
interface ValidatedAddress {
|
|
27
|
+
address: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A parsed RFC 5322 address with its display name. Returned by the
|
|
31
|
+
* loose parser for display-only call sites.
|
|
32
|
+
*
|
|
33
|
+
* `address` is lowercased on the same rationale as
|
|
34
|
+
* {@link ValidatedAddress}. The display name is preserved as provided
|
|
35
|
+
* (after addressparser's quote / encoded-word handling), or null if the
|
|
36
|
+
* header had no display name. Names from the loose parser are NOT
|
|
37
|
+
* trustworthy for downstream mail building: addressparser's recovery
|
|
38
|
+
* mode can fold trailing tokens or a second bracketed address into the
|
|
39
|
+
* name field. Treat as opaque text, sanitize before re-emitting.
|
|
40
|
+
*/
|
|
41
|
+
interface ParsedAddress {
|
|
42
|
+
address: string;
|
|
43
|
+
name: string | null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reason a strict From-header parse rejected the input. Stable enum so
|
|
47
|
+
* callers can branch on the reason without parsing message text.
|
|
48
|
+
*/
|
|
49
|
+
type ParseFromHeaderFailureReason = "empty" | "too_long" | "multiple_addresses" | "group_syntax" | "invalid_address";
|
|
50
|
+
type ParseFromHeaderResult = {
|
|
51
|
+
ok: true;
|
|
52
|
+
value: ValidatedAddress;
|
|
53
|
+
} | {
|
|
54
|
+
ok: false;
|
|
55
|
+
reason: ParseFromHeaderFailureReason;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Strict parser for RFC 5322 From-style headers in security-bearing
|
|
59
|
+
* contexts (allowlist gates, permission grants).
|
|
60
|
+
*
|
|
61
|
+
* Rejects, without falling back to a "best guess":
|
|
62
|
+
* - empty / whitespace-only input
|
|
63
|
+
* - inputs longer than RFC 5322's 998-octet line limit
|
|
64
|
+
* - multi-address From (RFC 5322 allows it but it is vanishingly
|
|
65
|
+
* rare and ambiguous as an identity)
|
|
66
|
+
* - group syntax ("Friends: a@b.com, c@d.com;")
|
|
67
|
+
* - any address that fails validator's isEmail check with our chosen
|
|
68
|
+
* options. That covers per-part length limits, dot-atom rules,
|
|
69
|
+
* hostname-label rules, TLD requirement, and other RFC 5321/5322
|
|
70
|
+
* conformance checks.
|
|
71
|
+
*
|
|
72
|
+
* Returns ONLY the validated address, with no display name. Strict
|
|
73
|
+
* exists for gating decisions, where the address is the security-
|
|
74
|
+
* bearing field. Display names from addressparser are not trustworthy
|
|
75
|
+
* here: weird inputs like `Name <user@x.com> <attacker@y.com>` get
|
|
76
|
+
* parsed as a single entry whose `name` silently includes the second
|
|
77
|
+
* address. Surfacing that as a "parsed name" would invite downstream
|
|
78
|
+
* misuse, so we drop it. If you need the name, call
|
|
79
|
+
* {@link parseFromHeaderLoose} alongside (it returns null on failure
|
|
80
|
+
* anyway, so you can still gate on strict's Result).
|
|
81
|
+
*
|
|
82
|
+
* Returns a typed Result so callers can map the failure reason to
|
|
83
|
+
* stable error codes without inspecting message text.
|
|
84
|
+
*/
|
|
85
|
+
declare function parseFromHeader(header: string | null | undefined): ParseFromHeaderResult;
|
|
86
|
+
/**
|
|
87
|
+
* Lenient parser for display-only call sites (inbox card "from",
|
|
88
|
+
* log lines, debugging). Returns the first parseable address with its
|
|
89
|
+
* display name, or null.
|
|
90
|
+
*
|
|
91
|
+
* Differences from {@link parseFromHeader}:
|
|
92
|
+
* - Multi-address From returns the first address instead of rejecting
|
|
93
|
+
* - Group syntax is flattened into its member addresses
|
|
94
|
+
* - Returns null instead of a typed reason on failure
|
|
95
|
+
* - Includes the parsed display name in the result
|
|
96
|
+
*
|
|
97
|
+
* Do not use for permission gates or any decision that grants access.
|
|
98
|
+
* That is what {@link parseFromHeader} is for. Names returned here can
|
|
99
|
+
* include addressparser's recovery output (trailing tokens, garbage
|
|
100
|
+
* before the address); treat as opaque text for display.
|
|
101
|
+
*/
|
|
102
|
+
declare function parseFromHeaderLoose(header: string | null | undefined): ParsedAddress | null; //#endregion
|
|
3
103
|
//#region src/parser/attachment-parser.d.ts
|
|
4
104
|
interface ParsedAttachment {
|
|
5
105
|
id: string;
|
|
@@ -61,7 +161,9 @@ declare function sha256Hex(buffer: Buffer): string;
|
|
|
61
161
|
* Sanitize a filename for safe use in Content-Disposition headers.
|
|
62
162
|
* Prevents path traversal, removes control characters, enforces length limits.
|
|
63
163
|
*/
|
|
64
|
-
declare function sanitizeFilename(filename: string | null, partIndex: number): string;
|
|
164
|
+
declare function sanitizeFilename(filename: string | null, partIndex: number): string;
|
|
165
|
+
|
|
166
|
+
//#endregion
|
|
65
167
|
//#region src/parser/attachment-bundler.d.ts
|
|
66
168
|
/**
|
|
67
169
|
* Result of bundling attachments into a tar.gz archive
|
|
@@ -187,4 +289,4 @@ declare function toCanonicalHeaders(parsed: ParsedEmailWithAttachments): {
|
|
|
187
289
|
declare function sanitizeHtml(html: string): string;
|
|
188
290
|
|
|
189
291
|
//#endregion
|
|
190
|
-
export { AttachmentMetadata, BundleResult, ParsedAttachment, ParsedEmail, ParsedEmailWithAttachments, attachmentMetadataToWebhookAttachments, bundleAttachments, extractAttachmentMetadata, getAttachmentsStorageKey, normalizeContentType, parseEmail, parseEmailWithAttachments, sanitizeFilename, sanitizeHtml, sha256Hex, toCanonicalHeaders, toParsedDataComplete, toWebhookAttachments };
|
|
292
|
+
export { AttachmentMetadata, BundleResult, ParseFromHeaderFailureReason, ParseFromHeaderResult, ParsedAddress, ParsedAttachment, ParsedEmail, ParsedEmailWithAttachments, ValidatedAddress, attachmentMetadataToWebhookAttachments, bundleAttachments, extractAttachmentMetadata, getAttachmentsStorageKey, normalizeContentType, parseEmail, parseEmailWithAttachments, parseFromHeader, parseFromHeaderLoose, sanitizeFilename, sanitizeHtml, sha256Hex, toCanonicalHeaders, toParsedDataComplete, toWebhookAttachments };
|
package/dist/parser/index.js
CHANGED
|
@@ -1,9 +1,114 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import addressparser from "nodemailer/lib/addressparser/index.js";
|
|
3
|
+
import isEmail from "validator/lib/isEmail.js";
|
|
2
4
|
import { createGzip } from "node:zlib";
|
|
3
5
|
import { pack } from "tar-stream";
|
|
4
6
|
import { simpleParser } from "mailparser";
|
|
5
7
|
import DOMPurify from "isomorphic-dompurify";
|
|
6
8
|
|
|
9
|
+
//#region src/parser/address-parser.ts
|
|
10
|
+
const MAX_HEADER_LENGTH = 998;
|
|
11
|
+
const IS_EMAIL_OPTIONS = {
|
|
12
|
+
allow_ip_domain: true,
|
|
13
|
+
require_tld: true,
|
|
14
|
+
allow_display_name: false,
|
|
15
|
+
allow_utf8_local_part: true
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Strict parser for RFC 5322 From-style headers in security-bearing
|
|
19
|
+
* contexts (allowlist gates, permission grants).
|
|
20
|
+
*
|
|
21
|
+
* Rejects, without falling back to a "best guess":
|
|
22
|
+
* - empty / whitespace-only input
|
|
23
|
+
* - inputs longer than RFC 5322's 998-octet line limit
|
|
24
|
+
* - multi-address From (RFC 5322 allows it but it is vanishingly
|
|
25
|
+
* rare and ambiguous as an identity)
|
|
26
|
+
* - group syntax ("Friends: a@b.com, c@d.com;")
|
|
27
|
+
* - any address that fails validator's isEmail check with our chosen
|
|
28
|
+
* options. That covers per-part length limits, dot-atom rules,
|
|
29
|
+
* hostname-label rules, TLD requirement, and other RFC 5321/5322
|
|
30
|
+
* conformance checks.
|
|
31
|
+
*
|
|
32
|
+
* Returns ONLY the validated address, with no display name. Strict
|
|
33
|
+
* exists for gating decisions, where the address is the security-
|
|
34
|
+
* bearing field. Display names from addressparser are not trustworthy
|
|
35
|
+
* here: weird inputs like `Name <user@x.com> <attacker@y.com>` get
|
|
36
|
+
* parsed as a single entry whose `name` silently includes the second
|
|
37
|
+
* address. Surfacing that as a "parsed name" would invite downstream
|
|
38
|
+
* misuse, so we drop it. If you need the name, call
|
|
39
|
+
* {@link parseFromHeaderLoose} alongside (it returns null on failure
|
|
40
|
+
* anyway, so you can still gate on strict's Result).
|
|
41
|
+
*
|
|
42
|
+
* Returns a typed Result so callers can map the failure reason to
|
|
43
|
+
* stable error codes without inspecting message text.
|
|
44
|
+
*/
|
|
45
|
+
function parseFromHeader(header) {
|
|
46
|
+
if (header === null || header === void 0) return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: "empty"
|
|
49
|
+
};
|
|
50
|
+
const trimmed = header.trim();
|
|
51
|
+
if (trimmed.length === 0) return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: "empty"
|
|
54
|
+
};
|
|
55
|
+
if (Buffer.byteLength(trimmed, "utf8") > MAX_HEADER_LENGTH) return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: "too_long"
|
|
58
|
+
};
|
|
59
|
+
const parsed = addressparser(trimmed);
|
|
60
|
+
if (parsed.length > 1) return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: "multiple_addresses"
|
|
63
|
+
};
|
|
64
|
+
const entry = parsed[0];
|
|
65
|
+
if (entry === void 0) return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: "invalid_address"
|
|
68
|
+
};
|
|
69
|
+
if ("group" in entry) return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: "group_syntax"
|
|
72
|
+
};
|
|
73
|
+
if (!isEmail(entry.address, IS_EMAIL_OPTIONS)) return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: "invalid_address"
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
ok: true,
|
|
79
|
+
value: { address: entry.address.toLowerCase() }
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Lenient parser for display-only call sites (inbox card "from",
|
|
84
|
+
* log lines, debugging). Returns the first parseable address with its
|
|
85
|
+
* display name, or null.
|
|
86
|
+
*
|
|
87
|
+
* Differences from {@link parseFromHeader}:
|
|
88
|
+
* - Multi-address From returns the first address instead of rejecting
|
|
89
|
+
* - Group syntax is flattened into its member addresses
|
|
90
|
+
* - Returns null instead of a typed reason on failure
|
|
91
|
+
* - Includes the parsed display name in the result
|
|
92
|
+
*
|
|
93
|
+
* Do not use for permission gates or any decision that grants access.
|
|
94
|
+
* That is what {@link parseFromHeader} is for. Names returned here can
|
|
95
|
+
* include addressparser's recovery output (trailing tokens, garbage
|
|
96
|
+
* before the address); treat as opaque text for display.
|
|
97
|
+
*/
|
|
98
|
+
function parseFromHeaderLoose(header) {
|
|
99
|
+
if (header === null || header === void 0) return null;
|
|
100
|
+
const trimmed = header.trim();
|
|
101
|
+
if (trimmed.length === 0 || Buffer.byteLength(trimmed, "utf8") > MAX_HEADER_LENGTH) return null;
|
|
102
|
+
const parsed = addressparser(trimmed, { flatten: true });
|
|
103
|
+
const entry = parsed[0];
|
|
104
|
+
if (entry === void 0 || !isEmail(entry.address, IS_EMAIL_OPTIONS)) return null;
|
|
105
|
+
return {
|
|
106
|
+
address: entry.address.toLowerCase(),
|
|
107
|
+
name: entry.name && entry.name.length > 0 ? entry.name : null
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
//#endregion
|
|
7
112
|
//#region src/parser/attachment-bundler.ts
|
|
8
113
|
function appendTarEntry(archive, name, content) {
|
|
9
114
|
return new Promise((resolve, reject) => {
|
|
@@ -557,4 +662,4 @@ function requireNonEmptyHeader(value, headerName) {
|
|
|
557
662
|
}
|
|
558
663
|
|
|
559
664
|
//#endregion
|
|
560
|
-
export { attachmentMetadataToWebhookAttachments, bundleAttachments, extractAttachmentMetadata, getAttachmentsStorageKey, normalizeContentType, parseEmail, parseEmailWithAttachments, sanitizeFilename, sanitizeHtml, sha256Hex, toCanonicalHeaders, toParsedDataComplete, toWebhookAttachments };
|
|
665
|
+
export { attachmentMetadataToWebhookAttachments, bundleAttachments, extractAttachmentMetadata, getAttachmentsStorageKey, normalizeContentType, parseEmail, parseEmailWithAttachments, parseFromHeader, parseFromHeaderLoose, sanitizeFilename, sanitizeHtml, sha256Hex, toCanonicalHeaders, toParsedDataComplete, toWebhookAttachments };
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitivedotdev/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Official Primitive Node.js SDK — webhook, api, openapi, contract, and parser modules",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "./dist/index.js",
|
|
@@ -132,15 +132,19 @@
|
|
|
132
132
|
"ajv": "^8.17.1",
|
|
133
133
|
"isomorphic-dompurify": "^3.8.0",
|
|
134
134
|
"mailparser": "^3.9.0",
|
|
135
|
-
"
|
|
135
|
+
"nodemailer": "^8.0.7",
|
|
136
|
+
"tar-stream": "^3.1.8",
|
|
137
|
+
"validator": "^13.15.35"
|
|
136
138
|
},
|
|
137
139
|
"devDependencies": {
|
|
138
|
-
"@hey-api/openapi-ts": "0.96.0",
|
|
139
140
|
"@biomejs/biome": "^2.4.10",
|
|
141
|
+
"@hey-api/openapi-ts": "0.96.0",
|
|
140
142
|
"@types/json-schema": "^7.0.15",
|
|
141
143
|
"@types/mailparser": "^3.4.6",
|
|
142
144
|
"@types/node": "^22.10.2",
|
|
145
|
+
"@types/nodemailer": "^8.0.0",
|
|
143
146
|
"@types/tar-stream": "^3.1.4",
|
|
147
|
+
"@types/validator": "^13.15.10",
|
|
144
148
|
"@vitest/coverage-v8": "^4.1.4",
|
|
145
149
|
"json-schema-to-typescript": "^15.0.4",
|
|
146
150
|
"oclif": "^4.23.0",
|