@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/inbound.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { paginate } from './pagination.ts';
2
+ import type { Transport } from './transport.ts';
3
+ import type {
4
+ InboundMessage,
5
+ InboundMessageDetail,
6
+ InboundPage,
7
+ InboundSearch,
8
+ InboundSearchBody,
9
+ RequestOptions,
10
+ } from './types.ts';
11
+
12
+ /** The mail the token's server has received on its inbound stream. */
13
+ export class InboundResource {
14
+ readonly #transport: Transport;
15
+
16
+ constructor(transport: Transport) {
17
+ this.#transport = transport;
18
+ }
19
+
20
+ /**
21
+ * One received message: its envelope, parties, headers, text and HTML,
22
+ * and its attachments described. The attachment bytes and the raw
23
+ * message are downloads of their own. Once the stream's content window
24
+ * has passed, `contentAvailable` is false and the content fields are
25
+ * null.
26
+ *
27
+ * @example
28
+ * const message = await sendora.inbound.get(inboundMessageId);
29
+ * console.log(message.from?.address, message.subject, message.text);
30
+ */
31
+ get(inboundMessageId: string, options: RequestOptions = {}): Promise<InboundMessageDetail> {
32
+ return this.#transport.request<InboundMessageDetail>({
33
+ method: 'GET',
34
+ path: `/v1/inbound/${encodeURIComponent(inboundMessageId)}`,
35
+ idempotent: true,
36
+ signal: options.signal,
37
+ });
38
+ }
39
+
40
+ /**
41
+ * One page of received messages, newest first. Every filter is
42
+ * optional; pass the page's `next` as `after` for the following page,
43
+ * or use `searchAll`.
44
+ *
45
+ * @example
46
+ * const page = await sendora.inbound.search({ from: 'anna@example.com', limit: 20 });
47
+ */
48
+ search(search: InboundSearch = {}, options: RequestOptions = {}): Promise<InboundPage> {
49
+ return this.#transport.request<InboundPage>({
50
+ method: 'POST',
51
+ path: '/v1/inbound/search',
52
+ body: encodeInboundSearch(search),
53
+ idempotent: true,
54
+ signal: options.signal,
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Every received message the filters match, page by page, for `for await`.
60
+ *
61
+ * @example
62
+ * for await (const message of sendora.inbound.searchAll({ recipient: address })) {
63
+ * console.log(message.receivedAt, message.subject);
64
+ * }
65
+ */
66
+ searchAll(
67
+ search: InboundSearch = {},
68
+ options: RequestOptions = {},
69
+ ): AsyncIterable<InboundMessage> {
70
+ return paginate(
71
+ (after) => this.search({ ...search, after }, options),
72
+ (page) => page.messages,
73
+ (page) => page.next,
74
+ search.after,
75
+ );
76
+ }
77
+
78
+ /**
79
+ * The message byte for byte as it was received, as `message/rfc822`.
80
+ * Throws `content_expired` once the stream's content window has passed.
81
+ *
82
+ * @example
83
+ * const raw = await sendora.inbound.raw(inboundMessageId);
84
+ * await writeFile(`${inboundMessageId}.eml`, raw);
85
+ */
86
+ raw(inboundMessageId: string, options: RequestOptions = {}): Promise<Uint8Array> {
87
+ return this.#transport.requestBytes({
88
+ method: 'GET',
89
+ path: `/v1/inbound/${encodeURIComponent(inboundMessageId)}/raw`,
90
+ idempotent: true,
91
+ signal: options.signal,
92
+ });
93
+ }
94
+
95
+ /**
96
+ * An attachment's bytes, whatever the sender declared them to be; the
97
+ * name and the declared type are on the message. Throws
98
+ * `content_expired` once the stream's content window has passed.
99
+ *
100
+ * @example
101
+ * const message = await sendora.inbound.get(inboundMessageId);
102
+ * for (const attachment of message.attachments) {
103
+ * const bytes = await sendora.inbound.attachment(inboundMessageId, attachment.attachmentId);
104
+ * }
105
+ */
106
+ attachment(
107
+ inboundMessageId: string,
108
+ attachmentId: string,
109
+ options: RequestOptions = {},
110
+ ): Promise<Uint8Array> {
111
+ return this.#transport.requestBytes({
112
+ method: 'GET',
113
+ path: `/v1/inbound/${encodeURIComponent(inboundMessageId)}/attachments/${encodeURIComponent(attachmentId)}`,
114
+ idempotent: true,
115
+ signal: options.signal,
116
+ });
117
+ }
118
+ }
119
+
120
+ /** The body as the API takes it: times as ISO 8601 strings. */
121
+ export function encodeInboundSearch(search: InboundSearch): InboundSearchBody {
122
+ const { receivedFrom, receivedTo, ...fields } = search;
123
+ const body: InboundSearchBody = { ...fields };
124
+ if (receivedFrom !== undefined) {
125
+ body.receivedFrom = isoOf(receivedFrom);
126
+ }
127
+ if (receivedTo !== undefined) {
128
+ body.receivedTo = isoOf(receivedTo);
129
+ }
130
+ return body;
131
+ }
132
+
133
+ function isoOf(value: Date | string): string {
134
+ return typeof value === 'string' ? value : value.toISOString();
135
+ }
package/src/index.ts CHANGED
@@ -1,11 +1,18 @@
1
- export { DEFAULT_BASE_URL, Sendora } from './client.ts';
1
+ export { SendoraAccount } from './account.ts';
2
+ export type { SendoraAccountOptions } from './account.ts';
3
+ export { Sendora } from './client.ts';
2
4
  export type { SendoraOptions } from './client.ts';
3
5
  export type { BroadcastsResource } from './broadcasts.ts';
4
6
  export type { DomainsResource } from './domains.ts';
5
7
  export type { EmailResource } from './email.ts';
6
8
  export { SendoraError } from './error.ts';
7
9
  export type { SendoraErrorCode } from './error.ts';
10
+ export type { InboundResource } from './inbound.ts';
11
+ export type { InboundDomainsResource } from './inbound-domains.ts';
8
12
  export type { MessagesResource } from './messages.ts';
13
+ export { DEFAULT_BASE_URL } from './options.ts';
14
+ export type { ClientOptions } from './options.ts';
15
+ export type { ServersResource, ServerTokensResource } from './servers.ts';
9
16
  export type { StreamsResource } from './streams.ts';
10
17
  export type { SuppressionsResource } from './suppressions.ts';
11
18
  export type { TokensResource } from './tokens.ts';
package/src/options.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { DEFAULT_MAX_RETRIES } from './retry.ts';
2
+ import { Transport } from './transport.ts';
3
+ import { SDK_VERSION } from './version.ts';
4
+
5
+ export const DEFAULT_BASE_URL = 'https://api.sendora.se';
6
+ const DEFAULT_TIMEOUT_MS = 30_000;
7
+
8
+ /** What both clients take besides the key. */
9
+ export interface ClientOptions {
10
+ /** The API's origin; https://api.sendora.se unless you test against another. */
11
+ baseUrl?: string | undefined;
12
+ /** The fetch to use; the global one unless you need a proxy or a fake. */
13
+ fetch?: typeof fetch | undefined;
14
+ /** How long one attempt may take; 30 seconds by default. */
15
+ timeoutMs?: number | undefined;
16
+ /** How many times a failed call is repeated when repeating is safe; 2 by default, 0 turns retries off. */
17
+ maxRetries?: number | undefined;
18
+ }
19
+
20
+ /**
21
+ * The transport a client's resources share, built once at construction so
22
+ * a missing key or a nonsense option fails at startup rather than at the
23
+ * first call.
24
+ */
25
+ export function transportFor(
26
+ token: string | undefined,
27
+ options: ClientOptions,
28
+ missingKey: string,
29
+ ): Transport {
30
+ if (token === undefined || token.trim() === '') {
31
+ throw new TypeError(missingKey);
32
+ }
33
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
34
+ new URL(baseUrl);
35
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
36
+ if (!(timeoutMs > 0)) {
37
+ throw new TypeError('timeoutMs must be a positive number of milliseconds.');
38
+ }
39
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
40
+ if (!Number.isInteger(maxRetries) || maxRetries < 0) {
41
+ throw new TypeError('maxRetries must be a whole number of zero or more.');
42
+ }
43
+ return new Transport({
44
+ baseUrl,
45
+ token,
46
+ fetch: options.fetch ?? fetch,
47
+ timeoutMs,
48
+ maxRetries,
49
+ userAgent: `sendora-sdk/${SDK_VERSION}`,
50
+ });
51
+ }
package/src/servers.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { Transport } from './transport.ts';
2
+ import type {
3
+ CreatedServer,
4
+ CreatedToken,
5
+ CreateServerRequest,
6
+ CreateTokenRequest,
7
+ RequestOptions,
8
+ Server,
9
+ ServerList,
10
+ Token,
11
+ TokenList,
12
+ UpdateServerRequest,
13
+ } from './types.ts';
14
+
15
+ /**
16
+ * The keys of one server, managed with the account key. A server holds at
17
+ * most two live keys, so rotation is create, switch, revoke.
18
+ */
19
+ export class ServerTokensResource {
20
+ readonly #transport: Transport;
21
+
22
+ constructor(transport: Transport) {
23
+ this.#transport = transport;
24
+ }
25
+
26
+ /**
27
+ * Creates another live key for the server and answers its value once; it
28
+ * is never shown again, so store it at once. A third live key is refused
29
+ * with `token_limit`.
30
+ *
31
+ * @example
32
+ * const { token } = await account.servers.tokens.create(serverId, { name: 'Fakturasystemet' });
33
+ */
34
+ create(
35
+ serverId: string,
36
+ request: CreateTokenRequest,
37
+ options: RequestOptions = {},
38
+ ): Promise<CreatedToken> {
39
+ return this.#transport.request<CreatedToken>({
40
+ method: 'POST',
41
+ path: `/v1/servers/${encodeURIComponent(serverId)}/tokens`,
42
+ body: request,
43
+ idempotent: false,
44
+ signal: options.signal,
45
+ });
46
+ }
47
+
48
+ /** Every key of the server, revoked ones included. */
49
+ list(serverId: string, options: RequestOptions = {}): Promise<TokenList> {
50
+ return this.#transport.request<TokenList>({
51
+ method: 'GET',
52
+ path: `/v1/servers/${encodeURIComponent(serverId)}/tokens`,
53
+ idempotent: true,
54
+ signal: options.signal,
55
+ });
56
+ }
57
+
58
+ /** One key by id; its value is never part of the answer. */
59
+ get(serverId: string, tokenId: string, options: RequestOptions = {}): Promise<Token> {
60
+ return this.#transport.request<Token>({
61
+ method: 'GET',
62
+ path: `/v1/servers/${encodeURIComponent(serverId)}/tokens/${encodeURIComponent(tokenId)}`,
63
+ idempotent: true,
64
+ signal: options.signal,
65
+ });
66
+ }
67
+
68
+ /**
69
+ * The key stops working at once. The last live key of a server cannot be
70
+ * revoked (`last_token`), so a server is never locked out.
71
+ *
72
+ * @example
73
+ * await account.servers.tokens.revoke(serverId, tokenId);
74
+ */
75
+ revoke(serverId: string, tokenId: string, options: RequestOptions = {}): Promise<void> {
76
+ return this.#transport.request<undefined>({
77
+ method: 'DELETE',
78
+ path: `/v1/servers/${encodeURIComponent(serverId)}/tokens/${encodeURIComponent(tokenId)}`,
79
+ idempotent: true,
80
+ signal: options.signal,
81
+ });
82
+ }
83
+ }
84
+
85
+ /** The account's servers: each an isolation boundary with its own keys, streams, log, suppressions and webhooks. */
86
+ export class ServersResource {
87
+ readonly #transport: Transport;
88
+ /** The keys of each server. */
89
+ readonly tokens: ServerTokensResource;
90
+
91
+ constructor(transport: Transport) {
92
+ this.#transport = transport;
93
+ this.tokens = new ServerTokensResource(transport);
94
+ }
95
+
96
+ /**
97
+ * Creates a server with its default transactional stream and its first
98
+ * key, whose value is answered this once. A taken name is
99
+ * `server_exists`; the account's cap is `server_limit` with `max`.
100
+ *
101
+ * @example
102
+ * const server = await account.servers.create({ name: 'Fakturering' });
103
+ * const sendora = new Sendora({ token: server.token.token });
104
+ */
105
+ create(request: CreateServerRequest, options: RequestOptions = {}): Promise<CreatedServer> {
106
+ return this.#transport.request<CreatedServer>({
107
+ method: 'POST',
108
+ path: '/v1/servers',
109
+ body: request,
110
+ idempotent: false,
111
+ signal: options.signal,
112
+ });
113
+ }
114
+
115
+ /** Every server of the account, oldest first. */
116
+ list(options: RequestOptions = {}): Promise<ServerList> {
117
+ return this.#transport.request<ServerList>({
118
+ method: 'GET',
119
+ path: '/v1/servers',
120
+ idempotent: true,
121
+ signal: options.signal,
122
+ });
123
+ }
124
+
125
+ /** One server by id. */
126
+ get(serverId: string, options: RequestOptions = {}): Promise<Server> {
127
+ return this.#transport.request<Server>({
128
+ method: 'GET',
129
+ path: `/v1/servers/${encodeURIComponent(serverId)}`,
130
+ idempotent: true,
131
+ signal: options.signal,
132
+ });
133
+ }
134
+
135
+ /** Renames the server; the id, the keys and everything it holds stay. A taken name is `server_exists`. */
136
+ update(
137
+ serverId: string,
138
+ request: UpdateServerRequest,
139
+ options: RequestOptions = {},
140
+ ): Promise<Server> {
141
+ return this.#transport.request<Server>({
142
+ method: 'PATCH',
143
+ path: `/v1/servers/${encodeURIComponent(serverId)}`,
144
+ body: request,
145
+ idempotent: true,
146
+ signal: options.signal,
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Removes the server with its keys, streams, messages, suppressions and
152
+ * webhooks. Refused with `server_in_flight` while its messages are still
153
+ * being delivered; try again once they have left.
154
+ *
155
+ * @example
156
+ * await account.servers.delete(serverId);
157
+ */
158
+ delete(serverId: string, options: RequestOptions = {}): Promise<void> {
159
+ return this.#transport.request<undefined>({
160
+ method: 'DELETE',
161
+ path: `/v1/servers/${encodeURIComponent(serverId)}`,
162
+ idempotent: true,
163
+ signal: options.signal,
164
+ });
165
+ }
166
+ }
package/src/transport.ts CHANGED
@@ -46,11 +46,20 @@ export class Transport {
46
46
  }
47
47
 
48
48
  async request<T>(spec: RequestSpec): Promise<T> {
49
+ return this.#send<T>(spec, 'json');
50
+ }
51
+
52
+ /** A download: the answer's bytes as they came, with the same errors and retries as any other request. */
53
+ async requestBytes(spec: RequestSpec): Promise<Uint8Array> {
54
+ return this.#send<Uint8Array>(spec, 'bytes');
55
+ }
56
+
57
+ async #send<T>(spec: RequestSpec, shape: 'json' | 'bytes'): Promise<T> {
49
58
  const url = this.#url(spec);
50
59
  const body = spec.body === undefined ? undefined : JSON.stringify(spec.body);
51
60
  let waitedMs = 0;
52
61
  for (let attempt = 0; ; attempt += 1) {
53
- const outcome = await this.#attempt<T>(spec, url, body);
62
+ const outcome = await this.#attempt<T>(spec, url, body, shape);
54
63
  if (outcome.ok) {
55
64
  return outcome.value;
56
65
  }
@@ -69,12 +78,17 @@ export class Transport {
69
78
  }
70
79
  }
71
80
 
72
- async #attempt<T>(spec: RequestSpec, url: string, body: string | undefined): Promise<Attempt<T>> {
81
+ async #attempt<T>(
82
+ spec: RequestSpec,
83
+ url: string,
84
+ body: string | undefined,
85
+ shape: 'json' | 'bytes',
86
+ ): Promise<Attempt<T>> {
73
87
  const timeout = AbortSignal.timeout(this.#options.timeoutMs);
74
88
  const signal = spec.signal === undefined ? timeout : AbortSignal.any([spec.signal, timeout]);
75
89
  const init: RequestInit = {
76
90
  method: spec.method,
77
- headers: this.#headers(spec, body),
91
+ headers: this.#headers(spec, body, shape),
78
92
  signal,
79
93
  redirect: 'manual',
80
94
  };
@@ -113,6 +127,9 @@ export class Transport {
113
127
  await response.body?.cancel();
114
128
  return { ok: true, value: undefined as T };
115
129
  }
130
+ if (shape === 'bytes' && response.ok) {
131
+ return { ok: true, value: new Uint8Array(await response.arrayBuffer()) as T };
132
+ }
116
133
  const json = parseJson(await response.text());
117
134
  if (response.status === 0 || (response.status >= 300 && response.status < 400)) {
118
135
  return {
@@ -143,10 +160,14 @@ export class Transport {
143
160
  };
144
161
  }
145
162
 
146
- #headers(spec: RequestSpec, body: string | undefined): Record<string, string> {
163
+ #headers(
164
+ spec: RequestSpec,
165
+ body: string | undefined,
166
+ shape: 'json' | 'bytes',
167
+ ): Record<string, string> {
147
168
  const headers: Record<string, string> = {
148
169
  authorization: `Bearer ${this.#options.token}`,
149
- accept: 'application/json',
170
+ accept: shape === 'bytes' ? '*/*' : 'application/json',
150
171
  'user-agent': this.#options.userAgent,
151
172
  ...spec.headers,
152
173
  };