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