@sendora/sdk 1.1.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/README.md +364 -0
- package/dist/base64.d.ts +3 -0
- package/dist/base64.d.ts.map +1 -0
- package/dist/base64.js +17 -0
- package/dist/base64.js.map +1 -0
- package/dist/broadcasts.d.ts +60 -0
- package/dist/broadcasts.d.ts.map +1 -0
- package/dist/broadcasts.js +98 -0
- package/dist/broadcasts.js.map +1 -0
- package/dist/client.d.ts +51 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +69 -0
- package/dist/client.js.map +1 -0
- package/dist/domains.d.ts +34 -0
- package/dist/domains.d.ts.map +1 -0
- package/dist/domains.js +70 -0
- package/dist/domains.js.map +1 -0
- package/dist/email.d.ts +40 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +68 -0
- package/dist/email.js.map +1 -0
- package/dist/error.d.ts +67 -0
- package/dist/error.d.ts.map +1 -0
- package/dist/error.js +173 -0
- package/dist/error.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/messages.d.ts +37 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +68 -0
- package/dist/messages.js.map +1 -0
- package/dist/pagination.d.ts +3 -0
- package/dist/pagination.d.ts.map +1 -0
- package/dist/pagination.js +14 -0
- package/dist/pagination.js.map +1 -0
- package/dist/retry.d.ts +6 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +14 -0
- package/dist/retry.js.map +1 -0
- package/dist/streams.d.ts +45 -0
- package/dist/streams.d.ts.map +1 -0
- package/dist/streams.js +82 -0
- package/dist/streams.js.map +1 -0
- package/dist/suppressions.d.ts +37 -0
- package/dist/suppressions.d.ts.map +1 -0
- package/dist/suppressions.js +56 -0
- package/dist/suppressions.js.map +1 -0
- package/dist/tokens.d.ts +28 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +57 -0
- package/dist/tokens.js.map +1 -0
- package/dist/transport.d.ts +34 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +146 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +802 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/webhook-verify.d.ts +33 -0
- package/dist/webhook-verify.d.ts.map +1 -0
- package/dist/webhook-verify.js +115 -0
- package/dist/webhook-verify.js.map +1 -0
- package/dist/webhooks.d.ts +63 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +118 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +49 -0
- package/skills/sendora/SKILL.md +86 -0
- package/src/base64.ts +17 -0
- package/src/broadcasts.ts +115 -0
- package/src/client.ts +88 -0
- package/src/domains.ts +84 -0
- package/src/email.ts +83 -0
- package/src/error.ts +252 -0
- package/src/index.ts +16 -0
- package/src/messages.ts +88 -0
- package/src/pagination.ts +18 -0
- package/src/retry.ts +20 -0
- package/src/streams.ts +100 -0
- package/src/suppressions.ts +73 -0
- package/src/tokens.ts +70 -0
- package/src/transport.ts +179 -0
- package/src/types.ts +848 -0
- package/src/version.ts +2 -0
- package/src/webhook-verify.ts +147 -0
- package/src/webhooks.ts +157 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { encodeAttachment } from './email.ts';
|
|
2
|
+
import type { Transport } from './transport.ts';
|
|
3
|
+
import type {
|
|
4
|
+
AcceptedBroadcast,
|
|
5
|
+
Broadcast,
|
|
6
|
+
BroadcastList,
|
|
7
|
+
BroadcastPage,
|
|
8
|
+
RequestOptions,
|
|
9
|
+
SendBroadcastBody,
|
|
10
|
+
SendBroadcastRequest,
|
|
11
|
+
SendOptions,
|
|
12
|
+
} from './types.ts';
|
|
13
|
+
|
|
14
|
+
/** One message to many, on a broadcast stream of the token's server: sent as a whole, followed as a whole. */
|
|
15
|
+
export class BroadcastsResource {
|
|
16
|
+
readonly #transport: Transport;
|
|
17
|
+
|
|
18
|
+
constructor(transport: Transport) {
|
|
19
|
+
this.#transport = transport;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stores the content once and one message per entry of `messages`, up to
|
|
24
|
+
* 50,000, on a broadcast stream. Every message is a message in the log
|
|
25
|
+
* with its own events and webhooks, sent with the recipient's own
|
|
26
|
+
* unsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the
|
|
27
|
+
* stream's suppression list are dropped and counted in `suppressed`; a
|
|
28
|
+
* list with nothing left throws `recipient_suppressed`. A larger list is
|
|
29
|
+
* several broadcasts under one tag. The SDK sets an idempotency key, so a
|
|
30
|
+
* retry answers the same broadcast; pass your own to make a retry from
|
|
31
|
+
* your side safe too.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const { broadcastId, total } = await sendora.broadcasts.send({
|
|
35
|
+
* streamId,
|
|
36
|
+
* from: { email: 'nyheter@example.se', name: 'Example AB' },
|
|
37
|
+
* subject: 'Nyheter i oktober',
|
|
38
|
+
* text: 'Hej!\n\nHär är månadens nyheter.\n\nVill du inte ha fler? {{ unsubscribe_url }}',
|
|
39
|
+
* messages: recipients.map((email) => ({ to: [email] })),
|
|
40
|
+
* });
|
|
41
|
+
*/
|
|
42
|
+
send(request: SendBroadcastRequest, options: SendOptions = {}): Promise<AcceptedBroadcast> {
|
|
43
|
+
return this.#transport.request<AcceptedBroadcast>({
|
|
44
|
+
method: 'POST',
|
|
45
|
+
path: '/v1/broadcasts',
|
|
46
|
+
body: encodeBroadcastRequest(request),
|
|
47
|
+
headers: { 'idempotency-key': options.idempotencyKey ?? crypto.randomUUID() },
|
|
48
|
+
idempotent: true,
|
|
49
|
+
signal: options.signal,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The broadcast and how far it has come: `released` and `failed` move
|
|
55
|
+
* as the worker hands its messages over, and `status` is `completed`
|
|
56
|
+
* once nothing is left.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* const broadcast = await sendora.broadcasts.get(broadcastId);
|
|
60
|
+
* console.log(`${broadcast.released} of ${broadcast.total} released`);
|
|
61
|
+
*/
|
|
62
|
+
get(broadcastId: string, options: RequestOptions = {}): Promise<Broadcast> {
|
|
63
|
+
return this.#transport.request<Broadcast>({
|
|
64
|
+
method: 'GET',
|
|
65
|
+
path: `/v1/broadcasts/${encodeURIComponent(broadcastId)}`,
|
|
66
|
+
idempotent: true,
|
|
67
|
+
signal: options.signal,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One page of the server's broadcasts, newest first; pass the page's
|
|
73
|
+
* `next` as `after` for the following one.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* const { broadcasts } = await sendora.broadcasts.list({ limit: 20 });
|
|
77
|
+
*/
|
|
78
|
+
list(query: BroadcastList = {}, options: RequestOptions = {}): Promise<BroadcastPage> {
|
|
79
|
+
return this.#transport.request<BroadcastPage>({
|
|
80
|
+
method: 'GET',
|
|
81
|
+
path: '/v1/broadcasts',
|
|
82
|
+
query: { limit: query.limit, after: query.after },
|
|
83
|
+
idempotent: true,
|
|
84
|
+
signal: options.signal,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Holds every message of the broadcast that has not been handed to the
|
|
90
|
+
* mail server yet; those count as `failed`. A message already on its way
|
|
91
|
+
* is delivered. A completed or cancelled broadcast throws
|
|
92
|
+
* `broadcast_not_open`.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* const cancelled = await sendora.broadcasts.cancel(broadcastId);
|
|
96
|
+
* console.log(`${cancelled.failed} messages held back`);
|
|
97
|
+
*/
|
|
98
|
+
cancel(broadcastId: string, options: RequestOptions = {}): Promise<Broadcast> {
|
|
99
|
+
return this.#transport.request<Broadcast>({
|
|
100
|
+
method: 'POST',
|
|
101
|
+
path: `/v1/broadcasts/${encodeURIComponent(broadcastId)}/cancel`,
|
|
102
|
+
idempotent: true,
|
|
103
|
+
signal: options.signal,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The body as the API takes it: attachment bytes become base64, everything else is passed through. */
|
|
109
|
+
export function encodeBroadcastRequest(request: SendBroadcastRequest): SendBroadcastBody {
|
|
110
|
+
const { attachments, ...fields } = request;
|
|
111
|
+
if (attachments === undefined) {
|
|
112
|
+
return fields;
|
|
113
|
+
}
|
|
114
|
+
return { ...fields, attachments: attachments.map(encodeAttachment) };
|
|
115
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { DomainsResource } from './domains.ts';
|
|
2
|
+
import { BroadcastsResource } from './broadcasts.ts';
|
|
3
|
+
import { EmailResource } from './email.ts';
|
|
4
|
+
import { MessagesResource } from './messages.ts';
|
|
5
|
+
import { DEFAULT_MAX_RETRIES } from './retry.ts';
|
|
6
|
+
import { StreamsResource } from './streams.ts';
|
|
7
|
+
import { SuppressionsResource } from './suppressions.ts';
|
|
8
|
+
import { TokensResource } from './tokens.ts';
|
|
9
|
+
import { Transport } from './transport.ts';
|
|
10
|
+
import { SDK_VERSION } from './version.ts';
|
|
11
|
+
import { WebhooksResource } from './webhooks.ts';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_BASE_URL = 'https://api.sendora.se';
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
export interface SendoraOptions {
|
|
17
|
+
/**
|
|
18
|
+
* A server's API token, created in the dashboard. Keep it on the server;
|
|
19
|
+
* never ship it to a browser. `undefined`, as an unset environment
|
|
20
|
+
* variable gives, throws at construction rather than at the first call.
|
|
21
|
+
*/
|
|
22
|
+
token: string | undefined;
|
|
23
|
+
/** The API's origin; https://api.sendora.se unless you test against another. */
|
|
24
|
+
baseUrl?: string | undefined;
|
|
25
|
+
/** The fetch to use; the global one unless you need a proxy or a fake. */
|
|
26
|
+
fetch?: typeof fetch | undefined;
|
|
27
|
+
/** How long one attempt may take; 30 seconds by default. */
|
|
28
|
+
timeoutMs?: number | undefined;
|
|
29
|
+
/** How many times a failed call is repeated when repeating is safe; 2 by default, 0 turns retries off. */
|
|
30
|
+
maxRetries?: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The client. One instance per server token; every resource hangs off it.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* import { Sendora } from '@sendora/sdk';
|
|
38
|
+
*
|
|
39
|
+
* const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });
|
|
40
|
+
* const { messageId } = await sendora.email.send({
|
|
41
|
+
* from: 'no-reply@example.se',
|
|
42
|
+
* to: ['anna@example.com'],
|
|
43
|
+
* subject: 'Välkommen',
|
|
44
|
+
* text: 'Hej Anna!',
|
|
45
|
+
* });
|
|
46
|
+
*/
|
|
47
|
+
export class Sendora {
|
|
48
|
+
readonly email: EmailResource;
|
|
49
|
+
readonly broadcasts: BroadcastsResource;
|
|
50
|
+
readonly messages: MessagesResource;
|
|
51
|
+
readonly streams: StreamsResource;
|
|
52
|
+
readonly suppressions: SuppressionsResource;
|
|
53
|
+
readonly tokens: TokensResource;
|
|
54
|
+
readonly webhooks: WebhooksResource;
|
|
55
|
+
readonly domains: DomainsResource;
|
|
56
|
+
|
|
57
|
+
constructor(options: SendoraOptions) {
|
|
58
|
+
if (options.token === undefined || options.token.trim() === '') {
|
|
59
|
+
throw new TypeError('Sendora needs a server API token; create one in the dashboard.');
|
|
60
|
+
}
|
|
61
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
62
|
+
new URL(baseUrl);
|
|
63
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
64
|
+
if (!(timeoutMs > 0)) {
|
|
65
|
+
throw new TypeError('timeoutMs must be a positive number of milliseconds.');
|
|
66
|
+
}
|
|
67
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
68
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
|
|
69
|
+
throw new TypeError('maxRetries must be a whole number of zero or more.');
|
|
70
|
+
}
|
|
71
|
+
const transport = new Transport({
|
|
72
|
+
baseUrl,
|
|
73
|
+
token: options.token,
|
|
74
|
+
fetch: options.fetch ?? fetch,
|
|
75
|
+
timeoutMs,
|
|
76
|
+
maxRetries,
|
|
77
|
+
userAgent: `sendora-sdk/${SDK_VERSION}`,
|
|
78
|
+
});
|
|
79
|
+
this.email = new EmailResource(transport);
|
|
80
|
+
this.broadcasts = new BroadcastsResource(transport);
|
|
81
|
+
this.messages = new MessagesResource(transport);
|
|
82
|
+
this.streams = new StreamsResource(transport);
|
|
83
|
+
this.suppressions = new SuppressionsResource(transport);
|
|
84
|
+
this.tokens = new TokensResource(transport);
|
|
85
|
+
this.webhooks = new WebhooksResource(transport);
|
|
86
|
+
this.domains = new DomainsResource(transport);
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/domains.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Transport } from './transport.ts';
|
|
2
|
+
import type {
|
|
3
|
+
CreateDomainRequest,
|
|
4
|
+
DomainList,
|
|
5
|
+
RequestOptions,
|
|
6
|
+
SendingDomain,
|
|
7
|
+
VerifiedDomain,
|
|
8
|
+
} from './types.ts';
|
|
9
|
+
|
|
10
|
+
/** The account's sending domains and the two DNS records each one needs. Any server's token manages them. */
|
|
11
|
+
export class DomainsResource {
|
|
12
|
+
readonly #transport: Transport;
|
|
13
|
+
|
|
14
|
+
constructor(transport: Transport) {
|
|
15
|
+
this.#transport = transport;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Adds a domain with its own DKIM key and answers the two records to
|
|
20
|
+
* create: a CNAME for the return path and a TXT record for the DKIM key.
|
|
21
|
+
* Mail may go out once both are seen. `domain_exists` carries the id of
|
|
22
|
+
* the domain the account already has as `existingId`.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* const domain = await sendora.domains.create({ domain: 'example.se' });
|
|
26
|
+
* console.log(domain.returnPath.host, domain.returnPath.value);
|
|
27
|
+
* console.log(domain.dkim.host, domain.dkim.value);
|
|
28
|
+
*/
|
|
29
|
+
create(request: CreateDomainRequest, options: RequestOptions = {}): Promise<SendingDomain> {
|
|
30
|
+
return this.#transport.request<SendingDomain>({
|
|
31
|
+
method: 'POST',
|
|
32
|
+
path: '/v1/domains',
|
|
33
|
+
body: request,
|
|
34
|
+
idempotent: false,
|
|
35
|
+
signal: options.signal,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every sending domain of the account. */
|
|
40
|
+
list(options: RequestOptions = {}): Promise<DomainList> {
|
|
41
|
+
return this.#transport.request<DomainList>({
|
|
42
|
+
method: 'GET',
|
|
43
|
+
path: '/v1/domains',
|
|
44
|
+
idempotent: true,
|
|
45
|
+
signal: options.signal,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One domain by id, with the state of its records. */
|
|
50
|
+
get(domainId: string, options: RequestOptions = {}): Promise<SendingDomain> {
|
|
51
|
+
return this.#transport.request<SendingDomain>({
|
|
52
|
+
method: 'GET',
|
|
53
|
+
path: `/v1/domains/${encodeURIComponent(domainId)}`,
|
|
54
|
+
idempotent: true,
|
|
55
|
+
signal: options.signal,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Looks both records up now and answers the domain as it stands plus
|
|
61
|
+
* what each lookup found: `ok`, `missing`, `mismatch` or `dns_error`.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* const { verified, check } = await sendora.domains.verify(domainId);
|
|
65
|
+
*/
|
|
66
|
+
verify(domainId: string, options: RequestOptions = {}): Promise<VerifiedDomain> {
|
|
67
|
+
return this.#transport.request<VerifiedDomain>({
|
|
68
|
+
method: 'POST',
|
|
69
|
+
path: `/v1/domains/${encodeURIComponent(domainId)}/verify`,
|
|
70
|
+
idempotent: true,
|
|
71
|
+
signal: options.signal,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Removes the domain; mail from it is refused from then on and its DKIM key is gone. */
|
|
76
|
+
delete(domainId: string, options: RequestOptions = {}): Promise<void> {
|
|
77
|
+
return this.#transport.request<undefined>({
|
|
78
|
+
method: 'DELETE',
|
|
79
|
+
path: `/v1/domains/${encodeURIComponent(domainId)}`,
|
|
80
|
+
idempotent: true,
|
|
81
|
+
signal: options.signal,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { toBase64 } from './base64.ts';
|
|
2
|
+
import type { Transport } from './transport.ts';
|
|
3
|
+
import type {
|
|
4
|
+
AcceptedEmail,
|
|
5
|
+
Attachment,
|
|
6
|
+
BatchResult,
|
|
7
|
+
EncodedAttachment,
|
|
8
|
+
SendEmailBody,
|
|
9
|
+
SendEmailRequest,
|
|
10
|
+
SendOptions,
|
|
11
|
+
} from './types.ts';
|
|
12
|
+
|
|
13
|
+
/** Hands messages over for delivery, one at a time or up to 100 in a batch. */
|
|
14
|
+
export class EmailResource {
|
|
15
|
+
readonly #transport: Transport;
|
|
16
|
+
|
|
17
|
+
constructor(transport: Transport) {
|
|
18
|
+
this.#transport = transport;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sends one message and answers at once with its id. Delivery, deferral,
|
|
23
|
+
* bounce and complaint arrive later as events on the message and as
|
|
24
|
+
* webhooks. Every send carries an idempotency key, yours or a random one,
|
|
25
|
+
* so the SDK's retries can never send twice.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const { messageId } = await sendora.email.send({
|
|
29
|
+
* from: { email: 'no-reply@example.se', name: 'Example AB' },
|
|
30
|
+
* to: ['anna@example.com'],
|
|
31
|
+
* subject: 'Din faktura för september',
|
|
32
|
+
* text: 'Hej Anna, fakturan finns bifogad.',
|
|
33
|
+
* attachments: [{ name: 'faktura.pdf', content: pdfBytes, contentType: 'application/pdf' }],
|
|
34
|
+
* tag: 'invoice',
|
|
35
|
+
* });
|
|
36
|
+
*/
|
|
37
|
+
send(request: SendEmailRequest, options: SendOptions = {}): Promise<AcceptedEmail> {
|
|
38
|
+
return this.#transport.request<AcceptedEmail>({
|
|
39
|
+
method: 'POST',
|
|
40
|
+
path: '/v1/email',
|
|
41
|
+
body: encodeSendRequest(request),
|
|
42
|
+
headers: { 'idempotency-key': options.idempotencyKey ?? crypto.randomUUID() },
|
|
43
|
+
idempotent: true,
|
|
44
|
+
signal: options.signal,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sends up to 100 messages and answers one result per message, in order:
|
|
50
|
+
* accepted with its id, or refused with the same error a single send
|
|
51
|
+
* would give. The batch as a whole never fails halfway, so read
|
|
52
|
+
* `results[i].status`. A retry under the same key sends nothing twice:
|
|
53
|
+
* items accepted before come back with `replayed` set.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* const { results } = await sendora.email.sendBatch(messages, { idempotencyKey: 'invoices-2026-09' });
|
|
57
|
+
* const refused = results.filter((result) => result.status === 'error');
|
|
58
|
+
*/
|
|
59
|
+
sendBatch(messages: SendEmailRequest[], options: SendOptions = {}): Promise<BatchResult> {
|
|
60
|
+
return this.#transport.request<BatchResult>({
|
|
61
|
+
method: 'POST',
|
|
62
|
+
path: '/v1/email/batch',
|
|
63
|
+
body: messages.map(encodeSendRequest),
|
|
64
|
+
headers: { 'idempotency-key': options.idempotencyKey ?? crypto.randomUUID() },
|
|
65
|
+
idempotent: true,
|
|
66
|
+
signal: options.signal,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The body as the API takes it: attachment bytes become base64, everything else is passed through. */
|
|
72
|
+
export function encodeSendRequest(request: SendEmailRequest): SendEmailBody {
|
|
73
|
+
const { attachments, ...fields } = request;
|
|
74
|
+
if (attachments === undefined) {
|
|
75
|
+
return fields;
|
|
76
|
+
}
|
|
77
|
+
return { ...fields, attachments: attachments.map(encodeAttachment) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function encodeAttachment(attachment: Attachment): EncodedAttachment {
|
|
81
|
+
const { content, ...fields } = attachment;
|
|
82
|
+
return { ...fields, content: typeof content === 'string' ? content : toBase64(content) };
|
|
83
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { LimitScope, SuppressedRecipient, ValidationIssue } from './types.ts';
|
|
2
|
+
|
|
3
|
+
/** Every code the API answers, the three the SDK raises when it never got a proper answer, and the two of the webhook verifier. */
|
|
4
|
+
export type SendoraErrorCode =
|
|
5
|
+
| 'invalid_request'
|
|
6
|
+
| 'unauthorized'
|
|
7
|
+
| 'payment_required'
|
|
8
|
+
| 'tenant_paused'
|
|
9
|
+
| 'tenant_not_active'
|
|
10
|
+
| 'spam_complaint_locked'
|
|
11
|
+
| 'unsubscribe_locked'
|
|
12
|
+
| 'not_found'
|
|
13
|
+
| 'domain_exists'
|
|
14
|
+
| 'webhook_exists'
|
|
15
|
+
| 'stream_exists'
|
|
16
|
+
| 'inbound_stream_exists'
|
|
17
|
+
| 'default_stream'
|
|
18
|
+
| 'last_token'
|
|
19
|
+
| 'request_too_large'
|
|
20
|
+
| 'from_domain_not_verified'
|
|
21
|
+
| 'stream_not_found'
|
|
22
|
+
| 'stream_archived'
|
|
23
|
+
| 'stream_paused'
|
|
24
|
+
| 'stream_not_sendable'
|
|
25
|
+
| 'stream_not_broadcast'
|
|
26
|
+
| 'broadcast_not_enabled'
|
|
27
|
+
| 'broadcast_not_open'
|
|
28
|
+
| 'substitution_missing'
|
|
29
|
+
| 'unsubscribe_placeholder_missing'
|
|
30
|
+
| 'list_unsubscribe_reserved'
|
|
31
|
+
| 'recipient_suppressed'
|
|
32
|
+
| 'idempotency_key_mismatch'
|
|
33
|
+
| 'idempotency_key_required'
|
|
34
|
+
| 'rate_limited'
|
|
35
|
+
| 'monthly_cap_reached'
|
|
36
|
+
| 'sending_disabled'
|
|
37
|
+
| 'connection_failed'
|
|
38
|
+
| 'timeout'
|
|
39
|
+
| 'unexpected_response'
|
|
40
|
+
| 'invalid_signature'
|
|
41
|
+
| 'stale_signature';
|
|
42
|
+
|
|
43
|
+
const apiCodes: ReadonlySet<string> = new Set<SendoraErrorCode>([
|
|
44
|
+
'invalid_request',
|
|
45
|
+
'unauthorized',
|
|
46
|
+
'payment_required',
|
|
47
|
+
'tenant_paused',
|
|
48
|
+
'tenant_not_active',
|
|
49
|
+
'spam_complaint_locked',
|
|
50
|
+
'unsubscribe_locked',
|
|
51
|
+
'not_found',
|
|
52
|
+
'domain_exists',
|
|
53
|
+
'webhook_exists',
|
|
54
|
+
'stream_exists',
|
|
55
|
+
'inbound_stream_exists',
|
|
56
|
+
'default_stream',
|
|
57
|
+
'last_token',
|
|
58
|
+
'request_too_large',
|
|
59
|
+
'from_domain_not_verified',
|
|
60
|
+
'stream_not_found',
|
|
61
|
+
'stream_archived',
|
|
62
|
+
'stream_paused',
|
|
63
|
+
'stream_not_sendable',
|
|
64
|
+
'stream_not_broadcast',
|
|
65
|
+
'broadcast_not_enabled',
|
|
66
|
+
'broadcast_not_open',
|
|
67
|
+
'substitution_missing',
|
|
68
|
+
'unsubscribe_placeholder_missing',
|
|
69
|
+
'list_unsubscribe_reserved',
|
|
70
|
+
'recipient_suppressed',
|
|
71
|
+
'idempotency_key_mismatch',
|
|
72
|
+
'idempotency_key_required',
|
|
73
|
+
'rate_limited',
|
|
74
|
+
'monthly_cap_reached',
|
|
75
|
+
'sending_disabled',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export interface SendoraErrorFields {
|
|
79
|
+
code: SendoraErrorCode;
|
|
80
|
+
message: string;
|
|
81
|
+
status: number | null;
|
|
82
|
+
retryAfter?: number | null;
|
|
83
|
+
scope?: LimitScope | null;
|
|
84
|
+
limit?: number | null;
|
|
85
|
+
cap?: number | null;
|
|
86
|
+
used?: number | null;
|
|
87
|
+
resetsAt?: string | null;
|
|
88
|
+
issues?: ValidationIssue[];
|
|
89
|
+
suppressed?: SuppressedRecipient[];
|
|
90
|
+
streamId?: string | null;
|
|
91
|
+
existingId?: string | null;
|
|
92
|
+
cause?: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What every failed call throws. Narrow on `code`; the other fields are
|
|
97
|
+
* filled where the API gives them and null or empty otherwise. Neither the
|
|
98
|
+
* token nor the request body is ever part of it.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* try {
|
|
102
|
+
* await sendora.email.send(message);
|
|
103
|
+
* } catch (error) {
|
|
104
|
+
* if (error instanceof SendoraError && error.code === 'recipient_suppressed') {
|
|
105
|
+
* console.log(error.suppressed.map((entry) => entry.address));
|
|
106
|
+
* } else {
|
|
107
|
+
* throw error;
|
|
108
|
+
* }
|
|
109
|
+
* }
|
|
110
|
+
*/
|
|
111
|
+
export class SendoraError extends Error {
|
|
112
|
+
override readonly name = 'SendoraError';
|
|
113
|
+
readonly code: SendoraErrorCode;
|
|
114
|
+
/** The HTTP status, or null when no answer arrived. */
|
|
115
|
+
readonly status: number | null;
|
|
116
|
+
/** Seconds to wait before trying again, when the API said so. */
|
|
117
|
+
readonly retryAfter: number | null;
|
|
118
|
+
/** Whose limit or cap it is, on rate_limited and monthly_cap_reached. */
|
|
119
|
+
readonly scope: LimitScope | null;
|
|
120
|
+
/** Emails per minute allowed for that scope, on rate_limited. */
|
|
121
|
+
readonly limit: number | null;
|
|
122
|
+
/** The monthly cap and what was used of it, on monthly_cap_reached. */
|
|
123
|
+
readonly cap: number | null;
|
|
124
|
+
readonly used: number | null;
|
|
125
|
+
/** When the month counter resets, on monthly_cap_reached. */
|
|
126
|
+
readonly resetsAt: string | null;
|
|
127
|
+
/** One entry per invalid field, on invalid_request. */
|
|
128
|
+
readonly issues: ValidationIssue[];
|
|
129
|
+
/** The recipients the server refused, on recipient_suppressed. */
|
|
130
|
+
readonly suppressed: SuppressedRecipient[];
|
|
131
|
+
/** The stream whose suppression list refused the send, on recipient_suppressed. */
|
|
132
|
+
readonly streamId: string | null;
|
|
133
|
+
/** The id of the domain, webhook or stream that already exists, on domain_exists, webhook_exists and stream_exists. */
|
|
134
|
+
readonly existingId: string | null;
|
|
135
|
+
|
|
136
|
+
constructor(fields: SendoraErrorFields) {
|
|
137
|
+
super(fields.message, { cause: fields.cause });
|
|
138
|
+
this.code = fields.code;
|
|
139
|
+
this.status = fields.status;
|
|
140
|
+
this.retryAfter = fields.retryAfter ?? null;
|
|
141
|
+
this.scope = fields.scope ?? null;
|
|
142
|
+
this.limit = fields.limit ?? null;
|
|
143
|
+
this.cap = fields.cap ?? null;
|
|
144
|
+
this.used = fields.used ?? null;
|
|
145
|
+
this.resetsAt = fields.resetsAt ?? null;
|
|
146
|
+
this.issues = fields.issues ?? [];
|
|
147
|
+
this.suppressed = fields.suppressed ?? [];
|
|
148
|
+
this.streamId = fields.streamId ?? null;
|
|
149
|
+
this.existingId = fields.existingId ?? null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** True when waiting and calling again could succeed: a rate limit, sending paused for everyone, a lost connection, a timeout, or a server-side failure. */
|
|
153
|
+
get retryable(): boolean {
|
|
154
|
+
return (
|
|
155
|
+
this.code === 'rate_limited' ||
|
|
156
|
+
this.code === 'sending_disabled' ||
|
|
157
|
+
this.code === 'connection_failed' ||
|
|
158
|
+
this.code === 'timeout' ||
|
|
159
|
+
(this.status !== null && this.status >= 500)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
toJSON(): Record<string, unknown> {
|
|
164
|
+
return {
|
|
165
|
+
name: this.name,
|
|
166
|
+
code: this.code,
|
|
167
|
+
status: this.status,
|
|
168
|
+
message: this.message,
|
|
169
|
+
retryAfter: this.retryAfter,
|
|
170
|
+
scope: this.scope,
|
|
171
|
+
limit: this.limit,
|
|
172
|
+
cap: this.cap,
|
|
173
|
+
used: this.used,
|
|
174
|
+
resetsAt: this.resetsAt,
|
|
175
|
+
issues: this.issues,
|
|
176
|
+
suppressed: this.suppressed,
|
|
177
|
+
streamId: this.streamId,
|
|
178
|
+
existingId: this.existingId,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The error a non-2xx answer amounts to; an answer without a known code is `unexpected_response`. */
|
|
184
|
+
export function errorFromAnswer(
|
|
185
|
+
status: number,
|
|
186
|
+
body: unknown,
|
|
187
|
+
retryAfterHeader: string | null,
|
|
188
|
+
): SendoraError {
|
|
189
|
+
const answer = isRecord(body) ? body : {};
|
|
190
|
+
const code = knownCode(answer.error);
|
|
191
|
+
const issues = Array.isArray(answer.issues) ? answer.issues.filter(isIssue) : [];
|
|
192
|
+
const message =
|
|
193
|
+
typeof answer.message === 'string'
|
|
194
|
+
? answer.message
|
|
195
|
+
: code === 'invalid_request'
|
|
196
|
+
? `The request is invalid: ${issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`
|
|
197
|
+
: `The API answered ${String(status)} without an error body.`;
|
|
198
|
+
return new SendoraError({
|
|
199
|
+
code,
|
|
200
|
+
message,
|
|
201
|
+
status,
|
|
202
|
+
retryAfter: integerOf(answer.retryAfter) ?? integerOf(retryAfterHeader),
|
|
203
|
+
scope: answer.scope === 'tenant' || answer.scope === 'server' ? answer.scope : null,
|
|
204
|
+
limit: integerOf(answer.limit),
|
|
205
|
+
cap: integerOf(answer.cap),
|
|
206
|
+
used: integerOf(answer.used),
|
|
207
|
+
resetsAt: typeof answer.resetsAt === 'string' ? answer.resetsAt : null,
|
|
208
|
+
issues,
|
|
209
|
+
suppressed: Array.isArray(answer.suppressed) ? answer.suppressed.filter(isSuppressed) : [],
|
|
210
|
+
streamId: code === 'recipient_suppressed' ? stringOf(answer.streamId) : null,
|
|
211
|
+
existingId:
|
|
212
|
+
stringOf(answer.domainId) ?? stringOf(answer.webhookId) ?? stringOf(answer.streamId),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function stringOf(value: unknown): string | null {
|
|
217
|
+
return typeof value === 'string' ? value : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function knownCode(value: unknown): SendoraErrorCode {
|
|
221
|
+
return typeof value === 'string' && apiCodes.has(value)
|
|
222
|
+
? (value as SendoraErrorCode)
|
|
223
|
+
: 'unexpected_response';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
227
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function isIssue(value: unknown): value is ValidationIssue {
|
|
231
|
+
return isRecord(value) && typeof value.path === 'string' && typeof value.message === 'string';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isSuppressed(value: unknown): value is SuppressedRecipient {
|
|
235
|
+
return (
|
|
236
|
+
isRecord(value) &&
|
|
237
|
+
typeof value.address === 'string' &&
|
|
238
|
+
(value.reason === 'hard_bounce' ||
|
|
239
|
+
value.reason === 'spam_complaint' ||
|
|
240
|
+
value.reason === 'manual')
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function integerOf(value: unknown): number | null {
|
|
245
|
+
if (typeof value === 'number' && Number.isInteger(value)) {
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
if (typeof value === 'string' && /^\d+$/.test(value)) {
|
|
249
|
+
return Number(value);
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { DEFAULT_BASE_URL, Sendora } from './client.ts';
|
|
2
|
+
export type { SendoraOptions } from './client.ts';
|
|
3
|
+
export type { BroadcastsResource } from './broadcasts.ts';
|
|
4
|
+
export type { DomainsResource } from './domains.ts';
|
|
5
|
+
export type { EmailResource } from './email.ts';
|
|
6
|
+
export { SendoraError } from './error.ts';
|
|
7
|
+
export type { SendoraErrorCode } from './error.ts';
|
|
8
|
+
export type { MessagesResource } from './messages.ts';
|
|
9
|
+
export type { StreamsResource } from './streams.ts';
|
|
10
|
+
export type { SuppressionsResource } from './suppressions.ts';
|
|
11
|
+
export type { TokensResource } from './tokens.ts';
|
|
12
|
+
export type * from './types.ts';
|
|
13
|
+
export { SDK_VERSION } from './version.ts';
|
|
14
|
+
export { verifyWebhook } from './webhook-verify.ts';
|
|
15
|
+
export type { VerifyWebhookInput } from './webhook-verify.ts';
|
|
16
|
+
export type { WebhooksResource } from './webhooks.ts';
|