@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.
Files changed (93) hide show
  1. package/README.md +364 -0
  2. package/dist/base64.d.ts +3 -0
  3. package/dist/base64.d.ts.map +1 -0
  4. package/dist/base64.js +17 -0
  5. package/dist/base64.js.map +1 -0
  6. package/dist/broadcasts.d.ts +60 -0
  7. package/dist/broadcasts.d.ts.map +1 -0
  8. package/dist/broadcasts.js +98 -0
  9. package/dist/broadcasts.js.map +1 -0
  10. package/dist/client.d.ts +51 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +69 -0
  13. package/dist/client.js.map +1 -0
  14. package/dist/domains.d.ts +34 -0
  15. package/dist/domains.d.ts.map +1 -0
  16. package/dist/domains.js +70 -0
  17. package/dist/domains.js.map +1 -0
  18. package/dist/email.d.ts +40 -0
  19. package/dist/email.d.ts.map +1 -0
  20. package/dist/email.js +68 -0
  21. package/dist/email.js.map +1 -0
  22. package/dist/error.d.ts +67 -0
  23. package/dist/error.d.ts.map +1 -0
  24. package/dist/error.js +173 -0
  25. package/dist/error.js.map +1 -0
  26. package/dist/index.d.ts +17 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +5 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/messages.d.ts +37 -0
  31. package/dist/messages.d.ts.map +1 -0
  32. package/dist/messages.js +68 -0
  33. package/dist/messages.js.map +1 -0
  34. package/dist/pagination.d.ts +3 -0
  35. package/dist/pagination.d.ts.map +1 -0
  36. package/dist/pagination.js +14 -0
  37. package/dist/pagination.js.map +1 -0
  38. package/dist/retry.d.ts +6 -0
  39. package/dist/retry.d.ts.map +1 -0
  40. package/dist/retry.js +14 -0
  41. package/dist/retry.js.map +1 -0
  42. package/dist/streams.d.ts +45 -0
  43. package/dist/streams.d.ts.map +1 -0
  44. package/dist/streams.js +82 -0
  45. package/dist/streams.js.map +1 -0
  46. package/dist/suppressions.d.ts +37 -0
  47. package/dist/suppressions.d.ts.map +1 -0
  48. package/dist/suppressions.js +56 -0
  49. package/dist/suppressions.js.map +1 -0
  50. package/dist/tokens.d.ts +28 -0
  51. package/dist/tokens.d.ts.map +1 -0
  52. package/dist/tokens.js +57 -0
  53. package/dist/tokens.js.map +1 -0
  54. package/dist/transport.d.ts +34 -0
  55. package/dist/transport.d.ts.map +1 -0
  56. package/dist/transport.js +146 -0
  57. package/dist/transport.js.map +1 -0
  58. package/dist/types.d.ts +802 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +2 -0
  61. package/dist/types.js.map +1 -0
  62. package/dist/version.d.ts +3 -0
  63. package/dist/version.d.ts.map +1 -0
  64. package/dist/version.js +3 -0
  65. package/dist/version.js.map +1 -0
  66. package/dist/webhook-verify.d.ts +33 -0
  67. package/dist/webhook-verify.d.ts.map +1 -0
  68. package/dist/webhook-verify.js +115 -0
  69. package/dist/webhook-verify.js.map +1 -0
  70. package/dist/webhooks.d.ts +63 -0
  71. package/dist/webhooks.d.ts.map +1 -0
  72. package/dist/webhooks.js +118 -0
  73. package/dist/webhooks.js.map +1 -0
  74. package/package.json +49 -0
  75. package/skills/sendora/SKILL.md +86 -0
  76. package/src/base64.ts +17 -0
  77. package/src/broadcasts.ts +115 -0
  78. package/src/client.ts +88 -0
  79. package/src/domains.ts +84 -0
  80. package/src/email.ts +83 -0
  81. package/src/error.ts +252 -0
  82. package/src/index.ts +16 -0
  83. package/src/messages.ts +88 -0
  84. package/src/pagination.ts +18 -0
  85. package/src/retry.ts +20 -0
  86. package/src/streams.ts +100 -0
  87. package/src/suppressions.ts +73 -0
  88. package/src/tokens.ts +70 -0
  89. package/src/transport.ts +179 -0
  90. package/src/types.ts +848 -0
  91. package/src/version.ts +2 -0
  92. package/src/webhook-verify.ts +147 -0
  93. package/src/webhooks.ts +157 -0
package/README.md ADDED
@@ -0,0 +1,364 @@
1
+ # @sendora/sdk
2
+
3
+ The official TypeScript SDK for [Sendora](https://sendora.se), transactional
4
+ email delivery hosted in Sweden. Zero dependencies. Runs on Node 20.19 or
5
+ newer and on any runtime with `fetch` and WebCrypto: Bun, Deno, Cloudflare
6
+ Workers. ESM only; CommonJS projects on Node 20.19 or 22.12 and newer load it
7
+ with `require`.
8
+
9
+ ```sh
10
+ npm install @sendora/sdk
11
+ ```
12
+
13
+ The token is a secret. Create it in the dashboard, keep it on the server,
14
+ and never ship it to a browser. The client throws at construction when the
15
+ token is missing, so build it at startup.
16
+
17
+ ```ts
18
+ import { Sendora } from '@sendora/sdk';
19
+
20
+ const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });
21
+
22
+ const { messageId } = await sendora.email.send({
23
+ from: { email: 'no-reply@example.se', name: 'Example AB' },
24
+ to: ['anna@example.com'],
25
+ subject: 'Din faktura för september',
26
+ text: 'Hej Anna, fakturan finns bifogad.',
27
+ attachments: [{ name: 'faktura.pdf', content: pdfBytes, contentType: 'application/pdf' }],
28
+ tag: 'invoice',
29
+ metadata: { invoiceId: '2026-0912' },
30
+ });
31
+ ```
32
+
33
+ `from` must be on a verified sending domain. `to`, `cc` and `bcc` take
34
+ plain addresses or objects with a display name, at most 50 in all; at least
35
+ one of `text` and `html` is required. `streamId` names the stream of the
36
+ server the message goes on; without it the default transactional stream. Attachments take bytes (`Uint8Array`,
37
+ so a Node `Buffer` too) or a base64 string. Every method takes what the
38
+ route takes and answers what the route answers; every call accepts an
39
+ `options.signal` to cancel it.
40
+
41
+ ## Sending
42
+
43
+ | Call | Answers |
44
+ | --------------------------------------------- | -------------------------------------------------------------------------- |
45
+ | `sendora.email.send(message, options?)` | `{ messageId, status: 'accepted', submittedAt }` |
46
+ | `sendora.email.sendBatch(messages, options?)` | `{ results }`, one per message in order, `accepted` with its id or `error` |
47
+
48
+ `accepted` means the message is stored and on its way to the mail server;
49
+ delivery, deferral, bounce and complaint arrive later as events on the
50
+ message and as webhooks.
51
+
52
+ `options.idempotencyKey` names the request so a retry cannot send twice.
53
+ The SDK makes a random UUID when you give none; give your own when a retry
54
+ may come from another process. The API remembers a key for 24 hours and
55
+ refuses it with a different body (`idempotency_key_mismatch`).
56
+
57
+ A batch never fails halfway. Each result is `status: 'accepted'` with the
58
+ id, or `status: 'error'` with the same `error` code a single send would
59
+ throw; a retried batch answers the items sent before with `replayed: true`.
60
+
61
+ ```ts
62
+ const { results } = await sendora.email.sendBatch(messages, { idempotencyKey: 'invoices-2026-09' });
63
+ for (const result of results) {
64
+ if (result.status === 'error') {
65
+ console.log(result.index, result.error, result.message);
66
+ }
67
+ }
68
+ ```
69
+
70
+ ## Messages
71
+
72
+ | Call | Answers |
73
+ | -------------------------------------- | --------------------------------------------------------------------------- |
74
+ | `sendora.messages.get(messageId)` | The message with its recipients, its attachments described and its timeline |
75
+ | `sendora.messages.search(filters?)` | `{ messages, next }`, one page newest first |
76
+ | `sendora.messages.searchAll(filters?)` | Every match, page by page, for `for await` |
77
+
78
+ Filters: `recipient`, `streamId`, `tag`, `status` (`queued`, `delivered`,
79
+ `deferred`, `bounced`, `expired`), `from` and `to` as `Date` or ISO 8601,
80
+ `limit` (1 to 100), `after`. Bodies are never returned; the log keeps messages for 13
81
+ months.
82
+
83
+ ```ts
84
+ const message = await sendora.messages.get(messageId);
85
+ const delivered = message.recipients.every((recipient) => recipient.status === 'delivered');
86
+
87
+ for await (const bounced of sendora.messages.searchAll({
88
+ status: 'bounced',
89
+ from: new Date('2026-09-01'),
90
+ })) {
91
+ console.log(bounced.messageId, bounced.subject);
92
+ }
93
+ ```
94
+
95
+ ## Streams
96
+
97
+ Every message goes on a stream of its server. A server starts with a
98
+ default transactional stream, and the account adds more: transactional
99
+ ones for every account, broadcast ones once Sendora has enabled broadcast
100
+ for the account, which an administrator applies for under Broadcast in
101
+ the dashboard, and one inbound stream, which receives mail at its
102
+ `inboundAddress` instead of sending. Each sending stream has a
103
+ suppression list of its own.
104
+
105
+ | Call | Answers |
106
+ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
107
+ | `sendora.streams.create({ kind, name })` | The stream; a taken name is `stream_exists`, a second inbound stream `inbound_stream_exists`, both with `existingId` |
108
+ | `sendora.streams.list()` | `{ streams }`, archived ones included |
109
+ | `sendora.streams.get(streamId)` | One stream |
110
+ | `sendora.streams.update(streamId, { name, contentRetentionDays })` | The updated stream; the days apply to an inbound stream |
111
+ | `sendora.streams.archive(streamId)` | The stream with `archivedAt`; it takes no new messages from then on |
112
+
113
+ ```ts
114
+ const stream = await sendora.streams.create({ kind: 'transactional', name: 'Aviseringar' });
115
+ await sendora.email.send({ ...message, streamId: stream.streamId });
116
+ ```
117
+
118
+ ## Broadcasts
119
+
120
+ One message to many on a broadcast stream, sent as a whole: the content
121
+ once and one entry per message, up to 50,000 and 50 MB per broadcast.
122
+ Every message is a message in the log with its own events and webhooks,
123
+ carrying the `broadcastId`, and every recipient gets their own
124
+ unsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the
125
+ stream's suppression list are dropped and counted in `suppressed`. The
126
+ SDK sets an idempotency key, so a retry answers the same broadcast. A
127
+ message's `substitutions`, up to 20 strings, replace `{{ key }}` in the
128
+ subject, the text and the HTML, escaped in the HTML; a key the content
129
+ names that a message lacks throws `substitution_missing`.
130
+
131
+ | Call | Answers |
132
+ | ---------------------------------------------- | ------------------------------------------------------------- |
133
+ | `sendora.broadcasts.send(broadcast, options?)` | `{ broadcastId, total, suppressed }`; refused whole otherwise |
134
+ | `sendora.broadcasts.get(broadcastId)` | The broadcast with `status`, `released` and `failed` |
135
+ | `sendora.broadcasts.list({ limit?, after? })` | `{ broadcasts, next }`, newest first |
136
+ | `sendora.broadcasts.cancel(broadcastId)` | The cancelled broadcast; what had not left counts as failed |
137
+
138
+ ```ts
139
+ const { broadcastId } = await sendora.broadcasts.send({
140
+ streamId: '7c9e6679-7425-40de-944b-e07fc1f90ae7',
141
+ from: { email: 'nyheter@example.se', name: 'Example AB' },
142
+ subject: 'Nyheter i oktober',
143
+ text: 'Hej! Här är månadens nyheter. Vill du inte ha fler? {{ unsubscribe_url }}',
144
+ messages: ['anna@example.com', 'bo@example.com'].map((email) => ({ to: [email] })),
145
+ });
146
+ const progress = await sendora.broadcasts.get(broadcastId);
147
+ ```
148
+
149
+ ## Suppressions
150
+
151
+ Addresses the server no longer sends to: hard bounces, spam complaints and
152
+ entries added by hand. A send to one of them throws `recipient_suppressed`.
153
+
154
+ | Call | Answers |
155
+ | ---------------------------------------------------------- | ----------------------------------------- |
156
+ | `sendora.suppressions.list({ streamId?, limit?, after? })` | `{ suppressions, next }`, newest first |
157
+ | `sendora.suppressions.listAll({ streamId? })` | Every entry, for `for await` |
158
+ | `sendora.suppressions.delete({ address, streamId? })` | Nothing; the address may be sent to again |
159
+
160
+ Every stream has a list of its own; without `streamId` the calls mean the
161
+ default transactional stream. A spam complaint or a recipient's own
162
+ unsubscribe cannot be lifted this way (`spam_complaint_locked`,
163
+ `unsubscribe_locked`); only Sendora support can, at the recipient's own
164
+ request. A refused send carries `streamId` beside `suppressed`.
165
+
166
+ ```ts
167
+ await sendora.suppressions.delete({ address: 'anna@example.com' });
168
+ ```
169
+
170
+ ## Tokens
171
+
172
+ Every token of a server has the same rights; the value is shown once, when
173
+ it is created.
174
+
175
+ | Call | Answers |
176
+ | --------------------------------- | ------------------------------------------------------ |
177
+ | `sendora.tokens.create({ name })` | The token with its `token` value, once |
178
+ | `sendora.tokens.list()` | `{ tokens }`, revoked ones included |
179
+ | `sendora.tokens.get(tokenId)` | One token, without its value |
180
+ | `sendora.tokens.revoke(tokenId)` | Nothing; the last live token is refused (`last_token`) |
181
+
182
+ ## Webhooks
183
+
184
+ An https URL of yours that Sendora posts events to as they happen, signed
185
+ with a secret shown once at creation.
186
+
187
+ | Call | Answers |
188
+ | -------------------------------------------------------- | ------------------------------------------------ |
189
+ | `sendora.webhooks.create({ url, events?, streamId? })` | The webhook with its `secret`, once |
190
+ | `sendora.webhooks.list()` | `{ webhooks }` |
191
+ | `sendora.webhooks.get(webhookId)` | One webhook |
192
+ | `sendora.webhooks.delete(webhookId)` | Nothing; pending deliveries are dropped |
193
+ | `sendora.webhooks.deliveries(webhookId, { status? })` | `{ deliveries, next }`, newest first |
194
+ | `sendora.webhooks.deliveriesAll(webhookId, { status? })` | Every delivery, for `for await` |
195
+ | `sendora.webhooks.replay(webhookId, deliveryId)` | The delivery, queued again |
196
+ | `sendora.webhooks.receive(request, secret)` | The verified, typed event from a Fetch `Request` |
197
+
198
+ Events: `delivered`, `bounced`, `deferred`, `spam_complaint` and
199
+ `unsubscribed` about a message, with `messageId`, `streamId`, `recipient`,
200
+ `occurredAt`, `serverId`, your `tag` and `metadata`, and `details` from the
201
+ receiver, or for `unsubscribed` the `source` (`link` or `one_click`);
202
+ `cap_warning` and `cap_reached` about usage, with `scope`, `cap`, `used`
203
+ and the period; `inbound` about a message received on an inbound stream,
204
+ with `inboundMessageId`, `envelopeRecipient`, the sizes, the
205
+ `authentication` verdicts and, for a webhook with `inboundContent:
206
+ 'full'`, the message itself: `from`, `to`, `subject`, `text`, `html`,
207
+ `attachments` and the rest. Every event carries `id`, the delivery id,
208
+ and `attempt`.
209
+
210
+ A failed delivery is retried with growing delays for about a day, then it
211
+ is `dead` until you replay it. Deliveries arrive at least once and not
212
+ always in order, so key your handling on `event.id`.
213
+
214
+ ### Receiving
215
+
216
+ Verify the signature before you parse anything, answer 2xx quickly, and do
217
+ the work afterwards. With a framework that hands you a Fetch `Request`
218
+ (Next.js route handlers, Hono, SvelteKit, Cloudflare Workers):
219
+
220
+ ```ts
221
+ import { Sendora } from '@sendora/sdk';
222
+
223
+ const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });
224
+
225
+ export async function POST(request: Request): Promise<Response> {
226
+ const event = await sendora.webhooks.receive(request, process.env.SENDORA_WEBHOOK_SECRET);
227
+ if (event.event === 'bounced' && event.details.hard) {
228
+ console.log(
229
+ `${event.recipient} is undeliverable: ${event.details.classification ?? 'unknown'}`,
230
+ );
231
+ }
232
+ return new Response(null, { status: 204 });
233
+ }
234
+ ```
235
+
236
+ With Express or Fastify, keep the raw body (`express.raw({ type: '*/*' })`
237
+ or the raw-body plugin) and call the verifier directly:
238
+
239
+ ```ts
240
+ import { verifyWebhook } from '@sendora/sdk';
241
+
242
+ const event = await verifyWebhook({
243
+ secret: process.env.SENDORA_WEBHOOK_SECRET,
244
+ signature: req.headers['sendora-signature'],
245
+ body: req.body,
246
+ });
247
+ ```
248
+
249
+ The signature is `Sendora-Signature: t=<unix seconds>,v1=<hex>`, an
250
+ HMAC-SHA256 with your secret over `<t>.<raw body>`, compared in constant
251
+ time through WebCrypto. A signature older than five minutes is refused
252
+ (`stale_signature`; `toleranceSeconds` changes the limit), a wrong one
253
+ throws `invalid_signature`.
254
+
255
+ ## Domains
256
+
257
+ Sending domains belong to the account, and every server sends from them.
258
+ A domain sends once both DNS records are seen.
259
+
260
+ | Call | Answers |
261
+ | ------------------------------------ | ---------------------------------------------------------------------------- |
262
+ | `sendora.domains.create({ domain })` | The domain with `returnPath` (CNAME) and `dkim` (TXT) to add |
263
+ | `sendora.domains.list()` | `{ domains }` |
264
+ | `sendora.domains.get(domainId)` | One domain with the state of its records |
265
+ | `sendora.domains.verify(domainId)` | The domain plus `check` per record: `ok`, `missing`, `mismatch`, `dns_error` |
266
+ | `sendora.domains.delete(domainId)` | Nothing; mail from it is refused from then on |
267
+
268
+ ```ts
269
+ const domain = await sendora.domains.create({ domain: 'example.se' });
270
+ console.log(`${domain.returnPath.type} ${domain.returnPath.host} -> ${domain.returnPath.value}`);
271
+ console.log(`${domain.dkim.type} ${domain.dkim.host} -> ${domain.dkim.value}`);
272
+ ```
273
+
274
+ ## Errors
275
+
276
+ Every failed call throws `SendoraError`. Narrow on `code`:
277
+
278
+ ```ts
279
+ import { SendoraError } from '@sendora/sdk';
280
+
281
+ try {
282
+ await sendora.email.send(message);
283
+ } catch (error) {
284
+ if (error instanceof SendoraError) {
285
+ switch (error.code) {
286
+ case 'recipient_suppressed':
287
+ // error.suppressed lists the addresses and why
288
+ break;
289
+ case 'rate_limited':
290
+ // error.retryAfter seconds; error.scope and error.limit say whose limit
291
+ break;
292
+ case 'invalid_request':
293
+ // error.issues, one { path, message } per field
294
+ break;
295
+ }
296
+ }
297
+ throw error;
298
+ }
299
+ ```
300
+
301
+ | Code | Status | Meaning |
302
+ | --------------------------------- | ------ | ------------------------------------------------------------------------- |
303
+ | `invalid_request` | 400 | A field is wrong; `issues` says which. |
304
+ | `idempotency_key_required` | 400 | A batch was sent without a key. |
305
+ | `unauthorized` | 401 | The token is missing, malformed or revoked. |
306
+ | `payment_required` | 402 | The account has no active subscription. |
307
+ | `tenant_paused` | 403 | Sendora has paused the account. |
308
+ | `tenant_not_active` | 403 | The account is not approved to send, or not active for an inbound stream. |
309
+ | `spam_complaint_locked` | 403 | Only support can lift a spam-complaint suppression. |
310
+ | `unsubscribe_locked` | 403 | Only support can lift a recipient's own unsubscribe. |
311
+ | `not_found` | 404 | No such id on this server, or no such suppressed address. |
312
+ | `domain_exists` | 409 | The account already has the domain; `existingId` names it. |
313
+ | `webhook_exists` | 409 | The server already has a webhook for that URL; `existingId` names it. |
314
+ | `stream_exists` | 409 | The server already has a stream with that name; `existingId` names it. |
315
+ | `inbound_stream_exists` | 409 | The server already has a live inbound stream; `existingId` names it. |
316
+ | `default_stream` | 409 | The default stream cannot be archived. |
317
+ | `last_token` | 409 | The only live token cannot be revoked. |
318
+ | `request_too_large` | 413 | The body exceeds 10 MB. |
319
+ | `from_domain_not_verified` | 422 | The From domain is not a verified sending domain. |
320
+ | `stream_not_found` | 422 | `streamId` names no stream of this server. |
321
+ | `stream_archived` | 422 | The stream is archived and takes no new messages. |
322
+ | `stream_paused` | 422 | Sendora paused the stream after complaints; support resumes it. |
323
+ | `stream_not_sendable` | 422 | An inbound stream receives mail; it takes no messages and has no list. |
324
+ | `stream_not_broadcast` | 422 | A broadcast goes on a broadcast stream; this one is transactional. |
325
+ | `substitution_missing` | 422 | The content names a `{{ key }}` a message does not give. |
326
+ | `broadcast_not_open` | 409 | The broadcast is completed or cancelled; nothing is left to cancel. |
327
+ | `broadcast_not_enabled` | 422 | Broadcast is not enabled for the account: no broadcast stream, no send. |
328
+ | `unsubscribe_placeholder_missing` | 422 | A broadcast message lacks `{{ unsubscribe_url }}` in a part. |
329
+ | `list_unsubscribe_reserved` | 422 | Sendora writes List-Unsubscribe on broadcast streams; leave it out. |
330
+ | `recipient_suppressed` | 422 | Recipients on the suppression list; `suppressed` lists them. |
331
+ | `idempotency_key_mismatch` | 422 | The key was used before with a different body. |
332
+ | `rate_limited` | 429 | Per-minute limit; `retryAfter`, `scope`, `limit`. |
333
+ | `monthly_cap_reached` | 429 | The monthly cap is used up; `scope`, `cap`, `used`, `resetsAt`. |
334
+ | `sending_disabled` | 503 | Sending is paused for everyone; `retryAfter`. |
335
+ | `connection_failed` | none | The request never reached the API; `cause` holds the failure. |
336
+ | `timeout` | none | No answer within `timeoutMs`. |
337
+ | `unexpected_response` | any | An answer without a JSON error body, such as from a proxy. |
338
+ | `invalid_signature` | none | A webhook's signature is missing, malformed or wrong. |
339
+ | `stale_signature` | none | A webhook's signature is older than the tolerance. |
340
+
341
+ `error.retryable` is true when waiting and calling again could succeed.
342
+ Neither the token nor a request body is ever part of an error.
343
+
344
+ ## Retries
345
+
346
+ Lost connections, timeouts, `rate_limited`, `sending_disabled` and server
347
+ errors are retried with jittered backoff, twice by default, honouring
348
+ `retryAfter` when it fits within five seconds of waiting in all. A longer
349
+ `retryAfter` is thrown at once for you to schedule. A request that creates
350
+ something (`tokens.create`, `webhooks.create`, `domains.create`, `replay`)
351
+ is repeated only after a `rate_limited` or `sending_disabled` answer, never
352
+ after a lost connection, so nothing is created twice. Nothing else is
353
+ retried. `new Sendora({ maxRetries: 0 })` turns retries off; `timeoutMs`
354
+ sets the limit per attempt.
355
+
356
+ ## For coding agents
357
+
358
+ The package ships `skills/sendora/SKILL.md`, a short guide in the agent
359
+ skill format with the rules above; point your agent at it or at this
360
+ README. Every exported symbol carries JSDoc with an example.
361
+
362
+ ## Docs
363
+
364
+ Guides and the HTTP reference: [sendora.se/docs](https://sendora.se/docs).
@@ -0,0 +1,3 @@
1
+ /** Standard base64 without line breaks, as the API takes attachment content; needs nothing from the runtime. */
2
+ export declare function toBase64(bytes: Uint8Array): string;
3
+ //# sourceMappingURL=base64.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base64.d.ts","sourceRoot":"","sources":["../src/base64.ts"],"names":[],"mappings":"AAEA,gHAAgH;AAChH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAalD"}
package/dist/base64.js ADDED
@@ -0,0 +1,17 @@
1
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
2
+ /** Standard base64 without line breaks, as the API takes attachment content; needs nothing from the runtime. */
3
+ export function toBase64(bytes) {
4
+ let out = '';
5
+ for (let i = 0; i < bytes.length; i += 3) {
6
+ const a = bytes[i] ?? 0;
7
+ const b = bytes[i + 1];
8
+ const c = bytes[i + 2];
9
+ const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0);
10
+ out += ALPHABET.charAt((n >>> 18) & 63);
11
+ out += ALPHABET.charAt((n >>> 12) & 63);
12
+ out += b === undefined ? '=' : ALPHABET.charAt((n >>> 6) & 63);
13
+ out += c === undefined ? '=' : ALPHABET.charAt(n & 63);
14
+ }
15
+ return out;
16
+ }
17
+ //# sourceMappingURL=base64.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base64.js","sourceRoot":"","sources":["../src/base64.ts"],"names":[],"mappings":"AAAA,MAAM,QAAQ,GAAG,kEAAkE,CAAC;AAEpF,gHAAgH;AAChH,MAAM,UAAU,QAAQ,CAAC,KAAiB;IACxC,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACzC,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;QACxB,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QACvB,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QACvB,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QACjD,GAAG,IAAI,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;QACxC,GAAG,IAAI,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;QACxC,GAAG,IAAI,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;QAC/D,GAAG,IAAI,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;IACzD,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,60 @@
1
+ import type { Transport } from './transport.ts';
2
+ import type { AcceptedBroadcast, Broadcast, BroadcastList, BroadcastPage, RequestOptions, SendBroadcastBody, SendBroadcastRequest, SendOptions } from './types.ts';
3
+ /** One message to many, on a broadcast stream of the token's server: sent as a whole, followed as a whole. */
4
+ export declare class BroadcastsResource {
5
+ #private;
6
+ constructor(transport: Transport);
7
+ /**
8
+ * Stores the content once and one message per entry of `messages`, up to
9
+ * 50,000, on a broadcast stream. Every message is a message in the log
10
+ * with its own events and webhooks, sent with the recipient's own
11
+ * unsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the
12
+ * stream's suppression list are dropped and counted in `suppressed`; a
13
+ * list with nothing left throws `recipient_suppressed`. A larger list is
14
+ * several broadcasts under one tag. The SDK sets an idempotency key, so a
15
+ * retry answers the same broadcast; pass your own to make a retry from
16
+ * your side safe too.
17
+ *
18
+ * @example
19
+ * const { broadcastId, total } = await sendora.broadcasts.send({
20
+ * streamId,
21
+ * from: { email: 'nyheter@example.se', name: 'Example AB' },
22
+ * subject: 'Nyheter i oktober',
23
+ * text: 'Hej!\n\nHär är månadens nyheter.\n\nVill du inte ha fler? {{ unsubscribe_url }}',
24
+ * messages: recipients.map((email) => ({ to: [email] })),
25
+ * });
26
+ */
27
+ send(request: SendBroadcastRequest, options?: SendOptions): Promise<AcceptedBroadcast>;
28
+ /**
29
+ * The broadcast and how far it has come: `released` and `failed` move
30
+ * as the worker hands its messages over, and `status` is `completed`
31
+ * once nothing is left.
32
+ *
33
+ * @example
34
+ * const broadcast = await sendora.broadcasts.get(broadcastId);
35
+ * console.log(`${broadcast.released} of ${broadcast.total} released`);
36
+ */
37
+ get(broadcastId: string, options?: RequestOptions): Promise<Broadcast>;
38
+ /**
39
+ * One page of the server's broadcasts, newest first; pass the page's
40
+ * `next` as `after` for the following one.
41
+ *
42
+ * @example
43
+ * const { broadcasts } = await sendora.broadcasts.list({ limit: 20 });
44
+ */
45
+ list(query?: BroadcastList, options?: RequestOptions): Promise<BroadcastPage>;
46
+ /**
47
+ * Holds every message of the broadcast that has not been handed to the
48
+ * mail server yet; those count as `failed`. A message already on its way
49
+ * is delivered. A completed or cancelled broadcast throws
50
+ * `broadcast_not_open`.
51
+ *
52
+ * @example
53
+ * const cancelled = await sendora.broadcasts.cancel(broadcastId);
54
+ * console.log(`${cancelled.failed} messages held back`);
55
+ */
56
+ cancel(broadcastId: string, options?: RequestOptions): Promise<Broadcast>;
57
+ }
58
+ /** The body as the API takes it: attachment bytes become base64, everything else is passed through. */
59
+ export declare function encodeBroadcastRequest(request: SendBroadcastRequest): SendBroadcastBody;
60
+ //# sourceMappingURL=broadcasts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broadcasts.d.ts","sourceRoot":"","sources":["../src/broadcasts.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,KAAK,EACV,iBAAiB,EACjB,SAAS,EACT,aAAa,EACb,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,oBAAoB,EACpB,WAAW,EACZ,MAAM,YAAY,CAAC;AAEpB,8GAA8G;AAC9G,qBAAa,kBAAkB;;gBAGjB,SAAS,EAAE,SAAS;IAIhC;;;;;;;;;;;;;;;;;;;OAmBG;IACH,IAAI,CAAC,OAAO,EAAE,oBAAoB,EAAE,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAW1F;;;;;;;;OAQG;IACH,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,SAAS,CAAC;IAS1E;;;;;;OAMG;IACH,IAAI,CAAC,KAAK,GAAE,aAAkB,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC;IAUrF;;;;;;;;;OASG;IACH,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,SAAS,CAAC;CAQ9E;AAED,uGAAuG;AACvG,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,oBAAoB,GAAG,iBAAiB,CAMvF"}
@@ -0,0 +1,98 @@
1
+ import { encodeAttachment } from "./email.js";
2
+ /** One message to many, on a broadcast stream of the token's server: sent as a whole, followed as a whole. */
3
+ export class BroadcastsResource {
4
+ #transport;
5
+ constructor(transport) {
6
+ this.#transport = transport;
7
+ }
8
+ /**
9
+ * Stores the content once and one message per entry of `messages`, up to
10
+ * 50,000, on a broadcast stream. Every message is a message in the log
11
+ * with its own events and webhooks, sent with the recipient's own
12
+ * unsubscribe link where `{{ unsubscribe_url }}` stands. Addresses on the
13
+ * stream's suppression list are dropped and counted in `suppressed`; a
14
+ * list with nothing left throws `recipient_suppressed`. A larger list is
15
+ * several broadcasts under one tag. The SDK sets an idempotency key, so a
16
+ * retry answers the same broadcast; pass your own to make a retry from
17
+ * your side safe too.
18
+ *
19
+ * @example
20
+ * const { broadcastId, total } = await sendora.broadcasts.send({
21
+ * streamId,
22
+ * from: { email: 'nyheter@example.se', name: 'Example AB' },
23
+ * subject: 'Nyheter i oktober',
24
+ * text: 'Hej!\n\nHär är månadens nyheter.\n\nVill du inte ha fler? {{ unsubscribe_url }}',
25
+ * messages: recipients.map((email) => ({ to: [email] })),
26
+ * });
27
+ */
28
+ send(request, options = {}) {
29
+ return this.#transport.request({
30
+ method: 'POST',
31
+ path: '/v1/broadcasts',
32
+ body: encodeBroadcastRequest(request),
33
+ headers: { 'idempotency-key': options.idempotencyKey ?? crypto.randomUUID() },
34
+ idempotent: true,
35
+ signal: options.signal,
36
+ });
37
+ }
38
+ /**
39
+ * The broadcast and how far it has come: `released` and `failed` move
40
+ * as the worker hands its messages over, and `status` is `completed`
41
+ * once nothing is left.
42
+ *
43
+ * @example
44
+ * const broadcast = await sendora.broadcasts.get(broadcastId);
45
+ * console.log(`${broadcast.released} of ${broadcast.total} released`);
46
+ */
47
+ get(broadcastId, options = {}) {
48
+ return this.#transport.request({
49
+ method: 'GET',
50
+ path: `/v1/broadcasts/${encodeURIComponent(broadcastId)}`,
51
+ idempotent: true,
52
+ signal: options.signal,
53
+ });
54
+ }
55
+ /**
56
+ * One page of the server's broadcasts, newest first; pass the page's
57
+ * `next` as `after` for the following one.
58
+ *
59
+ * @example
60
+ * const { broadcasts } = await sendora.broadcasts.list({ limit: 20 });
61
+ */
62
+ list(query = {}, options = {}) {
63
+ return this.#transport.request({
64
+ method: 'GET',
65
+ path: '/v1/broadcasts',
66
+ query: { limit: query.limit, after: query.after },
67
+ idempotent: true,
68
+ signal: options.signal,
69
+ });
70
+ }
71
+ /**
72
+ * Holds every message of the broadcast that has not been handed to the
73
+ * mail server yet; those count as `failed`. A message already on its way
74
+ * is delivered. A completed or cancelled broadcast throws
75
+ * `broadcast_not_open`.
76
+ *
77
+ * @example
78
+ * const cancelled = await sendora.broadcasts.cancel(broadcastId);
79
+ * console.log(`${cancelled.failed} messages held back`);
80
+ */
81
+ cancel(broadcastId, options = {}) {
82
+ return this.#transport.request({
83
+ method: 'POST',
84
+ path: `/v1/broadcasts/${encodeURIComponent(broadcastId)}/cancel`,
85
+ idempotent: true,
86
+ signal: options.signal,
87
+ });
88
+ }
89
+ }
90
+ /** The body as the API takes it: attachment bytes become base64, everything else is passed through. */
91
+ export function encodeBroadcastRequest(request) {
92
+ const { attachments, ...fields } = request;
93
+ if (attachments === undefined) {
94
+ return fields;
95
+ }
96
+ return { ...fields, attachments: attachments.map(encodeAttachment) };
97
+ }
98
+ //# sourceMappingURL=broadcasts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"broadcasts.js","sourceRoot":"","sources":["../src/broadcasts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAa9C,8GAA8G;AAC9G,MAAM,OAAO,kBAAkB;IACpB,UAAU,CAAY;IAE/B,YAAY,SAAoB;QAC9B,IAAI,CAAC,UAAU,GAAG,SAAS,CAAC;IAC9B,CAAC;IAED;;;;;;;;;;;;;;;;;;;OAmBG;IACH,IAAI,CAAC,OAA6B,EAAE,UAAuB,EAAE;QAC3D,OAAO,IAAI,CAAC,UAAU,CAAC,OAAO,CAAoB;YAChD,MAAM,EAAE,MAAM;YACd,IAAI,EAAE,gBAAgB;YACtB,IAAI,EAAE,sBAAsB,CAAC,OAAO,CAAC;YACrC,OAAO,EAAE,EAAE,iBAAiB,EAAE,OAAO,CAAC,cAAc,IAAI,MAAM,CAAC,UAAU,EAAE,EAAE;YAC7E,UAAU,EAAE,IAAI;YAChB,MAAM,EAAE,OAAO,CAAC,MAAM;SACvB,CAAC,CAAC;IACL,CAAC;IAED;;;;;;;;OAQG;IACH,GAAG,CAAC,WAAmB,EAAE,UAA0B,EAAE;QACnD,OAAO,IAAI,CAAC,UAAU,CAAC,OAAO,CAAY;YACxC,MAAM,EAAE,KAAK;YACb,IAAI,EAAE,kBAAkB,kBAAkB,CAAC,WAAW,CAAC,EAAE;YACzD,UAAU,EAAE,IAAI;YAChB,MAAM,EAAE,OAAO,CAAC,MAAM;SACvB,CAAC,CAAC;IACL,CAAC;IAED;;;;;;OAMG;IACH,IAAI,CAAC,QAAuB,EAAE,EAAE,UAA0B,EAAE;QAC1D,OAAO,IAAI,CAAC,UAAU,CAAC,OAAO,CAAgB;YAC5C,MAAM,EAAE,KAAK;YACb,IAAI,EAAE,gBAAgB;YACtB,KAAK,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,EAAE;YACjD,UAAU,EAAE,IAAI;YAChB,MAAM,EAAE,OAAO,CAAC,MAAM;SACvB,CAAC,CAAC;IACL,CAAC;IAED;;;;;;;;;OASG;IACH,MAAM,CAAC,WAAmB,EAAE,UAA0B,EAAE;QACtD,OAAO,IAAI,CAAC,UAAU,CAAC,OAAO,CAAY;YACxC,MAAM,EAAE,MAAM;YACd,IAAI,EAAE,kBAAkB,kBAAkB,CAAC,WAAW,CAAC,SAAS;YAChE,UAAU,EAAE,IAAI;YAChB,MAAM,EAAE,OAAO,CAAC,MAAM;SACvB,CAAC,CAAC;IACL,CAAC;CACF;AAED,uGAAuG;AACvG,MAAM,UAAU,sBAAsB,CAAC,OAA6B;IAClE,MAAM,EAAE,WAAW,EAAE,GAAG,MAAM,EAAE,GAAG,OAAO,CAAC;IAC3C,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;QAC9B,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,OAAO,EAAE,GAAG,MAAM,EAAE,WAAW,EAAE,WAAW,CAAC,GAAG,CAAC,gBAAgB,CAAC,EAAE,CAAC;AACvE,CAAC"}
@@ -0,0 +1,51 @@
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 { StreamsResource } from './streams.ts';
6
+ import { SuppressionsResource } from './suppressions.ts';
7
+ import { TokensResource } from './tokens.ts';
8
+ import { WebhooksResource } from './webhooks.ts';
9
+ export declare const DEFAULT_BASE_URL = "https://api.sendora.se";
10
+ export interface SendoraOptions {
11
+ /**
12
+ * A server's API token, created in the dashboard. Keep it on the server;
13
+ * never ship it to a browser. `undefined`, as an unset environment
14
+ * variable gives, throws at construction rather than at the first call.
15
+ */
16
+ token: string | undefined;
17
+ /** The API's origin; https://api.sendora.se unless you test against another. */
18
+ baseUrl?: string | undefined;
19
+ /** The fetch to use; the global one unless you need a proxy or a fake. */
20
+ fetch?: typeof fetch | undefined;
21
+ /** How long one attempt may take; 30 seconds by default. */
22
+ timeoutMs?: number | undefined;
23
+ /** How many times a failed call is repeated when repeating is safe; 2 by default, 0 turns retries off. */
24
+ maxRetries?: number | undefined;
25
+ }
26
+ /**
27
+ * The client. One instance per server token; every resource hangs off it.
28
+ *
29
+ * @example
30
+ * import { Sendora } from '@sendora/sdk';
31
+ *
32
+ * const sendora = new Sendora({ token: process.env.SENDORA_API_TOKEN });
33
+ * const { messageId } = await sendora.email.send({
34
+ * from: 'no-reply@example.se',
35
+ * to: ['anna@example.com'],
36
+ * subject: 'Välkommen',
37
+ * text: 'Hej Anna!',
38
+ * });
39
+ */
40
+ export declare class Sendora {
41
+ readonly email: EmailResource;
42
+ readonly broadcasts: BroadcastsResource;
43
+ readonly messages: MessagesResource;
44
+ readonly streams: StreamsResource;
45
+ readonly suppressions: SuppressionsResource;
46
+ readonly tokens: TokensResource;
47
+ readonly webhooks: WebhooksResource;
48
+ readonly domains: DomainsResource;
49
+ constructor(options: SendoraOptions);
50
+ }
51
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC3C,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEjD,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAG7C,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAEjD,eAAO,MAAM,gBAAgB,2BAA2B,CAAC;AAGzD,MAAM,WAAW,cAAc;IAC7B;;;;OAIG;IACH,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,0EAA0E;IAC1E,KAAK,CAAC,EAAE,OAAO,KAAK,GAAG,SAAS,CAAC;IACjC,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,0GAA0G;IAC1G,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED;;;;;;;;;;;;;GAaG;AACH,qBAAa,OAAO;IAClB,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC;IAC9B,QAAQ,CAAC,UAAU,EAAE,kBAAkB,CAAC;IACxC,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;IAClC,QAAQ,CAAC,YAAY,EAAE,oBAAoB,CAAC;IAC5C,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,gBAAgB,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAC;gBAEtB,OAAO,EAAE,cAAc;CA+BpC"}