@zindua/sdk 1.2.9 → 1.4.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/README.md CHANGED
@@ -5,6 +5,7 @@ Official **server-side** SDK for [Zindua](https://zindua.run): send transactiona
5
5
  | | |
6
6
  |---|---|
7
7
  | Full API reference | [zindua.run/developers](https://zindua.run/developers) |
8
+ | React Email → Zindua | [zindua.run/react-email](https://zindua.run/react-email) |
8
9
  | Dashboard (keys, templates, services) | [zindua.run/login](https://zindua.run/login) |
9
10
  | Pricing | [zindua.run/pricing](https://zindua.run/pricing) |
10
11
 
@@ -58,6 +59,7 @@ await zindua.send({
58
59
  });
59
60
 
60
61
  // WhatsApp — E.164 phone with + (channel is required for WhatsApp)
62
+ // Works for OTP, alerts, receipts — any template.
61
63
  await zindua.send({
62
64
  to: "+243812345678",
63
65
  channel: "whatsapp",
@@ -66,6 +68,14 @@ await zindua.send({
66
68
  });
67
69
  ```
68
70
 
71
+ ### WhatsApp: brand name + save contact
72
+
73
+ WhatsApp may still show the phone number in the chat header until the recipient saves your contact. Zindua helps in three ways:
74
+
75
+ 1. **Sender name** in Dashboard → Project → WhatsApp (required after linking).
76
+ 2. **Brand prefix** — every WhatsApp body is prefixed with that name (`Acme: …`) when set.
77
+ 3. **Save contact** — download the `.vcf` from the dashboard and ask users to save it before important messages. Optionally enable **Send contact card on first message** so each new recipient gets a saveable card once (never again for that number).
78
+
69
79
  ### WhatsApp anti-ban robot (required)
70
80
 
71
81
  Zindua runs an **anti-ban Guardian** on WhatsApp sends. Burst / simultaneous OTP traffic looks like spam and can **ban the number you linked** in the dashboard.
@@ -79,7 +89,7 @@ Guide: [WhatsApp anti-ban Guardian](https://zindua.run/whatsapp/anti-ban)
79
89
  ### Upgrade (already installed?)
80
90
 
81
91
  ```bash
82
- npm install @zindua/sdk@1.2.8
92
+ npm install @zindua/sdk@1.4.0
83
93
  ```
84
94
 
85
95
  ```typescript
@@ -175,6 +185,60 @@ Allowed codes: short ISO 639-1 style (`fr`, `en`, `sw`, `en-us`). Invalid format
175
185
 
176
186
  ---
177
187
 
188
+ ## Templates API (v1.4.0+)
189
+
190
+ Hosted templates stay the source of truth. Use these methods to **import** HTML (e.g. from [React Email](https://zindua.run/react-email) `render()`) and optionally **render** without sending.
191
+
192
+ | Method | HTTP | Purpose |
193
+ |--------|------|---------|
194
+ | `getTemplates()` | `GET /templates` | List slugs, langs, `{{variables}}` |
195
+ | `upsertTemplate({ slug, lang, subject, bodyHtml, bodyText?, isDefault? })` | `PUT /templates/{slug}/versions/{lang}` | Create/update a locale (creates the slug if missing) |
196
+ | `renderTemplate({ template, lang?, variables? })` | `POST /templates/render` | Interpolate `{{vars}}` → `{ subject, html, text }` — **no delivery** |
197
+ | `send({ to, template, … })` | `POST /send` | **Recommended** — interpolate + deliver via your connected Service |
198
+
199
+ ### Push HTML (React Email / CLI-friendly)
200
+
201
+ ```typescript
202
+ import { readFileSync } from "node:fs";
203
+ import { Zindua } from "@zindua/sdk";
204
+
205
+ const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
206
+
207
+ await zindua.upsertTemplate({
208
+ slug: "welcome",
209
+ lang: "en",
210
+ subject: "Welcome {{name}}",
211
+ bodyHtml: readFileSync("./welcome.html", "utf8"),
212
+ isDefault: true,
213
+ });
214
+ ```
215
+
216
+ Companion helper (local `render` + upsert): [`@zindua/react-email`](https://www.npmjs.com/package/@zindua/react-email) → `pushReactEmail({ client, slug, lang, subject, element })`.
217
+
218
+ CLI:
219
+
220
+ ```bash
221
+ npx @zindua/cli templates push --slug welcome --lang en \
222
+ --subject "Welcome {{name}}" --html ./welcome.html --default
223
+ ```
224
+
225
+ ### Render without sending (escape hatch)
226
+
227
+ Prefer `send()` for production. Use `renderTemplate` only for progressive migration (keep Nodemailer/SendGrid in-process temporarily) or CI previews:
228
+
229
+ ```typescript
230
+ const rendered = await zindua.renderTemplate({
231
+ template: "welcome",
232
+ lang: "en",
233
+ variables: { name: "Ada", appName: "Acme" },
234
+ });
235
+ // rendered.subject, rendered.html, rendered.text
236
+ ```
237
+
238
+ Keep SendGrid / SES / SMTP credentials in **Dashboard → Service**. Do not re-wire transporters in the SDK.
239
+
240
+ ---
241
+
178
242
  ## Plans and channels
179
243
 
180
244
  | Plan | Email API | WhatsApp | Email / month | WhatsApp OTP / month |
@@ -571,7 +635,7 @@ Many errors include a **`context`** field (same shape as success) with `plan` an
571
635
  1. Store the API key in environment variables or a secrets manager.
572
636
  2. Call Zindua from your **API routes** only, not from React/Vue/mobile bundles.
573
637
  3. Use **`znd_test_…`** in staging; **`znd_live_…`** in production.
574
- 4. Pin the SDK version in `package.json`, e.g. `"@zindua/sdk": "1.2.6"`.
638
+ 4. Pin the SDK version in `package.json`, e.g. `"@zindua/sdk": "1.3.0"`.
575
639
  5. One Zindua project per client when quotas, senders, or templates must stay isolated.
576
640
 
577
641
  ---
@@ -593,9 +657,11 @@ See [HTTP / cURL](https://zindua.run/developers#http).
593
657
 
594
658
  | Export | Description |
595
659
  |--------|-------------|
596
- | `Zindua` | Client class |
660
+ | `Zindua` | Client class — `send`, `getTemplates`, `upsertTemplate`, `renderTemplate`, `verifyEmail`, `getLog`, `getProject`, `connect`, `pushMirror` |
597
661
  | `ZinduaError` | Error type (`status`, `code`, `message`, `details`) |
598
- | `ZinduaSendResult` | Success payload type |
662
+ | `ZinduaSendResult` | Success payload type for `send()` |
663
+ | `ZinduaUpsertTemplateResult` | Result of `upsertTemplate()` |
664
+ | `ZinduaRenderTemplateResult` | Result of `renderTemplate()` |
599
665
  | `ZinduaSendContext` | Type of `result.context` |
600
666
  | `DEFAULT_API_BASE` | `https://zindua.run/api/v1` |
601
667
  | `LIMITS` | Client-side validation limits |
package/dist/client.d.ts CHANGED
@@ -9,6 +9,23 @@ export type ZinduaSendOptions = {
9
9
  bcc?: string;
10
10
  replyTo?: string;
11
11
  attachments?: unknown[];
12
+ /** When true, server verifies email (format/MX/disposable) before queueing. */
13
+ validate?: boolean;
14
+ };
15
+ export type ZinduaEmailVerifyResult = {
16
+ ok: true;
17
+ email: string;
18
+ status: "valid" | "risky" | "invalid";
19
+ score: number;
20
+ formatOk: boolean;
21
+ mxFound: boolean;
22
+ disposable: boolean;
23
+ didYouMean: string | null;
24
+ domain: string | null;
25
+ reasons: string[];
26
+ suppressed: boolean;
27
+ suppressionReason: string | null;
28
+ deliverable: boolean;
12
29
  };
13
30
  export type ZinduaClientOptions = {
14
31
  apiKey: string;
@@ -45,6 +62,42 @@ export type ZinduaTemplateInfo = {
45
62
  defaultLang: string;
46
63
  variables: string[];
47
64
  };
65
+ export type ZinduaUpsertTemplateOptions = {
66
+ slug: string;
67
+ lang: string;
68
+ subject: string;
69
+ bodyHtml: string;
70
+ bodyText?: string;
71
+ isDefault?: boolean;
72
+ };
73
+ export type ZinduaUpsertTemplateResult = {
74
+ ok: true;
75
+ slug: string;
76
+ created: boolean;
77
+ templateCreated: boolean;
78
+ version: {
79
+ lang: string;
80
+ subject: string;
81
+ isDefault: boolean;
82
+ updatedAt: string;
83
+ };
84
+ variables: string[];
85
+ };
86
+ export type ZinduaRenderTemplateOptions = {
87
+ template: string;
88
+ lang?: string;
89
+ variables?: Record<string, string>;
90
+ };
91
+ export type ZinduaRenderTemplateResult = {
92
+ ok: true;
93
+ template: string;
94
+ langUsed: string;
95
+ langFallback: boolean;
96
+ subject: string;
97
+ html: string;
98
+ text: string | null;
99
+ variables: string[];
100
+ };
48
101
  export type ZinduaConnectResult = {
49
102
  ok: true;
50
103
  connected: true;
@@ -272,6 +325,21 @@ export declare class Zindua {
272
325
  templates: ZinduaTemplateInfo[];
273
326
  limits?: Record<string, unknown>;
274
327
  }>;
328
+ /**
329
+ * Create or update a template locale (PUT …/templates/{slug}/versions/{lang}).
330
+ * Use after React Email `render()` to push HTML — creates the slug if missing.
331
+ */
332
+ upsertTemplate(options: ZinduaUpsertTemplateOptions): Promise<ZinduaUpsertTemplateResult>;
333
+ /**
334
+ * Interpolate a hosted template without sending (POST /templates/render).
335
+ * Prefer `send()` for delivery — use this for progressive migration only.
336
+ */
337
+ renderTemplate(options: ZinduaRenderTemplateOptions): Promise<ZinduaRenderTemplateResult>;
338
+ /**
339
+ * Verify an email address (format, MX, disposable, typo suggestion).
340
+ * POST /api/v1/email/verify — protects your BYO provider reputation.
341
+ */
342
+ verifyEmail(email: string): Promise<ZinduaEmailVerifyResult>;
275
343
  /** Delivery status for a logId returned by send() (GET /logs/{logId}). */
276
344
  getLog(logId: string): Promise<Record<string, unknown>>;
277
345
  send(options: ZinduaSendOptions): Promise<ZinduaSendResult>;
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@ exports.Zindua = void 0;
4
4
  const errors_1 = require("./errors");
5
5
  const validate_1 = require("./validate");
6
6
  const whatsapp_anti_ban_1 = require("./whatsapp-anti-ban");
7
- const SDK_VERSION = "1.2.9";
7
+ const SDK_VERSION = "1.4.0";
8
8
  const USER_AGENT = `Zindua-JS/${SDK_VERSION}`;
9
9
  function buildPayload(options, channel) {
10
10
  const to = (0, validate_1.validateRecipient)(options.to, channel);
@@ -35,6 +35,8 @@ function buildPayload(options, channel) {
35
35
  payload.replyTo = replyTo;
36
36
  if (attachments)
37
37
  payload.attachments = attachments;
38
+ if (options.validate === true)
39
+ payload.validate = true;
38
40
  }
39
41
  return payload;
40
42
  }
@@ -256,6 +258,110 @@ class Zindua {
256
258
  limits: data.limits,
257
259
  };
258
260
  }
261
+ /**
262
+ * Create or update a template locale (PUT …/templates/{slug}/versions/{lang}).
263
+ * Use after React Email `render()` to push HTML — creates the slug if missing.
264
+ */
265
+ async upsertTemplate(options) {
266
+ const slug = (0, validate_1.validateTemplateSlug)(options.slug);
267
+ const lang = (0, validate_1.validateLang)(options.lang);
268
+ if (!lang) {
269
+ throw new errors_1.ZinduaError("lang is required.", { status: 0, code: "MISSING_FIELDS" });
270
+ }
271
+ const subject = typeof options.subject === "string" ? options.subject.trim() : "";
272
+ const bodyHtml = typeof options.bodyHtml === "string" ? options.bodyHtml : "";
273
+ if (!subject) {
274
+ throw new errors_1.ZinduaError("subject is required.", { status: 0, code: "MISSING_FIELDS" });
275
+ }
276
+ if (!bodyHtml.trim()) {
277
+ throw new errors_1.ZinduaError("bodyHtml is required.", { status: 0, code: "MISSING_FIELDS" });
278
+ }
279
+ const payload = { subject, bodyHtml };
280
+ if (typeof options.bodyText === "string")
281
+ payload.bodyText = options.bodyText;
282
+ if (options.isDefault === true)
283
+ payload.isDefault = true;
284
+ const data = await this.request("PUT", `templates/${encodeURIComponent(slug)}/versions/${encodeURIComponent(lang)}`, payload);
285
+ if (data.ok !== true || !data.version || typeof data.version !== "object") {
286
+ throw new errors_1.ZinduaError("API response missing upsert payload.", {
287
+ status: 200,
288
+ code: "INVALID_RESPONSE",
289
+ });
290
+ }
291
+ return {
292
+ ok: true,
293
+ slug: typeof data.slug === "string" ? data.slug : slug,
294
+ created: Boolean(data.created),
295
+ templateCreated: Boolean(data.templateCreated),
296
+ version: data.version,
297
+ variables: Array.isArray(data.variables) ? data.variables : [],
298
+ };
299
+ }
300
+ /**
301
+ * Interpolate a hosted template without sending (POST /templates/render).
302
+ * Prefer `send()` for delivery — use this for progressive migration only.
303
+ */
304
+ async renderTemplate(options) {
305
+ const template = (0, validate_1.validateTemplateSlug)(options.template);
306
+ const lang = (0, validate_1.validateLang)(options.lang);
307
+ const variables = (0, validate_1.sanitizeVariables)(options.variables);
308
+ const payload = { template };
309
+ if (lang)
310
+ payload.lang = lang;
311
+ if (variables)
312
+ payload.variables = variables;
313
+ const data = await this.request("POST", "templates/render", payload);
314
+ if (data.ok !== true ||
315
+ typeof data.subject !== "string" ||
316
+ typeof data.html !== "string") {
317
+ throw new errors_1.ZinduaError("API response missing render payload.", {
318
+ status: 200,
319
+ code: "INVALID_RESPONSE",
320
+ });
321
+ }
322
+ return {
323
+ ok: true,
324
+ template: typeof data.template === "string" ? data.template : template,
325
+ langUsed: typeof data.langUsed === "string" ? data.langUsed : lang ?? "en",
326
+ langFallback: Boolean(data.langFallback),
327
+ subject: data.subject,
328
+ html: data.html,
329
+ text: typeof data.text === "string" ? data.text : null,
330
+ variables: Array.isArray(data.variables) ? data.variables : [],
331
+ };
332
+ }
333
+ /**
334
+ * Verify an email address (format, MX, disposable, typo suggestion).
335
+ * POST /api/v1/email/verify — protects your BYO provider reputation.
336
+ */
337
+ async verifyEmail(email) {
338
+ const trimmed = email.trim();
339
+ if (!trimmed) {
340
+ throw new errors_1.ZinduaError("email is required.", { status: 0, code: "MISSING_FIELDS" });
341
+ }
342
+ const data = await this.request("POST", "email/verify", { email: trimmed });
343
+ if (data.ok !== true || typeof data.email !== "string") {
344
+ throw new errors_1.ZinduaError("API response missing verify payload.", {
345
+ status: 200,
346
+ code: "INVALID_RESPONSE",
347
+ });
348
+ }
349
+ return {
350
+ ok: true,
351
+ email: data.email,
352
+ status: data.status === "risky" || data.status === "invalid" ? data.status : "valid",
353
+ score: typeof data.score === "number" ? data.score : 0,
354
+ formatOk: Boolean(data.formatOk),
355
+ mxFound: Boolean(data.mxFound),
356
+ disposable: Boolean(data.disposable),
357
+ didYouMean: typeof data.didYouMean === "string" ? data.didYouMean : null,
358
+ domain: typeof data.domain === "string" ? data.domain : null,
359
+ reasons: Array.isArray(data.reasons) ? data.reasons : [],
360
+ suppressed: Boolean(data.suppressed),
361
+ suppressionReason: typeof data.suppressionReason === "string" ? data.suppressionReason : null,
362
+ deliverable: Boolean(data.deliverable),
363
+ };
364
+ }
259
365
  /** Delivery status for a logId returned by send() (GET /logs/{logId}). */
260
366
  async getLog(logId) {
261
367
  const trimmed = logId.trim();
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { Zindua } from "./client";
2
- export type { ZinduaClientOptions, ZinduaConnectResult, ZinduaProjectInfo, ZinduaSendContext, ZinduaSendOptions, ZinduaSendResult, ZinduaTemplateInfo, SendChannel, } from "./client";
2
+ export type { ZinduaClientOptions, ZinduaConnectResult, ZinduaEmailVerifyResult, ZinduaProjectInfo, ZinduaRenderTemplateOptions, ZinduaRenderTemplateResult, ZinduaSendContext, ZinduaSendOptions, ZinduaSendResult, ZinduaTemplateInfo, ZinduaUpsertTemplateOptions, ZinduaUpsertTemplateResult, SendChannel, } from "./client";
3
3
  export { ZinduaError } from "./errors";
4
4
  export type { ZinduaErrorCode } from "./errors";
5
5
  export { DEFAULT_API_BASE, LIMITS } from "./validate";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zindua/sdk",
3
- "version": "1.2.9",
4
- "description": "Official Zindua SDK for Node.js — transactional email and WhatsApp via POST /api/v1/send.",
3
+ "version": "1.4.0",
4
+ "description": "Official Zindua SDK for Node.js — transactional email, WhatsApp, template upsert/render via /api/v1.",
5
5
  "author": "Zindua",
6
6
  "license": "MIT",
7
7
  "homepage": "https://zindua.run/developers#sdks",