nornweave 0.1.2__py3-none-any.whl

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 (80) hide show
  1. nornweave/__init__.py +3 -0
  2. nornweave/adapters/__init__.py +1 -0
  3. nornweave/adapters/base.py +5 -0
  4. nornweave/adapters/mailgun.py +196 -0
  5. nornweave/adapters/resend.py +510 -0
  6. nornweave/adapters/sendgrid.py +492 -0
  7. nornweave/adapters/ses.py +824 -0
  8. nornweave/cli.py +186 -0
  9. nornweave/core/__init__.py +26 -0
  10. nornweave/core/config.py +172 -0
  11. nornweave/core/exceptions.py +25 -0
  12. nornweave/core/interfaces.py +390 -0
  13. nornweave/core/storage.py +192 -0
  14. nornweave/core/utils.py +23 -0
  15. nornweave/huginn/__init__.py +10 -0
  16. nornweave/huginn/client.py +296 -0
  17. nornweave/huginn/config.py +52 -0
  18. nornweave/huginn/resources.py +165 -0
  19. nornweave/huginn/server.py +202 -0
  20. nornweave/models/__init__.py +113 -0
  21. nornweave/models/attachment.py +136 -0
  22. nornweave/models/event.py +275 -0
  23. nornweave/models/inbox.py +33 -0
  24. nornweave/models/message.py +284 -0
  25. nornweave/models/thread.py +172 -0
  26. nornweave/muninn/__init__.py +14 -0
  27. nornweave/muninn/tools.py +207 -0
  28. nornweave/search/__init__.py +1 -0
  29. nornweave/search/embeddings.py +1 -0
  30. nornweave/search/vector_store.py +1 -0
  31. nornweave/skuld/__init__.py +1 -0
  32. nornweave/skuld/rate_limiter.py +1 -0
  33. nornweave/skuld/scheduler.py +1 -0
  34. nornweave/skuld/sender.py +25 -0
  35. nornweave/skuld/webhooks.py +1 -0
  36. nornweave/storage/__init__.py +20 -0
  37. nornweave/storage/database.py +165 -0
  38. nornweave/storage/gcs.py +144 -0
  39. nornweave/storage/local.py +152 -0
  40. nornweave/storage/s3.py +164 -0
  41. nornweave/urdr/__init__.py +14 -0
  42. nornweave/urdr/adapters/__init__.py +16 -0
  43. nornweave/urdr/adapters/base.py +385 -0
  44. nornweave/urdr/adapters/postgres.py +50 -0
  45. nornweave/urdr/adapters/sqlite.py +51 -0
  46. nornweave/urdr/migrations/env.py +94 -0
  47. nornweave/urdr/migrations/script.py.mako +26 -0
  48. nornweave/urdr/migrations/versions/.gitkeep +0 -0
  49. nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +182 -0
  50. nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +241 -0
  51. nornweave/urdr/orm.py +641 -0
  52. nornweave/verdandi/__init__.py +45 -0
  53. nornweave/verdandi/attachments.py +471 -0
  54. nornweave/verdandi/content.py +420 -0
  55. nornweave/verdandi/headers.py +404 -0
  56. nornweave/verdandi/parser.py +25 -0
  57. nornweave/verdandi/sanitizer.py +9 -0
  58. nornweave/verdandi/threading.py +359 -0
  59. nornweave/yggdrasil/__init__.py +1 -0
  60. nornweave/yggdrasil/app.py +86 -0
  61. nornweave/yggdrasil/dependencies.py +190 -0
  62. nornweave/yggdrasil/middleware/__init__.py +1 -0
  63. nornweave/yggdrasil/middleware/auth.py +1 -0
  64. nornweave/yggdrasil/middleware/logging.py +1 -0
  65. nornweave/yggdrasil/routes/__init__.py +1 -0
  66. nornweave/yggdrasil/routes/v1/__init__.py +1 -0
  67. nornweave/yggdrasil/routes/v1/inboxes.py +124 -0
  68. nornweave/yggdrasil/routes/v1/messages.py +200 -0
  69. nornweave/yggdrasil/routes/v1/search.py +84 -0
  70. nornweave/yggdrasil/routes/v1/threads.py +142 -0
  71. nornweave/yggdrasil/routes/webhooks/__init__.py +1 -0
  72. nornweave/yggdrasil/routes/webhooks/mailgun.py +136 -0
  73. nornweave/yggdrasil/routes/webhooks/resend.py +344 -0
  74. nornweave/yggdrasil/routes/webhooks/sendgrid.py +15 -0
  75. nornweave/yggdrasil/routes/webhooks/ses.py +15 -0
  76. nornweave-0.1.2.dist-info/METADATA +324 -0
  77. nornweave-0.1.2.dist-info/RECORD +80 -0
  78. nornweave-0.1.2.dist-info/WHEEL +4 -0
  79. nornweave-0.1.2.dist-info/entry_points.txt +5 -0
  80. nornweave-0.1.2.dist-info/licenses/LICENSE +201 -0
nornweave/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """NornWeave: Open-source Inbox-as-a-Service API for AI Agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Email provider adapters (BYOP)."""
@@ -0,0 +1,5 @@
1
+ """EmailProvider base (re-export from core)."""
2
+
3
+ from nornweave.core.interfaces import EmailProvider, InboundMessage
4
+
5
+ __all__ = ["EmailProvider", "InboundMessage"]
@@ -0,0 +1,196 @@
1
+ """Mailgun email provider adapter."""
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ import httpx
7
+ import markdown # type: ignore[import-untyped]
8
+
9
+ from nornweave.core.interfaces import EmailProvider, InboundMessage
10
+
11
+ if TYPE_CHECKING:
12
+ from nornweave.models.attachment import SendAttachment
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class MailgunAdapter(EmailProvider):
18
+ """Mailgun implementation of EmailProvider."""
19
+
20
+ def __init__(self, api_key: str, domain: str, api_url: str = "https://api.mailgun.net") -> None:
21
+ """Initialize Mailgun adapter.
22
+
23
+ Args:
24
+ api_key: Mailgun API key
25
+ domain: Mailgun domain (e.g., mail.example.com)
26
+ api_url: Mailgun API URL (default: https://api.mailgun.net)
27
+ """
28
+ self._api_key = api_key
29
+ self._domain = domain
30
+ self._api_url = api_url.rstrip("/")
31
+
32
+ async def send_email(
33
+ self,
34
+ to: list[str],
35
+ subject: str,
36
+ body: str,
37
+ *,
38
+ from_address: str,
39
+ reply_to: str | None = None,
40
+ headers: dict[str, str] | None = None,
41
+ message_id: str | None = None,
42
+ in_reply_to: str | None = None,
43
+ references: list[str] | None = None,
44
+ cc: list[str] | None = None,
45
+ bcc: list[str] | None = None,
46
+ attachments: list[SendAttachment] | None = None,
47
+ html_body: str | None = None,
48
+ ) -> str:
49
+ """Send email via Mailgun API.
50
+
51
+ Args:
52
+ to: List of recipient email addresses
53
+ subject: Email subject
54
+ body: Email body in Markdown format
55
+ from_address: Sender email address
56
+ reply_to: Optional reply-to address
57
+ headers: Optional custom headers
58
+ message_id: Optional custom Message-ID
59
+ in_reply_to: Optional In-Reply-To header for threading
60
+ references: Optional References header for threading
61
+ cc: Optional CC recipients
62
+ bcc: Optional BCC recipients
63
+ attachments: Optional list of attachments
64
+ html_body: Optional pre-rendered HTML body (if not provided, body is converted from Markdown)
65
+
66
+ Returns:
67
+ Provider message ID from Mailgun
68
+ """
69
+ url = f"{self._api_url}/v3/{self._domain}/messages"
70
+
71
+ # Convert Markdown body to HTML if html_body not provided
72
+ html_content = html_body or markdown.markdown(body)
73
+
74
+ # Build form data
75
+ data: dict[str, Any] = {
76
+ "from": from_address,
77
+ "to": to,
78
+ "subject": subject,
79
+ "text": body,
80
+ "html": html_content,
81
+ }
82
+
83
+ if reply_to:
84
+ data["h:Reply-To"] = reply_to
85
+
86
+ if cc:
87
+ data["cc"] = cc
88
+
89
+ if bcc:
90
+ data["bcc"] = bcc
91
+
92
+ if message_id:
93
+ data["h:Message-Id"] = message_id
94
+
95
+ if in_reply_to:
96
+ data["h:In-Reply-To"] = in_reply_to
97
+
98
+ if references:
99
+ data["h:References"] = " ".join(references)
100
+
101
+ # Add custom headers
102
+ if headers:
103
+ for key, value in headers.items():
104
+ data[f"h:{key}"] = value
105
+
106
+ # Prepare files for attachments
107
+ files: list[tuple[str, tuple[str, bytes, str]]] = []
108
+ if attachments:
109
+ for att in attachments:
110
+ content_bytes = att.get_content_bytes()
111
+ if att.filename and content_bytes and att.content_type:
112
+ files.append(("attachment", (att.filename, content_bytes, att.content_type)))
113
+
114
+ logger.debug("Sending email via Mailgun to %s", to)
115
+
116
+ async with httpx.AsyncClient() as client:
117
+ response = await client.post(
118
+ url,
119
+ auth=("api", self._api_key),
120
+ data=data,
121
+ files=files if files else None,
122
+ timeout=30.0,
123
+ )
124
+
125
+ if response.status_code != 200:
126
+ logger.error(
127
+ "Mailgun API error: %s - %s",
128
+ response.status_code,
129
+ response.text,
130
+ )
131
+ response.raise_for_status()
132
+
133
+ result = response.json()
134
+ provider_message_id: str = result.get("id", "")
135
+ logger.info("Email sent via Mailgun: %s", provider_message_id)
136
+ return provider_message_id
137
+
138
+ def parse_inbound_webhook(self, payload: dict[str, Any]) -> InboundMessage:
139
+ """Parse Mailgun inbound webhook payload into standardized InboundMessage.
140
+
141
+ Mailgun sends inbound emails as multipart/form-data with fields like:
142
+ - sender, from, recipient, subject
143
+ - body-plain, body-html, stripped-text, stripped-html
144
+ - Message-Id, In-Reply-To, References
145
+ - message-headers (JSON array of [name, value] pairs)
146
+ - timestamp, signature, token
147
+ - attachment-count, attachment-1, attachment-2, etc.
148
+ """
149
+ from datetime import UTC, datetime
150
+
151
+ # Parse sender - extract email from "Name <email>" format
152
+ from_field = payload.get("from", payload.get("sender", ""))
153
+ from_address = from_field
154
+ if "<" in from_field and ">" in from_field:
155
+ from_address = from_field.split("<")[1].split(">")[0]
156
+
157
+ # Parse recipient
158
+ to_address = payload.get("recipient", "")
159
+
160
+ # Parse headers from JSON array if present
161
+ headers: dict[str, str] = {}
162
+ headers_raw = payload.get("message-headers", "")
163
+ if headers_raw:
164
+ try:
165
+ import json
166
+
167
+ headers_list = json.loads(headers_raw)
168
+ headers = {h[0]: h[1] for h in headers_list if len(h) >= 2}
169
+ except (json.JSONDecodeError, TypeError):
170
+ pass
171
+
172
+ # Parse references into list
173
+ references_str = payload.get("References", "") or ""
174
+ references = [ref.strip() for ref in references_str.split() if ref.strip()]
175
+
176
+ # Parse timestamp
177
+ timestamp_unix = payload.get("timestamp")
178
+ if timestamp_unix:
179
+ timestamp = datetime.fromtimestamp(int(timestamp_unix), tz=UTC)
180
+ else:
181
+ timestamp = datetime.now(UTC)
182
+
183
+ return InboundMessage(
184
+ from_address=from_address,
185
+ to_address=to_address,
186
+ subject=payload.get("subject", ""),
187
+ body_plain=payload.get("body-plain", ""),
188
+ body_html=payload.get("body-html"),
189
+ stripped_text=payload.get("stripped-text"),
190
+ stripped_html=payload.get("stripped-html"),
191
+ message_id=payload.get("Message-Id"),
192
+ in_reply_to=payload.get("In-Reply-To") or None,
193
+ references=references,
194
+ headers=headers,
195
+ timestamp=timestamp,
196
+ )