@sendora/sdk 1.1.0 → 1.2.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/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
@@ -5,6 +5,8 @@ export type { DomainsResource } from './domains.ts';
5
5
  export type { EmailResource } from './email.ts';
6
6
  export { SendoraError } from './error.ts';
7
7
  export type { SendoraErrorCode } from './error.ts';
8
+ export type { InboundResource } from './inbound.ts';
9
+ export type { InboundDomainsResource } from './inbound-domains.ts';
8
10
  export type { MessagesResource } from './messages.ts';
9
11
  export type { StreamsResource } from './streams.ts';
10
12
  export type { SuppressionsResource } from './suppressions.ts';
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
  };
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. */
@@ -567,6 +673,8 @@ export interface Delivery {
567
673
  event: WebhookEventName;
568
674
  /** Null for an event about usage rather than a message. */
569
675
  messageId: string | null;
676
+ /** The received message an inbound event is about; null for every other event. */
677
+ inboundMessageId: string | null;
570
678
  status: DeliveryStatus;
571
679
  attempts: number;
572
680
  /** When the next attempt is due, while pending. */
@@ -598,7 +706,7 @@ export interface CreateDomainRequest {
598
706
  }
599
707
 
600
708
  /** A DNS record the domain needs and whether it has been seen. */
601
- export interface DnsRecord<Type extends 'CNAME' | 'TXT'> {
709
+ export interface DnsRecord<Type extends 'CNAME' | 'TXT' | 'MX'> {
602
710
  type: Type;
603
711
  /** The name to create the record under. */
604
712
  host: string;
@@ -635,6 +743,43 @@ export interface DomainList {
635
743
  domains: SendingDomain[];
636
744
  }
637
745
 
746
+ /** The body POST /v1/inbound/domains takes. */
747
+ export interface CreateInboundDomainRequest {
748
+ /** An inbound stream of the server. */
749
+ streamId: string;
750
+ /** The domain to receive on, such as post.example.se. */
751
+ domain: string;
752
+ }
753
+
754
+ /** A domain on an inbound stream and the two records it needs. */
755
+ export interface InboundDomain {
756
+ inboundDomainId: string;
757
+ /** The inbound stream the domain delivers to. */
758
+ streamId: string;
759
+ /** The domain, lower-cased and IDNA-encoded. */
760
+ domain: string;
761
+ /** True while both records are seen; only then is mail to the domain accepted. */
762
+ verified: boolean;
763
+ createdAt: string;
764
+ /** When the records were last looked up. */
765
+ lastCheckedAt: string | null;
766
+ /** 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. */
767
+ unverifiedAt: string | null;
768
+ /** The MX record that brings the domain's mail to Sendora, at any priority. */
769
+ mx: DnsRecord<'MX'>;
770
+ /** The TXT record that proves the claim; it carries a token only you were shown. */
771
+ txt: DnsRecord<'TXT'>;
772
+ }
773
+
774
+ /** The domain as it stands after a check, and what each lookup found. */
775
+ export interface VerifiedInboundDomain extends InboundDomain {
776
+ check: { mx: RecordCheck; txt: RecordCheck };
777
+ }
778
+
779
+ export interface InboundDomainList {
780
+ domains: InboundDomain[];
781
+ }
782
+
638
783
  /** What every event about a message carries. */
639
784
  export interface MessageEventFields {
640
785
  /** The delivery id. Deliveries arrive at least once, so key your handling on it. */
@@ -831,7 +976,7 @@ export interface InboundEventContent {
831
976
  /**
832
977
  * A message received on an inbound stream. The content fields are present
833
978
  * for a webhook with `inboundContent: 'full'` and absent for
834
- * `reference`; `'contentOmitted' in event` tells them apart.
979
+ * `reference`; `event.contentOmitted !== undefined` tells them apart.
835
980
  */
836
981
  export type InboundEvent = InboundEventFields &
837
982
  (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 = '1.2.0';