vintasend-api 3.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. vintasend_api-3.5.0/PKG-INFO +528 -0
  2. vintasend_api-3.5.0/README.md +489 -0
  3. vintasend_api-3.5.0/openapi.yaml +666 -0
  4. vintasend_api-3.5.0/pyproject.toml +146 -0
  5. vintasend_api-3.5.0/vintasend_api/__init__.py +0 -0
  6. vintasend_api-3.5.0/vintasend_api/asgi.py +15 -0
  7. vintasend_api-3.5.0/vintasend_api/dashboard/__init__.py +0 -0
  8. vintasend_api-3.5.0/vintasend_api/dashboard/api.py +390 -0
  9. vintasend_api-3.5.0/vintasend_api/dashboard/apps.py +88 -0
  10. vintasend_api-3.5.0/vintasend_api/dashboard/auth.py +152 -0
  11. vintasend_api-3.5.0/vintasend_api/dashboard/bodies.py +73 -0
  12. vintasend_api-3.5.0/vintasend_api/dashboard/capabilities.py +78 -0
  13. vintasend_api-3.5.0/vintasend_api/dashboard/conf.py +113 -0
  14. vintasend_api-3.5.0/vintasend_api/dashboard/contract.py +204 -0
  15. vintasend_api-3.5.0/vintasend_api/dashboard/cors.py +81 -0
  16. vintasend_api-3.5.0/vintasend_api/dashboard/errors.py +84 -0
  17. vintasend_api-3.5.0/vintasend_api/dashboard/filters.py +155 -0
  18. vintasend_api-3.5.0/vintasend_api/dashboard/hooks.py +129 -0
  19. vintasend_api-3.5.0/vintasend_api/dashboard/preview.py +91 -0
  20. vintasend_api-3.5.0/vintasend_api/dashboard/query.py +89 -0
  21. vintasend_api-3.5.0/vintasend_api/dashboard/serialize.py +173 -0
  22. vintasend_api-3.5.0/vintasend_api/dashboard/service.py +324 -0
  23. vintasend_api-3.5.0/vintasend_api/dashboard/template_source.py +290 -0
  24. vintasend_api-3.5.0/vintasend_api/dashboard/urls.py +25 -0
  25. vintasend_api-3.5.0/vintasend_api/dashboard/views.py +23 -0
  26. vintasend_api-3.5.0/vintasend_api/py.typed +0 -0
  27. vintasend_api-3.5.0/vintasend_api/settings.py +147 -0
  28. vintasend_api-3.5.0/vintasend_api/urls.py +21 -0
  29. vintasend_api-3.5.0/vintasend_api/vintasend_config.example.py +69 -0
  30. vintasend_api-3.5.0/vintasend_api/wsgi.py +10 -0
@@ -0,0 +1,528 @@
1
+ Metadata-Version: 2.4
2
+ Name: vintasend-api
3
+ Version: 3.5.0
4
+ Summary: REST API that exposes a VintaSend notification service over HTTP for the VintaSend dashboard UI
5
+ License-Expression: MIT
6
+ Keywords: vintasend,notifications,django,django-ninja,api
7
+ Author: Vinta Software
8
+ Author-email: contact@vinta.com.br
9
+ Requires-Python: >=3.10,<3.15
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Django :: 4.2
14
+ Classifier: Framework :: Django :: 5.0
15
+ Classifier: Framework :: Django :: 5.1
16
+ Classifier: Framework :: Django :: 5.2
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Communications :: Email
26
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
27
+ Classifier: Typing :: Typed
28
+ Requires-Dist: django (>=4.2,<6.0)
29
+ Requires-Dist: django-ninja (>=1.4.3,<2.0.0)
30
+ Requires-Dist: python-dotenv (>=1.1.1,<2.0.0)
31
+ Requires-Dist: requests (>=2.34.2,<3.0.0)
32
+ Requires-Dist: vintasend (==3.5.0)
33
+ Project-URL: Changelog, https://github.com/vintasoftware/vintasend/blob/main/RELEASE_NOTES.md
34
+ Project-URL: Homepage, https://github.com/vintasoftware/vintasend-api
35
+ Project-URL: Issues, https://github.com/vintasoftware/vintasend-api/issues
36
+ Project-URL: Repository, https://github.com/vintasoftware/vintasend-api
37
+ Description-Content-Type: text/markdown
38
+
39
+ # VintaSend API (Python)
40
+
41
+ REST API that exposes a [VintaSend](https://github.com/vintasoftware/vintasend)
42
+ notification service over HTTP, built with Django and
43
+ [django-ninja](https://django-ninja.dev/).
44
+
45
+ It exists so the [VintaSend dashboard](https://github.com/vintasoftware/vintasend-dashboard)
46
+ no longer has to embed a notification service: the dashboard is a pure API client, and
47
+ any implementation of this contract can serve it.
48
+
49
+ **[`openapi.yaml`](https://github.com/vintasoftware/vintasend-api/blob/main/openapi.yaml) is the contract.** It is shipped here byte-identical
50
+ to the copy in [`vintasend-ts-api`](https://github.com/vintasoftware/vintasend-ts-api),
51
+ the TypeScript reference implementation. This project is the Python implementation of the
52
+ same document, so one dashboard consumes either without knowing which is behind it.
53
+
54
+ This repository is developed and released on its own, and the
55
+ [`vintasend`](https://github.com/vintasoftware/vintasend) library repository tracks it as a
56
+ git submodule under `tools/vintasend-api` — the same arrangement its `implementations/`
57
+ packages use. Contribute here; the parent repo only records which commit it points at.
58
+
59
+ ## Why Django, for a library with no web framework
60
+
61
+ The notification store is the deciding factor. `vintasend-django` persists notifications
62
+ through the Django ORM, and reading them needs a Django app registry and connection
63
+ handling — a FastAPI process would have to boot a half-configured Django anyway to use
64
+ it. Serving from Django removes that.
65
+
66
+ Nothing is lost for non-Django deployments. The backend is a pluggable seam, so a
67
+ FastAPI application storing notifications through `vintasend-sqlalchemy` is served by
68
+ this same API: point `NOTIFICATION_SERVICE_FACTORY` at a factory that builds a
69
+ SQLAlchemy-backed service and the HTTP layer neither knows nor cares.
70
+
71
+ ```
72
+ ┌─────────────────────┐ HTTPS + API key ┌──────────────────┐
73
+ │ Dashboard (Next) │ ───────────────────▶ │ vintasend-api │
74
+ │ server-side only │ ◀─────────────────── │ (this project) │
75
+ └─────────────────────┘ JSON contract └────────┬─────────┘
76
+ │
77
+ ┌──────────────┴──────────────┐
78
+ │ Your VintaSend service │
79
+ │ backend + adapters + │
80
+ │ template renderer │
81
+ └─────────────────────────────┘
82
+ ```
83
+
84
+ The API owns everything that needs backend credentials — database access, template
85
+ rendering, GitHub template lookups. The UI owns presentation and user authentication.
86
+
87
+ ## Endpoints
88
+
89
+ | Method | Path | Purpose |
90
+ | --- | --- | --- |
91
+ | GET | `/health` | Liveness probe (unauthenticated) |
92
+ | GET | `/api/v1/capabilities` | Filter/order capabilities of the configured backend |
93
+ | GET | `/api/v1/notifications` | List notifications with filters, ordering and pagination |
94
+ | GET | `/api/v1/notifications/pending` | Notifications awaiting send |
95
+ | GET | `/api/v1/notifications/future` | Notifications scheduled for the future |
96
+ | GET | `/api/v1/notifications/one-off` | One-off notifications |
97
+ | GET | `/api/v1/notifications/{id}` | One notification, including context payloads |
98
+ | GET | `/api/v1/notifications/{id}/preview` | Templates rendered at the notification's commit |
99
+ | POST | `/api/v1/notifications/{id}/resend` | Resend a notification |
100
+ | POST | `/api/v1/notifications/{id}/cancel` | Cancel a pending notification |
101
+
102
+ A browsable version of the generated schema is served at `/api/v1/docs`.
103
+
104
+ Conventions the dashboard depends on:
105
+
106
+ - `page` is **1-indexed** on the wire.
107
+ - `hasMore` is `true` when the next page has at least one row, so a list that exactly
108
+ fills its last page never offers an empty one. Backends are not required to produce a
109
+ total count: after a full page, the API reads the one row that would follow it.
110
+ - List rows carry a `kind` field (`user` or `one-off`) so clients can discriminate
111
+ without sniffing for the presence of fields.
112
+ - Timestamps are ISO-8601 UTC strings, `null` when unset — never absent.
113
+ - Every notification payload carries `requestedTemplateVersion` and `usedTemplateVersion`,
114
+ which are `null` for a service whose template renderer has no versions. See
115
+ [Template versions](#template-versions).
116
+ - Errors always use the envelope `{ "error": { "code", "message", "details"? } }`, and
117
+ every 400 carries `details.issues: [{ path, message }]` — `path` empty for the body as a
118
+ whole.
119
+ - Request bodies are JSON. A request declaring `application/json` (or any
120
+ `application/*+json`) must carry a valid JSON object. One declaring no media type, or
121
+ another one, counts as an omitted body when it is empty and is a 400 otherwise:
122
+ `curl -d` sends form encoding unless told otherwise, and reading that body as `{}` would
123
+ resend with a regenerated context instead of the stored one.
124
+ - `FORBIDDEN` (403) is declared on every route, for a host that authenticates callers
125
+ itself and refuses one it knows. The API key alone never produces it.
126
+
127
+ ## Template versions
128
+
129
+ A notification records which version of its template it renders, for services whose
130
+ template renderer versions templates at all — a store-backed one such as
131
+ [`vintasend-managed-templates`](https://github.com/vintasoftware/vintasend-managed-templates).
132
+ Two fields on every notification payload say it:
133
+
134
+ | Field | Written | Means |
135
+ | --- | --- | --- |
136
+ | `requestedTemplateVersion` | at create/update | The version this notification is pinned to. `null` is not "unknown": it means the notification was deliberately left unpinned and renders whatever version is current when it sends. |
137
+ | `usedTemplateVersion` | after the send | What the renderer reported it actually used. `null` until the notification has been sent. |
138
+
139
+ On a pinned notification the two read the same, and an edit to the template cannot change
140
+ what it renders — that is what pinning is for. On an unpinned one they differ, and
141
+ `usedTemplateVersion` is the only record of which version went out: by the time anyone
142
+ asks, the template has moved on.
143
+
144
+ Both are `null` for a file-based renderer, which has no versions to report, and for any
145
+ notification created before pinning existed. A dashboard should read `null` as *not
146
+ applicable* rather than as a missing value, and hide the field rather than showing a zero.
147
+
148
+ This is independent of `gitCommitSha`, which pins the same idea for the other kind of
149
+ renderer: templates as files in a repository. A notification uses one mechanism or the
150
+ other, never both, so a payload with a `gitCommitSha` normally has null versions and one
151
+ with versions normally has a null SHA.
152
+
153
+ Both are filterable on the listing:
154
+
155
+ ```
156
+ GET /api/v1/notifications?requestedTemplateVersion=3 # pinned to v3
157
+ GET /api/v1/notifications?usedTemplateVersion=3 # actually rendered v3 — the audit query
158
+ ```
159
+
160
+ They match exactly, and they never match a notification whose version is `null` — the
161
+ library's NULL semantics, the same as every other filter field. So there is no way to ask
162
+ for "the unpinned ones" positively; `not` is what includes them, and this API exposes no
163
+ `not`. A negative version is a 400, but the floor is 0 rather than 1: version numbering
164
+ belongs to the template renderer, and this API has no basis for assuming it is 1-based.
165
+
166
+ A backend that cannot evaluate either filter reports `fields.requestedTemplateVersion` /
167
+ `fields.usedTemplateVersion` as `false` in `/capabilities`, and the dashboard greys the
168
+ control out. Unlike ordering, an unsupported *field* filter is not dropped server-side —
169
+ that has always been this API's split, and these two follow it.
170
+
171
+ ## Authentication
172
+
173
+ By default, every `/api/v1` request must carry the shared secret:
174
+
175
+ ```
176
+ Authorization: Bearer $VINTASEND_API_KEY
177
+ ```
178
+
179
+ The dashboard calls this API only from its own server side, so the key never reaches a
180
+ browser. If you do need to call the API from a browser, set `VINTASEND_API_CORS_ORIGINS`
181
+ to the allowed origins — and put a per-user auth layer in front of it first.
182
+
183
+ A host that authenticates callers itself sets `VINTASEND_API_AUTHENTICATOR` instead. See
184
+ [Authenticating callers yourself](#authenticating-callers-yourself).
185
+
186
+ ## Installing and embedding
187
+
188
+ ```bash
189
+ pip install vintasend-api
190
+ ```
191
+
192
+ The package is a Django app, `vintasend_api.dashboard`, plus a project that runs it on its
193
+ own (see [Running it on its own](#running-it-on-its-own)). A Django project of yours can
194
+ serve the API itself: install the app and include its URLs under a prefix.
195
+
196
+ ```python
197
+ # settings.py
198
+ INSTALLED_APPS = [
199
+ # ...
200
+ "vintasend_api.dashboard",
201
+ ]
202
+
203
+ NOTIFICATION_SERVICE_FACTORY = "myproject.notifications.create_notification_service"
204
+ VINTASEND_API_AUTHENTICATOR = "myproject.notifications_auth.authenticate"
205
+ ```
206
+
207
+ ```python
208
+ # urls.py
209
+ from django.urls import include, path
210
+
211
+ urlpatterns = [
212
+ # ...
213
+ path("notifications-api/", include("vintasend_api.dashboard.urls")),
214
+ ]
215
+ ```
216
+
217
+ That serves `/notifications-api/health` and everything under `/notifications-api/api/v1/`.
218
+ The app's own namespace is `vintasend_api`, so `reverse("vintasend_api:api-root")` finds the
219
+ API wherever it is mounted, and a project can mount
220
+ [`vintasend-templates-management-api`](https://github.com/vintasoftware/vintasend-templates-management-api)
221
+ beside it without the two colliding. Include the URLconf once per project: a second
222
+ include registers the same namespaces again, and `reverse` only ever finds one of them. The
223
+ app has no models and no migrations. It needs nothing from the bundled project: not its
224
+ settings module, its `handler404` or its middleware.
225
+
226
+ The app reads these settings, each at the moment it is used, so `override_settings` works
227
+ on them. Every one the host leaves out falls back to the default shown.
228
+
229
+ | Setting | Required | Default | Description |
230
+ | --- | --- | --- | --- |
231
+ | `NOTIFICATION_SERVICE_FACTORY` | yes | — | Dotted path to the callable building your VintaSend service. See [Configuring your VintaSend service](#configuring-your-vintasend-service). |
232
+ | `VINTASEND_API_AUTHENTICATOR` | this or the key | unset | Callable `(request) -> None`, or its dotted path, that authenticates callers. When set, the key is not checked. |
233
+ | `VINTASEND_API_KEY` | this or the authenticator | `""` | Shared secret callers send as a bearer token. Only read when no authenticator is set. |
234
+ | `VINTASEND_BACKEND_IDENTIFIER` | no | `None` | Read from a non-primary backend registered in your service. |
235
+ | `VINTASEND_UNHANDLED_ERROR_HANDLER` | no | unset | Callable `(exc, request, request_id)`, or its dotted path, receiving every unexpected error. See [Unexpected errors](#unexpected-errors). |
236
+ | `VINTASEND_API_CORS_ORIGINS` | no | `()` | Browser origins allowed to call the API. Only read by the optional CORS middleware. |
237
+ | `GITHUB_REPO` / `GITHUB_API_KEY` | preview only | `""` | Repository holding the templates, and a token with read access to it. |
238
+ | `GITHUB_API_BASE_URL` | no | `https://api.github.com` | GitHub API root. |
239
+ | `GITHUB_TEMPLATES_BASE_PATH` | no | `""` | Prefix added to template paths before the GitHub lookup. |
240
+ | `GITHUB_TEMPLATE_CACHE_MAX_ENTRIES` | no | `100` | Template files kept in memory per process. |
241
+ | `GITHUB_TEMPLATE_TIMEOUT_SECONDS` | no | `10` | Timeout of each GitHub request. |
242
+
243
+ `manage.py check` reports a missing service factory, a missing key when no authenticator is
244
+ set, and an authenticator or error handler that cannot be imported. See
245
+ [Environment variables](#environment-variables) for the check IDs.
246
+
247
+ ### Authenticating callers yourself
248
+
249
+ `VINTASEND_API_AUTHENTICATOR` runs before every `/api/v1` route, in place of the shared key.
250
+ It refuses a caller by raising `ApiError.unauthorized(...)` when no valid credential was
251
+ presented, and `ApiError.forbidden(...)` when it knows who is calling and refuses them: a
252
+ 401 would tell a signed-in user to sign in again. Both answer in the contract's error
253
+ envelope. Returning lets the request through. It may be `async`. It mirrors the TypeScript
254
+ reference's `authenticate` option.
255
+
256
+ `bearer_token(request)` reads the token of an `Authorization: Bearer` header, matching the
257
+ scheme in any case, and is `None` when the request carries none:
258
+
259
+ ```python
260
+ # myproject/notifications_auth.py
261
+ from django.http import HttpRequest
262
+
263
+ from vintasend_api.dashboard.auth import ApiError, bearer_token
264
+
265
+ from myproject.identity import verify_access_token # your own
266
+
267
+
268
+ def authenticate(request: HttpRequest) -> None:
269
+ token = bearer_token(request)
270
+ claims = verify_access_token(token) if token else None
271
+ if claims is None:
272
+ raise ApiError.unauthorized("Sign in first.")
273
+ if "notifications:manage" not in claims.scopes:
274
+ raise ApiError.forbidden("Not allowed.")
275
+ ```
276
+
277
+ Give the setting as a dotted path. Assigning the function itself also works, but importing
278
+ it into `settings.py` imports django-ninja while the settings are still being defined, and
279
+ django-ninja reads its own `NINJA_*` settings at import: any defined further down are missed.
280
+
281
+ Raise `ApiError`, which `vintasend_api.dashboard.auth` re-exports. The setting has the same
282
+ name and shape in `vintasend-templates-management-api`, so a project mounting both can point
283
+ them at one function, and that function may raise either package's `ApiError`: a refusal is
284
+ recognised by its class name and its code, as the TypeScript packages do, not by its class.
285
+ Only `UNAUTHORIZED` and `FORBIDDEN` count as a refusal; an `ApiError` with any other code, like
286
+ any other exception, is an unexpected error and answers 500.
287
+
288
+ `check_api_key(request)` in the same module is the shared-key check the app runs when no
289
+ authenticator is set, for an authenticator that still accepts the key, such as from a
290
+ server-side caller.
291
+
292
+ An authenticator that cannot be imported does not fall back to the key: every request is
293
+ answered with a 500 until it is fixed, and `manage.py check` reports it as
294
+ `vintasend_api.E004`.
295
+
296
+ Prefer a credential the caller sends explicitly, like the bearer token above. The API's views
297
+ are CSRF-exempt, as a bearer-token API's are, so an authenticator that trusts the session
298
+ cookie leaves the resend and cancel routes open to cross-site request forgery.
299
+
300
+ ### Optional extras
301
+
302
+ Two things the bundled project sets up that a host may want too:
303
+
304
+ - **CORS.** Add `"vintasend_api.dashboard.cors.CorsMiddleware"` to `MIDDLEWARE` and list the
305
+ origins in `VINTASEND_API_CORS_ORIGINS` to let browsers call the API. It applies to the
306
+ API's routes only, under whatever prefix they are mounted. Without it, the host's own CORS
307
+ handling applies, or none.
308
+ - **The 404 envelope.** Within the API's routes, errors use the contract's envelope. A path
309
+ that matches no route at all is answered by the project's `handler404`, which in the
310
+ bundled project is `vintasend_api.dashboard.views.envelope_404`. A host can set it too,
311
+ but it then answers every unmatched path in the host's project in that shape.
312
+
313
+ ## Running it on its own
314
+
315
+ The package also carries a complete Django project for a deployment with no Django project of
316
+ its own. It is configured from environment variables, read from a `.env` file in the working
317
+ directory when there is one.
318
+
319
+ ```bash
320
+ pip install vintasend-api gunicorn
321
+ ```
322
+
323
+ Write the factory building your service in a module of your own, say `vintasend_config.py`
324
+ in the working directory, and point `NOTIFICATION_SERVICE_FACTORY` at it (see
325
+ [Configuring your VintaSend service](#configuring-your-vintasend-service)). Then check the
326
+ configuration and serve:
327
+
328
+ ```bash
329
+ export DJANGO_SETTINGS_MODULE=vintasend_api.settings
330
+ export NOTIFICATION_SERVICE_FACTORY=vintasend_config.create_notification_service
331
+ export VINTASEND_API_KEY=... # or VINTASEND_API_AUTHENTICATOR
332
+
333
+ python -m django check
334
+ gunicorn vintasend_api.wsgi:application --bind 0.0.0.0:3333
335
+ ```
336
+
337
+ gunicorn is not a dependency of the package, so install it, or another WSGI server, yourself.
338
+ An ASGI server can serve `vintasend_api.asgi:application` instead. The routes are at the root:
339
+ `/health` and `/api/v1/`. See [Environment variables](#environment-variables) for the rest of
340
+ the configuration.
341
+
342
+ ## Getting started
343
+
344
+ From a checkout, for development:
345
+
346
+ ```bash
347
+ poetry install
348
+ cp .env.example .env
349
+ ```
350
+
351
+ Then configure the service the API should read from (below), and run:
352
+
353
+ ```bash
354
+ poetry run python manage.py runserver 0.0.0.0:3333
355
+ ```
356
+
357
+ ## Configuring your VintaSend service
358
+
359
+ The API ships no backend of its own: which database, adapters and template renderer to
360
+ use is a deployment decision. Point `NOTIFICATION_SERVICE_FACTORY` at a callable that
361
+ returns a configured service:
362
+
363
+ ```python
364
+ # vintasend_api/vintasend_config.py
365
+ from vintasend.services.notification_service import NotificationService
366
+
367
+
368
+ def create_notification_service():
369
+ backend = ... # your backend
370
+ renderer = ... # your template renderer
371
+ adapter = ... # your notification adapter
372
+
373
+ return NotificationService(
374
+ notification_adapters=[adapter],
375
+ notification_backend=backend,
376
+ )
377
+ ```
378
+
379
+ In a checkout, start from [`vintasend_api/vintasend_config.example.py`](https://github.com/vintasoftware/vintasend-api/blob/main/vintasend_api/vintasend_config.example.py),
380
+ copying it to `vintasend_api/vintasend_config.py` (gitignored). Installed, put it in a module
381
+ of your own anywhere on the Python path. The factory is called
382
+ once per process and its result reused, so it must be safe to call once and the service
383
+ it returns must be safe to share across requests.
384
+
385
+ Either service class works. `NotificationService` and `AsyncIONotificationService` have
386
+ matching method names, and every call the API makes is awaited when it comes back as a
387
+ coroutine. The view layer stays synchronous either way, which is what Django ORM
388
+ backends need.
389
+
390
+ This is the same setting VintaSend's background-send worker reads, so one factory can
391
+ serve the worker and this API — and pointing both at it guarantees they agree about
392
+ which backend holds the notifications.
393
+
394
+ ## Environment variables
395
+
396
+ | Variable | Required | Description |
397
+ | --- | --- | --- |
398
+ | `VINTASEND_API_KEY` | unless an authenticator is set | Shared secret clients must send as a bearer token. |
399
+ | `VINTASEND_API_AUTHENTICATOR` | no | Dotted path to a callable `(request)` authenticating callers in place of the key. See [Authenticating callers yourself](#authenticating-callers-yourself). |
400
+ | `NOTIFICATION_SERVICE_FACTORY` | yes | Dotted path to the callable building your VintaSend service. |
401
+ | `VINTASEND_BACKEND_IDENTIFIER` | no | Read from a non-primary backend registered in your service. |
402
+ | `VINTASEND_UNHANDLED_ERROR_HANDLER` | no | Dotted path to a callable `(exc, request, request_id)` receiving every unexpected error. See [Unexpected errors](#unexpected-errors). |
403
+ | `VINTASEND_API_CORS_ORIGINS` | no | Comma-separated browser origins allowed to call the API. |
404
+ | `DJANGO_SECRET_KEY` | no | Django requires one; this API signs nothing. |
405
+ | `DJANGO_DEBUG` / `DJANGO_ALLOWED_HOSTS` / `DJANGO_LOG_LEVEL` | no | Standard Django knobs. |
406
+ | `DJANGO_DB_*` | no | Only needed by backends that resolve their model through Django. |
407
+ | `GITHUB_REPO` | preview only | Repository holding the templates, as `owner/repo` or a full URL. |
408
+ | `GITHUB_API_KEY` | preview only | Token with read access to that repository. |
409
+ | `GITHUB_API_BASE_URL` | no | Defaults to `https://api.github.com`. |
410
+ | `GITHUB_TEMPLATES_BASE_PATH` | no | Prefix added to template paths before the GitHub lookup. |
411
+
412
+ The `GITHUB_*` variables are only read when `/preview` is called, so the API runs fine
413
+ without them if you do not use template previews.
414
+
415
+ The key and the service factory are enforced by a Django system check, so a deployment
416
+ missing either fails on `manage.py check` and on `runserver` rather than on the first
417
+ request: `vintasend_api.E001` for the key, which is only required when no authenticator is
418
+ set, and `vintasend_api.E002` for the factory. So is a setting naming something that cannot
419
+ be imported or called: `vintasend_api.E003` for `VINTASEND_UNHANDLED_ERROR_HANDLER` and
420
+ `vintasend_api.E004` for `VINTASEND_API_AUTHENTICATOR`. Run `manage.py check` in your release
421
+ step if you serve with gunicorn.
422
+
423
+ ## Unexpected errors
424
+
425
+ An error the API does not map to a contract error answers a generic 500
426
+ `INTERNAL_ERROR` with an `X-Request-Id` header, and is logged as one line: the error's
427
+ class, the request id, the method and the route pattern (`api/v1/notifications/<id>`, not
428
+ the path). Never its message, its traceback, the request body or the notification id: an
429
+ error from the notification store, a provider or a context generator can quote
430
+ notification content, recipients and context values, which in the applications this API
431
+ serves can be health data. Django's own `django.request` record for the 500 is suppressed
432
+ too, since it would repeat the concrete path and attach the request.
433
+
434
+ The request id is the client's `X-Request-Id` when it matches `[A-Za-z0-9._-]{1,128}`, and
435
+ a fresh UUID otherwise, so a client cannot forge a log line through it.
436
+
437
+ Set `VINTASEND_UNHANDLED_ERROR_HANDLER` to send errors to a tracker with its own scrubbing
438
+ instead; keeping health data out of it is then your call. It may be `async`. If it raises,
439
+ the redacted line is logged in its place, and what it raised is not.
440
+
441
+ ## Development
442
+
443
+ ```bash
444
+ poetry run python manage.py runserver # dev server
445
+ poetry run pytest # tests
446
+ poetry run ruff check . # lint
447
+ poetry run ruff format . # format
448
+ poetry run mypy # type-check
449
+ ```
450
+
451
+ Tests drive the real Django application through the test client with an injected fake
452
+ service, so they cover routing, auth, validation, filter negotiation and serialization
453
+ without needing a database, a mail provider or GitHub. They mirror the TypeScript
454
+ reference's suite case for case, which is what keeps the two implementations honest.
455
+
456
+ ## Notes for anyone comparing the two implementations
457
+
458
+ The wire contract is identical. These are the places where getting there took different
459
+ code, and each is worth knowing if you are implementing the contract a third time.
460
+
461
+ **Pagination.** Both APIs are 1-indexed on the wire. Underneath, the two ecosystems
462
+ genuinely differ: `vintasend-ts` backends page from 0, VintaSend's Python backends page
463
+ from 1. So a literal port of either implementation's page arithmetic is wrong in the other.
464
+
465
+ Neither API hardcodes it any more. Backends report `pagination.oneIndexed` in their
466
+ capability map — defaulting to `True` in `vintasend` and `false` in `vintasend-ts`, matching
467
+ what their backends actually do — and each API converts from that. Here it lives in
468
+ `ServiceCaller`, so the routes only ever deal in contract pages and none of them can forget.
469
+ That also covers a custom backend that disagrees with its ecosystem's default, which no
470
+ hardcoded rule would.
471
+
472
+ The whole `pagination.*` namespace is withheld from `/api/v1/capabilities`, in both
473
+ implementations: the wire is unconditionally 1-indexed and the conversion happens
474
+ server-side, so telling the dashboard about the backend's convention could only lead it to
475
+ convert a second time.
476
+
477
+ Worth knowing because this failure mode is silent — an off-by-one in page numbering raises
478
+ nothing, it just serves the wrong page or an empty first one. `vintasend-ts` documented its
479
+ backends as 1-indexed when they page from 0; the docs were wrong for long enough that anyone
480
+ writing a backend from them would have shipped the bug without a failing test.
481
+
482
+ **Capability keys.** Every key both libraries define is spelled identically, on purpose,
483
+ so the filter negotiation here reads `stringLookups.caseInsensitive` with no translation.
484
+
485
+ Watch out for one trap if you implement this yourself: both libraries also define
486
+ `stringLookups.caseSensitive`, and the pair are **independent capabilities, not a flag and
487
+ its negation**. A backend on a case-insensitive collation reports `caseSensitive: False`
488
+ and can match only case-insensitively; a backend with no case folding reports
489
+ `caseInsensitive: False` and can match only case-sensitively. Deriving either from the
490
+ other inverts the answer for exactly the backends that had something to report, and you'd
491
+ end up declining the one lookup they support. Read the key you actually mean.
492
+
493
+ **Filter vocabulary.** The wire is camelCase throughout. The Python filter vocabulary is
494
+ snake_case (`notification_type`, `sent_at_range`, `case_sensitive`), so
495
+ `dashboard/filters.py` translates. The TypeScript implementation needs no such layer.
496
+
497
+ **One-off listing.** `vintasend-ts` backends expose `getOneOffNotifications`. The Python
498
+ package has no equivalent, and the filter vocabulary has no field discriminating the two
499
+ variants, so `GET /notifications/one-off` scans the notification stream and keeps the
500
+ one-offs. A service that does expose `get_one_off_notifications` is used directly
501
+ instead. The scan is bounded and logs when it truncates.
502
+
503
+ **Notification lookup.** The contract describes looking an id up as a user notification
504
+ first, then as a one-off. Python's `get_notification` returns whichever variant matches,
505
+ so that is one call here rather than two. Same outcome.
506
+
507
+ **Preview context.** `render_email_template_from_content` takes a materialised context in
508
+ Python, so this API resolves which context to render with — the stored one, or a
509
+ regenerated one — before calling it. The TypeScript version passes either shape down into
510
+ its service. Same outcome.
511
+
512
+ **Template versions.** `requestedTemplateVersion` and `usedTemplateVersion` — both the
513
+ payload fields and the two query parameters that filter on them — come from `vintasend`
514
+ additions that `vintasend-ts` has not made, so the TypeScript reference serves the fields
515
+ as `null` at best and omits them at worst, and ignores the filters, until it catches up.
516
+ They are additive and nullable, so a dashboard reading them tolerantly works against
517
+ either — but this is the one place where `openapi.yaml` is currently ahead of the
518
+ TypeScript implementation rather than describing both.
519
+
520
+ **`UPSTREAM_ERROR`.** `openapi.yaml` documents a 502 when the template source cannot be
521
+ reached. This implementation emits it. The TypeScript reference declares the code but
522
+ lets template-source failures fall through to a 500, so that one status differs today —
523
+ the normative document is what this follows.
524
+
525
+ ## License
526
+
527
+ MIT
528
+