@primitivedotdev/sdk 0.7.0 → 0.9.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 CHANGED
@@ -2,17 +2,18 @@
2
2
 
3
3
  Official Primitive Node.js SDK.
4
4
 
5
- This package ships five Node.js modules and one CLI:
5
+ The default root import is intentionally small and centered on email
6
+ automation:
6
7
 
7
- - `@primitivedotdev/sdk` for the webhook module
8
- - `@primitivedotdev/sdk/api` for the generated HTTP API client
9
- - `@primitivedotdev/sdk/openapi` for the canonical OpenAPI document export
10
- - `@primitivedotdev/sdk/contract` for the contract module
11
- - `@primitivedotdev/sdk/parser` for the parser module
8
+ - `primitive.receive(...)`
9
+ - `primitive.client(...)`
10
+ - `client.send(...)`
11
+ - `client.reply(...)`
12
+ - `client.forward(...)`
12
13
 
13
- It also publishes the `primitive` CLI bin from the same package.
14
-
15
- `contract`, `parser`, and `openapi` are Node-only extras. The Go and Python SDKs expose `webhook` and `api` modules.
14
+ Advanced webhook helpers, generated API operations, OpenAPI exports, contract
15
+ tooling, raw MIME parsing, and the CLI still exist as named exports or subpath
16
+ imports.
16
17
 
17
18
  ## Requirements
18
19
 
@@ -24,207 +25,154 @@ It also publishes the `primitive` CLI bin from the same package.
24
25
  npm install @primitivedotdev/sdk
25
26
  ```
26
27
 
27
- ## Modules
28
-
29
- ### Webhook
28
+ ## Basic usage
30
29
 
31
- The root entrypoint remains webhook-focused.
30
+ ### Receive and reply in a Next.js route
32
31
 
33
32
  ```ts
34
- import { handleWebhook, PrimitiveWebhookError } from "@primitivedotdev/sdk";
35
-
36
- app.post("/webhooks/email", express.raw({ type: "application/json" }), (req, res) => {
37
- try {
38
- const event = handleWebhook({
39
- body: req.body,
40
- headers: req.headers,
41
- secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
42
- });
43
-
44
- console.log("Email from:", event.email.headers.from);
45
- console.log("Subject:", event.email.headers.subject);
46
-
47
- res.json({ received: true });
48
- } catch (error) {
49
- if (error instanceof PrimitiveWebhookError) {
50
- return res.status(400).json({ error: error.code, message: error.message });
51
- }
52
-
53
- throw error;
54
- }
55
- });
56
- ```
33
+ import primitive from "@primitivedotdev/sdk";
57
34
 
58
- The same API is also available from `@primitivedotdev/sdk/webhook`.
35
+ export const runtime = "nodejs";
36
+ export const maxDuration = 300;
59
37
 
60
- Webhook exports include:
38
+ const client = primitive.client({
39
+ apiKey: process.env.PRIMITIVE_API_KEY!,
40
+ });
61
41
 
62
- - `handleWebhook(options)`
63
- - `parseWebhookEvent(input)`
64
- - `validateEmailReceivedEvent(input)`
65
- - `safeValidateEmailReceivedEvent(input)`
66
- - `verifyWebhookSignature(options)`
67
- - `validateEmailAuth(auth)`
68
- - `emailReceivedEventJsonSchema`
69
- - `WEBHOOK_VERSION`
70
- - webhook error classes and webhook types
42
+ export async function POST(req: Request) {
43
+ const email = await primitive.receive(req, {
44
+ secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
45
+ });
71
46
 
72
- ### API
47
+ await client.reply(email, "Thank you for your email.");
73
48
 
74
- Use the API module for outbound calls to the Primitive HTTP API.
49
+ return Response.json({ ok: true });
50
+ }
51
+ ```
52
+
53
+ ### Send a new email
75
54
 
76
55
  ```ts
77
- import { PrimitiveApiClient, getAccount } from "@primitivedotdev/sdk/api";
56
+ import primitive from "@primitivedotdev/sdk";
78
57
 
79
- const api = new PrimitiveApiClient({ apiKey: process.env.PRIMITIVE_API_KEY });
80
- const result = await getAccount({ client: api.client });
58
+ const client = primitive.client({
59
+ apiKey: process.env.PRIMITIVE_API_KEY!,
60
+ });
81
61
 
82
- if (result.error) {
83
- throw result.error;
84
- }
62
+ const result = await client.send({
63
+ from: "Support <support@example.com>",
64
+ to: "alice@example.com",
65
+ subject: "Hello",
66
+ bodyText: "Hi there",
67
+ // Use a unique key per logical send. Reusing a key returns the original
68
+ // response from the first send, which is how retries are deduplicated.
69
+ idempotencyKey: "customer-key-abc123",
70
+ wait: true,
71
+ waitTimeoutMs: 5000,
72
+ });
85
73
 
86
- console.log(result.data.id);
74
+ console.log(result.id, result.status, result.queueId, result.deliveryStatus);
87
75
  ```
88
76
 
89
- The package also ships a generated CLI bin named `primitive`.
90
-
91
- ### OpenAPI
77
+ `send`, `reply`, and `forward` keep the HTTP request open until Primitive's
78
+ downstream SMTP transaction completes. In production, configure your runtime or
79
+ transport with a request timeout long enough for SMTP delivery, typically 30-60
80
+ seconds.
92
81
 
93
- Use the OpenAPI module when another JavaScript application needs the canonical Primitive API spec.
82
+ ### About `wait` mode
94
83
 
95
- ```ts
96
- import { openapiDocument } from "@primitivedotdev/sdk/openapi";
84
+ When `wait: true`, the call returns the first downstream SMTP outcome (or
85
+ `waitTimeoutMs`, default 30000). Possible terminal `deliveryStatus` values:
97
86
 
98
- console.log(openapiDocument.openapi);
99
- ```
87
+ - `delivered` accepted by the receiving MTA
88
+ - `bounced` rejected by the receiving MTA (the response is still 200 OK)
89
+ - `deferred` temporary failure, the receiving MTA may retry
90
+ - `wait_timeout` no outcome was observed in time. Treat as "outcome unknown."
91
+ The send may still complete after the response returns.
100
92
 
101
- ### CLI
93
+ ### Reply from a different address
102
94
 
103
- Use the published `primitive` CLI for outbound API access from the terminal.
95
+ `reply()` defaults the From address to the inbound recipient (the address that
96
+ received the email). When your verified outbound domain differs from your
97
+ inbound domain, pass `from` explicitly:
104
98
 
105
- ```bash
106
- primitive --help
107
- primitive account get-account --api-key prim_test
108
- primitive emails download-raw-email --id <uuid> --api-key prim_test --output email.eml
99
+ ```ts
100
+ await client.reply(email, {
101
+ text: "Thanks for your email.",
102
+ from: "notifications@outbound.example.com",
103
+ });
109
104
  ```
110
105
 
111
- Autocomplete support is available through:
106
+ ### Forward an inbound email
112
107
 
113
- - `primitive completion fish`
114
- - `primitive completion bash`
115
- - `primitive completion zsh`
116
- - `primitive completion powershell`
108
+ ```ts
109
+ await client.forward(email, {
110
+ to: "ops@example.com",
111
+ bodyText: "Can you take this one?",
112
+ });
113
+ ```
117
114
 
118
- ### Contract
115
+ ## The normalized email object
119
116
 
120
- Use the contract module when constructing canonical Primitive webhook payloads on the producer side.
117
+ `primitive.receive(...)` returns a normalized inbound email object that keeps the
118
+ common case clean:
121
119
 
122
120
  ```ts
123
- import { buildEmailReceivedEvent, signWebhookPayload } from "@primitivedotdev/sdk/contract";
124
-
125
- const event = buildEmailReceivedEvent({
126
- email_id: "email-123",
127
- endpoint_id: "endpoint-456",
128
- message_id: "<msg@example.com>",
129
- sender: "from@example.com",
130
- recipient: "to@example.com",
131
- subject: "Hello",
132
- received_at: "2025-01-01T00:00:00Z",
133
- smtp_helo: "mail.example.com",
134
- smtp_mail_from: "from@example.com",
135
- smtp_rcpt_to: ["to@example.com"],
136
- raw_bytes: Buffer.from("hello"),
137
- raw_sha256: "a".repeat(64),
138
- raw_size_bytes: 5,
139
- attempt_count: 1,
140
- date_header: null,
141
- download_url: "https://example.com/raw",
142
- download_expires_at: "2025-01-02T00:00:00Z",
143
- attachments_download_url: null,
144
- auth: {
145
- spf: "pass",
146
- dmarc: "pass",
147
- dmarcPolicy: "reject",
148
- dmarcFromDomain: "example.com",
149
- dmarcSpfAligned: true,
150
- dmarcDkimAligned: true,
151
- dmarcSpfStrict: false,
152
- dmarcDkimStrict: false,
153
- dkimSignatures: [],
154
- },
155
- analysis: {},
156
- });
121
+ email.sender.address
122
+ email.sender.name
157
123
 
158
- const signature = signWebhookPayload(JSON.stringify(event), "whsec_test");
159
- ```
124
+ email.receivedBy
125
+ email.receivedByAll
160
126
 
161
- Contract exports include:
127
+ email.replyTarget.address
128
+ email.replySubject
129
+ email.forwardSubject
162
130
 
163
- - `buildEmailReceivedEvent(input, options?)`
164
- - `generateEventId(endpointId, emailId)`
165
- - `RAW_EMAIL_INLINE_THRESHOLD`
166
- - `signWebhookPayload(rawBody, secret, timestamp?)`
167
- - `WEBHOOK_VERSION`
168
- - contract input and payload helper types
131
+ email.subject
132
+ email.text
169
133
 
170
- ### Parser
134
+ email.thread.messageId
135
+ email.thread.references
171
136
 
172
- Use the parser module for raw `.eml` parsing and attachment extraction.
173
-
174
- ```ts
175
- import {
176
- bundleAttachments,
177
- parseEmail,
178
- parseEmailWithAttachments,
179
- toParsedDataComplete,
180
- } from "@primitivedotdev/sdk/parser";
181
-
182
- const parsed = await parseEmailWithAttachments(emlBuffer);
183
- const archive = await bundleAttachments(parsed.attachments);
184
- const webhookParsed = toParsedDataComplete(parsed, null);
185
-
186
- await parseEmail(emlBuffer.toString("utf8"));
137
+ email.raw
187
138
  ```
188
139
 
189
- Parser exports include:
190
-
191
- - `parseEmail(emlRaw)`
192
- - `parseEmailWithAttachments(emlBuffer, options?)`
193
- - `bundleAttachments(attachments)`
194
- - `extractAttachmentMetadata(attachments)`
195
- - `getAttachmentsStorageKey(emailId, sha256)`
196
- - `toParsedDataComplete(parsed, attachmentsDownloadUrl)`
197
- - `toWebhookAttachments(attachments)`
198
- - `attachmentMetadataToWebhookAttachments(metadata)`
199
- - `toCanonicalHeaders(parsed)`
200
- - parser attachment and bundle types
140
+ Use `email.raw` when you need the original validated webhook event shape.
201
141
 
202
- ## Shared Schema
142
+ ## Advanced usage
203
143
 
204
- The webhook payload contract is defined by the canonical JSON schema in the repository and is exported by this package as `emailReceivedEventJsonSchema`.
144
+ ### Explicit receive form
205
145
 
206
- The SDK uses that schema to generate:
146
+ If your framework does not expose a standard `Request`, use the lower-level
147
+ form:
207
148
 
208
- - TypeScript types
209
- - runtime validators
210
- - the published schema export
149
+ ```ts
150
+ const email = primitive.receive({
151
+ body: req.body,
152
+ headers: req.headers,
153
+ secret: process.env.PRIMITIVE_WEBHOOK_SECRET!,
154
+ });
155
+ ```
211
156
 
212
- ## Error Handling
157
+ ### Generated API module
213
158
 
214
- All SDK-specific runtime errors extend `PrimitiveWebhookError` and include a stable error `code`.
159
+ Use the API subpath when you want the full generated HTTP API surface:
215
160
 
216
161
  ```ts
217
- import { PrimitiveWebhookError } from "@primitivedotdev/sdk";
218
-
219
- try {
220
- // ...
221
- } catch (error) {
222
- if (error instanceof PrimitiveWebhookError) {
223
- console.error(error.code, error.message);
224
- }
225
- }
162
+ import { PrimitiveApiClient, getAccount } from "@primitivedotdev/sdk/api";
163
+
164
+ const api = new PrimitiveApiClient({ apiKey: process.env.PRIMITIVE_API_KEY });
165
+ const result = await getAccount({ client: api.client });
226
166
  ```
227
167
 
168
+ ### Other advanced surfaces
169
+
170
+ - `@primitivedotdev/sdk/webhook`
171
+ - `@primitivedotdev/sdk/openapi`
172
+ - `@primitivedotdev/sdk/contract`
173
+ - `@primitivedotdev/sdk/parser`
174
+ - `primitive` CLI
175
+
228
176
  ## Development
229
177
 
230
178
  From `sdks/sdk-node`:
@@ -240,51 +188,7 @@ pnpm build
240
188
  Or from repo root `sdks/`:
241
189
 
242
190
  ```bash
243
- make node-install
191
+ make node-generate
244
192
  make node-check
245
193
  make node-build
246
194
  ```
247
-
248
- ## Package Layout
249
-
250
- ```text
251
- sdk-node/
252
- bin/
253
- run.js
254
- src/
255
- api/
256
- generated/
257
- index.ts
258
- contract/
259
- contract.ts
260
- index.ts
261
- oclif/
262
- api-command.ts
263
- fish-completion.ts
264
- index.ts
265
- openapi/
266
- index.ts
267
- openapi.generated.ts
268
- operations.generated.ts
269
- parser/
270
- attachment-bundler.ts
271
- attachment-parser.ts
272
- email-parser.ts
273
- index.ts
274
- mapping.ts
275
- webhook/
276
- auth.ts
277
- encoding.ts
278
- errors.ts
279
- index.ts
280
- parsing.ts
281
- signing.ts
282
- version.ts
283
- generated/
284
- email-received-event.validator.generated.ts
285
- index.ts
286
- schema.generated.ts
287
- types.generated.ts
288
- types.ts
289
- validation.ts
290
- ```
@@ -0,0 +1,111 @@
1
+ import addressparser from "nodemailer/lib/addressparser/index.js";
2
+ import isEmail from "validator/lib/isEmail.js";
3
+ //#region src/parser/address-parser.ts
4
+ const MAX_HEADER_LENGTH = 998;
5
+ const IS_EMAIL_OPTIONS = {
6
+ allow_ip_domain: true,
7
+ require_tld: true,
8
+ allow_display_name: false,
9
+ allow_utf8_local_part: true
10
+ };
11
+ /**
12
+ * Strict parser for RFC 5322 From-style headers in security-bearing
13
+ * contexts (allowlist gates, permission grants).
14
+ *
15
+ * Rejects, without falling back to a "best guess":
16
+ * - empty / whitespace-only input
17
+ * - inputs longer than RFC 5322's 998-octet line limit
18
+ * - multi-address From (RFC 5322 allows it but it is vanishingly
19
+ * rare and ambiguous as an identity)
20
+ * - group syntax ("Friends: a@b.com, c@d.com;")
21
+ * - any address that fails validator's isEmail check with our chosen
22
+ * options. That covers per-part length limits, dot-atom rules,
23
+ * hostname-label rules, TLD requirement, and other RFC 5321/5322
24
+ * conformance checks.
25
+ *
26
+ * Returns ONLY the validated address, with no display name. Strict
27
+ * exists for gating decisions, where the address is the security-
28
+ * bearing field. Display names from addressparser are not trustworthy
29
+ * here: weird inputs like `Name <user@x.com> <attacker@y.com>` get
30
+ * parsed as a single entry whose `name` silently includes the second
31
+ * address. Surfacing that as a "parsed name" would invite downstream
32
+ * misuse, so we drop it. If you need the name, call
33
+ * {@link parseFromHeaderLoose} alongside (it returns null on failure
34
+ * anyway, so you can still gate on strict's Result).
35
+ *
36
+ * Returns a typed Result so callers can map the failure reason to
37
+ * stable error codes without inspecting message text.
38
+ */
39
+ function parseFromHeader(header) {
40
+ if (header === null || header === void 0) return {
41
+ ok: false,
42
+ reason: "empty"
43
+ };
44
+ const trimmed = header.trim();
45
+ if (trimmed.length === 0) return {
46
+ ok: false,
47
+ reason: "empty"
48
+ };
49
+ if (Buffer.byteLength(trimmed, "utf8") > MAX_HEADER_LENGTH) return {
50
+ ok: false,
51
+ reason: "too_long"
52
+ };
53
+ const parsed = addressparser(trimmed);
54
+ if (parsed.length > 1) return {
55
+ ok: false,
56
+ reason: "multiple_addresses"
57
+ };
58
+ const entry = parsed[0];
59
+ if (entry === void 0) return {
60
+ ok: false,
61
+ reason: "invalid_address"
62
+ };
63
+ if ("group" in entry) return {
64
+ ok: false,
65
+ reason: "group_syntax"
66
+ };
67
+ const address = entry.address;
68
+ if (address === void 0 || !isEmail(address, IS_EMAIL_OPTIONS)) return {
69
+ ok: false,
70
+ reason: "invalid_address"
71
+ };
72
+ return {
73
+ ok: true,
74
+ value: { address: address.toLowerCase() }
75
+ };
76
+ }
77
+ /**
78
+ * Lenient parser for display-only call sites (inbox card "from",
79
+ * log lines, debugging). Returns the first parseable address with its
80
+ * display name, or null.
81
+ *
82
+ * Differences from {@link parseFromHeader}:
83
+ * - Multi-address From returns the first address instead of rejecting
84
+ * - Group syntax is flattened into its member addresses
85
+ * - Returns null instead of a typed reason on failure
86
+ * - Includes the parsed display name in the result
87
+ *
88
+ * Do not use for permission gates or any decision that grants access.
89
+ * That is what {@link parseFromHeader} is for. Names returned here can
90
+ * include addressparser's recovery output (trailing tokens, garbage
91
+ * before the address); treat as opaque text for display.
92
+ */
93
+ function parseFromHeaderLoose(header) {
94
+ if (header === null || header === void 0) return null;
95
+ const trimmed = header.trim();
96
+ if (trimmed.length === 0 || Buffer.byteLength(trimmed, "utf8") > MAX_HEADER_LENGTH) return null;
97
+ const parsed = addressparser(trimmed);
98
+ for (const entry of parsed) {
99
+ const candidates = "group" in entry && Array.isArray(entry.group) ? entry.group : [entry];
100
+ for (const candidate of candidates) {
101
+ const address = candidate.address;
102
+ if (address !== void 0 && isEmail(address, IS_EMAIL_OPTIONS)) return {
103
+ address: address.toLowerCase(),
104
+ name: candidate.name && candidate.name.length > 0 ? candidate.name : null
105
+ };
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+ //#endregion
111
+ export { parseFromHeaderLoose as n, parseFromHeader as t };
@@ -1,2 +1,2 @@
1
1
  // This file is auto-generated by @hey-api/openapi-ts
2
- export { addDomain, createEndpoint, createFilter, deleteDomain, deleteEmail, deleteEndpoint, deleteFilter, downloadAttachments, downloadRawEmail, getAccount, getEmail, getStorageStats, getWebhookSecret, listDeliveries, listDomains, listEmails, listEndpoints, listFilters, replayDelivery, replayEmailWebhooks, rotateWebhookSecret, testEndpoint, updateAccount, updateDomain, updateEndpoint, updateFilter, verifyDomain } from './sdk.gen.js';
2
+ export { addDomain, createEndpoint, createFilter, deleteDomain, deleteEmail, deleteEndpoint, deleteFilter, downloadAttachments, downloadRawEmail, getAccount, getEmail, getStorageStats, getWebhookSecret, listDeliveries, listDomains, listEmails, listEndpoints, listFilters, replayDelivery, replayEmailWebhooks, rotateWebhookSecret, sendEmail, testEndpoint, updateAccount, updateDomain, updateEndpoint, updateFilter, verifyDomain } from './sdk.gen.js';
@@ -345,3 +345,20 @@ export const replayDelivery = (options) => (options.client ?? client).post({
345
345
  url: '/webhooks/deliveries/{id}/replay',
346
346
  ...options
347
347
  });
348
+ /**
349
+ * Send outbound email
350
+ *
351
+ * Sends an outbound email through Primitive's outbound relay. By default
352
+ * the request returns once the relay accepts the message for delivery.
353
+ * Set `wait: true` to wait for the first downstream SMTP delivery outcome.
354
+ *
355
+ */
356
+ export const sendEmail = (options) => (options.client ?? client).post({
357
+ security: [{ scheme: 'bearer', type: 'http' }],
358
+ url: '/send-mail',
359
+ ...options,
360
+ headers: {
361
+ 'Content-Type': 'application/json',
362
+ ...options.headers
363
+ }
364
+ });