@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
package/src/version.ts
ADDED
|
@@ -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
|
+
}
|
package/src/webhooks.ts
ADDED
|
@@ -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
|
+
}
|