@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 +21 -0
- package/README.md +220 -0
- package/dist/client.d.ts +37 -0
- package/dist/client.js +141 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +17 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +10 -0
- package/dist/validate.d.ts +25 -0
- package/dist/validate.js +210 -0
- package/package.json +53 -0
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)
|
package/dist/client.d.ts
ADDED
|
@@ -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;
|
package/dist/errors.d.ts
ADDED
|
@@ -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;
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/validate.js
ADDED
|
@@ -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
|
+
}
|