@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.
@@ -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 event ID and attempted-at timestamp.
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
  }
@@ -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 event ID and attempted-at timestamp.
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 = options?.event_id ?? generateEventId(input.endpoint_id, input.email_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`);
@@ -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; //#endregion
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 };
@@ -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 };
@@ -1263,5 +1263,5 @@
1263
1263
  "enableJsonFlag": false
1264
1264
  }
1265
1265
  },
1266
- "version": "0.5.1"
1266
+ "version": "0.7.0"
1267
1267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/sdk",
3
- "version": "0.5.1",
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
- "tar-stream": "^3.1.8"
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",