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.
Files changed (118) hide show
  1. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/PKG-INFO +78 -5
  2. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/README.md +75 -4
  3. persistence_kit-3.13.0/persistence_kit/mail/__init__.py +57 -0
  4. persistence_kit-3.13.0/persistence_kit/mail/contracts.py +9 -0
  5. persistence_kit-3.13.0/persistence_kit/mail/errors.py +26 -0
  6. persistence_kit-3.13.0/persistence_kit/mail/factory.py +91 -0
  7. persistence_kit-3.13.0/persistence_kit/mail/mailer.py +74 -0
  8. persistence_kit-3.13.0/persistence_kit/mail/memory.py +28 -0
  9. persistence_kit-3.13.0/persistence_kit/mail/message.py +121 -0
  10. persistence_kit-3.13.0/persistence_kit/mail/mime.py +64 -0
  11. persistence_kit-3.13.0/persistence_kit/mail/preview.py +84 -0
  12. persistence_kit-3.13.0/persistence_kit/mail/smtp.py +57 -0
  13. persistence_kit-3.13.0/persistence_kit/mail/templates.py +133 -0
  14. persistence_kit-3.13.0/persistence_kit/settings/mail_settings.py +24 -0
  15. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/pyproject.toml +5 -3
  16. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/LICENSE +0 -0
  17. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/__init__.py +0 -0
  18. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/__init__.py +0 -0
  19. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/common.py +0 -0
  20. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/encrypted_middleware.py +0 -0
  21. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/encrypted_routes.py +0 -0
  22. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/error_handlers.py +0 -0
  23. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/exceptions.py +0 -0
  24. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/rate_limit.py +0 -0
  25. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/api/route_loader.py +0 -0
  26. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/authenticated_user.py +0 -0
  27. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/__init__.py +0 -0
  28. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/configuration.py +0 -0
  29. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/seeders.py +0 -0
  30. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/bootstrap/startup.py +0 -0
  31. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/__init__.py +0 -0
  32. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/contracts.py +0 -0
  33. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/dynamodb.py +0 -0
  34. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/factory.py +0 -0
  35. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/memory.py +0 -0
  36. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/mongo.py +0 -0
  37. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/cache/namespaced.py +0 -0
  38. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/contracts/__init__.py +0 -0
  39. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/contracts/repository.py +0 -0
  40. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/contracts/view_repository.py +0 -0
  41. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/py.typed +0 -0
  42. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/__init__.py +0 -0
  43. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  44. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  45. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/filter_ops.py +0 -0
  46. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  47. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  48. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  49. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  50. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  51. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  52. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  53. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  54. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  55. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  56. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  57. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/__init__.py +0 -0
  58. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  59. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  60. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  61. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  62. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/view/__init__.py +0 -0
  63. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  64. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/resilience/__init__.py +0 -0
  65. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/resilience/circuit.py +0 -0
  66. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/__init__.py +0 -0
  67. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/aggregate.py +0 -0
  68. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/__init__.py +0 -0
  69. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/api_key.py +0 -0
  70. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/base.py +0 -0
  71. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/basic.py +0 -0
  72. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/bearer.py +0 -0
  73. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/login.py +0 -0
  74. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/auth/oauth2.py +0 -0
  75. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/caching.py +0 -0
  76. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/client.py +0 -0
  77. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/config.py +0 -0
  78. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/contracts.py +0 -0
  79. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/errors.py +0 -0
  80. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/factory.py +0 -0
  81. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/mapping.py +0 -0
  82. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/memory.py +0 -0
  83. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/payload.py +0 -0
  84. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/populate.py +0 -0
  85. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/provider.py +0 -0
  86. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/registry.py +0 -0
  87. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/resolver.py +0 -0
  88. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/restclient/retry.py +0 -0
  89. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/__init__.py +0 -0
  90. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/encrypted/__init__.py +0 -0
  91. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/encrypted/envelope.py +0 -0
  92. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/encrypted/errors.py +0 -0
  93. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/factory.py +0 -0
  94. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/ports.py +0 -0
  95. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/__init__.py +0 -0
  96. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
  97. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/encryption_provider.py +0 -0
  98. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/providers/memory_security_provider.py +0 -0
  99. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/registration.py +0 -0
  100. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  101. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  102. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  103. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/__init__.py +0 -0
  104. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/app_settings.py +0 -0
  105. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/cache_settings.py +0 -0
  106. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/constants.py +0 -0
  107. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/parsers.py +0 -0
  108. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/settings/repo_settings.py +0 -0
  109. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/__init__.py +0 -0
  110. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/contracts.py +0 -0
  111. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/errors.py +0 -0
  112. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/factory.py +0 -0
  113. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/local.py +0 -0
  114. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/media.py +0 -0
  115. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/routes.py +0 -0
  116. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/storage/s3.py +0 -0
  117. {persistence_kit-3.12.0 → persistence_kit-3.13.0}/persistence_kit/utils/__init__.py +0 -0
  118. {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.12.0
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
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography |
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: **555 tests passing** (version 3.12.0). Async tests use
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 boto3.
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
- | `testing` | Everything the test suite needs | fastapi, pyjwt, httpx, python-multipart, cryptography |
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: **555 tests passing** (version 3.12.0). Async tests use
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 boto3.
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,9 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ from persistence_kit.mail.message import MailMessage
6
+
7
+
8
+ class MailSender(Protocol):
9
+ async def send(self, message: MailMessage) -> None: ...
@@ -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())