supersendtx 0.1.0 → 0.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.
package/dist/index.d.ts CHANGED
@@ -53,13 +53,89 @@ type SuperSendTXOptions = {
53
53
  baseUrl?: string;
54
54
  fetch?: typeof fetch;
55
55
  };
56
+ type DomainStatus = 'pending' | 'verified' | 'failed';
57
+ type Domain = {
58
+ id: string;
59
+ name: string;
60
+ status: DomainStatus | string;
61
+ created_at: string;
62
+ verified_at: string | null;
63
+ };
64
+ type DnsRecord = {
65
+ type: string;
66
+ host: string;
67
+ value: string;
68
+ purpose: string;
69
+ };
70
+ type CreateDomainParams = {
71
+ name: string;
72
+ };
73
+ type CreateDomainResult = {
74
+ domain: Domain;
75
+ records: DnsRecord[];
76
+ };
77
+ type VerifyDomainResult = {
78
+ domain: Domain;
79
+ records: DnsRecord[];
80
+ verified: boolean;
81
+ checks?: {
82
+ ownership: boolean;
83
+ spf: boolean;
84
+ dkim: boolean;
85
+ dmarc: boolean;
86
+ };
87
+ auto?: boolean;
88
+ };
89
+ type DnsProviderName$1 = 'cloudflare' | 'godaddy';
90
+ type ApplyDnsParams = {
91
+ provider: DnsProviderName$1;
92
+ /** Domain id or name (fetched from SuperSend TX for DNS records). */
93
+ idOrName: string;
94
+ cloudflareApiToken?: string;
95
+ cloudflareZoneId?: string;
96
+ godaddyApiKey?: string;
97
+ godaddyApiSecret?: string;
98
+ };
99
+ type ApplyDnsRecordResult$1 = {
100
+ purpose: string;
101
+ host: string;
102
+ action: 'created' | 'updated' | 'unchanged' | 'merged';
103
+ detail?: string;
104
+ };
105
+ type ApplyDnsResult$1 = {
106
+ provider: DnsProviderName$1;
107
+ domain: string;
108
+ results: ApplyDnsRecordResult$1[];
109
+ };
56
110
  type ApiErrorBody = {
57
- error?: {
111
+ error?: string | {
58
112
  message?: string;
59
113
  details?: unknown;
60
114
  };
61
115
  };
62
116
 
117
+ declare class DomainsResource {
118
+ private readonly client;
119
+ constructor(client: SuperSendTX);
120
+ list(): Promise<{
121
+ domains: Domain[];
122
+ }>;
123
+ get(idOrName: string): Promise<{
124
+ domain: Domain;
125
+ records: DnsRecord[];
126
+ }>;
127
+ create(params: CreateDomainParams): Promise<CreateDomainResult>;
128
+ verify(idOrName: string): Promise<VerifyDomainResult>;
129
+ delete(idOrName: string): Promise<{
130
+ ok: boolean;
131
+ }>;
132
+ /**
133
+ * Fetch SuperSend TX DNS records, then write them at Cloudflare or GoDaddy
134
+ * using the caller's provider credentials (never sent to SuperSend TX).
135
+ */
136
+ applyDns(params: ApplyDnsParams): Promise<ApplyDnsResult$1>;
137
+ }
138
+
63
139
  declare class EmailsResource {
64
140
  private readonly client;
65
141
  constructor(client: SuperSendTX);
@@ -85,6 +161,7 @@ declare const DEFAULT_API_BASE_URL = "https://api.supersendtx.com";
85
161
  declare class SuperSendTX {
86
162
  private readonly apiKey;
87
163
  private readonly options;
164
+ readonly domains: DomainsResource;
88
165
  readonly emails: EmailsResource;
89
166
  readonly webhooks: WebhooksResource;
90
167
  constructor(apiKey: string, options?: SuperSendTXOptions);
@@ -100,4 +177,34 @@ declare class SuperSendTXError extends Error {
100
177
  static fromResponse(status: number, body: ApiErrorBody): SuperSendTXError;
101
178
  }
102
179
 
103
- export { type CreateWebhookParams, DEFAULT_API_BASE_URL, type EmailListItem, type ListEmailsParams, type SendEmailParams, type SendEmailResult, SuperSendTX, SuperSendTXError, type SuperSendTXOptions, type WebhookEndpoint, type WebhookEndpointWithSecret, type WebhookEventType };
180
+ /** Merge `include:host` into an existing SPF record (or create a minimal one). */
181
+ declare function mergeSpfInclude(existing: string | null | undefined, includeHost: string): string;
182
+
183
+ type DnsProviderName = 'cloudflare' | 'godaddy';
184
+ type ApplyDnsRecordResult = {
185
+ purpose: string;
186
+ host: string;
187
+ action: 'created' | 'updated' | 'unchanged' | 'merged';
188
+ detail?: string;
189
+ };
190
+ type ApplyDnsResult = {
191
+ provider: DnsProviderName;
192
+ domain: string;
193
+ results: ApplyDnsRecordResult[];
194
+ };
195
+ type ApplyDnsCredentials = {
196
+ cloudflareApiToken?: string;
197
+ cloudflareZoneId?: string;
198
+ godaddyApiKey?: string;
199
+ godaddyApiSecret?: string;
200
+ };
201
+
202
+ declare function applyDnsRecords(input: {
203
+ provider: DnsProviderName;
204
+ domain: string;
205
+ records: DnsRecord[];
206
+ credentials: ApplyDnsCredentials;
207
+ fetchImpl?: typeof fetch;
208
+ }): Promise<ApplyDnsResult>;
209
+
210
+ export { type ApplyDnsParams, type ApplyDnsRecordResult$1 as ApplyDnsRecordResult, type ApplyDnsResult$1 as ApplyDnsResult, type CreateDomainParams, type CreateDomainResult, type CreateWebhookParams, DEFAULT_API_BASE_URL, type DnsProviderName$1 as DnsProviderName, type DnsRecord, type Domain, type DomainStatus, type EmailListItem, type ListEmailsParams, type SendEmailParams, type SendEmailResult, SuperSendTX, SuperSendTXError, type SuperSendTXOptions, type VerifyDomainResult, type WebhookEndpoint, type WebhookEndpointWithSecret, type WebhookEventType, applyDnsRecords, mergeSpfInclude };
package/dist/index.js CHANGED
@@ -7,8 +7,377 @@ var SuperSendTXError = class _SuperSendTXError extends Error {
7
7
  this.details = details;
8
8
  }
9
9
  static fromResponse(status, body) {
10
- const message = body.error?.message || `Request failed with status ${status}`;
11
- return new _SuperSendTXError(message, status, body.error?.details);
10
+ const err = body.error;
11
+ const message = typeof err === "string" ? err : err?.message || `Request failed with status ${status}`;
12
+ const details = typeof err === "object" && err ? err.details : void 0;
13
+ return new _SuperSendTXError(message, status, details);
14
+ }
15
+ };
16
+
17
+ // src/dns/spf.ts
18
+ function mergeSpfInclude(existing, includeHost) {
19
+ const include = `include:${includeHost.replace(/^include:/i, "")}`;
20
+ const trimmed = existing?.trim();
21
+ if (!trimmed) {
22
+ return `v=spf1 ${include} ~all`;
23
+ }
24
+ if (trimmed.toLowerCase().includes(include.toLowerCase())) {
25
+ return trimmed;
26
+ }
27
+ if (!/^v=spf1\b/i.test(trimmed)) {
28
+ return `v=spf1 ${include} ~all`;
29
+ }
30
+ if (/\s[~?+\-]all\s*$/i.test(trimmed)) {
31
+ return trimmed.replace(/\s([~?+\-]all)\s*$/i, ` ${include} $1`);
32
+ }
33
+ return `${trimmed} ${include} ~all`;
34
+ }
35
+ function isSpfRecord(value) {
36
+ return value.trim().toLowerCase().startsWith("v=spf1");
37
+ }
38
+
39
+ // src/dns/cloudflare.ts
40
+ function relativeName(fqdn, zone) {
41
+ const domain = zone.replace(/\.$/, "").toLowerCase();
42
+ const host = fqdn.replace(/\.$/, "").toLowerCase();
43
+ if (host === domain) return "@";
44
+ if (host.endsWith(`.${domain}`)) return host.slice(0, -(domain.length + 1));
45
+ return host;
46
+ }
47
+ function createCloudflareProvider(options) {
48
+ const fetchImpl = options.fetchImpl || fetch;
49
+ const headers = {
50
+ Authorization: `Bearer ${options.apiToken}`,
51
+ "Content-Type": "application/json"
52
+ };
53
+ async function cf(path, init) {
54
+ const response = await fetchImpl(`https://api.cloudflare.com/client/v4${path}`, {
55
+ ...init,
56
+ headers: { ...headers, ...init?.headers || {} }
57
+ });
58
+ const body = await response.json().catch(() => ({}));
59
+ if (!response.ok || body.success === false) {
60
+ const message = body.errors?.map((e) => e.message).filter(Boolean).join("; ") || `Cloudflare HTTP ${response.status}`;
61
+ throw new Error(message);
62
+ }
63
+ return body.result;
64
+ }
65
+ async function resolveZoneId(domain) {
66
+ if (options.zoneId) return options.zoneId;
67
+ const zones = await cf(`/zones?name=${encodeURIComponent(domain)}&status=active`);
68
+ const zone = zones?.[0];
69
+ if (!zone) {
70
+ throw new Error(`No active Cloudflare zone found for ${domain}`);
71
+ }
72
+ return zone.id;
73
+ }
74
+ return {
75
+ name: "cloudflare",
76
+ async apply(domain, records) {
77
+ const zoneId = await resolveZoneId(domain);
78
+ const zoneName = domain.toLowerCase();
79
+ const results = [];
80
+ for (const record of records) {
81
+ if (record.type.toUpperCase() !== "TXT") {
82
+ results.push({
83
+ purpose: record.purpose,
84
+ host: record.host,
85
+ action: "unchanged",
86
+ detail: `Skipped unsupported type ${record.type}`
87
+ });
88
+ continue;
89
+ }
90
+ const name = relativeName(record.host, zoneName);
91
+ const absoluteName = name === "@" ? zoneName : `${name}.${zoneName}`;
92
+ if (record.purpose === "SPF") {
93
+ const allTxt = await cf(
94
+ `/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(zoneName)}&per_page=100`
95
+ );
96
+ const spfRecords = (allTxt || []).filter((r) => isSpfRecord(r.content));
97
+ const existing = spfRecords[0];
98
+ const includeHost = record.value.match(/include:([^\s]+)/i)?.[1] || "spf.supersendtx.com";
99
+ const merged = mergeSpfInclude(existing?.content, includeHost);
100
+ if (existing) {
101
+ if (existing.content.trim() === merged.trim()) {
102
+ results.push({
103
+ purpose: record.purpose,
104
+ host: record.host,
105
+ action: "unchanged",
106
+ detail: "SPF already includes SuperSend TX"
107
+ });
108
+ } else {
109
+ await cf(`/zones/${zoneId}/dns_records/${existing.id}`, {
110
+ method: "PUT",
111
+ body: JSON.stringify({
112
+ type: "TXT",
113
+ name: zoneName,
114
+ content: merged,
115
+ ttl: 1
116
+ })
117
+ });
118
+ results.push({
119
+ purpose: record.purpose,
120
+ host: record.host,
121
+ action: "merged",
122
+ detail: merged
123
+ });
124
+ }
125
+ } else {
126
+ await cf(`/zones/${zoneId}/dns_records`, {
127
+ method: "POST",
128
+ body: JSON.stringify({
129
+ type: "TXT",
130
+ name: zoneName,
131
+ content: merged,
132
+ ttl: 1
133
+ })
134
+ });
135
+ results.push({
136
+ purpose: record.purpose,
137
+ host: record.host,
138
+ action: "created",
139
+ detail: merged
140
+ });
141
+ }
142
+ continue;
143
+ }
144
+ const existingList = await cf(
145
+ `/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(absoluteName)}&per_page=100`
146
+ );
147
+ const match = (existingList || []).find((r) => r.content === record.value);
148
+ if (match) {
149
+ results.push({
150
+ purpose: record.purpose,
151
+ host: record.host,
152
+ action: "unchanged"
153
+ });
154
+ continue;
155
+ }
156
+ const replaceable = (existingList || []).find((r) => {
157
+ if (record.purpose === "Domain ownership") return r.content.includes("supersendtx-verify=");
158
+ if (record.purpose === "DKIM") return r.content.toLowerCase().includes("v=dkim1");
159
+ if (record.purpose === "DMARC") return r.content.toLowerCase().includes("v=dmarc1");
160
+ return false;
161
+ });
162
+ if (replaceable) {
163
+ await cf(`/zones/${zoneId}/dns_records/${replaceable.id}`, {
164
+ method: "PUT",
165
+ body: JSON.stringify({
166
+ type: "TXT",
167
+ name: absoluteName,
168
+ content: record.value,
169
+ ttl: 1
170
+ })
171
+ });
172
+ results.push({ purpose: record.purpose, host: record.host, action: "updated" });
173
+ } else {
174
+ await cf(`/zones/${zoneId}/dns_records`, {
175
+ method: "POST",
176
+ body: JSON.stringify({
177
+ type: "TXT",
178
+ name: absoluteName,
179
+ content: record.value,
180
+ ttl: 1
181
+ })
182
+ });
183
+ results.push({ purpose: record.purpose, host: record.host, action: "created" });
184
+ }
185
+ }
186
+ return results;
187
+ }
188
+ };
189
+ }
190
+
191
+ // src/dns/godaddy.ts
192
+ function relativeName2(fqdn, zone) {
193
+ const domain = zone.replace(/\.$/, "").toLowerCase();
194
+ const host = fqdn.replace(/\.$/, "").toLowerCase();
195
+ if (host === domain) return "@";
196
+ if (host.endsWith(`.${domain}`)) return host.slice(0, -(domain.length + 1));
197
+ return host;
198
+ }
199
+ function createGoDaddyProvider(options) {
200
+ const fetchImpl = options.fetchImpl || fetch;
201
+ const headers = {
202
+ Authorization: `sso-key ${options.apiKey}:${options.apiSecret}`,
203
+ "Content-Type": "application/json",
204
+ Accept: "application/json"
205
+ };
206
+ async function gd(path, init) {
207
+ const response = await fetchImpl(`https://api.godaddy.com${path}`, {
208
+ ...init,
209
+ headers: { ...headers, ...init?.headers || {} }
210
+ });
211
+ if (response.status === 204) return void 0;
212
+ const body = await response.json().catch(() => ({}));
213
+ if (!response.ok) {
214
+ const message = body.message || (Array.isArray(body.errors) ? body.errors.map((e) => e.message).join("; ") : null) || `GoDaddy HTTP ${response.status}`;
215
+ throw new Error(message);
216
+ }
217
+ return body;
218
+ }
219
+ return {
220
+ name: "godaddy",
221
+ async apply(domain, records) {
222
+ const zone = domain.toLowerCase();
223
+ const results = [];
224
+ for (const record of records) {
225
+ if (record.type.toUpperCase() !== "TXT") {
226
+ results.push({
227
+ purpose: record.purpose,
228
+ host: record.host,
229
+ action: "unchanged",
230
+ detail: `Skipped unsupported type ${record.type}`
231
+ });
232
+ continue;
233
+ }
234
+ const name = relativeName2(record.host, zone);
235
+ if (record.purpose === "SPF") {
236
+ const existing2 = await gd(`/v1/domains/${zone}/records/TXT/${encodeURIComponent(name)}`);
237
+ const list2 = existing2 || [];
238
+ const spfIndex = list2.findIndex((r) => isSpfRecord(r.data));
239
+ const includeHost = record.value.match(/include:([^\s]+)/i)?.[1] || "spf.supersendtx.com";
240
+ const currentSpf = spfIndex >= 0 ? list2[spfIndex].data : null;
241
+ const merged = mergeSpfInclude(currentSpf, includeHost);
242
+ if (spfIndex >= 0 && list2[spfIndex].data.trim() === merged.trim()) {
243
+ results.push({
244
+ purpose: record.purpose,
245
+ host: record.host,
246
+ action: "unchanged",
247
+ detail: "SPF already includes SuperSend TX"
248
+ });
249
+ continue;
250
+ }
251
+ const next2 = list2.map(
252
+ (r, i) => i === spfIndex ? { type: "TXT", name, data: merged, ttl: Math.max(r.ttl || 600, 600) } : r
253
+ );
254
+ if (spfIndex < 0) {
255
+ next2.push({ type: "TXT", name, data: merged, ttl: 600 });
256
+ }
257
+ await gd(`/v1/domains/${zone}/records/TXT/${encodeURIComponent(name)}`, {
258
+ method: "PUT",
259
+ body: JSON.stringify(next2.map(({ type, name: n, data, ttl }) => ({ type, name: n, data, ttl })))
260
+ });
261
+ results.push({
262
+ purpose: record.purpose,
263
+ host: record.host,
264
+ action: spfIndex >= 0 ? "merged" : "created",
265
+ detail: merged
266
+ });
267
+ continue;
268
+ }
269
+ const existing = await gd(`/v1/domains/${zone}/records/TXT/${encodeURIComponent(name)}`);
270
+ const list = existing || [];
271
+ const exact = list.find((r) => r.data === record.value);
272
+ if (exact) {
273
+ results.push({ purpose: record.purpose, host: record.host, action: "unchanged" });
274
+ continue;
275
+ }
276
+ const replaceableIndex = list.findIndex((r) => {
277
+ if (record.purpose === "Domain ownership") return r.data.includes("supersendtx-verify=");
278
+ if (record.purpose === "DKIM") return r.data.toLowerCase().includes("v=dkim1");
279
+ if (record.purpose === "DMARC") return r.data.toLowerCase().includes("v=dmarc1");
280
+ return false;
281
+ });
282
+ const next = replaceableIndex >= 0 ? list.map(
283
+ (r, i) => i === replaceableIndex ? { type: "TXT", name, data: record.value, ttl: Math.max(r.ttl || 600, 600) } : r
284
+ ) : [...list, { type: "TXT", name, data: record.value, ttl: 600 }];
285
+ await gd(`/v1/domains/${zone}/records/TXT/${encodeURIComponent(name)}`, {
286
+ method: "PUT",
287
+ body: JSON.stringify(next.map(({ type, name: n, data, ttl }) => ({ type, name: n, data, ttl })))
288
+ });
289
+ results.push({
290
+ purpose: record.purpose,
291
+ host: record.host,
292
+ action: replaceableIndex >= 0 ? "updated" : "created"
293
+ });
294
+ }
295
+ return results;
296
+ }
297
+ };
298
+ }
299
+
300
+ // src/dns/apply.ts
301
+ function createDnsProvider(provider, credentials, fetchImpl) {
302
+ if (provider === "cloudflare") {
303
+ const token = credentials.cloudflareApiToken;
304
+ if (!token) {
305
+ throw new Error("Missing Cloudflare token. Set CLOUDFLARE_API_TOKEN or pass cloudflareApiToken.");
306
+ }
307
+ return createCloudflareProvider({
308
+ apiToken: token,
309
+ zoneId: credentials.cloudflareZoneId,
310
+ fetchImpl
311
+ });
312
+ }
313
+ if (provider === "godaddy") {
314
+ const apiKey = credentials.godaddyApiKey;
315
+ const apiSecret = credentials.godaddyApiSecret;
316
+ if (!apiKey || !apiSecret) {
317
+ throw new Error(
318
+ "Missing GoDaddy credentials. Set GODADDY_API_KEY and GODADDY_API_SECRET."
319
+ );
320
+ }
321
+ return createGoDaddyProvider({ apiKey, apiSecret, fetchImpl });
322
+ }
323
+ throw new Error(`Unsupported DNS provider: ${provider}`);
324
+ }
325
+ async function applyDnsRecords(input) {
326
+ const provider = createDnsProvider(input.provider, input.credentials, input.fetchImpl);
327
+ const results = await provider.apply(input.domain, input.records);
328
+ return {
329
+ provider: input.provider,
330
+ domain: input.domain,
331
+ results
332
+ };
333
+ }
334
+
335
+ // src/domains.ts
336
+ var DomainsResource = class {
337
+ constructor(client) {
338
+ this.client = client;
339
+ }
340
+ async list() {
341
+ return this.client.request("/domains", { method: "GET" });
342
+ }
343
+ async get(idOrName) {
344
+ return this.client.request(`/domains/${encodeURIComponent(idOrName)}`, { method: "GET" });
345
+ }
346
+ async create(params) {
347
+ return this.client.request("/domains", {
348
+ method: "POST",
349
+ body: JSON.stringify(params)
350
+ });
351
+ }
352
+ async verify(idOrName) {
353
+ return this.client.request(`/domains/${encodeURIComponent(idOrName)}`, {
354
+ method: "POST",
355
+ body: JSON.stringify({ action: "verify" })
356
+ });
357
+ }
358
+ async delete(idOrName) {
359
+ return this.client.request(`/domains/${encodeURIComponent(idOrName)}`, {
360
+ method: "DELETE"
361
+ });
362
+ }
363
+ /**
364
+ * Fetch SuperSend TX DNS records, then write them at Cloudflare or GoDaddy
365
+ * using the caller's provider credentials (never sent to SuperSend TX).
366
+ */
367
+ async applyDns(params) {
368
+ const { domain, records } = await this.get(params.idOrName);
369
+ return applyDnsRecords({
370
+ provider: params.provider,
371
+ domain: domain.name,
372
+ records,
373
+ credentials: {
374
+ cloudflareApiToken: params.cloudflareApiToken,
375
+ cloudflareZoneId: params.cloudflareZoneId,
376
+ godaddyApiKey: params.godaddyApiKey,
377
+ godaddyApiSecret: params.godaddyApiSecret
378
+ },
379
+ fetchImpl: this.client.fetchImpl
380
+ });
12
381
  }
13
382
  };
14
383
 
@@ -70,6 +439,7 @@ var SuperSendTX = class {
70
439
  if (!apiKey?.startsWith("stx_")) {
71
440
  throw new Error("SuperSend TX API key must start with stx_");
72
441
  }
442
+ this.domains = new DomainsResource(this);
73
443
  this.emails = new EmailsResource(this);
74
444
  this.webhooks = new WebhooksResource(this);
75
445
  }
@@ -98,5 +468,7 @@ var SuperSendTX = class {
98
468
  export {
99
469
  DEFAULT_API_BASE_URL,
100
470
  SuperSendTX,
101
- SuperSendTXError
471
+ SuperSendTXError,
472
+ applyDnsRecords,
473
+ mergeSpfInclude
102
474
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supersendtx",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "SuperSend TX transactional email API client",
5
5
  "license": "MIT",
6
6
  "type": "module",