persistence-kit 3.12.0__tar.gz → 3.13.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.13.0}/PKG-INFO +78 -5
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/README.md +75 -4
- persistence_kit-3.13.0/persistence_kit/mail/__init__.py +57 -0
- persistence_kit-3.13.0/persistence_kit/mail/contracts.py +9 -0
- persistence_kit-3.13.0/persistence_kit/mail/errors.py +26 -0
- persistence_kit-3.13.0/persistence_kit/mail/factory.py +91 -0
- persistence_kit-3.13.0/persistence_kit/mail/mailer.py +74 -0
- persistence_kit-3.13.0/persistence_kit/mail/memory.py +28 -0
- persistence_kit-3.13.0/persistence_kit/mail/message.py +121 -0
- persistence_kit-3.13.0/persistence_kit/mail/mime.py +64 -0
- persistence_kit-3.13.0/persistence_kit/mail/preview.py +84 -0
- persistence_kit-3.13.0/persistence_kit/mail/smtp.py +57 -0
- persistence_kit-3.13.0/persistence_kit/mail/templates.py +133 -0
- persistence_kit-3.13.0/persistence_kit/settings/mail_settings.py +24 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/pyproject.toml +5 -3
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/LICENSE +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/common.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/encrypted_middleware.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/encrypted_routes.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/error_handlers.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/exceptions.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/rate_limit.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/route_loader.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/authenticated_user.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/configuration.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/seeders.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/startup.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/contracts.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/dynamodb.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/memory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/mongo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/namespaced.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/contracts/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/contracts/repository.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/contracts/view_repository.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/py.typed +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/filter_ops.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/resilience/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/resilience/circuit.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/aggregate.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/api_key.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/base.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/basic.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/bearer.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/login.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/caching.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/client.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/config.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/contracts.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/errors.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/mapping.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/memory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/payload.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/populate.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/provider.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/registry.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/resolver.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/retry.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/encrypted/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/encrypted/envelope.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/encrypted/errors.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/ports.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/encryption_provider.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/registration.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/app_settings.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/cache_settings.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/constants.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/parsers.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/repo_settings.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/contracts.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/errors.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/factory.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/local.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/media.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/routes.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/s3.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/utils/__init__.py +0 -0
- {persistence_kit-3.12.0 → persistence_kit-3.13.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.13.0
|
|
4
4
|
Summary: Reusable persistence and repository toolkit
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -22,6 +22,7 @@ Provides-Extra: all
|
|
|
22
22
|
Provides-Extra: api
|
|
23
23
|
Provides-Extra: dynamodb
|
|
24
24
|
Provides-Extra: encrypted
|
|
25
|
+
Provides-Extra: mail
|
|
25
26
|
Provides-Extra: restclient
|
|
26
27
|
Provides-Extra: security
|
|
27
28
|
Provides-Extra: security-cognito
|
|
@@ -33,6 +34,7 @@ Requires-Dist: boto3 (>=1.35.0,<2.0.0) ; extra == "storage-s3" or extra == "secu
|
|
|
33
34
|
Requires-Dist: cryptography (>=42.0.0,<43.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
|
|
34
35
|
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
36
|
Requires-Dist: httpx (>=0.28.0,<1.0.0) ; extra == "restclient" or extra == "testing" or extra == "all"
|
|
37
|
+
Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "mail" or extra == "testing" or extra == "all"
|
|
36
38
|
Requires-Dist: motor (>=3.7.1,<4.0.0)
|
|
37
39
|
Requires-Dist: pydantic-settings (>=2.3.0,<3.0.0)
|
|
38
40
|
Requires-Dist: pyjwt[crypto] (>=2.10.1,<3.0.0) ; extra == "security" or extra == "security-cognito" or extra == "testing" or extra == "all"
|
|
@@ -152,7 +154,8 @@ pip install "persistence-kit[api,security,restclient]"
|
|
|
152
154
|
| `dynamodb` | DynamoDB repository backend | boto3 |
|
|
153
155
|
| `restclient` | The REST client and its cache | httpx |
|
|
154
156
|
| `encrypted` | Encrypted request and response payloads | fastapi, cryptography |
|
|
155
|
-
| `
|
|
157
|
+
| `mail` | Mail templates rendered with Jinja2 (sending itself needs no extra) | jinja2 |
|
|
158
|
+
| `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography, jinja2 |
|
|
156
159
|
| `all` | Every optional capability | all of the above |
|
|
157
160
|
|
|
158
161
|
Importing `persistence_kit` never loads an optional dependency by itself. Ask for
|
|
@@ -171,6 +174,7 @@ add, not an obscure `ImportError`.
|
|
|
171
174
|
| `security/` | Identity providers and JWT verifiers, memory or Cognito, and the encrypted envelope format |
|
|
172
175
|
| `restclient/` | HTTP client with pluggable auth, endpoint resolution, retries and DTO decoding |
|
|
173
176
|
| `cache/` | Key-value cache with TTL: memory, Mongo or DynamoDB |
|
|
177
|
+
| `mail/` | Outbound mail through an SMTP relay, with templates that live in the app |
|
|
174
178
|
| `resilience/` | Circuit breaker, shared by anything that calls out |
|
|
175
179
|
| `api/` | Reusable FastAPI pieces: error handlers, pagination, rate limiting, encrypted routes |
|
|
176
180
|
| `bootstrap/` | Startup helpers, configuration registry, seed orchestration |
|
|
@@ -333,6 +337,69 @@ Both are covered in depth in [`docs/restclient_and_cache.md`](docs/restclient_an
|
|
|
333
337
|
|
|
334
338
|
Needs `[restclient]`.
|
|
335
339
|
|
|
340
|
+
## Mail
|
|
341
|
+
|
|
342
|
+
Sending mail from an application without it knowing which relay carries it.
|
|
343
|
+
The kit ships the machinery and no templates: the words belong to the app that
|
|
344
|
+
sends them.
|
|
345
|
+
|
|
346
|
+
```python
|
|
347
|
+
from persistence_kit.mail import MailMessage, build_mailer
|
|
348
|
+
|
|
349
|
+
mailer = build_mailer("app/templates/mail")
|
|
350
|
+
|
|
351
|
+
message = mailer.compose(
|
|
352
|
+
"user_roles_changed",
|
|
353
|
+
to="ana@udea.edu.co",
|
|
354
|
+
cc=["coordinacion@udea.edu.co"],
|
|
355
|
+
bcc="auditoria@udea.edu.co",
|
|
356
|
+
context={"name": "Ana", "roles": ["Préstamos", "Reportes"]},
|
|
357
|
+
)
|
|
358
|
+
mailer.dispatch(message)
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
A template is up to three files in the app's directory: `user_roles_changed.subject.txt`
|
|
362
|
+
(required), and `user_roles_changed.html` or `user_roles_changed.txt` (at least one).
|
|
363
|
+
They are Jinja2, so a layout can be shared with `{% extends %}`. HTML is autoescaped,
|
|
364
|
+
and a variable missing from the context fails instead of rendering blank.
|
|
365
|
+
|
|
366
|
+
`to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
|
|
367
|
+
Blind copies travel in the SMTP envelope only and never appear in the headers.
|
|
368
|
+
Attachments go in `attachments` as `MailAttachment(filename, content, content_type)`.
|
|
369
|
+
|
|
370
|
+
`compose` raises on a broken template, so a test that composes every template
|
|
371
|
+
catches it. `dispatch` delivers in the background: if the relay fails, the error
|
|
372
|
+
is logged and the request that triggered the mail carries on. Use `await
|
|
373
|
+
mailer.send(message)` when the caller has to know, and `await
|
|
374
|
+
mailer.wait_for_pending()` in tests or at shutdown.
|
|
375
|
+
|
|
376
|
+
Images are embedded, not linked: `<img src="cid:logo.png">` in a template attaches
|
|
377
|
+
`images/logo.png` from the same directory as an inline part, so it shows even where
|
|
378
|
+
remote images are blocked. `compose` adds it on its own; a missing file or one that
|
|
379
|
+
is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
|
|
380
|
+
does the same for images the caller builds.
|
|
381
|
+
|
|
382
|
+
A template can carry a fourth file, `user_roles_changed.sample.json`, with an
|
|
383
|
+
example context. `MailTemplates.names()` lists the templates and
|
|
384
|
+
`sample_context(name)` reads that file, so one parametrized test covers them all.
|
|
385
|
+
The same sample drives the preview, which renders without sending:
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
python -m persistence_kit.mail.preview app/templates/mail user_roles_changed
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
It writes the `.html` and the `.txt` to a temporary folder, prints the subject
|
|
392
|
+
and opens the HTML in the browser. Without names it renders every template;
|
|
393
|
+
`--output` changes the folder and `--no-open` skips the browser.
|
|
394
|
+
|
|
395
|
+
The sender is chosen by `MAIL_BACKEND`. `memory` keeps messages in
|
|
396
|
+
`MemoryMailSender.sent`, for tests and for local runs where no relay accepts you.
|
|
397
|
+
`smtp` delivers through `SmtpMailSender`, which upgrades with STARTTLS and logs in
|
|
398
|
+
only if `MAIL_SMTP_USERNAME` is set, so a relay that authorizes by IP works as is.
|
|
399
|
+
|
|
400
|
+
Needs `[mail]` for templates. `MailMessage` and the senders use only the standard
|
|
401
|
+
library.
|
|
402
|
+
|
|
336
403
|
## Resilience
|
|
337
404
|
|
|
338
405
|
`CircuitBreaker` is domain-agnostic and shared by anything that calls out: after
|
|
@@ -357,7 +424,7 @@ class Settings(PersistenceKitSettings):
|
|
|
357
424
|
```
|
|
358
425
|
|
|
359
426
|
The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
360
|
-
`get_export_storage`, `get_cache`.
|
|
427
|
+
`get_export_storage`, `get_cache`, `get_mail_sender`.
|
|
361
428
|
|
|
362
429
|
### Environment variables
|
|
363
430
|
|
|
@@ -375,6 +442,12 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
|
375
442
|
| `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
|
|
376
443
|
| `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
|
|
377
444
|
| `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
|
|
445
|
+
| `MAIL_BACKEND` | `memory` (default) or `smtp` |
|
|
446
|
+
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | Default sender. The address is required by `smtp` |
|
|
447
|
+
| `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT` | The relay. Port defaults to `587` |
|
|
448
|
+
| `MAIL_SMTP_STARTTLS` | Upgrade the connection with STARTTLS. Defaults to `true` |
|
|
449
|
+
| `MAIL_SMTP_USERNAME`, `MAIL_SMTP_PASSWORD` | Only for relays that require a login |
|
|
450
|
+
| `MAIL_SMTP_TIMEOUT_SECONDS` | Connection and command timeout. Defaults to `30` |
|
|
378
451
|
|
|
379
452
|
## Wiring it into an application
|
|
380
453
|
|
|
@@ -498,11 +571,11 @@ poetry install --with dev --all-extras
|
|
|
498
571
|
poetry run pytest -q
|
|
499
572
|
```
|
|
500
573
|
|
|
501
|
-
Current baseline: **
|
|
574
|
+
Current baseline: **621 tests passing** (version 3.13.0). Async tests use
|
|
502
575
|
`pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
|
|
503
576
|
|
|
504
577
|
`tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
|
|
505
|
-
importing `persistence_kit` drags in fastapi, pyjwt or
|
|
578
|
+
importing `persistence_kit` drags in fastapi, pyjwt, boto3 or jinja2.
|
|
506
579
|
|
|
507
580
|
## Releasing
|
|
508
581
|
|
|
@@ -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,69 @@ 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 up to three files in the app's directory: `user_roles_changed.subject.txt`
|
|
314
|
+
(required), and `user_roles_changed.html` or `user_roles_changed.txt` (at least one).
|
|
315
|
+
They are Jinja2, so a layout can be shared with `{% extends %}`. HTML is autoescaped,
|
|
316
|
+
and a variable missing from the context fails instead of rendering blank.
|
|
317
|
+
|
|
318
|
+
`to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
|
|
319
|
+
Blind copies travel in the SMTP envelope only and never appear in the headers.
|
|
320
|
+
Attachments go in `attachments` as `MailAttachment(filename, content, content_type)`.
|
|
321
|
+
|
|
322
|
+
`compose` raises on a broken template, so a test that composes every template
|
|
323
|
+
catches it. `dispatch` delivers in the background: if the relay fails, the error
|
|
324
|
+
is logged and the request that triggered the mail carries on. Use `await
|
|
325
|
+
mailer.send(message)` when the caller has to know, and `await
|
|
326
|
+
mailer.wait_for_pending()` in tests or at shutdown.
|
|
327
|
+
|
|
328
|
+
Images are embedded, not linked: `<img src="cid:logo.png">` in a template attaches
|
|
329
|
+
`images/logo.png` from the same directory as an inline part, so it shows even where
|
|
330
|
+
remote images are blocked. `compose` adds it on its own; a missing file or one that
|
|
331
|
+
is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
|
|
332
|
+
does the same for images the caller builds.
|
|
333
|
+
|
|
334
|
+
A template can carry a fourth file, `user_roles_changed.sample.json`, with an
|
|
335
|
+
example context. `MailTemplates.names()` lists the templates and
|
|
336
|
+
`sample_context(name)` reads that file, so one parametrized test covers them all.
|
|
337
|
+
The same sample drives the preview, which renders without sending:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
python -m persistence_kit.mail.preview app/templates/mail user_roles_changed
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
It writes the `.html` and the `.txt` to a temporary folder, prints the subject
|
|
344
|
+
and opens the HTML in the browser. Without names it renders every template;
|
|
345
|
+
`--output` changes the folder and `--no-open` skips the browser.
|
|
346
|
+
|
|
347
|
+
The sender is chosen by `MAIL_BACKEND`. `memory` keeps messages in
|
|
348
|
+
`MemoryMailSender.sent`, for tests and for local runs where no relay accepts you.
|
|
349
|
+
`smtp` delivers through `SmtpMailSender`, which upgrades with STARTTLS and logs in
|
|
350
|
+
only if `MAIL_SMTP_USERNAME` is set, so a relay that authorizes by IP works as is.
|
|
351
|
+
|
|
352
|
+
Needs `[mail]` for templates. `MailMessage` and the senders use only the standard
|
|
353
|
+
library.
|
|
354
|
+
|
|
290
355
|
## Resilience
|
|
291
356
|
|
|
292
357
|
`CircuitBreaker` is domain-agnostic and shared by anything that calls out: after
|
|
@@ -311,7 +376,7 @@ class Settings(PersistenceKitSettings):
|
|
|
311
376
|
```
|
|
312
377
|
|
|
313
378
|
The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
314
|
-
`get_export_storage`, `get_cache`.
|
|
379
|
+
`get_export_storage`, `get_cache`, `get_mail_sender`.
|
|
315
380
|
|
|
316
381
|
### Environment variables
|
|
317
382
|
|
|
@@ -329,6 +394,12 @@ The factories read that object: `get_identity_provider`, `get_token_verifier`,
|
|
|
329
394
|
| `ENCRYPTED_PRIVATE_KEY` | Base64 PEM of the RSA private key that unwraps encrypted payloads |
|
|
330
395
|
| `ENCRYPTED_TYPE` | `memory` (default, key generated at startup), `local`, or `kms` for AWS KMS |
|
|
331
396
|
| `KMS_KEY_ID` | The KMS key that unwraps the AES key, required by `kms` |
|
|
397
|
+
| `MAIL_BACKEND` | `memory` (default) or `smtp` |
|
|
398
|
+
| `MAIL_FROM_ADDRESS`, `MAIL_FROM_NAME` | Default sender. The address is required by `smtp` |
|
|
399
|
+
| `MAIL_SMTP_HOST`, `MAIL_SMTP_PORT` | The relay. Port defaults to `587` |
|
|
400
|
+
| `MAIL_SMTP_STARTTLS` | Upgrade the connection with STARTTLS. Defaults to `true` |
|
|
401
|
+
| `MAIL_SMTP_USERNAME`, `MAIL_SMTP_PASSWORD` | Only for relays that require a login |
|
|
402
|
+
| `MAIL_SMTP_TIMEOUT_SECONDS` | Connection and command timeout. Defaults to `30` |
|
|
332
403
|
|
|
333
404
|
## Wiring it into an application
|
|
334
405
|
|
|
@@ -452,11 +523,11 @@ poetry install --with dev --all-extras
|
|
|
452
523
|
poetry run pytest -q
|
|
453
524
|
```
|
|
454
525
|
|
|
455
|
-
Current baseline: **
|
|
526
|
+
Current baseline: **621 tests passing** (version 3.13.0). Async tests use
|
|
456
527
|
`pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
|
|
457
528
|
|
|
458
529
|
`tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
|
|
459
|
-
importing `persistence_kit` drags in fastapi, pyjwt or
|
|
530
|
+
importing `persistence_kit` drags in fastapi, pyjwt, boto3 or jinja2.
|
|
460
531
|
|
|
461
532
|
## Releasing
|
|
462
533
|
|
|
@@ -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()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from email.utils import formataddr
|
|
7
|
+
|
|
8
|
+
from persistence_kit.mail.errors import MailMessageError
|
|
9
|
+
|
|
10
|
+
_LINE_BREAKS = ("\r", "\n")
|
|
11
|
+
CONTENT_ID_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._@-]*")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _reject_line_breaks(value: str, what: str) -> None:
|
|
15
|
+
for line_break in _LINE_BREAKS:
|
|
16
|
+
if line_break in value:
|
|
17
|
+
raise MailMessageError(f"{what} cannot contain line breaks: {value!r}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, kw_only=True)
|
|
21
|
+
class MailAddress:
|
|
22
|
+
"""An address with an optional display name."""
|
|
23
|
+
|
|
24
|
+
email: str
|
|
25
|
+
name: str | None = None
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
email = self.email.strip()
|
|
29
|
+
if not email or "@" not in email:
|
|
30
|
+
raise MailMessageError(f"Not a mail address: {self.email!r}")
|
|
31
|
+
_reject_line_breaks(email, "An address")
|
|
32
|
+
if self.name is not None:
|
|
33
|
+
_reject_line_breaks(self.name, "A display name")
|
|
34
|
+
object.__setattr__(self, "email", email)
|
|
35
|
+
|
|
36
|
+
def formatted(self) -> str:
|
|
37
|
+
return formataddr((self.name or "", self.email))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Recipients = str | MailAddress | Iterable[str | MailAddress]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def to_address(value: str | MailAddress) -> MailAddress:
|
|
44
|
+
if isinstance(value, MailAddress):
|
|
45
|
+
return value
|
|
46
|
+
return MailAddress(email=value)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_addresses(value: Recipients | None) -> tuple[MailAddress, ...]:
|
|
50
|
+
if value is None:
|
|
51
|
+
return ()
|
|
52
|
+
if isinstance(value, (str, MailAddress)):
|
|
53
|
+
return (to_address(value),)
|
|
54
|
+
addresses: list[MailAddress] = []
|
|
55
|
+
for item in value:
|
|
56
|
+
addresses.append(to_address(item))
|
|
57
|
+
return tuple(addresses)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, kw_only=True)
|
|
61
|
+
class MailAttachment:
|
|
62
|
+
"""A file sent with the message. With `content_id` it goes inline, for `<img src="cid:...">`."""
|
|
63
|
+
|
|
64
|
+
filename: str
|
|
65
|
+
content: bytes
|
|
66
|
+
content_type: str = "application/octet-stream"
|
|
67
|
+
content_id: str | None = None
|
|
68
|
+
|
|
69
|
+
def __post_init__(self) -> None:
|
|
70
|
+
_reject_line_breaks(self.filename, "An attachment name")
|
|
71
|
+
if self.content_type.count("/") != 1:
|
|
72
|
+
raise MailMessageError(f"Not a content type: {self.content_type!r}")
|
|
73
|
+
if self.content_id is not None and not CONTENT_ID_PATTERN.fullmatch(self.content_id):
|
|
74
|
+
raise MailMessageError(f"Not a content id: {self.content_id!r}")
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def inline(self) -> bool:
|
|
78
|
+
return self.content_id is not None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, kw_only=True)
|
|
82
|
+
class MailMessage:
|
|
83
|
+
"""One message. `to`, `cc`, `bcc` and `reply_to` take a string, an address or a list of them.
|
|
84
|
+
|
|
85
|
+
Blind copies travel in the envelope only; they never appear in the headers.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
subject: str
|
|
89
|
+
to: Recipients = ()
|
|
90
|
+
cc: Recipients = ()
|
|
91
|
+
bcc: Recipients = ()
|
|
92
|
+
reply_to: Recipients = ()
|
|
93
|
+
html: str | None = None
|
|
94
|
+
text: str | None = None
|
|
95
|
+
attachments: tuple[MailAttachment, ...] = field(default_factory=tuple)
|
|
96
|
+
sender: str | MailAddress | None = None
|
|
97
|
+
|
|
98
|
+
def __post_init__(self) -> None:
|
|
99
|
+
object.__setattr__(self, "to", to_addresses(self.to))
|
|
100
|
+
object.__setattr__(self, "cc", to_addresses(self.cc))
|
|
101
|
+
object.__setattr__(self, "bcc", to_addresses(self.bcc))
|
|
102
|
+
object.__setattr__(self, "reply_to", to_addresses(self.reply_to))
|
|
103
|
+
object.__setattr__(self, "attachments", tuple(self.attachments))
|
|
104
|
+
if self.sender is not None:
|
|
105
|
+
object.__setattr__(self, "sender", to_address(self.sender))
|
|
106
|
+
|
|
107
|
+
_reject_line_breaks(self.subject, "The subject")
|
|
108
|
+
if not self.to and not self.cc and not self.bcc:
|
|
109
|
+
raise MailMessageError("A message needs at least one recipient in to, cc or bcc.")
|
|
110
|
+
if not self.html and not self.text:
|
|
111
|
+
raise MailMessageError("A message needs an html or a text body.")
|
|
112
|
+
if not self.html and any(attachment.inline for attachment in self.attachments):
|
|
113
|
+
raise MailMessageError("Inline attachments need an html body.")
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def envelope_recipients(self) -> tuple[str, ...]:
|
|
117
|
+
"""Every address the relay has to deliver to, blind copies included, without repeats."""
|
|
118
|
+
seen: dict[str, str] = {}
|
|
119
|
+
for address in (*self.to, *self.cc, *self.bcc):
|
|
120
|
+
seen.setdefault(address.email.lower(), address.email)
|
|
121
|
+
return tuple(seen.values())
|