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.
- {nornweave-0.1.3 → nornweave-0.1.4}/PKG-INFO +19 -1
- {nornweave-0.1.3 → nornweave-0.1.4}/pyproject.toml +29 -2
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/resend.py +57 -7
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/config.py +50 -1
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/interfaces.py +63 -1
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/client.py +137 -8
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/resources.py +9 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/server.py +147 -8
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/thread.py +2 -0
- nornweave-0.1.4/src/nornweave/muninn/tools.py +455 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/database.py +1 -1
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/adapters/base.py +173 -3
- nornweave-0.1.4/src/nornweave/urdr/migrations/versions/20260205_0003_llm_thread_summary.py +54 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/orm.py +27 -1
- nornweave-0.1.4/src/nornweave/verdandi/llm/__init__.py +52 -0
- nornweave-0.1.4/src/nornweave/verdandi/llm/anthropic.py +63 -0
- nornweave-0.1.4/src/nornweave/verdandi/llm/base.py +35 -0
- nornweave-0.1.4/src/nornweave/verdandi/llm/gemini.py +78 -0
- nornweave-0.1.4/src/nornweave/verdandi/llm/openai.py +60 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/parser.py +2 -1
- nornweave-0.1.4/src/nornweave/verdandi/summarize.py +231 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/app.py +2 -1
- nornweave-0.1.4/src/nornweave/yggdrasil/routes/v1/attachments.py +317 -0
- nornweave-0.1.4/src/nornweave/yggdrasil/routes/v1/messages.py +360 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/v1/threads.py +4 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/mailgun.py +4 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/resend.py +76 -16
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/test_email_workflows.py +2 -2
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/helpers/ingest.py +7 -0
- nornweave-0.1.4/tests/integration/test_api/test_attachments.py +480 -0
- nornweave-0.1.4/tests/integration/test_mcp/mock_api.py +477 -0
- nornweave-0.1.4/tests/integration/test_mcp/test_tools.py +639 -0
- nornweave-0.1.4/tests/integration/test_summarize.py +154 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_urdr/test_storage_adapter.py +488 -0
- nornweave-0.1.4/tests/unit/test_verdandi/test_summarize.py +334 -0
- nornweave-0.1.4/tests/unit/test_yggdrasil/__init__.py +1 -0
- nornweave-0.1.4/tests/unit/test_yggdrasil/test_attachments_route.py +181 -0
- nornweave-0.1.4/tests/unit/test_yggdrasil/test_messages_route.py +272 -0
- nornweave-0.1.3/src/nornweave/muninn/tools.py +0 -207
- nornweave-0.1.3/src/nornweave/yggdrasil/routes/v1/messages.py +0 -200
- nornweave-0.1.3/tests/integration/test_mcp/mock_api.py +0 -261
- nornweave-0.1.3/tests/integration/test_mcp/test_tools.py +0 -207
- {nornweave-0.1.3 → nornweave-0.1.4}/.gitignore +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/LICENSE +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/README.md +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/base.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/mailgun.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/sendgrid.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/adapters/ses.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/cli.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/exceptions.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/storage.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/core/utils.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/huginn/config.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/attachment.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/event.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/inbox.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/models/message.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/muninn/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/search/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/search/embeddings.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/search/vector_store.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/rate_limiter.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/scheduler.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/sender.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/skuld/webhooks.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/gcs.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/local.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/storage/s3.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/adapters/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/adapters/postgres.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/adapters/sqlite.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/env.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/script.py.mako +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/versions/.gitkeep +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/attachments.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/content.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/headers.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/sanitizer.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/verdandi/threading.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/dependencies.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/middleware/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/middleware/auth.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/middleware/logging.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/v1/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/v1/inboxes.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/v1/search.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/sendgrid.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/src/nornweave/yggdrasil/routes/webhooks/ses.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/conftest.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/conftest.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/n8n/.gitignore +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/n8n/README.md +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/n8n/docker-compose.yml +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/e2e/n8n/setup.sh +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/emails/.gitkeep +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/mailgun_reply.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/mailgun_simple.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/mailgun_with_attachment.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_bounced.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_clicked.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_complained.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_delivered.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_delivery_delayed.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_failed.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_opened.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_scheduled.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_sent.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_simple.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_suppressed.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/resend_with_attachments.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/sendgrid_inline_image.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/sendgrid_simple.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/ses_reply.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/ses_simple.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/fixtures/webhooks/ses_sns_simple.json +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/helpers/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_api/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_api/test_health.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_mcp/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_mcp/conftest.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/integration/test_mcp/test_resources.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/mocks/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/mocks/email_provider.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_adapters/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_adapters/test_resend.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_adapters/test_sendgrid.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_adapters/test_ses.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_huginn/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_huginn/test_client.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_models/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_models/test_events.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_urdr/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_urdr/conftest.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/__init__.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_attachments.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_content.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_headers.py +0 -0
- {nornweave-0.1.3 → nornweave-0.1.4}/tests/unit/test_verdandi/test_parser.py +0 -0
- {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
|
+
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
|
+
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(
|
|
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
|
-
|
|
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
|
-
|
|
289
|
+
metadata_url,
|
|
273
290
|
headers={
|
|
274
291
|
"Authorization": f"Bearer {self._api_key}",
|
|
275
292
|
},
|
|
276
|
-
timeout=
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
params
|
|
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.*")
|