@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 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 178 endpoints, 259 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.
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 key and work in a browser.
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`, for a server you run yourself. 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.
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.5";
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 the mailbox. Anything',
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
- 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.',
69
- BASE_URL_SHAPE: 'is not a usable base URL. Pass an origin such as "https://api.openemail.uk", or the http origin of a server you run yourself.',
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 LOCAL_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', '::1'];
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 ModeOf = (apiKey) => apiKey.startsWith(API_KEY_PREFIXES.TEST) ? API_KEY_MODES.TEST : API_KEY_MODES.LIVE;
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 BuildUrl = (baseUrl, path, query) => {
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 ValidBaseUrl = (value) => {
1160
- const trimmed = value.trim().replace(/\/+$/, '');
1262
+ const ParseBaseUrl = (value) => {
1161
1263
  try {
1162
- const parsed = new URL(trimmed);
1163
- if (parsed.protocol === 'https:' || parsed.protocol === 'http:')
1164
- return trimmed;
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
- catch { }
1167
- throw new Error(`${JSON.stringify(value)} ${CLIENT_MESSAGES.BASE_URL_SHAPE}`);
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
- if (options.apiKey !== undefined)
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
- headersFor(options) {
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 credential = options.inboxToken ?? options.apiKey ?? this.#inboxToken ?? this.#apiKey;
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
- AssertApiKey(options.apiKey, '{ apiKey }');
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
- const host = trimmed.split(':')[0];
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: options.apiKey ?? FromEnvironment(ENV_VARS.API_KEY),
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;