supersendtx 0.1.0 → 0.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.
package/dist/index.d.ts CHANGED
@@ -53,13 +53,96 @@ 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
+ * Apply DNS using a Cloudflare token stored in the SuperSend TX dashboard
134
+ * (Settings → Integrations).
135
+ */
136
+ apply(idOrName: string, options?: {
137
+ provider?: 'cloudflare';
138
+ }): Promise<ApplyDnsResult$1>;
139
+ /**
140
+ * Fetch SuperSend TX DNS records, then write them at Cloudflare or GoDaddy
141
+ * using the caller's provider credentials (never sent to SuperSend TX).
142
+ */
143
+ applyDns(params: ApplyDnsParams): Promise<ApplyDnsResult$1>;
144
+ }
145
+
63
146
  declare class EmailsResource {
64
147
  private readonly client;
65
148
  constructor(client: SuperSendTX);
@@ -85,6 +168,7 @@ declare const DEFAULT_API_BASE_URL = "https://api.supersendtx.com";
85
168
  declare class SuperSendTX {
86
169
  private readonly apiKey;
87
170
  private readonly options;
171
+ readonly domains: DomainsResource;
88
172
  readonly emails: EmailsResource;
89
173
  readonly webhooks: WebhooksResource;
90
174
  constructor(apiKey: string, options?: SuperSendTXOptions);
@@ -100,4 +184,34 @@ declare class SuperSendTXError extends Error {
100
184
  static fromResponse(status: number, body: ApiErrorBody): SuperSendTXError;
101
185
  }
102
186
 
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 };
187
+ /** Merge `include:host` into an existing SPF record (or create a minimal one). */
188
+ declare function mergeSpfInclude(existing: string | null | undefined, includeHost: string): string;
189
+
190
+ type DnsProviderName = 'cloudflare' | 'godaddy';
191
+ type ApplyDnsRecordResult = {
192
+ purpose: string;
193
+ host: string;
194
+ action: 'created' | 'updated' | 'unchanged' | 'merged';
195
+ detail?: string;
196
+ };
197
+ type ApplyDnsResult = {
198
+ provider: DnsProviderName;
199
+ domain: string;
200
+ results: ApplyDnsRecordResult[];
201
+ };
202
+ type ApplyDnsCredentials = {
203
+ cloudflareApiToken?: string;
204
+ cloudflareZoneId?: string;
205
+ godaddyApiKey?: string;
206
+ godaddyApiSecret?: string;
207
+ };
208
+
209
+ declare function applyDnsRecords(input: {
210
+ provider: DnsProviderName;
211
+ domain: string;
212
+ records: DnsRecord[];
213
+ credentials: ApplyDnsCredentials;
214
+ fetchImpl?: typeof fetch;
215
+ }): Promise<ApplyDnsResult>;
216
+
217
+ 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,390 @@ 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
+ * Apply DNS using a Cloudflare token stored in the SuperSend TX dashboard
365
+ * (Settings → Integrations).
366
+ */
367
+ async apply(idOrName, options = {}) {
368
+ return this.client.request(`/domains/${encodeURIComponent(idOrName)}`, {
369
+ method: "POST",
370
+ body: JSON.stringify({
371
+ action: "apply",
372
+ provider: options.provider || "cloudflare"
373
+ })
374
+ });
375
+ }
376
+ /**
377
+ * Fetch SuperSend TX DNS records, then write them at Cloudflare or GoDaddy
378
+ * using the caller's provider credentials (never sent to SuperSend TX).
379
+ */
380
+ async applyDns(params) {
381
+ const { domain, records } = await this.get(params.idOrName);
382
+ return applyDnsRecords({
383
+ provider: params.provider,
384
+ domain: domain.name,
385
+ records,
386
+ credentials: {
387
+ cloudflareApiToken: params.cloudflareApiToken,
388
+ cloudflareZoneId: params.cloudflareZoneId,
389
+ godaddyApiKey: params.godaddyApiKey,
390
+ godaddyApiSecret: params.godaddyApiSecret
391
+ },
392
+ fetchImpl: this.client.fetchImpl
393
+ });
12
394
  }
13
395
  };
14
396
 
@@ -70,6 +452,7 @@ var SuperSendTX = class {
70
452
  if (!apiKey?.startsWith("stx_")) {
71
453
  throw new Error("SuperSend TX API key must start with stx_");
72
454
  }
455
+ this.domains = new DomainsResource(this);
73
456
  this.emails = new EmailsResource(this);
74
457
  this.webhooks = new WebhooksResource(this);
75
458
  }
@@ -98,5 +481,7 @@ var SuperSendTX = class {
98
481
  export {
99
482
  DEFAULT_API_BASE_URL,
100
483
  SuperSendTX,
101
- SuperSendTXError
484
+ SuperSendTXError,
485
+ applyDnsRecords,
486
+ mergeSpfInclude
102
487
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "supersendtx",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "SuperSend TX transactional email API client",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,9 @@
12
12
  "import": "./dist/index.js"
13
13
  }
14
14
  },
15
- "files": ["dist"],
15
+ "files": [
16
+ "dist"
17
+ ],
16
18
  "repository": {
17
19
  "type": "git",
18
20
  "url": "git+https://github.com/Super-Send/superTX-app.git",