Metadata-Version: 2.4
Name: zindua-sdk
Version: 1.0.1
Summary: Official Zindua SDK for Python — transactional email and WhatsApp via POST /api/v1/send.
Project-URL: Homepage, https://zindua.run/fastapi
Project-URL: Documentation, https://zindua.run/developers
Project-URL: Repository, https://github.com/bbasabana/zindua
Author-email: Zindua <hello@zindua.run>
License-Expression: MIT
License-File: LICENSE
Keywords: email,fastapi,otp,sdk,transactional,whatsapp,zindua
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27.0
Provides-Extra: dev
Requires-Dist: fastapi>=0.110.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: uvicorn>=0.29.0; extra == 'dev'
Provides-Extra: fastapi
Requires-Dist: fastapi>=0.110.0; extra == 'fastapi'
Description-Content-Type: text/markdown

# zindua-sdk

Official **server-side** Python SDK for [Zindua](https://zindua.run): send transactional **email** and **WhatsApp** from FastAPI, Django, Flask, or any Python 3.10+ backend.

| | |
|---|---|
| FastAPI guide | [zindua.run/fastapi](https://zindua.run/fastapi) |
| Full API reference | [zindua.run/developers](https://zindua.run/developers) |
| Dashboard | [zindua.run/login](https://zindua.run/login) |

```bash
pip install zindua-sdk
```

With FastAPI helpers:

```bash
pip install "zindua-sdk[fastapi]"
```

**Requirements:** Python 3.10+. Run only on your **backend** — never ship `ZINDUA_API_KEY` to browsers or mobile apps.

---

## Before you write code (Dashboard setup)

Zindua is an orchestration layer: you configure delivery and templates in the dashboard, then call `send()` from your API.

### Step 1 — Create a project

1. Sign in at [zindua.run/login](https://zindua.run/login).
2. Go to **Projects** → **Create project** (e.g. `My SaaS`).
3. Copy the API key:
   - `znd_test_…` — sandbox (safe for local dev).
   - `znd_live_…` — production.
4. Set the **default language** for the project (Dashboard → project settings). Example: `fr`. This language is used when you omit `lang` in `send()`.

### Step 2 — Connect a delivery service

| Channel | Dashboard path | What you configure |
|---------|----------------|-------------------|
| **Email** | Project → **Service** | Gmail OAuth, Outlook, or custom SMTP. Zindua sends through **your** provider. |
| **WhatsApp** | Project → **WhatsApp** | Scan QR code to link the number. Required for OTP on WhatsApp. |

Without a connected service, `send()` returns `EMAIL_SERVICE_NOT_CONFIGURED` or `WHATSAPP_NOT_CONNECTED`.

### Step 3 — Create templates

1. Open **Templates** → **New template**.
2. Set a **slug** (e.g. `otp-verification`, `welcome`, `password-reset`). You pass this slug in `send(template=...)`.
3. Add **variables** used in the body, e.g. `{{code}}`, `{{appName}}`, `{{name}}`.
4. Add **language versions** for each locale you support:
   - French (`fr`) — subject + body with `{{code}}`.
   - English (`en`) — same variables, translated copy.
   - Swahili (`sw`), etc.
5. Pick one language as the **template default** (used when the requested `lang` does not exist).

Example template slug `otp-verification`:

| Lang | Subject | Body snippet |
|------|---------|--------------|
| `fr` (default) | `Votre code {{appName}}` | `Votre code est {{code}}. Il expire dans 10 minutes.` |
| `en` | `Your {{appName}} code` | `Your code is {{code}}. It expires in 10 minutes.` |

### Step 4 — Verify setup from your terminal

```bash
export ZINDUA_API_KEY=znd_test_your_key_here
python -m zindua doctor
python -m zindua send --to you@example.com --template otp-verification --var code=482910 --var appName=MyApp
```

---

## Install and configure

```bash
pip install zindua-sdk python-dotenv
# FastAPI stack:
pip install "zindua-sdk[fastapi]" uvicorn
```

```bash
# .env — never commit; load via python-dotenv or your host (Railway, Fly.io, etc.)
ZINDUA_API_KEY=znd_test_xxxxxxxxxxxxxxxxxxxxxxxx

# Optional overrides
# ZINDUA_API_BASE_URL=https://zindua.run/api/v1
# ZINDUA_SITE_URL=https://yourapp.com
```

```python
import os
from dotenv import load_dotenv
from zindua import Zindua

load_dotenv()

zindua = Zindua(api_key=os.environ["ZINDUA_API_KEY"])
```

---

## Send a message

```python
import asyncio
from zindua import Zindua

async def main():
    client = Zindua(api_key="znd_test_xxxxxxxxxxxxxxxxxxxxxxxx")

    # Email (default channel)
    result = await client.send(
        to="user@example.com",
        template="welcome",
        variables={"name": "Alex"},
    )

    # WhatsApp — E.164 phone with +
    result = await client.send(
        to="+243812345678",
        channel="whatsapp",
        template="otp-verification",
        variables={"code": "482910", "appName": "MyApp"},
    )

    print(result.log_id, result.status, result.lang_used)

asyncio.run(main())
```

Sync helpers exist for scripts and WSGI apps: `client.send_sync(...)`, `client.get_log_sync(...)`.

### Channel and `to` must match

The SDK validates locally before calling the API.

| `channel` | Valid `to` | Rejected |
|-----------|------------|----------|
| `email` (default) | `user@example.com` | `+243812345678` |
| `whatsapp` | `+243812345678` | `user@example.com` |

---

## Template language (`lang`)

When you create a project, you choose a **default language**. Each template can have several language versions.

| What you pass to `send()` | What Zindua renders |
|---------------------------|---------------------|
| No `lang` | Project default language |
| `lang="en"` and English exists on the template | English version, `result.lang_used == "en"` |
| `lang="de"` but only `fr` / `en` exist | Template default + `result.lang_fallback is True` |

```python
# Use the user's locale from your database or Accept-Language header
user_locale = "fr"  # optional

result = await zindua.send(
    to=contact,
    channel=channel,
    template="otp-verification",
    lang=user_locale,  # omit to use project default
    variables={"code": code, "appName": "MyApp"},
)

if result.lang_fallback:
    # Requested lang was missing; template default was used
    pass
```

List available langs per template:

```python
data = await zindua.get_templates()
for tpl in data["templates"]:
    print(tpl.slug, tpl.langs, tpl.default_lang, tpl.variables)
```

---

## End-to-end: FastAPI backend + Next.js frontend

**Rule:** the browser never sees `ZINDUA_API_KEY`. The Next.js app calls **your** FastAPI API; FastAPI calls Zindua.

```
[Next.js browser]  →  POST /api/auth/request-otp  →  [FastAPI]
                                                          ↓
                                                    store OTP in Redis
                                                    zindua.send(...)
                                                          ↓
                                                    [Zindua → Gmail / WhatsApp]
```

### FastAPI — generate OTP, send, verify

```python
# main.py
import os
import secrets
from contextlib import asynccontextmanager

import redis.asyncio as redis
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
from zindua import Zindua, ZinduaError

load_dotenv()

redis_client: redis.Redis | None = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global redis_client
    redis_client = redis.from_url(os.environ["REDIS_URL"])
    yield
    await redis_client.aclose()


app = FastAPI(lifespan=lifespan)
zindua = Zindua(api_key=os.environ["ZINDUA_API_KEY"])


class RequestOtpBody(BaseModel):
    email: EmailStr
    lang: str | None = None  # e.g. "fr", "en" — optional


class VerifyOtpBody(BaseModel):
    email: EmailStr
    code: str


@app.post("/auth/request-otp")
async def request_otp(body: RequestOtpBody):
    code = f"{secrets.randbelow(1_000_000):06d}"
    await redis_client.setex(f"otp:{body.email}", 600, code)

    try:
        result = await zindua.send(
            to=body.email,
            channel="email",
            template="otp-verification",
            lang=body.lang,
            variables={"code": code, "appName": "MyApp"},
        )
    except ZinduaError as exc:
        raise HTTPException(
            status_code=exc.status or 502,
            detail={"code": exc.code, "error": str(exc)},
        ) from exc

    return {"ok": True, "logId": result.log_id, "status": result.status}


@app.post("/auth/verify-otp")
async def verify_otp(body: VerifyOtpBody):
    stored = await redis_client.get(f"otp:{body.email}")
    if not stored or stored.decode() != body.code:
        raise HTTPException(status_code=401, detail="Invalid or expired code")
    await redis_client.delete(f"otp:{body.email}")
    return {"ok": True}
```

Run locally:

```bash
uvicorn main:app --reload
# Open http://127.0.0.1:8000/docs to test routes
```

### FastAPI — dependency injection

```python
from fastapi import Depends
from zindua.integrations.fastapi import ZinduaDep

@app.post("/send-otp")
async def send_otp(to: str, code: str, zindua: ZinduaDep):
    return await zindua.send(
        to=to,
        template="otp-verification",
        variables={"code": code, "appName": "MyApp"},
    )
```

Install: `pip install "zindua-sdk[fastapi]"`.

### Next.js — call your FastAPI backend

```typescript
// app/login/page.tsx — client component (no Zindua key here)
"use client";

export default function LoginPage() {
  async function requestOtp(email: string, lang?: string) {
    const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/request-otp`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, lang }),
    });
    if (!res.ok) throw new Error("Failed to send OTP");
    return res.json();
  }

  // ... UI that collects email, calls requestOtp(), then verify endpoint
}
```

```bash
# .env.local (Next.js — public URL of your FastAPI only)
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
```

Alternatively, proxy through a Next.js Route Handler (same pattern as `@zindua/sdk` on Node):

```typescript
// app/api/auth/request-otp/route.ts
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.json();
  const upstream = await fetch(`${process.env.FASTAPI_URL}/auth/request-otp`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  return NextResponse.json(await upstream.json(), { status: upstream.status });
}
```

Use **one** place for the Zindua key: either FastAPI or a Node BFF — not both unless you run separate projects.

---

## Email attachments

Email only. Pass up to **5** HTTPS URLs; Zindua fetches them server-side and attaches the files.

```python
result = await zindua.send(
    to="user@example.com",
    template="invoice",
    variables={"name": "Sarah", "invoiceNumber": "INV-1042"},
    attachments=[
        {"url": "https://cdn.example.com/invoices/inv-1042.pdf", "filename": "invoice.pdf"},
    ],
)
```

---

## Track delivery (`get_log`)

Every successful `send()` returns a `log_id`. Poll status from your backend:

```python
result = await zindua.send(
    to="user@example.com",
    template="welcome",
    variables={"name": "Alex"},
)

log = await zindua.get_log(result.log_id)
print(log.status, log.error, log.opened_at)
```

Statuses include `queued`, `sent`, `delivered`, `failed`, etc. For full history, use Dashboard → Logs or configure [webhooks](https://zindua.run/developers#webhooks).

---

## Error handling

```python
from zindua import ZinduaError

try:
    result = await zindua.send(
        to="user@example.com",
        template="otp-verification",
        variables={"code": "482910"},
    )
except ZinduaError as exc:
    # exc.status — HTTP status (400, 422, 429, …)
    # exc.code   — platform code (TEMPLATE_NOT_FOUND, QUOTA_EXCEEDED, …)
    print(exc.code, exc.status, exc.details)
```

| Code | Typical cause | Fix |
|------|---------------|-----|
| `TEMPLATE_NOT_FOUND` | Wrong slug | Match Dashboard → Templates slug |
| `EMAIL_SERVICE_NOT_CONFIGURED` | No Gmail/SMTP | Project → Service |
| `WHATSAPP_NOT_CONNECTED` | QR not scanned | Project → WhatsApp |
| `QUOTA_EXCEEDED` | Plan limit | Upgrade or wait for reset |
| `INVALID_EMAIL` / `INVALID_PHONE` | `to` does not match `channel` | Fix payload or let SDK validate early |

---

## CLI (smoke tests)

Ships with the package — no separate install.

```bash
export ZINDUA_API_KEY=znd_test_...
python -m zindua doctor
python -m zindua send --to user@example.com --template otp-verification --var code=482910
python -m zindua send --to +243812345678 --channel whatsapp --template otp-verification --var code=482910
```

---

## API methods

| Method | HTTP | Description |
|--------|------|-------------|
| `send()` | `POST /send` | Queue email or WhatsApp |
| `get_log()` | `GET /logs/{logId}` | Delivery status for one message |
| `get_project()` | `GET /project` | Plan, channels, project metadata |
| `get_templates()` | `GET /templates` | Slugs, langs, variables |
| `connect()` | `POST /connect` | Bind key to site URL (WordPress-style) |
| `is_test_mode()` | (local) | `True` for `znd_test_` keys |

---

## Security

| Do | Don't |
|----|-------|
| Store `ZINDUA_API_KEY` in env / secrets manager | Expose the key in React, Flutter, or Swagger on a public URL |
| Call Zindua from FastAPI, Django, Celery, cron | Import `zindua` in a public Jupyter notebook with a live key |
| Store OTPs in Redis/Postgres with TTL | Rely on Zindua to store verification codes |
| Use `znd_test_` in staging | Share `znd_live_` keys in chat or git |

---

## Client options

```python
Zindua(
    api_key="znd_live_xxxxxxxxxxxxxxxxxxxxxxxx",
    base_url="http://localhost:3000/api/v1",  # optional — local Zindua dev only
    timeout=30.0,                              # seconds, max 120
    site_url="https://yourapp.com",            # optional — WordPress / connect flows
)
```

Environment variables read by `get_zindua()`:

| Variable | Required | Description |
|----------|----------|-------------|
| `ZINDUA_API_KEY` | Yes | Project API key |
| `ZINDUA_API_BASE_URL` | No | Override API base (default `https://zindua.run/api/v1`) |
| `ZINDUA_SITE_URL` | No | Sent as `X-Zindua-Site-Url` when set |

---

## License

MIT © [Zindua](https://zindua.run)
