mailchannels-sdk 0.7.10 โ†’ 0.7.11

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
@@ -18,13 +18,13 @@ This library provides a simple way to interact with the [MailChannels API](https
18
18
  ## Contents
19
19
 
20
20
  - ๐Ÿš€ [Features](#features)
21
- - ๐Ÿ“ [Requirements](#requirements)
21
+ - ๐Ÿ“ [Prerequisites](#prerequisites)
22
22
  - ๐Ÿ“ฆ [Installation](#installation)
23
23
  - ๐Ÿ“š [Usage](#usage)
24
24
  - ๐Ÿ“ [Naming Conventions](#naming-conventions)
25
+ - ๐Ÿงช [Local simulator](#local-simulator)
25
26
  - โš–๏ธ [License](#license)
26
27
  - ๐Ÿ’ป [Development](#development)
27
- - ๐Ÿงช [Local simulator](#local-simulator)
28
28
 
29
29
  ## <a name="features">๐Ÿš€ Features</a>
30
30
 
@@ -41,6 +41,7 @@ Some of the things you can do with the SDK:
41
41
  - Retrieve metrics
42
42
  - Inspect webhook delivery batches
43
43
  - Handle suppressions
44
+ - Run a local simulator for development testing
44
45
  - Configure inbound domains
45
46
  - Manage account and recipient lists
46
47
 
@@ -48,7 +49,7 @@ Some of the things you can do with the SDK:
48
49
  > For a detailed reference mapping each SDK method to its corresponding MailChannels API endpoint reference, see the [SDK-API Mapping](https://mailchannels.yizack.com/sdk-api-mapping)
49
50
  <!-- #endregion features -->
50
51
 
51
- ## <a name="requirements">๐Ÿ“ Requirements</a>
52
+ ## <a name="prerequisites">๐Ÿ“ Prerequisites</a>
52
53
 
53
54
  - [Create a MailChannels account](https://www.mailchannels.com/pricing/#for_devs)
54
55
  - [Create an API key](https://console.mailchannels.net/settings/accountSettings#APIKeys)
@@ -101,59 +102,44 @@ Most properties in the MailChannels API use `snake_case`. To follow JavaScript c
101
102
  - While most fields match the API docs (just with `camelCase`), a few may be simplified or reorganized to feel more natural for JavaScript developers.
102
103
  <!-- #endregion naming-conventions -->
103
104
 
104
- ## <a name="license">โš–๏ธ License</a>
105
-
106
- [MIT License](LICENSE)
107
-
108
- ## <a name="development">๐Ÿ’ป Development</a>
109
-
110
- <details>
111
- <summary>Local development</summary>
112
-
113
- ```sh
114
- # Install dependencies
115
- pnpm install
116
-
117
- # Build the package
118
- pnpm build
119
-
120
- # Run Oxlint
121
- pnpm lint
105
+ ## <a name="local-simulator">๐Ÿงช Local simulator</a>
122
106
 
123
- # Run Vitest
124
- pnpm test
125
- pnpm test:watch
107
+ <!-- #region simulator -->
108
+ This package includes a local MailChannels simulator you can run via the CLI. It holds state in memory and emulates the SDK-supported endpoints, letting you develop and test your application locally without hitting the real MailChannels service.
126
109
 
127
- # Run typecheck
128
- pnpm test:types
110
+ | API | Source |
111
+ | ----------- | -------------------------------------------------------------------------------------------------------------- |
112
+ | Email API | [`src/simulator/email-api.mjs`](https://github.com/Yizack/mailchannels/blob/main/src/simulator/email-api.mjs) |
113
+ | Inbound API | N/A |
129
114
 
130
- # Refresh API parity fixtures
131
- pnpm parity:fixtures
115
+ > [!IMPORTANT]
116
+ > The simulator approximates the MailChannels service for local development and testing. It is not a production implementation and may differ from the live service.
132
117
 
133
- # Run the local Email API simulator
134
- pnpm simulate:email-api
118
+ ### Start the simulator
135
119
 
136
- # Release new version
137
- pnpm release
120
+ ```sh
121
+ # default: http://127.0.0.1:8787
122
+ npx mailchannels-sdk simulate
138
123
  ```
139
124
 
140
- </details>
141
-
142
- ## <a name="local-simulator">๐Ÿงช Local simulator</a>
125
+ ### Options
143
126
 
144
- This repo includes a small local MailChannels Email API simulator at [scripts/email-api-simulator.mjs](./scripts/email-api-simulator.mjs). It keeps state in memory and emulates the SDK-supported Email API endpoints so you can test your application without calling the real MailChannels service.
127
+ | Option | Description | Default |
128
+ | ----------------- | ----------------------- | ----------- |
129
+ | `--port <number>` | Port to listen on | `8787` |
130
+ | `--host <host>` | Host address | `127.0.0.1` |
131
+ | `--silent` | Suppress simulator logs | `false` |
145
132
 
146
- ### Start the simulator
133
+ You can override the bind address with the `--host` and `--port` options:
147
134
 
148
135
  ```sh
149
- # default: http://127.0.0.1:8787
150
- pnpm simulate:email-api
136
+ npx mailchannels-sdk simulate --host 127.0.0.1 --port 8787
151
137
  ```
152
138
 
153
- You can override the bind address with environment variables:
139
+ Disable logs with the `--silent` option:
154
140
 
155
141
  ```sh
156
- MAILCHANNELS_SIMULATOR_HOST=127.0.0.1 MAILCHANNELS_SIMULATOR_PORT=8787 pnpm simulate:email-api
142
+ npx mailchannels-sdk simulate --silent
157
143
  ```
158
144
 
159
145
  ### Point the SDK at the simulator
@@ -192,6 +178,45 @@ const { data, error } = await mailchannels.emails.send({
192
178
  - Webhook responses are simulated locally, but the simulator does not yet emit real webhook callbacks to your application
193
179
 
194
180
  The next planned expansion is outbound webhook delivery so client applications can test webhook ingestion flows against the simulator as well.
181
+ <!-- #endregion simulator -->
182
+
183
+ ## <a name="license">โš–๏ธ License</a>
184
+
185
+ [MIT License](LICENSE)
186
+
187
+ ## <a name="development">๐Ÿ’ป Development</a>
188
+
189
+ <details>
190
+ <summary>Local development</summary>
191
+
192
+ ```sh
193
+ # Install dependencies
194
+ pnpm install
195
+
196
+ # Build the package
197
+ pnpm build
198
+
199
+ # Run Oxlint
200
+ pnpm lint
201
+
202
+ # Run Vitest
203
+ pnpm test
204
+ pnpm test:watch
205
+
206
+ # Run typecheck
207
+ pnpm test:types
208
+
209
+ # Refresh API parity fixtures
210
+ pnpm parity:fixtures
211
+
212
+ # Run the local simulator
213
+ pnpm simulate
214
+
215
+ # Release new version
216
+ pnpm release
217
+ ```
218
+
219
+ </details>
195
220
 
196
221
  <!-- Badges -->
197
222
  [npm-version-src]: https://img.shields.io/npm/v/mailchannels-sdk.svg?style=flat&colorA=070a30&colorB=35a047
@@ -0,0 +1,743 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ const SIMULATOR_SIGNING_KEY_ID = "simulator-default";
4
+ const JSON_HEADERS = { "content-type": "application/json" };
5
+ const currentTimestamp = () => (/* @__PURE__ */ new Date()).toISOString();
6
+ const createId = (prefix) => `${prefix}_${randomBytes(6).toString("hex")}`;
7
+ const clone = (value) => JSON.parse(JSON.stringify(value));
8
+ const readBody = async (request) => {
9
+ const chunks = [];
10
+ for await (const chunk of request) chunks.push(chunk);
11
+ if (!chunks.length) return null;
12
+ const raw = Buffer.concat(chunks).toString("utf8");
13
+ if (!raw) return null;
14
+ return JSON.parse(raw);
15
+ };
16
+ const sendJson = (response, statusCode, body) => {
17
+ response.writeHead(statusCode, JSON_HEADERS);
18
+ response.end(JSON.stringify(body));
19
+ };
20
+ const sendNoContent = (response) => {
21
+ response.writeHead(204);
22
+ response.end();
23
+ };
24
+ const sendText = (response, statusCode, message) => {
25
+ response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
26
+ response.end(message);
27
+ };
28
+ const getArrayQuery = (url, name) => {
29
+ return url.searchParams.getAll(name).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
30
+ };
31
+ const renderTemplate = (value, data = {}) => value.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (_, key) => {
32
+ const replacement = data[key];
33
+ return replacement === void 0 || replacement === null ? "" : String(replacement);
34
+ });
35
+ const buildRenderedMessage = (payload, personalization) => {
36
+ const recipientList = (personalization.to || []).map((recipient) => recipient.email).join(", ");
37
+ const subject = personalization.subject || payload.subject;
38
+ const templateData = {
39
+ ...payload.personalizations?.[0]?.dynamic_template_data || {},
40
+ ...personalization.dynamic_template_data || {}
41
+ };
42
+ const content = (payload.content || []).map((item) => renderTemplate(item.value, templateData)).join("\n\n");
43
+ return [
44
+ `From: ${personalization.from?.email || payload.from?.email || "sender@example.com"}`,
45
+ `To: ${recipientList}`,
46
+ `Subject: ${subject}`,
47
+ "",
48
+ content
49
+ ].join("\n");
50
+ };
51
+ const createMetricsBuckets = (count) => [{
52
+ count,
53
+ period_start: currentTimestamp()
54
+ }];
55
+ const createAccountState = (apiKey) => ({
56
+ apiKey,
57
+ customerHandle: createId("customer"),
58
+ dkimKeysByDomain: /* @__PURE__ */ new Map(),
59
+ messages: [],
60
+ subAccounts: /* @__PURE__ */ new Map(),
61
+ suppressionEntries: [],
62
+ webhookBatches: [],
63
+ webhooks: /* @__PURE__ */ new Set(),
64
+ webhookSigningKeys: new Map([[SIMULATOR_SIGNING_KEY_ID, "SIMULATOR_PUBLIC_SIGNING_KEY"]]),
65
+ nextIds: {
66
+ apiKey: 1,
67
+ batch: 1,
68
+ smtpPassword: 1,
69
+ subAccount: 1
70
+ }
71
+ });
72
+ const createSubAccount = (account, companyName, handle) => ({
73
+ apiKeys: [],
74
+ company_name: companyName,
75
+ enabled: true,
76
+ handle: handle || `subaccount${account.nextIds.subAccount++}`,
77
+ limit: null,
78
+ smtpPasswords: [],
79
+ usage: 0
80
+ });
81
+ const toDkimDnsRecord = (selector, domain, value) => ({
82
+ name: `${selector}._domainkey.${domain}`,
83
+ type: "TXT",
84
+ value: `v=DKIM1; k=rsa; p=${value}`
85
+ });
86
+ const createDkimKey = (domain, selector, overrides = {}) => {
87
+ const publicKey = Buffer.from(`${domain}:${selector}:public-key`).toString("base64");
88
+ return {
89
+ algorithm: "rsa",
90
+ created_at: currentTimestamp(),
91
+ dkim_dns_records: [toDkimDnsRecord(selector, domain, publicKey)],
92
+ domain,
93
+ gracePeriodExpiresAt: null,
94
+ key_length: 2048,
95
+ public_key: publicKey,
96
+ retiresAt: null,
97
+ selector,
98
+ status: "active",
99
+ status_modified_at: currentTimestamp(),
100
+ ...overrides
101
+ };
102
+ };
103
+ const listDkimKeys = (account, domain) => account.dkimKeysByDomain.get(domain) || [];
104
+ const recordWebhookBatch = (account, webhook, eventCount, status = "2xx_response", statusCode = 200) => {
105
+ account.webhookBatches.unshift({
106
+ batch_id: account.nextIds.batch++,
107
+ created_at: currentTimestamp(),
108
+ customer_handle: account.customerHandle,
109
+ duration: {
110
+ unit: "milliseconds",
111
+ value: 25
112
+ },
113
+ event_count: eventCount,
114
+ status,
115
+ status_code: statusCode,
116
+ webhook
117
+ });
118
+ };
119
+ const collectMessages = (account, filters = {}) => {
120
+ return account.messages.filter((message) => {
121
+ if (filters.campaignId && message.campaignId !== filters.campaignId) return false;
122
+ if (filters.scopeHandle !== void 0 && message.scopeHandle !== filters.scopeHandle) return false;
123
+ return true;
124
+ });
125
+ };
126
+ const summarizeMessages = (messages) => ({
127
+ bounced: messages.reduce((total, message) => total + message.bounced, 0),
128
+ click: messages.reduce((total, message) => total + message.click, 0),
129
+ clickTrackingDelivered: messages.reduce((total, message) => total + message.clickTrackingDelivered, 0),
130
+ delivered: messages.reduce((total, message) => total + message.delivered, 0),
131
+ dropped: messages.reduce((total, message) => total + message.dropped, 0),
132
+ open: messages.reduce((total, message) => total + message.open, 0),
133
+ openTrackingDelivered: messages.reduce((total, message) => total + message.openTrackingDelivered, 0),
134
+ processed: messages.reduce((total, message) => total + message.processed, 0),
135
+ unsubscribeDelivered: messages.reduce((total, message) => total + message.unsubscribeDelivered, 0),
136
+ unsubscribed: messages.reduce((total, message) => total + message.unsubscribed, 0)
137
+ });
138
+ const createScope = () => ({
139
+ account: null,
140
+ handle: null,
141
+ type: "parent"
142
+ });
143
+ const createEmailApiHandler = ({ logRequests = true } = {}) => {
144
+ const accounts = /* @__PURE__ */ new Map();
145
+ const apiKeyScopes = /* @__PURE__ */ new Map();
146
+ const ensureParentScope = (apiKey) => {
147
+ if (apiKeyScopes.has(apiKey)) return apiKeyScopes.get(apiKey);
148
+ const account = createAccountState(apiKey);
149
+ accounts.set(account.customerHandle, account);
150
+ const scope = createScope();
151
+ scope.account = account;
152
+ apiKeyScopes.set(apiKey, scope);
153
+ return scope;
154
+ };
155
+ const resolveScope = (apiKey) => {
156
+ const scope = ensureParentScope(apiKey);
157
+ return {
158
+ account: scope.account,
159
+ scopeHandle: scope.handle
160
+ };
161
+ };
162
+ const notFound = (response) => sendJson(response, 404, { error: "Not Found" });
163
+ const handler = async (request, response) => {
164
+ try {
165
+ const method = request.method || "GET";
166
+ const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
167
+ if (logRequests) console.info("[Simulator]", `${method} ${url.pathname}`);
168
+ const publicPaths = ["/tx/v1/webhook/public-key"];
169
+ const apiKey = request.headers["x-api-key"];
170
+ if (!publicPaths.includes(url.pathname) && (typeof apiKey !== "string" || !apiKey)) {
171
+ sendJson(response, 401, { error: "Missing X-API-Key header." });
172
+ return;
173
+ }
174
+ const { account, scopeHandle } = resolveScope(apiKey);
175
+ const body = await readBody(request).catch(() => void 0);
176
+ if (body === void 0) {
177
+ sendJson(response, 400, { error: "Invalid JSON request body." });
178
+ return;
179
+ }
180
+ if (method === "POST" && url.pathname === "/tx/v1/send") {
181
+ const requestId = createId("request");
182
+ const personalizations = body?.personalizations || [];
183
+ const eventCount = personalizations.length;
184
+ if (url.searchParams.get("dry-run") === "true") {
185
+ sendJson(response, 200, {
186
+ data: personalizations.map((personalization) => buildRenderedMessage(body, personalization)),
187
+ request_id: requestId
188
+ });
189
+ return;
190
+ }
191
+ const results = personalizations.map((personalization, index) => {
192
+ const messageId = `<${randomUUID()}@simulator.mailchannels.local>`;
193
+ const recipientCount = [
194
+ ...personalization.to || [],
195
+ ...personalization.cc || [],
196
+ ...personalization.bcc || []
197
+ ].length;
198
+ account.messages.push({
199
+ bounced: 0,
200
+ campaignId: body?.campaign_id || "uncategorized",
201
+ click: body?.tracking_settings?.click_tracking?.enable ? 1 : 0,
202
+ clickTrackingDelivered: body?.tracking_settings?.click_tracking?.enable ? 1 : 0,
203
+ delivered: 1,
204
+ dropped: 0,
205
+ open: body?.tracking_settings?.open_tracking?.enable ? 1 : 0,
206
+ openTrackingDelivered: body?.tracking_settings?.open_tracking?.enable ? 1 : 0,
207
+ processed: 1,
208
+ recipientCount,
209
+ requestId,
210
+ scopeHandle,
211
+ timestamp: currentTimestamp(),
212
+ unsubscribeDelivered: body?.transactional === false ? 1 : 0,
213
+ unsubscribed: 0
214
+ });
215
+ if (scopeHandle) {
216
+ const subAccount = account.subAccounts.get(scopeHandle);
217
+ if (subAccount) subAccount.usage += 1;
218
+ }
219
+ return {
220
+ index,
221
+ message_id: messageId,
222
+ status: "sent"
223
+ };
224
+ });
225
+ for (const webhook of account.webhooks) recordWebhookBatch(account, webhook, eventCount);
226
+ sendJson(response, 200, {
227
+ request_id: requestId,
228
+ results
229
+ });
230
+ return;
231
+ }
232
+ if (method === "POST" && url.pathname === "/tx/v1/send-async") {
233
+ const eventCount = body?.personalizations?.length || 0;
234
+ for (const webhook of account.webhooks) recordWebhookBatch(account, webhook, eventCount);
235
+ sendJson(response, 202, {
236
+ queued_at: currentTimestamp(),
237
+ request_id: createId("request")
238
+ });
239
+ return;
240
+ }
241
+ if (method === "POST" && url.pathname === "/tx/v1/check-domain") {
242
+ const domain = body?.domain || "example.com";
243
+ sendJson(response, 200, { check_results: {
244
+ dkim: (body?.dkim_settings?.length ? body.dkim_settings : listDkimKeys(account, domain).map((key) => ({
245
+ dkim_domain: key.domain,
246
+ dkim_selector: key.selector
247
+ }))).map((setting) => ({
248
+ dkim_domain: setting.dkim_domain || domain,
249
+ dkim_key_status: setting.dkim_private_key ? "provided" : listDkimKeys(account, setting.dkim_domain || domain).find((key) => key.selector === setting.dkim_selector)?.status || "active",
250
+ dkim_selector: setting.dkim_selector || "default",
251
+ verdict: "passed"
252
+ })),
253
+ domain_lockdown: { verdict: "passed" },
254
+ sender_domain: {
255
+ a: { verdict: "passed" },
256
+ mx: { verdict: "passed" },
257
+ verdict: "passed"
258
+ },
259
+ spf: {
260
+ verdict: "passed",
261
+ spfRecord: "v=spf1 a mx include:relay.mailchannels.local ~all"
262
+ }
263
+ } });
264
+ return;
265
+ }
266
+ const dkimCollectionMatch = url.pathname.match(/^\/tx\/v1\/domains\/([^/]+)\/dkim-keys$/);
267
+ if (dkimCollectionMatch) {
268
+ const [, domain] = dkimCollectionMatch;
269
+ if (method === "POST") {
270
+ const key = createDkimKey(domain, body?.selector || createId("selector"), {
271
+ algorithm: body?.algorithm || "rsa",
272
+ key_length: body?.key_length || 2048
273
+ });
274
+ account.dkimKeysByDomain.set(domain, [...listDkimKeys(account, domain), key]);
275
+ sendJson(response, 201, key);
276
+ return;
277
+ }
278
+ if (method === "GET") {
279
+ const selector = url.searchParams.get("selector");
280
+ const status = url.searchParams.get("status");
281
+ const offset = Number(url.searchParams.get("offset") || "0");
282
+ const limit = Number(url.searchParams.get("limit") || "10");
283
+ let keys = listDkimKeys(account, domain);
284
+ if (selector) keys = keys.filter((key) => key.selector === selector);
285
+ if (status) keys = keys.filter((key) => key.status === status);
286
+ sendJson(response, 200, { keys: keys.slice(offset, offset + limit) });
287
+ return;
288
+ }
289
+ }
290
+ const rotateDkimMatch = url.pathname.match(/^\/tx\/v1\/domains\/([^/]+)\/dkim-keys\/([^/]+)\/rotate$/);
291
+ if (rotateDkimMatch && method === "POST") {
292
+ const [, domain, selector] = rotateDkimMatch;
293
+ const keys = listDkimKeys(account, domain);
294
+ const targetKey = keys.find((key) => key.selector === selector);
295
+ if (!targetKey) {
296
+ sendJson(response, 404, { error: "Specified key pair not found." });
297
+ return;
298
+ }
299
+ targetKey.status = "rotated";
300
+ targetKey.status_modified_at = currentTimestamp();
301
+ targetKey.gracePeriodExpiresAt = new Date(Date.now() + 10080 * 60 * 1e3).toISOString();
302
+ targetKey.retiresAt = new Date(Date.now() + 720 * 60 * 60 * 1e3).toISOString();
303
+ const newKey = createDkimKey(domain, body?.new_key?.selector || createId("selector"));
304
+ account.dkimKeysByDomain.set(domain, [...keys, newKey]);
305
+ sendJson(response, 201, {
306
+ new_key: newKey,
307
+ rotated_key: targetKey
308
+ });
309
+ return;
310
+ }
311
+ const dkimItemMatch = url.pathname.match(/^\/tx\/v1\/domains\/([^/]+)\/dkim-keys\/([^/]+)$/);
312
+ if (dkimItemMatch && method === "PATCH") {
313
+ const [, domain, selector] = dkimItemMatch;
314
+ const targetKey = listDkimKeys(account, domain).find((key) => key.selector === selector);
315
+ if (!targetKey) {
316
+ sendJson(response, 404, { error: "Specified key pair not found." });
317
+ return;
318
+ }
319
+ targetKey.status = body?.status || targetKey.status;
320
+ targetKey.status_modified_at = currentTimestamp();
321
+ sendNoContent(response);
322
+ return;
323
+ }
324
+ if (url.pathname === "/tx/v1/webhook" && method === "POST") {
325
+ const endpoint = url.searchParams.get("endpoint");
326
+ if (!endpoint) {
327
+ sendJson(response, 400, { error: "Missing endpoint query parameter." });
328
+ return;
329
+ }
330
+ if (account.webhooks.has(endpoint)) {
331
+ sendJson(response, 409, { error: "Webhook already enrolled." });
332
+ return;
333
+ }
334
+ account.webhooks.add(endpoint);
335
+ response.writeHead(201);
336
+ response.end();
337
+ return;
338
+ }
339
+ if (url.pathname === "/tx/v1/webhook" && method === "GET") {
340
+ sendJson(response, 200, Array.from(account.webhooks).map((webhook) => ({ webhook })));
341
+ return;
342
+ }
343
+ if (url.pathname === "/tx/v1/webhook" && method === "DELETE") {
344
+ account.webhooks.clear();
345
+ sendNoContent(response);
346
+ return;
347
+ }
348
+ if (url.pathname === "/tx/v1/webhook/public-key" && method === "GET") {
349
+ const id = url.searchParams.get("id") || SIMULATOR_SIGNING_KEY_ID;
350
+ const key = account.webhookSigningKeys.get(id) || "SIMULATOR_PUBLIC_SIGNING_KEY";
351
+ account.webhookSigningKeys.set(id, key);
352
+ sendJson(response, 200, {
353
+ id,
354
+ key
355
+ });
356
+ return;
357
+ }
358
+ if (url.pathname === "/tx/v1/webhook/validate" && method === "POST") {
359
+ if (!account.webhooks.size) {
360
+ sendJson(response, 404, { error: "No webhooks found for the account." });
361
+ return;
362
+ }
363
+ sendJson(response, 200, {
364
+ all_passed: true,
365
+ results: Array.from(account.webhooks).map((webhook) => {
366
+ recordWebhookBatch(account, webhook, 1);
367
+ return {
368
+ result: "passed",
369
+ webhook,
370
+ response: {
371
+ body: "simulated validation ok",
372
+ status: 200
373
+ }
374
+ };
375
+ })
376
+ });
377
+ return;
378
+ }
379
+ if (url.pathname === "/tx/v1/webhook-batch" && method === "GET") {
380
+ const createdAfter = url.searchParams.get("created_after");
381
+ const createdBefore = url.searchParams.get("created_before");
382
+ const statuses = getArrayQuery(url, "statuses");
383
+ const webhook = url.searchParams.get("webhook");
384
+ const limit = Number(url.searchParams.get("limit") || "500");
385
+ const offset = Number(url.searchParams.get("offset") || "0");
386
+ sendJson(response, 200, { webhook_batches: account.webhookBatches.filter((batch) => {
387
+ if (createdAfter && batch.created_at < createdAfter) return false;
388
+ if (createdBefore && batch.created_at >= createdBefore) return false;
389
+ if (webhook && batch.webhook !== webhook) return false;
390
+ if (statuses.length) {
391
+ if (!statuses.map((status) => `${status}_response`.replace("no_response_response", "no_response")).includes(batch.status)) return false;
392
+ }
393
+ return true;
394
+ }).slice(offset, offset + limit) });
395
+ return;
396
+ }
397
+ const webhookBatchResendMatch = url.pathname.match(/^\/tx\/v1\/webhook-batch\/(\d+)\/resend$/);
398
+ if (webhookBatchResendMatch && method === "POST") {
399
+ const batchId = Number(webhookBatchResendMatch[1]);
400
+ const batch = account.webhookBatches.find((batch) => batch.batch_id === batchId);
401
+ if (!batch) {
402
+ sendJson(response, 404, { error: "webhook not found." });
403
+ return;
404
+ }
405
+ recordWebhookBatch(account, batch.webhook, batch.event_count);
406
+ sendJson(response, 200, {
407
+ batch_id: batch.batch_id,
408
+ customer_handle: account.customerHandle,
409
+ webhook: batch.webhook,
410
+ created_at: currentTimestamp(),
411
+ status_code: 200,
412
+ duration_in_ms: 25,
413
+ event_count: batch.event_count
414
+ });
415
+ return;
416
+ }
417
+ if (url.pathname === "/tx/v1/sub-account" && method === "POST") {
418
+ const subAccount = createSubAccount(account, body?.company_name || "Simulator Company", body?.handle);
419
+ account.subAccounts.set(subAccount.handle, subAccount);
420
+ sendJson(response, 201, clone(subAccount));
421
+ return;
422
+ }
423
+ if (url.pathname === "/tx/v1/sub-account" && method === "GET") {
424
+ sendJson(response, 200, Array.from(account.subAccounts.values()).map((subAccount) => ({
425
+ company_name: subAccount.company_name,
426
+ enabled: subAccount.enabled,
427
+ handle: subAccount.handle
428
+ })));
429
+ return;
430
+ }
431
+ const subAccountMatch = url.pathname.match(/^\/tx\/v1\/sub-account\/([^/]+)(?:\/(.+))?$/);
432
+ if (subAccountMatch) {
433
+ const [, handle, suffix = ""] = subAccountMatch;
434
+ const subAccount = account.subAccounts.get(handle);
435
+ if (!subAccount) {
436
+ sendJson(response, 404, { error: `Sub-account '${handle}' not found.` });
437
+ return;
438
+ }
439
+ if (!suffix && method === "DELETE") {
440
+ account.subAccounts.delete(handle);
441
+ sendNoContent(response);
442
+ return;
443
+ }
444
+ if (suffix === "suspend" && method === "POST") {
445
+ subAccount.enabled = false;
446
+ sendNoContent(response);
447
+ return;
448
+ }
449
+ if (suffix === "activate" && method === "POST") {
450
+ subAccount.enabled = true;
451
+ sendNoContent(response);
452
+ return;
453
+ }
454
+ if (suffix === "api-key" && method === "POST") {
455
+ const apiKeyId = subAccount.apiKeys.length ? Math.max(...subAccount.apiKeys.map((key) => key.id)) + 1 : account.nextIds.apiKey++;
456
+ const keyValue = createId(`subkey_${handle}`);
457
+ subAccount.apiKeys.push({
458
+ id: apiKeyId,
459
+ key: keyValue
460
+ });
461
+ const scope = {
462
+ account,
463
+ handle,
464
+ type: "sub-account"
465
+ };
466
+ apiKeyScopes.set(keyValue, scope);
467
+ sendJson(response, 201, {
468
+ id: apiKeyId,
469
+ key: keyValue
470
+ });
471
+ return;
472
+ }
473
+ if (suffix === "api-key" && method === "GET") {
474
+ sendJson(response, 200, clone(subAccount.apiKeys));
475
+ return;
476
+ }
477
+ const apiKeyItemMatch = suffix.match(/^api-key\/(\d+)$/);
478
+ if (apiKeyItemMatch && method === "DELETE") {
479
+ const apiKeyId = Number(apiKeyItemMatch[1]);
480
+ const deletedKey = subAccount.apiKeys.find((key) => key.id === apiKeyId);
481
+ subAccount.apiKeys = subAccount.apiKeys.filter((key) => key.id !== apiKeyId);
482
+ if (deletedKey) apiKeyScopes.delete(deletedKey.key);
483
+ sendNoContent(response);
484
+ return;
485
+ }
486
+ if (suffix === "smtp-password" && method === "POST") {
487
+ const smtpPassword = {
488
+ enabled: true,
489
+ id: account.nextIds.smtpPassword++,
490
+ smtp_password: createId(`smtp_${handle}`)
491
+ };
492
+ subAccount.smtpPasswords.push(smtpPassword);
493
+ sendJson(response, 201, clone(smtpPassword));
494
+ return;
495
+ }
496
+ if (suffix === "smtp-password" && method === "GET") {
497
+ sendJson(response, 200, clone(subAccount.smtpPasswords));
498
+ return;
499
+ }
500
+ const smtpPasswordItemMatch = suffix.match(/^smtp-password\/(\d+)$/);
501
+ if (smtpPasswordItemMatch && method === "DELETE") {
502
+ const smtpPasswordId = Number(smtpPasswordItemMatch[1]);
503
+ subAccount.smtpPasswords = subAccount.smtpPasswords.filter((password) => password.id !== smtpPasswordId);
504
+ sendNoContent(response);
505
+ return;
506
+ }
507
+ if (suffix === "limit" && method === "GET") {
508
+ sendJson(response, 200, subAccount.limit || { sends: -1 });
509
+ return;
510
+ }
511
+ if (suffix === "limit" && method === "PUT") {
512
+ subAccount.limit = body || { sends: -1 };
513
+ sendNoContent(response);
514
+ return;
515
+ }
516
+ if (suffix === "limit" && method === "DELETE") {
517
+ subAccount.limit = null;
518
+ sendNoContent(response);
519
+ return;
520
+ }
521
+ if (suffix === "usage" && method === "GET") {
522
+ sendJson(response, 200, {
523
+ period_end_date: currentTimestamp(),
524
+ period_start_date: new Date((/* @__PURE__ */ new Date()).setDate(1)).toISOString(),
525
+ total_usage: subAccount.usage
526
+ });
527
+ return;
528
+ }
529
+ }
530
+ if (url.pathname === "/tx/v1/metrics/engagement" && method === "GET") {
531
+ const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
532
+ sendJson(response, 200, {
533
+ buckets: {
534
+ click: createMetricsBuckets(summary.click),
535
+ click_tracking_delivered: createMetricsBuckets(summary.clickTrackingDelivered),
536
+ open: createMetricsBuckets(summary.open),
537
+ open_tracking_delivered: createMetricsBuckets(summary.openTrackingDelivered)
538
+ },
539
+ click: summary.click,
540
+ click_tracking_delivered: summary.clickTrackingDelivered,
541
+ end_time: url.searchParams.get("end_time") || currentTimestamp(),
542
+ open: summary.open,
543
+ open_tracking_delivered: summary.openTrackingDelivered,
544
+ start_time: url.searchParams.get("start_time") || (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString()
545
+ });
546
+ return;
547
+ }
548
+ if (url.pathname === "/tx/v1/metrics/performance" && method === "GET") {
549
+ const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
550
+ sendJson(response, 200, {
551
+ bounced: summary.bounced,
552
+ buckets: {
553
+ bounced: createMetricsBuckets(summary.bounced),
554
+ delivered: createMetricsBuckets(summary.delivered),
555
+ processed: createMetricsBuckets(summary.processed)
556
+ },
557
+ delivered: summary.delivered,
558
+ end_time: url.searchParams.get("end_time") || currentTimestamp(),
559
+ processed: summary.processed,
560
+ start_time: url.searchParams.get("start_time") || (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString()
561
+ });
562
+ return;
563
+ }
564
+ if (url.pathname === "/tx/v1/metrics/recipient-behaviour" && method === "GET") {
565
+ const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
566
+ sendJson(response, 200, {
567
+ buckets: {
568
+ unsubscribe_delivered: createMetricsBuckets(summary.unsubscribeDelivered),
569
+ unsubscribed: createMetricsBuckets(summary.unsubscribed)
570
+ },
571
+ end_time: url.searchParams.get("end_time") || currentTimestamp(),
572
+ start_time: url.searchParams.get("start_time") || (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString(),
573
+ unsubscribe_delivered: summary.unsubscribeDelivered,
574
+ unsubscribed: summary.unsubscribed
575
+ });
576
+ return;
577
+ }
578
+ if (url.pathname === "/tx/v1/metrics/volume" && method === "GET") {
579
+ const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
580
+ sendJson(response, 200, {
581
+ buckets: {
582
+ delivered: createMetricsBuckets(summary.delivered),
583
+ dropped: createMetricsBuckets(summary.dropped),
584
+ processed: createMetricsBuckets(summary.processed)
585
+ },
586
+ delivered: summary.delivered,
587
+ dropped: summary.dropped,
588
+ end_time: url.searchParams.get("end_time") || currentTimestamp(),
589
+ processed: summary.processed,
590
+ start_time: url.searchParams.get("start_time") || (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString()
591
+ });
592
+ return;
593
+ }
594
+ const senderMetricsMatch = url.pathname.match(/^\/tx\/v1\/metrics\/senders\/([^/]+)$/);
595
+ if (senderMetricsMatch && method === "GET") {
596
+ const [, senderType] = senderMetricsMatch;
597
+ const limit = Number(url.searchParams.get("limit") || "10");
598
+ const offset = Number(url.searchParams.get("offset") || "0");
599
+ const sortOrder = url.searchParams.get("sort_order") || "desc";
600
+ const senders = senderType === "campaigns" ? Array.from(collectMessages(account).reduce((map, message) => {
601
+ const name = message.campaignId || "uncategorized";
602
+ const current = map.get(name) || {
603
+ bounced: 0,
604
+ delivered: 0,
605
+ dropped: 0,
606
+ name,
607
+ processed: 0
608
+ };
609
+ current.bounced += message.bounced;
610
+ current.delivered += message.delivered;
611
+ current.dropped += message.dropped;
612
+ current.processed += message.processed;
613
+ map.set(name, current);
614
+ return map;
615
+ }, /* @__PURE__ */ new Map()).values()) : Array.from(account.subAccounts.values()).map((subAccount) => ({
616
+ bounced: 0,
617
+ delivered: subAccount.usage,
618
+ dropped: 0,
619
+ name: subAccount.handle,
620
+ processed: subAccount.usage
621
+ }));
622
+ senders.sort((left, right) => {
623
+ return (sortOrder === "asc" ? 1 : -1) * (left.processed - right.processed);
624
+ });
625
+ sendJson(response, 200, {
626
+ end_time: currentTimestamp(),
627
+ limit,
628
+ offset,
629
+ senders: senders.slice(offset, offset + limit),
630
+ start_time: (/* @__PURE__ */ new Date(Date.now() - 720 * 60 * 60 * 1e3)).toISOString(),
631
+ total: senders.length
632
+ });
633
+ return;
634
+ }
635
+ if (url.pathname === "/tx/v1/usage" && method === "GET") {
636
+ sendJson(response, 200, {
637
+ period_end_date: currentTimestamp(),
638
+ period_start_date: new Date((/* @__PURE__ */ new Date()).setDate(1)).toISOString(),
639
+ total_usage: account.messages.length
640
+ });
641
+ return;
642
+ }
643
+ if (url.pathname === "/tx/v1/suppression-list" && method === "POST") {
644
+ const entries = body?.suppression_entries || [];
645
+ for (const entry of entries) account.suppressionEntries.push({
646
+ created_at: currentTimestamp(),
647
+ notes: entry.notes,
648
+ recipient: entry.recipient,
649
+ sender: void 0,
650
+ source: "api",
651
+ suppression_types: entry.suppression_types || ["non-transactional"]
652
+ });
653
+ sendNoContent(response);
654
+ return;
655
+ }
656
+ if (url.pathname === "/tx/v1/suppression-list" && method === "GET") {
657
+ const recipient = url.searchParams.get("recipient");
658
+ const source = url.searchParams.get("source");
659
+ const limit = Number(url.searchParams.get("limit") || "1000");
660
+ const offset = Number(url.searchParams.get("offset") || "0");
661
+ let suppressionList = account.suppressionEntries;
662
+ if (recipient) suppressionList = suppressionList.filter((entry) => entry.recipient === recipient);
663
+ if (source) suppressionList = suppressionList.filter((entry) => entry.source === source);
664
+ sendJson(response, 200, { suppression_list: suppressionList.slice(offset, offset + limit) });
665
+ return;
666
+ }
667
+ const suppressionDeleteMatch = url.pathname.match(/^\/tx\/v1\/suppression-list\/recipients\/([^/]+)$/);
668
+ if (suppressionDeleteMatch && method === "DELETE") {
669
+ const [, recipient] = suppressionDeleteMatch;
670
+ const source = url.searchParams.get("source");
671
+ account.suppressionEntries = account.suppressionEntries.filter((entry) => {
672
+ if (entry.recipient !== decodeURIComponent(recipient)) return true;
673
+ if (!source || source === "all") return false;
674
+ return entry.source !== source;
675
+ });
676
+ sendNoContent(response);
677
+ return;
678
+ }
679
+ notFound(response);
680
+ } catch (error) {
681
+ sendText(response, 500, error instanceof Error ? error.message : "Simulator error");
682
+ }
683
+ };
684
+ return {
685
+ handler,
686
+ state: {
687
+ accounts,
688
+ apiKeyScopes
689
+ }
690
+ };
691
+ };
692
+ const DEFAULT_HOST = "127.0.0.1";
693
+ const DEFAULT_PORT = 8787;
694
+ const createSimulator = (options = {}) => {
695
+ const { host = DEFAULT_HOST, port = DEFAULT_PORT } = options;
696
+ const logRequests = !options.silent;
697
+ const emailApi = createEmailApiHandler({ logRequests });
698
+ const server = createServer(async (request, response) => {
699
+ if (new URL(request.url || "/", `http://${request.headers.host || "localhost"}`).pathname.startsWith("/tx/")) return emailApi.handler(request, response);
700
+ response.writeHead(404, { "content-type": "application/json" });
701
+ response.end(JSON.stringify({ error: "Not Found" }));
702
+ });
703
+ const sockets = /* @__PURE__ */ new Set();
704
+ server.on("connection", (socket) => {
705
+ sockets.add(socket);
706
+ socket.on("close", () => {
707
+ sockets.delete(socket);
708
+ });
709
+ });
710
+ let serverUrl = null;
711
+ return {
712
+ server,
713
+ state: { emailApi: emailApi.state },
714
+ get url() {
715
+ return serverUrl;
716
+ },
717
+ async close() {
718
+ await new Promise((resolve, reject) => {
719
+ for (const socket of sockets) socket.destroy();
720
+ server.close((error) => error ? reject(error) : resolve());
721
+ });
722
+ },
723
+ async listen(listenOptions = {}) {
724
+ const nextHost = listenOptions.host || host;
725
+ const nextPort = listenOptions.port ?? port;
726
+ await new Promise((resolve, reject) => {
727
+ const onError = (error) => {
728
+ reject(error);
729
+ };
730
+ server.once("error", onError);
731
+ server.listen(nextPort, nextHost, () => {
732
+ server.off("error", onError);
733
+ resolve();
734
+ });
735
+ });
736
+ const address = server.address();
737
+ serverUrl = `http://${nextHost}:${typeof address === "object" && address ? address.port : nextPort}`;
738
+ if (logRequests) console.info("[Simulator]", `listening on ${serverUrl}`);
739
+ return serverUrl;
740
+ }
741
+ };
742
+ };
743
+ export { createSimulator };
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export { };
package/dist/cli.mjs ADDED
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ const LOGGER_NAME = "[MailChannels-CLI]";
4
+ console.info = console.info.bind(console.info, LOGGER_NAME);
5
+ console.error = console.error.bind(console.error, LOGGER_NAME);
6
+ const [command, ...args] = process.argv.slice(2);
7
+ switch (command) {
8
+ case "simulate":
9
+ const { createSimulator } = await import("./_chunks/simulator.mjs");
10
+ const { MAILCHANNELS_SIMULATOR_PORT, MAILCHANNELS_SIMULATOR_HOST } = process.env;
11
+ const { values } = parseArgs({
12
+ args,
13
+ options: {
14
+ port: {
15
+ type: "string",
16
+ short: "p",
17
+ default: MAILCHANNELS_SIMULATOR_PORT
18
+ },
19
+ host: {
20
+ type: "string",
21
+ short: "h",
22
+ default: MAILCHANNELS_SIMULATOR_HOST
23
+ },
24
+ silent: {
25
+ type: "boolean",
26
+ short: "s",
27
+ default: false
28
+ }
29
+ }
30
+ });
31
+ const port = values.port !== void 0 ? Number.parseInt(values.port, 10) : void 0;
32
+ if (port !== void 0 && (isNaN(port) || port < 0 || port > 65535)) {
33
+ console.error("[Simulator]", `Invalid port "${values.port}": must be an integer between 0 and 65535.`);
34
+ process.exit(1);
35
+ }
36
+ const simulator = createSimulator({
37
+ host: values.host,
38
+ port,
39
+ silent: values.silent
40
+ });
41
+ await simulator.listen();
42
+ const shutdown = async () => {
43
+ await simulator.close();
44
+ process.exit(0);
45
+ };
46
+ process.on("SIGINT", shutdown);
47
+ process.on("SIGTERM", shutdown);
48
+ break;
49
+ }
50
+ export {};
@@ -1,7 +1,7 @@
1
1
  import { $fetch } from "ofetch";
2
2
  import { subtle } from "node:crypto";
3
3
  import { Buffer } from "node:buffer";
4
- var version = "0.7.10";
4
+ var version = "0.7.11";
5
5
  var MailChannelsClient = class MailChannelsClient {
6
6
  static DEFAULT_BASE_URL = "https://api.mailchannels.net";
7
7
  #baseUrl;
@@ -291,6 +291,7 @@ const buildSendPayload = (options) => {
291
291
  };
292
292
  };
293
293
  var Emails = class {
294
+ mailchannels;
294
295
  constructor(mailchannels) {
295
296
  this.mailchannels = mailchannels;
296
297
  }
@@ -635,6 +636,7 @@ async function isValidWebhook(options) {
635
636
  return subtle.verify(ED25519.name, key, webhookSignatureBuffer, encoder.encode(signingString));
636
637
  }
637
638
  var Webhooks = class Webhooks {
639
+ mailchannels;
638
640
  constructor(mailchannels) {
639
641
  this.mailchannels = mailchannels;
640
642
  }
@@ -864,6 +866,7 @@ var Webhooks = class Webhooks {
864
866
  }
865
867
  };
866
868
  var SubAccounts = class SubAccounts {
869
+ mailchannels;
867
870
  static COMPANY_PATTERN = /^.{3,128}$/;
868
871
  static HANDLE_PATTERN = /^[a-z0-9]{3,128}$/;
869
872
  constructor(mailchannels) {
@@ -1269,6 +1272,7 @@ var SubAccounts = class SubAccounts {
1269
1272
  }
1270
1273
  };
1271
1274
  var Metrics = class {
1275
+ mailchannels;
1272
1276
  constructor(mailchannels) {
1273
1277
  this.mailchannels = mailchannels;
1274
1278
  }
@@ -1480,6 +1484,7 @@ var Metrics = class {
1480
1484
  }
1481
1485
  };
1482
1486
  var Suppressions = class {
1487
+ mailchannels;
1483
1488
  constructor(mailchannels) {
1484
1489
  this.mailchannels = mailchannels;
1485
1490
  }
@@ -1571,6 +1576,7 @@ var Suppressions = class {
1571
1576
  }
1572
1577
  };
1573
1578
  var Domains = class {
1579
+ mailchannels;
1574
1580
  constructor(mailchannels) {
1575
1581
  this.mailchannels = mailchannels;
1576
1582
  }
@@ -1982,6 +1988,7 @@ var Domains = class {
1982
1988
  }
1983
1989
  };
1984
1990
  var Lists = class {
1991
+ mailchannels;
1985
1992
  constructor(mailchannels) {
1986
1993
  this.mailchannels = mailchannels;
1987
1994
  }
@@ -2070,6 +2077,7 @@ var Lists = class {
2070
2077
  }
2071
2078
  };
2072
2079
  var Users = class {
2080
+ mailchannels;
2073
2081
  constructor(mailchannels) {
2074
2082
  this.mailchannels = mailchannels;
2075
2083
  }
@@ -2230,6 +2238,7 @@ var Users = class {
2230
2238
  }
2231
2239
  };
2232
2240
  var Service = class {
2241
+ mailchannels;
2233
2242
  constructor(mailchannels) {
2234
2243
  this.mailchannels = mailchannels;
2235
2244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailchannels-sdk",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "Node.js SDK to integrate MailChannels API into your JavaScript or TypeScript server-side applications.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,10 @@
30
30
  }
31
31
  },
32
32
  "types": "./dist/mailchannels.d.mts",
33
+ "bin": {
34
+ "mailchannels-sdk": "./dist/cli.mjs",
35
+ "mailchannels": "./dist/cli.mjs"
36
+ },
33
37
  "files": [
34
38
  "dist"
35
39
  ],
@@ -39,24 +43,23 @@
39
43
  "devDependencies": {
40
44
  "@stylistic/eslint-plugin": "^5.10.0",
41
45
  "@types/markdown-it": "^14.1.2",
42
- "@types/node": "^25.6.0",
46
+ "@types/node": "^25.6.2",
43
47
  "@vitest/coverage-v8": "^4.1.5",
44
48
  "changelogen": "^0.6.2",
45
- "jiti": "^2.6.1",
46
- "obuild": "^0.4.33",
47
- "oxlint": "^1.62.0",
49
+ "obuild": "^0.4.35",
50
+ "oxlint": "^1.63.0",
48
51
  "scule": "^1.3.0",
49
52
  "typescript": "^6.0.3",
50
53
  "vitepress": "^2.0.0-alpha.17",
51
54
  "vitepress-plugin-group-icons": "^1.7.5",
52
- "vitepress-plugin-llms": "^1.12.1",
55
+ "vitepress-plugin-llms": "^1.12.2",
53
56
  "vitest": "^4.1.5"
54
57
  },
55
58
  "scripts": {
56
59
  "build": "obuild",
57
60
  "parity:fixtures": "node scripts/generate-parity-fixtures.mjs",
58
61
  "release": "pnpm lint && pnpm test && pnpm build && changelogen --release && git push --follow-tags",
59
- "simulate:email-api": "node scripts/email-api-simulator.mjs",
62
+ "simulate": "node src/cli.ts simulate",
60
63
  "lint": "oxlint",
61
64
  "lint:fix": "oxlint --fix",
62
65
  "test": "vitest run --reporter=verbose --coverage",
@@ -65,6 +68,6 @@
65
68
  "docs:dev": "vitepress dev docs",
66
69
  "docs:build": "(git fetch --unshallow -q || git fetch --all -q) && vitepress build docs",
67
70
  "docs:preview": "vitepress preview docs",
68
- "docs:snippets": "jiti docs/scripts/snippets"
71
+ "docs:snippets": "node docs/scripts/snippets.ts"
69
72
  }
70
73
  }