@nateq/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nateq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,289 @@
1
+ # @nateq/sdk
2
+
3
+ [![npm](https://img.shields.io/npm/v/@nateq/sdk.svg)](https://www.npmjs.com/package/@nateq/sdk)
4
+ [![CI](https://github.com/nateq-ai/sdk-node/actions/workflows/ci.yml/badge.svg)](https://github.com/nateq-ai/sdk-node/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
+
7
+ Official Node.js SDK for the [Nateq](https://nateq.io) API.
8
+
9
+ Works with any Node.js server framework — Express, Fastify, NestJS, Next.js route handlers, background workers. Zero runtime dependencies, ESM + CommonJS, fully typed.
10
+
11
+ > **Server-side only.** Your API key grants access to your whole organization. Never ship it to a browser, a mobile app, or any client bundle.
12
+
13
+ ## Contents
14
+
15
+ - [Install](#install)
16
+ - [Quick start](#quick-start)
17
+ - [Authentication](#authentication) — keys, scopes, and how the SDK protects them
18
+ - [Sending email](#sending-email)
19
+ - [Reading email](#reading-email)
20
+ - [Errors](#errors)
21
+ - [Retries and duplicate sends](#retries-and-duplicate-sends)
22
+ - [Configuration](#configuration)
23
+ - [Framework usage](#framework-usage) — Express, NestJS, Next.js
24
+ - [License](#license)
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install @nateq/sdk
30
+ ```
31
+
32
+ Requires Node.js 18 or newer (it uses the built-in `fetch`).
33
+
34
+ ## Quick start
35
+
36
+ ```ts
37
+ import { Nateq } from "@nateq/sdk";
38
+
39
+ const nateq = new Nateq(); // reads process.env.NATEQ_API_KEY
40
+
41
+ const email = await nateq.emails.send({
42
+ toEmails: ["customer@example.com"],
43
+ subject: "Welcome aboard",
44
+ htmlBody: "<p>Thanks for signing up.</p>",
45
+ });
46
+
47
+ console.log(email.id, email.status);
48
+ ```
49
+
50
+ ## Authentication
51
+
52
+ Create an API key in the Nateq developer portal and grant it the scopes it needs:
53
+
54
+ | Scope | Allows |
55
+ | --- | --- |
56
+ | `emails:send` | `emails.send()` |
57
+ | `emails:read` | `emails.get()`, `emails.list()` |
58
+
59
+ Grant only what the key needs — a key that just sends mail should not carry `emails:read`.
60
+
61
+ The SDK reads `NATEQ_API_KEY` from the environment by default, which keeps the key out of your source:
62
+
63
+ ```bash
64
+ # .env — never commit this
65
+ NATEQ_API_KEY=tg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
66
+ ```
67
+
68
+ Or pass it explicitly, ideally from a secret manager:
69
+
70
+ ```ts
71
+ const nateq = new Nateq({ apiKey: await secrets.get("nateq-api-key") });
72
+ ```
73
+
74
+ Keys are environment-scoped by prefix: `tg_live_` for production, `tg_test_` for sandbox.
75
+
76
+ ### How the SDK protects your key
77
+
78
+ - **Header only.** The key is sent as an `Authorization: Bearer` header, never in a URL, where it would be captured by server logs, proxies, and browser history.
79
+ - **HTTPS enforced.** Configuring a plaintext `http://` base URL throws rather than putting your key on the wire in cleartext. `localhost` is exempt for local development.
80
+ - **Never printed.** `console.log(nateq)`, `JSON.stringify(nateq)`, and `util.inspect` all redact the key. It is held in a private field, so it will not appear via property enumeration.
81
+ - **Never in errors.** Every error message is scrubbed of the key before it is thrown, so it cannot reach your logs through a stack trace.
82
+ - **Validated locally.** A malformed key throws immediately, without a network round-trip that would transmit it.
83
+
84
+ ## Sending email
85
+
86
+ `toEmails` and `subject` are required, plus at least one of `htmlBody` / `plainTextBody`. Everything else is optional:
87
+
88
+ ```ts
89
+ await nateq.emails.send({
90
+ toEmails: ["a@example.com", "b@example.com"],
91
+ ccEmails: ["cc@example.com"],
92
+ bccEmails: ["bcc@example.com"],
93
+ replyTo: "support@yourcompany.com",
94
+ subject: "Your order shipped",
95
+ htmlBody: "<p>Tracking: <b>ABC123</b></p>",
96
+ plainTextBody: "Tracking: ABC123",
97
+ fromName: "Acme Support",
98
+ attachmentIds: ["6f1c…"], // ids of files already uploaded to Nateq
99
+ headers: { "X-Campaign": "shipping" },
100
+ });
101
+ ```
102
+
103
+ The From address defaults to your organization's default verified address. Override it with `emailAddressId` (preferred) or `fromEmail` — both must already be verified for your organization.
104
+
105
+ `send()` resolves once Nateq has **accepted** the email. Delivery happens asynchronously, so the returned `status` is usually `pending` or `sent`, not `delivered`.
106
+
107
+ ### Threading and linking
108
+
109
+ Attach an email to a ticket, conversation, or contact, or thread it onto an existing message:
110
+
111
+ ```ts
112
+ await nateq.emails.send({
113
+ toEmails: ["customer@example.com"],
114
+ subject: "Re: Invoice #42",
115
+ htmlBody: "<p>Attached.</p>",
116
+ ticketId: "…",
117
+ inReplyTo: "<message-id@nateq.io>",
118
+ });
119
+ ```
120
+
121
+ ## Reading email
122
+
123
+ ```ts
124
+ const email = await nateq.emails.get("email-id");
125
+ console.log(email.status, email.deliveredAt, email.bounceReason);
126
+
127
+ const { emails, total } = await nateq.emails.list({
128
+ status: "bounced",
129
+ limit: 50,
130
+ });
131
+ ```
132
+
133
+ `list()` returns newest first. `limit` defaults to 50 and is capped at 100 by the API; page with `offset`.
134
+
135
+ ## Errors
136
+
137
+ Every failure is an instance of `NateqError`, with a specific subclass you can branch on:
138
+
139
+ ```ts
140
+ import { NateqError, NateqRateLimitError, NateqPermissionError } from "@nateq/sdk";
141
+
142
+ try {
143
+ await nateq.emails.send({ /* … */ });
144
+ } catch (err) {
145
+ if (err instanceof NateqRateLimitError) {
146
+ await sleep((err.retryAfter ?? 60) * 1000);
147
+ } else if (err instanceof NateqPermissionError) {
148
+ // key is missing the emails:send scope
149
+ console.error(err.code, err.details);
150
+ } else if (err instanceof NateqError) {
151
+ console.error(err.status, err.message, err.requestId);
152
+ }
153
+ throw err;
154
+ }
155
+ ```
156
+
157
+ | Class | When |
158
+ | --- | --- |
159
+ | `NateqConfigurationError` | Bad SDK setup. Thrown before any network call. |
160
+ | `NateqValidationError` | 400/422, or params rejected locally. |
161
+ | `NateqAuthenticationError` | 401 — key missing, malformed, revoked, or unknown. |
162
+ | `NateqPermissionError` | 403 — missing scope, or IP outside the key's allow-list. |
163
+ | `NateqNotFoundError` | 404. |
164
+ | `NateqRateLimitError` | 429. Carries `retryAfter`. |
165
+ | `NateqServerError` | 5xx. |
166
+ | `NateqConnectionError` | No response: DNS, TLS, socket, offline. |
167
+ | `NateqTimeoutError` | Timed out or aborted. |
168
+
169
+ Errors carry `requestId` (from `X-Request-Id`) whenever the API supplies one — include it when contacting support.
170
+
171
+ ## Retries and duplicate sends
172
+
173
+ Reads (`get`, `list`) retry automatically on 429, 408, 5xx, and network errors, with exponential backoff and full jitter, honouring `Retry-After`.
174
+
175
+ **`send()` is deliberately different.** Because the API has no idempotency key, a send that fails *after* reaching the server may already have delivered the mail — replaying it would send twice. So `send()` is only retried on **429**, which the API rejects during validation before any mail leaves, and is therefore provably safe.
176
+
177
+ On a timeout or 5xx from `send()`, the SDK throws rather than guessing. If you must recover, check before resending:
178
+
179
+ ```ts
180
+ try {
181
+ await nateq.emails.send(params);
182
+ } catch (err) {
183
+ if (err instanceof NateqServerError || err instanceof NateqTimeoutError) {
184
+ // Ambiguous: it may or may not have sent. Confirm first.
185
+ const { emails } = await nateq.emails.list({ toEmail: params.toEmails[0], limit: 5 });
186
+ const alreadySent = emails.some((e) => e.subject === params.subject);
187
+ if (!alreadySent) await nateq.emails.send(params);
188
+ }
189
+ }
190
+ ```
191
+
192
+ ## Configuration
193
+
194
+ ```ts
195
+ const nateq = new Nateq({
196
+ apiKey: process.env.NATEQ_API_KEY,
197
+ baseUrl: "https://api.nateq.io/api", // or NATEQ_BASE_URL
198
+ timeout: 30_000, // per attempt, ms
199
+ maxRetries: 2, // 0 disables retries
200
+ fetch: customFetch, // for proxies or tests
201
+ });
202
+ ```
203
+
204
+ Cancel a request with an `AbortSignal`:
205
+
206
+ ```ts
207
+ await nateq.emails.send(params, { signal: AbortSignal.timeout(5000) });
208
+ ```
209
+
210
+ ## Framework usage
211
+
212
+ The client is a plain class with no framework coupling, so it drops into anything running on Node 18+. Create one instance and share it — it is stateless and safe to reuse across requests.
213
+
214
+ **Express / Fastify** — build the client once at startup, not per request:
215
+
216
+ ```ts
217
+ const nateq = new Nateq();
218
+
219
+ app.post("/signup", async (req, res, next) => {
220
+ try {
221
+ await nateq.emails.send({
222
+ toEmails: [req.body.email],
223
+ subject: "Welcome",
224
+ htmlBody: "<p>Thanks for signing up.</p>",
225
+ });
226
+ res.sendStatus(202);
227
+ } catch (err) {
228
+ next(err);
229
+ }
230
+ });
231
+ ```
232
+
233
+ **NestJS** — provide the client through DI so services can inject it and tests can swap it:
234
+
235
+ ```ts
236
+ // nateq.module.ts
237
+ import { Global, Module } from "@nestjs/common";
238
+ import { Nateq } from "@nateq/sdk";
239
+
240
+ export const NATEQ_CLIENT = "NATEQ_CLIENT";
241
+
242
+ @Global()
243
+ @Module({
244
+ providers: [
245
+ {
246
+ provide: NATEQ_CLIENT,
247
+ useFactory: () => new Nateq({ apiKey: process.env.NATEQ_API_KEY }),
248
+ },
249
+ ],
250
+ exports: [NATEQ_CLIENT],
251
+ })
252
+ export class NateqModule {}
253
+ ```
254
+
255
+ ```ts
256
+ // mail.service.ts
257
+ import { Inject, Injectable } from "@nestjs/common";
258
+ import { Nateq } from "@nateq/sdk";
259
+
260
+ @Injectable()
261
+ export class MailService {
262
+ constructor(@Inject(NATEQ_CLIENT) private readonly nateq: Nateq) {}
263
+
264
+ sendWelcome(to: string) {
265
+ return this.nateq.emails.send({
266
+ toEmails: [to],
267
+ subject: "Welcome",
268
+ htmlBody: "<p>Hi there</p>",
269
+ });
270
+ }
271
+ }
272
+ ```
273
+
274
+ In tests, override the provider with a stub — or pass a fake `fetch`:
275
+
276
+ ```ts
277
+ const module = await Test.createTestingModule({ providers: [MailService] })
278
+ .overrideProvider(NATEQ_CLIENT)
279
+ .useValue(new Nateq({ apiKey: TEST_KEY, fetch: fakeFetch }))
280
+ .compile();
281
+ ```
282
+
283
+ **Next.js** — use it in route handlers, server actions, or any server-only module. Never import it into a client component: `NATEQ_API_KEY` must stay server-side (do not prefix it with `NEXT_PUBLIC_`).
284
+
285
+ **Module systems.** ESM and CommonJS are both supported, with correct types for `node10`, `node16`/`nodenext`, and bundler resolution — so `import` and `require` both work, whatever your `tsconfig` says.
286
+
287
+ ## License
288
+
289
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,353 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/errors.ts
3
+ var NateqError = class extends Error {
4
+ /** HTTP status, when the failure came from a response. */
5
+ status;
6
+ /** API error code, when the response carried one. */
7
+ code;
8
+ /** Extra context from the API (validation details, scope lists, ...). */
9
+ details;
10
+ /** Value of the `X-Request-Id` response header, useful for support tickets. */
11
+ requestId;
12
+ constructor(message, opts = {}) {
13
+ super(message, opts.cause !== void 0 ? { cause: opts.cause } : void 0);
14
+ this.name = new.target.name;
15
+ this.status = opts.status;
16
+ this.code = opts.code;
17
+ this.details = opts.details;
18
+ this.requestId = opts.requestId;
19
+ Error.captureStackTrace?.(this, new.target);
20
+ }
21
+ };
22
+ /** The key is missing, malformed, revoked, or unknown (401). */
23
+ var NateqAuthenticationError = class extends NateqError {};
24
+ /**
25
+ * The key is valid but not allowed to do this (403).
26
+ *
27
+ * Common causes: the key lacks the `emails:send` scope, the endpoint is not
28
+ * exposed to API keys, or the caller's IP is outside the key's allow-list.
29
+ */
30
+ var NateqPermissionError = class extends NateqError {};
31
+ /** The request body failed validation (400/422). */
32
+ var NateqValidationError = class extends NateqError {};
33
+ /** The requested resource does not exist, or is not visible to this key (404). */
34
+ var NateqNotFoundError = class extends NateqError {};
35
+ /** A rate limit was hit (429). Nothing was sent — this is safe to retry. */
36
+ var NateqRateLimitError = class extends NateqError {
37
+ /** Seconds to wait before retrying, when the API supplies `Retry-After`. */
38
+ retryAfter;
39
+ constructor(message, opts = {}) {
40
+ super(message, opts);
41
+ this.retryAfter = opts.retryAfter;
42
+ }
43
+ };
44
+ /** The API failed to handle the request (5xx). */
45
+ var NateqServerError = class extends NateqError {};
46
+ /** The request never produced a response: DNS, TLS, socket, or offline. */
47
+ var NateqConnectionError = class extends NateqError {};
48
+ /** The request exceeded the configured timeout, or was aborted by the caller. */
49
+ var NateqTimeoutError = class extends NateqError {};
50
+ /** Bad SDK usage — thrown before any network call (never carries a status). */
51
+ var NateqConfigurationError = class extends NateqError {};
52
+ //#endregion
53
+ //#region src/emails.ts
54
+ /** Outbound email operations. Reachable at `client.emails`. */
55
+ var Emails = class {
56
+ transport;
57
+ constructor(transport) {
58
+ this.transport = transport;
59
+ }
60
+ /**
61
+ * Sends an email, returning once the API has accepted it. Delivery itself is
62
+ * asynchronous — poll {@link get} or subscribe to a webhook for the outcome.
63
+ *
64
+ * Requires an API key with the `emails:send` scope.
65
+ *
66
+ * A failed send is **not** replayed automatically unless the API rejected it
67
+ * outright (429), because a request that fails after reaching the server may
68
+ * already have sent the mail. On a timeout or 5xx, look the email up with
69
+ * {@link list} before retrying, or you risk sending twice.
70
+ *
71
+ * @throws {NateqValidationError} if the params are unusable, before any call.
72
+ * @throws {NateqPermissionError} if the key lacks the `emails:send` scope.
73
+ */
74
+ async send(params, opts = {}) {
75
+ if (!params.toEmails?.length) throw new NateqValidationError("`toEmails` must contain at least one recipient.");
76
+ if (!params.subject?.trim()) throw new NateqValidationError("`subject` is required.");
77
+ if (!params.htmlBody?.trim() && !params.plainTextBody?.trim()) throw new NateqValidationError("Provide `htmlBody`, `plainTextBody`, or both.");
78
+ return this.transport.request({
79
+ method: "POST",
80
+ path: "/outbound-emails",
81
+ body: params,
82
+ signal: opts.signal,
83
+ retryOnAmbiguousFailure: false
84
+ });
85
+ }
86
+ /**
87
+ * Fetches a single sent email by id, including delivery status.
88
+ *
89
+ * Requires an API key with the `emails:read` scope.
90
+ */
91
+ async get(id, opts = {}) {
92
+ if (!id?.trim()) throw new NateqValidationError("`id` is required.");
93
+ return this.transport.request({
94
+ method: "GET",
95
+ path: `/outbound-emails/${encodeURIComponent(id)}`,
96
+ signal: opts.signal,
97
+ retryOnAmbiguousFailure: true
98
+ });
99
+ }
100
+ /**
101
+ * Lists sent emails, newest first. `limit` defaults to 50 and is capped at 100.
102
+ *
103
+ * Requires an API key with the `emails:read` scope.
104
+ */
105
+ async list(params = {}, opts = {}) {
106
+ return this.transport.request({
107
+ method: "GET",
108
+ path: "/outbound-emails",
109
+ query: params,
110
+ signal: opts.signal,
111
+ retryOnAmbiguousFailure: true
112
+ });
113
+ }
114
+ };
115
+ //#endregion
116
+ //#region src/http.ts
117
+ /**
118
+ * Removes the API key from a string. Applied to every message the SDK raises so
119
+ * a leaked stack trace or log line can never reveal the credential.
120
+ */
121
+ function redact(text, apiKey) {
122
+ return apiKey ? text.split(apiKey).join("[REDACTED]") : text;
123
+ }
124
+ function isAbortError(err) {
125
+ return err instanceof Error && err.name === "AbortError";
126
+ }
127
+ function parseRetryAfter(headers) {
128
+ const raw = headers.get("retry-after");
129
+ if (!raw) return void 0;
130
+ const seconds = Number(raw);
131
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds;
132
+ const date = Date.parse(raw);
133
+ if (Number.isNaN(date)) return void 0;
134
+ return Math.max(0, (date - Date.now()) / 1e3);
135
+ }
136
+ function buildUrl(baseUrl, path, query) {
137
+ const url = new URL(baseUrl.replace(/\/+$/, "") + path);
138
+ for (const [key, value] of Object.entries(query ?? {})) {
139
+ if (value === void 0 || value === null) continue;
140
+ url.searchParams.set(key, String(value));
141
+ }
142
+ return url.toString();
143
+ }
144
+ /** Maps a failed response onto the matching error class. */
145
+ function toError(status, payload, headers, apiKey) {
146
+ let code;
147
+ let message;
148
+ let details;
149
+ const err = payload?.error;
150
+ if (typeof err === "string") {
151
+ message = err;
152
+ details = payload?.details;
153
+ } else if (err && typeof err === "object") {
154
+ code = err.code;
155
+ message = err.message;
156
+ details = err.details;
157
+ }
158
+ const requestId = headers.get("x-request-id") ?? void 0;
159
+ const text = redact(message ?? `Request failed with status ${status}`, apiKey);
160
+ const opts = {
161
+ status,
162
+ code,
163
+ details,
164
+ requestId
165
+ };
166
+ if (status === 401) return new NateqAuthenticationError(text, opts);
167
+ if (status === 403) return new NateqPermissionError(text, opts);
168
+ if (status === 404) return new NateqNotFoundError(text, opts);
169
+ if (status === 400 || status === 422) return new NateqValidationError(text, opts);
170
+ if (status === 429) return new NateqRateLimitError(text, {
171
+ ...opts,
172
+ retryAfter: parseRetryAfter(headers)
173
+ });
174
+ if (status >= 500) return new NateqServerError(text, opts);
175
+ return new NateqError(text, opts);
176
+ }
177
+ /** Full-jitter exponential backoff, capped at 8s, honouring `Retry-After`. */
178
+ function backoffDelay(attempt, retryAfter) {
179
+ if (retryAfter !== void 0) return Math.min(retryAfter * 1e3, 6e4);
180
+ const ceiling = Math.min(500 * 2 ** attempt, 8e3);
181
+ return Math.random() * ceiling;
182
+ }
183
+ function sleep(ms, signal) {
184
+ return new Promise((resolve, reject) => {
185
+ if (signal?.aborted) {
186
+ reject(new NateqTimeoutError("Request aborted by caller"));
187
+ return;
188
+ }
189
+ const timer = setTimeout(() => {
190
+ signal?.removeEventListener("abort", onAbort);
191
+ resolve();
192
+ }, ms);
193
+ const onAbort = () => {
194
+ clearTimeout(timer);
195
+ reject(new NateqTimeoutError("Request aborted by caller"));
196
+ };
197
+ signal?.addEventListener("abort", onAbort, { once: true });
198
+ });
199
+ }
200
+ var Transport = class {
201
+ options;
202
+ constructor(options) {
203
+ this.options = options;
204
+ }
205
+ async request(req) {
206
+ const { apiKey, baseUrl, timeout, maxRetries, fetch: doFetch, userAgent } = this.options;
207
+ const url = buildUrl(baseUrl, req.path, req.query);
208
+ let lastError;
209
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
210
+ if (attempt > 0) {
211
+ const retryAfter = lastError instanceof NateqRateLimitError ? lastError.retryAfter : void 0;
212
+ await sleep(backoffDelay(attempt - 1, retryAfter), req.signal);
213
+ }
214
+ const timeoutSignal = AbortSignal.timeout(timeout);
215
+ const signal = req.signal ? AbortSignal.any([req.signal, timeoutSignal]) : timeoutSignal;
216
+ let response;
217
+ try {
218
+ response = await doFetch(url, {
219
+ method: req.method,
220
+ signal,
221
+ headers: {
222
+ Authorization: `Bearer ${apiKey}`,
223
+ Accept: "application/json",
224
+ "User-Agent": userAgent,
225
+ ...req.body !== void 0 ? { "Content-Type": "application/json" } : {}
226
+ },
227
+ ...req.body !== void 0 ? { body: JSON.stringify(req.body) } : {}
228
+ });
229
+ } catch (err) {
230
+ const caller = req.signal?.aborted === true;
231
+ lastError = caller ? new NateqTimeoutError("Request aborted by caller", { cause: err }) : isAbortError(err) || err?.name === "TimeoutError" ? new NateqTimeoutError(`Request timed out after ${timeout}ms`, { cause: err }) : new NateqConnectionError(redact(`Could not reach the Nateq API: ${err?.message ?? err}`, apiKey), { cause: err });
232
+ if (caller) throw lastError;
233
+ if (req.retryOnAmbiguousFailure && attempt < maxRetries) continue;
234
+ throw lastError;
235
+ }
236
+ if (response.ok) {
237
+ if (response.status === 204) return void 0;
238
+ return await response.json();
239
+ }
240
+ let payload;
241
+ try {
242
+ payload = await response.json();
243
+ } catch {
244
+ payload = void 0;
245
+ }
246
+ lastError = toError(response.status, payload, response.headers, apiKey);
247
+ if ((lastError instanceof NateqRateLimitError || req.retryOnAmbiguousFailure && (lastError instanceof NateqServerError || response.status === 408)) && attempt < maxRetries) continue;
248
+ throw lastError;
249
+ }
250
+ /* istanbul ignore next — the loop always returns or throws. */
251
+ throw lastError ?? new NateqError("Request failed");
252
+ }
253
+ };
254
+ //#endregion
255
+ //#region src/client.ts
256
+ const VERSION = "0.1.0";
257
+ /** Recognised API-key prefixes, mirroring the API's own validation. */
258
+ const KEY_PREFIXES = ["tg_live_", "tg_test_"];
259
+ const MIN_KEY_LENGTH = 50;
260
+ function resolveApiKey(explicit) {
261
+ const key = explicit ?? process.env["NATEQ_API_KEY"];
262
+ if (!key) throw new NateqConfigurationError("No API key provided. Pass `new Nateq({ apiKey })` or set the NATEQ_API_KEY environment variable.");
263
+ if (key !== key.trim()) throw new NateqConfigurationError("The API key has leading or trailing whitespace, which usually means it was copied incorrectly.");
264
+ if (!KEY_PREFIXES.some((p) => key.startsWith(p))) throw new NateqConfigurationError(`Invalid API key format: expected it to start with ${KEY_PREFIXES.join(" or ")}.`);
265
+ if (key.length < MIN_KEY_LENGTH) throw new NateqConfigurationError("Invalid API key format: the key is too short.");
266
+ return key;
267
+ }
268
+ function resolveBaseUrl(explicit) {
269
+ const raw = explicit ?? process.env["NATEQ_BASE_URL"] ?? "https://api.nateq.io/api/v1";
270
+ let url;
271
+ try {
272
+ url = new URL(raw);
273
+ } catch {
274
+ throw new NateqConfigurationError(`Invalid baseUrl: ${raw}`);
275
+ }
276
+ const isLoopback = [
277
+ "localhost",
278
+ "127.0.0.1",
279
+ "[::1]",
280
+ "::1"
281
+ ].includes(url.hostname);
282
+ if (url.protocol !== "https:" && !isLoopback) throw new NateqConfigurationError(`Refusing to send an API key over ${url.protocol}// to ${url.hostname}. Use https, or a localhost address for local development.`);
283
+ return url.toString().replace(/\/+$/, "");
284
+ }
285
+ /**
286
+ * The Nateq API client.
287
+ *
288
+ * ```ts
289
+ * import { Nateq } from "@nateq/sdk";
290
+ *
291
+ * const nateq = new Nateq(); // reads NATEQ_API_KEY
292
+ *
293
+ * await nateq.emails.send({
294
+ * toEmails: ["customer@example.com"],
295
+ * subject: "Welcome aboard",
296
+ * htmlBody: "<p>Thanks for signing up.</p>",
297
+ * });
298
+ * ```
299
+ *
300
+ * The instance holds your API key, so it is never safe to serialise: `console.log`,
301
+ * `JSON.stringify`, and util.inspect all render it with the key redacted.
302
+ */
303
+ var Nateq = class {
304
+ /** Outbound email: send, get, list. */
305
+ emails;
306
+ baseUrl;
307
+ /** Stored non-enumerably so the key cannot leak via property enumeration. */
308
+ #apiKey;
309
+ constructor(options = {}) {
310
+ this.#apiKey = resolveApiKey(options.apiKey);
311
+ this.baseUrl = resolveBaseUrl(options.baseUrl);
312
+ const fetchImpl = options.fetch ?? globalThis.fetch;
313
+ if (typeof fetchImpl !== "function") throw new NateqConfigurationError("No global fetch available. Use Node 18+, or pass a fetch implementation via `new Nateq({ fetch })`.");
314
+ const transport = new Transport({
315
+ apiKey: this.#apiKey,
316
+ baseUrl: this.baseUrl,
317
+ timeout: options.timeout ?? 3e4,
318
+ maxRetries: options.maxRetries ?? 2,
319
+ fetch: fetchImpl.bind(globalThis),
320
+ userAgent: `nateq-node/${VERSION} (node ${process.versions.node})`
321
+ });
322
+ this.emails = new Emails(transport);
323
+ }
324
+ /** Last four characters of the key, for logging which key is in use. */
325
+ get apiKeyLast4() {
326
+ return this.#apiKey.slice(-4);
327
+ }
328
+ toJSON() {
329
+ return {
330
+ baseUrl: this.baseUrl,
331
+ apiKey: `[REDACTED:...${this.apiKeyLast4}]`
332
+ };
333
+ }
334
+ /** Keeps the key out of `console.log(client)` and util.inspect output. */
335
+ [Symbol.for("nodejs.util.inspect.custom")]() {
336
+ return `Nateq { baseUrl: '${this.baseUrl}', apiKey: '[REDACTED:...${this.apiKeyLast4}]' }`;
337
+ }
338
+ };
339
+ //#endregion
340
+ exports.Emails = Emails;
341
+ exports.Nateq = Nateq;
342
+ exports.NateqAuthenticationError = NateqAuthenticationError;
343
+ exports.NateqConfigurationError = NateqConfigurationError;
344
+ exports.NateqConnectionError = NateqConnectionError;
345
+ exports.NateqError = NateqError;
346
+ exports.NateqNotFoundError = NateqNotFoundError;
347
+ exports.NateqPermissionError = NateqPermissionError;
348
+ exports.NateqRateLimitError = NateqRateLimitError;
349
+ exports.NateqServerError = NateqServerError;
350
+ exports.NateqTimeoutError = NateqTimeoutError;
351
+ exports.NateqValidationError = NateqValidationError;
352
+
353
+ //# sourceMappingURL=index.cjs.map