@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/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** The package version, sent as the User-Agent; a test holds it equal to package.json. */
2
+ export const SDK_VERSION = '1.1.0';
@@ -0,0 +1,147 @@
1
+ import { SendoraError } from './error.ts';
2
+ import type { WebhookEvent, WebhookEventName } from './types.ts';
3
+
4
+ export interface VerifyWebhookInput {
5
+ /** The signing secret the webhook was created with; `undefined`, as an unset environment variable gives, throws. */
6
+ secret: string | undefined;
7
+ /** The Sendora-Signature header as received. */
8
+ signature: string | null | undefined;
9
+ /** The raw request body exactly as received; parse nothing before verifying. */
10
+ body: string | Uint8Array;
11
+ /** How old a signature may be, in seconds; five minutes by default. */
12
+ toleranceSeconds?: number | undefined;
13
+ /** The current time in seconds since the epoch; tests pin it. */
14
+ now?: number | undefined;
15
+ }
16
+
17
+ const DEFAULT_TOLERANCE_SECONDS = 300;
18
+ const MAC_HEX = /^[0-9a-f]{64}$/i;
19
+
20
+ const eventNames: ReadonlySet<string> = new Set<WebhookEventName>([
21
+ 'delivered',
22
+ 'bounced',
23
+ 'deferred',
24
+ 'spam_complaint',
25
+ 'unsubscribed',
26
+ 'cap_warning',
27
+ 'cap_reached',
28
+ 'inbound',
29
+ ]);
30
+
31
+ const encoder = new TextEncoder();
32
+ const decoder = new TextDecoder();
33
+
34
+ /**
35
+ * Checks a webhook request came from Sendora and answers its event, typed
36
+ * by `event`. The header is `Sendora-Signature: t=<unix seconds>,v1=<hex>`,
37
+ * the hex an HMAC-SHA256 with your secret over `<t>.<raw body>`; the
38
+ * timestamp is inside the signed text, so a captured request cannot be
39
+ * replayed after the tolerance. Throws `SendoraError` with
40
+ * `invalid_signature` or `stale_signature`.
41
+ *
42
+ * @example
43
+ * const event = await verifyWebhook({
44
+ * secret: process.env.SENDORA_WEBHOOK_SECRET,
45
+ * signature: req.headers['sendora-signature'],
46
+ * body: rawBody,
47
+ * });
48
+ * if (event.event === 'bounced' && event.details.hard) {
49
+ * await markUndeliverable(event.recipient);
50
+ * }
51
+ */
52
+ export async function verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent> {
53
+ if (input.secret === undefined || input.secret === '') {
54
+ throw new TypeError('verifyWebhook needs the signing secret the webhook was created with.');
55
+ }
56
+ const signature = parseSignature(input.signature);
57
+ const now = input.now ?? Math.floor(Date.now() / 1000);
58
+ const age = Math.abs(now - signature.timestamp);
59
+ if (age > (input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS)) {
60
+ throw new SendoraError({
61
+ code: 'stale_signature',
62
+ status: null,
63
+ message: `The signature is ${String(age)} seconds old, more than the tolerance allows.`,
64
+ });
65
+ }
66
+ const body = typeof input.body === 'string' ? encoder.encode(input.body) : input.body;
67
+ const signed = concat(encoder.encode(`${String(signature.timestamp)}.`), body);
68
+ const key = await crypto.subtle.importKey(
69
+ 'raw',
70
+ encoder.encode(input.secret),
71
+ { name: 'HMAC', hash: 'SHA-256' },
72
+ false,
73
+ ['verify'],
74
+ );
75
+ if (!(await crypto.subtle.verify('HMAC', key, signature.mac, signed))) {
76
+ throw invalidSignature('The signature does not match the body.');
77
+ }
78
+ return parseEvent(decoder.decode(body));
79
+ }
80
+
81
+ function parseSignature(header: string | null | undefined): {
82
+ timestamp: number;
83
+ mac: Uint8Array<ArrayBuffer>;
84
+ } {
85
+ if (typeof header !== 'string' || header === '') {
86
+ throw invalidSignature('The request carries no Sendora-Signature header.');
87
+ }
88
+ const parts = new Map<string, string>();
89
+ for (const part of header.split(',')) {
90
+ const separator = part.indexOf('=');
91
+ if (separator > 0) {
92
+ parts.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim());
93
+ }
94
+ }
95
+ const timestamp = Number(parts.get('t'));
96
+ const hex = parts.get('v1');
97
+ if (!Number.isInteger(timestamp) || hex === undefined || !MAC_HEX.test(hex)) {
98
+ throw invalidSignature('The Sendora-Signature header is malformed.');
99
+ }
100
+ const mac = new Uint8Array(hex.length / 2);
101
+ for (let i = 0; i < mac.length; i += 1) {
102
+ mac[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
103
+ }
104
+ return { timestamp, mac };
105
+ }
106
+
107
+ function parseEvent(text: string): WebhookEvent {
108
+ let value: unknown;
109
+ try {
110
+ value = JSON.parse(text);
111
+ } catch {
112
+ throw unknownEvent();
113
+ }
114
+ if (
115
+ typeof value !== 'object' ||
116
+ value === null ||
117
+ !('event' in value) ||
118
+ typeof value.event !== 'string' ||
119
+ !eventNames.has(value.event) ||
120
+ !('id' in value) ||
121
+ typeof value.id !== 'string' ||
122
+ !('attempt' in value) ||
123
+ typeof value.attempt !== 'number'
124
+ ) {
125
+ throw unknownEvent();
126
+ }
127
+ return value as WebhookEvent;
128
+ }
129
+
130
+ function invalidSignature(message: string): SendoraError {
131
+ return new SendoraError({ code: 'invalid_signature', status: null, message });
132
+ }
133
+
134
+ function unknownEvent(): SendoraError {
135
+ return new SendoraError({
136
+ code: 'unexpected_response',
137
+ status: null,
138
+ message: 'The body is signed but is not an event this SDK knows.',
139
+ });
140
+ }
141
+
142
+ function concat(head: Uint8Array, tail: Uint8Array): Uint8Array<ArrayBuffer> {
143
+ const joined = new Uint8Array(head.length + tail.length);
144
+ joined.set(head, 0);
145
+ joined.set(tail, head.length);
146
+ return joined;
147
+ }
@@ -0,0 +1,157 @@
1
+ import { paginate } from './pagination.ts';
2
+ import type { Transport } from './transport.ts';
3
+ import type {
4
+ CreatedWebhook,
5
+ CreateWebhookRequest,
6
+ Delivery,
7
+ DeliveryPage,
8
+ DeliveryQuery,
9
+ ReceiveOptions,
10
+ RequestOptions,
11
+ Webhook,
12
+ WebhookEvent,
13
+ WebhookList,
14
+ } from './types.ts';
15
+ import { verifyWebhook } from './webhook-verify.ts';
16
+
17
+ /** Where the server's events are posted, every delivery of them, and the receiving side. */
18
+ export class WebhooksResource {
19
+ readonly #transport: Transport;
20
+
21
+ constructor(transport: Transport) {
22
+ this.#transport = transport;
23
+ }
24
+
25
+ /**
26
+ * Registers an https URL on a public host to receive events, all of them
27
+ * unless `events` narrows it, from every stream unless `streamId` limits
28
+ * it to one. The answer carries the signing `secret` once; keep it
29
+ * beside the token and hand it to `receive` or `verifyWebhook`.
30
+ *
31
+ * @example
32
+ * const { secret } = await sendora.webhooks.create({
33
+ * url: 'https://example.se/hooks/sendora',
34
+ * events: ['delivered', 'bounced', 'spam_complaint'],
35
+ * });
36
+ */
37
+ create(request: CreateWebhookRequest, options: RequestOptions = {}): Promise<CreatedWebhook> {
38
+ return this.#transport.request<CreatedWebhook>({
39
+ method: 'POST',
40
+ path: '/v1/webhooks',
41
+ body: request,
42
+ idempotent: false,
43
+ signal: options.signal,
44
+ });
45
+ }
46
+
47
+ /** Every webhook of the server. */
48
+ list(options: RequestOptions = {}): Promise<WebhookList> {
49
+ return this.#transport.request<WebhookList>({
50
+ method: 'GET',
51
+ path: '/v1/webhooks',
52
+ idempotent: true,
53
+ signal: options.signal,
54
+ });
55
+ }
56
+
57
+ /** One webhook by id. */
58
+ get(webhookId: string, options: RequestOptions = {}): Promise<Webhook> {
59
+ return this.#transport.request<Webhook>({
60
+ method: 'GET',
61
+ path: `/v1/webhooks/${encodeURIComponent(webhookId)}`,
62
+ idempotent: true,
63
+ signal: options.signal,
64
+ });
65
+ }
66
+
67
+ /** Removes the webhook; deliveries still pending are dropped with it. */
68
+ delete(webhookId: string, options: RequestOptions = {}): Promise<void> {
69
+ return this.#transport.request<undefined>({
70
+ method: 'DELETE',
71
+ path: `/v1/webhooks/${encodeURIComponent(webhookId)}`,
72
+ idempotent: true,
73
+ signal: options.signal,
74
+ });
75
+ }
76
+
77
+ /**
78
+ * One page of the events handed to the webhook, newest first, with the
79
+ * outcome of the last attempt; `status` narrows to `pending`,
80
+ * `delivered` or `dead`.
81
+ *
82
+ * @example
83
+ * const { deliveries } = await sendora.webhooks.deliveries(webhookId, { status: 'dead' });
84
+ */
85
+ deliveries(
86
+ webhookId: string,
87
+ query: DeliveryQuery = {},
88
+ options: RequestOptions = {},
89
+ ): Promise<DeliveryPage> {
90
+ return this.#transport.request<DeliveryPage>({
91
+ method: 'GET',
92
+ path: `/v1/webhooks/${encodeURIComponent(webhookId)}/deliveries`,
93
+ query: { limit: query.limit, after: query.after, status: query.status },
94
+ idempotent: true,
95
+ signal: options.signal,
96
+ });
97
+ }
98
+
99
+ /** Every delivery the query matches, page by page, for `for await`. */
100
+ deliveriesAll(
101
+ webhookId: string,
102
+ query: DeliveryQuery = {},
103
+ options: RequestOptions = {},
104
+ ): AsyncIterable<Delivery> {
105
+ return paginate(
106
+ (after) => this.deliveries(webhookId, { ...query, after }, options),
107
+ (page) => page.deliveries,
108
+ (page) => page.next,
109
+ query.after,
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Queues a delivery again, whatever its state, with the same payload.
115
+ *
116
+ * @example
117
+ * for await (const dead of sendora.webhooks.deliveriesAll(webhookId, { status: 'dead' })) {
118
+ * await sendora.webhooks.replay(webhookId, dead.deliveryId);
119
+ * }
120
+ */
121
+ replay(webhookId: string, deliveryId: string, options: RequestOptions = {}): Promise<Delivery> {
122
+ return this.#transport.request<Delivery>({
123
+ method: 'POST',
124
+ path: `/v1/webhooks/${encodeURIComponent(webhookId)}/deliveries/${encodeURIComponent(deliveryId)}/replay`,
125
+ idempotent: false,
126
+ signal: options.signal,
127
+ });
128
+ }
129
+
130
+ /**
131
+ * The receiving side for any framework that hands you a Fetch `Request`
132
+ * (Next.js route handlers, Hono, SvelteKit, Cloudflare Workers): reads the
133
+ * raw body, verifies the signature with the webhook's secret and answers
134
+ * the typed event. For Express or Fastify, pass the raw body to
135
+ * `verifyWebhook` instead. Answer 2xx quickly and do the work afterwards;
136
+ * deliveries arrive at least once, so key your handling on `event.id`.
137
+ *
138
+ * @example
139
+ * export async function POST(request: Request) {
140
+ * const event = await sendora.webhooks.receive(request, process.env.SENDORA_WEBHOOK_SECRET);
141
+ * await queue.push(event);
142
+ * return new Response(null, { status: 204 });
143
+ * }
144
+ */
145
+ async receive(
146
+ request: Request,
147
+ secret: string | undefined,
148
+ options: ReceiveOptions = {},
149
+ ): Promise<WebhookEvent> {
150
+ return verifyWebhook({
151
+ secret,
152
+ signature: request.headers.get('sendora-signature'),
153
+ body: new Uint8Array(await request.arrayBuffer()),
154
+ toleranceSeconds: options.toleranceSeconds,
155
+ });
156
+ }
157
+ }