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.
- zindua_sdk-1.0.0/.gitignore +63 -0
- zindua_sdk-1.0.0/LICENSE +21 -0
- zindua_sdk-1.0.0/PKG-INFO +97 -0
- zindua_sdk-1.0.0/README.md +67 -0
- zindua_sdk-1.0.0/pyproject.toml +45 -0
- zindua_sdk-1.0.0/src/zindua/__init__.py +15 -0
- zindua_sdk-1.0.0/src/zindua/__main__.py +114 -0
- zindua_sdk-1.0.0/src/zindua/client.py +118 -0
- zindua_sdk-1.0.0/src/zindua/errors.py +15 -0
- zindua_sdk-1.0.0/src/zindua/http.py +126 -0
- zindua_sdk-1.0.0/src/zindua/integrations/__init__.py +0 -0
- zindua_sdk-1.0.0/src/zindua/integrations/fastapi.py +32 -0
- zindua_sdk-1.0.0/src/zindua/types.py +96 -0
- zindua_sdk-1.0.0/src/zindua/validate.py +244 -0
- zindua_sdk-1.0.0/tests/test_validate.py +47 -0
|
@@ -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
|
zindua_sdk-1.0.0/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.
|
|
@@ -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"
|