timber-common 1.2.2__tar.gz → 1.2.3__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 (183) hide show
  1. {timber_common-1.2.2 → timber_common-1.2.3}/CHANGELOG.md +12 -0
  2. {timber_common-1.2.2 → timber_common-1.2.3}/PKG-INFO +1 -1
  3. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/communication/__init__.py +2 -0
  4. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/communication/email_service.py +19 -10
  5. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/communication/messaging_service.py +27 -35
  6. timber_common-1.2.3/common/services/communication/resend_provider.py +74 -0
  7. {timber_common-1.2.2 → timber_common-1.2.3}/pyproject.toml +1 -1
  8. {timber_common-1.2.2 → timber_common-1.2.3}/LICENSE +0 -0
  9. {timber_common-1.2.2 → timber_common-1.2.3}/README.md +0 -0
  10. {timber_common-1.2.2 → timber_common-1.2.3}/common/__init__.py +0 -0
  11. {timber_common-1.2.2 → timber_common-1.2.3}/common/cli/__init__.py +0 -0
  12. {timber_common-1.2.2 → timber_common-1.2.3}/common/cli/commands/__init__.py +0 -0
  13. {timber_common-1.2.2 → timber_common-1.2.3}/common/cli/commands/validate.py +0 -0
  14. {timber_common-1.2.2 → timber_common-1.2.3}/common/cli/main.py +0 -0
  15. {timber_common-1.2.2 → timber_common-1.2.3}/common/config/__init__.py +0 -0
  16. {timber_common-1.2.2 → timber_common-1.2.3}/common/config/model_loader.py +0 -0
  17. {timber_common-1.2.2 → timber_common-1.2.3}/common/engine/__init__.py +0 -0
  18. {timber_common-1.2.2 → timber_common-1.2.3}/common/engine/config_executor.py +0 -0
  19. {timber_common-1.2.2 → timber_common-1.2.3}/common/engine/operation_registry.py +0 -0
  20. {timber_common-1.2.2 → timber_common-1.2.3}/common/init.py +0 -0
  21. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/__init__.py +0 -0
  22. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/a2ui.py +0 -0
  23. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/base.py +0 -0
  24. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/configs/__init__.py +0 -0
  25. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/core/__init.__.py +0 -0
  26. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/core/enterprise.py +0 -0
  27. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/core/tag.py +0 -0
  28. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/core/user.py +0 -0
  29. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/factory.py +0 -0
  30. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/mixins.py +0 -0
  31. {timber_common-1.2.2 → timber_common-1.2.3}/common/models/registry.py +0 -0
  32. {timber_common-1.2.2 → timber_common-1.2.3}/common/plugins/__init__.py +0 -0
  33. {timber_common-1.2.2 → timber_common-1.2.3}/common/plugins/contract.py +0 -0
  34. {timber_common-1.2.2 → timber_common-1.2.3}/common/plugins/discovery.py +0 -0
  35. {timber_common-1.2.2 → timber_common-1.2.3}/common/render/__init__.py +0 -0
  36. {timber_common-1.2.2 → timber_common-1.2.3}/common/render/pdf.py +0 -0
  37. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/README.md +0 -0
  38. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/__init__.py +0 -0
  39. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/base.py +0 -0
  40. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/workflow/__init__.py +0 -0
  41. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/workflow/definition.py +0 -0
  42. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/workflow/enums.py +0 -0
  43. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/workflow/patterns.py +0 -0
  44. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/workflow/state.py +0 -0
  45. {timber_common-1.2.2 → timber_common-1.2.3}/common/schemas/workflow/transition.py +0 -0
  46. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/__init__.py +0 -0
  47. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/a2a/__init__.py +0 -0
  48. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/a2a/client.py +0 -0
  49. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/communication/sms_service.py +0 -0
  50. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/db_service.py +0 -0
  51. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/decisioning/__init__.py +0 -0
  52. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/decisioning/decision_engine.py +0 -0
  53. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/decisioning/expression_engine.py +0 -0
  54. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/decisioning/graph_evaluator.py +0 -0
  55. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/decisioning/models.py +0 -0
  56. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/decisioning/table_evaluator.py +0 -0
  57. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/encryption/__init__.py +0 -0
  58. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/encryption/field_encryption.py +0 -0
  59. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/gdpr/__init__.py +0 -0
  60. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/gdpr/deletion.py +0 -0
  61. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/integrations/__init__.py +0 -0
  62. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/integrations/auth_service.py +0 -0
  63. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/integrations/integration_service.py +0 -0
  64. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/integrations/mapping_service.py +0 -0
  65. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/integrations/models.py +0 -0
  66. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/integrations/registry.py +0 -0
  67. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/inventory/__init__.py +0 -0
  68. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/inventory/available_capabilities.py +0 -0
  69. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/inventory/cached_capabilities.py +0 -0
  70. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/inventory/loader.py +0 -0
  71. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/__init__.py +0 -0
  72. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/batch.py +0 -0
  73. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/calibration.py +0 -0
  74. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/confidence.py +0 -0
  75. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/contract.py +0 -0
  76. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/guards.py +0 -0
  77. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/models.py +0 -0
  78. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/providers/__init__.py +0 -0
  79. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/providers/fixture.py +0 -0
  80. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/judgment/providers/typesafe.py +0 -0
  81. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/llm/__init__.py +0 -0
  82. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/llm/base.py +0 -0
  83. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/llm/claude_provider.py +0 -0
  84. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/llm/gemini_provider.py +0 -0
  85. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/llm/groq_provider.py +0 -0
  86. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/llm/model_choice.py +0 -0
  87. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/llm/perplexity_provider.py +0 -0
  88. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/llm_service.py +0 -0
  89. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/DIRECTORY_STRUCTURE.md +0 -0
  90. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/MIGRATION_GUIDE.md +0 -0
  91. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/QUICKSTART.md +0 -0
  92. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/README.md +0 -0
  93. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/VIDEO_README.md +0 -0
  94. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/__init__.py +0 -0
  95. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/config_helpers.py +0 -0
  96. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/examples.py +0 -0
  97. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/image_generation.py +0 -0
  98. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/model_resolver.py +0 -0
  99. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/video_examples.py +0 -0
  100. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/media/video_generation.py +0 -0
  101. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/__init__.py +0 -0
  102. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/base.py +0 -0
  103. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/cache.py +0 -0
  104. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/instances.py +0 -0
  105. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/manager.py +0 -0
  106. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/notification.py +0 -0
  107. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/research.py +0 -0
  108. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/session.py +0 -0
  109. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/persistence/tracker.py +0 -0
  110. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/registry.py +0 -0
  111. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/runtime_cache/__init__.py +0 -0
  112. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/runtime_cache/invalidator.py +0 -0
  113. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/runtime_cache/user_context_cache.py +0 -0
  114. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/runtime_cache/valkey_client.py +0 -0
  115. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/runtime_cache/workflow_catalog_cache.py +0 -0
  116. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/security/__init__.py +0 -0
  117. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/security/oauth_service.py +0 -0
  118. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/vector/__init__.py +0 -0
  119. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/vector/auto_ingestion.py +0 -0
  120. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/vector/search.py +0 -0
  121. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/vector/tag_embedding.py +0 -0
  122. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/vendors/__init__.py +0 -0
  123. {timber_common-1.2.2 → timber_common-1.2.3}/common/services/vendors/stripe_service.py +0 -0
  124. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/__init__.py +0 -0
  125. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/config.py +0 -0
  126. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/db_url.py +0 -0
  127. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/db_utils.py +0 -0
  128. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/decisioning/__init__.py +0 -0
  129. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/decisioning/csv_parser.py +0 -0
  130. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/decisioning/dmn_parser.py +0 -0
  131. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/decisioning/yaml_exporter.py +0 -0
  132. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/decisioning/yaml_parser.py +0 -0
  133. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/helpers.py +0 -0
  134. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/llm/chunk_aggregator.py +0 -0
  135. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/llm/content_condenser.py +0 -0
  136. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/llm/content_handler.py +0 -0
  137. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/llm/hierarchical_summarizer.py +0 -0
  138. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/serialization_helpers.py +0 -0
  139. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/time_helpers.py +0 -0
  140. {timber_common-1.2.2 → timber_common-1.2.3}/common/utils/validators.py +0 -0
  141. {timber_common-1.2.2 → timber_common-1.2.3}/data/models/00_association_tables.yaml +0 -0
  142. {timber_common-1.2.2 → timber_common-1.2.3}/data/models/messaging_models.yaml +0 -0
  143. {timber_common-1.2.2 → timber_common-1.2.3}/data/models/notification_models.yaml +0 -0
  144. {timber_common-1.2.2 → timber_common-1.2.3}/data/models/oauth_models.yaml +0 -0
  145. {timber_common-1.2.2 → timber_common-1.2.3}/data/models/user_preferences_models.yaml +0 -0
  146. {timber_common-1.2.2 → timber_common-1.2.3}/data/models/vector_db_models.yaml +0 -0
  147. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/DOCUMENTATION_INDEX.md +0 -0
  148. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/DOCUMENTATION_SUMMARY.md +0 -0
  149. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/PROGRESS_UPDATE.md +0 -0
  150. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/TIMBER_SESSION_API_REFERENCE.md +0 -0
  151. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/best_practices/01_model_design_patterns.md +0 -0
  152. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/best_practices/02_service_architecture.md +0 -0
  153. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/best_practices/03_data_fetching_strategies.md +0 -0
  154. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/best_practices/04_caching_strategies.md +0 -0
  155. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/best_practices/05_error_handling.md +0 -0
  156. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/best_practices/06_performance_optimization.md +0 -0
  157. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/best_practices/07_security_best_practices.md +0 -0
  158. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/design_guides/01_system_architecture.md +0 -0
  159. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/design_guides/02_config_driven_models.md +0 -0
  160. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/design_guides/03_persistence_layer.md +0 -0
  161. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/design_guides/04_vector_integration.md +0 -0
  162. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/design_guides/05_multi_app_support.md +0 -0
  163. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/01_getting_started.md +0 -0
  164. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/02_creating_models.md +0 -0
  165. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/03_using_services.md +0 -0
  166. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/04_financial_data_fetching.md +0 -0
  167. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/05_encryption_and_security.md +0 -0
  168. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/06_vector_search.md +0 -0
  169. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/07_gdpr_compliance.md +0 -0
  170. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/08_testing_guide.md +0 -0
  171. {timber_common-1.2.2 → timber_common-1.2.3}/documentation/how_to/09_typed_judgment.md +0 -0
  172. {timber_common-1.2.2 → timber_common-1.2.3}/modules/__init__.py +0 -0
  173. {timber_common-1.2.2 → timber_common-1.2.3}/modules/config/custom_analysis.yaml +0 -0
  174. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/README.md +0 -0
  175. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/__init__.py +0 -0
  176. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/bootstrap.py +0 -0
  177. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/cache.py +0 -0
  178. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/canopy_auth.py +0 -0
  179. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/client.py +0 -0
  180. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/env.py +0 -0
  181. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/models.py +0 -0
  182. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/specs.py +0 -0
  183. {timber_common-1.2.2 → timber_common-1.2.3}/oak_config/subscriber.py +0 -0
@@ -5,6 +5,18 @@ All notable changes to Timber will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.3] - 2026-10-27
9
+
10
+ ### Added
11
+
12
+ - Resend transactional email transport using runtime-injected credentials and stable delivery identifiers.
13
+
14
+ ### Fixed
15
+
16
+ - Connect multichannel email delivery to the shared email service and scope message updates by ID.
17
+ - Record provider acceptance separately from delivery and reject unknown email providers.
18
+ - Select the default email provider lazily after runtime configuration loads.
19
+
8
20
  ## [1.2.2] - 2026-10-24
9
21
 
10
22
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: timber-common
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Configuration-driven persistence library with ml tools (finance related) config driven db model registration, llm model choice, automatic encryption, caching, vector search, and GDPR compliance for Python applications
5
5
  License: Apache-2.0
6
6
  Keywords: orm,sqlalchemy,persistence,vector-search,semantic-search,encryption,gdpr,yaml-config,postgres,redis,configuration-driven,data-modeling,machine-learning,embeddings
@@ -64,6 +64,7 @@ from .email_service import (
64
64
  SESProvider,
65
65
  SMTPProvider,
66
66
  ConsoleProvider,
67
+ ResendProvider,
67
68
  get_email_service,
68
69
  email_service,
69
70
  )
@@ -103,6 +104,7 @@ __all__ = [
103
104
  'SESProvider',
104
105
  'SMTPProvider',
105
106
  'ConsoleProvider',
107
+ 'ResendProvider',
106
108
  'get_email_service',
107
109
  'email_service',
108
110
 
@@ -608,6 +608,9 @@ class ConsoleProvider(EmailProvider):
608
608
  # EMAIL SERVICE
609
609
  # =============================================================================
610
610
 
611
+ from .resend_provider import ResendProvider
612
+
613
+
611
614
  class EmailService:
612
615
  """
613
616
  Main email service with provider abstraction.
@@ -621,6 +624,7 @@ class EmailService:
621
624
  'ses': SESProvider,
622
625
  'smtp': SMTPProvider,
623
626
  'console': ConsoleProvider,
627
+ 'resend': ResendProvider,
624
628
  }
625
629
 
626
630
  def __init__(self, provider: Optional[str] = None):
@@ -631,7 +635,7 @@ class EmailService:
631
635
  provider: Provider name ('sendgrid', 'ses', 'smtp', 'console')
632
636
  If not specified, auto-detects from environment.
633
637
  """
634
- self._provider_name = provider or os.getenv('EMAIL_PROVIDER', 'console')
638
+ self._provider_name = provider
635
639
  self._provider: Optional[EmailProvider] = None
636
640
  self._db = None
637
641
 
@@ -652,10 +656,10 @@ class EmailService:
652
656
  def provider(self) -> EmailProvider:
653
657
  """Get the configured email provider."""
654
658
  if self._provider is None:
659
+ self._provider_name = self._provider_name or os.getenv("EMAIL_PROVIDER", "console")
655
660
  provider_class = self.PROVIDERS.get(self._provider_name)
656
661
  if not provider_class:
657
- logger.warning(f"Unknown provider '{self._provider_name}', falling back to console")
658
- provider_class = ConsoleProvider
662
+ raise ValueError(f"Unknown email provider: {self._provider_name}")
659
663
  self._provider = provider_class()
660
664
  return self._provider
661
665
 
@@ -724,14 +728,19 @@ class EmailService:
724
728
  metadata=metadata or {},
725
729
  )
726
730
 
727
- # Send via provider
731
+ return await self.send_message(message)
732
+
733
+ async def send_message(self, message: EmailMessage, *, log_delivery: bool = True) -> EmailResult:
734
+ """Send a prepared message, preserving its identity across retries.
735
+
736
+ MessagingService owns its persisted delivery log and disables this log.
737
+ Provider acceptance is not proof of recipient delivery.
738
+ """
728
739
  result = await self.provider.send(message)
729
-
730
- # Log to database
731
- await self._log_email(message, result)
732
-
740
+ if log_delivery:
741
+ await self._log_email(message, result)
733
742
  return result
734
-
743
+
735
744
  async def send_template(
736
745
  self,
737
746
  to: Union[str, List[str], EmailAddress, List[EmailAddress]],
@@ -894,7 +903,7 @@ class EmailService:
894
903
  },
895
904
  'error_message': result.error,
896
905
  'attempted_at': datetime.now(timezone.utc),
897
- 'delivered_at': result.sent_at,
906
+ 'delivered_at': None,
898
907
  })
899
908
  except Exception as e:
900
909
  logger.debug(f"Failed to log email: {e}")
@@ -277,7 +277,7 @@ class MessagingService:
277
277
  message = self.db.get('Message', message_id)
278
278
 
279
279
  if message and message['user_id'] == user_id:
280
- self.db.update('Message', message_id, {
280
+ self.db.update('Message', {'id': message_id}, {
281
281
  'in_app_status': 'read',
282
282
  'in_app_read_at': datetime.now(timezone.utc),
283
283
  'updated_at': datetime.now(timezone.utc),
@@ -298,7 +298,7 @@ class MessagingService:
298
298
  count = 0
299
299
  now = datetime.now(timezone.utc)
300
300
  for msg in messages:
301
- self.db.update('Message', msg['id'], {
301
+ self.db.update('Message', {'id': msg['id']}, {
302
302
  'in_app_status': 'read',
303
303
  'in_app_read_at': now,
304
304
  'updated_at': now,
@@ -312,7 +312,7 @@ class MessagingService:
312
312
  message = self.db.get('Message', message_id)
313
313
 
314
314
  if message and message['user_id'] == user_id:
315
- self.db.update('Message', message_id, {
315
+ self.db.update('Message', {'id': message_id}, {
316
316
  'in_app_status': 'dismissed',
317
317
  'updated_at': datetime.now(timezone.utc),
318
318
  })
@@ -358,7 +358,7 @@ class MessagingService:
358
358
  rendered: Dict[str, str],
359
359
  ) -> None:
360
360
  """Deliver in-app message (update status)."""
361
- self.db.update('Message', message_id, {
361
+ self.db.update('Message', {'id': message_id}, {
362
362
  'in_app_status': 'delivered',
363
363
  'updated_at': datetime.now(timezone.utc),
364
364
  })
@@ -375,38 +375,30 @@ class MessagingService:
375
375
  preferences: Dict[str, Any],
376
376
  ) -> None:
377
377
  """Deliver email message."""
378
+ from .email_service import EmailMessage, EmailAddress, get_email_service
379
+
378
380
  email = preferences.get('email_address')
379
-
380
381
  if not email:
381
- logger.warning(f"No email address for message {message_id}")
382
- self.db.update('Message', message_id, {
383
- 'email_status': 'failed',
384
- })
385
- return
386
-
387
- # TODO: Integrate with email provider (SendGrid, SES, etc.)
388
- # For now, just log and mark as sent
389
-
390
- subject = rendered.get('email_subject', rendered.get('in_app_title', ''))
391
- body_html = rendered.get('email_body_html', '')
392
- body_text = rendered.get('email_body_text', rendered.get('in_app_body', ''))
393
-
394
- # Placeholder for actual email sending
395
- # response = await self._email_provider.send(
396
- # to=email,
397
- # subject=subject,
398
- # html=body_html,
399
- # text=body_text,
400
- # )
401
-
402
- logger.info(f"📧 Email message queued: {message_id} -> {email}")
403
-
404
- self.db.update('Message', message_id, {
405
- 'email_status': 'sent',
406
- 'email_sent_at': datetime.now(timezone.utc),
382
+ self.db.update('Message', {'id': message_id}, {'email_status': 'failed'})
383
+ raise ValueError("No email address configured")
384
+ service = self._email_provider or get_email_service()
385
+ message = EmailMessage(
386
+ to=[EmailAddress.parse(email)], message_id=message_id,
387
+ subject=rendered.get('email_subject', rendered.get('in_app_title', '')),
388
+ html=rendered.get('email_body_html') or None,
389
+ text=rendered.get('email_body_text', rendered.get('in_app_body', '')) or None,
390
+ )
391
+ result = await service.send_message(message, log_delivery=False)
392
+ accepted = result.success and result.provider != 'console'
393
+ self.db.update('Message', {'id': message_id}, {
394
+ 'email_status': result.status.value if accepted else 'failed',
395
+ 'email_message_id': result.provider_message_id,
396
+ 'email_sent_at': result.sent_at if accepted else None,
407
397
  'updated_at': datetime.now(timezone.utc),
408
398
  })
409
-
399
+ if not accepted:
400
+ raise RuntimeError(result.error or "Email transport did not accept delivery")
401
+
410
402
  async def _deliver_push(
411
403
  self,
412
404
  message_id: str,
@@ -418,7 +410,7 @@ class MessagingService:
418
410
 
419
411
  if not subscription:
420
412
  logger.warning(f"No push subscription for message {message_id}")
421
- self.db.update('Message', message_id, {
413
+ self.db.update('Message', {'id': message_id}, {
422
414
  'push_status': 'failed',
423
415
  })
424
416
  return
@@ -440,7 +432,7 @@ class MessagingService:
440
432
 
441
433
  logger.info(f"🔔 Push message queued: {message_id}")
442
434
 
443
- self.db.update('Message', message_id, {
435
+ self.db.update('Message', {'id': message_id}, {
444
436
  'push_status': 'sent',
445
437
  'push_sent_at': datetime.now(timezone.utc),
446
438
  'updated_at': datetime.now(timezone.utc),
@@ -550,7 +542,7 @@ class MessagingService:
550
542
  'provider': provider,
551
543
  'error_message': error_message,
552
544
  'attempted_at': datetime.now(timezone.utc),
553
- 'delivered_at': datetime.now(timezone.utc) if status == 'success' else None,
545
+ 'delivered_at': datetime.now(timezone.utc) if status == 'success' and channel == 'in_app' else None,
554
546
  'created_at': datetime.now(timezone.utc),
555
547
  'updated_at': datetime.now(timezone.utc),
556
548
  })
@@ -0,0 +1,74 @@
1
+ """Resend transactional transport. Broadcast campaigns use a separate contract."""
2
+ import asyncio
3
+ import base64
4
+ import json
5
+ import os
6
+ import urllib.error
7
+ import urllib.request
8
+ from datetime import datetime, timezone
9
+
10
+ from .email_service import EmailAddress, EmailProvider, EmailResult, EmailStatus
11
+
12
+
13
+ class ResendProvider(EmailProvider):
14
+ name = "resend"
15
+
16
+ def __init__(self, api_key=None):
17
+ self.api_key = api_key or os.getenv("RESEND_API_KEY")
18
+
19
+ def is_configured(self):
20
+ return bool(self.api_key)
21
+
22
+ async def send(self, message):
23
+ if not self.is_configured():
24
+ return EmailResult(False, message.message_id, status=EmailStatus.FAILED,
25
+ error="Resend API key not configured", provider=self.name)
26
+ sender = message.from_email or EmailAddress(os.getenv("EMAIL_FROM_ADDRESS", ""),
27
+ os.getenv("EMAIL_FROM_NAME", "OakQuant"))
28
+ if not sender.email:
29
+ return EmailResult(False, message.message_id, status=EmailStatus.FAILED,
30
+ error="Email sender not configured", provider=self.name)
31
+ payload = {"from": str(sender), "to": [str(r) for r in message.to],
32
+ "subject": message.subject}
33
+ for key in ("html", "text"):
34
+ if getattr(message, key):
35
+ payload[key] = getattr(message, key)
36
+ reply = message.reply_to or os.getenv("EMAIL_REPLY_TO")
37
+ if reply:
38
+ payload["reply_to"] = str(reply)
39
+ for key in ("cc", "bcc"):
40
+ if getattr(message, key):
41
+ payload[key] = [str(r) for r in getattr(message, key)]
42
+ if message.headers:
43
+ payload["headers"] = message.headers
44
+ if message.attachments:
45
+ payload["attachments"] = [dict(filename=a.filename,
46
+ content=base64.b64encode(a.content).decode(),
47
+ **({"content_id": a.content_id} if a.content_id else {}))
48
+ for a in message.attachments]
49
+ return await asyncio.to_thread(self._send, message.message_id, payload)
50
+
51
+ def _send(self, message_id, payload):
52
+ request = urllib.request.Request("https://api.resend.com/emails",
53
+ data=json.dumps(payload).encode(), method="POST", headers={
54
+ "Authorization": "Bearer " + self.api_key,
55
+ "Content-Type": "application/json", "User-Agent": "timber-common",
56
+ "Idempotency-Key": message_id})
57
+ try:
58
+ with urllib.request.urlopen(request, timeout=25) as response:
59
+ data = json.load(response)
60
+ if not data.get("id"):
61
+ raise ValueError("Missing provider message ID")
62
+ return EmailResult(True, message_id, provider_message_id=data["id"],
63
+ status=EmailStatus.QUEUED, provider=self.name,
64
+ sent_at=datetime.now(timezone.utc))
65
+ except urllib.error.HTTPError as exc:
66
+ error = f"Resend rejected request (HTTP {exc.code})"
67
+ except Exception:
68
+ # Provider bodies and exception strings may contain recipient data or credentials.
69
+ error = "Resend request failed; delivery acceptance unknown"
70
+ return EmailResult(False, message_id, status=EmailStatus.FAILED,
71
+ error=error, provider=self.name)
72
+
73
+ async def send_bulk(self, messages):
74
+ return [await self.send(message) for message in messages]
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "timber-common"
3
- version = "1.2.2"
3
+ version = "1.2.3"
4
4
  description = "Configuration-driven persistence library with ml tools (finance related) config driven db model registration, llm model choice, automatic encryption, caching, vector search, and GDPR compliance for Python applications"
5
5
  authors = ["Pumulo Sikaneta <pumulo@gmail.com>"]
6
6
  maintainers = ["Pumulo Sikaneta <pumulo@gmail.com>"]
File without changes
File without changes