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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ unione-sdk>=1.1.3
2
+ fastapi>=0.100
@@ -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