zindua-sdk 1.0.0__tar.gz

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.
@@ -0,0 +1,63 @@
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ examples/**/node_modules/
6
+ /.pnp
7
+ .pnp.*
8
+ .yarn/*
9
+ !.yarn/patches
10
+ !.yarn/plugins
11
+ !.yarn/releases
12
+ !.yarn/versions
13
+
14
+ # local build artifacts in examples
15
+ examples/**/.next/
16
+ examples/**/dist/
17
+ examples/**/build/
18
+ examples/**/package-lock.json
19
+
20
+ # testing
21
+ /coverage
22
+
23
+ # next.js
24
+ /.next/
25
+ /out/
26
+
27
+ # production
28
+ /build
29
+
30
+ # WhatsApp Baileys auth (per project)
31
+ .wa-sessions/
32
+
33
+ # misc
34
+ .DS_Store
35
+ *.pem
36
+
37
+ # debug
38
+ npm-debug.log*
39
+ yarn-debug.log*
40
+ yarn-error.log*
41
+ .pnpm-debug.log*
42
+
43
+ # env files (can opt-in for committing if needed)
44
+ .env*
45
+ !.env.example
46
+
47
+ # npm publish token (local only)
48
+ packages/zindua-js/.npmrc
49
+
50
+ # OAuth client downloads (never commit secrets)
51
+ config/google-oauth.client.json
52
+ config/microsoft-oauth.client.json
53
+
54
+ # vercel
55
+ .vercel
56
+
57
+ # typescript
58
+ *.tsbuildinfo
59
+ next-env.d.ts
60
+
61
+ # local support uploads (replace with object storage in production)
62
+ /public/support-uploads/*
63
+ !/public/support-uploads/.gitkeep
@@ -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.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: zindua-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Zindua SDK for Python — transactional email and WhatsApp via POST /api/v1/send.
5
+ Project-URL: Homepage, https://zindua.run/fastapi
6
+ Project-URL: Documentation, https://zindua.run/developers
7
+ Project-URL: Repository, https://github.com/bbasabana/zindua
8
+ Author-email: Zindua <hello@zindua.run>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: email,fastapi,otp,sdk,transactional,whatsapp,zindua
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.27.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: fastapi>=0.110.0; extra == 'dev'
24
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
26
+ Requires-Dist: uvicorn>=0.29.0; extra == 'dev'
27
+ Provides-Extra: fastapi
28
+ Requires-Dist: fastapi>=0.110.0; extra == 'fastapi'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # zindua-sdk
32
+
33
+ Official **server-side** Python SDK for [Zindua](https://zindua.run): send transactional **email** and **WhatsApp** messages.
34
+
35
+ | | |
36
+ |---|---|
37
+ | FastAPI guide | [zindua.run/fastapi](https://zindua.run/fastapi) |
38
+ | Full API reference | [zindua.run/developers](https://zindua.run/developers) |
39
+ | Dashboard | [zindua.run/login](https://zindua.run/login) |
40
+
41
+ ```bash
42
+ pip install zindua-sdk
43
+ ```
44
+
45
+ **Requirements:** Python 3.10+, run only on your **backend** (never ship `ZINDUA_API_KEY` to browsers).
46
+
47
+ ## Quick start
48
+
49
+ ```python
50
+ import asyncio
51
+ from zindua import Zindua
52
+
53
+ async def main():
54
+ client = Zindua(api_key="znd_test_xxxxxxxxxxxxxxxxxxxxxxxx")
55
+ result = await client.send(
56
+ to="user@example.com",
57
+ template="otp-verification",
58
+ lang="fr",
59
+ variables={"code": "482910", "appName": "MyApp"},
60
+ )
61
+ print(result.log_id, result.status)
62
+
63
+ asyncio.run(main())
64
+ ```
65
+
66
+ ## FastAPI
67
+
68
+ ```python
69
+ from zindua.integrations.fastapi import get_zindua
70
+
71
+ @app.post("/send-otp")
72
+ async def send_otp(body: SendOtpRequest, zindua: Zindua = Depends(get_zindua)):
73
+ return await zindua.send(to=body.to, template="otp-verification", variables={"code": body.code})
74
+ ```
75
+
76
+ ## CLI
77
+
78
+ ```bash
79
+ export ZINDUA_API_KEY=znd_test_...
80
+ python -m zindua doctor
81
+ python -m zindua send --to user@example.com --template otp-verification --var code=482910
82
+ ```
83
+
84
+ ## Publish (maintainers)
85
+
86
+ ```bash
87
+ cd packages/zindua-python
88
+ python -m pip install build twine
89
+ python -m build
90
+ twine upload dist/*
91
+ ```
92
+
93
+ Bump `version` in `pyproject.toml` and `PYTHON_SDK_VERSION` in the app before each release.
94
+
95
+ ## License
96
+
97
+ MIT © [Zindua](https://zindua.run)
@@ -0,0 +1,67 @@
1
+ # zindua-sdk
2
+
3
+ Official **server-side** Python SDK for [Zindua](https://zindua.run): send transactional **email** and **WhatsApp** messages.
4
+
5
+ | | |
6
+ |---|---|
7
+ | FastAPI guide | [zindua.run/fastapi](https://zindua.run/fastapi) |
8
+ | Full API reference | [zindua.run/developers](https://zindua.run/developers) |
9
+ | Dashboard | [zindua.run/login](https://zindua.run/login) |
10
+
11
+ ```bash
12
+ pip install zindua-sdk
13
+ ```
14
+
15
+ **Requirements:** Python 3.10+, run only on your **backend** (never ship `ZINDUA_API_KEY` to browsers).
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ import asyncio
21
+ from zindua import Zindua
22
+
23
+ async def main():
24
+ client = Zindua(api_key="znd_test_xxxxxxxxxxxxxxxxxxxxxxxx")
25
+ result = await client.send(
26
+ to="user@example.com",
27
+ template="otp-verification",
28
+ lang="fr",
29
+ variables={"code": "482910", "appName": "MyApp"},
30
+ )
31
+ print(result.log_id, result.status)
32
+
33
+ asyncio.run(main())
34
+ ```
35
+
36
+ ## FastAPI
37
+
38
+ ```python
39
+ from zindua.integrations.fastapi import get_zindua
40
+
41
+ @app.post("/send-otp")
42
+ async def send_otp(body: SendOtpRequest, zindua: Zindua = Depends(get_zindua)):
43
+ return await zindua.send(to=body.to, template="otp-verification", variables={"code": body.code})
44
+ ```
45
+
46
+ ## CLI
47
+
48
+ ```bash
49
+ export ZINDUA_API_KEY=znd_test_...
50
+ python -m zindua doctor
51
+ python -m zindua send --to user@example.com --template otp-verification --var code=482910
52
+ ```
53
+
54
+ ## Publish (maintainers)
55
+
56
+ ```bash
57
+ cd packages/zindua-python
58
+ python -m pip install build twine
59
+ python -m build
60
+ twine upload dist/*
61
+ ```
62
+
63
+ Bump `version` in `pyproject.toml` and `PYTHON_SDK_VERSION` in the app before each release.
64
+
65
+ ## License
66
+
67
+ MIT © [Zindua](https://zindua.run)
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "zindua-sdk"
7
+ version = "1.0.0"
8
+ description = "Official Zindua SDK for Python — transactional email and WhatsApp via POST /api/v1/send."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Zindua", email = "hello@zindua.run" }]
13
+ keywords = ["zindua", "email", "whatsapp", "otp", "transactional", "sdk", "fastapi"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Typing :: Typed",
23
+ ]
24
+ dependencies = [
25
+ "httpx>=0.27.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ fastapi = ["fastapi>=0.110.0"]
30
+ dev = ["pytest>=8.0.0", "pytest-asyncio>=0.23.0", "fastapi>=0.110.0", "uvicorn>=0.29.0"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://zindua.run/fastapi"
34
+ Documentation = "https://zindua.run/developers"
35
+ Repository = "https://github.com/bbasabana/zindua"
36
+
37
+ [project.scripts]
38
+ zindua = "zindua.__main__:main"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/zindua"]
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
45
+ testpaths = ["tests"]
@@ -0,0 +1,15 @@
1
+ from zindua.client import Zindua
2
+ from zindua.errors import ZinduaError
3
+ from zindua.types import LogStatus, SendResult, TemplateInfo
4
+ from zindua.validate import DEFAULT_API_BASE
5
+
6
+ __all__ = [
7
+ "Zindua",
8
+ "ZinduaError",
9
+ "SendResult",
10
+ "LogStatus",
11
+ "TemplateInfo",
12
+ "DEFAULT_API_BASE",
13
+ ]
14
+
15
+ __version__ = "1.0.0"
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import os
6
+ import re
7
+ import sys
8
+
9
+ from zindua import Zindua
10
+ from zindua.errors import ZinduaError
11
+
12
+ API_KEY_RE = re.compile(r"^znd_(live|test)_[a-z0-9]{24}$")
13
+
14
+
15
+ def _load_dotenv() -> None:
16
+ for name in (".env", ".env.local"):
17
+ if not os.path.isfile(name):
18
+ continue
19
+ with open(name, encoding="utf-8") as fh:
20
+ for line in fh:
21
+ line = line.strip()
22
+ if not line or line.startswith("#") or "=" not in line:
23
+ continue
24
+ key, value = line.split("=", 1)
25
+ key = key.strip()
26
+ value = value.strip().strip('"').strip("'")
27
+ os.environ.setdefault(key, value)
28
+
29
+
30
+ def _resolve_api_key(flag: str | None) -> str:
31
+ _load_dotenv()
32
+ key = (flag or os.environ.get("ZINDUA_API_KEY") or "").strip()
33
+ if not key:
34
+ print("ZINDUA_API_KEY is not set.", file=sys.stderr)
35
+ sys.exit(1)
36
+ return key
37
+
38
+
39
+ async def _doctor(api_key: str) -> int:
40
+ print("Zindua doctor")
41
+ ok_format = bool(API_KEY_RE.match(api_key))
42
+ print(f" API key format: {'ok' if ok_format else 'invalid'}")
43
+ if not ok_format:
44
+ return 1
45
+ client = Zindua(api_key=api_key)
46
+ try:
47
+ project = await client.get_project()
48
+ except ZinduaError as exc:
49
+ print(f" GET /project: failed ({exc.code}) {exc}")
50
+ return 1
51
+ name = project.get("project", {}).get("name") if isinstance(project.get("project"), dict) else project.get("name")
52
+ channels = project.get("channels") if isinstance(project.get("channels"), dict) else {}
53
+ email_ready = bool((channels.get("email") or {}).get("ready") if isinstance(channels.get("email"), dict) else channels.get("email"))
54
+ wa_ready = bool((channels.get("whatsapp") or {}).get("ready") if isinstance(channels.get("whatsapp"), dict) else channels.get("whatsapp"))
55
+ print(f" GET /project: ok ({name or 'unknown'})")
56
+ print(f" Email channel: {'ready' if email_ready else 'not connected'}")
57
+ print(f" WhatsApp channel: {'ready' if wa_ready else 'not connected'}")
58
+ return 0
59
+
60
+
61
+ async def _send(api_key: str, args: argparse.Namespace) -> int:
62
+ variables: dict[str, str] = {}
63
+ for item in args.var or []:
64
+ if "=" not in item:
65
+ print(f"Invalid --var {item!r}, expected key=value", file=sys.stderr)
66
+ return 1
67
+ key, value = item.split("=", 1)
68
+ variables[key.strip()] = value
69
+
70
+ client = Zindua(api_key=api_key)
71
+ try:
72
+ result = await client.send(
73
+ to=args.to,
74
+ template=args.template,
75
+ channel=args.channel,
76
+ lang=args.lang,
77
+ variables=variables or None,
78
+ )
79
+ except ZinduaError as exc:
80
+ print(f"send failed ({exc.code}): {exc}", file=sys.stderr)
81
+ return 1
82
+
83
+ print(f"ok logId={result.log_id} status={result.status} langUsed={result.lang_used}")
84
+ return 0
85
+
86
+
87
+ def main() -> None:
88
+ parser = argparse.ArgumentParser(prog="zindua", description="Zindua Python SDK CLI")
89
+ parser.add_argument("--api-key", dest="api_key", help="Override ZINDUA_API_KEY")
90
+ sub = parser.add_subparsers(dest="command", required=True)
91
+
92
+ sub.add_parser("doctor", help="Verify API key and project connectivity")
93
+
94
+ send_p = sub.add_parser("send", help="Send a test message")
95
+ send_p.add_argument("--to", required=True)
96
+ send_p.add_argument("--template", required=True)
97
+ send_p.add_argument("--channel", choices=["email", "whatsapp"], default="email")
98
+ send_p.add_argument("--lang")
99
+ send_p.add_argument("--var", action="append", help="Template variable key=value")
100
+
101
+ args = parser.parse_args()
102
+ api_key = _resolve_api_key(args.api_key)
103
+
104
+ if args.command == "doctor":
105
+ raise SystemExit(asyncio.run(_doctor(api_key)))
106
+ if args.command == "send":
107
+ raise SystemExit(asyncio.run(_send(api_key, args)))
108
+
109
+ parser.print_help()
110
+ raise SystemExit(1)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from zindua.errors import ZinduaError
6
+ from zindua.http import HttpClient
7
+ from zindua.types import LogStatus, SendResult, TemplateInfo
8
+ from zindua.validate import (
9
+ build_send_payload,
10
+ resolve_base_url,
11
+ validate_api_key,
12
+ validate_site_url,
13
+ validate_timeout,
14
+ )
15
+
16
+
17
+ class Zindua:
18
+ """Async Zindua API client (server-side only)."""
19
+
20
+ def __init__(
21
+ self,
22
+ api_key: str,
23
+ *,
24
+ base_url: str | None = None,
25
+ timeout: float | None = None,
26
+ site_url: str | None = None,
27
+ ) -> None:
28
+ self._api_key = validate_api_key(api_key)
29
+ self._base_url = resolve_base_url(base_url)
30
+ self._timeout = validate_timeout(timeout)
31
+ self._site_url = validate_site_url(site_url)
32
+ self._http = HttpClient(self._api_key, self._base_url, self._timeout, self._site_url)
33
+
34
+ def is_test_mode(self) -> bool:
35
+ return self._api_key.startswith("znd_test_")
36
+
37
+ async def send(
38
+ self,
39
+ *,
40
+ to: str,
41
+ template: str,
42
+ channel: str | None = None,
43
+ lang: str | None = None,
44
+ variables: dict[str, str] | None = None,
45
+ cc: str | None = None,
46
+ bcc: str | None = None,
47
+ reply_to: str | None = None,
48
+ attachments: list[dict[str, str]] | None = None,
49
+ ) -> SendResult:
50
+ payload = build_send_payload(
51
+ to=to,
52
+ template=template,
53
+ channel=channel,
54
+ lang=lang,
55
+ variables=variables,
56
+ cc=cc,
57
+ bcc=bcc,
58
+ reply_to=reply_to,
59
+ attachments=attachments,
60
+ )
61
+ data = await self._http.request("POST", "send", payload)
62
+ if data.get("success") is not True or not isinstance(data.get("logId"), str):
63
+ raise ZinduaError("API response missing success or logId.", status=200, code="INVALID_RESPONSE")
64
+ return SendResult.from_api(data)
65
+
66
+ async def get_project(self) -> dict[str, Any]:
67
+ return await self._http.request("GET", "project")
68
+
69
+ async def get_templates(self) -> dict[str, Any]:
70
+ data = await self._http.request("GET", "templates")
71
+ templates_raw = data.get("templates") if isinstance(data.get("templates"), list) else []
72
+ return {
73
+ "templates": [
74
+ TemplateInfo.from_api(item) for item in templates_raw if isinstance(item, dict)
75
+ ],
76
+ "limits": data.get("limits") if isinstance(data.get("limits"), dict) else None,
77
+ }
78
+
79
+ async def get_log(self, log_id: str) -> LogStatus:
80
+ trimmed = log_id.strip()
81
+ if not trimmed:
82
+ raise ZinduaError("log_id is required.", status=0, code="MISSING_FIELDS")
83
+ data = await self._http.request("GET", f"logs/{trimmed}")
84
+ return LogStatus.from_api(data)
85
+
86
+ async def connect(self, site_url: str | None = None) -> dict[str, Any]:
87
+ url = validate_site_url(site_url) or self._site_url
88
+ if not url:
89
+ raise ZinduaError("siteUrl is required for connect().", status=0, code="INVALID_OPTIONS")
90
+ data = await self._http.request("POST", "connect", {"siteUrl": url})
91
+ if data.get("ok") is not True:
92
+ raise ZinduaError("API response missing connect payload.", status=200, code="INVALID_RESPONSE")
93
+ return data
94
+
95
+ def send_sync(self, **kwargs: Any) -> SendResult:
96
+ import asyncio
97
+
98
+ return asyncio.run(self.send(**kwargs))
99
+
100
+ def get_project_sync(self) -> dict[str, Any]:
101
+ import asyncio
102
+
103
+ return asyncio.run(self.get_project())
104
+
105
+ def get_templates_sync(self) -> dict[str, Any]:
106
+ import asyncio
107
+
108
+ return asyncio.run(self.get_templates())
109
+
110
+ def get_log_sync(self, log_id: str) -> LogStatus:
111
+ import asyncio
112
+
113
+ return asyncio.run(self.get_log(log_id))
114
+
115
+ def connect_sync(self, site_url: str | None = None) -> dict[str, Any]:
116
+ import asyncio
117
+
118
+ return asyncio.run(self.connect(site_url=site_url))
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class ZinduaError(Exception):
9
+ message: str
10
+ status: int = 0
11
+ code: str = "API_ERROR"
12
+ details: dict[str, Any] | None = None
13
+
14
+ def __str__(self) -> str:
15
+ return self.message
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from zindua.errors import ZinduaError
8
+
9
+ SDK_VERSION = "1.0.0"
10
+ USER_AGENT = f"Zindua-Python/{SDK_VERSION}"
11
+
12
+
13
+ class HttpClient:
14
+ def __init__(
15
+ self,
16
+ api_key: str,
17
+ base_url: str,
18
+ timeout_s: float,
19
+ site_url: str | None = None,
20
+ ) -> None:
21
+ self.api_key = api_key
22
+ self.base_url = base_url.rstrip("/")
23
+ self.timeout_s = timeout_s
24
+ self.site_url = site_url
25
+
26
+ def _headers(self) -> dict[str, str]:
27
+ headers = {
28
+ "Authorization": f"Bearer {self.api_key}",
29
+ "User-Agent": USER_AGENT,
30
+ "Accept": "application/json",
31
+ }
32
+ if self.site_url:
33
+ headers["X-Zindua-Site-Url"] = self.site_url
34
+ return headers
35
+
36
+ def _parse_error(self, status: int, body: dict[str, Any]) -> ZinduaError:
37
+ message = body.get("error") if isinstance(body.get("error"), str) else None
38
+ if not message and isinstance(body.get("message"), str):
39
+ message = body["message"]
40
+ if not message:
41
+ message = f"Request failed with HTTP {status}"
42
+
43
+ details: dict[str, Any] = {}
44
+ if isinstance(body.get("code"), str):
45
+ details["code"] = body["code"]
46
+ if isinstance(body.get("hint"), str):
47
+ details["hint"] = body["hint"]
48
+ if isinstance(body.get("retryAfterSec"), (int, float)):
49
+ details["retryAfterSec"] = body["retryAfterSec"]
50
+ if isinstance(body.get("boundSite"), str):
51
+ details["boundSite"] = body["boundSite"]
52
+
53
+ hint = body.get("hint")
54
+ full = f"{message} {hint}" if isinstance(hint, str) and hint else message
55
+ code = body.get("code") if isinstance(body.get("code"), str) else "API_ERROR"
56
+ return ZinduaError(full, status=status, code=code, details=details or None)
57
+
58
+ async def request(
59
+ self,
60
+ method: str,
61
+ path: str,
62
+ json_body: dict[str, Any] | None = None,
63
+ ) -> dict[str, Any]:
64
+ url = f"{self.base_url}/{path.lstrip('/')}"
65
+ headers = self._headers()
66
+ if json_body is not None:
67
+ headers["Content-Type"] = "application/json"
68
+
69
+ try:
70
+ async with httpx.AsyncClient(timeout=self.timeout_s, follow_redirects=False) as client:
71
+ response = await client.request(method.upper(), url, headers=headers, json=json_body)
72
+ except httpx.TimeoutException as exc:
73
+ raise ZinduaError(
74
+ f"Request timed out after {self.timeout_s}s.",
75
+ status=0,
76
+ code="REQUEST_TIMEOUT",
77
+ ) from exc
78
+ except httpx.HTTPError as exc:
79
+ raise ZinduaError("Network request failed.", status=0, code="API_ERROR") from exc
80
+
81
+ try:
82
+ data = response.json() if response.content else {}
83
+ except ValueError as exc:
84
+ raise ZinduaError("API returned non-JSON response.", status=response.status_code, code="INVALID_RESPONSE") from exc
85
+
86
+ if not isinstance(data, dict):
87
+ raise ZinduaError("API returned invalid JSON object.", status=response.status_code, code="INVALID_RESPONSE")
88
+
89
+ if response.status_code < 200 or response.status_code >= 300:
90
+ raise self._parse_error(response.status_code, data)
91
+ return data
92
+
93
+ def request_sync(
94
+ self,
95
+ method: str,
96
+ path: str,
97
+ json_body: dict[str, Any] | None = None,
98
+ ) -> dict[str, Any]:
99
+ url = f"{self.base_url}/{path.lstrip('/')}"
100
+ headers = self._headers()
101
+ if json_body is not None:
102
+ headers["Content-Type"] = "application/json"
103
+
104
+ try:
105
+ with httpx.Client(timeout=self.timeout_s, follow_redirects=False) as client:
106
+ response = client.request(method.upper(), url, headers=headers, json=json_body)
107
+ except httpx.TimeoutException as exc:
108
+ raise ZinduaError(
109
+ f"Request timed out after {self.timeout_s}s.",
110
+ status=0,
111
+ code="REQUEST_TIMEOUT",
112
+ ) from exc
113
+ except httpx.HTTPError as exc:
114
+ raise ZinduaError("Network request failed.", status=0, code="API_ERROR") from exc
115
+
116
+ try:
117
+ data = response.json() if response.content else {}
118
+ except ValueError as exc:
119
+ raise ZinduaError("API returned non-JSON response.", status=response.status_code, code="INVALID_RESPONSE") from exc
120
+
121
+ if not isinstance(data, dict):
122
+ raise ZinduaError("API returned invalid JSON object.", status=response.status_code, code="INVALID_RESPONSE")
123
+
124
+ if response.status_code < 200 or response.status_code >= 300:
125
+ raise self._parse_error(response.status_code, data)
126
+ return data
File without changes
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from functools import lru_cache
5
+ from typing import Annotated
6
+
7
+ from zindua import Zindua
8
+
9
+ try:
10
+ from fastapi import Depends
11
+ except ImportError: # pragma: no cover
12
+ Depends = None # type: ignore[misc, assignment]
13
+
14
+
15
+ @lru_cache
16
+ def get_zindua() -> Zindua:
17
+ api_key = os.environ.get("ZINDUA_API_KEY", "").strip()
18
+ if not api_key:
19
+ raise RuntimeError("ZINDUA_API_KEY is not set.")
20
+ return Zindua(
21
+ api_key=api_key,
22
+ base_url=os.environ.get("ZINDUA_API_BASE_URL") or None,
23
+ site_url=os.environ.get("ZINDUA_SITE_URL") or None,
24
+ )
25
+
26
+
27
+ if Depends is not None:
28
+ ZinduaDep = Annotated[Zindua, Depends(get_zindua)]
29
+ else:
30
+ ZinduaDep = Zindua # type: ignore[misc, assignment]
31
+
32
+ __all__ = ["get_zindua", "ZinduaDep"]
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Literal
5
+
6
+ SendChannel = Literal["email", "whatsapp"]
7
+
8
+
9
+ @dataclass
10
+ class AttachmentInput:
11
+ url: str
12
+ filename: str | None = None
13
+
14
+
15
+ @dataclass
16
+ class SendResult:
17
+ success: bool
18
+ channel: SendChannel
19
+ status: str
20
+ log_id: str
21
+ lang_used: str | None = None
22
+ lang_fallback: bool | None = None
23
+ test_mode: bool | None = None
24
+ project: str | None = None
25
+ context: dict[str, Any] | None = None
26
+
27
+ @classmethod
28
+ def from_api(cls, data: dict[str, Any]) -> "SendResult":
29
+ return cls(
30
+ success=bool(data.get("success")),
31
+ channel="whatsapp" if data.get("channel") == "whatsapp" else "email",
32
+ status=str(data.get("status") or "queued"),
33
+ log_id=str(data["logId"]),
34
+ lang_used=data.get("langUsed") if isinstance(data.get("langUsed"), str) else None,
35
+ lang_fallback=data.get("langFallback") if isinstance(data.get("langFallback"), bool) else None,
36
+ test_mode=data.get("testMode") if isinstance(data.get("testMode"), bool) else None,
37
+ project=data.get("project") if isinstance(data.get("project"), str) else None,
38
+ context=data.get("context") if isinstance(data.get("context"), dict) else None,
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class LogStatus:
44
+ log_id: str
45
+ channel: str
46
+ recipient: str
47
+ status: str
48
+ template_slug: str | None
49
+ lang_used: str | None
50
+ message_id: str | None
51
+ error: str | None
52
+ lang_fallback: bool
53
+ is_test_mode: bool
54
+ has_attachments: bool
55
+ created_at: str | None
56
+ opened_at: str | None
57
+ clicked_at: str | None
58
+
59
+ @classmethod
60
+ def from_api(cls, data: dict[str, Any]) -> "LogStatus":
61
+ log = data.get("log") if isinstance(data.get("log"), dict) else data
62
+ return cls(
63
+ log_id=str(log.get("logId") or log.get("id") or ""),
64
+ channel=str(log.get("channel") or "email"),
65
+ recipient=str(log.get("recipient") or ""),
66
+ status=str(log.get("status") or "queued"),
67
+ template_slug=log.get("templateSlug") if isinstance(log.get("templateSlug"), str) else None,
68
+ lang_used=log.get("langUsed") if isinstance(log.get("langUsed"), str) else None,
69
+ message_id=log.get("messageId") if isinstance(log.get("messageId"), str) else None,
70
+ error=log.get("error") if isinstance(log.get("error"), str) else None,
71
+ lang_fallback=bool(log.get("langFallback")),
72
+ is_test_mode=bool(log.get("isTestMode")),
73
+ has_attachments=bool(log.get("hasAttachments")),
74
+ created_at=str(log.get("createdAt")) if log.get("createdAt") else None,
75
+ opened_at=str(log.get("openedAt")) if log.get("openedAt") else None,
76
+ clicked_at=str(log.get("clickedAt")) if log.get("clickedAt") else None,
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class TemplateInfo:
82
+ slug: str
83
+ langs: list[str]
84
+ default_lang: str
85
+ variables: list[str]
86
+
87
+ @classmethod
88
+ def from_api(cls, data: dict[str, Any]) -> "TemplateInfo":
89
+ langs = data.get("langs") if isinstance(data.get("langs"), list) else []
90
+ variables = data.get("variables") if isinstance(data.get("variables"), list) else []
91
+ return cls(
92
+ slug=str(data.get("slug") or ""),
93
+ langs=[str(x) for x in langs],
94
+ default_lang=str(data.get("defaultLang") or ""),
95
+ variables=[str(x) for x in variables],
96
+ )
@@ -0,0 +1,244 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import Any, Literal
6
+ from urllib.parse import urlparse
7
+
8
+ from zindua.errors import ZinduaError
9
+
10
+ SendChannel = Literal["email", "whatsapp"]
11
+
12
+ E164_RE = re.compile(r"^\+[1-9]\d{6,14}$")
13
+ EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
14
+ TEMPLATE_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$", re.I)
15
+ LANG_RE = re.compile(r"^[a-z]{2}(-[a-z]{2})?$", re.I)
16
+ API_KEY_RE = re.compile(r"^znd_(live|test)_[a-z0-9]{24}$")
17
+
18
+ DEFAULT_API_BASE = "https://zindua.run/api/v1"
19
+ DEFAULT_TIMEOUT_S = 30.0
20
+ MAX_TIMEOUT_S = 120.0
21
+ MAX_TO_LENGTH = 320
22
+ MAX_TEMPLATE_LENGTH = 64
23
+ MAX_VARIABLES = 50
24
+ MAX_VAR_KEY_LENGTH = 64
25
+ MAX_VAR_VALUE_LENGTH = 4096
26
+ MAX_ATTACHMENTS = 5
27
+
28
+ BLOCKED_VAR_KEYS = {"__proto__", "constructor", "prototype"}
29
+
30
+
31
+ def validate_api_key(api_key: str) -> str:
32
+ key = api_key.strip()
33
+ if not key:
34
+ raise ZinduaError("apiKey is required.", status=0, code="INVALID_API_KEY")
35
+ if not API_KEY_RE.match(key):
36
+ raise ZinduaError(
37
+ "apiKey must be znd_live_… or znd_test_… (project key from Dashboard → Projects).",
38
+ status=0,
39
+ code="INVALID_API_KEY",
40
+ )
41
+ return key
42
+
43
+
44
+ def validate_base_url(raw: str) -> str:
45
+ trimmed = raw.strip().rstrip("/")
46
+ parsed = urlparse(trimmed)
47
+ if not parsed.scheme or not parsed.netloc:
48
+ raise ZinduaError("baseUrl is not a valid URL.", status=0, code="INVALID_BASE_URL")
49
+ if parsed.username or parsed.password:
50
+ raise ZinduaError("baseUrl must not contain credentials.", status=0, code="INVALID_BASE_URL")
51
+
52
+ host = (parsed.hostname or "").lower()
53
+ is_local = host in {"localhost", "127.0.0.1", "[::1]"} or host.endswith(".localhost")
54
+ if parsed.scheme != "https" and not (is_local and parsed.scheme == "http"):
55
+ raise ZinduaError(
56
+ "baseUrl must use HTTPS (HTTP is only allowed for localhost).",
57
+ status=0,
58
+ code="INVALID_BASE_URL",
59
+ )
60
+ return trimmed
61
+
62
+
63
+ def resolve_base_url(explicit: str | None = None) -> str:
64
+ if explicit and explicit.strip():
65
+ return validate_base_url(explicit)
66
+ from_env = os.environ.get("ZINDUA_API_BASE_URL", "").strip()
67
+ if from_env:
68
+ return validate_base_url(from_env)
69
+ return DEFAULT_API_BASE
70
+
71
+
72
+ def validate_timeout(timeout_s: float | None) -> float:
73
+ if timeout_s is None:
74
+ return DEFAULT_TIMEOUT_S
75
+ if timeout_s < 1.0 or timeout_s > MAX_TIMEOUT_S:
76
+ raise ZinduaError(
77
+ f"timeout must be between 1 and {MAX_TIMEOUT_S} seconds.",
78
+ status=0,
79
+ code="INVALID_OPTIONS",
80
+ )
81
+ return float(timeout_s)
82
+
83
+
84
+ def validate_channel(channel: str | None) -> SendChannel:
85
+ if channel is None or channel == "" or channel == "email":
86
+ return "email"
87
+ if channel == "whatsapp":
88
+ return "whatsapp"
89
+ raise ZinduaError('channel must be "email" or "whatsapp".', status=0, code="INVALID_CHANNEL")
90
+
91
+
92
+ def validate_template_slug(template: str) -> str:
93
+ slug = template.strip()
94
+ if not slug or len(slug) > MAX_TEMPLATE_LENGTH:
95
+ raise ZinduaError("template slug is required (max 64 characters).", status=0, code="INVALID_TEMPLATE")
96
+ if not TEMPLATE_SLUG_RE.match(slug):
97
+ raise ZinduaError(
98
+ "template slug may only contain letters, numbers, hyphens, and underscores.",
99
+ status=0,
100
+ code="INVALID_TEMPLATE",
101
+ )
102
+ return slug
103
+
104
+
105
+ def validate_recipient(to: str, channel: SendChannel) -> str:
106
+ recipient = to.strip()
107
+ if not recipient or len(recipient) > MAX_TO_LENGTH:
108
+ raise ZinduaError("to is required.", status=0, code="MISSING_FIELDS")
109
+ if channel == "email":
110
+ if not EMAIL_RE.match(recipient):
111
+ raise ZinduaError(
112
+ "Invalid email address, or phone used with channel email.",
113
+ status=0,
114
+ code="INVALID_EMAIL",
115
+ )
116
+ return recipient
117
+ if not E164_RE.match(recipient):
118
+ raise ZinduaError(
119
+ "Invalid WhatsApp phone (E.164 with +), or email used with channel whatsapp.",
120
+ status=0,
121
+ code="INVALID_PHONE",
122
+ )
123
+ return recipient
124
+
125
+
126
+ def validate_lang(lang: str | None) -> str | None:
127
+ if lang is None or lang == "":
128
+ return None
129
+ value = lang.strip()
130
+ if len(value) > 10 or not LANG_RE.match(value):
131
+ raise ZinduaError("lang must be ISO 639-1 (e.g. fr, en, en-us).", status=0, code="INVALID_LANG")
132
+ return value
133
+
134
+
135
+ def sanitize_variables(variables: dict[str, str] | None) -> dict[str, str] | None:
136
+ if not variables:
137
+ return None
138
+ out: dict[str, str] = {}
139
+ count = 0
140
+ for key, value in variables.items():
141
+ if count >= MAX_VARIABLES:
142
+ break
143
+ if key in BLOCKED_VAR_KEYS:
144
+ continue
145
+ if not isinstance(value, str):
146
+ value = str(value)
147
+ safe_key = key.strip()[:MAX_VAR_KEY_LENGTH]
148
+ if not safe_key:
149
+ continue
150
+ out[safe_key] = value[:MAX_VAR_VALUE_LENGTH]
151
+ count += 1
152
+ return out or None
153
+
154
+
155
+ def validate_optional_email_field(field: str, value: str | None) -> str | None:
156
+ if value is None or value == "":
157
+ return None
158
+ trimmed = value.strip()
159
+ if not EMAIL_RE.match(trimmed):
160
+ raise ZinduaError(f"Invalid {field} email address.", status=0, code="INVALID_OPTIONS")
161
+ return trimmed
162
+
163
+
164
+ def validate_attachments(attachments: list[dict[str, str]] | None) -> list[dict[str, str]] | None:
165
+ if not attachments:
166
+ return None
167
+ if len(attachments) > MAX_ATTACHMENTS:
168
+ raise ZinduaError(f"At most {MAX_ATTACHMENTS} attachments allowed.", status=0, code="INVALID_OPTIONS")
169
+ out: list[dict[str, str]] = []
170
+ for item in attachments:
171
+ if not isinstance(item, dict):
172
+ raise ZinduaError("Each attachment must be an object with url.", status=0, code="INVALID_OPTIONS")
173
+ url = item.get("url")
174
+ if not isinstance(url, str) or not url.strip():
175
+ raise ZinduaError("Attachment url is required.", status=0, code="INVALID_OPTIONS")
176
+ parsed = urlparse(url.strip())
177
+ if parsed.scheme != "https":
178
+ raise ZinduaError("Attachment urls must use HTTPS.", status=0, code="INVALID_OPTIONS")
179
+ entry: dict[str, str] = {"url": url.strip()}
180
+ filename = item.get("filename")
181
+ if isinstance(filename, str) and filename.strip():
182
+ entry["filename"] = filename.strip()
183
+ out.append(entry)
184
+ return out
185
+
186
+
187
+ def validate_site_url(site_url: str | None) -> str | None:
188
+ if site_url is None or site_url == "":
189
+ return None
190
+ trimmed = site_url.strip()
191
+ if not trimmed:
192
+ return None
193
+ candidate = trimmed if "://" in trimmed else f"https://{trimmed}"
194
+ parsed = urlparse(candidate)
195
+ if not parsed.scheme or not parsed.netloc:
196
+ raise ZinduaError("siteUrl must be a valid URL (e.g. https://example.com).", status=0, code="INVALID_OPTIONS")
197
+ return f"{parsed.scheme}://{parsed.netloc}"
198
+
199
+
200
+ def build_send_payload(
201
+ *,
202
+ to: str,
203
+ template: str,
204
+ channel: SendChannel | None = None,
205
+ lang: str | None = None,
206
+ variables: dict[str, str] | None = None,
207
+ cc: str | None = None,
208
+ bcc: str | None = None,
209
+ reply_to: str | None = None,
210
+ attachments: list[dict[str, str]] | None = None,
211
+ ) -> dict[str, Any]:
212
+ resolved_channel = validate_channel(channel)
213
+ payload: dict[str, Any] = {
214
+ "to": validate_recipient(to, resolved_channel),
215
+ "template": validate_template_slug(template),
216
+ }
217
+ if resolved_channel == "whatsapp":
218
+ payload["channel"] = "whatsapp"
219
+ elif channel == "email":
220
+ payload["channel"] = "email"
221
+
222
+ validated_lang = validate_lang(lang)
223
+ if validated_lang:
224
+ payload["lang"] = validated_lang
225
+
226
+ vars_out = sanitize_variables(variables)
227
+ if vars_out:
228
+ payload["variables"] = vars_out
229
+
230
+ if resolved_channel == "email":
231
+ cc_v = validate_optional_email_field("cc", cc)
232
+ bcc_v = validate_optional_email_field("bcc", bcc)
233
+ reply_v = validate_optional_email_field("replyTo", reply_to)
234
+ att_v = validate_attachments(attachments)
235
+ if cc_v:
236
+ payload["cc"] = cc_v
237
+ if bcc_v:
238
+ payload["bcc"] = bcc_v
239
+ if reply_v:
240
+ payload["replyTo"] = reply_v
241
+ if att_v:
242
+ payload["attachments"] = att_v
243
+
244
+ return payload
@@ -0,0 +1,47 @@
1
+ import pytest
2
+
3
+ from zindua.errors import ZinduaError
4
+ from zindua.validate import (
5
+ build_send_payload,
6
+ validate_api_key,
7
+ validate_recipient,
8
+ )
9
+
10
+
11
+ def test_validate_api_key_accepts_test_key():
12
+ key = "znd_test_" + "a" * 24
13
+ assert validate_api_key(key) == key
14
+
15
+
16
+ def test_validate_api_key_rejects_bad_prefix():
17
+ with pytest.raises(ZinduaError) as exc:
18
+ validate_api_key("sk_live_bad")
19
+ assert exc.value.code == "INVALID_API_KEY"
20
+
21
+
22
+ def test_channel_to_mismatch_email():
23
+ with pytest.raises(ZinduaError) as exc:
24
+ validate_recipient("+243812345678", "email")
25
+ assert exc.value.code == "INVALID_EMAIL"
26
+
27
+
28
+ def test_build_send_payload_with_lang_and_attachments():
29
+ payload = build_send_payload(
30
+ to="user@example.com",
31
+ template="invoice-ready",
32
+ lang="fr",
33
+ variables={"code": "1"},
34
+ attachments=[{"url": "https://cdn.example.com/file.pdf", "filename": "invoice.pdf"}],
35
+ )
36
+ assert payload["lang"] == "fr"
37
+ assert payload["attachments"][0]["url"].startswith("https://")
38
+
39
+
40
+ def test_attachments_require_https():
41
+ with pytest.raises(ZinduaError) as exc:
42
+ build_send_payload(
43
+ to="user@example.com",
44
+ template="t",
45
+ attachments=[{"url": "http://insecure.example.com/a.pdf"}],
46
+ )
47
+ assert exc.value.code == "INVALID_OPTIONS"