mailchannels-sdk 1.1.0 → 1.3.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.
@@ -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.3.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;
@@ -1480,7 +1743,8 @@ var SubAccounts = class SubAccounts {
1480
1743
  data: clean({
1481
1744
  endDate: response.period_end_date,
1482
1745
  startDate: response.period_start_date,
1483
- total: response.total_usage
1746
+ total: response.total_usage,
1747
+ monthlyLimit: response.monthly_limit
1484
1748
  }),
1485
1749
  error: null
1486
1750
  };
@@ -1558,14 +1822,22 @@ var Metrics = class {
1558
1822
  click: response.buckets.click.map(mapBucket),
1559
1823
  clickTrackingDelivered: response.buckets.click_tracking_delivered.map(mapBucket),
1560
1824
  open: response.buckets.open.map(mapBucket),
1561
- openTrackingDelivered: response.buckets.open_tracking_delivered.map(mapBucket)
1825
+ openTrackingDelivered: response.buckets.open_tracking_delivered.map(mapBucket),
1826
+ uniqueClick: response.buckets.unique_click?.map(mapBucket),
1827
+ uniqueClickTrackingDelivered: response.buckets.unique_click_tracking_delivered?.map(mapBucket),
1828
+ uniqueOpen: response.buckets.unique_open?.map(mapBucket),
1829
+ uniqueOpenTrackingDelivered: response.buckets.unique_open_tracking_delivered?.map(mapBucket)
1562
1830
  },
1563
1831
  click: response.click,
1564
1832
  clickTrackingDelivered: response.click_tracking_delivered,
1565
1833
  endTime: response.end_time,
1566
1834
  open: response.open,
1567
1835
  openTrackingDelivered: response.open_tracking_delivered,
1568
- startTime: response.start_time
1836
+ startTime: response.start_time,
1837
+ uniqueClick: response.unique_click,
1838
+ uniqueClickTrackingDelivered: response.unique_click_tracking_delivered,
1839
+ uniqueOpen: response.unique_open,
1840
+ uniqueOpenTrackingDelivered: response.unique_open_tracking_delivered
1569
1841
  }),
1570
1842
  error: null
1571
1843
  };
@@ -1601,8 +1873,10 @@ var Metrics = class {
1601
1873
  return {
1602
1874
  data: clean({
1603
1875
  bounced: response.bounced,
1876
+ complained: response.complained,
1604
1877
  buckets: {
1605
1878
  bounced: response.buckets.bounced.map(mapBucket),
1879
+ complained: response.buckets.complained.map(mapBucket),
1606
1880
  delivered: response.buckets.delivered.map(mapBucket),
1607
1881
  processed: response.buckets.processed.map(mapBucket)
1608
1882
  },
@@ -1716,7 +1990,8 @@ var Metrics = class {
1716
1990
  data: clean({
1717
1991
  endDate: response.period_end_date,
1718
1992
  startDate: response.period_start_date,
1719
- total: response.total_usage
1993
+ total: response.total_usage,
1994
+ monthlyLimit: response.monthly_limit
1720
1995
  }),
1721
1996
  error: null
1722
1997
  };
@@ -1776,9 +2051,10 @@ var Suppressions = class {
1776
2051
  constructor(mailchannels) {
1777
2052
  this.mailchannels = mailchannels;
1778
2053
  }
1779
- async create(options) {
2054
+ async create(entriesOrOptions, options) {
1780
2055
  let error = null;
1781
- const { addToSubAccounts, entries } = options;
2056
+ const entries = Array.isArray(entriesOrOptions) ? entriesOrOptions : entriesOrOptions.entries;
2057
+ const createOptions = Array.isArray(entriesOrOptions) ? options : entriesOrOptions;
1782
2058
  if (entries.length > 1e3) {
1783
2059
  error = createValidationError("The number of suppression entries must not exceed 1000.");
1784
2060
  return {
@@ -1787,7 +2063,7 @@ var Suppressions = class {
1787
2063
  };
1788
2064
  }
1789
2065
  const payload = {
1790
- add_to_sub_accounts: addToSubAccounts,
2066
+ add_to_sub_accounts: createOptions?.addToSubAccounts,
1791
2067
  suppression_entries: entries.map((entry) => ({
1792
2068
  notes: entry.notes,
1793
2069
  recipient: entry.recipient,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mailchannels-sdk",
3
- "version": "1.1.0",
3
+ "version": "1.3.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.38",
50
+ "oxlint": "^1.74.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.3",
56
+ "vitest": "^4.1.10"
57
57
  },
58
58
  "engines": {
59
59
  "node": ">=20"