nornweave 0.1.3__tar.gz → 0.1.4__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 (158) hide show
  1. {nornweave-0.1.3 → nornweave-0.1.4}/PKG-INFO +19 -1
  2. {nornweave-0.1.3 → nornweave-0.1.4}/pyproject.toml +29 -2
  3. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/resend.py +57 -7
  4. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/config.py +50 -1
  5. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/interfaces.py +63 -1
  6. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/client.py +137 -8
  7. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/resources.py +9 -0
  8. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/server.py +147 -8
  9. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/thread.py +2 -0
  10. nornweave-0.1.4/src/nornweave/muninn/tools.py +455 -0
  11. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/database.py +1 -1
  12. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/adapters/base.py +173 -3
  13. nornweave-0.1.4/src/nornweave/urdr/migrations/versions/20260205_0003_llm_thread_summary.py +54 -0
  14. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/orm.py +27 -1
  15. nornweave-0.1.4/src/nornweave/verdandi/llm/__init__.py +52 -0
  16. nornweave-0.1.4/src/nornweave/verdandi/llm/anthropic.py +63 -0
  17. nornweave-0.1.4/src/nornweave/verdandi/llm/base.py +35 -0
  18. nornweave-0.1.4/src/nornweave/verdandi/llm/gemini.py +78 -0
  19. nornweave-0.1.4/src/nornweave/verdandi/llm/openai.py +60 -0
  20. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/parser.py +2 -1
  21. nornweave-0.1.4/src/nornweave/verdandi/summarize.py +231 -0
  22. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/app.py +2 -1
  23. nornweave-0.1.4/src/nornweave/yggdrasil/routes/v1/attachments.py +317 -0
  24. nornweave-0.1.4/src/nornweave/yggdrasil/routes/v1/messages.py +360 -0
  25. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/v1/threads.py +4 -0
  26. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/mailgun.py +4 -0
  27. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/resend.py +76 -16
  28. {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/test_email_workflows.py +2 -2
  29. {nornweave-0.1.3 → nornweave-0.1.4}/tests/helpers/ingest.py +7 -0
  30. nornweave-0.1.4/tests/integration/test_api/test_attachments.py +480 -0
  31. nornweave-0.1.4/tests/integration/test_mcp/mock_api.py +477 -0
  32. nornweave-0.1.4/tests/integration/test_mcp/test_tools.py +639 -0
  33. nornweave-0.1.4/tests/integration/test_summarize.py +154 -0
  34. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_urdr/test_storage_adapter.py +488 -0
  35. nornweave-0.1.4/tests/unit/test_verdandi/test_summarize.py +334 -0
  36. nornweave-0.1.4/tests/unit/test_yggdrasil/__init__.py +1 -0
  37. nornweave-0.1.4/tests/unit/test_yggdrasil/test_attachments_route.py +181 -0
  38. nornweave-0.1.4/tests/unit/test_yggdrasil/test_messages_route.py +272 -0
  39. nornweave-0.1.3/src/nornweave/muninn/tools.py +0 -207
  40. nornweave-0.1.3/src/nornweave/yggdrasil/routes/v1/messages.py +0 -200
  41. nornweave-0.1.3/tests/integration/test_mcp/mock_api.py +0 -261
  42. nornweave-0.1.3/tests/integration/test_mcp/test_tools.py +0 -207
  43. {nornweave-0.1.3 → nornweave-0.1.4}/.gitignore +0 -0
  44. {nornweave-0.1.3 → nornweave-0.1.4}/LICENSE +0 -0
  45. {nornweave-0.1.3 → nornweave-0.1.4}/README.md +0 -0
  46. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/__init__.py +0 -0
  47. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/__init__.py +0 -0
  48. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/base.py +0 -0
  49. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/mailgun.py +0 -0
  50. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/sendgrid.py +0 -0
  51. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/ses.py +0 -0
  52. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/cli.py +0 -0
  53. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/__init__.py +0 -0
  54. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/exceptions.py +0 -0
  55. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/storage.py +0 -0
  56. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/utils.py +0 -0
  57. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/__init__.py +0 -0
  58. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/config.py +0 -0
  59. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/__init__.py +0 -0
  60. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/attachment.py +0 -0
  61. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/event.py +0 -0
  62. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/inbox.py +0 -0
  63. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/message.py +0 -0
  64. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/muninn/__init__.py +0 -0
  65. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/search/__init__.py +0 -0
  66. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/search/embeddings.py +0 -0
  67. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/search/vector_store.py +0 -0
  68. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/__init__.py +0 -0
  69. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/rate_limiter.py +0 -0
  70. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/scheduler.py +0 -0
  71. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/sender.py +0 -0
  72. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/webhooks.py +0 -0
  73. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/__init__.py +0 -0
  74. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/gcs.py +0 -0
  75. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/local.py +0 -0
  76. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/s3.py +0 -0
  77. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/__init__.py +0 -0
  78. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/adapters/__init__.py +0 -0
  79. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/adapters/postgres.py +0 -0
  80. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/adapters/sqlite.py +0 -0
  81. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/env.py +0 -0
  82. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/script.py.mako +0 -0
  83. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/versions/.gitkeep +0 -0
  84. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +0 -0
  85. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +0 -0
  86. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/__init__.py +0 -0
  87. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/attachments.py +0 -0
  88. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/content.py +0 -0
  89. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/headers.py +0 -0
  90. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/sanitizer.py +0 -0
  91. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/threading.py +0 -0
  92. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/__init__.py +0 -0
  93. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/dependencies.py +0 -0
  94. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/middleware/__init__.py +0 -0
  95. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/middleware/auth.py +0 -0
  96. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/middleware/logging.py +0 -0
  97. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/__init__.py +0 -0
  98. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/v1/__init__.py +0 -0
  99. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/v1/inboxes.py +0 -0
  100. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/v1/search.py +0 -0
  101. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/__init__.py +0 -0
  102. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/sendgrid.py +0 -0
  103. {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/ses.py +0 -0
  104. {nornweave-0.1.3 → nornweave-0.1.4}/tests/__init__.py +0 -0
  105. {nornweave-0.1.3 → nornweave-0.1.4}/tests/conftest.py +0 -0
  106. {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/__init__.py +0 -0
  107. {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/conftest.py +0 -0
  108. {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/n8n/.gitignore +0 -0
  109. {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/n8n/README.md +0 -0
  110. {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/n8n/docker-compose.yml +0 -0
  111. {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/n8n/setup.sh +0 -0
  112. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/emails/.gitkeep +0 -0
  113. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/mailgun_reply.json +0 -0
  114. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/mailgun_simple.json +0 -0
  115. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/mailgun_with_attachment.json +0 -0
  116. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_bounced.json +0 -0
  117. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_clicked.json +0 -0
  118. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_complained.json +0 -0
  119. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_delivered.json +0 -0
  120. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_delivery_delayed.json +0 -0
  121. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_failed.json +0 -0
  122. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_opened.json +0 -0
  123. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_scheduled.json +0 -0
  124. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_sent.json +0 -0
  125. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_simple.json +0 -0
  126. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_suppressed.json +0 -0
  127. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_with_attachments.json +0 -0
  128. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/sendgrid_inline_image.json +0 -0
  129. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/sendgrid_simple.json +0 -0
  130. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/ses_reply.json +0 -0
  131. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/ses_simple.json +0 -0
  132. {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/ses_sns_simple.json +0 -0
  133. {nornweave-0.1.3 → nornweave-0.1.4}/tests/helpers/__init__.py +0 -0
  134. {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/__init__.py +0 -0
  135. {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_api/__init__.py +0 -0
  136. {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_api/test_health.py +0 -0
  137. {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_mcp/__init__.py +0 -0
  138. {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_mcp/conftest.py +0 -0
  139. {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_mcp/test_resources.py +0 -0
  140. {nornweave-0.1.3 → nornweave-0.1.4}/tests/mocks/__init__.py +0 -0
  141. {nornweave-0.1.3 → nornweave-0.1.4}/tests/mocks/email_provider.py +0 -0
  142. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/__init__.py +0 -0
  143. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_adapters/__init__.py +0 -0
  144. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_adapters/test_resend.py +0 -0
  145. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_adapters/test_sendgrid.py +0 -0
  146. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_adapters/test_ses.py +0 -0
  147. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_huginn/__init__.py +0 -0
  148. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_huginn/test_client.py +0 -0
  149. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_models/__init__.py +0 -0
  150. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_models/test_events.py +0 -0
  151. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_urdr/__init__.py +0 -0
  152. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_urdr/conftest.py +0 -0
  153. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/__init__.py +0 -0
  154. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_attachments.py +0 -0
  155. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_content.py +0 -0
  156. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_headers.py +0 -0
  157. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_parser.py +0 -0
  158. {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_threading.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nornweave
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Open-source, self-hosted Inbox-as-a-Service API for AI Agents
5
5
  Project-URL: Homepage, https://github.com/DataCovey/nornweave
6
6
  Project-URL: Documentation, https://nornweave.datacovey.com/docs/
@@ -38,8 +38,12 @@ Requires-Dist: sqlalchemy[asyncio]>=2.0.36
38
38
  Requires-Dist: svix>=1.24.0
39
39
  Requires-Dist: uvicorn[standard]>=0.32.0
40
40
  Provides-Extra: all
41
+ Requires-Dist: anthropic>=0.42.0; extra == 'all'
41
42
  Requires-Dist: asyncpg>=0.30.0; extra == 'all'
43
+ Requires-Dist: boto3>=1.35.0; extra == 'all'
42
44
  Requires-Dist: fastmcp>=2.0.0; extra == 'all'
45
+ Requires-Dist: google-cloud-storage>=2.18.0; extra == 'all'
46
+ Requires-Dist: google-genai>=1.0.0; extra == 'all'
43
47
  Requires-Dist: mcp>=1.0.0; extra == 'all'
44
48
  Requires-Dist: openai>=1.57.0; extra == 'all'
45
49
  Requires-Dist: pgvector>=0.3.6; extra == 'all'
@@ -47,6 +51,8 @@ Requires-Dist: psycopg2-binary>=2.9.0; extra == 'all'
47
51
  Requires-Dist: pypdf>=5.1.0; extra == 'all'
48
52
  Requires-Dist: python-magic>=0.4.27; extra == 'all'
49
53
  Requires-Dist: redis>=5.2.0; extra == 'all'
54
+ Provides-Extra: anthropic
55
+ Requires-Dist: anthropic>=0.42.0; extra == 'anthropic'
50
56
  Provides-Extra: attachments
51
57
  Requires-Dist: pypdf>=5.1.0; extra == 'attachments'
52
58
  Requires-Dist: python-magic>=0.4.27; extra == 'attachments'
@@ -59,14 +65,26 @@ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
59
65
  Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
60
66
  Requires-Dist: pytest>=8.3.0; extra == 'dev'
61
67
  Requires-Dist: ruff>=0.8.0; extra == 'dev'
68
+ Provides-Extra: gcs
69
+ Requires-Dist: google-cloud-storage>=2.18.0; extra == 'gcs'
70
+ Provides-Extra: gemini
71
+ Requires-Dist: google-genai>=1.0.0; extra == 'gemini'
72
+ Provides-Extra: llm
73
+ Requires-Dist: anthropic>=0.42.0; extra == 'llm'
74
+ Requires-Dist: google-genai>=1.0.0; extra == 'llm'
75
+ Requires-Dist: openai>=1.57.0; extra == 'llm'
62
76
  Provides-Extra: mcp
63
77
  Requires-Dist: fastmcp>=2.0.0; extra == 'mcp'
64
78
  Requires-Dist: mcp>=1.0.0; extra == 'mcp'
79
+ Provides-Extra: openai
80
+ Requires-Dist: openai>=1.57.0; extra == 'openai'
65
81
  Provides-Extra: postgres
66
82
  Requires-Dist: asyncpg>=0.30.0; extra == 'postgres'
67
83
  Requires-Dist: psycopg2-binary>=2.9.0; extra == 'postgres'
68
84
  Provides-Extra: ratelimit
69
85
  Requires-Dist: redis>=5.2.0; extra == 'ratelimit'
86
+ Provides-Extra: s3
87
+ Requires-Dist: boto3>=1.35.0; extra == 's3'
70
88
  Provides-Extra: search
71
89
  Requires-Dist: openai>=1.57.0; extra == 'search'
72
90
  Requires-Dist: pgvector>=0.3.6; extra == 'search'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nornweave"
3
- version = "0.1.3"
3
+ version = "0.1.4"
4
4
  description = "Open-source, self-hosted Inbox-as-a-Service API for AI Agents"
5
5
  readme = "README.md"
6
6
  license = { text = "Apache-2.0" }
@@ -62,9 +62,30 @@ search = [
62
62
  ratelimit = [
63
63
  "redis>=5.2.0",
64
64
  ]
65
+ # AWS S3 attachment storage
66
+ s3 = [
67
+ "boto3>=1.35.0",
68
+ ]
69
+ # Google Cloud Storage attachment storage
70
+ gcs = [
71
+ "google-cloud-storage>=2.18.0",
72
+ ]
73
+ # LLM providers for thread summarization
74
+ openai = [
75
+ "openai>=1.57.0",
76
+ ]
77
+ anthropic = [
78
+ "anthropic>=0.42.0",
79
+ ]
80
+ gemini = [
81
+ "google-genai>=1.0.0",
82
+ ]
83
+ llm = [
84
+ "nornweave[openai,anthropic,gemini]",
85
+ ]
65
86
  # All optional dependencies
66
87
  all = [
67
- "nornweave[postgres,mcp,attachments,search,ratelimit]",
88
+ "nornweave[postgres,mcp,attachments,search,ratelimit,s3,gcs,llm]",
68
89
  ]
69
90
  # Development dependencies
70
91
  dev = [
@@ -197,7 +218,13 @@ module = [
197
218
  "talon",
198
219
  "talon.*",
199
220
  "boto3",
221
+ "google",
222
+ "google.*",
200
223
  "google.cloud.*",
224
+ "google.genai",
225
+ "google.genai.*",
226
+ "anthropic",
227
+ "anthropic.*",
201
228
  "pdfplumber",
202
229
  "svix",
203
230
  "svix.*",
@@ -253,38 +253,88 @@ class ResendAdapter(EmailProvider):
253
253
  result: dict[str, Any] = response.json()
254
254
  return result
255
255
 
256
- async def fetch_attachment_content(self, email_id: str, attachment_id: str) -> bytes:
256
+ async def fetch_attachment_content(
257
+ self, email_id: str, attachment_id: str, *, inbound: bool = True
258
+ ) -> bytes:
257
259
  """Fetch attachment content from Resend API.
258
260
 
261
+ The Resend API returns attachment metadata with a download_url.
262
+ This method fetches the metadata, then downloads the actual content.
263
+
264
+ See:
265
+ - Inbound: https://resend.com/docs/api-reference/emails/retrieve-received-email-attachment
266
+ - Outbound: https://resend.com/docs/api-reference/emails/retrieve-email-attachment
267
+
259
268
  Args:
260
269
  email_id: Resend email ID
261
270
  attachment_id: Attachment ID from webhook/email data
271
+ inbound: If True, use receiving endpoint; if False, use sending endpoint
262
272
 
263
273
  Returns:
264
274
  Attachment binary content
265
275
  """
266
- url = f"{self._api_url}/emails/receiving/{email_id}/attachments/{attachment_id}"
276
+ # Step 1: Get attachment metadata (contains download_url)
277
+ # Use different endpoints for inbound vs outbound emails
278
+ if inbound:
279
+ metadata_url = (
280
+ f"{self._api_url}/emails/receiving/{email_id}/attachments/{attachment_id}"
281
+ )
282
+ else:
283
+ metadata_url = f"{self._api_url}/emails/{email_id}/attachments/{attachment_id}"
267
284
 
268
- logger.debug("Fetching attachment %s from email %s", attachment_id, email_id)
285
+ logger.debug("Fetching attachment metadata %s from email %s", attachment_id, email_id)
269
286
 
270
287
  async with httpx.AsyncClient() as client:
271
288
  response = await client.get(
272
- url,
289
+ metadata_url,
273
290
  headers={
274
291
  "Authorization": f"Bearer {self._api_key}",
275
292
  },
276
- timeout=60.0,
293
+ timeout=30.0,
277
294
  )
278
295
 
279
296
  if response.status_code != 200:
280
297
  logger.error(
281
- "Resend API error fetching attachment: %s - %s",
298
+ "Resend API error fetching attachment metadata: %s - %s",
282
299
  response.status_code,
283
300
  response.text,
284
301
  )
285
302
  response.raise_for_status()
286
303
 
287
- return response.content
304
+ metadata = response.json()
305
+ download_url = metadata.get("download_url")
306
+
307
+ if not download_url:
308
+ raise ValueError(f"No download_url in attachment metadata: {metadata}")
309
+
310
+ logger.debug(
311
+ "Got attachment metadata: filename=%s, size=%s, downloading from %s",
312
+ metadata.get("filename"),
313
+ metadata.get("size"),
314
+ download_url[:50] + "..." if len(download_url) > 50 else download_url,
315
+ )
316
+
317
+ # Step 2: Download actual content from the CDN URL
318
+ content_response = await client.get(
319
+ download_url,
320
+ timeout=60.0,
321
+ follow_redirects=True,
322
+ )
323
+
324
+ if content_response.status_code != 200:
325
+ logger.error(
326
+ "Error downloading attachment content: %s - %s",
327
+ content_response.status_code,
328
+ content_response.text[:200] if content_response.text else "No content",
329
+ )
330
+ content_response.raise_for_status()
331
+
332
+ logger.debug(
333
+ "Downloaded attachment content: %d bytes",
334
+ len(content_response.content),
335
+ )
336
+
337
+ return content_response.content
288
338
 
289
339
  def parse_inbound_webhook(self, payload: dict[str, Any]) -> InboundMessage:
290
340
  """Parse Resend inbound webhook payload into standardized InboundMessage.
@@ -3,7 +3,7 @@
3
3
  from functools import lru_cache
4
4
  from typing import Literal
5
5
 
6
- from pydantic import Field
6
+ from pydantic import Field, model_validator
7
7
  from pydantic_settings import BaseSettings, SettingsConfigDict
8
8
 
9
9
 
@@ -146,6 +146,44 @@ class Settings(BaseSettings):
146
146
  description="Maximum number of attachments per message",
147
147
  )
148
148
 
149
+ # -------------------------------------------------------------------------
150
+ # LLM Thread Summarization Configuration
151
+ # -------------------------------------------------------------------------
152
+ llm_provider: Literal["openai", "anthropic", "gemini"] | None = Field(
153
+ default=None,
154
+ alias="LLM_PROVIDER",
155
+ description="LLM provider for thread summarization. None = feature disabled.",
156
+ )
157
+ llm_api_key: str = Field(
158
+ default="",
159
+ alias="LLM_API_KEY",
160
+ description="API key for the selected LLM provider",
161
+ )
162
+ llm_model: str = Field(
163
+ default="",
164
+ alias="LLM_MODEL",
165
+ description="Model override (auto-selected per provider if empty)",
166
+ )
167
+ llm_summary_prompt: str = Field(
168
+ default=(
169
+ "You are an email thread summarizer. Given a chronological email conversation, "
170
+ "produce a concise summary that captures:\n"
171
+ "- Key topics discussed\n"
172
+ "- Decisions made or actions agreed upon\n"
173
+ "- Open questions or pending items\n"
174
+ "- Current status of the conversation\n\n"
175
+ "Keep the summary under 300 words. Use bullet points for clarity.\n"
176
+ "Do not include greetings, sign-offs, or meta-commentary."
177
+ ),
178
+ alias="LLM_SUMMARY_PROMPT",
179
+ description="System prompt for thread summarization",
180
+ )
181
+ llm_daily_token_limit: int = Field(
182
+ default=1_000_000,
183
+ alias="LLM_DAILY_TOKEN_LIMIT",
184
+ description="Max tokens per day for summarization (0 = unlimited)",
185
+ )
186
+
149
187
  # -------------------------------------------------------------------------
150
188
  # Content Extraction Configuration (Talon)
151
189
  # -------------------------------------------------------------------------
@@ -165,6 +203,17 @@ class Settings(BaseSettings):
165
203
  description="Return original content if extraction fails",
166
204
  )
167
205
 
206
+ @model_validator(mode="after")
207
+ def validate_llm_config(self) -> Settings:
208
+ """Validate LLM configuration: API key is required when provider is set."""
209
+ if self.llm_provider is not None and not self.llm_api_key:
210
+ msg = (
211
+ f"LLM_API_KEY is required when LLM_PROVIDER is set to '{self.llm_provider}'. "
212
+ "Set LLM_API_KEY in your environment or .env file."
213
+ )
214
+ raise ValueError(msg)
215
+ return self
216
+
168
217
 
169
218
  @lru_cache
170
219
  def get_settings() -> Settings:
@@ -2,7 +2,7 @@
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
  from dataclasses import dataclass, field
5
- from datetime import datetime
5
+ from datetime import date, datetime
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from nornweave.models.attachment import AttachmentDisposition, SendAttachment
@@ -252,6 +252,31 @@ class StorageInterface(ABC):
252
252
  """Search messages by content (ILIKE/LIKE on content_clean and content_raw)."""
253
253
  ...
254
254
 
255
+ @abstractmethod
256
+ async def search_messages_advanced(
257
+ self,
258
+ *,
259
+ inbox_id: str | None = None,
260
+ thread_id: str | None = None,
261
+ query: str | None = None,
262
+ limit: int = 50,
263
+ offset: int = 0,
264
+ ) -> tuple[list[Message], int]:
265
+ """
266
+ Search messages with flexible filters.
267
+
268
+ Args:
269
+ inbox_id: Filter by inbox (optional)
270
+ thread_id: Filter by thread (optional)
271
+ query: Text search across subject, text, from_address, attachment filenames
272
+ limit: Maximum results to return
273
+ offset: Pagination offset
274
+
275
+ Returns:
276
+ Tuple of (messages, total_count)
277
+ """
278
+ ...
279
+
255
280
  # -------------------------------------------------------------------------
256
281
  # Event methods (Phase 3 webhooks)
257
282
  # -------------------------------------------------------------------------
@@ -291,6 +316,8 @@ class StorageInterface(ABC):
291
316
  content_id: str | None = None,
292
317
  storage_path: str | None = None,
293
318
  storage_backend: str | None = None,
319
+ content_hash: str | None = None,
320
+ content: bytes | None = None,
294
321
  ) -> str:
295
322
  """Create attachment record. Returns attachment ID."""
296
323
  ...
@@ -305,6 +332,28 @@ class StorageInterface(ABC):
305
332
  """List attachments for a message."""
306
333
  ...
307
334
 
335
+ @abstractmethod
336
+ async def list_attachments_for_thread(
337
+ self,
338
+ thread_id: str,
339
+ *,
340
+ limit: int = 100,
341
+ offset: int = 0,
342
+ ) -> list[dict[str, Any]]:
343
+ """List attachments for all messages in a thread."""
344
+ ...
345
+
346
+ @abstractmethod
347
+ async def list_attachments_for_inbox(
348
+ self,
349
+ inbox_id: str,
350
+ *,
351
+ limit: int = 100,
352
+ offset: int = 0,
353
+ ) -> list[dict[str, Any]]:
354
+ """List attachments for all messages in an inbox."""
355
+ ...
356
+
308
357
  @abstractmethod
309
358
  async def delete_attachment(self, attachment_id: str) -> bool:
310
359
  """Delete attachment. Returns True if deleted."""
@@ -331,6 +380,19 @@ class StorageInterface(ABC):
331
380
  """Get thread by normalized subject within time window (for subject-based threading)."""
332
381
  ...
333
382
 
383
+ # -------------------------------------------------------------------------
384
+ # LLM Token Usage methods
385
+ # -------------------------------------------------------------------------
386
+ @abstractmethod
387
+ async def get_token_usage(self, usage_date: date) -> int:
388
+ """Get total tokens used for a given date. Returns 0 if no record exists."""
389
+ ...
390
+
391
+ @abstractmethod
392
+ async def record_token_usage(self, usage_date: date, tokens: int) -> None:
393
+ """Record token usage for a given date. Creates or increments the daily counter."""
394
+ ...
395
+
334
396
 
335
397
  class EmailProvider(ABC):
336
398
  """Abstract email provider (BYOP). Implementations: Mailgun, SES, SendGrid, Resend."""
@@ -173,25 +173,36 @@ class NornWeaveClient:
173
173
 
174
174
  async def list_messages(
175
175
  self,
176
- inbox_id: str,
176
+ inbox_id: str | None = None,
177
+ thread_id: str | None = None,
178
+ q: str | None = None,
177
179
  limit: int = 50,
178
180
  offset: int = 0,
179
181
  ) -> dict[str, Any]:
180
- """List messages for an inbox.
182
+ """List and search messages with flexible filters.
183
+
184
+ At least one of inbox_id or thread_id must be provided.
181
185
 
182
186
  Args:
183
- inbox_id: The inbox ID.
187
+ inbox_id: Filter by inbox ID (optional).
188
+ thread_id: Filter by thread ID (optional).
189
+ q: Text search query (optional).
184
190
  limit: Maximum number of messages to return.
185
191
  offset: Number of messages to skip.
186
192
 
187
193
  Returns:
188
- List response with messages.
194
+ List response with messages, count, and total.
189
195
  """
190
196
  client = await self._get_client()
191
- response = await client.get(
192
- "/v1/messages",
193
- params={"inbox_id": inbox_id, "limit": limit, "offset": offset},
194
- )
197
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
198
+ if inbox_id:
199
+ params["inbox_id"] = inbox_id
200
+ if thread_id:
201
+ params["thread_id"] = thread_id
202
+ if q:
203
+ params["q"] = q
204
+
205
+ response = await client.get("/v1/messages", params=params)
195
206
  response.raise_for_status()
196
207
  return cast("dict[str, Any]", response.json())
197
208
 
@@ -294,3 +305,121 @@ class NornWeaveClient:
294
305
  if not messages:
295
306
  return None
296
307
  return cast("dict[str, Any]", messages[-1])
308
+
309
+ # -------------------------------------------------------------------------
310
+ # Attachment Operations
311
+ # -------------------------------------------------------------------------
312
+
313
+ async def list_attachments(
314
+ self,
315
+ message_id: str | None = None,
316
+ thread_id: str | None = None,
317
+ inbox_id: str | None = None,
318
+ limit: int = 100,
319
+ offset: int = 0,
320
+ ) -> dict[str, Any]:
321
+ """List attachments filtered by message, thread, or inbox.
322
+
323
+ Args:
324
+ message_id: Filter by message ID.
325
+ thread_id: Filter by thread ID.
326
+ inbox_id: Filter by inbox ID.
327
+ limit: Maximum number of attachments to return.
328
+ offset: Number of attachments to skip.
329
+
330
+ Returns:
331
+ List response with attachment metadata.
332
+ """
333
+ client = await self._get_client()
334
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
335
+ if message_id:
336
+ params["message_id"] = message_id
337
+ elif thread_id:
338
+ params["thread_id"] = thread_id
339
+ elif inbox_id:
340
+ params["inbox_id"] = inbox_id
341
+
342
+ response = await client.get("/v1/attachments", params=params)
343
+ response.raise_for_status()
344
+ return cast("dict[str, Any]", response.json())
345
+
346
+ async def get_attachment(self, attachment_id: str) -> dict[str, Any]:
347
+ """Get attachment metadata.
348
+
349
+ Args:
350
+ attachment_id: The attachment ID.
351
+
352
+ Returns:
353
+ Attachment metadata with download_url.
354
+ """
355
+ client = await self._get_client()
356
+ response = await client.get(f"/v1/attachments/{attachment_id}")
357
+ response.raise_for_status()
358
+ return cast("dict[str, Any]", response.json())
359
+
360
+ async def get_attachment_content(
361
+ self,
362
+ attachment_id: str,
363
+ response_format: str = "base64",
364
+ ) -> dict[str, Any]:
365
+ """Get attachment content.
366
+
367
+ Args:
368
+ attachment_id: The attachment ID.
369
+ response_format: Response format - "binary" or "base64" (default: base64).
370
+
371
+ Returns:
372
+ For base64: {"content": "...", "content_type": "...", "filename": "..."}
373
+ For binary: raw bytes (handled by httpx)
374
+ """
375
+ client = await self._get_client()
376
+ response = await client.get(
377
+ f"/v1/attachments/{attachment_id}/content",
378
+ params={"format": response_format},
379
+ )
380
+ response.raise_for_status()
381
+ return cast("dict[str, Any]", response.json())
382
+
383
+ async def send_message_with_attachments(
384
+ self,
385
+ inbox_id: str,
386
+ to: list[str],
387
+ subject: str,
388
+ body: str,
389
+ attachments: list[dict[str, str]],
390
+ reply_to_thread_id: str | None = None,
391
+ ) -> dict[str, Any]:
392
+ """Send an outbound message with attachments.
393
+
394
+ Args:
395
+ inbox_id: The inbox ID to send from.
396
+ to: List of recipient email addresses.
397
+ subject: Email subject.
398
+ body: Markdown body content.
399
+ attachments: List of attachment dicts with filename, content_type, content (base64).
400
+ reply_to_thread_id: Thread ID if this is a reply.
401
+
402
+ Returns:
403
+ Send response with message_id, thread_id, status.
404
+ """
405
+ client = await self._get_client()
406
+ payload: dict[str, Any] = {
407
+ "inbox_id": inbox_id,
408
+ "to": to,
409
+ "subject": subject,
410
+ "body": body,
411
+ "attachments": [
412
+ {
413
+ "filename": att["filename"],
414
+ "content_type": att["content_type"],
415
+ "content_base64": att["content"],
416
+ }
417
+ for att in attachments
418
+ ],
419
+ }
420
+ if reply_to_thread_id:
421
+ payload["reply_to_thread_id"] = reply_to_thread_id
422
+
423
+ response = await client.post("/v1/messages", json=payload)
424
+ response.raise_for_status()
425
+ return cast("dict[str, Any]", response.json())
@@ -52,6 +52,7 @@ async def get_recent_threads(client: NornWeaveClient, inbox_id: str) -> str:
52
52
  "last_message_at": thread.get("last_message_at"),
53
53
  "message_count": message_count,
54
54
  "participants": participants,
55
+ "summary": thread.get("summary"),
55
56
  }
56
57
  )
57
58
 
@@ -114,6 +115,14 @@ def format_thread_markdown(thread: dict[str, Any]) -> str:
114
115
  lines.append(f"## Thread: {subject}")
115
116
  lines.append("")
116
117
 
118
+ # Include summary if available
119
+ summary = thread.get("summary")
120
+ if summary:
121
+ lines.append("## Summary")
122
+ lines.append("")
123
+ lines.append(summary)
124
+ lines.append("")
125
+
117
126
  messages = thread.get("messages", [])
118
127
  if not messages:
119
128
  lines.append("*No messages in this thread.*")