@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/README.md +67 -46
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +6 -0
- package/dist/client.js.map +1 -1
- package/dist/error.d.ts +2 -2
- package/dist/error.d.ts.map +1 -1
- package/dist/error.js +9 -2
- package/dist/error.js.map +1 -1
- package/dist/inbound-domains.d.ts +36 -0
- package/dist/inbound-domains.d.ts.map +1 -0
- package/dist/inbound-domains.js +72 -0
- package/dist/inbound-domains.js.map +1 -0
- package/dist/inbound.d.ts +61 -0
- package/dist/inbound.d.ts.map +1 -0
- package/dist/inbound.js +106 -0
- package/dist/inbound.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/transport.d.ts +2 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +15 -5
- package/dist/transport.js.map +1 -1
- package/dist/types.d.ts +142 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -8
- package/skills/sendora/SKILL.md +1 -1
- package/src/client.ts +6 -0
- package/src/error.ts +13 -2
- package/src/inbound-domains.ts +89 -0
- package/src/inbound.ts +135 -0
- package/src/index.ts +2 -0
- package/src/transport.ts +26 -5
- package/src/types.ts +147 -2
- package/src/version.ts +1 -1
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>(
|
|
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(
|
|
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`; `
|
|
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.
|
|
2
|
+
export const SDK_VERSION = '1.2.0';
|