mailchannels-sdk 1.1.0 → 1.2.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.
@@ -0,0 +1,83 @@
1
+ # Custom Tracking Domains
2
+
3
+ Register your own hostname for click tracking, open tracking, or unsubscribe
4
+ links instead of MailChannels' shared domains. Once a domain is `active`,
5
+ select it by `name` via `customDomainName` on the send payload — see
6
+ [sending.md](sending.md#custom-tracking-domains).
7
+
8
+ ## Register A Domain
9
+
10
+ ```ts
11
+ import { MailChannels } from 'mailchannels-sdk'
12
+
13
+ const mc = new MailChannels('YOUR-API-KEY')
14
+
15
+ const { data, error } = await mc.domains.customTracking.create(
16
+ 'newsletter-clicks', // label used at send time, ^[a-z0-9-]+$, max 64
17
+ 'click.example.com',
18
+ 'click' // click | open | unsubscribe
19
+ )
20
+ ```
21
+
22
+ Before this can complete, `hostname` needs a CNAME record pointing to
23
+ `links.mailchannels.net`.
24
+
25
+ ## DNS Verification Is A Two-Step, Non-Error Flow
26
+
27
+ `create()` and `update()` (when re-activating a disabled domain) return one
28
+ of two shapes in `data` — both are 2xx responses, not errors:
29
+
30
+ - **Registered**: `dnsSetupRequired: false` with `name`, `hostname`, `scope`,
31
+ `status`, `createdAt` are all present..
32
+ - **Pending**: `dnsSetupRequired: true` with `token`, `txtRecordName`,
33
+ `txtRecordValue`, `instructions` is present (a CNAME-only failure can omit the TXT
34
+ fields; a fresh registration includes all of them).
35
+
36
+ Add the TXT record alongside the CNAME, wait for propagation, then call
37
+ `create()` again with the same `name`/`hostname`/`scope`.
38
+
39
+ If DNS still isn't in place on retry, MailChannels responds `422` instead of
40
+ `202` — that's a real error status, so the SDK returns an `error` object.
41
+ The same pending-verification body is on `error.response`:
42
+
43
+ ```ts
44
+ const { data, error } = await mc.domains.customTracking.create(
45
+ 'newsletter-clicks',
46
+ 'click.example.com',
47
+ 'click'
48
+ )
49
+
50
+ if (error) {
51
+ if (error.statusCode === 422 && error.response) {
52
+ console.log(error.response.instructions)
53
+ }
54
+ return;
55
+ }
56
+ ```
57
+
58
+ ## List, Update, Delete
59
+
60
+ ```ts
61
+ // List
62
+ const { data: listData, error: listError } = await mc.domains.customTracking.list({
63
+ scope: 'click', // optional filter
64
+ status: 'active', // optional filter: active | disabled
65
+ limit: 100,
66
+ offset: 0
67
+ })
68
+
69
+ // Update
70
+ const { data: updateData, error: updateError } = await mc.domains.customTracking.update(
71
+ 'click.example.com',
72
+ 'click',
73
+ { status: 'disabled' } // or { name: 'new-label' } to rename
74
+ )
75
+
76
+ // Delete
77
+ const { success, error: deleteError } = await mc.domains.customTracking.delete('click.example.com', 'click')
78
+ ```
79
+
80
+ Deleting a domain takes effect immediately — tracking links and unsubscribe
81
+ URLs already sent using that domain stop working right away. Re-activating a
82
+ domain whose DNS verification has since lapsed goes through the same
83
+ pending-result flow as `create()`.
@@ -23,6 +23,7 @@ if (error) {
23
23
  // error.message — human-readable description
24
24
  // error.type — stable string identifier (see table below)
25
25
  // error.statusCode — HTTP status code, or null for non-HTTP errors
26
+ // error.response — Response body (Object) for 4xx/5xx errors
26
27
  console.error(error.message, error.type, error.statusCode)
27
28
  return
28
29
  }
@@ -57,11 +57,12 @@ for (const bucket of data?.buckets.processed ?? []) {
57
57
 
58
58
  #### Engagement Buckets
59
59
 
60
- `data.buckets` has `open`, `click`, `openTrackingDelivered`, `clickTrackingDelivered`.
60
+ `data.buckets` has `open`, `click`, `uniqueOpen`, `uniqueClick`, `openTrackingDelivered`,
61
+ `clickTrackingDelivered`, `uniqueOpenTrackingDelivered`, `uniqueClickTrackingDelivered`.
61
62
 
62
63
  #### Performance Buckets
63
64
 
64
- `data.buckets` has `processed`, `delivered`, `bounced`.
65
+ `data.buckets` has `processed`, `delivered`, `bounced`, `complained`.
65
66
 
66
67
  #### Recipient Behaviour Buckets
67
68
 
@@ -193,3 +193,29 @@ const { data, error } = await mc.emails.queue({
193
193
  ```
194
194
 
195
195
  Open and click tracking require a subscription that supports them.
196
+
197
+ ### Custom Tracking Domains
198
+
199
+ Pass `customDomainName` on the `tracking` field (or on `unsubscribe`) to route those
200
+ links through a domain you registered instead of the shared MailChannels domain.
201
+ The name must match an **active** custom tracking domain registered for that
202
+ scope — see [custom-tracking-domains](custom-tracking-domains.md) for
203
+ registration and DNS verification.
204
+
205
+ ```ts
206
+ const { data, error } = await mc.emails.queue({
207
+ from: 'sender@example.com',
208
+ to: 'recipient@example.net',
209
+ subject: 'Tracked with a custom domain',
210
+ html: "<p>Hello <a href='https://example.com'>click here</a></p>",
211
+ tracking: {
212
+ click: {
213
+ enable: true,
214
+ customDomainName: 'newsletter-clicks'
215
+ }
216
+ },
217
+ unsubscribe: {
218
+ customDomainName: 'newsletter-unsubscribe'
219
+ }
220
+ })
221
+ ```
@@ -67,7 +67,7 @@ if (createPwdErr) { /*...*/ }
67
67
  const { data: passwords, error: listPwdErr } = await mc.subAccounts.smtpPasswords.list('clienta')
68
68
  if (listPwdErr) { /*...*/ }
69
69
 
70
- const { error: deletePwdErr } = await mc.subAccounts.smtpPasswordss.delete('clienta', storedPasswordId)
70
+ const { error: deletePwdErr } = await mc.subAccounts.smtpPasswords.delete('clienta', storedPasswordId)
71
71
  if (deletePwdErr) { /*...*/ }
72
72
  ```
73
73
 
package/README.md CHANGED
@@ -9,12 +9,12 @@
9
9
  [![TypeScript][typescript-src]][typescript-href]
10
10
  [![Node.js][node-src]][node-href]
11
11
 
12
- > Built and tested against Email API `1.0.0`
12
+ > Built and tested against Email API `1.4.0`
13
13
 
14
- Node.js SDK to integrate [MailChannels Email API](https://docs.mailchannels.net/email-api) into your JavaScript or TypeScript server-side applications.
14
+ Node.js SDK to integrate [MailChannels Email API](https://docs.mailchannels.com/email-api) into your JavaScript or TypeScript server-side applications.
15
15
 
16
16
  <!-- #region overview -->
17
- This library provides a simple way to interact with the [MailChannels Email API](https://docs.mailchannels.net/email-api). It is written in TypeScript and can be used in both JavaScript and TypeScript projects and in different runtimes.
17
+ This library provides a simple way to interact with the [MailChannels Email API](https://docs.mailchannels.com/email-api). It is written in TypeScript and can be used in both JavaScript and TypeScript projects and in different runtimes.
18
18
  <!-- #endregion overview -->
19
19
 
20
20
  - [✨ Release Notes](https://bitbucket.org/mailchannels/mailchannels-email-api-sdk-js/src/HEAD/CHANGELOG.md)
@@ -35,7 +35,7 @@ This library provides a simple way to interact with the [MailChannels Email API]
35
35
  ## <a name="features">🚀 Features</a>
36
36
 
37
37
  <!-- #region features -->
38
- This SDK fully supports all features and operations available in the [MailChannels Email API](https://docs.mailchannels.net/email-api). It is actively maintained to ensure compatibility and to quickly add support for new API features as they are released.
38
+ This SDK fully supports all features and operations available in the [MailChannels Email API](https://docs.mailchannels.com/email-api). It is actively maintained to ensure compatibility and to quickly add support for new API features as they are released.
39
39
 
40
40
  Some of the things you can do with the SDK:
41
41
 
@@ -169,6 +169,7 @@ const { data, error } = await mailchannels.emails.send({
169
169
  - Email sends and async sends
170
170
  - Domain checks
171
171
  - DKIM key create, list, rotate, and update
172
+ - Custom tracking domain create, list, update, and delete
172
173
  - Webhook enrollment, listing, validation, signing key lookup, and batch inspection
173
174
  - Sub-account lifecycle, API keys, SMTP passwords, limits, and usage
174
175
  - Engagement, performance, recipient behaviour, sender, volume, and usage metrics
@@ -179,6 +180,7 @@ const { data, error } = await mailchannels.emails.send({
179
180
  - State is in-memory only and is reset when the process stops
180
181
  - Any non-empty `X-API-Key` is accepted, with separate in-memory state per API key
181
182
  - Webhook responses are simulated locally, but the simulator does not yet emit real webhook callbacks to your application
183
+ - Custom tracking domain verification is simulated, but the simulator does not yet check DNS records
182
184
 
183
185
  The next planned expansion is outbound webhook delivery so client applications can test webhook ingestion flows against the simulator as well.
184
186
  <!-- #endregion simulator -->
@@ -55,6 +55,7 @@ const createMetricsBuckets = (count) => [{
55
55
  const createAccountState = (apiKey) => ({
56
56
  apiKey,
57
57
  customerHandle: createId("customer"),
58
+ customTrackingDomains: [],
58
59
  dkimKeysByDomain: /* @__PURE__ */ new Map(),
59
60
  messages: [],
60
61
  subAccounts: /* @__PURE__ */ new Map(),
@@ -101,6 +102,16 @@ const createDkimKey = (domain, selector, overrides = {}) => {
101
102
  };
102
103
  };
103
104
  const listDkimKeys = (account, domain) => account.dkimKeysByDomain.get(domain) || [];
105
+ const createCustomTrackingDomain = ({ hostname, name, scope, status = "active" }) => ({
106
+ created_at: currentTimestamp(),
107
+ hostname,
108
+ name,
109
+ scope,
110
+ status
111
+ });
112
+ const findCustomTrackingDomain = (account, hostname, scope) => {
113
+ return account.customTrackingDomains.find((domain) => domain.hostname === hostname && domain.scope === scope);
114
+ };
104
115
  const recordWebhookBatch = (account, webhook, eventCount, status = "2xx_response", statusCode = 200) => {
105
116
  account.webhookBatches.unshift({
106
117
  batch_id: account.nextIds.batch++,
@@ -125,6 +136,7 @@ const collectMessages = (account, filters = {}) => {
125
136
  };
126
137
  const summarizeMessages = (messages) => ({
127
138
  bounced: messages.reduce((total, message) => total + message.bounced, 0),
139
+ complained: messages.reduce((total, message) => total + message.complained, 0),
128
140
  click: messages.reduce((total, message) => total + message.click, 0),
129
141
  clickTrackingDelivered: messages.reduce((total, message) => total + message.clickTrackingDelivered, 0),
130
142
  delivered: messages.reduce((total, message) => total + message.delivered, 0),
@@ -198,6 +210,7 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
198
210
  account.messages.push({
199
211
  bounced: 0,
200
212
  campaignId: body?.campaign_id || "uncategorized",
213
+ complained: 0,
201
214
  click: body?.tracking_settings?.click_tracking?.enable ? 1 : 0,
202
215
  clickTrackingDelivered: body?.tracking_settings?.click_tracking?.enable ? 1 : 0,
203
216
  delivered: 1,
@@ -240,11 +253,12 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
240
253
  }
241
254
  if (method === "POST" && url.pathname === "/tx/v1/check-domain") {
242
255
  const domain = body?.domain || "example.com";
256
+ const dkimSettings = body?.dkim_settings?.length ? body.dkim_settings : listDkimKeys(account, domain).map((key) => ({
257
+ dkim_domain: key.domain,
258
+ dkim_selector: key.selector
259
+ }));
243
260
  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) => ({
261
+ dkim: dkimSettings.map((setting) => ({
248
262
  dkim_domain: setting.dkim_domain || domain,
249
263
  dkim_key_status: setting.dkim_private_key ? "provided" : listDkimKeys(account, setting.dkim_domain || domain).find((key) => key.selector === setting.dkim_selector)?.status || "active",
250
264
  dkim_selector: setting.dkim_selector || "default",
@@ -321,6 +335,71 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
321
335
  sendNoContent(response);
322
336
  return;
323
337
  }
338
+ if (url.pathname === "/tx/v1/custom-tracking-domains") {
339
+ if (method === "POST") {
340
+ const { hostname, name, scope } = body || {};
341
+ if (!hostname || !name || !scope) {
342
+ sendJson(response, 400, { error: "Invalid request body." });
343
+ return;
344
+ }
345
+ const hasDuplicateName = account.customTrackingDomains.some((domain) => domain.name === name);
346
+ const hasDuplicateHostnameAndScope = Boolean(findCustomTrackingDomain(account, hostname, scope));
347
+ if (hasDuplicateName || hasDuplicateHostnameAndScope) {
348
+ sendJson(response, 409, { error: "Custom tracking domain already exists." });
349
+ return;
350
+ }
351
+ const domain = createCustomTrackingDomain({
352
+ hostname,
353
+ name,
354
+ scope
355
+ });
356
+ account.customTrackingDomains.push(domain);
357
+ sendJson(response, 201, clone(domain));
358
+ return;
359
+ }
360
+ if (method === "GET") {
361
+ const name = url.searchParams.get("name");
362
+ const status = url.searchParams.get("status");
363
+ const scope = url.searchParams.get("scope");
364
+ const offset = Number(url.searchParams.get("offset") || "0");
365
+ const limit = Number(url.searchParams.get("limit") || "100");
366
+ let domains = account.customTrackingDomains;
367
+ if (name) domains = domains.filter((domain) => domain.name === name);
368
+ if (status) domains = domains.filter((domain) => domain.status === status);
369
+ if (scope) domains = domains.filter((domain) => domain.scope === scope);
370
+ sendJson(response, 200, {
371
+ custom_tracking_domains: clone(domains.slice(offset, offset + limit)),
372
+ total: domains.length
373
+ });
374
+ return;
375
+ }
376
+ }
377
+ const customTrackingDomainMatch = url.pathname.match(/^\/tx\/v1\/custom-tracking-domains\/([^/]+)\/([^/]+)$/);
378
+ if (customTrackingDomainMatch) {
379
+ const [, hostname, scope] = customTrackingDomainMatch;
380
+ const targetDomain = findCustomTrackingDomain(account, hostname, scope);
381
+ if (!targetDomain) {
382
+ sendJson(response, 404, { error: `Custom tracking domain for hostname '${hostname}' and scope '${scope}' not found.` });
383
+ return;
384
+ }
385
+ if (method === "PATCH") {
386
+ if (body?.name) {
387
+ if (account.customTrackingDomains.some((domain) => domain !== targetDomain && domain.name === body.name)) {
388
+ sendJson(response, 409, { error: "Name already used by another domain." });
389
+ return;
390
+ }
391
+ targetDomain.name = body.name;
392
+ }
393
+ if (body?.status) targetDomain.status = body.status;
394
+ sendJson(response, 200, clone(targetDomain));
395
+ return;
396
+ }
397
+ if (method === "DELETE") {
398
+ account.customTrackingDomains = account.customTrackingDomains.filter((domain) => domain !== targetDomain);
399
+ sendNoContent(response);
400
+ return;
401
+ }
402
+ }
324
403
  if (url.pathname === "/tx/v1/webhook" && method === "POST") {
325
404
  const endpoint = url.searchParams.get("endpoint");
326
405
  if (!endpoint) {
@@ -360,19 +439,20 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
360
439
  sendJson(response, 404, { error: "No webhooks found for the account." });
361
440
  return;
362
441
  }
442
+ const results = Array.from(account.webhooks).map((webhook) => {
443
+ recordWebhookBatch(account, webhook, 1);
444
+ return {
445
+ result: "passed",
446
+ webhook,
447
+ response: {
448
+ body: "simulated validation ok",
449
+ status: 200
450
+ }
451
+ };
452
+ });
363
453
  sendJson(response, 200, {
364
454
  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
- })
455
+ results
376
456
  });
377
457
  return;
378
458
  }
@@ -383,7 +463,7 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
383
463
  const webhook = url.searchParams.get("webhook");
384
464
  const limit = Number(url.searchParams.get("limit") || "500");
385
465
  const offset = Number(url.searchParams.get("offset") || "0");
386
- sendJson(response, 200, { webhook_batches: account.webhookBatches.filter((batch) => {
466
+ const batches = account.webhookBatches.filter((batch) => {
387
467
  if (createdAfter && batch.created_at < createdAfter) return false;
388
468
  if (createdBefore && batch.created_at >= createdBefore) return false;
389
469
  if (webhook && batch.webhook !== webhook) return false;
@@ -391,7 +471,8 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
391
471
  if (!statuses.map((status) => `${status}_response`.replace("no_response_response", "no_response")).includes(batch.status)) return false;
392
472
  }
393
473
  return true;
394
- }).slice(offset, offset + limit) });
474
+ });
475
+ sendJson(response, 200, { webhook_batches: batches.slice(offset, offset + limit) });
395
476
  return;
396
477
  }
397
478
  const webhookBatchResendMatch = url.pathname.match(/^\/tx\/v1\/webhook-batch\/(\d+)\/resend$/);
@@ -528,7 +609,8 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
528
609
  }
529
610
  }
530
611
  if (url.pathname === "/tx/v1/metrics/engagement" && method === "GET") {
531
- const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
612
+ const messages = collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 });
613
+ const summary = summarizeMessages(messages);
532
614
  sendJson(response, 200, {
533
615
  buckets: {
534
616
  click: createMetricsBuckets(summary.click),
@@ -546,11 +628,14 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
546
628
  return;
547
629
  }
548
630
  if (url.pathname === "/tx/v1/metrics/performance" && method === "GET") {
549
- const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
631
+ const messages = collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 });
632
+ const summary = summarizeMessages(messages);
550
633
  sendJson(response, 200, {
551
634
  bounced: summary.bounced,
635
+ complained: summary.complained,
552
636
  buckets: {
553
637
  bounced: createMetricsBuckets(summary.bounced),
638
+ complained: createMetricsBuckets(summary.complained),
554
639
  delivered: createMetricsBuckets(summary.delivered),
555
640
  processed: createMetricsBuckets(summary.processed)
556
641
  },
@@ -562,7 +647,8 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
562
647
  return;
563
648
  }
564
649
  if (url.pathname === "/tx/v1/metrics/recipient-behaviour" && method === "GET") {
565
- const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
650
+ const messages = collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 });
651
+ const summary = summarizeMessages(messages);
566
652
  sendJson(response, 200, {
567
653
  buckets: {
568
654
  unsubscribe_delivered: createMetricsBuckets(summary.unsubscribeDelivered),
@@ -576,7 +662,8 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
576
662
  return;
577
663
  }
578
664
  if (url.pathname === "/tx/v1/metrics/volume" && method === "GET") {
579
- const summary = summarizeMessages(collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 }));
665
+ const messages = collectMessages(account, { campaignId: url.searchParams.get("campaign_id") || void 0 });
666
+ const summary = summarizeMessages(messages);
580
667
  sendJson(response, 200, {
581
668
  buckets: {
582
669
  delivered: createMetricsBuckets(summary.delivered),
@@ -678,7 +765,8 @@ const createEmailApiHandler = ({ logRequests = true } = {}) => {
678
765
  }
679
766
  notFound(response);
680
767
  } catch (error) {
681
- sendText(response, 500, error instanceof Error ? error.message : "Simulator error");
768
+ const message = error instanceof Error ? error.message : "Simulator error";
769
+ sendText(response, 500, message);
682
770
  }
683
771
  };
684
772
  return {
@@ -62,6 +62,11 @@ interface ErrorResponse {
62
62
  * This field is intended for diagnostic use only and should not be relied upon.
63
63
  */
64
64
  type: ErrorType;
65
+ /**
66
+ * An object containing the response, if available.
67
+ * This field may be `null` if no response is available or if the error is not related to an HTTP request or if the response is not a JSON object.
68
+ */
69
+ response: Record<string, unknown> | null;
65
70
  }
66
71
  interface SuccessResponse {
67
72
  /**
@@ -129,6 +134,12 @@ interface EmailsSendTracking {
129
134
  * Track when a recipient clicks a link in your email.
130
135
  */
131
136
  click?: {
137
+ /**
138
+ * The name of a configured active click tracking domain.
139
+ * When specified, click tracking links will use this domain instead of the default MailChannels domain.
140
+ * The domain must be registered in your account and have an active status.
141
+ */
142
+ customDomainName?: string;
132
143
  /**
133
144
  * @default false
134
145
  */
@@ -138,6 +149,12 @@ interface EmailsSendTracking {
138
149
  * Track when a recipient opens your email. Please note that some email clients may not support open tracking.
139
150
  */
140
151
  open?: {
152
+ /**
153
+ * The name of a configured active open tracking domain.
154
+ * When specified, the open tracking pixel will use this domain instead of the default MailChannels domain.
155
+ * The domain must be registered in your account and have an active status.
156
+ */
157
+ customDomainName?: string;
141
158
  /**
142
159
  * @default false
143
160
  */
@@ -360,10 +377,21 @@ interface EmailsSendOptionsBase {
360
377
  /**
361
378
  * Mark these messages as transactional or non-transactional. In order for a message to be marked as non-transactional, it must have exactly one recipient per personalization, and it must be DKIM signed. 400 Bad Request will be returned if there are more than one recipient in any personalization for non-transactional messages. If a message is marked as non-transactional, it changes the sending process as follows:
362
379
  *
363
- * List-Unsubscribe headers will be added.
380
+ * List-Unsubscribe and List-Unsubscribe-Post headers will be added, unless you supply your own List-Unsubscribe header, in which case yours is used and neither is added.
364
381
  * @default true
365
382
  */
366
383
  transactional?: boolean;
384
+ /**
385
+ * Settings to customize the unsubscribe experience for the message.
386
+ */
387
+ unsubscribe?: {
388
+ /**
389
+ * The name of a configured active unsubscribe tracking domain.
390
+ * When specified, unsubscribe links will use this domain instead of the default MailChannels domain.
391
+ * The domain must be registered in your account and have an active status.
392
+ */
393
+ customDomainName?: string;
394
+ };
367
395
  }
368
396
  type EmailsSendTargetOptions = {
369
397
  personalizations: (Omit<EmailsSendPersonalization, "template"> & {
@@ -697,6 +725,171 @@ declare class DomainsDkim {
697
725
  */
698
726
  rotate(domain: string, selector: string, options: DomainsDkimRotateOptions): Promise<DomainsDkimRotateResponse>;
699
727
  }
728
+ type DomainsCustomTrackingScope = "click" | "open" | "unsubscribe";
729
+ interface DomainsCustomTrackingDomain {
730
+ /**
731
+ * The label for this custom tracking domain.
732
+ */
733
+ name: string;
734
+ /**
735
+ * The registered domain hostname.
736
+ */
737
+ hostname: string;
738
+ /**
739
+ * The event type this domain handles.
740
+ */
741
+ scope: DomainsCustomTrackingScope;
742
+ /**
743
+ * Current status of the custom tracking domain.
744
+ */
745
+ status: "active" | "disabled";
746
+ /**
747
+ * ISO 8601 timestamp when the domain was registered.
748
+ */
749
+ createdAt: string;
750
+ }
751
+ interface DomainsCustomTrackingDnsSetupRequired {
752
+ /**
753
+ * UUID v4 nonce; also the TXT record value to set. Present only when TXT ownership verification is pending.
754
+ * @example "550e8400-e29b-41d4-a716-446655440000"
755
+ */
756
+ token?: string;
757
+ /**
758
+ * Fully-qualified DNS TXT record name to add. Present only when TXT ownership verification is pending.
759
+ * @example "_mailchannels-verify.click.example.com"
760
+ */
761
+ txtRecordName?: string;
762
+ /**
763
+ * Value for the DNS TXT record (same as token). Present only when TXT ownership verification is pending.
764
+ * @example "550e8400-e29b-41d4-a716-446655440000"
765
+ */
766
+ txtRecordValue?: string;
767
+ /**
768
+ * Human-readable guidance for the DNS records that must be in place before retrying.
769
+ */
770
+ instructions?: string;
771
+ }
772
+ type DomainsCustomTrackingWithDnsSetupRequired<T extends 202 | 201 | 200 | undefined = undefined> = T extends undefined ? DomainsCustomTrackingDomain & {
773
+ dnsSetupRequired: false;
774
+ } | DomainsCustomTrackingDnsSetupRequired & {
775
+ dnsSetupRequired: true;
776
+ } : T extends 202 ? DomainsCustomTrackingDnsSetupRequired & {
777
+ dnsSetupRequired: true;
778
+ } : DomainsCustomTrackingDomain & {
779
+ dnsSetupRequired: false;
780
+ };
781
+ type DomainsCustomTrackingCreateResponse = DataResponse<DomainsCustomTrackingWithDnsSetupRequired>;
782
+ interface DomainsCustomTrackingListOptions {
783
+ /**
784
+ * Filter by custom tracking domain label.
785
+ */
786
+ name?: string;
787
+ /**
788
+ * Filter by status.
789
+ */
790
+ status?: "active" | "disabled";
791
+ /**
792
+ * Filter by scope.
793
+ */
794
+ scope?: DomainsCustomTrackingScope;
795
+ /**
796
+ * The maximum number of domains to return. Possible values are `1` to `1000`.
797
+ * @default 100
798
+ */
799
+ limit?: number;
800
+ /**
801
+ * The number of domains to skip before returning results. The default is `0`.
802
+ * @default 0
803
+ */
804
+ offset?: number;
805
+ }
806
+ type DomainsCustomTrackingListResponse = DataResponse<{
807
+ /**
808
+ * List of custom tracking domains matching the filter criteria.
809
+ */
810
+ customTrackingDomains: DomainsCustomTrackingDomain[];
811
+ /**
812
+ * Total number of custom tracking domains.
813
+ */
814
+ total: number;
815
+ }>;
816
+ interface DomainsCustomTrackingUpdateOptions {
817
+ /**
818
+ * New label for this custom tracking domain. Maximum length is `64` characters. Must match the pattern `^[a-z0-9-]+$`.
819
+ */
820
+ name?: string;
821
+ /**
822
+ * New status. Re-activation requires DNS verification — add the TXT record and CNAME record described in the response body, then retry.
823
+ */
824
+ status?: "active" | "disabled";
825
+ }
826
+ type DomainsCustomTrackingUpdateResponse = DataResponse<DomainsCustomTrackingWithDnsSetupRequired>;
827
+ declare class DomainsCustomTracking {
828
+ private mailchannels;
829
+ private static readonly SCOPE_VALUES;
830
+ constructor(mailchannels: MailChannelsClient);
831
+ /**
832
+ * Retrieve all custom tracking domains registered under your account.
833
+ * Optional filters include domain name, status, scope, limit and offset.
834
+ * @param options - Optional filter options.
835
+ * @example
836
+ * ```ts
837
+ * const mailchannels = new MailChannels('your-api-key')
838
+ * const { data, error } = await mailchannels.domains.customTracking.list({
839
+ * status: 'active'
840
+ * })
841
+ * ```
842
+ */
843
+ list(options?: DomainsCustomTrackingListOptions): Promise<DomainsCustomTrackingListResponse>;
844
+ /**
845
+ * Register a custom branded domain for click tracking, open tracking, or unsubscribe handling. By default, MailChannels uses shared domains for these links. Using a custom domain improves brand consistency by replacing shared domains with your own (e.g., `click.example.com`). Once registered, select the domain at send time using its `name`.
846
+ *
847
+ * Before registration completes, two DNS records must be in place:
848
+ * 1. A TXT record at `_mailchannels-verify.<hostname>` containing the verification token (returned when DNS setup is required).
849
+ * 2. A CNAME record at `<hostname>` pointing to `links.mailchannels.net`.
850
+ * @param name - A unique label used to select this domain at message send time. Maximum length is `64` characters. Must match the pattern `^[a-z0-9-]+$`.
851
+ * @param hostname - The hostname to register as a custom tracking domain (e.g., `click.example.com`).
852
+ * @param scope - The event type this domain handles.
853
+ * @example
854
+ * ```ts
855
+ * const mailchannels = new MailChannels('your-api-key')
856
+ * const { data, error } = await mailchannels.domains.customTracking.create(
857
+ * 'clickdemo',
858
+ * 'click.example.com',
859
+ * 'click'
860
+ * )
861
+ * ```
862
+ */
863
+ create(name: string, hostname: string, scope: DomainsCustomTrackingScope): Promise<DomainsCustomTrackingCreateResponse>;
864
+ /**
865
+ * Update an existing custom tracking domain by its hostname and scope. Supports updating the custom tracking domain's name or toggling its active status.
866
+ * @param hostname - The hostname of the custom tracking domain to update.
867
+ * @param scope - The scope of the custom tracking domain to update.
868
+ * @param options - The options for updating the custom tracking domain.
869
+ * @example
870
+ * ```ts
871
+ * const mailchannels = new MailChannels('your-api-key')
872
+ * const { data, error } = await mailchannels.domains.customTracking.update('click.example.com', 'click', {
873
+ * name: 'newclickname',
874
+ * status: 'active'
875
+ * })
876
+ * ```
877
+ */
878
+ update(hostname: string, scope: DomainsCustomTrackingScope, options: DomainsCustomTrackingUpdateOptions): Promise<DomainsCustomTrackingUpdateResponse>;
879
+ /**
880
+ * Permanently delete an existing custom tracking domain for the given hostname and scope. The domain can be re-registered if needed.
881
+ *
882
+ * WARNING: Any tracking links or unsubscribe URLs in previously sent emails using this domain will stop working immediately.
883
+ * @param hostname - The hostname of the custom tracking domain to delete.
884
+ * @param scope - The scope of the custom tracking domain to delete.
885
+ * @example
886
+ * ```ts
887
+ * const mailchannels = new MailChannels('your-api-key')
888
+ * const { success, error } = await mailchannels.domains.customTracking.delete('click.example.com', 'click')
889
+ * ```
890
+ */
891
+ delete(hostname: string, scope: DomainsCustomTrackingScope): Promise<SuccessResponse>;
892
+ }
700
893
  interface DomainsCheck {
701
894
  /**
702
895
  * Domain used for DKIM signing.
@@ -792,6 +985,7 @@ type DomainsCheckResponse = DataResponse<{
792
985
  declare class Domains {
793
986
  protected mailchannels: MailChannelsClient;
794
987
  readonly dkim: DomainsDkim;
988
+ readonly customTracking: DomainsCustomTracking;
795
989
  constructor(mailchannels: MailChannelsClient);
796
990
  /**
797
991
  * Validates a domain's email authentication setup by retrieving its DKIM, SPF, and Domain Lockdown status. This endpoint checks whether the domain is properly configured for secure email delivery.
@@ -1259,25 +1453,74 @@ interface MetricsEngagement {
1259
1453
  clickTrackingDelivered: MetricsBucket[];
1260
1454
  open: MetricsBucket[];
1261
1455
  openTrackingDelivered: MetricsBucket[];
1456
+ uniqueClick?: MetricsBucket[];
1457
+ uniqueClickTrackingDelivered?: MetricsBucket[];
1458
+ uniqueOpen?: MetricsBucket[];
1459
+ uniqueOpenTrackingDelivered?: MetricsBucket[];
1262
1460
  };
1461
+ /**
1462
+ * Count of click events by recipients.
1463
+ */
1263
1464
  click: number;
1465
+ /**
1466
+ * Count of recipients of delivered messages with HTML content that contains tracked click URLs, where click tracking is enabled in the send request.
1467
+ */
1264
1468
  clickTrackingDelivered: number;
1469
+ /**
1470
+ * The end of the time range for retrieving message engagement metrics (exclusive).
1471
+ */
1265
1472
  endTime: string;
1473
+ /**
1474
+ * Count of open events by recipients.
1475
+ */
1266
1476
  open: number;
1477
+ /**
1478
+ * Count of recipients of delivered messages with HTML content where open tracking was enabled in the send request.
1479
+ */
1267
1480
  openTrackingDelivered: number;
1481
+ /**
1482
+ * The beginning of the time range for retrieving message engagement metrics (inclusive).
1483
+ */
1268
1484
  startTime: string;
1485
+ /**
1486
+ * Count of distinct messages that had at least one click event.
1487
+ * Unlike `click`, each message is counted at most once regardless of how many links were clicked or how many times.
1488
+ * Use this to compute click rates without exceeding 100%.
1489
+ */
1490
+ uniqueClick?: number;
1491
+ /**
1492
+ * Count of distinct messages delivered with click tracking enabled (message-level, not recipient-level).
1493
+ * Use as the denominator when computing unique click rates.
1494
+ */
1495
+ uniqueClickTrackingDelivered?: number;
1496
+ /**
1497
+ * Count of distinct messages that had at least one open event.
1498
+ * Unlike `open`, each message is counted at most once regardless of how many times its tracking pixel was fired.
1499
+ * Use this to compute open rates without exceeding 100%.
1500
+ */
1501
+ uniqueOpen?: number;
1502
+ /**
1503
+ * Count of distinct messages delivered with open tracking enabled (message-level, not recipient-level).
1504
+ * Use as the denominator when computing unique open rates.
1505
+ */
1506
+ uniqueOpenTrackingDelivered?: number;
1269
1507
  }
1270
1508
  type MetricsEngagementResponse = DataResponse<MetricsEngagement>;
1271
1509
  interface MetricsPerformance {
1272
1510
  /**
1273
- * Count of messages bounced during the specified time range.
1511
+ * Count of messages hard-bounced during the specified time range.
1274
1512
  */
1275
1513
  bounced: number;
1514
+ /**
1515
+ * Count of messages complained during the specified time range.
1516
+ */
1517
+ complained: number;
1276
1518
  /**
1277
1519
  * A series of metrics aggregations bucketed by time interval (e.g. hour, day).
1278
1520
  */
1279
1521
  buckets: {
1280
1522
  bounced: MetricsBucket[];
1523
+ complained: MetricsBucket[];
1281
1524
  delivered: MetricsBucket[];
1282
1525
  processed: MetricsBucket[];
1283
1526
  };
@@ -1727,7 +1970,7 @@ declare class Metrics {
1727
1970
  */
1728
1971
  engagement(options?: MetricsOptions): Promise<MetricsEngagementResponse>;
1729
1972
  /**
1730
- * Retrieve performance metrics for messages sent from your account, including counts of processed, delivered, hard-bounced events. Supports optional filters for time range, and campaign ID.
1973
+ * Retrieve performance metrics for messages sent from your account, including counts of processed, delivered, hard-bounced, and complained events. Supports optional filters for time range, and campaign ID.
1731
1974
  * @param options - Options to filter and customize the performance metrics retrieval.
1732
1975
  * @example
1733
1976
  * ```ts
@@ -1827,4 +2070,4 @@ declare class MailChannels extends MailChannelsClient {
1827
2070
  readonly suppressions: Suppressions;
1828
2071
  constructor(key: string, options?: MailChannelsClientOptions);
1829
2072
  }
1830
- export { Attachment, type DataResponse, Domains, type DomainsCheckOptions, type DomainsCheckResponse, type DomainsCheckVerdict, type DomainsDkimCreateOptions, type DomainsDkimCreateResponse, type DomainsDkimKey, type DomainsDkimKeyStatus, type DomainsDkimListOptions, type DomainsDkimListResponse, type DomainsDkimRotateOptions, type DomainsDkimRotateResponse, type DomainsDkimUpdateStatusOptions, Emails, type EmailsQueueResponse, type EmailsSendAsyncResponse, type EmailsSendAttachment, type EmailsSendContent, type EmailsSendDkim, type EmailsSendOptions, type EmailsSendPersonalization, type EmailsSendRecipient, type EmailsSendRecipientInput, type EmailsSendResponse, type EmailsSendTemplate, type EmailsSendTemplateType, type EmailsSendTemplateValue, type EmailsSendTracking, type ErrorResponse, type ErrorType, MailChannels, MailChannelsClient, type MailChannelsClientOptions, Metrics, type MetricsBucket, type MetricsEngagement, type MetricsEngagementResponse, type MetricsOptions, type MetricsPerformance, type MetricsPerformanceResponse, type MetricsRecipientBehaviour, type MetricsRecipientBehaviourResponse, type MetricsSenders, type MetricsSendersOptions, type MetricsSendersResponse, type MetricsSendersType, type MetricsUsageResponse, type MetricsVolume, type MetricsVolumeResponse, type SubAccount, SubAccounts, type SubAccountsAccount, type SubAccountsApiKey, type SubAccountsApiKeysCreateResponse, type SubAccountsApiKeysListOptions, type SubAccountsApiKeysListResponse, type SubAccountsCreateApiKeyResponse, type SubAccountsCreateResponse, type SubAccountsCreateSmtpPasswordResponse, type SubAccountsLimit, type SubAccountsLimitResponse, type SubAccountsLimitsGetResponse, type SubAccountsLimitsSetOptions, type SubAccountsListApiKeyOptions, type SubAccountsListApiKeyResponse, type SubAccountsListOptions, type SubAccountsListResponse, type SubAccountsListSmtpPasswordResponse, type SubAccountsSmtpPassword, type SubAccountsSmtpPasswordsCreateResponse, type SubAccountsSmtpPasswordsListResponse, type SubAccountsUsage, type SubAccountsUsageResponse, type SuccessResponse, Suppressions, type SuppressionsCreateOptions, type SuppressionsListEntry, type SuppressionsListOptions, type SuppressionsListResponse, type SuppressionsSource, type SuppressionsTypes, type WebhookEvent, type WebhookEventClick, type WebhookEventComplained, type WebhookEventDelivered, type WebhookEventDropped, type WebhookEventHardBounced, type WebhookEventOpen, type WebhookEventProcessed, type WebhookEventSoftBounced, type WebhookEventTest, type WebhookEventType, type WebhookEventUnsubscribed, Webhooks, type WebhooksBatch, type WebhooksBatchResponseStatus, type WebhooksBatchStatus, type WebhooksBatchesOptions, type WebhooksBatchesResponse, type WebhooksListResponse, type WebhooksResendBatch, type WebhooksResendBatchResponse, type WebhooksSigningKeyResponse, type WebhooksValidateResponse, type WebhooksVerifyOptions, type WebhooksVerifyResponse };
2073
+ export { Attachment, type DataResponse, Domains, type DomainsCheckOptions, type DomainsCheckResponse, type DomainsCheckVerdict, type DomainsCustomTrackingCreateResponse, type DomainsCustomTrackingDnsSetupRequired, type DomainsCustomTrackingDomain, type DomainsCustomTrackingListOptions, type DomainsCustomTrackingListResponse, type DomainsCustomTrackingScope, type DomainsCustomTrackingUpdateOptions, type DomainsCustomTrackingUpdateResponse, type DomainsCustomTrackingWithDnsSetupRequired, type DomainsDkimCreateOptions, type DomainsDkimCreateResponse, type DomainsDkimKey, type DomainsDkimKeyStatus, type DomainsDkimListOptions, type DomainsDkimListResponse, type DomainsDkimRotateOptions, type DomainsDkimRotateResponse, type DomainsDkimUpdateStatusOptions, Emails, type EmailsQueueResponse, type EmailsSendAsyncResponse, type EmailsSendAttachment, type EmailsSendContent, type EmailsSendDkim, type EmailsSendOptions, type EmailsSendPersonalization, type EmailsSendRecipient, type EmailsSendRecipientInput, type EmailsSendResponse, type EmailsSendTemplate, type EmailsSendTemplateType, type EmailsSendTemplateValue, type EmailsSendTracking, type ErrorResponse, type ErrorType, MailChannels, MailChannelsClient, type MailChannelsClientOptions, Metrics, type MetricsBucket, type MetricsEngagement, type MetricsEngagementResponse, type MetricsOptions, type MetricsPerformance, type MetricsPerformanceResponse, type MetricsRecipientBehaviour, type MetricsRecipientBehaviourResponse, type MetricsSenders, type MetricsSendersOptions, type MetricsSendersResponse, type MetricsSendersType, type MetricsUsageResponse, type MetricsVolume, type MetricsVolumeResponse, type SubAccount, SubAccounts, type SubAccountsAccount, type SubAccountsApiKey, type SubAccountsApiKeysCreateResponse, type SubAccountsApiKeysListOptions, type SubAccountsApiKeysListResponse, type SubAccountsCreateApiKeyResponse, type SubAccountsCreateResponse, type SubAccountsCreateSmtpPasswordResponse, type SubAccountsLimit, type SubAccountsLimitResponse, type SubAccountsLimitsGetResponse, type SubAccountsLimitsSetOptions, type SubAccountsListApiKeyOptions, type SubAccountsListApiKeyResponse, type SubAccountsListOptions, type SubAccountsListResponse, type SubAccountsListSmtpPasswordResponse, type SubAccountsSmtpPassword, type SubAccountsSmtpPasswordsCreateResponse, type SubAccountsSmtpPasswordsListResponse, type SubAccountsUsage, type SubAccountsUsageResponse, type SuccessResponse, Suppressions, type SuppressionsCreateOptions, type SuppressionsListEntry, type SuppressionsListOptions, type SuppressionsListResponse, type SuppressionsSource, type SuppressionsTypes, type WebhookEvent, type WebhookEventClick, type WebhookEventComplained, type WebhookEventDelivered, type WebhookEventDropped, type WebhookEventHardBounced, type WebhookEventOpen, type WebhookEventProcessed, type WebhookEventSoftBounced, type WebhookEventTest, type WebhookEventType, type WebhookEventUnsubscribed, Webhooks, type WebhooksBatch, type WebhooksBatchResponseStatus, type WebhooksBatchStatus, type WebhooksBatchesOptions, type WebhooksBatchesResponse, type WebhooksListResponse, type WebhooksResendBatch, type WebhooksResendBatchResponse, type WebhooksSigningKeyResponse, type WebhooksValidateResponse, type WebhooksVerifyOptions, type WebhooksVerifyResponse };
@@ -2,7 +2,7 @@ import { $fetch } from "ofetch";
2
2
  import { subtle } from "node:crypto";
3
3
  import { Buffer } from "node:buffer";
4
4
  import mime from "mime";
5
- var version = "1.1.0";
5
+ var version = "1.2.0";
6
6
  var MailChannelsClient = class MailChannelsClient {
7
7
  static DEFAULT_BASE_URL = "https://api.mailchannels.net";
8
8
  static DEFAULT_TIMEOUT = 12e4;
@@ -79,21 +79,26 @@ const STATUS_ERROR_TYPE_MAP = {
79
79
  [429]: "rate_limit_error",
80
80
  [500]: "internal_server_error"
81
81
  };
82
- const createError = (message, statusCode = null, type) => {
82
+ const createError = (message, statusCode = null, type, response = null) => {
83
83
  return {
84
84
  message,
85
85
  statusCode,
86
- type
86
+ type,
87
+ response
87
88
  };
88
89
  };
89
90
  const getStatusError = (response, errors = {}) => {
90
91
  const statusText = errors[response.status] || "Unknown error.";
91
92
  const payload = response._data ?? response.data;
92
93
  let details;
94
+ let errorResponse = null;
93
95
  if (typeof payload === "string") details = payload;
94
- else if (payload?.message) details = payload.message;
95
- else if (Array.isArray(payload?.errors) && payload.errors?.length) details = payload.errors.join(", ");
96
- return createError(details ? `${statusText} ${details}` : statusText, response.status ?? null, STATUS_ERROR_TYPE_MAP[response.status] || "api_error");
96
+ else if (typeof payload === "object" && payload !== null) {
97
+ if (typeof payload.message === "string") details = payload.message;
98
+ else if (Array.isArray(payload.errors) && payload.errors.length) details = payload.errors.join(", ");
99
+ errorResponse = payload;
100
+ }
101
+ return createError(details ? `${statusText} ${details}` : statusText, response.status ?? null, STATUS_ERROR_TYPE_MAP[response.status] || "api_error", errorResponse);
97
102
  };
98
103
  const getResultError = (e, fallback) => {
99
104
  return createError(e instanceof Error ? e.message : fallback, null, "application_error");
@@ -107,6 +112,12 @@ const validatePagination = (pagination = {}) => {
107
112
  if (typeof offset === "number" && offset < 0) return createValidationError("Offset must be greater than or equal to 0.");
108
113
  return null;
109
114
  };
115
+ const CUSTOM_TRACKING_NAME_PATTERN = /^[a-z0-9-]+$/;
116
+ const validateCustomTrackingName = (name) => {
117
+ if (!name || name.length < 1 || name.length > 64) return createValidationError("The custom tracking domain name must be between 1 and 64 characters.");
118
+ if (!CUSTOM_TRACKING_NAME_PATTERN.test(name)) return createValidationError("The custom tracking domain name must match ^[a-z0-9-]+$");
119
+ return null;
120
+ };
110
121
  const clean = (data) => {
111
122
  if (Array.isArray(data)) {
112
123
  const result = [];
@@ -305,6 +316,18 @@ const buildSendPayload = async (options) => {
305
316
  return getRecipientCount(personalization.to) + getRecipientCount(personalization.cc) + getRecipientCount(personalization.bcc) !== 1;
306
317
  })) return "Non-transactional messages must have exactly one recipient per personalization.";
307
318
  }
319
+ if (options.tracking?.click?.customDomainName !== void 0) {
320
+ const clickCustomTrackingNameError = validateCustomTrackingName(options.tracking.click.customDomainName);
321
+ if (clickCustomTrackingNameError) return `Invalid click tracking: ${clickCustomTrackingNameError.message}`;
322
+ }
323
+ if (options.tracking?.open?.customDomainName !== void 0) {
324
+ const openCustomTrackingNameError = validateCustomTrackingName(options.tracking.open.customDomainName);
325
+ if (openCustomTrackingNameError) return `Invalid open tracking: ${openCustomTrackingNameError.message}`;
326
+ }
327
+ if (options.unsubscribe?.customDomainName !== void 0) {
328
+ const unsubscribeCustomTrackingNameError = validateCustomTrackingName(options.unsubscribe.customDomainName);
329
+ if (unsubscribeCustomTrackingNameError) return `Invalid unsubscribe settings: ${unsubscribeCustomTrackingNameError.message}`;
330
+ }
308
331
  const content = [];
309
332
  const template_type = options.template?.type;
310
333
  if (text) content.push({
@@ -335,11 +358,18 @@ const buildSendPayload = async (options) => {
335
358
  from: parsedFrom,
336
359
  subject: options.subject,
337
360
  content,
338
- tracking_settings: options.tracking ? {
339
- click_tracking: options.tracking.click ? { enable: options.tracking.click.enable } : void 0,
340
- open_tracking: options.tracking.open ? { enable: options.tracking.open.enable } : void 0
341
- } : void 0,
342
- transactional: options.transactional
361
+ tracking_settings: options.tracking && {
362
+ click_tracking: options.tracking.click && {
363
+ custom_domain_name: options.tracking.click.customDomainName,
364
+ enable: options.tracking.click.enable
365
+ },
366
+ open_tracking: options.tracking.open && {
367
+ custom_domain_name: options.tracking.open.customDomainName,
368
+ enable: options.tracking.open.enable
369
+ }
370
+ },
371
+ transactional: options.transactional,
372
+ unsubscribe_settings: options.unsubscribe && { custom_domain_name: options.unsubscribe.customDomainName }
343
373
  };
344
374
  };
345
375
  var Emails = class {
@@ -603,12 +633,245 @@ var DomainsDkim = class {
603
633
  };
604
634
  }
605
635
  };
636
+ var DomainsCustomTracking = class DomainsCustomTracking {
637
+ mailchannels;
638
+ static SCOPE_VALUES = /* @__PURE__ */ new Set([
639
+ "click",
640
+ "open",
641
+ "unsubscribe"
642
+ ]);
643
+ constructor(mailchannels) {
644
+ this.mailchannels = mailchannels;
645
+ }
646
+ async list(options) {
647
+ let error = null;
648
+ error = validatePagination({
649
+ ...options,
650
+ max: 1e3
651
+ });
652
+ if (error) return {
653
+ data: null,
654
+ error
655
+ };
656
+ const response = await this.mailchannels.get("/tx/v1/custom-tracking-domains", {
657
+ query: options,
658
+ onResponseError: async ({ response }) => {
659
+ error = getStatusError(response, { [400]: "Bad Request." });
660
+ }
661
+ }).catch((e) => {
662
+ error ||= getResultError(e, "Failed to fetch custom tracking domains.");
663
+ return null;
664
+ });
665
+ if (!response) return {
666
+ data: null,
667
+ error
668
+ };
669
+ return {
670
+ data: clean({
671
+ customTrackingDomains: response.custom_tracking_domains.map((d) => ({
672
+ name: d.name,
673
+ hostname: d.hostname,
674
+ scope: d.scope,
675
+ status: d.status,
676
+ createdAt: d.created_at
677
+ })),
678
+ total: response.total
679
+ }),
680
+ error: null
681
+ };
682
+ }
683
+ async create(name, hostname, scope) {
684
+ let error = null;
685
+ error = validateCustomTrackingName(name);
686
+ if (error) return {
687
+ data: null,
688
+ error
689
+ };
690
+ if (!hostname) {
691
+ error = createValidationError("Hostname is required.");
692
+ return {
693
+ data: null,
694
+ error
695
+ };
696
+ }
697
+ if (!scope || !DomainsCustomTracking.SCOPE_VALUES.has(scope)) {
698
+ error = createValidationError("Scope must be one of 'click', 'open', or 'unsubscribe'.");
699
+ return {
700
+ data: null,
701
+ error
702
+ };
703
+ }
704
+ const payload = {
705
+ name,
706
+ hostname,
707
+ scope
708
+ };
709
+ let statusCode = null;
710
+ const response = await this.mailchannels.post("/tx/v1/custom-tracking-domains", {
711
+ body: payload,
712
+ onResponse: async ({ response }) => {
713
+ statusCode = response.status;
714
+ },
715
+ onResponseError: async ({ response }) => {
716
+ error = getStatusError(response, {
717
+ [400]: "Invalid request body.",
718
+ [403]: "No permission to register this domain.",
719
+ [409]: "A domain with the same name already exists, or the hostname and scope combination is already registered.",
720
+ [422]: "DNS verification incomplete. Either the TXT ownership record has not propagated yet or the hostname CNAME does not point to the required target."
721
+ });
722
+ }
723
+ }).catch((e) => {
724
+ error ||= getResultError(e, "Failed to create custom tracking domain.");
725
+ return null;
726
+ });
727
+ if (!response) return {
728
+ data: null,
729
+ error
730
+ };
731
+ if (statusCode === 202) {
732
+ const dnsSetupRequiredResponse = response;
733
+ return {
734
+ data: clean({
735
+ dnsSetupRequired: true,
736
+ token: dnsSetupRequiredResponse.token,
737
+ txtRecordName: dnsSetupRequiredResponse.txt_record_name,
738
+ txtRecordValue: dnsSetupRequiredResponse.txt_record_value,
739
+ instructions: dnsSetupRequiredResponse.instructions
740
+ }),
741
+ error: null
742
+ };
743
+ }
744
+ const createResponse = response;
745
+ return {
746
+ data: clean({
747
+ dnsSetupRequired: false,
748
+ name: createResponse.name,
749
+ hostname: createResponse.hostname,
750
+ scope: createResponse.scope,
751
+ status: createResponse.status,
752
+ createdAt: createResponse.created_at
753
+ }),
754
+ error: null
755
+ };
756
+ }
757
+ async update(hostname, scope, options) {
758
+ let error = null;
759
+ if (!hostname) {
760
+ error = createValidationError("Hostname is required.");
761
+ return {
762
+ data: null,
763
+ error
764
+ };
765
+ }
766
+ if (!scope || !DomainsCustomTracking.SCOPE_VALUES.has(scope)) {
767
+ error = createValidationError("Scope must be one of 'click', 'open', or 'unsubscribe'.");
768
+ return {
769
+ data: null,
770
+ error
771
+ };
772
+ }
773
+ if (options.name !== void 0) {
774
+ error = validateCustomTrackingName(options.name);
775
+ if (error) return {
776
+ data: null,
777
+ error
778
+ };
779
+ }
780
+ if (options.name === void 0 && options.status === void 0) {
781
+ error = createValidationError("At least one of 'name' or 'status' must be provided.");
782
+ return {
783
+ data: null,
784
+ error
785
+ };
786
+ }
787
+ const payload = {
788
+ name: options.name,
789
+ status: options.status
790
+ };
791
+ let statusCode = null;
792
+ const response = await this.mailchannels.patch(`/tx/v1/custom-tracking-domains/${encodeURIComponent(hostname)}/${encodeURIComponent(scope)}`, {
793
+ body: payload,
794
+ onResponse: async ({ response }) => {
795
+ statusCode = response.status;
796
+ },
797
+ onResponseError: async ({ response }) => {
798
+ error = getStatusError(response, {
799
+ [400]: "Bad Request.",
800
+ [403]: "No permission to update this domain.",
801
+ [404]: `Custom tracking domain for hostname '${hostname}' and scope '${scope}' not found.`,
802
+ [409]: "Name already used by another domain.",
803
+ [422]: "DNS verification incomplete. Either the TXT ownership record has not propagated yet or the hostname CNAME does not point to the required target."
804
+ });
805
+ }
806
+ }).catch((e) => {
807
+ error ||= getResultError(e, "Failed to update custom tracking domain.");
808
+ return null;
809
+ });
810
+ if (!response) return {
811
+ data: null,
812
+ error
813
+ };
814
+ if (statusCode === 202) {
815
+ const dnsSetupRequiredResponse = response;
816
+ return {
817
+ data: clean({
818
+ dnsSetupRequired: true,
819
+ token: dnsSetupRequiredResponse.token,
820
+ txtRecordName: dnsSetupRequiredResponse.txt_record_name,
821
+ txtRecordValue: dnsSetupRequiredResponse.txt_record_value,
822
+ instructions: dnsSetupRequiredResponse.instructions
823
+ }),
824
+ error: null
825
+ };
826
+ }
827
+ const updateResponse = response;
828
+ return {
829
+ data: clean({
830
+ dnsSetupRequired: false,
831
+ name: updateResponse.name,
832
+ hostname: updateResponse.hostname,
833
+ scope: updateResponse.scope,
834
+ status: updateResponse.status,
835
+ createdAt: updateResponse.created_at
836
+ }),
837
+ error: null
838
+ };
839
+ }
840
+ async delete(hostname, scope) {
841
+ let error = null;
842
+ if (!hostname) {
843
+ error = createValidationError("Hostname is required.");
844
+ return {
845
+ success: false,
846
+ error
847
+ };
848
+ }
849
+ if (!scope || !DomainsCustomTracking.SCOPE_VALUES.has(scope)) {
850
+ error = createValidationError("Scope must be one of 'click', 'open', or 'unsubscribe'.");
851
+ return {
852
+ success: false,
853
+ error
854
+ };
855
+ }
856
+ await this.mailchannels.delete(`/tx/v1/custom-tracking-domains/${encodeURIComponent(hostname)}/${encodeURIComponent(scope)}`, { onResponseError: async ({ response }) => {
857
+ error = getStatusError(response, { [400]: "Invalid hostname or scope value" });
858
+ } }).catch((e) => {
859
+ error ||= getResultError(e, "Failed to delete custom tracking domain.");
860
+ });
861
+ return {
862
+ success: !error,
863
+ error
864
+ };
865
+ }
866
+ };
606
867
  var Domains = class {
607
868
  mailchannels;
608
869
  dkim;
870
+ customTracking;
609
871
  constructor(mailchannels) {
610
872
  this.mailchannels = mailchannels;
611
873
  this.dkim = new DomainsDkim(mailchannels);
874
+ this.customTracking = new DomainsCustomTracking(mailchannels);
612
875
  }
613
876
  async check(domain, options) {
614
877
  let error = null;
@@ -1558,14 +1821,22 @@ var Metrics = class {
1558
1821
  click: response.buckets.click.map(mapBucket),
1559
1822
  clickTrackingDelivered: response.buckets.click_tracking_delivered.map(mapBucket),
1560
1823
  open: response.buckets.open.map(mapBucket),
1561
- openTrackingDelivered: response.buckets.open_tracking_delivered.map(mapBucket)
1824
+ openTrackingDelivered: response.buckets.open_tracking_delivered.map(mapBucket),
1825
+ uniqueClick: response.buckets.unique_click?.map(mapBucket),
1826
+ uniqueClickTrackingDelivered: response.buckets.unique_click_tracking_delivered?.map(mapBucket),
1827
+ uniqueOpen: response.buckets.unique_open?.map(mapBucket),
1828
+ uniqueOpenTrackingDelivered: response.buckets.unique_open_tracking_delivered?.map(mapBucket)
1562
1829
  },
1563
1830
  click: response.click,
1564
1831
  clickTrackingDelivered: response.click_tracking_delivered,
1565
1832
  endTime: response.end_time,
1566
1833
  open: response.open,
1567
1834
  openTrackingDelivered: response.open_tracking_delivered,
1568
- startTime: response.start_time
1835
+ startTime: response.start_time,
1836
+ uniqueClick: response.unique_click,
1837
+ uniqueClickTrackingDelivered: response.unique_click_tracking_delivered,
1838
+ uniqueOpen: response.unique_open,
1839
+ uniqueOpenTrackingDelivered: response.unique_open_tracking_delivered
1569
1840
  }),
1570
1841
  error: null
1571
1842
  };
@@ -1601,8 +1872,10 @@ var Metrics = class {
1601
1872
  return {
1602
1873
  data: clean({
1603
1874
  bounced: response.bounced,
1875
+ complained: response.complained,
1604
1876
  buckets: {
1605
1877
  bounced: response.buckets.bounced.map(mapBucket),
1878
+ complained: response.buckets.complained.map(mapBucket),
1606
1879
  delivered: response.buckets.delivered.map(mapBucket),
1607
1880
  processed: response.buckets.processed.map(mapBucket)
1608
1881
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailchannels-sdk",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Node.js SDK to integrate MailChannels Email API into your JavaScript or TypeScript server-side applications.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,17 +43,17 @@
43
43
  "devDependencies": {
44
44
  "@stylistic/eslint-plugin": "^5.10.0",
45
45
  "@types/markdown-it": "^14.1.2",
46
- "@types/node": "^25.9.3",
47
- "@vitest/coverage-v8": "^4.1.9",
46
+ "@types/node": "^26.1.1",
47
+ "@vitest/coverage-v8": "^4.1.10",
48
48
  "changelogen": "^0.6.2",
49
- "obuild": "^0.4.36",
50
- "oxlint": "^1.70.0",
49
+ "obuild": "^0.4.37",
50
+ "oxlint": "^1.73.0",
51
51
  "scule": "^1.3.0",
52
52
  "typescript": "^6.0.3",
53
- "vitepress": "^2.0.0-alpha.17",
53
+ "vitepress": "^2.0.0-alpha.18",
54
54
  "vitepress-plugin-group-icons": "^1.7.5",
55
- "vitepress-plugin-llms": "^1.13.1",
56
- "vitest": "^4.1.9"
55
+ "vitepress-plugin-llms": "^1.13.2",
56
+ "vitest": "^4.1.10"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=20"