@sendora/sdk 1.1.0 → 2.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 (69) hide show
  1. package/README.md +152 -70
  2. package/dist/account.d.ts +30 -0
  3. package/dist/account.d.ts.map +1 -0
  4. package/dist/account.js +25 -0
  5. package/dist/account.js.map +1 -0
  6. package/dist/client.d.ts +13 -16
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +11 -30
  9. package/dist/client.js.map +1 -1
  10. package/dist/domains.d.ts +1 -1
  11. package/dist/domains.d.ts.map +1 -1
  12. package/dist/domains.js +1 -1
  13. package/dist/domains.js.map +1 -1
  14. package/dist/error.d.ts +6 -3
  15. package/dist/error.d.ts.map +1 -1
  16. package/dist/error.js +22 -2
  17. package/dist/error.js.map +1 -1
  18. package/dist/inbound-domains.d.ts +36 -0
  19. package/dist/inbound-domains.d.ts.map +1 -0
  20. package/dist/inbound-domains.js +72 -0
  21. package/dist/inbound-domains.js.map +1 -0
  22. package/dist/inbound.d.ts +61 -0
  23. package/dist/inbound.d.ts.map +1 -0
  24. package/dist/inbound.js +106 -0
  25. package/dist/inbound.js.map +1 -0
  26. package/dist/index.d.ts +8 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/options.d.ts +20 -0
  31. package/dist/options.d.ts.map +1 -0
  32. package/dist/options.js +34 -0
  33. package/dist/options.js.map +1 -0
  34. package/dist/servers.d.ts +64 -0
  35. package/dist/servers.d.ts.map +1 -0
  36. package/dist/servers.js +133 -0
  37. package/dist/servers.js.map +1 -0
  38. package/dist/transport.d.ts +2 -0
  39. package/dist/transport.d.ts.map +1 -1
  40. package/dist/transport.js +15 -5
  41. package/dist/transport.js.map +1 -1
  42. package/dist/types.d.ts +182 -5
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/webhook-verify.d.ts +7 -5
  47. package/dist/webhook-verify.d.ts.map +1 -1
  48. package/dist/webhook-verify.js +38 -17
  49. package/dist/webhook-verify.js.map +1 -1
  50. package/dist/webhooks.d.ts +13 -1
  51. package/dist/webhooks.d.ts.map +1 -1
  52. package/dist/webhooks.js +26 -0
  53. package/dist/webhooks.js.map +1 -1
  54. package/package.json +4 -9
  55. package/skills/sendora/SKILL.md +19 -9
  56. package/src/account.ts +40 -0
  57. package/src/client.ts +20 -43
  58. package/src/domains.ts +1 -1
  59. package/src/error.ts +36 -3
  60. package/src/inbound-domains.ts +89 -0
  61. package/src/inbound.ts +135 -0
  62. package/src/index.ts +8 -1
  63. package/src/options.ts +51 -0
  64. package/src/servers.ts +166 -0
  65. package/src/transport.ts +26 -5
  66. package/src/types.ts +194 -5
  67. package/src/version.ts +1 -1
  68. package/src/webhook-verify.ts +39 -18
  69. package/src/webhooks.ts +29 -0
package/src/types.ts CHANGED
@@ -296,6 +296,112 @@ export interface MessagePage {
296
296
  next: string | null;
297
297
  }
298
298
 
299
+ /** The body POST /v1/inbound/search takes. */
300
+ export interface InboundSearchBody {
301
+ streamId?: string | undefined;
302
+ from?: string | undefined;
303
+ recipient?: string | undefined;
304
+ mailboxHash?: string | undefined;
305
+ receivedFrom?: string | undefined;
306
+ receivedTo?: string | undefined;
307
+ limit?: number | undefined;
308
+ after?: string | undefined;
309
+ }
310
+
311
+ /** The filters of a search over received mail; every one is optional. */
312
+ export interface InboundSearch {
313
+ /** Messages received on this inbound stream of the server. */
314
+ streamId?: string | undefined;
315
+ /** Messages from this address, in the envelope or the From header. */
316
+ from?: string | undefined;
317
+ /** Messages sent to this address of yours. */
318
+ recipient?: string | undefined;
319
+ /** Messages whose address carried this text after the plus sign. */
320
+ mailboxHash?: string | undefined;
321
+ /** Accepted at or after this time. */
322
+ receivedFrom?: Date | string | undefined;
323
+ /** Accepted before this time. */
324
+ receivedTo?: Date | string | undefined;
325
+ /** Page size, 1 to 100; 50 when left out. */
326
+ limit?: number | undefined;
327
+ /** The `next` value of the previous page. */
328
+ after?: string | undefined;
329
+ }
330
+
331
+ /** An attachment of a received message, described; its bytes are `sendora.inbound.attachment`. */
332
+ export interface InboundMessageAttachment {
333
+ attachmentId: string;
334
+ /** Its place among the message's attachments, from 0. */
335
+ position: number;
336
+ /** The filename, sanitised; null once the content is gone. */
337
+ name: string | null;
338
+ /** The type the sender declared; every download is served as application/octet-stream. */
339
+ contentType: string | null;
340
+ /** The Content-ID an HTML body refers to with `cid:`. */
341
+ contentId: string | null;
342
+ size: number;
343
+ /** True for a part shown in the body rather than offered as a file. */
344
+ inline: boolean;
345
+ }
346
+
347
+ /** A received message as the list shows it. */
348
+ export interface InboundMessage {
349
+ inboundMessageId: string;
350
+ /** The inbound stream that received it. */
351
+ streamId: string;
352
+ /** When the message was accepted from the sending server. */
353
+ receivedAt: string;
354
+ /** The address of yours the message was sent to. */
355
+ envelopeRecipient: string;
356
+ /** The text after the plus sign in that address, when the sender used one. */
357
+ mailboxHash: string | null;
358
+ sizeBytes: number;
359
+ attachmentCount: number;
360
+ hasText: boolean;
361
+ hasHtml: boolean;
362
+ /** What the parser met, from a closed set; null when the message parsed clean. */
363
+ parseIssue: string | null;
364
+ authentication: InboundAuthentication;
365
+ /** False once the stream's content window has passed; only the reference remains. */
366
+ contentAvailable: boolean;
367
+ /** When the content goes. */
368
+ contentExpiresAt: string;
369
+ /** The From header; null without it or once the content is gone. */
370
+ from: InboundAddress | null;
371
+ subject: string | null;
372
+ /** The sender's Date header as ISO 8601, when it was a real moment. */
373
+ date: string | null;
374
+ }
375
+
376
+ /** The received message with everything but the attachment bytes. */
377
+ export interface InboundMessageDetail extends InboundMessage {
378
+ /** The envelope sender; null for a bounce, or once the content is gone. */
379
+ envelope: { sender: string | null } | null;
380
+ replyTo: InboundAddress[];
381
+ /** Up to 100 entries; `toCount` is the whole number. */
382
+ to: InboundAddress[];
383
+ toCount: number;
384
+ cc: InboundAddress[];
385
+ ccCount: number;
386
+ messageIdHeader: string | null;
387
+ inReplyTo: string | null;
388
+ references: string[];
389
+ /** Every header in order; null once the content is gone. */
390
+ headers: InboundHeader[] | null;
391
+ /** The plain-text body; null without one or once the content is gone. */
392
+ text: string | null;
393
+ /** The HTML body as received; null without one or once the content is gone. */
394
+ html: string | null;
395
+ attachments: InboundMessageAttachment[];
396
+ }
397
+
398
+ /** One page of received messages, newest first. */
399
+ export interface InboundPage {
400
+ messages: InboundMessage[];
401
+ /** Pass as `after` for the next page; null on the last. */
402
+ next: string | null;
403
+ }
404
+
299
405
  /** Cursor paging for the lists that page. */
300
406
  export interface PageQuery {
301
407
  /** Page size, 1 to 1000; 100 when left out. */
@@ -480,13 +586,13 @@ export interface BroadcastPage {
480
586
  /** What `broadcasts.list` takes. */
481
587
  export type BroadcastList = PageQuery;
482
588
 
483
- /** The body POST /v1/tokens takes. */
589
+ /** The body POST /v1/tokens and POST /v1/servers/{id}/tokens take. */
484
590
  export interface CreateTokenRequest {
485
591
  /** 1 to 100 characters, for people to tell tokens apart. */
486
592
  name: string;
487
593
  }
488
594
 
489
- /** A token of the server; its value is shown only when it is created. */
595
+ /** A key of a server; its value is shown only when it is created. */
490
596
  export interface Token {
491
597
  tokenId: string;
492
598
  name: string;
@@ -496,7 +602,7 @@ export interface Token {
496
602
  revokedAt: string | null;
497
603
  }
498
604
 
499
- /** A token just created, with its value, shown this once. */
605
+ /** A key just created, with its value, shown this once. */
500
606
  export interface CreatedToken extends Token {
501
607
  token: string;
502
608
  }
@@ -505,6 +611,37 @@ export interface TokenList {
505
611
  tokens: Token[];
506
612
  }
507
613
 
614
+ /** A server of the account: an isolation boundary with its own keys, streams, log, suppressions and webhooks. */
615
+ export interface Server {
616
+ /** The id every server key, stream and message of the server refers to. */
617
+ serverId: string;
618
+ /** The name given at creation, unique within the account. */
619
+ name: string;
620
+ createdAt: string;
621
+ }
622
+
623
+ /** A server just created, with its first key, whose value is shown this once. */
624
+ export interface CreatedServer extends Server {
625
+ /** The server's first key, named default; its value is never shown again. */
626
+ token: CreatedToken;
627
+ }
628
+
629
+ export interface ServerList {
630
+ servers: Server[];
631
+ }
632
+
633
+ /** The body POST /v1/servers takes. */
634
+ export interface CreateServerRequest {
635
+ /** 1 to 100 characters, unique within the account. */
636
+ name: string;
637
+ }
638
+
639
+ /** The body PATCH /v1/servers/{id} takes. */
640
+ export interface UpdateServerRequest {
641
+ /** 1 to 100 characters, unique within the account. */
642
+ name: string;
643
+ }
644
+
508
645
  /** The events a webhook can receive. */
509
646
  export type WebhookEventName =
510
647
  | 'delivered'
@@ -543,6 +680,19 @@ export interface Webhook {
543
680
  /** How the inbound event carries a received message: the parsed message, or a reference. */
544
681
  inboundContent: InboundContent;
545
682
  createdAt: string;
683
+ /** The live secrets, oldest first; every delivery is signed with each of them, the newest first in Sendora-Signature. */
684
+ secrets: WebhookSecret[];
685
+ }
686
+
687
+ /** A live signing secret of a webhook by id and age; its value was shown once when it was created. */
688
+ export interface WebhookSecret {
689
+ secretId: string;
690
+ createdAt: string;
691
+ }
692
+
693
+ /** A secret just created, with its value, shown this once. */
694
+ export interface CreatedWebhookSecret extends WebhookSecret {
695
+ secret: string;
546
696
  }
547
697
 
548
698
  /** A webhook just created, with its signing secret, shown this once. */
@@ -567,6 +717,8 @@ export interface Delivery {
567
717
  event: WebhookEventName;
568
718
  /** Null for an event about usage rather than a message. */
569
719
  messageId: string | null;
720
+ /** The received message an inbound event is about; null for every other event. */
721
+ inboundMessageId: string | null;
570
722
  status: DeliveryStatus;
571
723
  attempts: number;
572
724
  /** When the next attempt is due, while pending. */
@@ -598,7 +750,7 @@ export interface CreateDomainRequest {
598
750
  }
599
751
 
600
752
  /** A DNS record the domain needs and whether it has been seen. */
601
- export interface DnsRecord<Type extends 'CNAME' | 'TXT'> {
753
+ export interface DnsRecord<Type extends 'CNAME' | 'TXT' | 'MX'> {
602
754
  type: Type;
603
755
  /** The name to create the record under. */
604
756
  host: string;
@@ -635,6 +787,43 @@ export interface DomainList {
635
787
  domains: SendingDomain[];
636
788
  }
637
789
 
790
+ /** The body POST /v1/inbound/domains takes. */
791
+ export interface CreateInboundDomainRequest {
792
+ /** An inbound stream of the server. */
793
+ streamId: string;
794
+ /** The domain to receive on, such as post.example.se. */
795
+ domain: string;
796
+ }
797
+
798
+ /** A domain on an inbound stream and the two records it needs. */
799
+ export interface InboundDomain {
800
+ inboundDomainId: string;
801
+ /** The inbound stream the domain delivers to. */
802
+ streamId: string;
803
+ /** The domain, lower-cased and IDNA-encoded. */
804
+ domain: string;
805
+ /** True while both records are seen; only then is mail to the domain accepted. */
806
+ verified: boolean;
807
+ createdAt: string;
808
+ /** When the records were last looked up. */
809
+ lastCheckedAt: string | null;
810
+ /** When a verified domain lost its records. Mail to it is deferred for 72 hours from then and refused after, until the records are back. */
811
+ unverifiedAt: string | null;
812
+ /** The MX record that brings the domain's mail to Sendora, at any priority. */
813
+ mx: DnsRecord<'MX'>;
814
+ /** The TXT record that proves the claim; it carries a token only you were shown. */
815
+ txt: DnsRecord<'TXT'>;
816
+ }
817
+
818
+ /** The domain as it stands after a check, and what each lookup found. */
819
+ export interface VerifiedInboundDomain extends InboundDomain {
820
+ check: { mx: RecordCheck; txt: RecordCheck };
821
+ }
822
+
823
+ export interface InboundDomainList {
824
+ domains: InboundDomain[];
825
+ }
826
+
638
827
  /** What every event about a message carries. */
639
828
  export interface MessageEventFields {
640
829
  /** The delivery id. Deliveries arrive at least once, so key your handling on it. */
@@ -831,7 +1020,7 @@ export interface InboundEventContent {
831
1020
  /**
832
1021
  * A message received on an inbound stream. The content fields are present
833
1022
  * for a webhook with `inboundContent: 'full'` and absent for
834
- * `reference`; `'contentOmitted' in event` tells them apart.
1023
+ * `reference`; `event.contentOmitted !== undefined` tells them apart.
835
1024
  */
836
1025
  export type InboundEvent = InboundEventFields &
837
1026
  (InboundEventContent | { contentOmitted?: undefined });
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
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';
2
+ export const SDK_VERSION = '2.1.0';
@@ -33,11 +33,13 @@ const decoder = new TextDecoder();
33
33
 
34
34
  /**
35
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`.
36
+ * by `event`. The header is `Sendora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>]`,
37
+ * one hex per live secret of the webhook, each an HMAC-SHA256 with that
38
+ * secret over `<t>.<raw body>`; the one your secret produces is enough, so
39
+ * a secret being rolled in verifies as well as the old one. The timestamp
40
+ * is inside the signed text, so a captured request cannot be replayed
41
+ * after the tolerance. Throws `SendoraError` with `invalid_signature` or
42
+ * `stale_signature`.
41
43
  *
42
44
  * @example
43
45
  * const event = await verifyWebhook({
@@ -72,36 +74,55 @@ export async function verifyWebhook(input: VerifyWebhookInput): Promise<WebhookE
72
74
  false,
73
75
  ['verify'],
74
76
  );
75
- if (!(await crypto.subtle.verify('HMAC', key, signature.mac, signed))) {
76
- throw invalidSignature('The signature does not match the body.');
77
+ // Every signature is checked, so the time taken says nothing about which one matched.
78
+ let matched = false;
79
+ for (const mac of signature.macs) {
80
+ matched = (await crypto.subtle.verify('HMAC', key, mac, signed)) || matched;
81
+ }
82
+ if (!matched) {
83
+ throw invalidSignature('No signature in the header matches the body under this secret.');
77
84
  }
78
85
  return parseEvent(decoder.decode(body));
79
86
  }
80
87
 
88
+ /** The timestamp and every `v1` value, one per live secret of the webhook. */
81
89
  function parseSignature(header: string | null | undefined): {
82
90
  timestamp: number;
83
- mac: Uint8Array<ArrayBuffer>;
91
+ macs: Uint8Array<ArrayBuffer>[];
84
92
  } {
85
93
  if (typeof header !== 'string' || header === '') {
86
94
  throw invalidSignature('The request carries no Sendora-Signature header.');
87
95
  }
88
- const parts = new Map<string, string>();
96
+ let timestamp: number | undefined;
97
+ const macs: Uint8Array<ArrayBuffer>[] = [];
89
98
  for (const part of header.split(',')) {
90
99
  const separator = part.indexOf('=');
91
- if (separator > 0) {
92
- parts.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim());
100
+ if (separator <= 0) {
101
+ continue;
102
+ }
103
+ const name = part.slice(0, separator).trim();
104
+ const value = part.slice(separator + 1).trim();
105
+ if (name === 't') {
106
+ timestamp = Number(value);
107
+ } else if (name === 'v1') {
108
+ if (!MAC_HEX.test(value)) {
109
+ throw invalidSignature('The Sendora-Signature header is malformed.');
110
+ }
111
+ macs.push(bytesOf(value));
93
112
  }
94
113
  }
95
- const timestamp = Number(parts.get('t'));
96
- const hex = parts.get('v1');
97
- if (!Number.isInteger(timestamp) || hex === undefined || !MAC_HEX.test(hex)) {
114
+ if (timestamp === undefined || !Number.isInteger(timestamp) || macs.length === 0) {
98
115
  throw invalidSignature('The Sendora-Signature header is malformed.');
99
116
  }
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);
117
+ return { timestamp, macs };
118
+ }
119
+
120
+ function bytesOf(hex: string): Uint8Array<ArrayBuffer> {
121
+ const bytes = new Uint8Array(hex.length / 2);
122
+ for (let i = 0; i < bytes.length; i += 1) {
123
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
103
124
  }
104
- return { timestamp, mac };
125
+ return bytes;
105
126
  }
106
127
 
107
128
  function parseEvent(text: string): WebhookEvent {
package/src/webhooks.ts CHANGED
@@ -2,6 +2,7 @@ import { paginate } from './pagination.ts';
2
2
  import type { Transport } from './transport.ts';
3
3
  import type {
4
4
  CreatedWebhook,
5
+ CreatedWebhookSecret,
5
6
  CreateWebhookRequest,
6
7
  Delivery,
7
8
  DeliveryPage,
@@ -74,6 +75,34 @@ export class WebhooksResource {
74
75
  });
75
76
  }
76
77
 
78
+ /**
79
+ * Adds a second live secret and answers its value once. Every delivery
80
+ * then carries one signature per live secret, the newest first, so
81
+ * switch your receiver to the new secret and delete the old one; a third
82
+ * is refused with `secret_limit`.
83
+ *
84
+ *
85
+ * const { secret, secretId } = await sendora.webhooks.createSecret(webhookId);
86
+ */
87
+ createSecret(webhookId: string, options: RequestOptions = {}): Promise<CreatedWebhookSecret> {
88
+ return this.#transport.request<CreatedWebhookSecret>({
89
+ method: 'POST',
90
+ path: `/v1/webhooks/${encodeURIComponent(webhookId)}/secrets`,
91
+ idempotent: false,
92
+ signal: options.signal,
93
+ });
94
+ }
95
+
96
+ /** Deletes a secret; deliveries are no longer signed with it. The last live secret is refused (`last_secret`). */
97
+ deleteSecret(webhookId: string, secretId: string, options: RequestOptions = {}): Promise<void> {
98
+ return this.#transport.request<undefined>({
99
+ method: 'DELETE',
100
+ path: `/v1/webhooks/${encodeURIComponent(webhookId)}/secrets/${encodeURIComponent(secretId)}`,
101
+ idempotent: true,
102
+ signal: options.signal,
103
+ });
104
+ }
105
+
77
106
  /**
78
107
  * One page of the events handed to the webhook, newest first, with the
79
108
  * outcome of the last attempt; `status` narrows to `pending`,