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.
- nornweave/__init__.py +3 -0
- nornweave/adapters/__init__.py +1 -0
- nornweave/adapters/base.py +5 -0
- nornweave/adapters/mailgun.py +196 -0
- nornweave/adapters/resend.py +510 -0
- nornweave/adapters/sendgrid.py +492 -0
- nornweave/adapters/ses.py +824 -0
- nornweave/cli.py +186 -0
- nornweave/core/__init__.py +26 -0
- nornweave/core/config.py +172 -0
- nornweave/core/exceptions.py +25 -0
- nornweave/core/interfaces.py +390 -0
- nornweave/core/storage.py +192 -0
- nornweave/core/utils.py +23 -0
- nornweave/huginn/__init__.py +10 -0
- nornweave/huginn/client.py +296 -0
- nornweave/huginn/config.py +52 -0
- nornweave/huginn/resources.py +165 -0
- nornweave/huginn/server.py +202 -0
- nornweave/models/__init__.py +113 -0
- nornweave/models/attachment.py +136 -0
- nornweave/models/event.py +275 -0
- nornweave/models/inbox.py +33 -0
- nornweave/models/message.py +284 -0
- nornweave/models/thread.py +172 -0
- nornweave/muninn/__init__.py +14 -0
- nornweave/muninn/tools.py +207 -0
- nornweave/search/__init__.py +1 -0
- nornweave/search/embeddings.py +1 -0
- nornweave/search/vector_store.py +1 -0
- nornweave/skuld/__init__.py +1 -0
- nornweave/skuld/rate_limiter.py +1 -0
- nornweave/skuld/scheduler.py +1 -0
- nornweave/skuld/sender.py +25 -0
- nornweave/skuld/webhooks.py +1 -0
- nornweave/storage/__init__.py +20 -0
- nornweave/storage/database.py +165 -0
- nornweave/storage/gcs.py +144 -0
- nornweave/storage/local.py +152 -0
- nornweave/storage/s3.py +164 -0
- nornweave/urdr/__init__.py +14 -0
- nornweave/urdr/adapters/__init__.py +16 -0
- nornweave/urdr/adapters/base.py +385 -0
- nornweave/urdr/adapters/postgres.py +50 -0
- nornweave/urdr/adapters/sqlite.py +51 -0
- nornweave/urdr/migrations/env.py +94 -0
- nornweave/urdr/migrations/script.py.mako +26 -0
- nornweave/urdr/migrations/versions/.gitkeep +0 -0
- nornweave/urdr/migrations/versions/20260131_0001_initial_schema.py +182 -0
- nornweave/urdr/migrations/versions/20260131_0002_extended_schema.py +241 -0
- nornweave/urdr/orm.py +641 -0
- nornweave/verdandi/__init__.py +45 -0
- nornweave/verdandi/attachments.py +471 -0
- nornweave/verdandi/content.py +420 -0
- nornweave/verdandi/headers.py +404 -0
- nornweave/verdandi/parser.py +25 -0
- nornweave/verdandi/sanitizer.py +9 -0
- nornweave/verdandi/threading.py +359 -0
- nornweave/yggdrasil/__init__.py +1 -0
- nornweave/yggdrasil/app.py +86 -0
- nornweave/yggdrasil/dependencies.py +190 -0
- nornweave/yggdrasil/middleware/__init__.py +1 -0
- nornweave/yggdrasil/middleware/auth.py +1 -0
- nornweave/yggdrasil/middleware/logging.py +1 -0
- nornweave/yggdrasil/routes/__init__.py +1 -0
- nornweave/yggdrasil/routes/v1/__init__.py +1 -0
- nornweave/yggdrasil/routes/v1/inboxes.py +124 -0
- nornweave/yggdrasil/routes/v1/messages.py +200 -0
- nornweave/yggdrasil/routes/v1/search.py +84 -0
- nornweave/yggdrasil/routes/v1/threads.py +142 -0
- nornweave/yggdrasil/routes/webhooks/__init__.py +1 -0
- nornweave/yggdrasil/routes/webhooks/mailgun.py +136 -0
- nornweave/yggdrasil/routes/webhooks/resend.py +344 -0
- nornweave/yggdrasil/routes/webhooks/sendgrid.py +15 -0
- nornweave/yggdrasil/routes/webhooks/ses.py +15 -0
- nornweave-0.1.2.dist-info/METADATA +324 -0
- nornweave-0.1.2.dist-info/RECORD +80 -0
- nornweave-0.1.2.dist-info/WHEEL +4 -0
- nornweave-0.1.2.dist-info/entry_points.txt +5 -0
- nornweave-0.1.2.dist-info/licenses/LICENSE +201 -0
nornweave/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Email provider adapters (BYOP)."""
|
|
@@ -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
|
+
)
|