@productcraft/mail 0.2.4 → 1.0.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
@@ -1,191 +1,229 @@
1
1
  # @productcraft/mail
2
2
 
3
- Typed Node.js SDK for [ProductCraft Mail](https://productcraft.co) — receive-and-store mail platform: send template-rendered messages, manage mailboxes + domains + DKIM, lint templates, track deliveries, configure outbound webhooks, hold suppression lists.
3
+ Node.js / TypeScript client for **ProductCraft Mail**: transactional email (raw HTML or templates), sending domains with DKIM, the per-recipient delivery log, contacts, segments, broadcasts, suppression lists and signed webhooks.
4
4
 
5
- ```bash
5
+ Every method, type, error class and mock is generated from the production OpenAPI spec. Upgrading from 0.x? See the [1.0 migration guide](https://github.com/clauderanelagh/productcraft-node/blob/main/docs/migrations/mail-1.0.0.md).
6
+
7
+ ## Install
8
+
9
+ ```sh
6
10
  npm install @productcraft/mail
7
11
  ```
8
12
 
9
- Server-side only. The SDK ships a Platform API Key in the `Authorization` header — never embed it in a browser bundle.
13
+ Node 20.3 or later, Bun, Deno and edge runtimes. Or install the umbrella package for every product: `npm install productcraft`.
10
14
 
11
15
  ## Quick start
12
16
 
13
- ```ts before
17
+ ```ts
14
18
  import { Mail } from "@productcraft/mail";
15
19
 
16
- // Reads PRODUCTCRAFT_API_KEY from the environment
17
- const mail = new Mail();
20
+ // Reads PRODUCTCRAFT_API_KEY (and PRODUCTCRAFT_WORKSPACE_ID) from the environment.
21
+ const mail = new Mail({ workspaceId: "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b" });
18
22
 
19
- // Send a template-rendered message
20
- const { data, error } = await mail.client.POST(
21
- "/v1/workspaces/{workspace_id}/templates/{name}/send",
22
- {
23
- params: {
24
- path: { workspace_id: "ws_...", name: "welcome" },
25
- // Make retries safe — replays return the original response
26
- // with `Idempotent-Replay: true` for 24h.
27
- header: { "Idempotency-Key": "welcome-2026-05-22-alice" },
28
- },
29
- body: {
30
- from: "hello@yourbrand.com",
31
- to: "alice@example.com",
32
- subject: "Welcome", // optional — falls back to template subject
33
- data: { name: "Alice" }, // rendered into the template
34
- },
35
- },
36
- );
23
+ // Send raw HTML (an Idempotency-Key is generated for you, so a retry never sends twice).
24
+ const email = await mail.emails.send({
25
+ from: "Acme <hello@mail.acme.com>",
26
+ to: "success@simulator.amazonses.com",
27
+ subject: "Welcome",
28
+ html: "<p>Thanks for signing up.</p>",
29
+ });
30
+ console.log(email.id);
31
+
32
+ // Or render a stored template with your data.
33
+ await mail.templates.send("welcome", { to: "success@simulator.amazonses.com", data: { first_name: "Ada" } });
34
+
35
+ // Walk the delivery log; pages are fetched as you iterate.
36
+ for await (const message of mail.messages.list({ status: ["bounced", "complained"] })) {
37
+ console.log(message.id, message.status);
38
+ }
37
39
  ```
38
40
 
39
- The `client` property is an [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/) instance bound to `https://api.mail.productcraft.co` and your auth credential. Every endpoint in the published OpenAPI spec is reachable through it — your editor's autocomplete lists paths + method shapes + body fields.
41
+ `workspaceId` is optional for workspace API keys: without it the client looks it up once per process (one extra request on a cold start). Pass it, or set `PRODUCTCRAFT_WORKSPACE_ID`, to skip the lookup. Bearer tokens must always pass it.
42
+
43
+ Nouns: `emails` is the call you made (send, batch, list, retrieve, metrics); `messages` is the per-recipient delivery log (list, retrieve, timeline, body, export, erase). Exports, raw MIME and attachments resolve to the raw `Response` (`.text()`, `.arrayBuffer()`, `.body`).
44
+
45
+ ## Credentials
46
+
47
+ Pass `apiKey` (a workspace key, `pcft_live_…`), or omit it and the client reads `PRODUCTCRAFT_API_KEY`. An explicit `auth: { type: "bearer", token }` works for platform user tokens. The environment fallback is server-only: never put a key in `NEXT_PUBLIC_*` or any variable a bundler inlines into browser code (a workspace key in a browser throws unless you set `dangerouslyAllowBrowser: true`).
48
+
49
+ Webhook signing secrets: Mail has a workspace default secret, per-domain secrets and one per endpoint. Pass all that apply as an array (`new Mail({ webhookSecret: [a, b] })` or `mail.webhooks.unwrap({ ..., secret: [a, b] })`); `unwrap` accepts the Standard Webhooks headers and the older `X-Mail-Signature` scheme.
50
+
51
+ <!-- generated:start (scripts/gen-sdk.mjs; edits between the markers are overwritten) -->
52
+
53
+ ## Resources
54
+
55
+ Full reference: [api.md](./api.md).
56
+
57
+ - `mail.brand`: `reset`, `retrieve`, `update`
58
+ - `mail.broadcasts`: `audience`, `cancel`, `clickedLinks.list`, `create`, `delete`, `duplicate`, `list`, `recipients.list`, `retrieve`, `send`, `settings.retrieve`, `settings.update`, `update`
59
+ - `mail.contacts`: `create`, `delete`, `imports.create`, `imports.list`, `imports.retrieve`, `list`, `properties.create`, `properties.delete`, `properties.list`, `properties.retrieve`, `properties.update`, `retrieve`, `segments.add`, `segments.delete`, `segments.list`, `topics.list`, `topics.update`, `update`
60
+ - `mail.deliverability`: `retrieve`
61
+ - `mail.domains`: `create`, `delete`, `dnsRecords.apply`, `dnsRecords.check`, `dnsRecords.export`, `dnsRecords.list`, `domainConnect.start`, `events.list`, `list`, `lookup`, `retrieve`, `shareLinks.create`, `shareLinks.delete`, `shareLinks.list`, `smtpConfig.reset`, `smtpConfig.retrieve`, `smtpConfig.update`, `smtpConfig.verifySaved`, `testSend`, `update`, `verify`
62
+ - `mail.emails`: `batch`, `list`, `metrics`, `retrieve`, `send`
63
+ - `mail.integrations`: `delete`, `github.connect`, `github.repos.branches.list`, `github.repos.list`, `github.settings.update`, `github.sync.create`, `github.sync.retrieve`, `list`, `retrieve`
64
+ - `mail.mailboxes`: `create`, `delete`, `list`, `messages.attachments.retrieve`, `messages.delete`, `messages.list`, `messages.raw`, `messages.read`, `messages.retrieve`, `retrieve`, `update`
65
+ - `mail.messages`: `body`, `erase`, `export`, `list`, `retrieve`, `settings.retrieve`, `settings.update`, `timeline`
66
+ - `mail.received`: `attachments.retrieve`, `delete`, `export`, `list`, `raw`, `read`, `retrieve`
67
+ - `mail.segments`: `contacts.list`, `create`, `delete`, `list`, `metrics`, `preview`, `retrieve`, `update`
68
+ - `mail.sendingDefaults`: `retrieve`, `update`
69
+ - `mail.smtpConfig`: `list`, `reset`, `retrieve`, `update`, `verify`, `verifySaved`
70
+ - `mail.stats`: `retrieve`
71
+ - `mail.suppression`: `create`, `delete`, `list`
72
+ - `mail.templates`: `create`, `delete`, `lint`, `list`, `preview`, `render`, `retrieve`, `send`, `sendBatch`, `tags.list`, `testSend`, `variables.list`
73
+ - `mail.topics`: `create`, `delete`, `list`, `retrieve`, `update`
74
+ - `mail.webhooks`: `attempts.list`, `endpoints.create`, `endpoints.delete`, `endpoints.list`, `endpoints.retrieve`, `endpoints.rotateSecret`, `endpoints.test`, `endpoints.update`, `eventTypes.list`, `events.list`, `events.replay`, `events.retrieve`
40
75
 
41
- ## Credentials from the environment
76
+ ## Pagination
42
77
 
43
- Server-side, omit `auth` and the client reads your workspace key from the environment:
78
+ List methods return a `PagePromise`: `for await` walks every item across pages, `await` gives one page.
44
79
 
45
- ```bash
46
- # .env
47
- PRODUCTCRAFT_API_KEY=pcft_live_...
80
+ ```ts
81
+ import { Mail } from "@productcraft/mail";
82
+ const mail = new Mail({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
83
+ for await (const item of mail.broadcasts.list({ limit: 50 })) console.log(item);
84
+ const page = await mail.broadcasts.list();
85
+ if (page.hasNextPage()) await page.getNextPage();
86
+ const all = await mail.broadcasts.list().toArray({ maxItems: 500 });
48
87
  ```
49
88
 
50
- - An explicit `auth` always wins over the environment.
51
- - Hosts are never read from the environment. Override them in code with `baseUrl`.
52
- - This is a server-only fallback. Never put a key in `NEXT_PUBLIC_*` or any other variable a bundler inlines into browser code.
53
-
54
- ## Configuration
55
-
56
- ```ts before
57
- new Mail({
58
- // The auth credential the SDK presents to Mail
59
- // Omit to use PRODUCTCRAFT_API_KEY from the environment
60
- auth: { type: "apiKey", key: "pcft_live_..." }
61
- | { type: "bearer", token: "eyJ..." }
62
- | { type: "cookie", value: "auth_token=..." },
63
- // Override the prod base URL — useful for staging / a local proxy
64
- baseUrl: "https://api.mail.example.test",
65
- // Custom fetch implementation (undici with retry, mock in tests, ...)
66
- fetch: customFetch,
67
- });
89
+ ## Errors
90
+
91
+ Every failure is a class you can catch. `APIError` carries `status`, `code`, `message`, `errors[]`, `requestId` and `docUrl`.
92
+
93
+ ```ts
94
+ import { Mail, NotFoundError, RateLimitError, APIError } from "@productcraft/mail";
95
+ const mail = new Mail();
96
+ try {
97
+ await mail.broadcasts.retrieve("00000000-0000-0000-0000-000000000000");
98
+ } catch (err) {
99
+ if (err instanceof NotFoundError) console.log(err.code, err.requestId, err.docUrl);
100
+ else if (err instanceof RateLimitError) console.log(err.retryAfter);
101
+ else if (err instanceof APIError) console.log(err.status, err.errors);
102
+ else throw err;
103
+ }
68
104
  ```
69
105
 
70
- ## Common operations
71
-
72
- Every path is workspace-scoped — `{workspace_id}` is the UUID of the workspace that owns the resource, returned by `@productcraft/platform-auth`'s introspect endpoint.
73
-
74
- ### Templates
75
-
76
- ```ts before
77
- // List templates
78
- await mail.client.GET(
79
- "/v1/workspaces/{workspace_id}/templates",
80
- { params: { path: { workspace_id: "ws_..." } } },
81
- );
82
-
83
- // Lint a template before saving — the body field is `body_html`
84
- // (wire) / `bodyHtml` (camelCase TS DTO), NOT `html`.
85
- await mail.client.POST(
86
- "/v1/workspaces/{workspace_id}/templates/lint",
87
- {
88
- params: { path: { workspace_id: "<workspace-uuid>" } },
89
- body: { body_html: "<p>Hello {{name}}</p>" },
90
- },
91
- );
92
- // → { score, findings: [...] }
93
-
94
- // Preview / render with sample data
95
- await mail.client.POST(
96
- "/v1/workspaces/{workspace_id}/templates/{name}/render",
97
- {
98
- params: { path: { workspace_id: "ws_...", name: "welcome" } },
99
- body: { data: { name: "Alice" } },
100
- },
101
- );
102
-
103
- // Send to one address (template-rendered)
104
- await mail.client.POST(
105
- "/v1/workspaces/{workspace_id}/templates/{name}/send",
106
- { params: { ... }, body: { from, to, data } },
107
- );
108
-
109
- // Send the same template to many addresses (batch)
110
- await mail.client.POST(
111
- "/v1/workspaces/{workspace_id}/templates/{name}/send-batch",
112
- { params: { ... }, body: { recipients: [{ to, data }, ...] } },
113
- );
106
+ ## Retries and timeouts
107
+
108
+ Network errors, 408, 429, 5xx and `409 IDEMPOTENCY_IN_PROGRESS` are retried twice by default with exponential backoff and jitter, honouring `Retry-After` up to 60 s. A POST or PATCH without an idempotency key is never retried. Each attempt times out after 60 s, so the worst case with defaults is about 3 minutes; pass `signal: AbortSignal.timeout(ms)` for an overall deadline.
109
+
110
+ ```ts
111
+ import { Mail } from "@productcraft/mail";
112
+ const mail = new Mail({ maxRetries: 3, timeout: 20_000 });
113
+ await mail.broadcasts.list({}, { maxRetries: 0, signal: AbortSignal.timeout(5_000) });
114
114
  ```
115
115
 
116
- ### Domains + DKIM
116
+ ## Idempotency
117
+
118
+ Writes that support it send an `Idempotency-Key` automatically and reuse it on every retry. Pass your own to make a retry across processes safe. If the API's idempotency store is unavailable it runs the request without it (fail-open), so a retry during that window can execute twice.
119
+
120
+ ```ts
121
+ import { Mail } from "@productcraft/mail";
122
+ const mail = new Mail({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
123
+ const broadcast = await mail.broadcasts.create({ name: "October product update" }, { idempotencyKey: "order-1234" });
124
+ ```
117
125
 
118
- ```ts before
119
- // List domains
120
- await mail.client.GET("/v1/workspaces/{workspace_id}/domains", { ... });
126
+ ## Polling
121
127
 
122
- // Add a domain (then publish the DKIM TXT record Mail prints)
123
- await mail.client.POST(
124
- "/v1/workspaces/{workspace_id}/domains",
125
- { params: { ... }, body: { name: "mail.yourbrand.com" } },
126
- );
128
+ `domains.poll(id)` retrieves until `status` is terminal (`active`, `disabled`), honouring `Retry-After`; `timeoutMs` (default 5 min) throws `APITimeoutError`.
127
129
 
128
- // Verify after DNS propagation
129
- await mail.client.POST(
130
- "/v1/workspaces/{workspace_id}/domains/{domain_id}/verify",
131
- { params: { path: { workspace_id, domain_id } } },
132
- );
130
+ ```ts
131
+ import { Mail } from "@productcraft/mail";
132
+ const mail = new Mail({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
133
+ const done = await mail.domains.poll("9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b", { intervalMs: 2_000, timeoutMs: 600_000 });
134
+ console.log(done.status);
135
+ ```
136
+
137
+ ## Webhooks
138
+
139
+ `mail.webhooks.unwrap()` verifies the signature against the **raw** body and returns a typed `Mail.UnwrapWebhookEvent`. The secret comes from `{ secret }`, the client's `webhookSecret`, or `PRODUCTCRAFT_MAIL_WEBHOOK_SECRET`.
140
+
141
+ Next.js route handler:
142
+
143
+ ```ts
144
+ import { Mail } from "@productcraft/mail";
145
+ const mail = new Mail();
146
+ export async function POST(req: Request) {
147
+ const event = await mail.webhooks.unwrap({ payload: await req.text(), headers: req.headers });
148
+ switch (event.type) {
149
+ case "contact.created":
150
+ console.log(event.data);
151
+ break;
152
+ default:
153
+ break; // UnknownWebhookEvent: types added later
154
+ }
155
+ return new Response(null, { status: 204 });
156
+ }
157
+ ```
158
+
159
+ Express (keep the body raw):
160
+
161
+ ```ts
162
+ import express from "express";
163
+ import { Mail } from "@productcraft/mail";
164
+ const mail = new Mail();
165
+ const app = express();
166
+ app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
167
+ const event = await mail.webhooks.unwrap({ payload: req.body, headers: req.headers });
168
+ console.log(event.type);
169
+ res.sendStatus(204);
170
+ });
133
171
  ```
134
172
 
135
- ### Messages
136
-
137
- ```ts before
138
- // Workspace-wide message listing + filters
139
- await mail.client.GET(
140
- "/v1/workspaces/{workspace_id}/messages",
141
- { params: { path: { workspace_id }, query: { limit: 50 } } },
142
- );
143
-
144
- // Read body / attachments / raw RFC822
145
- await mail.client.GET("/v1/workspaces/{workspace_id}/messages/{id}/body", { ... });
146
- await mail.client.GET(
147
- "/v1/workspaces/{workspace_id}/mailboxes/{id}/messages/{mid}/attachments/{position}",
148
- { ... },
149
- );
173
+ Hono:
174
+
175
+ ```ts
176
+ import { Hono } from "hono";
177
+ import { Mail } from "@productcraft/mail";
178
+ const mail = new Mail();
179
+ const app = new Hono();
180
+ app.post("/webhooks", async (c) => {
181
+ const event = await mail.webhooks.unwrap({ payload: await c.req.text(), headers: c.req.raw.headers });
182
+ console.log(event.type);
183
+ return c.body(null, 204);
184
+ });
150
185
  ```
151
186
 
152
- ### Suppression list
187
+ ## Raw responses
188
+
189
+ Every returned object has a non-enumerable `lastResponse` (`requestId`, `status`, `headers`). `.withResponse()` adds the `Response` and parsed rate-limit headers.
153
190
 
154
- ```ts before
155
- // Add an email to the workspace suppression list
156
- await mail.client.POST(
157
- "/v1/workspaces/{workspace_id}/suppression",
158
- { params: { ... }, body: { email: "bounced@example.com", reason: "hard_bounce" } },
159
- );
191
+ ```ts
192
+ import { Mail } from "@productcraft/mail";
193
+ const mail = new Mail({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
194
+ const id = "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b";
195
+ const obj = await mail.broadcasts.retrieve(id);
196
+ console.log(obj.lastResponse.requestId);
197
+ const { data, response, requestId, rateLimit } = await mail.broadcasts.retrieve(id).withResponse();
198
+ console.log(data, response.status, requestId, rateLimit?.remaining);
160
199
  ```
161
200
 
162
- ### Outbound webhooks
163
-
164
- ```ts before
165
- // Configure the workspace's default webhook (delivery / open / bounce events)
166
- await mail.client.PUT(
167
- "/v1/workspaces/{workspace_id}/webhooks/default",
168
- {
169
- params: { ... },
170
- body: { url: "https://yourapp.com/hooks/mail", events: ["message.delivered"] },
171
- },
172
- );
173
-
174
- // Per-domain webhook overrides + secret rotation
175
- await mail.client.POST(
176
- "/v1/workspaces/{workspace_id}/webhooks/domains/{domain_id}/rotate-secret",
177
- { ... },
178
- );
201
+ ## Testing
202
+
203
+ `@productcraft/mail/testing` exports `createMockFetch()`, a `fetch` that answers every operation with its spec example. Override one operation by name.
204
+
205
+ ```ts
206
+ import { Mail } from "@productcraft/mail";
207
+ import { createMockFetch } from "@productcraft/mail/testing";
208
+ const fetch = createMockFetch();
209
+ const mail = new Mail({ apiKey: "test", workspaceId: "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b", fetch });
210
+ await mail.broadcasts.list();
211
+ console.log(fetch.calls.length);
179
212
  ```
180
213
 
181
- ## Idempotency
214
+ ## Escape hatch
182
215
 
183
- Send endpoints accept an `Idempotency-Key` header (1–256 chars, `[A-Za-z0-9_\-:]`). Retries with the same key + same body replay the original response with `Idempotent-Replay: true` for 24h. Same key + different body returns `409 IDEMPOTENCY_KEY_REUSE`.
216
+ `.client` is the typed `openapi-fetch` client over the same credential and base URL (no retries). Use it for anything the resource methods do not cover.
184
217
 
185
- ## How this SDK is built
218
+ ```ts
219
+ import { Mail } from "@productcraft/mail";
220
+ const mail = new Mail({ apiKey: process.env.PRODUCTCRAFT_API_KEY });
221
+ const { data, error } = await mail.client.GET("/v1/workspaces/{workspace_id}/brand", { params: { path: { workspace_id: "9b2f6c1e-0d6a-4a63-9a43-2f1c3e4d5a6b" } } });
222
+ console.log(data, error);
223
+ ```
186
224
 
187
- Generated from the live OpenAPI spec at `https://api.mail.productcraft.co/docs-json` via [`openapi-typescript`](https://openapi-ts.dev/) (types) + [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/) (runtime). The nightly `spec-refresh` workflow opens a PR whenever the spec changes; merging publishes a patch bump so type-safe consumers stay current automatically.
225
+ <!-- generated:end -->
188
226
 
189
227
  ## License
190
228
 
191
- [MIT](https://github.com/clauderanelagh/productcraft-node/blob/main/LICENSE).
229
+ MIT