persistence-kit 3.12.0__tar.gz → 3.14.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.
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/PKG-INFO +93 -5
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/README.md +89 -4
- persistence_kit-3.14.0/persistence_kit/mail/__init__.py +57 -0
- persistence_kit-3.14.0/persistence_kit/mail/contracts.py +9 -0
- persistence_kit-3.14.0/persistence_kit/mail/errors.py +26 -0
- persistence_kit-3.14.0/persistence_kit/mail/factory.py +91 -0
- persistence_kit-3.14.0/persistence_kit/mail/mailer.py +74 -0
- persistence_kit-3.14.0/persistence_kit/mail/memory.py +28 -0
- persistence_kit-3.14.0/persistence_kit/mail/message.py +121 -0
- persistence_kit-3.14.0/persistence_kit/mail/mime.py +64 -0
- persistence_kit-3.14.0/persistence_kit/mail/preview.py +84 -0
- persistence_kit-3.14.0/persistence_kit/mail/smtp.py +57 -0
- persistence_kit-3.14.0/persistence_kit/mail/templates.py +163 -0
- persistence_kit-3.14.0/persistence_kit/settings/mail_settings.py +24 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/pyproject.toml +5 -3
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/LICENSE +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/api/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/api/common.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/api/encrypted_middleware.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/api/encrypted_routes.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/api/error_handlers.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/api/exceptions.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/api/rate_limit.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/api/route_loader.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/authenticated_user.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/bootstrap/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/bootstrap/configuration.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/bootstrap/seeders.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/bootstrap/startup.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/cache/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/cache/contracts.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/cache/dynamodb.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/cache/factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/cache/memory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/cache/mongo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/cache/namespaced.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/contracts/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/contracts/repository.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/contracts/view_repository.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/py.typed +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/filter_ops.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository_factory/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/resilience/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/resilience/circuit.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/aggregate.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/auth/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/auth/api_key.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/auth/base.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/auth/basic.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/auth/bearer.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/auth/login.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/caching.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/client.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/config.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/contracts.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/errors.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/mapping.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/memory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/payload.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/populate.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/provider.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/registry.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/resolver.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/restclient/retry.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/encrypted/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/encrypted/envelope.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/encrypted/errors.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/ports.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/providers/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/providers/encryption_provider.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/registration.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/settings/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/settings/app_settings.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/settings/cache_settings.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/settings/constants.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/settings/parsers.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/settings/repo_settings.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/storage/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/storage/contracts.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/storage/errors.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/storage/factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/storage/local.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/storage/media.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/storage/routes.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/storage/s3.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/utils/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.14.0}/persistence_kit/utils/upsert.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: persistence-kit
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.14.0
|
|
4
4
|
Summary: Reusable persistence and repository toolkit
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -16,12 +16,14 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.12
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
19
20
|
Classifier: Topic :: Database
|
|
20
21
|
Classifier: Typing :: Typed
|
|
21
22
|
Provides-Extra: all
|
|
22
23
|
Provides-Extra: api
|
|
23
24
|
Provides-Extra: dynamodb
|
|
24
25
|
Provides-Extra: encrypted
|
|
26
|
+
Provides-Extra: mail
|
|
25
27
|
Provides-Extra: restclient
|
|
26
28
|
Provides-Extra: security
|
|
27
29
|
Provides-Extra: security-cognito
|
|
@@ -33,6 +35,7 @@ Requires-Dist: boto3 (>=1.35.0,<2.0.0) ; extra == "storage-s3" or extra == "secu
|
|
|
33
35
|
Requires-Dist: cryptography (>=42.0.0,<43.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
|
|
34
36
|
Requires-Dist: fastapi (>=0.115.0,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "encrypted" or extra == "testing" or extra == "all"
|
|
35
37
|
Requires-Dist: httpx (>=0.28.0,<1.0.0) ; extra == "restclient" or extra == "testing" or extra == "all"
|
|
38
|
+
Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "mail" or extra == "testing" or extra == "all"
|
|
36
39
|
Requires-Dist: motor (>=3.7.1,<4.0.0)
|
|
37
40
|
Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
|
|
38
41
|
Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0) ; extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
|
|
@@ -152,7 +155,8 @@ pip install "persistence-kit[api,security,restclient]"
|
|
|
152
155
|
| `dynamodb` | DynamoDB repository backend | boto3 |
|
|
153
156
|
| `restclient` | The REST client and its cache | httpx |
|
|
154
157
|
| `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
|
|
155
|
-
| `
|
|
158
|
+
| `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
|
|
159
|
+
| `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
|
|
156
160
|
| `all` | Every optional capability | all of the above |
|
|
157
161
|
|
|
158
162
|
Importing `persistence_kit` never loads an optional dependency by itself. Ask for
|
|
@@ -171,6 +175,7 @@ add, not an obscure `ImportError`.
|
|
|
171
175
|
| `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
|
|
172
176
|
| `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
|
|
173
177
|
| `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
|
|
178
|
+
| `mail/` | Outbound mail through an SMTP relay, with templates that live in the app |
|
|
174
179
|
| `resilience/` | Circuit breaker, shared by anything that calls out |
|
|
175
180
|
| `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
|
|
176
181
|
| `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
|
|
@@ -333,6 +338,83 @@ Both are covered in depth in [`docs/restclient_and_cache.md`](docs/restclient_an
|
|
|
333
338
|
|
|
334
339
|
Needs `[restclient]`.
|
|
335
340
|
|
|
341
|
+
## Mail
|
|
342
|
+
|
|
343
|
+
Sending mail from an application without it knowing which relay carries it.
|
|
344
|
+
The kit ships the machinery and no templates: the words belong to the app that
|
|
345
|
+
sends them.
|
|
346
|
+
|
|
347
|
+
```python
|
|
348
|
+
from persistence_kit.mail import MailMessage, build_mailer
|
|
349
|
+
|
|
350
|
+
mailer = build_mailer("app/templates/mail")
|
|
351
|
+
|
|
352
|
+
message = mailer.compose(
|
|
353
|
+
"user_roles_changed",
|
|
354
|
+
to="ana@udea.edu.co",
|
|
355
|
+
cc=["coordinacion@udea.edu.co"],
|
|
356
|
+
bcc="auditoria@udea.edu.co",
|
|
357
|
+
context={"name": "Ana", "roles": ["Préstamos", "Reportes"]},
|
|
358
|
+
)
|
|
359
|
+
mailer.dispatch(message)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
A template is a folder in the app's directory, `user_roles_changed/`, with
|
|
363
|
+
`subject.txt` (required) and `body.html` or `body.txt` (at least one). The same
|
|
364
|
+
files can also live loose in the directory as `user_roles_changed.subject.txt`,
|
|
365
|
+
`user_roles_changed.html` and `user_roles_changed.txt`; the folder wins when both
|
|
366
|
+
exist. They are Jinja2, so a layout can be shared with `{% extends "shared/layout.html" %}`,
|
|
367
|
+
resolved from the directory root. HTML is autoescaped, and a variable missing from
|
|
368
|
+
the context fails instead of rendering blank.
|
|
369
|
+
|
|
370
|
+
```text
|
|
371
|
+
templates/mail/
|
|
372
|
+
shared/layout.html
|
|
373
|
+
images/logo.png
|
|
374
|
+
user_roles_changed/
|
|
375
|
+
subject.txt
|
|
376
|
+
body.html
|
|
377
|
+
body.txt
|
|
378
|
+
sample.json
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
|
|
382
|
+
Blind copies travel in the SMTP envelope only and never appear in the headers.
|
|
383
|
+
Attachments go in `attachments` as `MailAttachment(filename, content, content_type)`.
|
|
384
|
+
|
|
385
|
+
`compose` raises on a broken template, so a test that composes every template
|
|
386
|
+
catches it. `dispatch` delivers in the background: if the relay fails, the error
|
|
387
|
+
is logged and the request that triggered the mail carries on. Use `await
|
|
388
|
+
mailer.send(message)` when the caller has to know, and `await
|
|
389
|
+
mailer.wait_for_pending()` in tests or at shutdown.
|
|
390
|
+
|
|
391
|
+
Images are embedded, not linked: `<img src="cid:logo.png">` in a template attaches
|
|
392
|
+
`images/logo.png` from the same directory as an inline part, so it shows even where
|
|
393
|
+
remote images are blocked. `compose` adds it on its own; a missing file or one that
|
|
394
|
+
is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
|
|
395
|
+
does the same for images the caller builds.
|
|
396
|
+
|
|
397
|
+
A template can carry a fourth file, `sample.json` (`user_roles_changed.sample.json`
|
|
398
|
+
when loose), with an example context. `MailTemplates.names()` lists the templates and
|
|
399
|
+
`sample_context(name)` reads that file, so one parametrized test covers them all.
|
|
400
|
+
The same sample drives the preview, which renders without sending:
|
|
401
|
+
|
|
402
|
+
```bash
|
|
403
|
+
python -m persistence_kit.mail.preview app/templates/mail user_roles_changed
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
It writes the `.html` and the `.txt` to a temporary folder, prints the subject
|
|
407
|
+
and opens the HTML in the browser. Without names it renders every template;
|
|
408
|
+
`--output` changes the folder and `--no-open` skips the browser.
|
|
409
|
+
|
|
410
|
+
The sender is chosen by `MAIL_BACKEND`. `memory` keeps messages in
|
|
411
|
+
`MemoryMailSender.sent`, for tests and for local runs where no relay accepts you.
|
|
412
|
+
`smtp` delivers through `SmtpMailSender`, which upgrades with STARTTLS and logs in
|
|
413
|
+
only if `MAIL_SMTP_USERNAME` is set, so a relay that authorizes by IP works as is.
|
|
414
|
+
|
|
415
|
+
Needs `[mail]` for templates. `MailMessage` and the senders use only the standard
|
|
416
|
+
library.
|
|
417
|
+
|
|
336
418
|
## Resilience
|
|
337
419
|
|
|
338
420
|
`CircuitBreaker` is domain-agnostic and shared by anything that calls out: after
|
|
@@ -357,7 +439,7 @@ class Settings(PersistenceKitSettings):
|
|
|
357
439
|
```
|
|
358
440
|
|
|
359
441
|
The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
360
|
-
`get_export_storage`, `get_cache`.
|
|
442
|
+
`get_export_storage`, `get_cache`, `get_mail_sender`.
|
|
361
443
|
|
|
362
444
|
### Environment variables
|
|
363
445
|
|
|
@@ -375,6 +457,12 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
|
375
457
|
| `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
|
|
376
458
|
| `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
|
|
377
459
|
| `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
|
|
460
|
+
| `MAIL_BACKEND` | `memory` (default) or `smtp` |
|
|
461
|
+
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | Default sender. The address is required by `smtp` |
|
|
462
|
+
| `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT` | The relay. Port defaults to `587` |
|
|
463
|
+
| `MAIL_SMTP_STARTTLS` | Upgrade the connection with STARTTLS. Defaults to `true` |
|
|
464
|
+
| `MAIL_SMTP_USERNAME`, `MAIL_SMTP_PASSWORD` | Only for relays that require a login |
|
|
465
|
+
| `MAIL_SMTP_TIMEOUT_SECONDS` | Connection and command timeout. Defaults to `30` |
|
|
378
466
|
|
|
379
467
|
## Wiring it into an application
|
|
380
468
|
|
|
@@ -498,11 +586,11 @@ poetry install --with dev --all-extras
|
|
|
498
586
|
poetry run pytest -q
|
|
499
587
|
```
|
|
500
588
|
|
|
501
|
-
Current baseline: **
|
|
589
|
+
Current baseline: **627 tests passing** (version 3.14.0). Async tests use
|
|
502
590
|
`pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
|
|
503
591
|
|
|
504
592
|
`tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
|
|
505
|
-
importing `persistence_kit` drags in fastapi, pyjwt or
|
|
593
|
+
importing `persistence_kit` drags in fastapi, pyjwt, boto3 or jinja2.
|
|
506
594
|
|
|
507
595
|
## Releasing
|
|
508
596
|
|
|
@@ -106,7 +106,8 @@ pip install "persistence-kit[api,security,restclient]"
|
|
|
106
106
|
| `dynamodb` | DynamoDB repository backend | boto3 |
|
|
107
107
|
| `restclient` | The REST client and its cache | httpx |
|
|
108
108
|
| `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
|
|
109
|
-
| `
|
|
109
|
+
| `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
|
|
110
|
+
| `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
|
|
110
111
|
| `all` | Every optional capability | all of the above |
|
|
111
112
|
|
|
112
113
|
Importing `persistence_kit` never loads an optional dependency by itself. Ask for
|
|
@@ -125,6 +126,7 @@ add, not an obscure `ImportError`.
|
|
|
125
126
|
| `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
|
|
126
127
|
| `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
|
|
127
128
|
| `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
|
|
129
|
+
| `mail/` | Outbound mail through an SMTP relay, with templates that live in the app |
|
|
128
130
|
| `resilience/` | Circuit breaker, shared by anything that calls out |
|
|
129
131
|
| `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
|
|
130
132
|
| `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
|
|
@@ -287,6 +289,83 @@ Both are covered in depth in [`docs/restclient_and_cache.md`](docs/restclient_an
|
|
|
287
289
|
|
|
288
290
|
Needs `[restclient]`.
|
|
289
291
|
|
|
292
|
+
## Mail
|
|
293
|
+
|
|
294
|
+
Sending mail from an application without it knowing which relay carries it.
|
|
295
|
+
The kit ships the machinery and no templates: the words belong to the app that
|
|
296
|
+
sends them.
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
from persistence_kit.mail import MailMessage, build_mailer
|
|
300
|
+
|
|
301
|
+
mailer = build_mailer("app/templates/mail")
|
|
302
|
+
|
|
303
|
+
message = mailer.compose(
|
|
304
|
+
"user_roles_changed",
|
|
305
|
+
to="ana@udea.edu.co",
|
|
306
|
+
cc=["coordinacion@udea.edu.co"],
|
|
307
|
+
bcc="auditoria@udea.edu.co",
|
|
308
|
+
context={"name": "Ana", "roles": ["Préstamos", "Reportes"]},
|
|
309
|
+
)
|
|
310
|
+
mailer.dispatch(message)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
A template is a folder in the app's directory, `user_roles_changed/`, with
|
|
314
|
+
`subject.txt` (required) and `body.html` or `body.txt` (at least one). The same
|
|
315
|
+
files can also live loose in the directory as `user_roles_changed.subject.txt`,
|
|
316
|
+
`user_roles_changed.html` and `user_roles_changed.txt`; the folder wins when both
|
|
317
|
+
exist. They are Jinja2, so a layout can be shared with `{% extends "shared/layout.html" %}`,
|
|
318
|
+
resolved from the directory root. HTML is autoescaped, and a variable missing from
|
|
319
|
+
the context fails instead of rendering blank.
|
|
320
|
+
|
|
321
|
+
```text
|
|
322
|
+
templates/mail/
|
|
323
|
+
shared/layout.html
|
|
324
|
+
images/logo.png
|
|
325
|
+
user_roles_changed/
|
|
326
|
+
subject.txt
|
|
327
|
+
body.html
|
|
328
|
+
body.txt
|
|
329
|
+
sample.json
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
|
|
333
|
+
Blind copies travel in the SMTP envelope only and never appear in the headers.
|
|
334
|
+
Attachments go in `attachments` as `MailAttachment(filename, content, content_type)`.
|
|
335
|
+
|
|
336
|
+
`compose` raises on a broken template, so a test that composes every template
|
|
337
|
+
catches it. `dispatch` delivers in the background: if the relay fails, the error
|
|
338
|
+
is logged and the request that triggered the mail carries on. Use `await
|
|
339
|
+
mailer.send(message)` when the caller has to know, and `await
|
|
340
|
+
mailer.wait_for_pending()` in tests or at shutdown.
|
|
341
|
+
|
|
342
|
+
Images are embedded, not linked: `<img src="cid:logo.png">` in a template attaches
|
|
343
|
+
`images/logo.png` from the same directory as an inline part, so it shows even where
|
|
344
|
+
remote images are blocked. `compose` adds it on its own; a missing file or one that
|
|
345
|
+
is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
|
|
346
|
+
does the same for images the caller builds.
|
|
347
|
+
|
|
348
|
+
A template can carry a fourth file, `sample.json` (`user_roles_changed.sample.json`
|
|
349
|
+
when loose), with an example context. `MailTemplates.names()` lists the templates and
|
|
350
|
+
`sample_context(name)` reads that file, so one parametrized test covers them all.
|
|
351
|
+
The same sample drives the preview, which renders without sending:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
python -m persistence_kit.mail.preview app/templates/mail user_roles_changed
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
It writes the `.html` and the `.txt` to a temporary folder, prints the subject
|
|
358
|
+
and opens the HTML in the browser. Without names it renders every template;
|
|
359
|
+
`--output` changes the folder and `--no-open` skips the browser.
|
|
360
|
+
|
|
361
|
+
The sender is chosen by `MAIL_BACKEND`. `memory` keeps messages in
|
|
362
|
+
`MemoryMailSender.sent`, for tests and for local runs where no relay accepts you.
|
|
363
|
+
`smtp` delivers through `SmtpMailSender`, which upgrades with STARTTLS and logs in
|
|
364
|
+
only if `MAIL_SMTP_USERNAME` is set, so a relay that authorizes by IP works as is.
|
|
365
|
+
|
|
366
|
+
Needs `[mail]` for templates. `MailMessage` and the senders use only the standard
|
|
367
|
+
library.
|
|
368
|
+
|
|
290
369
|
## Resilience
|
|
291
370
|
|
|
292
371
|
`CircuitBreaker` is domain-agnostic and shared by anything that calls out: after
|
|
@@ -311,7 +390,7 @@ class Settings(PersistenceKitSettings):
|
|
|
311
390
|
```
|
|
312
391
|
|
|
313
392
|
The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
314
|
-
`get_export_storage`, `get_cache`.
|
|
393
|
+
`get_export_storage`, `get_cache`, `get_mail_sender`.
|
|
315
394
|
|
|
316
395
|
### Environment variables
|
|
317
396
|
|
|
@@ -329,6 +408,12 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
|
329
408
|
| `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
|
|
330
409
|
| `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
|
|
331
410
|
| `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
|
|
411
|
+
| `MAIL_BACKEND` | `memory` (default) or `smtp` |
|
|
412
|
+
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | Default sender. The address is required by `smtp` |
|
|
413
|
+
| `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT` | The relay. Port defaults to `587` |
|
|
414
|
+
| `MAIL_SMTP_STARTTLS` | Upgrade the connection with STARTTLS. Defaults to `true` |
|
|
415
|
+
| `MAIL_SMTP_USERNAME`, `MAIL_SMTP_PASSWORD` | Only for relays that require a login |
|
|
416
|
+
| `MAIL_SMTP_TIMEOUT_SECONDS` | Connection and command timeout. Defaults to `30` |
|
|
332
417
|
|
|
333
418
|
## Wiring it into an application
|
|
334
419
|
|
|
@@ -452,11 +537,11 @@ poetry install --with dev --all-extras
|
|
|
452
537
|
poetry run pytest -q
|
|
453
538
|
```
|
|
454
539
|
|
|
455
|
-
Current baseline: **
|
|
540
|
+
Current baseline: **627 tests passing** (version 3.14.0). Async tests use
|
|
456
541
|
`pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
|
|
457
542
|
|
|
458
543
|
`tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
|
|
459
|
-
importing `persistence_kit` drags in fastapi, pyjwt or
|
|
544
|
+
importing `persistence_kit` drags in fastapi, pyjwt, boto3 or jinja2.
|
|
460
545
|
|
|
461
546
|
## Releasing
|
|
462
547
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
|
|
3
|
+
from persistence_kit.mail.contracts import MailSender
|
|
4
|
+
from persistence_kit.mail.errors import (
|
|
5
|
+
MailConfigError,
|
|
6
|
+
MailDeliveryError,
|
|
7
|
+
MailError,
|
|
8
|
+
MailMessageError,
|
|
9
|
+
MailRecipientsRefusedError,
|
|
10
|
+
MailTemplateError,
|
|
11
|
+
)
|
|
12
|
+
from persistence_kit.mail.factory import build_mailer, get_mail_sender, reset_mail_sender
|
|
13
|
+
from persistence_kit.mail.message import MailAddress, MailAttachment, MailMessage
|
|
14
|
+
from persistence_kit.settings.mail_settings import MailBackend, MailSettings
|
|
15
|
+
|
|
16
|
+
_LAZY_EXPORTS = {
|
|
17
|
+
"MemoryMailSender": ("persistence_kit.mail.memory", "MemoryMailSender"),
|
|
18
|
+
"SmtpMailSender": ("persistence_kit.mail.smtp", "SmtpMailSender"),
|
|
19
|
+
"MailTemplates": ("persistence_kit.mail.templates", "MailTemplates"),
|
|
20
|
+
"RenderedMail": ("persistence_kit.mail.templates", "RenderedMail"),
|
|
21
|
+
"Mailer": ("persistence_kit.mail.mailer", "Mailer"),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"MailSender",
|
|
26
|
+
"MailAddress",
|
|
27
|
+
"MailAttachment",
|
|
28
|
+
"MailMessage",
|
|
29
|
+
"MailError",
|
|
30
|
+
"MailConfigError",
|
|
31
|
+
"MailMessageError",
|
|
32
|
+
"MailTemplateError",
|
|
33
|
+
"MailDeliveryError",
|
|
34
|
+
"MailRecipientsRefusedError",
|
|
35
|
+
"MailBackend",
|
|
36
|
+
"MailSettings",
|
|
37
|
+
"get_mail_sender",
|
|
38
|
+
"build_mailer",
|
|
39
|
+
"reset_mail_sender",
|
|
40
|
+
*_LAZY_EXPORTS,
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def __getattr__(name: str):
|
|
45
|
+
if name not in _LAZY_EXPORTS:
|
|
46
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
47
|
+
module_name, attr_name = _LAZY_EXPORTS[name]
|
|
48
|
+
try:
|
|
49
|
+
module = import_module(module_name)
|
|
50
|
+
except ModuleNotFoundError as exc:
|
|
51
|
+
raise ModuleNotFoundError(
|
|
52
|
+
f"{name} requires the optional capability 'mail'. "
|
|
53
|
+
"Install it with: pip install 'persistence-kit[mail]'"
|
|
54
|
+
) from exc
|
|
55
|
+
value = getattr(module, attr_name)
|
|
56
|
+
globals()[name] = value
|
|
57
|
+
return value
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class MailError(Exception):
|
|
2
|
+
"""Base exception for the mail capability."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MailConfigError(MailError):
|
|
6
|
+
"""Raised when the mail backend is not configured correctly."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MailMessageError(MailError, ValueError):
|
|
10
|
+
"""Raised when a message cannot be built: no recipients, no body or unsafe header values."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MailTemplateError(MailError):
|
|
14
|
+
"""Raised when a mail template is missing or fails to render."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MailDeliveryError(MailError):
|
|
18
|
+
"""Raised when the relay cannot be reached or rejects the message."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MailRecipientsRefusedError(MailDeliveryError):
|
|
22
|
+
"""Raised when the relay accepted the message for some recipients and refused the rest."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, refused: dict[str, tuple[int, bytes]]) -> None:
|
|
25
|
+
self.refused = refused
|
|
26
|
+
super().__init__(f"The relay refused {len(refused)} recipient(s): {', '.join(sorted(refused))}")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from persistence_kit.mail.contracts import MailSender
|
|
8
|
+
from persistence_kit.mail.errors import MailConfigError
|
|
9
|
+
from persistence_kit.mail.message import MailAddress
|
|
10
|
+
from persistence_kit.settings.mail_settings import MailBackend, MailSettings
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from persistence_kit.mail.mailer import Mailer
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _configured_sender(settings: MailSettings) -> MailAddress | None:
|
|
17
|
+
if not settings.mail_from_address:
|
|
18
|
+
return None
|
|
19
|
+
return MailAddress(email=settings.mail_from_address, name=settings.mail_from_name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lru_cache
|
|
23
|
+
def _memory_sender_cached(default_sender: MailAddress | None) -> MailSender:
|
|
24
|
+
from persistence_kit.mail.memory import MemoryMailSender
|
|
25
|
+
|
|
26
|
+
if default_sender is None:
|
|
27
|
+
return MemoryMailSender()
|
|
28
|
+
return MemoryMailSender(default_sender=default_sender)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@lru_cache
|
|
32
|
+
def _smtp_sender_cached(
|
|
33
|
+
host: str,
|
|
34
|
+
port: int,
|
|
35
|
+
starttls: bool,
|
|
36
|
+
username: str | None,
|
|
37
|
+
password: str | None,
|
|
38
|
+
timeout_seconds: float,
|
|
39
|
+
default_sender: MailAddress,
|
|
40
|
+
) -> MailSender:
|
|
41
|
+
from persistence_kit.mail.smtp import SmtpMailSender
|
|
42
|
+
|
|
43
|
+
return SmtpMailSender(
|
|
44
|
+
host=host,
|
|
45
|
+
port=port,
|
|
46
|
+
starttls=starttls,
|
|
47
|
+
username=username,
|
|
48
|
+
password=password,
|
|
49
|
+
timeout_seconds=timeout_seconds,
|
|
50
|
+
default_sender=default_sender,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_mail_sender(settings: MailSettings | None = None) -> MailSender:
|
|
55
|
+
"""Returns the sender chosen by `MAIL_BACKEND`; `smtp` requires a host and a from address."""
|
|
56
|
+
settings = settings or MailSettings()
|
|
57
|
+
default_sender = _configured_sender(settings)
|
|
58
|
+
|
|
59
|
+
if settings.mail_backend == MailBackend.MEMORY:
|
|
60
|
+
return _memory_sender_cached(default_sender)
|
|
61
|
+
|
|
62
|
+
if settings.mail_backend == MailBackend.SMTP:
|
|
63
|
+
if not settings.mail_smtp_host:
|
|
64
|
+
raise MailConfigError("MAIL_SMTP_HOST is required when MAIL_BACKEND is smtp.")
|
|
65
|
+
if default_sender is None:
|
|
66
|
+
raise MailConfigError("MAIL_FROM_ADDRESS is required when MAIL_BACKEND is smtp.")
|
|
67
|
+
return _smtp_sender_cached(
|
|
68
|
+
settings.mail_smtp_host,
|
|
69
|
+
settings.mail_smtp_port,
|
|
70
|
+
settings.mail_smtp_starttls,
|
|
71
|
+
settings.mail_smtp_username,
|
|
72
|
+
settings.mail_smtp_password,
|
|
73
|
+
settings.mail_smtp_timeout_seconds,
|
|
74
|
+
default_sender,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
backend = getattr(settings.mail_backend, "value", settings.mail_backend)
|
|
78
|
+
raise MailConfigError(f"Unsupported mail backend: '{backend}'.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_mailer(templates_dir: str | Path, settings: MailSettings | None = None) -> Mailer:
|
|
82
|
+
"""A `Mailer` over the configured sender and the application's template directory."""
|
|
83
|
+
from persistence_kit.mail.mailer import Mailer
|
|
84
|
+
from persistence_kit.mail.templates import MailTemplates
|
|
85
|
+
|
|
86
|
+
return Mailer(sender=get_mail_sender(settings), templates=MailTemplates(templates_dir))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reset_mail_sender() -> None:
|
|
90
|
+
_memory_sender_cached.cache_clear()
|
|
91
|
+
_smtp_sender_cached.cache_clear()
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from persistence_kit.mail.contracts import MailSender
|
|
9
|
+
from persistence_kit.mail.message import MailAddress, MailAttachment, MailMessage, Recipients
|
|
10
|
+
from persistence_kit.mail.templates import MailTemplates
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Mailer:
|
|
16
|
+
"""Composes messages from the application's templates and sends them.
|
|
17
|
+
|
|
18
|
+
`compose` raises on a broken template, so it shows up in tests. `dispatch` delivers in the
|
|
19
|
+
background: a relay failure is logged and never reaches the request that triggered it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, sender: MailSender, templates: MailTemplates) -> None:
|
|
23
|
+
self._sender = sender
|
|
24
|
+
self._templates = templates
|
|
25
|
+
self._pending: set[asyncio.Task[None]] = set()
|
|
26
|
+
|
|
27
|
+
def compose(
|
|
28
|
+
self,
|
|
29
|
+
template: str,
|
|
30
|
+
*,
|
|
31
|
+
to: Recipients = (),
|
|
32
|
+
cc: Recipients = (),
|
|
33
|
+
bcc: Recipients = (),
|
|
34
|
+
reply_to: Recipients = (),
|
|
35
|
+
context: Mapping[str, Any] | None = None,
|
|
36
|
+
attachments: tuple[MailAttachment, ...] = (),
|
|
37
|
+
sender: str | MailAddress | None = None,
|
|
38
|
+
) -> MailMessage:
|
|
39
|
+
rendered = self._templates.render(template, context)
|
|
40
|
+
return MailMessage(
|
|
41
|
+
subject=rendered.subject,
|
|
42
|
+
html=rendered.html,
|
|
43
|
+
text=rendered.text,
|
|
44
|
+
to=to,
|
|
45
|
+
cc=cc,
|
|
46
|
+
bcc=bcc,
|
|
47
|
+
reply_to=reply_to,
|
|
48
|
+
attachments=(*rendered.inline_images, *attachments),
|
|
49
|
+
sender=sender,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
async def send(self, message: MailMessage) -> None:
|
|
53
|
+
await self._sender.send(message)
|
|
54
|
+
|
|
55
|
+
def dispatch(self, message: MailMessage) -> asyncio.Task[None]:
|
|
56
|
+
task = asyncio.create_task(self._send_logging_failures(message))
|
|
57
|
+
self._pending.add(task)
|
|
58
|
+
task.add_done_callback(self._pending.discard)
|
|
59
|
+
return task
|
|
60
|
+
|
|
61
|
+
async def wait_for_pending(self) -> None:
|
|
62
|
+
"""Waits for every dispatched message, for tests and for a clean shutdown."""
|
|
63
|
+
if self._pending:
|
|
64
|
+
await asyncio.gather(*self._pending, return_exceptions=True)
|
|
65
|
+
|
|
66
|
+
async def _send_logging_failures(self, message: MailMessage) -> None:
|
|
67
|
+
try:
|
|
68
|
+
await self._sender.send(message)
|
|
69
|
+
except Exception:
|
|
70
|
+
logger.exception(
|
|
71
|
+
"mail.dispatch_failed subject=%r recipients=%d",
|
|
72
|
+
message.subject,
|
|
73
|
+
len(message.envelope_recipients),
|
|
74
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from email.message import EmailMessage
|
|
4
|
+
|
|
5
|
+
from persistence_kit.mail.message import MailAddress, MailMessage
|
|
6
|
+
from persistence_kit.mail.mime import build_mime_message
|
|
7
|
+
|
|
8
|
+
DEFAULT_MEMORY_SENDER = MailAddress(email="no-reply@localhost.localdomain")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MemoryMailSender:
|
|
12
|
+
"""Keeps every message in process instead of delivering it. Used by tests and local runs.
|
|
13
|
+
|
|
14
|
+
It still builds the MIME message, so a message the relay would reject fails here too.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *, default_sender: MailAddress = DEFAULT_MEMORY_SENDER) -> None:
|
|
18
|
+
self._default_sender = default_sender
|
|
19
|
+
self.sent: list[MailMessage] = []
|
|
20
|
+
self.mime: list[EmailMessage] = []
|
|
21
|
+
|
|
22
|
+
async def send(self, message: MailMessage) -> None:
|
|
23
|
+
self.mime.append(build_mime_message(message, default_sender=self._default_sender))
|
|
24
|
+
self.sent.append(message)
|
|
25
|
+
|
|
26
|
+
def clear(self) -> None:
|
|
27
|
+
self.sent.clear()
|
|
28
|
+
self.mime.clear()
|