unione-fastapi 1.1.3__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.
- unione_fastapi-1.1.3/LICENSE +21 -0
- unione_fastapi-1.1.3/PKG-INFO +310 -0
- unione_fastapi-1.1.3/README.md +293 -0
- unione_fastapi-1.1.3/pyproject.toml +25 -0
- unione_fastapi-1.1.3/setup.cfg +4 -0
- unione_fastapi-1.1.3/src/unione_fastapi.egg-info/PKG-INFO +310 -0
- unione_fastapi-1.1.3/src/unione_fastapi.egg-info/SOURCES.txt +13 -0
- unione_fastapi-1.1.3/src/unione_fastapi.egg-info/dependency_links.txt +1 -0
- unione_fastapi-1.1.3/src/unione_fastapi.egg-info/requires.txt +2 -0
- unione_fastapi-1.1.3/src/unione_fastapi.egg-info/top_level.txt +1 -0
- unione_fastapi-1.1.3/src/unione_wrapper/__init__.py +106 -0
- unione_fastapi-1.1.3/src/unione_wrapper/_settings.py +53 -0
- unione_fastapi-1.1.3/src/unione_wrapper/py.typed +0 -0
- unione_fastapi-1.1.3/src/unione_wrapper/signature.py +33 -0
- unione_fastapi-1.1.3/src/unione_wrapper/webhook.py +39 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 UniOne
|
|
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,310 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unione-fastapi
|
|
3
|
+
Version: 1.1.3
|
|
4
|
+
Summary: UniOne Web API for FastAPI: API client, webhooks
|
|
5
|
+
Author: UniOne
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/unione-repo/unione-fastapi
|
|
8
|
+
Project-URL: Documentation, https://docs.unione.io/en/web-api-ref
|
|
9
|
+
Keywords: unione,fastapi,email,webhook
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: unione-sdk>=1.1.3
|
|
15
|
+
Requires-Dist: fastapi>=0.100
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# UniOne FastAPI 1.1.3
|
|
19
|
+
|
|
20
|
+
Web API 1.90
|
|
21
|
+
|
|
22
|
+
Quickstart guide: [Send emails with Python](https://docs.unione.io/en/send-with-python) · Docs for LLMs: [llms.txt](https://docs.unione.io/llms.txt) · [API reference](https://docs.unione.io/en/web-api-ref)
|
|
23
|
+
|
|
24
|
+
FastAPI integration of the [UniOne Python SDK](https://github.com/unione-repo/unione-python-sdk) (``): the API client in the dependencies (`Depends(get_client)`), a webhook endpoint with signature check.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install unione-fastapi
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requirements: Python 3.8+, FastAPI 0.100+.
|
|
33
|
+
|
|
34
|
+
## Get an API key
|
|
35
|
+
|
|
36
|
+
1. Sign up at [cp.unione.io](https://cp.unione.io/en/user/registration/).
|
|
37
|
+
2. Copy your API key from [Settings → Security → API](https://cp.unione.io/en/settings/security/api).
|
|
38
|
+
3. Put it in an environment variable:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
export UNIONE_API_KEY="your-api-key"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The package reads it from `UNIONE_API_KEY` (see the configuration below).
|
|
45
|
+
|
|
46
|
+
> **Server-side only.** Use this package in backend code: API routes, server functions, workers.
|
|
47
|
+
> Never ship the API key to a browser or a mobile app, and never commit it.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Settings are read from the environment by default:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
UNIONE_API_KEY=YOUR_API_KEY
|
|
55
|
+
UNIONE_HOST=us1.unione.io
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Option | Environment variable | Default | Description |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| `Settings.api_key` | `UNIONE_API_KEY` | | User or project API key (required). |
|
|
61
|
+
| `Settings.host` | `UNIONE_HOST` | `api.unione.io` | API host (datacenter: `api.unione.io`, `eu1.unione.io`, `us1.unione.io`) or a full base URL. |
|
|
62
|
+
| `Settings.timeout` | `UNIONE_TIMEOUT` | `30` | Request timeout, seconds. |
|
|
63
|
+
| `Settings.webhook_key` | `UNIONE_WEBHOOK_KEY` | | API key the webhooks are signed with (the key of the user/project of the webhook), if it differs from the API key. |
|
|
64
|
+
| `Settings.webhook_path` | `UNIONE_WEBHOOK_PATH` | `/unione/webhook` | Path of the webhook endpoint. |
|
|
65
|
+
|
|
66
|
+
Other settings in code: `app.dependency_overrides[get_settings] = lambda: Settings(api_key=...)`.
|
|
67
|
+
|
|
68
|
+
## Send your first email — no DNS setup
|
|
69
|
+
|
|
70
|
+
Every account can send test email right away from a **sandbox domain**: pre-verified, DKIM and SPF already set.
|
|
71
|
+
It delivers only to addresses you confirm.
|
|
72
|
+
|
|
73
|
+
The examples use the API client of the package (see the configuration above).
|
|
74
|
+
|
|
75
|
+
**1. Get your sandbox domain** (`domain/get-sandbox-domain`). The call creates the domain on first use and returns the same one afterwards.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi import Depends
|
|
79
|
+
from unione import UniOneClient
|
|
80
|
+
from unione_wrapper import get_client
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.post("/sandbox-domain")
|
|
84
|
+
def get_sandbox_domain(client: UniOneClient = Depends(get_client)):
|
|
85
|
+
response = client.domain.get_sandbox_domain()
|
|
86
|
+
return {"domain": response.domain}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**2. Confirm your inbox** (`confirmed-email/create`). UniOne sends a confirmation email; click the link in it within 48 hours.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from fastapi import Depends
|
|
93
|
+
from unione import UniOneClient
|
|
94
|
+
from unione_wrapper import get_client
|
|
95
|
+
from unione.api.confirmed_email import ConfirmedEmailRequest
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.post("/confirm-email")
|
|
99
|
+
def confirm_email(client: UniOneClient = Depends(get_client)):
|
|
100
|
+
response = client.confirmed_email.create(ConfirmedEmailRequest(
|
|
101
|
+
email="you@example.com",
|
|
102
|
+
))
|
|
103
|
+
return {"status": response.status}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Check the status with `confirmed-email/list` — your address should show `"status": "confirmed"`.
|
|
107
|
+
|
|
108
|
+
**3. Send the email** (`email/send`) from any address on the sandbox domain to the confirmed address.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from fastapi import Depends
|
|
112
|
+
from unione import UniOneClient
|
|
113
|
+
from unione_wrapper import get_client
|
|
114
|
+
from unione.api.email import EmailSendRequest, MessageObject, MessageObjectRecipientsItem
|
|
115
|
+
from unione.common import BodyObject
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.post("/send")
|
|
119
|
+
def send(client: UniOneClient = Depends(get_client)):
|
|
120
|
+
response = client.email.send(EmailSendRequest(
|
|
121
|
+
message=MessageObject(
|
|
122
|
+
recipients=[
|
|
123
|
+
MessageObjectRecipientsItem(
|
|
124
|
+
email="you@example.com",
|
|
125
|
+
),
|
|
126
|
+
],
|
|
127
|
+
body=BodyObject(
|
|
128
|
+
html="<p>It works!</p>",
|
|
129
|
+
),
|
|
130
|
+
subject="Hello from UniOne",
|
|
131
|
+
from_email="noreply@<sandbox-domain>",
|
|
132
|
+
),
|
|
133
|
+
))
|
|
134
|
+
return {"job_id": response.job_id}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### What you get back
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"status": "success",
|
|
142
|
+
"job_id": "1ZymBc-00041N-9X",
|
|
143
|
+
"emails": [
|
|
144
|
+
"you@example.com"
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
On error the API returns `status: "error"`, a `message` and a numeric `code`:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"status": "error",
|
|
154
|
+
"code": 102,
|
|
155
|
+
"message": "Error ID:83274F98-C7D5-11F1-B221-6214FF8707B1. Can not decode key wrongkey"
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Same flow with curl
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
BASE=https://api.unione.io/en/transactional/api/v1
|
|
163
|
+
|
|
164
|
+
curl -s -X POST $BASE/domain/get-sandbox-domain.json \
|
|
165
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" -d '{}'
|
|
166
|
+
|
|
167
|
+
curl -s -X POST $BASE/confirmed-email/create.json \
|
|
168
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" \
|
|
169
|
+
-d '{"email": "you@example.com"}'
|
|
170
|
+
|
|
171
|
+
curl -s -X POST $BASE/email/send.json \
|
|
172
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" \
|
|
173
|
+
-d '{
|
|
174
|
+
"message": {
|
|
175
|
+
"recipients": [
|
|
176
|
+
{
|
|
177
|
+
"email": "you@example.com"
|
|
178
|
+
}
|
|
179
|
+
],
|
|
180
|
+
"from_email": "noreply@<sandbox-domain>",
|
|
181
|
+
"subject": "Hello from UniOne",
|
|
182
|
+
"body": {
|
|
183
|
+
"html": "<p>It works!</p>"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Webhooks
|
|
190
|
+
|
|
191
|
+
The endpoint answers `GET` with 200 OK (the URL check of [webhook/set](https://docs.unione.io/en/web-api-ref#webhook-set)) and accepts the `POST` notifications: it checks the `auth` field (MD5 of the request body in which the `auth` value is replaced by the API key), answers 401 to a wrong signature, and passes the notification and each of its events to the application.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from unione_wrapper import Webhooks
|
|
195
|
+
|
|
196
|
+
webhooks = Webhooks()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@webhooks.on_event
|
|
200
|
+
async def on_event(event, user):
|
|
201
|
+
data = event.event_data
|
|
202
|
+
print(event.event_name, data.email, data.status)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
app.include_router(webhooks.router()) # /unione/webhook
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Register the URL of the endpoint with `webhook/set`, e.g. `https://example.com/unione/webhook`.
|
|
209
|
+
|
|
210
|
+
## API client
|
|
211
|
+
|
|
212
|
+
Every section of the API is available on the client, see the [SDK docs](https://github.com/unione-repo/unione-python-sdk).
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from fastapi import Depends, FastAPI
|
|
216
|
+
from unione import UniOneClient
|
|
217
|
+
from unione_wrapper import get_client
|
|
218
|
+
|
|
219
|
+
app = FastAPI()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.get("/usage")
|
|
223
|
+
def usage(client: UniOneClient = Depends(get_client)): # the client is synchronous: a def endpoint
|
|
224
|
+
info = client.system.info()
|
|
225
|
+
return {"emails_sent": info.accounting.emails_sent if info.accounting else None}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Sending through the API
|
|
229
|
+
|
|
230
|
+
The client calls [email/send](https://docs.unione.io/en/web-api-ref#email-send) with all of its options: templates, substitutions, metadata, tracking. Fields of the request and other examples: [Python SDK docs](https://github.com/unione-repo/unione-python-sdk/blob/HEAD/docs/Email.md#send).
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from fastapi import Depends
|
|
234
|
+
from unione import UniOneClient
|
|
235
|
+
from unione_wrapper import get_client
|
|
236
|
+
from unione.api.email import EmailSendRequest, MessageObject, MessageObjectRecipientsItem
|
|
237
|
+
from unione.common import BodyObject
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.post("/send")
|
|
241
|
+
def send(client: UniOneClient = Depends(get_client)):
|
|
242
|
+
response = client.email.send(EmailSendRequest(
|
|
243
|
+
message=MessageObject(
|
|
244
|
+
recipients=[
|
|
245
|
+
MessageObjectRecipientsItem(
|
|
246
|
+
email="user@example.com",
|
|
247
|
+
substitutions={
|
|
248
|
+
"CustomerId": "12452",
|
|
249
|
+
"to_name": "John Smith",
|
|
250
|
+
},
|
|
251
|
+
),
|
|
252
|
+
],
|
|
253
|
+
body=BodyObject(
|
|
254
|
+
html="<b>Hello, {{to_name}}</b>",
|
|
255
|
+
plaintext="Hello, {{to_name}}",
|
|
256
|
+
),
|
|
257
|
+
subject="UniOne test email ",
|
|
258
|
+
from_email="email@example.com",
|
|
259
|
+
from_name="John Smith",
|
|
260
|
+
),
|
|
261
|
+
))
|
|
262
|
+
return {"job_id": response.job_id}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Sandbox limits
|
|
266
|
+
|
|
267
|
+
- Sends only to confirmed addresses: up to 10 per account.
|
|
268
|
+
- Up to 100 emails a day.
|
|
269
|
+
- The sandbox domain expires after 31 days; call `domain/get-sandbox-domain` again to get a new one.
|
|
270
|
+
- Works only with the account API key, not a project key.
|
|
271
|
+
- Not listed in `domain/list`; the other `domain/*` methods do not apply to it.
|
|
272
|
+
|
|
273
|
+
The sandbox is for development and testing. Do not use a sandbox domain as the sender in production.
|
|
274
|
+
|
|
275
|
+
## Before production: verify your domain
|
|
276
|
+
|
|
277
|
+
1. `domain/get-dns-records` — returns the values for your DNS records.
|
|
278
|
+
2. Add these records at your DNS provider (the domain page of the control panel shows them too):
|
|
279
|
+
|
|
280
|
+
| Type | Host | Value |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| TXT | `@` | the `verification-record` value (`unione-validate-hash=…`) |
|
|
283
|
+
| TXT | `<selector>._domainkey` (the selector is on the domain page of the control panel, e.g. `us`) | `v=DKIM1; k=rsa; p=<dkim>` |
|
|
284
|
+
| TXT | `@` | `v=spf1 include:spf.unione.io ~all` |
|
|
285
|
+
| CNAME | `_dmarc` | the `dmarc` value |
|
|
286
|
+
|
|
287
|
+
3. `domain/validate-verification-record`, then `domain/validate-dkim`.
|
|
288
|
+
4. `domain/list` — wait until the verification record is confirmed and DKIM is active.
|
|
289
|
+
5. Read `from_email` from your config, so switching from the sandbox needs no code change.
|
|
290
|
+
|
|
291
|
+
## Sending reliably
|
|
292
|
+
|
|
293
|
+
- **Retries:** set `message.idempotence_key` on sends that may be retried, so a repeated request does not send twice.
|
|
294
|
+
- **Limits:** up to 500 recipients and 10 MB per request; each attachment up to 7 MB.
|
|
295
|
+
- **Body:** always include `body.html` or `body.plaintext`.
|
|
296
|
+
- **Delivery status:** register a webhook (`webhook/set`) with a fast, idempotent HTTPS handler instead of polling.
|
|
297
|
+
- **Defaults:** keep the default unsubscribe footer and tracking; changing them requires UniOne support.
|
|
298
|
+
- **Errors:** retry `429` and `5xx` with backoff; do not retry other errors unchanged.
|
|
299
|
+
|
|
300
|
+
## Links
|
|
301
|
+
|
|
302
|
+
- [Send emails with Python](https://docs.unione.io/en/send-with-python) and other [integration guides](https://docs.unione.io/en/integrations/)
|
|
303
|
+
- [Web API reference](https://docs.unione.io/en/web-api-ref)
|
|
304
|
+
- [llms.txt](https://docs.unione.io/llms.txt) for AI coding agents
|
|
305
|
+
- [UniOne MCP server and agent setup](https://unione.io/en/agents)
|
|
306
|
+
- [Support](https://cp.unione.io/en/support)
|
|
307
|
+
|
|
308
|
+
## For package contributors
|
|
309
|
+
|
|
310
|
+
See [AGENTS.md](AGENTS.md): how this package is generated from the OpenAPI spec, built, tested and released.
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# UniOne FastAPI 1.1.3
|
|
2
|
+
|
|
3
|
+
Web API 1.90
|
|
4
|
+
|
|
5
|
+
Quickstart guide: [Send emails with Python](https://docs.unione.io/en/send-with-python) · Docs for LLMs: [llms.txt](https://docs.unione.io/llms.txt) · [API reference](https://docs.unione.io/en/web-api-ref)
|
|
6
|
+
|
|
7
|
+
FastAPI integration of the [UniOne Python SDK](https://github.com/unione-repo/unione-python-sdk) (``): the API client in the dependencies (`Depends(get_client)`), a webhook endpoint with signature check.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install unione-fastapi
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requirements: Python 3.8+, FastAPI 0.100+.
|
|
16
|
+
|
|
17
|
+
## Get an API key
|
|
18
|
+
|
|
19
|
+
1. Sign up at [cp.unione.io](https://cp.unione.io/en/user/registration/).
|
|
20
|
+
2. Copy your API key from [Settings → Security → API](https://cp.unione.io/en/settings/security/api).
|
|
21
|
+
3. Put it in an environment variable:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
export UNIONE_API_KEY="your-api-key"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The package reads it from `UNIONE_API_KEY` (see the configuration below).
|
|
28
|
+
|
|
29
|
+
> **Server-side only.** Use this package in backend code: API routes, server functions, workers.
|
|
30
|
+
> Never ship the API key to a browser or a mobile app, and never commit it.
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Settings are read from the environment by default:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
UNIONE_API_KEY=YOUR_API_KEY
|
|
38
|
+
UNIONE_HOST=us1.unione.io
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
| Option | Environment variable | Default | Description |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| `Settings.api_key` | `UNIONE_API_KEY` | | User or project API key (required). |
|
|
44
|
+
| `Settings.host` | `UNIONE_HOST` | `api.unione.io` | API host (datacenter: `api.unione.io`, `eu1.unione.io`, `us1.unione.io`) or a full base URL. |
|
|
45
|
+
| `Settings.timeout` | `UNIONE_TIMEOUT` | `30` | Request timeout, seconds. |
|
|
46
|
+
| `Settings.webhook_key` | `UNIONE_WEBHOOK_KEY` | | API key the webhooks are signed with (the key of the user/project of the webhook), if it differs from the API key. |
|
|
47
|
+
| `Settings.webhook_path` | `UNIONE_WEBHOOK_PATH` | `/unione/webhook` | Path of the webhook endpoint. |
|
|
48
|
+
|
|
49
|
+
Other settings in code: `app.dependency_overrides[get_settings] = lambda: Settings(api_key=...)`.
|
|
50
|
+
|
|
51
|
+
## Send your first email — no DNS setup
|
|
52
|
+
|
|
53
|
+
Every account can send test email right away from a **sandbox domain**: pre-verified, DKIM and SPF already set.
|
|
54
|
+
It delivers only to addresses you confirm.
|
|
55
|
+
|
|
56
|
+
The examples use the API client of the package (see the configuration above).
|
|
57
|
+
|
|
58
|
+
**1. Get your sandbox domain** (`domain/get-sandbox-domain`). The call creates the domain on first use and returns the same one afterwards.
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from fastapi import Depends
|
|
62
|
+
from unione import UniOneClient
|
|
63
|
+
from unione_wrapper import get_client
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.post("/sandbox-domain")
|
|
67
|
+
def get_sandbox_domain(client: UniOneClient = Depends(get_client)):
|
|
68
|
+
response = client.domain.get_sandbox_domain()
|
|
69
|
+
return {"domain": response.domain}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**2. Confirm your inbox** (`confirmed-email/create`). UniOne sends a confirmation email; click the link in it within 48 hours.
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from fastapi import Depends
|
|
76
|
+
from unione import UniOneClient
|
|
77
|
+
from unione_wrapper import get_client
|
|
78
|
+
from unione.api.confirmed_email import ConfirmedEmailRequest
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.post("/confirm-email")
|
|
82
|
+
def confirm_email(client: UniOneClient = Depends(get_client)):
|
|
83
|
+
response = client.confirmed_email.create(ConfirmedEmailRequest(
|
|
84
|
+
email="you@example.com",
|
|
85
|
+
))
|
|
86
|
+
return {"status": response.status}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Check the status with `confirmed-email/list` — your address should show `"status": "confirmed"`.
|
|
90
|
+
|
|
91
|
+
**3. Send the email** (`email/send`) from any address on the sandbox domain to the confirmed address.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from fastapi import Depends
|
|
95
|
+
from unione import UniOneClient
|
|
96
|
+
from unione_wrapper import get_client
|
|
97
|
+
from unione.api.email import EmailSendRequest, MessageObject, MessageObjectRecipientsItem
|
|
98
|
+
from unione.common import BodyObject
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.post("/send")
|
|
102
|
+
def send(client: UniOneClient = Depends(get_client)):
|
|
103
|
+
response = client.email.send(EmailSendRequest(
|
|
104
|
+
message=MessageObject(
|
|
105
|
+
recipients=[
|
|
106
|
+
MessageObjectRecipientsItem(
|
|
107
|
+
email="you@example.com",
|
|
108
|
+
),
|
|
109
|
+
],
|
|
110
|
+
body=BodyObject(
|
|
111
|
+
html="<p>It works!</p>",
|
|
112
|
+
),
|
|
113
|
+
subject="Hello from UniOne",
|
|
114
|
+
from_email="noreply@<sandbox-domain>",
|
|
115
|
+
),
|
|
116
|
+
))
|
|
117
|
+
return {"job_id": response.job_id}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### What you get back
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"status": "success",
|
|
125
|
+
"job_id": "1ZymBc-00041N-9X",
|
|
126
|
+
"emails": [
|
|
127
|
+
"you@example.com"
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
On error the API returns `status: "error"`, a `message` and a numeric `code`:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"status": "error",
|
|
137
|
+
"code": 102,
|
|
138
|
+
"message": "Error ID:83274F98-C7D5-11F1-B221-6214FF8707B1. Can not decode key wrongkey"
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Same flow with curl
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
BASE=https://api.unione.io/en/transactional/api/v1
|
|
146
|
+
|
|
147
|
+
curl -s -X POST $BASE/domain/get-sandbox-domain.json \
|
|
148
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" -d '{}'
|
|
149
|
+
|
|
150
|
+
curl -s -X POST $BASE/confirmed-email/create.json \
|
|
151
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" \
|
|
152
|
+
-d '{"email": "you@example.com"}'
|
|
153
|
+
|
|
154
|
+
curl -s -X POST $BASE/email/send.json \
|
|
155
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" \
|
|
156
|
+
-d '{
|
|
157
|
+
"message": {
|
|
158
|
+
"recipients": [
|
|
159
|
+
{
|
|
160
|
+
"email": "you@example.com"
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
"from_email": "noreply@<sandbox-domain>",
|
|
164
|
+
"subject": "Hello from UniOne",
|
|
165
|
+
"body": {
|
|
166
|
+
"html": "<p>It works!</p>"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}'
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Webhooks
|
|
173
|
+
|
|
174
|
+
The endpoint answers `GET` with 200 OK (the URL check of [webhook/set](https://docs.unione.io/en/web-api-ref#webhook-set)) and accepts the `POST` notifications: it checks the `auth` field (MD5 of the request body in which the `auth` value is replaced by the API key), answers 401 to a wrong signature, and passes the notification and each of its events to the application.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from unione_wrapper import Webhooks
|
|
178
|
+
|
|
179
|
+
webhooks = Webhooks()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@webhooks.on_event
|
|
183
|
+
async def on_event(event, user):
|
|
184
|
+
data = event.event_data
|
|
185
|
+
print(event.event_name, data.email, data.status)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
app.include_router(webhooks.router()) # /unione/webhook
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Register the URL of the endpoint with `webhook/set`, e.g. `https://example.com/unione/webhook`.
|
|
192
|
+
|
|
193
|
+
## API client
|
|
194
|
+
|
|
195
|
+
Every section of the API is available on the client, see the [SDK docs](https://github.com/unione-repo/unione-python-sdk).
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from fastapi import Depends, FastAPI
|
|
199
|
+
from unione import UniOneClient
|
|
200
|
+
from unione_wrapper import get_client
|
|
201
|
+
|
|
202
|
+
app = FastAPI()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@app.get("/usage")
|
|
206
|
+
def usage(client: UniOneClient = Depends(get_client)): # the client is synchronous: a def endpoint
|
|
207
|
+
info = client.system.info()
|
|
208
|
+
return {"emails_sent": info.accounting.emails_sent if info.accounting else None}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Sending through the API
|
|
212
|
+
|
|
213
|
+
The client calls [email/send](https://docs.unione.io/en/web-api-ref#email-send) with all of its options: templates, substitutions, metadata, tracking. Fields of the request and other examples: [Python SDK docs](https://github.com/unione-repo/unione-python-sdk/blob/HEAD/docs/Email.md#send).
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
from fastapi import Depends
|
|
217
|
+
from unione import UniOneClient
|
|
218
|
+
from unione_wrapper import get_client
|
|
219
|
+
from unione.api.email import EmailSendRequest, MessageObject, MessageObjectRecipientsItem
|
|
220
|
+
from unione.common import BodyObject
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.post("/send")
|
|
224
|
+
def send(client: UniOneClient = Depends(get_client)):
|
|
225
|
+
response = client.email.send(EmailSendRequest(
|
|
226
|
+
message=MessageObject(
|
|
227
|
+
recipients=[
|
|
228
|
+
MessageObjectRecipientsItem(
|
|
229
|
+
email="user@example.com",
|
|
230
|
+
substitutions={
|
|
231
|
+
"CustomerId": "12452",
|
|
232
|
+
"to_name": "John Smith",
|
|
233
|
+
},
|
|
234
|
+
),
|
|
235
|
+
],
|
|
236
|
+
body=BodyObject(
|
|
237
|
+
html="<b>Hello, {{to_name}}</b>",
|
|
238
|
+
plaintext="Hello, {{to_name}}",
|
|
239
|
+
),
|
|
240
|
+
subject="UniOne test email ",
|
|
241
|
+
from_email="email@example.com",
|
|
242
|
+
from_name="John Smith",
|
|
243
|
+
),
|
|
244
|
+
))
|
|
245
|
+
return {"job_id": response.job_id}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
## Sandbox limits
|
|
249
|
+
|
|
250
|
+
- Sends only to confirmed addresses: up to 10 per account.
|
|
251
|
+
- Up to 100 emails a day.
|
|
252
|
+
- The sandbox domain expires after 31 days; call `domain/get-sandbox-domain` again to get a new one.
|
|
253
|
+
- Works only with the account API key, not a project key.
|
|
254
|
+
- Not listed in `domain/list`; the other `domain/*` methods do not apply to it.
|
|
255
|
+
|
|
256
|
+
The sandbox is for development and testing. Do not use a sandbox domain as the sender in production.
|
|
257
|
+
|
|
258
|
+
## Before production: verify your domain
|
|
259
|
+
|
|
260
|
+
1. `domain/get-dns-records` — returns the values for your DNS records.
|
|
261
|
+
2. Add these records at your DNS provider (the domain page of the control panel shows them too):
|
|
262
|
+
|
|
263
|
+
| Type | Host | Value |
|
|
264
|
+
|---|---|---|
|
|
265
|
+
| TXT | `@` | the `verification-record` value (`unione-validate-hash=…`) |
|
|
266
|
+
| TXT | `<selector>._domainkey` (the selector is on the domain page of the control panel, e.g. `us`) | `v=DKIM1; k=rsa; p=<dkim>` |
|
|
267
|
+
| TXT | `@` | `v=spf1 include:spf.unione.io ~all` |
|
|
268
|
+
| CNAME | `_dmarc` | the `dmarc` value |
|
|
269
|
+
|
|
270
|
+
3. `domain/validate-verification-record`, then `domain/validate-dkim`.
|
|
271
|
+
4. `domain/list` — wait until the verification record is confirmed and DKIM is active.
|
|
272
|
+
5. Read `from_email` from your config, so switching from the sandbox needs no code change.
|
|
273
|
+
|
|
274
|
+
## Sending reliably
|
|
275
|
+
|
|
276
|
+
- **Retries:** set `message.idempotence_key` on sends that may be retried, so a repeated request does not send twice.
|
|
277
|
+
- **Limits:** up to 500 recipients and 10 MB per request; each attachment up to 7 MB.
|
|
278
|
+
- **Body:** always include `body.html` or `body.plaintext`.
|
|
279
|
+
- **Delivery status:** register a webhook (`webhook/set`) with a fast, idempotent HTTPS handler instead of polling.
|
|
280
|
+
- **Defaults:** keep the default unsubscribe footer and tracking; changing them requires UniOne support.
|
|
281
|
+
- **Errors:** retry `429` and `5xx` with backoff; do not retry other errors unchanged.
|
|
282
|
+
|
|
283
|
+
## Links
|
|
284
|
+
|
|
285
|
+
- [Send emails with Python](https://docs.unione.io/en/send-with-python) and other [integration guides](https://docs.unione.io/en/integrations/)
|
|
286
|
+
- [Web API reference](https://docs.unione.io/en/web-api-ref)
|
|
287
|
+
- [llms.txt](https://docs.unione.io/llms.txt) for AI coding agents
|
|
288
|
+
- [UniOne MCP server and agent setup](https://unione.io/en/agents)
|
|
289
|
+
- [Support](https://cp.unione.io/en/support)
|
|
290
|
+
|
|
291
|
+
## For package contributors
|
|
292
|
+
|
|
293
|
+
See [AGENTS.md](AGENTS.md): how this package is generated from the OpenAPI spec, built, tested and released.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unione-fastapi"
|
|
7
|
+
version = "1.1.3"
|
|
8
|
+
description = "UniOne Web API for FastAPI: API client, webhooks"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "UniOne" }]
|
|
13
|
+
keywords = ["unione", "fastapi", "email", "webhook"]
|
|
14
|
+
classifiers = ["Framework :: FastAPI"]
|
|
15
|
+
dependencies = ["unione-sdk>=1.1.3", "fastapi>=0.100"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/unione-repo/unione-fastapi"
|
|
19
|
+
Documentation = "https://docs.unione.io/en/web-api-ref"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
"*" = ["py.typed"]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unione-fastapi
|
|
3
|
+
Version: 1.1.3
|
|
4
|
+
Summary: UniOne Web API for FastAPI: API client, webhooks
|
|
5
|
+
Author: UniOne
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/unione-repo/unione-fastapi
|
|
8
|
+
Project-URL: Documentation, https://docs.unione.io/en/web-api-ref
|
|
9
|
+
Keywords: unione,fastapi,email,webhook
|
|
10
|
+
Classifier: Framework :: FastAPI
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: unione-sdk>=1.1.3
|
|
15
|
+
Requires-Dist: fastapi>=0.100
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# UniOne FastAPI 1.1.3
|
|
19
|
+
|
|
20
|
+
Web API 1.90
|
|
21
|
+
|
|
22
|
+
Quickstart guide: [Send emails with Python](https://docs.unione.io/en/send-with-python) · Docs for LLMs: [llms.txt](https://docs.unione.io/llms.txt) · [API reference](https://docs.unione.io/en/web-api-ref)
|
|
23
|
+
|
|
24
|
+
FastAPI integration of the [UniOne Python SDK](https://github.com/unione-repo/unione-python-sdk) (``): the API client in the dependencies (`Depends(get_client)`), a webhook endpoint with signature check.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install unione-fastapi
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requirements: Python 3.8+, FastAPI 0.100+.
|
|
33
|
+
|
|
34
|
+
## Get an API key
|
|
35
|
+
|
|
36
|
+
1. Sign up at [cp.unione.io](https://cp.unione.io/en/user/registration/).
|
|
37
|
+
2. Copy your API key from [Settings → Security → API](https://cp.unione.io/en/settings/security/api).
|
|
38
|
+
3. Put it in an environment variable:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
export UNIONE_API_KEY="your-api-key"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The package reads it from `UNIONE_API_KEY` (see the configuration below).
|
|
45
|
+
|
|
46
|
+
> **Server-side only.** Use this package in backend code: API routes, server functions, workers.
|
|
47
|
+
> Never ship the API key to a browser or a mobile app, and never commit it.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Settings are read from the environment by default:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
UNIONE_API_KEY=YOUR_API_KEY
|
|
55
|
+
UNIONE_HOST=us1.unione.io
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Option | Environment variable | Default | Description |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| `Settings.api_key` | `UNIONE_API_KEY` | | User or project API key (required). |
|
|
61
|
+
| `Settings.host` | `UNIONE_HOST` | `api.unione.io` | API host (datacenter: `api.unione.io`, `eu1.unione.io`, `us1.unione.io`) or a full base URL. |
|
|
62
|
+
| `Settings.timeout` | `UNIONE_TIMEOUT` | `30` | Request timeout, seconds. |
|
|
63
|
+
| `Settings.webhook_key` | `UNIONE_WEBHOOK_KEY` | | API key the webhooks are signed with (the key of the user/project of the webhook), if it differs from the API key. |
|
|
64
|
+
| `Settings.webhook_path` | `UNIONE_WEBHOOK_PATH` | `/unione/webhook` | Path of the webhook endpoint. |
|
|
65
|
+
|
|
66
|
+
Other settings in code: `app.dependency_overrides[get_settings] = lambda: Settings(api_key=...)`.
|
|
67
|
+
|
|
68
|
+
## Send your first email — no DNS setup
|
|
69
|
+
|
|
70
|
+
Every account can send test email right away from a **sandbox domain**: pre-verified, DKIM and SPF already set.
|
|
71
|
+
It delivers only to addresses you confirm.
|
|
72
|
+
|
|
73
|
+
The examples use the API client of the package (see the configuration above).
|
|
74
|
+
|
|
75
|
+
**1. Get your sandbox domain** (`domain/get-sandbox-domain`). The call creates the domain on first use and returns the same one afterwards.
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from fastapi import Depends
|
|
79
|
+
from unione import UniOneClient
|
|
80
|
+
from unione_wrapper import get_client
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.post("/sandbox-domain")
|
|
84
|
+
def get_sandbox_domain(client: UniOneClient = Depends(get_client)):
|
|
85
|
+
response = client.domain.get_sandbox_domain()
|
|
86
|
+
return {"domain": response.domain}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**2. Confirm your inbox** (`confirmed-email/create`). UniOne sends a confirmation email; click the link in it within 48 hours.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from fastapi import Depends
|
|
93
|
+
from unione import UniOneClient
|
|
94
|
+
from unione_wrapper import get_client
|
|
95
|
+
from unione.api.confirmed_email import ConfirmedEmailRequest
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.post("/confirm-email")
|
|
99
|
+
def confirm_email(client: UniOneClient = Depends(get_client)):
|
|
100
|
+
response = client.confirmed_email.create(ConfirmedEmailRequest(
|
|
101
|
+
email="you@example.com",
|
|
102
|
+
))
|
|
103
|
+
return {"status": response.status}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Check the status with `confirmed-email/list` — your address should show `"status": "confirmed"`.
|
|
107
|
+
|
|
108
|
+
**3. Send the email** (`email/send`) from any address on the sandbox domain to the confirmed address.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from fastapi import Depends
|
|
112
|
+
from unione import UniOneClient
|
|
113
|
+
from unione_wrapper import get_client
|
|
114
|
+
from unione.api.email import EmailSendRequest, MessageObject, MessageObjectRecipientsItem
|
|
115
|
+
from unione.common import BodyObject
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@app.post("/send")
|
|
119
|
+
def send(client: UniOneClient = Depends(get_client)):
|
|
120
|
+
response = client.email.send(EmailSendRequest(
|
|
121
|
+
message=MessageObject(
|
|
122
|
+
recipients=[
|
|
123
|
+
MessageObjectRecipientsItem(
|
|
124
|
+
email="you@example.com",
|
|
125
|
+
),
|
|
126
|
+
],
|
|
127
|
+
body=BodyObject(
|
|
128
|
+
html="<p>It works!</p>",
|
|
129
|
+
),
|
|
130
|
+
subject="Hello from UniOne",
|
|
131
|
+
from_email="noreply@<sandbox-domain>",
|
|
132
|
+
),
|
|
133
|
+
))
|
|
134
|
+
return {"job_id": response.job_id}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### What you get back
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"status": "success",
|
|
142
|
+
"job_id": "1ZymBc-00041N-9X",
|
|
143
|
+
"emails": [
|
|
144
|
+
"you@example.com"
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
On error the API returns `status: "error"`, a `message` and a numeric `code`:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{
|
|
153
|
+
"status": "error",
|
|
154
|
+
"code": 102,
|
|
155
|
+
"message": "Error ID:83274F98-C7D5-11F1-B221-6214FF8707B1. Can not decode key wrongkey"
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Same flow with curl
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
BASE=https://api.unione.io/en/transactional/api/v1
|
|
163
|
+
|
|
164
|
+
curl -s -X POST $BASE/domain/get-sandbox-domain.json \
|
|
165
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" -d '{}'
|
|
166
|
+
|
|
167
|
+
curl -s -X POST $BASE/confirmed-email/create.json \
|
|
168
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" \
|
|
169
|
+
-d '{"email": "you@example.com"}'
|
|
170
|
+
|
|
171
|
+
curl -s -X POST $BASE/email/send.json \
|
|
172
|
+
-H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" \
|
|
173
|
+
-d '{
|
|
174
|
+
"message": {
|
|
175
|
+
"recipients": [
|
|
176
|
+
{
|
|
177
|
+
"email": "you@example.com"
|
|
178
|
+
}
|
|
179
|
+
],
|
|
180
|
+
"from_email": "noreply@<sandbox-domain>",
|
|
181
|
+
"subject": "Hello from UniOne",
|
|
182
|
+
"body": {
|
|
183
|
+
"html": "<p>It works!</p>"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}'
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Webhooks
|
|
190
|
+
|
|
191
|
+
The endpoint answers `GET` with 200 OK (the URL check of [webhook/set](https://docs.unione.io/en/web-api-ref#webhook-set)) and accepts the `POST` notifications: it checks the `auth` field (MD5 of the request body in which the `auth` value is replaced by the API key), answers 401 to a wrong signature, and passes the notification and each of its events to the application.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
from unione_wrapper import Webhooks
|
|
195
|
+
|
|
196
|
+
webhooks = Webhooks()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@webhooks.on_event
|
|
200
|
+
async def on_event(event, user):
|
|
201
|
+
data = event.event_data
|
|
202
|
+
print(event.event_name, data.email, data.status)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
app.include_router(webhooks.router()) # /unione/webhook
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Register the URL of the endpoint with `webhook/set`, e.g. `https://example.com/unione/webhook`.
|
|
209
|
+
|
|
210
|
+
## API client
|
|
211
|
+
|
|
212
|
+
Every section of the API is available on the client, see the [SDK docs](https://github.com/unione-repo/unione-python-sdk).
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from fastapi import Depends, FastAPI
|
|
216
|
+
from unione import UniOneClient
|
|
217
|
+
from unione_wrapper import get_client
|
|
218
|
+
|
|
219
|
+
app = FastAPI()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.get("/usage")
|
|
223
|
+
def usage(client: UniOneClient = Depends(get_client)): # the client is synchronous: a def endpoint
|
|
224
|
+
info = client.system.info()
|
|
225
|
+
return {"emails_sent": info.accounting.emails_sent if info.accounting else None}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Sending through the API
|
|
229
|
+
|
|
230
|
+
The client calls [email/send](https://docs.unione.io/en/web-api-ref#email-send) with all of its options: templates, substitutions, metadata, tracking. Fields of the request and other examples: [Python SDK docs](https://github.com/unione-repo/unione-python-sdk/blob/HEAD/docs/Email.md#send).
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from fastapi import Depends
|
|
234
|
+
from unione import UniOneClient
|
|
235
|
+
from unione_wrapper import get_client
|
|
236
|
+
from unione.api.email import EmailSendRequest, MessageObject, MessageObjectRecipientsItem
|
|
237
|
+
from unione.common import BodyObject
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@app.post("/send")
|
|
241
|
+
def send(client: UniOneClient = Depends(get_client)):
|
|
242
|
+
response = client.email.send(EmailSendRequest(
|
|
243
|
+
message=MessageObject(
|
|
244
|
+
recipients=[
|
|
245
|
+
MessageObjectRecipientsItem(
|
|
246
|
+
email="user@example.com",
|
|
247
|
+
substitutions={
|
|
248
|
+
"CustomerId": "12452",
|
|
249
|
+
"to_name": "John Smith",
|
|
250
|
+
},
|
|
251
|
+
),
|
|
252
|
+
],
|
|
253
|
+
body=BodyObject(
|
|
254
|
+
html="<b>Hello, {{to_name}}</b>",
|
|
255
|
+
plaintext="Hello, {{to_name}}",
|
|
256
|
+
),
|
|
257
|
+
subject="UniOne test email ",
|
|
258
|
+
from_email="email@example.com",
|
|
259
|
+
from_name="John Smith",
|
|
260
|
+
),
|
|
261
|
+
))
|
|
262
|
+
return {"job_id": response.job_id}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Sandbox limits
|
|
266
|
+
|
|
267
|
+
- Sends only to confirmed addresses: up to 10 per account.
|
|
268
|
+
- Up to 100 emails a day.
|
|
269
|
+
- The sandbox domain expires after 31 days; call `domain/get-sandbox-domain` again to get a new one.
|
|
270
|
+
- Works only with the account API key, not a project key.
|
|
271
|
+
- Not listed in `domain/list`; the other `domain/*` methods do not apply to it.
|
|
272
|
+
|
|
273
|
+
The sandbox is for development and testing. Do not use a sandbox domain as the sender in production.
|
|
274
|
+
|
|
275
|
+
## Before production: verify your domain
|
|
276
|
+
|
|
277
|
+
1. `domain/get-dns-records` — returns the values for your DNS records.
|
|
278
|
+
2. Add these records at your DNS provider (the domain page of the control panel shows them too):
|
|
279
|
+
|
|
280
|
+
| Type | Host | Value |
|
|
281
|
+
|---|---|---|
|
|
282
|
+
| TXT | `@` | the `verification-record` value (`unione-validate-hash=…`) |
|
|
283
|
+
| TXT | `<selector>._domainkey` (the selector is on the domain page of the control panel, e.g. `us`) | `v=DKIM1; k=rsa; p=<dkim>` |
|
|
284
|
+
| TXT | `@` | `v=spf1 include:spf.unione.io ~all` |
|
|
285
|
+
| CNAME | `_dmarc` | the `dmarc` value |
|
|
286
|
+
|
|
287
|
+
3. `domain/validate-verification-record`, then `domain/validate-dkim`.
|
|
288
|
+
4. `domain/list` — wait until the verification record is confirmed and DKIM is active.
|
|
289
|
+
5. Read `from_email` from your config, so switching from the sandbox needs no code change.
|
|
290
|
+
|
|
291
|
+
## Sending reliably
|
|
292
|
+
|
|
293
|
+
- **Retries:** set `message.idempotence_key` on sends that may be retried, so a repeated request does not send twice.
|
|
294
|
+
- **Limits:** up to 500 recipients and 10 MB per request; each attachment up to 7 MB.
|
|
295
|
+
- **Body:** always include `body.html` or `body.plaintext`.
|
|
296
|
+
- **Delivery status:** register a webhook (`webhook/set`) with a fast, idempotent HTTPS handler instead of polling.
|
|
297
|
+
- **Defaults:** keep the default unsubscribe footer and tracking; changing them requires UniOne support.
|
|
298
|
+
- **Errors:** retry `429` and `5xx` with backoff; do not retry other errors unchanged.
|
|
299
|
+
|
|
300
|
+
## Links
|
|
301
|
+
|
|
302
|
+
- [Send emails with Python](https://docs.unione.io/en/send-with-python) and other [integration guides](https://docs.unione.io/en/integrations/)
|
|
303
|
+
- [Web API reference](https://docs.unione.io/en/web-api-ref)
|
|
304
|
+
- [llms.txt](https://docs.unione.io/llms.txt) for AI coding agents
|
|
305
|
+
- [UniOne MCP server and agent setup](https://unione.io/en/agents)
|
|
306
|
+
- [Support](https://cp.unione.io/en/support)
|
|
307
|
+
|
|
308
|
+
## For package contributors
|
|
309
|
+
|
|
310
|
+
See [AGENTS.md](AGENTS.md): how this package is generated from the OpenAPI spec, built, tested and released.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/unione_fastapi.egg-info/PKG-INFO
|
|
5
|
+
src/unione_fastapi.egg-info/SOURCES.txt
|
|
6
|
+
src/unione_fastapi.egg-info/dependency_links.txt
|
|
7
|
+
src/unione_fastapi.egg-info/requires.txt
|
|
8
|
+
src/unione_fastapi.egg-info/top_level.txt
|
|
9
|
+
src/unione_wrapper/__init__.py
|
|
10
|
+
src/unione_wrapper/_settings.py
|
|
11
|
+
src/unione_wrapper/py.typed
|
|
12
|
+
src/unione_wrapper/signature.py
|
|
13
|
+
src/unione_wrapper/webhook.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
unione_wrapper
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""UniOne Web API for FastAPI: client dependency, webhooks.
|
|
2
|
+
|
|
3
|
+
webhooks = Webhooks()
|
|
4
|
+
|
|
5
|
+
@webhooks.on_event
|
|
6
|
+
async def on_event(event, user): ...
|
|
7
|
+
|
|
8
|
+
app.include_router(webhooks.router())
|
|
9
|
+
|
|
10
|
+
@app.get("/usage")
|
|
11
|
+
def usage(client: UniOneClient = Depends(get_client)): ...
|
|
12
|
+
|
|
13
|
+
Settings come from the environment variables UNIONE_*; replace them with
|
|
14
|
+
app.dependency_overrides[get_settings] = lambda: Settings(api_key=...).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import inspect
|
|
19
|
+
from functools import lru_cache
|
|
20
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union
|
|
21
|
+
|
|
22
|
+
from fastapi import APIRouter, Depends, Request
|
|
23
|
+
from fastapi.responses import PlainTextResponse
|
|
24
|
+
from unione import UniOneClient
|
|
25
|
+
|
|
26
|
+
from . import webhook as notifications
|
|
27
|
+
from ._settings import Settings
|
|
28
|
+
from .webhook import WebhookCallback, WebhookCallbackEventsByUserItemEventsItem, WebhookCallbackEventsByUserItem
|
|
29
|
+
|
|
30
|
+
__all__ = ["Settings", "Webhooks", "get_client", "get_settings"]
|
|
31
|
+
__version__ = "1.1.3"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache(maxsize=None)
|
|
35
|
+
def get_settings() -> Settings:
|
|
36
|
+
"""Settings from the environment variables UNIONE_* (dependency, can be overridden)."""
|
|
37
|
+
return Settings.load()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@lru_cache(maxsize=None)
|
|
41
|
+
def _client(settings: Settings) -> UniOneClient:
|
|
42
|
+
return UniOneClient(settings.require_key(), settings.host, settings.timeout, platform="fastapi.1.1.3")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_client(settings: Settings = Depends(get_settings)) -> UniOneClient:
|
|
46
|
+
"""Dependency: the UniOne API client of the settings (one instance per settings).
|
|
47
|
+
|
|
48
|
+
The client is synchronous: use it in `def` endpoints (FastAPI runs them in a thread pool).
|
|
49
|
+
"""
|
|
50
|
+
return _client(settings)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
NotificationHandler = Callable[[WebhookCallback, Dict[str, Any]], Union[None, Awaitable[None]]]
|
|
54
|
+
EventHandler = Callable[[WebhookCallbackEventsByUserItemEventsItem, WebhookCallbackEventsByUserItem], Union[None, Awaitable[None]]]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _call(handler: Callable[..., Any], *args: Any) -> None:
|
|
58
|
+
value = handler(*args)
|
|
59
|
+
if inspect.isawaitable(value):
|
|
60
|
+
await value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Webhooks:
|
|
64
|
+
"""Handlers of the webhook notifications and the router of the endpoint.
|
|
65
|
+
|
|
66
|
+
GET/HEAD -> 200 (URL check of webhook/set), POST -> signature check, then every on_notification handler
|
|
67
|
+
with (callback, payload) and every on_event handler with (event, user) for each event. Handlers may be sync
|
|
68
|
+
or async; keep them fast, the API waits for 200 OK for 3 seconds only.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self.notification_handlers: List[NotificationHandler] = []
|
|
73
|
+
self.event_handlers: List[EventHandler] = []
|
|
74
|
+
|
|
75
|
+
def on_notification(self, handler: NotificationHandler) -> NotificationHandler:
|
|
76
|
+
self.notification_handlers.append(handler)
|
|
77
|
+
return handler
|
|
78
|
+
|
|
79
|
+
def on_event(self, handler: EventHandler) -> EventHandler:
|
|
80
|
+
self.event_handlers.append(handler)
|
|
81
|
+
return handler
|
|
82
|
+
|
|
83
|
+
async def handle(self, body: bytes, settings: Settings) -> PlainTextResponse:
|
|
84
|
+
try:
|
|
85
|
+
callback, payload = notifications.parse(body, settings.signing_key)
|
|
86
|
+
except notifications.WebhookError as e:
|
|
87
|
+
return PlainTextResponse(str(e), status_code=e.status)
|
|
88
|
+
for handler in self.notification_handlers:
|
|
89
|
+
await _call(handler, callback, payload)
|
|
90
|
+
for event, user in notifications.events(callback):
|
|
91
|
+
for event_handler in self.event_handlers:
|
|
92
|
+
await _call(event_handler, event, user)
|
|
93
|
+
return PlainTextResponse("OK")
|
|
94
|
+
|
|
95
|
+
def router(self, path: Optional[str] = None, **kwargs: Any) -> APIRouter:
|
|
96
|
+
"""Router with the endpoint at path (default: UNIONE_WEBHOOK_PATH or /unione/webhook); kwargs go to APIRouter."""
|
|
97
|
+
router = APIRouter(**kwargs)
|
|
98
|
+
|
|
99
|
+
async def webhook(request: Request, settings: Settings = Depends(get_settings)) -> PlainTextResponse:
|
|
100
|
+
if request.method != "POST":
|
|
101
|
+
return PlainTextResponse("OK")
|
|
102
|
+
return await self.handle(await request.body(), settings)
|
|
103
|
+
|
|
104
|
+
router.add_api_route(path or get_settings().webhook_path, webhook, methods=["GET", "HEAD", "POST"],
|
|
105
|
+
include_in_schema=False, name="unione_webhook")
|
|
106
|
+
return router
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Settings of the UniOne API: environment variables UNIONE_* with overrides of the framework config."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
ENV_PREFIX = "UNIONE_"
|
|
9
|
+
DEFAULT_WEBHOOK_PATH = "/unione/webhook"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Settings:
|
|
14
|
+
api_key: str = ""
|
|
15
|
+
"""User or project API key."""
|
|
16
|
+
|
|
17
|
+
host: Optional[str] = None
|
|
18
|
+
"""API host (datacenter: api.unione.io, eu1.unione.io, us1.unione.io) or a full base URL; None: the default one."""
|
|
19
|
+
|
|
20
|
+
timeout: float = 30.0
|
|
21
|
+
"""Request timeout, seconds."""
|
|
22
|
+
|
|
23
|
+
webhook_key: Optional[str] = None
|
|
24
|
+
"""API key the webhooks are signed with; None: api_key."""
|
|
25
|
+
|
|
26
|
+
webhook_path: str = DEFAULT_WEBHOOK_PATH
|
|
27
|
+
"""Path of the webhook endpoint."""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def signing_key(self) -> str:
|
|
31
|
+
return self.webhook_key or self.api_key
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def load(cls, overrides: Optional[Mapping[str, Any]] = None, environ: Optional[Mapping[str, str]] = None) -> "Settings":
|
|
35
|
+
"""Environment variables UNIONE_API_KEY, ..._HOST, ..._TIMEOUT, ..._WEBHOOK_KEY, ..._WEBHOOK_PATH; overrides
|
|
36
|
+
(keys api_key, host, ... in any case) win over them."""
|
|
37
|
+
env = os.environ if environ is None else environ
|
|
38
|
+
values = {key: env.get(ENV_PREFIX + key.upper()) for key in ("api_key", "host", "timeout", "webhook_key", "webhook_path")}
|
|
39
|
+
for key, value in (overrides or {}).items():
|
|
40
|
+
if key.lower() in values and value is not None:
|
|
41
|
+
values[key.lower()] = value
|
|
42
|
+
return cls(
|
|
43
|
+
api_key=str(values["api_key"] or ""),
|
|
44
|
+
host=str(values["host"]) if values["host"] else None,
|
|
45
|
+
timeout=float(values["timeout"] or 30.0),
|
|
46
|
+
webhook_key=str(values["webhook_key"]) if values["webhook_key"] else None,
|
|
47
|
+
webhook_path=str(values["webhook_path"] or DEFAULT_WEBHOOK_PATH),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def require_key(self) -> str:
|
|
51
|
+
if not self.api_key:
|
|
52
|
+
raise RuntimeError("UniOne: the API key is not set (UNIONE_API_KEY).")
|
|
53
|
+
return self.api_key
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Signature of the webhook notifications: the "auth" field is the MD5 of the request body in which the "auth"
|
|
2
|
+
value is replaced by the API key of the user/project of the webhook."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import re
|
|
8
|
+
from typing import Union
|
|
9
|
+
|
|
10
|
+
_AUTH = re.compile(rb'"auth"\s*:\s*"([0-9a-fA-F]{32})"')
|
|
11
|
+
PLACEHOLDER = "0" * 32
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _bytes(value: Union[bytes, str]) -> bytes:
|
|
15
|
+
return value.encode("utf-8") if isinstance(value, str) else value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify(body: Union[bytes, str], api_key: str) -> bool:
|
|
19
|
+
"""True when the "auth" field of the body is its signature made with api_key."""
|
|
20
|
+
body = _bytes(body)
|
|
21
|
+
match = _AUTH.search(body)
|
|
22
|
+
if not api_key or match is None:
|
|
23
|
+
return False
|
|
24
|
+
auth = match.group(1)
|
|
25
|
+
expected = hashlib.md5(body.replace(auth, _bytes(api_key))).hexdigest()
|
|
26
|
+
return hmac.compare_digest(expected, auth.decode("ascii").lower())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def sign(body: Union[bytes, str], api_key: str, placeholder: str = PLACEHOLDER) -> bytes:
|
|
30
|
+
"""Signs a body whose "auth" is the placeholder (32 hex digits), e.g. for tests."""
|
|
31
|
+
body = _bytes(body)
|
|
32
|
+
auth = hashlib.md5(body.replace(_bytes(placeholder), _bytes(api_key))).hexdigest()
|
|
33
|
+
return body.replace(_bytes(placeholder), auth.encode("ascii"))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Webhook notifications: signature check, parsing, events."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any, Dict, Iterator, Tuple, Union
|
|
6
|
+
|
|
7
|
+
from unione.api.webhook import WebhookApi, WebhookCallback, WebhookCallbackEventsByUserItem, WebhookCallbackEventsByUserItemEventsItem
|
|
8
|
+
|
|
9
|
+
from . import signature
|
|
10
|
+
|
|
11
|
+
__all__ = ["WebhookError", "parse", "events", "WebhookCallback", "WebhookCallbackEventsByUserItem", "WebhookCallbackEventsByUserItemEventsItem"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class WebhookError(Exception):
|
|
15
|
+
"""Rejected notification: status is the HTTP status of the answer (401 signature, 400 body)."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message: str, status: int) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse(body: Union[bytes, str], api_key: str) -> Tuple[WebhookCallback, Dict[str, Any]]:
|
|
23
|
+
"""Checks the signature and parses the notification: (callback model, decoded body)."""
|
|
24
|
+
if not signature.verify(body, api_key):
|
|
25
|
+
raise WebhookError("Invalid signature", 401)
|
|
26
|
+
text = body.decode("utf-8") if isinstance(body, bytes) else body
|
|
27
|
+
try:
|
|
28
|
+
payload = json.loads(text)
|
|
29
|
+
callback = WebhookApi.parse_callback(text)
|
|
30
|
+
except (ValueError, TypeError, KeyError, AttributeError) as e:
|
|
31
|
+
raise WebhookError(f"Invalid body: {e}", 400) from e
|
|
32
|
+
return callback, payload
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def events(callback: WebhookCallback) -> Iterator[Tuple[WebhookCallbackEventsByUserItemEventsItem, WebhookCallbackEventsByUserItem]]:
|
|
36
|
+
"""Every event of the notification with its user/project."""
|
|
37
|
+
for user in callback.events_by_user or []:
|
|
38
|
+
for event in user.events or []:
|
|
39
|
+
yield event, user
|