@zindua/sdk 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zindua
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,220 @@
1
+ # @zindua/sdk
2
+
3
+ Official **server-side** SDK for [Zindua](https://zindua.run): one API for transactional **email** and **WhatsApp** (`POST /api/v1/send`).
4
+
5
+ | Resource | Link |
6
+ |----------|------|
7
+ | Website | [zindua.run](https://zindua.run) |
8
+ | Developer documentation | [zindua.run/developers](https://zindua.run/developers) |
9
+ | HTTP / cURL (no SDK) | [zindua.run/developers#http](https://zindua.run/developers#http) |
10
+ | Domain & custom sender | [zindua.run/developers#domain](https://zindua.run/developers#domain) |
11
+ | Dashboard | [zindua.run/login](https://zindua.run/login) |
12
+
13
+ ---
14
+
15
+ ## Requirements
16
+
17
+ Before using the SDK, set up your Zindua project in the dashboard:
18
+
19
+ | Requirement | Required for | Where |
20
+ |-------------|--------------|--------|
21
+ | Zindua account | All | [Sign up / login](https://zindua.run/login) |
22
+ | Project + API key (`znd_live_…` or `znd_test_…`) | All | Dashboard → **Projects** → your project |
23
+ | Email **Service** connected (Gmail, Outlook, SendGrid, SMTP, …) | `channel: "email"` | Dashboard → **Service** |
24
+ | WhatsApp connected (QR) | `channel: "whatsapp"` | Dashboard → **WhatsApp** |
25
+ | Template slug (e.g. `welcome`, `otp-verification`) | All | Dashboard → **Templates** |
26
+ | **Pro** or **Team** plan | Email API on production sends (Free = WhatsApp OTP API) | Dashboard → **Billing** |
27
+
28
+ **Runtime**
29
+
30
+ - **Node.js 18+** (uses native `fetch`)
31
+ - **Server only** — do not bundle this package for the browser (see [Mobile & SPA](#mobile--spa))
32
+
33
+ ---
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ npm install @zindua/sdk
39
+ ```
40
+
41
+ ```bash
42
+ yarn add @zindua/sdk
43
+ ```
44
+
45
+ ```bash
46
+ pnpm add @zindua/sdk
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Configure
52
+
53
+ ### 1. API key
54
+
55
+ Copy your project key from the dashboard. Format: `znd_live_` + 24 characters, or `znd_test_` for test mode.
56
+
57
+ ```bash
58
+ # .env (example — load with dotenv or your framework)
59
+ ZINDUA_API_KEY=znd_live_xxxxxxxxxxxxxxxxxxxxxxxx
60
+ ```
61
+
62
+ The SDK does **not** read `ZINDUA_API_KEY` automatically. Pass it to the constructor explicitly.
63
+
64
+ ### 2. Base URL (optional)
65
+
66
+ | Variable | Default | Notes |
67
+ |----------|---------|--------|
68
+ | `ZINDUA_API_BASE_URL` | `https://zindua.run/api/v1` | Used when `baseUrl` is omitted in constructor |
69
+ | Constructor `baseUrl` | env or default | Overrides env; must be **HTTPS** in production (`http://localhost` allowed for local dev) |
70
+
71
+ ### 3. Timeout (optional)
72
+
73
+ | Option | Default | Max |
74
+ |--------|---------|-----|
75
+ | `timeoutMs` | `30000` | `120000` |
76
+
77
+ ### Minimal setup
78
+
79
+ ```typescript
80
+ import { Zindua } from "@zindua/sdk";
81
+
82
+ export const zindua = new Zindua({
83
+ apiKey: process.env.ZINDUA_API_KEY!,
84
+ // baseUrl: process.env.ZINDUA_API_BASE_URL, // optional
85
+ // timeoutMs: 30_000,
86
+ });
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Usage
92
+
93
+ ### Send email
94
+
95
+ ```typescript
96
+ await zindua.send({
97
+ to: "user@example.com",
98
+ template: "welcome",
99
+ variables: { name: "Alex" },
100
+ });
101
+ ```
102
+
103
+ ### Send WhatsApp OTP
104
+
105
+ Phone numbers must be **E.164** with a leading `+` (e.g. `+243812345678`).
106
+
107
+ ```typescript
108
+ await zindua.send({
109
+ to: "+243812345678",
110
+ channel: "whatsapp",
111
+ template: "otp-verification",
112
+ variables: { code: "4592", app: "MyApp" },
113
+ });
114
+ ```
115
+
116
+ ### Optional fields (email only)
117
+
118
+ ```typescript
119
+ await zindua.send({
120
+ to: "user@example.com",
121
+ template: "invoice",
122
+ lang: "fr",
123
+ cc: "billing@example.com",
124
+ replyTo: "support@example.com",
125
+ variables: { amount: "99" },
126
+ });
127
+ ```
128
+
129
+ ### Response
130
+
131
+ ```typescript
132
+ const result = await zindua.send({ /* … */ });
133
+ // { success: true, status: "queued", logId: "…", channel: "email" | "whatsapp", … }
134
+ ```
135
+
136
+ ---
137
+
138
+ ## Errors
139
+
140
+ ```typescript
141
+ import { Zindua, ZinduaError } from "@zindua/sdk";
142
+
143
+ try {
144
+ await zindua.send({ to: "+243…", channel: "whatsapp", template: "otp-verification" });
145
+ } catch (e) {
146
+ if (e instanceof ZinduaError) {
147
+ console.error(e.status, e.code, e.message);
148
+ // Platform codes may include: WHATSAPP_NOT_CONNECTED, RATE_LIMIT_EXCEEDED, …
149
+ }
150
+ throw e;
151
+ }
152
+ ```
153
+
154
+ `ZinduaError` never includes your API key in the message.
155
+
156
+ ---
157
+
158
+ ## Security
159
+
160
+ - **Server only** — throws in browser environments (`BROWSER_FORBIDDEN`).
161
+ - **HTTPS** for `baseUrl` except `http://localhost` in development.
162
+ - **No credentials in URL** — rejects `baseUrl` with embedded username/password.
163
+ - **Input validation** — E.164 phones, email format, template slug, variable size limits (aligned with the platform API).
164
+ - **Redirects disabled** — `fetch` uses `redirect: "error"`.
165
+
166
+ ---
167
+
168
+ ## Mobile & SPA
169
+
170
+ Never put `znd_live_` keys in React Native, Flutter, or browser code. Call your own backend; your backend uses this SDK.
171
+
172
+ See [Developer docs → Quickstart](https://zindua.run/developers#setup).
173
+
174
+ ---
175
+
176
+ ## HTTP without the SDK
177
+
178
+ Any language can call the same endpoint:
179
+
180
+ ```bash
181
+ curl -X POST https://zindua.run/api/v1/send \
182
+ -H "Authorization: Bearer znd_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
183
+ -H "Content-Type: application/json" \
184
+ -d '{"to":"user@example.com","template":"welcome","variables":{"name":"Alex"}}'
185
+ ```
186
+
187
+ Full reference: [HTTP / cURL](https://zindua.run/developers#http).
188
+
189
+ ---
190
+
191
+ ## API surface
192
+
193
+ | Export | Description |
194
+ |--------|-------------|
195
+ | `Zindua` | Client class |
196
+ | `ZinduaError` | Typed error |
197
+ | `DEFAULT_API_BASE` | `https://zindua.run/api/v1` |
198
+ | `LIMITS` | Validation limits (documented constants) |
199
+
200
+ ---
201
+
202
+ ## Local development (monorepo)
203
+
204
+ ```bash
205
+ cd packages/zindua-js
206
+ npm run build
207
+ npm test
208
+ ```
209
+
210
+ From the app repo root:
211
+
212
+ ```bash
213
+ npm install file:./packages/zindua-js
214
+ ```
215
+
216
+ ---
217
+
218
+ ## License
219
+
220
+ MIT © [Zindua](https://zindua.run)
@@ -0,0 +1,37 @@
1
+ export type SendChannel = "email" | "whatsapp";
2
+ export type ZinduaSendOptions = {
3
+ to: string;
4
+ template: string;
5
+ channel?: SendChannel;
6
+ lang?: string;
7
+ variables?: Record<string, string>;
8
+ cc?: string;
9
+ bcc?: string;
10
+ replyTo?: string;
11
+ attachments?: unknown[];
12
+ };
13
+ export type ZinduaClientOptions = {
14
+ apiKey: string;
15
+ /** Defaults to ZINDUA_API_BASE_URL env or https://zindua.run/api/v1 */
16
+ baseUrl?: string;
17
+ /** Request timeout (default 30s, max 120s). */
18
+ timeoutMs?: number;
19
+ };
20
+ export type ZinduaSendResult = {
21
+ success: true;
22
+ channel: SendChannel;
23
+ status: string;
24
+ logId: string;
25
+ langUsed?: string;
26
+ langFallback?: boolean;
27
+ testMode?: boolean;
28
+ };
29
+ export declare class Zindua {
30
+ private readonly apiKey;
31
+ private readonly sendUrl;
32
+ private readonly timeoutMs;
33
+ constructor(options: ZinduaClientOptions);
34
+ /** Returns true when using a znd_test_ key (sandbox / no real delivery). */
35
+ isTestMode(): boolean;
36
+ send(options: ZinduaSendOptions): Promise<ZinduaSendResult>;
37
+ }
package/dist/client.js ADDED
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Zindua = void 0;
4
+ const errors_1 = require("./errors");
5
+ const validate_1 = require("./validate");
6
+ const SDK_VERSION = "1.2.0";
7
+ const USER_AGENT = `Zindua-JS/${SDK_VERSION}`;
8
+ function buildPayload(options, channel) {
9
+ const to = (0, validate_1.validateRecipient)(options.to, channel);
10
+ const template = (0, validate_1.validateTemplateSlug)(options.template);
11
+ const lang = (0, validate_1.validateLang)(options.lang);
12
+ const variables = (0, validate_1.sanitizeVariables)(options.variables);
13
+ const payload = { to, template };
14
+ if (channel === "whatsapp") {
15
+ payload.channel = "whatsapp";
16
+ }
17
+ else if (options.channel === "email") {
18
+ payload.channel = "email";
19
+ }
20
+ if (lang)
21
+ payload.lang = lang;
22
+ if (variables)
23
+ payload.variables = variables;
24
+ if (channel === "email") {
25
+ const cc = (0, validate_1.validateOptionalEmailField)("cc", options.cc);
26
+ const bcc = (0, validate_1.validateOptionalEmailField)("bcc", options.bcc);
27
+ const replyTo = (0, validate_1.validateOptionalEmailField)("replyTo", options.replyTo);
28
+ const attachments = (0, validate_1.validateAttachments)(options.attachments);
29
+ if (cc)
30
+ payload.cc = cc;
31
+ if (bcc)
32
+ payload.bcc = bcc;
33
+ if (replyTo)
34
+ payload.replyTo = replyTo;
35
+ if (attachments)
36
+ payload.attachments = attachments;
37
+ }
38
+ return payload;
39
+ }
40
+ function parseApiError(status, body) {
41
+ const message = typeof body.error === "string"
42
+ ? body.error
43
+ : typeof body.message === "string"
44
+ ? body.message
45
+ : `Request failed with HTTP ${status}`;
46
+ const details = {};
47
+ if (typeof body.code === "string")
48
+ details.code = body.code;
49
+ if (typeof body.retryAfterSec === "number")
50
+ details.retryAfterSec = body.retryAfterSec;
51
+ return new errors_1.ZinduaError(message, {
52
+ status,
53
+ code: typeof body.code === "string" ? body.code : "API_ERROR",
54
+ details: Object.keys(details).length > 0 ? details : undefined,
55
+ });
56
+ }
57
+ class Zindua {
58
+ apiKey;
59
+ sendUrl;
60
+ timeoutMs;
61
+ constructor(options) {
62
+ (0, validate_1.assertServerRuntime)();
63
+ this.apiKey = (0, validate_1.validateApiKey)(options.apiKey);
64
+ const base = (0, validate_1.resolveBaseUrl)(options.baseUrl);
65
+ this.sendUrl = `${base}/send`;
66
+ this.timeoutMs = (0, validate_1.validateTimeoutMs)(options.timeoutMs);
67
+ }
68
+ /** Returns true when using a znd_test_ key (sandbox / no real delivery). */
69
+ isTestMode() {
70
+ return this.apiKey.startsWith("znd_test_");
71
+ }
72
+ async send(options) {
73
+ const channel = (0, validate_1.validateChannel)(options.channel);
74
+ const payload = buildPayload(options, channel);
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
77
+ try {
78
+ const response = await fetch(this.sendUrl, {
79
+ method: "POST",
80
+ headers: {
81
+ "Content-Type": "application/json",
82
+ Authorization: `Bearer ${this.apiKey}`,
83
+ "User-Agent": USER_AGENT,
84
+ Accept: "application/json",
85
+ },
86
+ body: JSON.stringify(payload),
87
+ signal: controller.signal,
88
+ redirect: "error",
89
+ });
90
+ const text = await response.text();
91
+ let data;
92
+ try {
93
+ data = text ? JSON.parse(text) : {};
94
+ }
95
+ catch {
96
+ throw new errors_1.ZinduaError("API returned non-JSON response.", {
97
+ status: response.status,
98
+ code: "INVALID_RESPONSE",
99
+ });
100
+ }
101
+ if (!response.ok) {
102
+ throw parseApiError(response.status, data);
103
+ }
104
+ if (data.success !== true || typeof data.logId !== "string") {
105
+ throw new errors_1.ZinduaError("API response missing success or logId.", {
106
+ status: response.status,
107
+ code: "INVALID_RESPONSE",
108
+ });
109
+ }
110
+ return {
111
+ success: true,
112
+ channel: (data.channel === "whatsapp" ? "whatsapp" : "email"),
113
+ status: typeof data.status === "string" ? data.status : "queued",
114
+ logId: data.logId,
115
+ langUsed: typeof data.langUsed === "string" ? data.langUsed : undefined,
116
+ langFallback: typeof data.langFallback === "boolean" ? data.langFallback : undefined,
117
+ testMode: typeof data.testMode === "boolean" ? data.testMode : undefined,
118
+ };
119
+ }
120
+ catch (err) {
121
+ if (err instanceof errors_1.ZinduaError)
122
+ throw err;
123
+ if (err instanceof Error && err.name === "AbortError") {
124
+ throw new errors_1.ZinduaError(`Request timed out after ${this.timeoutMs}ms.`, {
125
+ status: 0,
126
+ code: "REQUEST_TIMEOUT",
127
+ cause: err,
128
+ });
129
+ }
130
+ throw new errors_1.ZinduaError("Network request failed.", {
131
+ status: 0,
132
+ code: "API_ERROR",
133
+ cause: err,
134
+ });
135
+ }
136
+ finally {
137
+ clearTimeout(timer);
138
+ }
139
+ }
140
+ }
141
+ exports.Zindua = Zindua;
@@ -0,0 +1,13 @@
1
+ export type ZinduaErrorCode = "INVALID_API_KEY" | "INVALID_BASE_URL" | "BROWSER_FORBIDDEN" | "INVALID_OPTIONS" | "INVALID_RECIPIENT" | "INVALID_TEMPLATE" | "INVALID_CHANNEL" | "INVALID_LANG" | "INVALID_VARIABLES" | "REQUEST_TIMEOUT" | "INVALID_RESPONSE" | "API_ERROR";
2
+ /** Structured error — never includes the API key in the message. */
3
+ export declare class ZinduaError extends Error {
4
+ readonly status: number;
5
+ readonly code: ZinduaErrorCode | string;
6
+ readonly details?: Record<string, unknown>;
7
+ constructor(message: string, opts: {
8
+ status: number;
9
+ code: ZinduaErrorCode | string;
10
+ details?: Record<string, unknown>;
11
+ cause?: unknown;
12
+ });
13
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ZinduaError = void 0;
4
+ /** Structured error — never includes the API key in the message. */
5
+ class ZinduaError extends Error {
6
+ status;
7
+ code;
8
+ details;
9
+ constructor(message, opts) {
10
+ super(message, { cause: opts.cause });
11
+ this.name = "ZinduaError";
12
+ this.status = opts.status;
13
+ this.code = opts.code;
14
+ this.details = opts.details;
15
+ }
16
+ }
17
+ exports.ZinduaError = ZinduaError;
@@ -0,0 +1,5 @@
1
+ export { Zindua } from "./client";
2
+ export type { ZinduaClientOptions, ZinduaSendOptions, ZinduaSendResult, SendChannel, } from "./client";
3
+ export { ZinduaError } from "./errors";
4
+ export type { ZinduaErrorCode } from "./errors";
5
+ export { DEFAULT_API_BASE, LIMITS } from "./validate";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LIMITS = exports.DEFAULT_API_BASE = exports.ZinduaError = exports.Zindua = void 0;
4
+ var client_1 = require("./client");
5
+ Object.defineProperty(exports, "Zindua", { enumerable: true, get: function () { return client_1.Zindua; } });
6
+ var errors_1 = require("./errors");
7
+ Object.defineProperty(exports, "ZinduaError", { enumerable: true, get: function () { return errors_1.ZinduaError; } });
8
+ var validate_1 = require("./validate");
9
+ Object.defineProperty(exports, "DEFAULT_API_BASE", { enumerable: true, get: function () { return validate_1.DEFAULT_API_BASE; } });
10
+ Object.defineProperty(exports, "LIMITS", { enumerable: true, get: function () { return validate_1.LIMITS; } });
@@ -0,0 +1,25 @@
1
+ export declare const LIMITS: {
2
+ readonly maxToLength: 320;
3
+ readonly maxTemplateLength: 64;
4
+ readonly maxVariables: 50;
5
+ readonly maxVarKeyLength: 64;
6
+ readonly maxVarValueLength: 4096;
7
+ readonly maxAttachments: 5;
8
+ readonly maxLangLength: 10;
9
+ readonly defaultTimeoutMs: 30000;
10
+ readonly maxTimeoutMs: 120000;
11
+ };
12
+ export declare const DEFAULT_API_BASE = "https://zindua.run/api/v1";
13
+ export declare function assertServerRuntime(): void;
14
+ export declare function validateApiKey(apiKey: string): string;
15
+ /** HTTPS only in production; HTTP allowed for localhost dev. Rejects userinfo in URL (SSRF/credential leak). */
16
+ export declare function validateBaseUrl(raw: string): string;
17
+ export declare function resolveBaseUrl(explicit?: string): string;
18
+ export declare function validateTemplateSlug(template: string): string;
19
+ export declare function validateRecipient(to: string, channel: "email" | "whatsapp"): string;
20
+ export declare function validateChannel(channel?: string): "email" | "whatsapp";
21
+ export declare function validateLang(lang?: string): string | undefined;
22
+ export declare function sanitizeVariables(variables?: Record<string, string>): Record<string, string> | undefined;
23
+ export declare function validateOptionalEmailField(field: "cc" | "bcc" | "replyTo", value?: string): string | undefined;
24
+ export declare function validateAttachments(attachments?: unknown[]): unknown[] | undefined;
25
+ export declare function validateTimeoutMs(timeoutMs?: number): number;
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_API_BASE = exports.LIMITS = void 0;
4
+ exports.assertServerRuntime = assertServerRuntime;
5
+ exports.validateApiKey = validateApiKey;
6
+ exports.validateBaseUrl = validateBaseUrl;
7
+ exports.resolveBaseUrl = resolveBaseUrl;
8
+ exports.validateTemplateSlug = validateTemplateSlug;
9
+ exports.validateRecipient = validateRecipient;
10
+ exports.validateChannel = validateChannel;
11
+ exports.validateLang = validateLang;
12
+ exports.sanitizeVariables = sanitizeVariables;
13
+ exports.validateOptionalEmailField = validateOptionalEmailField;
14
+ exports.validateAttachments = validateAttachments;
15
+ exports.validateTimeoutMs = validateTimeoutMs;
16
+ const errors_1 = require("./errors");
17
+ /** Matches platform `isValidE164Phone` in zindua.run API. */
18
+ const E164_RE = /^\+[1-9]\d{6,14}$/;
19
+ /** Matches platform email check in POST /api/v1/send. */
20
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
+ const TEMPLATE_SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/i;
22
+ const LANG_RE = /^[a-z]{2}(-[a-z]{2})?$/i;
23
+ /** Same format as `generateApiKey()` in the Zindua platform. */
24
+ const API_KEY_RE = /^znd_(live|test)_[a-z0-9]{24}$/;
25
+ exports.LIMITS = {
26
+ maxToLength: 320,
27
+ maxTemplateLength: 64,
28
+ maxVariables: 50,
29
+ maxVarKeyLength: 64,
30
+ maxVarValueLength: 4096,
31
+ maxAttachments: 5,
32
+ maxLangLength: 10,
33
+ defaultTimeoutMs: 30_000,
34
+ maxTimeoutMs: 120_000,
35
+ };
36
+ exports.DEFAULT_API_BASE = "https://zindua.run/api/v1";
37
+ const BLOCKED_VAR_KEYS = new Set(["__proto__", "constructor", "prototype"]);
38
+ function assertServerRuntime() {
39
+ const g = globalThis;
40
+ if (g.window !== undefined && g.document !== undefined) {
41
+ throw new errors_1.ZinduaError("Zindua SDK must only run on the server. Proxy sends through your backend API.", { status: 0, code: "BROWSER_FORBIDDEN" });
42
+ }
43
+ }
44
+ function validateApiKey(apiKey) {
45
+ const key = apiKey.trim();
46
+ if (!key) {
47
+ throw new errors_1.ZinduaError("apiKey is required.", { status: 0, code: "INVALID_API_KEY" });
48
+ }
49
+ if (!API_KEY_RE.test(key)) {
50
+ throw new errors_1.ZinduaError("apiKey must be znd_live_… or znd_test_… (project key from Dashboard → Projects).", { status: 0, code: "INVALID_API_KEY" });
51
+ }
52
+ return key;
53
+ }
54
+ /** HTTPS only in production; HTTP allowed for localhost dev. Rejects userinfo in URL (SSRF/credential leak). */
55
+ function validateBaseUrl(raw) {
56
+ const trimmed = raw.trim().replace(/\/$/, "");
57
+ let parsed;
58
+ try {
59
+ parsed = new URL(trimmed);
60
+ }
61
+ catch {
62
+ throw new errors_1.ZinduaError("baseUrl is not a valid URL.", { status: 0, code: "INVALID_BASE_URL" });
63
+ }
64
+ if (parsed.username || parsed.password) {
65
+ throw new errors_1.ZinduaError("baseUrl must not contain credentials.", {
66
+ status: 0,
67
+ code: "INVALID_BASE_URL",
68
+ });
69
+ }
70
+ const host = parsed.hostname.toLowerCase();
71
+ const isLocal = host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost");
72
+ if (parsed.protocol !== "https:" && !(isLocal && parsed.protocol === "http:")) {
73
+ throw new errors_1.ZinduaError("baseUrl must use HTTPS (HTTP is only allowed for localhost).", {
74
+ status: 0,
75
+ code: "INVALID_BASE_URL",
76
+ });
77
+ }
78
+ return trimmed;
79
+ }
80
+ function resolveBaseUrl(explicit) {
81
+ if (explicit?.trim()) {
82
+ return validateBaseUrl(explicit);
83
+ }
84
+ const fromEnv = typeof process !== "undefined" && process.env?.ZINDUA_API_BASE_URL?.trim()
85
+ ? process.env.ZINDUA_API_BASE_URL.trim()
86
+ : "";
87
+ if (fromEnv) {
88
+ return validateBaseUrl(fromEnv);
89
+ }
90
+ return exports.DEFAULT_API_BASE;
91
+ }
92
+ function validateTemplateSlug(template) {
93
+ const slug = template.trim();
94
+ if (!slug || slug.length > exports.LIMITS.maxTemplateLength) {
95
+ throw new errors_1.ZinduaError("template slug is required (max 64 characters).", {
96
+ status: 0,
97
+ code: "INVALID_TEMPLATE",
98
+ });
99
+ }
100
+ if (!TEMPLATE_SLUG_RE.test(slug)) {
101
+ throw new errors_1.ZinduaError("template slug may only contain letters, numbers, hyphens, and underscores.", { status: 0, code: "INVALID_TEMPLATE" });
102
+ }
103
+ return slug;
104
+ }
105
+ function validateRecipient(to, channel) {
106
+ const recipient = to.trim();
107
+ if (!recipient || recipient.length > exports.LIMITS.maxToLength) {
108
+ throw new errors_1.ZinduaError("to is required and must be under 320 characters.", {
109
+ status: 0,
110
+ code: "INVALID_RECIPIENT",
111
+ });
112
+ }
113
+ if (channel === "email") {
114
+ if (!EMAIL_RE.test(recipient)) {
115
+ throw new errors_1.ZinduaError("Invalid email address for channel email.", {
116
+ status: 0,
117
+ code: "INVALID_RECIPIENT",
118
+ });
119
+ }
120
+ return recipient;
121
+ }
122
+ if (!E164_RE.test(recipient)) {
123
+ throw new errors_1.ZinduaError("WhatsApp to must be E.164 with leading + (e.g. +243812345678).", { status: 0, code: "INVALID_RECIPIENT" });
124
+ }
125
+ return recipient;
126
+ }
127
+ function validateChannel(channel) {
128
+ if (channel === undefined || channel === "email")
129
+ return "email";
130
+ if (channel === "whatsapp")
131
+ return "whatsapp";
132
+ throw new errors_1.ZinduaError('channel must be "email" or "whatsapp".', {
133
+ status: 0,
134
+ code: "INVALID_CHANNEL",
135
+ });
136
+ }
137
+ function validateLang(lang) {
138
+ if (lang === undefined || lang === "")
139
+ return undefined;
140
+ const code = lang.trim();
141
+ if (code.length > exports.LIMITS.maxLangLength || !LANG_RE.test(code)) {
142
+ throw new errors_1.ZinduaError("lang must be a short ISO 639-1 code (e.g. fr, en, sw).", {
143
+ status: 0,
144
+ code: "INVALID_LANG",
145
+ });
146
+ }
147
+ return code;
148
+ }
149
+ function sanitizeVariables(variables) {
150
+ if (!variables)
151
+ return undefined;
152
+ const out = {};
153
+ let count = 0;
154
+ for (const [key, value] of Object.entries(variables)) {
155
+ if (BLOCKED_VAR_KEYS.has(key))
156
+ continue;
157
+ if (typeof value !== "string") {
158
+ throw new errors_1.ZinduaError("variables values must be strings.", {
159
+ status: 0,
160
+ code: "INVALID_VARIABLES",
161
+ });
162
+ }
163
+ if (count >= exports.LIMITS.maxVariables) {
164
+ throw new errors_1.ZinduaError(`At most ${exports.LIMITS.maxVariables} variables allowed.`, {
165
+ status: 0,
166
+ code: "INVALID_VARIABLES",
167
+ });
168
+ }
169
+ const safeKey = key.trim().slice(0, exports.LIMITS.maxVarKeyLength);
170
+ if (!safeKey)
171
+ continue;
172
+ out[safeKey] = value.slice(0, exports.LIMITS.maxVarValueLength);
173
+ count += 1;
174
+ }
175
+ return Object.keys(out).length > 0 ? out : undefined;
176
+ }
177
+ function validateOptionalEmailField(field, value) {
178
+ if (value === undefined || value === "")
179
+ return undefined;
180
+ const v = value.trim();
181
+ if (!EMAIL_RE.test(v)) {
182
+ throw new errors_1.ZinduaError(`Invalid ${field} email address.`, {
183
+ status: 0,
184
+ code: "INVALID_OPTIONS",
185
+ });
186
+ }
187
+ return v;
188
+ }
189
+ function validateAttachments(attachments) {
190
+ if (!attachments)
191
+ return undefined;
192
+ if (!Array.isArray(attachments)) {
193
+ throw new errors_1.ZinduaError("attachments must be an array.", { status: 0, code: "INVALID_OPTIONS" });
194
+ }
195
+ if (attachments.length > exports.LIMITS.maxAttachments) {
196
+ throw new errors_1.ZinduaError(`At most ${exports.LIMITS.maxAttachments} attachments allowed.`, {
197
+ status: 0,
198
+ code: "INVALID_OPTIONS",
199
+ });
200
+ }
201
+ return attachments;
202
+ }
203
+ function validateTimeoutMs(timeoutMs) {
204
+ if (timeoutMs === undefined)
205
+ return exports.LIMITS.defaultTimeoutMs;
206
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000 || timeoutMs > exports.LIMITS.maxTimeoutMs) {
207
+ throw new errors_1.ZinduaError(`timeoutMs must be between 1000 and ${exports.LIMITS.maxTimeoutMs}.`, { status: 0, code: "INVALID_OPTIONS" });
208
+ }
209
+ return Math.floor(timeoutMs);
210
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@zindua/sdk",
3
+ "version": "1.2.0",
4
+ "description": "Official Zindua SDK for Node.js — transactional email and WhatsApp via POST /api/v1/send.",
5
+ "author": "Zindua <https://zindua.run>",
6
+ "license": "MIT",
7
+ "homepage": "https://zindua.run/developers#sdks",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/bbasabana/zindua.git",
11
+ "directory": "packages/zindua-js"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/bbasabana/zindua/issues"
15
+ },
16
+ "keywords": [
17
+ "zindua",
18
+ "email",
19
+ "transactional-email",
20
+ "whatsapp",
21
+ "otp",
22
+ "sdk",
23
+ "api-client"
24
+ ],
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "scripts": {
39
+ "build": "tsc",
40
+ "test": "npm run build && node --test test/*.test.mjs",
41
+ "prepublishOnly": "npm run test"
42
+ },
43
+ "engines": {
44
+ "node": ">=18"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "registry": "https://registry.npmjs.org/"
49
+ },
50
+ "devDependencies": {
51
+ "typescript": "^5.0.0"
52
+ }
53
+ }