@openemail/sdk 0.0.5 → 0.0.7
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 +54 -3
- package/index.cjs +191 -25
- package/index.d.cts +133 -13
- package/index.d.ts +133 -13
- package/index.js +188 -26
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
|
|
45
45
|
## Intro to the Npm Package
|
|
46
46
|
|
|
47
|
-
The official TypeScript client for the OpenEmail API. A method for every one of the
|
|
47
|
+
The official TypeScript client for the OpenEmail API. A method for every one of the 197 endpoints, 282 in all once the paging and upload helpers are counted, typed end to end, with zero dependencies. It runs on Node 20+, Bun, Deno and Cloudflare Workers, and ships as ESM and CommonJS.
|
|
48
48
|
|
|
49
|
-
It carries a workspace API key, so it belongs on a server. The one exception is disposable inboxes, which need no
|
|
49
|
+
It carries a workspace API key or an OAuth access token, so it belongs on a server or in a tool that runs on your own machine. The one exception is disposable inboxes, which need no credential and work in a browser.
|
|
50
50
|
|
|
51
51
|
### Installing
|
|
52
52
|
```bash
|
|
@@ -158,6 +158,46 @@ const { items } = await temp.listMessages(inbox.id, { inboxToken: inbox.token })
|
|
|
158
158
|
|
|
159
159
|
`create` needs no credential and is the only call that returns the inbox token, so keep it.
|
|
160
160
|
|
|
161
|
+
### OAuth access tokens
|
|
162
|
+
An app a person connected to OpenEmail with OAuth, such as a command line tool or an agent, holds an access token rather than an API key. Pass it as `accessToken`:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
import { OpenEmail } from '@openemail/sdk'
|
|
166
|
+
|
|
167
|
+
export const openemail = new OpenEmail({
|
|
168
|
+
accessToken: async () => session.freshAccessToken()
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`accessToken` takes the token itself, or a function that returns it. The function runs before every request, so renew the token there when it is close to expiring and the client never has to be rebuilt. Pass `apiKey` or `accessToken`, not both. `createOpenEmail()` reads `OPENEMAIL_ACCESS_TOKEN` when you pass neither and `OPENEMAIL_API_KEY` is not set. `me.get()` answers `object: 'oauth_token'` for a token, with the connected app's `clientId` and `expiresAt`, when the person's approval of the app runs out.
|
|
173
|
+
|
|
174
|
+
A token acts for a person, so before a sensitive change, such as deleting a domain or changing a webhook, it is asked for the same verification code the web app asks for. The request fails with `isStepUpRequired`. Ask for a code, check it, then replay the request:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
import { OpenEmailApiError } from '@openemail/sdk'
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await openemail.domains.delete(domain.id)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (!(error instanceof OpenEmailApiError) || !error.isStepUpRequired) throw error
|
|
185
|
+
|
|
186
|
+
const challenge = await openemail.security.beginStepUp()
|
|
187
|
+
|
|
188
|
+
const code = await ask(challenge.method === 'email'
|
|
189
|
+
? `Enter the code we emailed to ${challenge.sentTo}`
|
|
190
|
+
: 'Enter the code from your authenticator app, or a backup code')
|
|
191
|
+
|
|
192
|
+
await openemail.security.verifyStepUp({ code })
|
|
193
|
+
await openemail.domains.delete(domain.id)
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
An emailed code works for 10 minutes, and `beginStepUp({ resend: true })` sends a fresh one. The email shows the name the app registered with, its client ID and the change it asked to make, so the person can check who is asking before they hand the code over. Once a code is verified the app is not asked again for 60 minutes, for any of the 14 changes that ask for one, through the REST API or through the MCP tools that make the same changes. `verifyStepUp` lists them. `security.stepUpStatus()` says whether it is verified right now. An app that cannot ask for a code, such as an MCP connector, can instead be allowed by the person for 60 minutes with Allow changes for 60 minutes in Account settings, Connected apps, on the website. API keys are never asked for a code.
|
|
198
|
+
|
|
199
|
+
Each app has its own budget of codes, so another app never uses it up, and neither does the web app, which keeps its own 10 an hour. An app can ask for 5 codes an hour and 20 in 24 hours, then gets 429 `step_up_throttled`. A code allows 5 tries, and after the fifth wrong one a plain `beginStepUp()` starts a fresh challenge. Ten wrong codes in 24 hours pause verification for the app, and 20 in 24 hours from all of a person's apps together pause it for every app they connected. Either way both calls answer 429 `step_up_locked`, with the time the pause ends in the message. Codes entered in the web app count toward neither pause, and the person can still verify there.
|
|
200
|
+
|
|
161
201
|
### Configuring
|
|
162
202
|
Pass an options object instead of the bare key when the defaults are not right:
|
|
163
203
|
|
|
@@ -176,7 +216,18 @@ export const openemail = new OpenEmail({
|
|
|
176
216
|
|
|
177
217
|
`createOpenEmail(options)` is the same constructor with one difference: anything you leave out is read from the environment. The shipped `openemail` takes the same options through `init(options)`, called once at startup.
|
|
178
218
|
|
|
179
|
-
`baseUrl` also comes from `OPENEMAIL_BASE_URL`,
|
|
219
|
+
`baseUrl` also comes from `OPENEMAIL_BASE_URL`. Use an `https:` origin: the client refuses to send an API key, an access token or an inbox token over plain `http:`, and throws before the request leaves, unless the server is on this machine at `localhost`, a `127.x.x.x` address or `::1`. A `baseUrl` on `0.0.0.0` throws when the client is built, since that is the address a server listens on: use `127.0.0.1` with the same port. Reads are retried on 408 and 5xx with backoff. A 429 is retried only when it carries a `Retry-After`, and any wait longer than a minute throws instead of sleeping. Writes that cannot safely repeat are not retried. Every method outside `tempMail` takes `{ signal, apiKey }` as its last argument, so one process can serve several workspaces with one client. The `tempMail` methods take `{ signal, inboxToken }` instead.
|
|
220
|
+
|
|
221
|
+
An endpoint no method wraps yet is one `raw.request()` away, with the client's credential, base URL, timeout and retry policy applied:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
const result = await openemail.raw.request('/something-new', {
|
|
225
|
+
method: 'POST',
|
|
226
|
+
body: { name: 'Invoices' }
|
|
227
|
+
})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The path must begin with a single `/`. Anything else, such as `//host/x` or `@host/x`, throws before a request is sent, and so does a path whose finished URL leaves the base URL's origin, so the credential it carries never reaches another host.
|
|
180
231
|
|
|
181
232
|
When a newer version is on npm the client says so once on a TTY. `OPENEMAIL_DISABLE_UPDATE_NOTICE=1` or `{ disableUpdateNotice: true }` turns that off.
|
|
182
233
|
|
package/index.cjs
CHANGED
|
@@ -28,7 +28,7 @@ const BROADCAST_RECIPIENT_FILTERS = {
|
|
|
28
28
|
UNSUBSCRIBED: 'unsubscribed'
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
var version = "0.0.
|
|
31
|
+
var version = "0.0.7";
|
|
32
32
|
|
|
33
33
|
const BUILD_BASE_URL = 'https://api.openemail.uk';
|
|
34
34
|
|
|
@@ -42,8 +42,8 @@ const DEFAULTS = {
|
|
|
42
42
|
const BROWSER_REFUSAL = [
|
|
43
43
|
'OpenEmail refused to start in a browser.',
|
|
44
44
|
'',
|
|
45
|
-
' This client carries a workspace API key that can send mail and read
|
|
46
|
-
' you ship to a browser is readable by anyone who opens devtools.',
|
|
45
|
+
' This client carries a workspace API key or an OAuth access token that can send mail and read',
|
|
46
|
+
' the mailbox. Anything you ship to a browser is readable by anyone who opens devtools.',
|
|
47
47
|
'',
|
|
48
48
|
' Call it from a server, a serverless function or a script instead. Disposable inboxes are',
|
|
49
49
|
' the exception: createTempMail() needs no API key and is safe to use in a browser.',
|
|
@@ -53,6 +53,7 @@ const BROWSER_REFUSAL = [
|
|
|
53
53
|
].join('\n');
|
|
54
54
|
const ENV_VARS = {
|
|
55
55
|
API_KEY: 'OPENEMAIL_API_KEY',
|
|
56
|
+
ACCESS_TOKEN: 'OPENEMAIL_ACCESS_TOKEN',
|
|
56
57
|
BASE_URL: 'OPENEMAIL_BASE_URL'
|
|
57
58
|
};
|
|
58
59
|
const API_KEY_PREFIXES = {
|
|
@@ -63,10 +64,22 @@ const API_KEY_MODES = {
|
|
|
63
64
|
LIVE: 'live',
|
|
64
65
|
TEST: 'test'
|
|
65
66
|
};
|
|
67
|
+
const ACCESS_TOKEN_RULES = {
|
|
68
|
+
RESERVED_PREFIX: 'oe_',
|
|
69
|
+
MAX_LENGTH: 512
|
|
70
|
+
};
|
|
71
|
+
const CREDENTIAL_KINDS = {
|
|
72
|
+
API_KEY: 'apiKey',
|
|
73
|
+
OAUTH: 'oauth'
|
|
74
|
+
};
|
|
66
75
|
const CLIENT_MESSAGES = {
|
|
67
76
|
API_KEY_REQUIRED: `An OpenEmail API key is required. Pass { apiKey } or set ${ENV_VARS.API_KEY}. Create one in OpenEmail under Settings, API keys.`,
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
CREDENTIAL_REQUIRED: `An OpenEmail API key or OAuth access token is required. Pass { apiKey } or { accessToken }, or set ${ENV_VARS.API_KEY} or ${ENV_VARS.ACCESS_TOKEN}. Create a key in OpenEmail under Settings, API keys.`,
|
|
78
|
+
CREDENTIAL_CONFLICT: 'Pass { apiKey } or { accessToken }, not both. Every request carries one credential, so the client cannot tell which of the two you meant.',
|
|
79
|
+
API_KEY_SHAPE: 'is not an OpenEmail API key. The API accepts only keys beginning "oe_live_" or "oe_test_", so a session cookie, a session token or a key for another service is refused. Pass an OAuth access token as { accessToken } instead.',
|
|
80
|
+
ACCESS_TOKEN_SHAPE: `is not an OAuth access token. Pass the access token OpenEmail issued when the app was connected: a string of 1 to ${ACCESS_TOKEN_RULES.MAX_LENGTH} characters that does not begin "${ACCESS_TOKEN_RULES.RESERVED_PREFIX}". An API key goes in { apiKey } instead.`,
|
|
81
|
+
ACCESS_TOKEN_PROVIDED: 'The value the { accessToken } function resolved to',
|
|
82
|
+
BASE_URL_SHAPE: 'is not a usable base URL. Pass an https origin such as "https://api.openemail.uk". Plain http is for a server on this machine only, at localhost, a 127.x.x.x address or ::1, because the client never sends a credential over plain http to any other host.',
|
|
70
83
|
NO_FETCH: 'No global fetch is available. Pass one as { fetch }, or run on Node 20+, Bun, Deno, Cloudflare Workers or a browser.',
|
|
71
84
|
EMPTY_SEGMENT: 'An id must not be empty.',
|
|
72
85
|
FILENAME_REQUIRED: 'A file name is required. Pass it as { filename }, the name the file is stored and downloaded under.',
|
|
@@ -556,6 +569,8 @@ const PATHS = {
|
|
|
556
569
|
TRACKING_STATS: '/tracking/stats',
|
|
557
570
|
CALENDAR_EVENTS: '/calendar/events',
|
|
558
571
|
SETTINGS: '/settings',
|
|
572
|
+
SECURITY_STEP_UP: '/security/step-up',
|
|
573
|
+
SECURITY_STEP_UP_VERIFY: '/security/step-up/verify',
|
|
559
574
|
ROLES: '/roles',
|
|
560
575
|
ROLES_PERMISSIONS: '/roles/permissions',
|
|
561
576
|
MEMBERS: '/members',
|
|
@@ -711,6 +726,20 @@ const API_SCOPES = {
|
|
|
711
726
|
KEYS_MANAGE: 'keys:manage'
|
|
712
727
|
};
|
|
713
728
|
|
|
729
|
+
const STEP_UP_METHODS = {
|
|
730
|
+
EMAIL: 'email',
|
|
731
|
+
TOTP: 'totp'
|
|
732
|
+
};
|
|
733
|
+
const STEP_UP_ERROR_CODES = {
|
|
734
|
+
STEP_UP_REQUIRED: 'step_up_required',
|
|
735
|
+
STEP_UP_NOT_APPLICABLE: 'step_up_not_applicable',
|
|
736
|
+
STEP_UP_THROTTLED: 'step_up_throttled',
|
|
737
|
+
STEP_UP_UNDELIVERABLE: 'step_up_undeliverable',
|
|
738
|
+
STEP_UP_CODE_INVALID: 'step_up_code_invalid',
|
|
739
|
+
STEP_UP_CODE_EXPIRED: 'step_up_code_expired',
|
|
740
|
+
STEP_UP_LOCKED: 'step_up_locked'
|
|
741
|
+
};
|
|
742
|
+
|
|
714
743
|
const SUPPRESSION_REASONS = {
|
|
715
744
|
BOUNCE: 'bounce',
|
|
716
745
|
COMPLAINT: 'complaint',
|
|
@@ -783,7 +812,12 @@ const WEBHOOK_SIGNATURE_HEADERS = WEBHOOK_HEADERS;
|
|
|
783
812
|
const CLIENT_REGISTRY_KEY = Symbol.for('openemail.sdk.client.v1');
|
|
784
813
|
const CLIENT_REGISTRY = globalThis;
|
|
785
814
|
|
|
786
|
-
const
|
|
815
|
+
const LOOPBACK_HOSTNAMES = ['localhost', '::1', '[::1]'];
|
|
816
|
+
const LOOPBACK_IPV4_PATTERN = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
|
|
817
|
+
const LOOPBACK_IPV4_ADDRESS = '127.0.0.1';
|
|
818
|
+
const LOOPBACK_IPV6_ADDRESS = '[::1]';
|
|
819
|
+
const UNSPECIFIED_HOSTNAMES = ['0.0.0.0', '::', '[::]'];
|
|
820
|
+
const LOCAL_HOSTS = [...LOOPBACK_HOSTNAMES, LOOPBACK_IPV4_ADDRESS, ...UNSPECIFIED_HOSTNAMES];
|
|
787
821
|
const ENDPOINT_SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
788
822
|
|
|
789
823
|
const RETRYABLE_STATUSES = new Set(RETRY.STATUSES);
|
|
@@ -892,13 +926,36 @@ const RETRYABLE_METHODS = new Set(RETRY.METHODS);
|
|
|
892
926
|
scopes: [API_SCOPES.WEBHOOKS_READ]});
|
|
893
927
|
|
|
894
928
|
const IsApiKey = (value) => typeof value === 'string' && (value.startsWith(API_KEY_PREFIXES.LIVE) || value.startsWith(API_KEY_PREFIXES.TEST));
|
|
895
|
-
const
|
|
929
|
+
const IsAccessToken = (value) => typeof value === 'string'
|
|
930
|
+
&& value.length > 0
|
|
931
|
+
&& value.length <= ACCESS_TOKEN_RULES.MAX_LENGTH
|
|
932
|
+
&& !value.startsWith(ACCESS_TOKEN_RULES.RESERVED_PREFIX);
|
|
933
|
+
const IsPresent = (value) => value !== undefined && value !== null && value !== '';
|
|
934
|
+
const ModeOf = (apiKey) => apiKey?.startsWith(API_KEY_PREFIXES.TEST) ? API_KEY_MODES.TEST : API_KEY_MODES.LIVE;
|
|
896
935
|
const AssertApiKey = (apiKey, where) => {
|
|
897
936
|
if (!apiKey)
|
|
898
937
|
throw new Error(CLIENT_MESSAGES.API_KEY_REQUIRED);
|
|
899
938
|
if (!IsApiKey(apiKey))
|
|
900
939
|
throw new Error(`${where} ${CLIENT_MESSAGES.API_KEY_SHAPE}`);
|
|
901
940
|
};
|
|
941
|
+
const AssertAccessToken = (accessToken, where) => {
|
|
942
|
+
if (typeof accessToken === 'function')
|
|
943
|
+
return;
|
|
944
|
+
if (!IsAccessToken(accessToken))
|
|
945
|
+
throw new Error(`${where} ${CLIENT_MESSAGES.ACCESS_TOKEN_SHAPE}`);
|
|
946
|
+
};
|
|
947
|
+
const AssertCredential = (apiKey, accessToken, required) => {
|
|
948
|
+
const withKey = IsPresent(apiKey);
|
|
949
|
+
const withToken = IsPresent(accessToken);
|
|
950
|
+
if (withKey && withToken)
|
|
951
|
+
throw new Error(CLIENT_MESSAGES.CREDENTIAL_CONFLICT);
|
|
952
|
+
if (withKey)
|
|
953
|
+
AssertApiKey(apiKey, '{ apiKey }');
|
|
954
|
+
else if (withToken)
|
|
955
|
+
AssertAccessToken(accessToken, '{ accessToken }');
|
|
956
|
+
else if (required)
|
|
957
|
+
throw new Error(CLIENT_MESSAGES.CREDENTIAL_REQUIRED);
|
|
958
|
+
};
|
|
902
959
|
|
|
903
960
|
const IsBrowser = () => {
|
|
904
961
|
const scope = globalThis;
|
|
@@ -936,6 +993,9 @@ class OpenEmailApiError extends Error {
|
|
|
936
993
|
get isScopeMissing() {
|
|
937
994
|
return this.code === ERROR_CODES.INSUFFICIENT_SCOPE;
|
|
938
995
|
}
|
|
996
|
+
get isStepUpRequired() {
|
|
997
|
+
return this.code === STEP_UP_ERROR_CODES.STEP_UP_REQUIRED;
|
|
998
|
+
}
|
|
939
999
|
get isInvalidRequest() {
|
|
940
1000
|
return this.type === ERROR_TYPES.INVALID_REQUEST_ERROR;
|
|
941
1001
|
}
|
|
@@ -1035,8 +1095,25 @@ const Backoff = (attempt) => {
|
|
|
1035
1095
|
return Math.round(ceiling * (0.5 + Math.random() * 0.5));
|
|
1036
1096
|
};
|
|
1037
1097
|
|
|
1038
|
-
const
|
|
1098
|
+
const PATH_SHAPE = 'is not a usable request path. Pass a path on the API that begins with a single "/", such as "/threads". Anything else could send the request, and the credential it carries, to another host.';
|
|
1099
|
+
const OFF_ORIGIN = 'leaves the API origin, so the request was not sent. Pass a path on the API that begins with a single "/", such as "/threads".';
|
|
1100
|
+
const ApiUrl = (baseUrl, path) => {
|
|
1101
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.startsWith('/\\')) {
|
|
1102
|
+
throw new Error(`${JSON.stringify(path)} ${PATH_SHAPE}`);
|
|
1103
|
+
}
|
|
1039
1104
|
const url = `${baseUrl.replace(/\/+$/, '')}${path}`;
|
|
1105
|
+
let origin = null;
|
|
1106
|
+
try {
|
|
1107
|
+
origin = new URL(url).origin;
|
|
1108
|
+
}
|
|
1109
|
+
catch { }
|
|
1110
|
+
if (origin === null || origin !== new URL(baseUrl).origin)
|
|
1111
|
+
throw new Error(`${JSON.stringify(path)} ${OFF_ORIGIN}`);
|
|
1112
|
+
return url;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const BuildUrl = (baseUrl, path, query) => {
|
|
1116
|
+
const url = ApiUrl(baseUrl, path);
|
|
1040
1117
|
if (!query)
|
|
1041
1118
|
return url;
|
|
1042
1119
|
const params = new URLSearchParams();
|
|
@@ -1049,6 +1126,8 @@ const BuildUrl = (baseUrl, path, query) => {
|
|
|
1049
1126
|
return serialized ? `${url}?${serialized}` : url;
|
|
1050
1127
|
};
|
|
1051
1128
|
|
|
1129
|
+
const CleartextCredentialMessage = (baseUrl) => `Refused to send a credential to ${baseUrl} over plain http, where anyone on the network can read it. Use an https base URL. Plain http is accepted only for a server on this machine: localhost, a 127.x.x.x address or ::1.`;
|
|
1130
|
+
|
|
1052
1131
|
const Deadline = (timeoutMs, signal) => {
|
|
1053
1132
|
const controller = new AbortController();
|
|
1054
1133
|
let expired = false;
|
|
@@ -1087,6 +1166,23 @@ const IdempotencyKey = () => {
|
|
|
1087
1166
|
return `${IDEMPOTENCY_KEY_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
|
|
1088
1167
|
};
|
|
1089
1168
|
|
|
1169
|
+
const IsLoopbackHost = (hostname) => {
|
|
1170
|
+
const host = hostname.toLowerCase();
|
|
1171
|
+
return LOOPBACK_HOSTNAMES.includes(host) || LOOPBACK_IPV4_PATTERN.test(host);
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const IsSecureOrigin = (url) => {
|
|
1175
|
+
try {
|
|
1176
|
+
const parsed = new URL(url);
|
|
1177
|
+
return parsed.protocol === 'https:' || (parsed.protocol === 'http:' && IsLoopbackHost(parsed.hostname));
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const IsUnspecifiedHost = (hostname) => UNSPECIFIED_HOSTNAMES.includes(hostname.toLowerCase());
|
|
1185
|
+
|
|
1090
1186
|
const RetryAfter = (value) => {
|
|
1091
1187
|
if (!value)
|
|
1092
1188
|
return undefined;
|
|
@@ -1145,6 +1241,13 @@ const ToErrorProps = (response, body, rawText, retryAfterSeconds) => {
|
|
|
1145
1241
|
};
|
|
1146
1242
|
};
|
|
1147
1243
|
|
|
1244
|
+
const Substitute = (url) => {
|
|
1245
|
+
const suggestion = new URL(url.href);
|
|
1246
|
+
suggestion.hostname = url.hostname.startsWith('[') ? LOOPBACK_IPV6_ADDRESS : LOOPBACK_IPV4_ADDRESS;
|
|
1247
|
+
return suggestion.href.replace(/\/+$/, '');
|
|
1248
|
+
};
|
|
1249
|
+
const UnspecifiedHostMessage = (baseUrl, url) => `${JSON.stringify(baseUrl)} names ${url.hostname}, an address a server listens on, not one to send requests to. For a server on this machine, use ${Substitute(url)}.`;
|
|
1250
|
+
|
|
1148
1251
|
const Describe = (error) => error instanceof Error ? error.message : String(error);
|
|
1149
1252
|
const ParseJson = (text) => {
|
|
1150
1253
|
if (!text)
|
|
@@ -1156,34 +1259,45 @@ const ParseJson = (text) => {
|
|
|
1156
1259
|
return undefined;
|
|
1157
1260
|
}
|
|
1158
1261
|
};
|
|
1159
|
-
const
|
|
1160
|
-
const trimmed = value.trim().replace(/\/+$/, '');
|
|
1262
|
+
const ParseBaseUrl = (value) => {
|
|
1161
1263
|
try {
|
|
1162
|
-
const parsed = new URL(
|
|
1163
|
-
|
|
1164
|
-
|
|
1264
|
+
const parsed = new URL(value);
|
|
1265
|
+
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed : undefined;
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
return undefined;
|
|
1165
1269
|
}
|
|
1166
|
-
|
|
1167
|
-
|
|
1270
|
+
};
|
|
1271
|
+
const ValidBaseUrl = (value) => {
|
|
1272
|
+
const trimmed = value.trim().replace(/\/+$/, '');
|
|
1273
|
+
const parsed = ParseBaseUrl(trimmed);
|
|
1274
|
+
if (!parsed)
|
|
1275
|
+
throw new Error(`${JSON.stringify(value)} ${CLIENT_MESSAGES.BASE_URL_SHAPE}`);
|
|
1276
|
+
if (IsUnspecifiedHost(parsed.hostname))
|
|
1277
|
+
throw new Error(UnspecifiedHostMessage(value, parsed));
|
|
1278
|
+
return trimmed;
|
|
1168
1279
|
};
|
|
1169
1280
|
class Transport {
|
|
1170
1281
|
#apiKey;
|
|
1282
|
+
#accessToken;
|
|
1171
1283
|
#inboxToken;
|
|
1172
1284
|
baseUrl;
|
|
1285
|
+
#secureOrigin;
|
|
1173
1286
|
fetchImpl;
|
|
1174
1287
|
maxRetries;
|
|
1175
1288
|
timeoutMs;
|
|
1176
1289
|
userAgent;
|
|
1177
1290
|
headers;
|
|
1178
1291
|
constructor(options) {
|
|
1179
|
-
|
|
1180
|
-
AssertApiKey(options.apiKey, '{ apiKey }');
|
|
1292
|
+
AssertCredential(options.apiKey, options.accessToken, false);
|
|
1181
1293
|
const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
1182
1294
|
if (!fetchImpl)
|
|
1183
1295
|
throw new Error(CLIENT_MESSAGES.NO_FETCH);
|
|
1184
|
-
this.#apiKey = options.apiKey;
|
|
1296
|
+
this.#apiKey = IsPresent(options.apiKey) ? options.apiKey : undefined;
|
|
1297
|
+
this.#accessToken = IsPresent(options.accessToken) ? options.accessToken : undefined;
|
|
1185
1298
|
this.#inboxToken = options.inboxToken;
|
|
1186
1299
|
this.baseUrl = ValidBaseUrl(options.baseUrl ?? DEFAULTS.BASE_URL);
|
|
1300
|
+
this.#secureOrigin = IsSecureOrigin(this.baseUrl);
|
|
1187
1301
|
this.fetchImpl = fetchImpl;
|
|
1188
1302
|
this.maxRetries = Math.max(0, options.maxRetries ?? DEFAULTS.MAX_RETRIES);
|
|
1189
1303
|
this.timeoutMs = options.timeoutMs ?? DEFAULTS.TIMEOUT_MS;
|
|
@@ -1195,7 +1309,7 @@ class Transport {
|
|
|
1195
1309
|
async request(path, options = {}) {
|
|
1196
1310
|
const method = (options.method ?? HTTP_METHODS.GET).toUpperCase();
|
|
1197
1311
|
const url = BuildUrl(this.baseUrl, path, options.query);
|
|
1198
|
-
const headers = this.headersFor(options);
|
|
1312
|
+
const headers = await this.headersFor(options);
|
|
1199
1313
|
const body = options.raw !== undefined
|
|
1200
1314
|
? options.raw
|
|
1201
1315
|
: options.body === undefined ? undefined : JSON.stringify(options.body);
|
|
@@ -1227,7 +1341,16 @@ class Transport {
|
|
|
1227
1341
|
throw new OpenEmailApiError(ToErrorProps(response, ParseJson(text), text, retryAfter));
|
|
1228
1342
|
}
|
|
1229
1343
|
}
|
|
1230
|
-
|
|
1344
|
+
async resolveAccessToken() {
|
|
1345
|
+
const source = this.#accessToken;
|
|
1346
|
+
if (typeof source !== 'function')
|
|
1347
|
+
return source;
|
|
1348
|
+
const token = await source();
|
|
1349
|
+
if (!IsAccessToken(token))
|
|
1350
|
+
throw new Error(`${CLIENT_MESSAGES.ACCESS_TOKEN_PROVIDED} ${CLIENT_MESSAGES.ACCESS_TOKEN_SHAPE}`);
|
|
1351
|
+
return token;
|
|
1352
|
+
}
|
|
1353
|
+
async headersFor(options) {
|
|
1231
1354
|
const headers = {
|
|
1232
1355
|
...this.headers,
|
|
1233
1356
|
[HEADER_KEYS.ACCEPT]: options.accept ?? CONTENT_TYPES.JSON
|
|
@@ -1241,7 +1364,10 @@ class Transport {
|
|
|
1241
1364
|
if (options.anonymous !== true) {
|
|
1242
1365
|
if (options.apiKey !== undefined)
|
|
1243
1366
|
AssertApiKey(options.apiKey, '{ apiKey } on this call');
|
|
1244
|
-
const
|
|
1367
|
+
const direct = options.inboxToken ?? options.apiKey ?? this.#inboxToken ?? this.#apiKey;
|
|
1368
|
+
if ((direct ?? this.#accessToken) && !this.#secureOrigin)
|
|
1369
|
+
throw new Error(CleartextCredentialMessage(this.baseUrl));
|
|
1370
|
+
const credential = direct ?? await this.resolveAccessToken();
|
|
1245
1371
|
if (credential)
|
|
1246
1372
|
headers[HEADER_KEYS.AUTHORIZATION] = `Bearer ${credential}`;
|
|
1247
1373
|
}
|
|
@@ -2299,6 +2425,22 @@ const Rules = (transport) => ({
|
|
|
2299
2425
|
iterateRuns: (options = {}) => IteratePages(transport, PATHS.RULES_RUNS, CURSOR_STYLES.CURSOR, options, RunsQuery(options))
|
|
2300
2426
|
});
|
|
2301
2427
|
|
|
2428
|
+
const Security = (transport) => ({
|
|
2429
|
+
stepUpStatus: (options = {}) => transport.request(PATHS.SECURITY_STEP_UP, { signal: options.signal, apiKey: options.apiKey }),
|
|
2430
|
+
beginStepUp: (body = {}, options = {}) => transport.request(PATHS.SECURITY_STEP_UP, {
|
|
2431
|
+
method: HTTP_METHODS.POST,
|
|
2432
|
+
body,
|
|
2433
|
+
signal: options.signal,
|
|
2434
|
+
apiKey: options.apiKey
|
|
2435
|
+
}),
|
|
2436
|
+
verifyStepUp: (body, options = {}) => transport.request(PATHS.SECURITY_STEP_UP_VERIFY, {
|
|
2437
|
+
method: HTTP_METHODS.POST,
|
|
2438
|
+
body,
|
|
2439
|
+
signal: options.signal,
|
|
2440
|
+
apiKey: options.apiKey
|
|
2441
|
+
})
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2302
2444
|
const Settings = (transport) => ({
|
|
2303
2445
|
get: (options = {}) => transport.request(PATHS.SETTINGS, {
|
|
2304
2446
|
query: { [QUERY_KEYS.ADDRESS]: options.address },
|
|
@@ -2687,6 +2829,7 @@ class OpenEmail {
|
|
|
2687
2829
|
transport;
|
|
2688
2830
|
mode;
|
|
2689
2831
|
me;
|
|
2832
|
+
security;
|
|
2690
2833
|
keys;
|
|
2691
2834
|
addresses;
|
|
2692
2835
|
languages;
|
|
@@ -2713,12 +2856,13 @@ class OpenEmail {
|
|
|
2713
2856
|
tempMail;
|
|
2714
2857
|
constructor(configuration) {
|
|
2715
2858
|
const options = typeof configuration === 'string' ? { apiKey: configuration } : configuration;
|
|
2716
|
-
|
|
2859
|
+
AssertCredential(options.apiKey, options.accessToken, true);
|
|
2717
2860
|
if (IsBrowser() && options.dangerouslyAllowBrowser !== true)
|
|
2718
2861
|
throw new Error(BROWSER_REFUSAL);
|
|
2719
2862
|
this.transport = new Transport(options);
|
|
2720
2863
|
this.mode = ModeOf(options.apiKey);
|
|
2721
2864
|
this.me = Me(this.transport);
|
|
2865
|
+
this.security = Security(this.transport);
|
|
2722
2866
|
this.keys = Keys(this.transport);
|
|
2723
2867
|
this.addresses = Addresses(this.transport);
|
|
2724
2868
|
this.languages = Languages(this.transport);
|
|
@@ -2760,6 +2904,19 @@ const FromEnvironment = (name) => {
|
|
|
2760
2904
|
}
|
|
2761
2905
|
};
|
|
2762
2906
|
|
|
2907
|
+
const IsLocalHost = (hostname) => {
|
|
2908
|
+
const host = hostname.toLowerCase();
|
|
2909
|
+
return LOCAL_HOSTS.includes(host) || LOOPBACK_IPV4_PATTERN.test(host);
|
|
2910
|
+
};
|
|
2911
|
+
|
|
2912
|
+
const HostnameOf = (authority) => {
|
|
2913
|
+
try {
|
|
2914
|
+
return new URL(`http://${authority}`).hostname;
|
|
2915
|
+
}
|
|
2916
|
+
catch {
|
|
2917
|
+
return '';
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2763
2920
|
const NormaliseEndpoint = (value) => {
|
|
2764
2921
|
if (!value)
|
|
2765
2922
|
return undefined;
|
|
@@ -2768,15 +2925,18 @@ const NormaliseEndpoint = (value) => {
|
|
|
2768
2925
|
return undefined;
|
|
2769
2926
|
if (ENDPOINT_SCHEME_PATTERN.test(trimmed))
|
|
2770
2927
|
return trimmed;
|
|
2771
|
-
|
|
2772
|
-
return `${LOCAL_HOSTS.includes(host) ? 'http' : 'https'}://${trimmed}`;
|
|
2928
|
+
return `${IsLocalHost(HostnameOf(trimmed)) ? 'http' : 'https'}://${trimmed}`;
|
|
2773
2929
|
};
|
|
2774
2930
|
|
|
2775
2931
|
const CreateClient = (configuration = {}) => {
|
|
2776
2932
|
const options = typeof configuration === 'string' ? { apiKey: configuration } : configuration;
|
|
2933
|
+
const explicit = options.apiKey !== undefined || options.accessToken !== undefined;
|
|
2934
|
+
const apiKey = explicit ? options.apiKey : FromEnvironment(ENV_VARS.API_KEY);
|
|
2935
|
+
const accessToken = explicit || apiKey !== undefined ? options.accessToken : FromEnvironment(ENV_VARS.ACCESS_TOKEN);
|
|
2777
2936
|
return new OpenEmail({
|
|
2778
2937
|
...options,
|
|
2779
|
-
apiKey
|
|
2938
|
+
apiKey,
|
|
2939
|
+
accessToken,
|
|
2780
2940
|
baseUrl: NormaliseEndpoint(options.baseUrl ?? FromEnvironment(ENV_VARS.BASE_URL))
|
|
2781
2941
|
});
|
|
2782
2942
|
};
|
|
@@ -2784,6 +2944,7 @@ const CreateClient = (configuration = {}) => {
|
|
|
2784
2944
|
const CreateTempMail = (options = {}) => TempMail(new Transport({
|
|
2785
2945
|
...options,
|
|
2786
2946
|
apiKey: undefined,
|
|
2947
|
+
accessToken: undefined,
|
|
2787
2948
|
baseUrl: NormaliseEndpoint(options.baseUrl ?? FromEnvironment(ENV_VARS.BASE_URL))
|
|
2788
2949
|
}));
|
|
2789
2950
|
|
|
@@ -2911,6 +3072,7 @@ const createTempMail = CreateTempMail;
|
|
|
2911
3072
|
const verifyWebhookSignature = VerifyWebhookSignature;
|
|
2912
3073
|
const toBase64 = ToBase64;
|
|
2913
3074
|
const isApiKey = IsApiKey;
|
|
3075
|
+
const isAccessToken = IsAccessToken;
|
|
2914
3076
|
const isSealed = IsSealed;
|
|
2915
3077
|
const resolveLanguage = ResolveLanguage;
|
|
2916
3078
|
const languageByCode = LanguageByCode;
|
|
@@ -2923,6 +3085,7 @@ exports.BROADCAST_STATUSES = BROADCAST_STATUSES;
|
|
|
2923
3085
|
exports.CONTACT_BLOCK_LISTS = CONTACT_BLOCK_LISTS;
|
|
2924
3086
|
exports.CONTACT_PHOTO_TYPES = CONTACT_PHOTO_TYPES;
|
|
2925
3087
|
exports.CONTACT_THREAD_SORTS = CONTACT_THREAD_SORTS;
|
|
3088
|
+
exports.CREDENTIAL_KINDS = CREDENTIAL_KINDS;
|
|
2926
3089
|
exports.DOMAIN_DMARC_STAGES = DOMAIN_DMARC_STAGES;
|
|
2927
3090
|
exports.DOMAIN_RECORD_STATUSES = DOMAIN_RECORD_STATUSES;
|
|
2928
3091
|
exports.ERROR_TYPES = ERROR_TYPES;
|
|
@@ -2944,6 +3107,8 @@ exports.PROVIDER_IMPORT_STATUSES = PROVIDER_IMPORT_STATUSES;
|
|
|
2944
3107
|
exports.RULE_ACTIONS = RULE_ACTIONS;
|
|
2945
3108
|
exports.RULE_FIELDS = RULE_FIELDS;
|
|
2946
3109
|
exports.RULE_OPERATORS = RULE_OPERATORS;
|
|
3110
|
+
exports.STEP_UP_ERROR_CODES = STEP_UP_ERROR_CODES;
|
|
3111
|
+
exports.STEP_UP_METHODS = STEP_UP_METHODS;
|
|
2947
3112
|
exports.SUPPRESSION_REASONS = SUPPRESSION_REASONS;
|
|
2948
3113
|
exports.THREAD_SORTS = THREAD_SORTS;
|
|
2949
3114
|
exports.VERSION = VERSION;
|
|
@@ -2956,6 +3121,7 @@ exports.createTempMail = createTempMail;
|
|
|
2956
3121
|
exports.default = OpenEmail;
|
|
2957
3122
|
exports.getClient = getClient;
|
|
2958
3123
|
exports.init = init;
|
|
3124
|
+
exports.isAccessToken = isAccessToken;
|
|
2959
3125
|
exports.isApiKey = isApiKey;
|
|
2960
3126
|
exports.isRtlLanguage = isRtlLanguage;
|
|
2961
3127
|
exports.isSealed = isSealed;
|