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
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Email header parsing and generation utilities.
|
|
2
|
+
|
|
3
|
+
Provides RFC 5322 compliant header handling for:
|
|
4
|
+
- Message-ID generation
|
|
5
|
+
- References/In-Reply-To header building
|
|
6
|
+
- Header parsing and extraction
|
|
7
|
+
- Email address parsing
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from email.utils import formataddr, formatdate, parseaddr
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ParsedEmailAddress:
|
|
20
|
+
"""Parsed email address with display name and address parts."""
|
|
21
|
+
|
|
22
|
+
display_name: str
|
|
23
|
+
email: str
|
|
24
|
+
original: str
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def formatted(self) -> str:
|
|
28
|
+
"""Get formatted address 'Name <email>' or just 'email' if no name."""
|
|
29
|
+
if self.display_name:
|
|
30
|
+
return formataddr((self.display_name, self.email))
|
|
31
|
+
return self.email
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate_message_id(domain: str, *, timestamp: datetime | None = None) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Generate an RFC 5322 compliant Message-ID.
|
|
37
|
+
|
|
38
|
+
Format: <YYYYMMDDHHMMSS.UUID@domain>
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
domain: Domain to use in the Message-ID
|
|
42
|
+
timestamp: Optional timestamp (defaults to now)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Message-ID string with angle brackets
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> generate_message_id("example.com")
|
|
49
|
+
'<20260131153045.a1b2c3d4e5f6@example.com>'
|
|
50
|
+
"""
|
|
51
|
+
timestamp = timestamp or datetime.utcnow()
|
|
52
|
+
ts_str = timestamp.strftime("%Y%m%d%H%M%S")
|
|
53
|
+
unique_id = uuid.uuid4().hex[:12]
|
|
54
|
+
return f"<{ts_str}.{unique_id}@{domain}>"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_email_address(address: str) -> ParsedEmailAddress:
|
|
58
|
+
"""
|
|
59
|
+
Parse an email address into display name and email parts.
|
|
60
|
+
|
|
61
|
+
Handles formats like:
|
|
62
|
+
- "user@example.com"
|
|
63
|
+
- "John Doe <john@example.com>"
|
|
64
|
+
- "<john@example.com>"
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
address: Raw email address string
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
ParsedEmailAddress with name and email parts
|
|
71
|
+
"""
|
|
72
|
+
name, email = parseaddr(address)
|
|
73
|
+
return ParsedEmailAddress(
|
|
74
|
+
display_name=name.strip(),
|
|
75
|
+
email=email.strip().lower(),
|
|
76
|
+
original=address,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_email_list(addresses: str | list[str] | None) -> list[ParsedEmailAddress]:
|
|
81
|
+
"""
|
|
82
|
+
Parse a list of email addresses.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
addresses: Single address, comma-separated string, or list
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
List of ParsedEmailAddress objects
|
|
89
|
+
"""
|
|
90
|
+
if not addresses:
|
|
91
|
+
return []
|
|
92
|
+
|
|
93
|
+
if isinstance(addresses, str):
|
|
94
|
+
# Split by comma, handling quoted names
|
|
95
|
+
addr_list = []
|
|
96
|
+
current = ""
|
|
97
|
+
in_quotes = False
|
|
98
|
+
in_brackets = False
|
|
99
|
+
|
|
100
|
+
for char in addresses:
|
|
101
|
+
if char == '"' and not in_brackets:
|
|
102
|
+
in_quotes = not in_quotes
|
|
103
|
+
elif char == "<":
|
|
104
|
+
in_brackets = True
|
|
105
|
+
elif char == ">":
|
|
106
|
+
in_brackets = False
|
|
107
|
+
elif char == "," and not in_quotes and not in_brackets:
|
|
108
|
+
if current.strip():
|
|
109
|
+
addr_list.append(current.strip())
|
|
110
|
+
current = ""
|
|
111
|
+
continue
|
|
112
|
+
current += char
|
|
113
|
+
|
|
114
|
+
if current.strip():
|
|
115
|
+
addr_list.append(current.strip())
|
|
116
|
+
|
|
117
|
+
return [parse_email_address(addr) for addr in addr_list]
|
|
118
|
+
|
|
119
|
+
return [parse_email_address(addr) for addr in addresses]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_references_header(
|
|
123
|
+
parent_references: list[str] | None = None,
|
|
124
|
+
parent_message_id: str | None = None,
|
|
125
|
+
*,
|
|
126
|
+
max_references: int = 20,
|
|
127
|
+
) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Build a References header for a reply message.
|
|
130
|
+
|
|
131
|
+
Per RFC 5322, References should contain Message-IDs of all ancestors.
|
|
132
|
+
We limit to max_references to prevent unbounded growth.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
parent_references: References from the parent message
|
|
136
|
+
parent_message_id: Message-ID of the parent message
|
|
137
|
+
max_references: Maximum number of references to include
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Space-separated string of Message-IDs for the References header
|
|
141
|
+
"""
|
|
142
|
+
refs: list[str] = []
|
|
143
|
+
|
|
144
|
+
# Add existing references
|
|
145
|
+
if parent_references:
|
|
146
|
+
for ref in parent_references:
|
|
147
|
+
normalized = normalize_message_id(ref)
|
|
148
|
+
if normalized and normalized not in refs:
|
|
149
|
+
refs.append(normalized)
|
|
150
|
+
|
|
151
|
+
# Add parent Message-ID
|
|
152
|
+
if parent_message_id:
|
|
153
|
+
normalized = normalize_message_id(parent_message_id)
|
|
154
|
+
if normalized and normalized not in refs:
|
|
155
|
+
refs.append(normalized)
|
|
156
|
+
|
|
157
|
+
# Trim to max (keep most recent)
|
|
158
|
+
if len(refs) > max_references:
|
|
159
|
+
refs = refs[-max_references:]
|
|
160
|
+
|
|
161
|
+
return " ".join(refs)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def normalize_message_id(message_id: str | None) -> str | None:
|
|
165
|
+
"""
|
|
166
|
+
Normalize a Message-ID to consistent format with angle brackets.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
message_id: Raw Message-ID value
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Normalized Message-ID or None if invalid
|
|
173
|
+
"""
|
|
174
|
+
if not message_id:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
mid = message_id.strip()
|
|
178
|
+
if not mid:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
# Must contain @
|
|
182
|
+
if "@" not in mid:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
# Ensure angle brackets
|
|
186
|
+
if not mid.startswith("<"):
|
|
187
|
+
mid = "<" + mid
|
|
188
|
+
if not mid.endswith(">"):
|
|
189
|
+
mid = mid + ">"
|
|
190
|
+
|
|
191
|
+
return mid
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse_references_header(references: str | None) -> list[str]:
|
|
195
|
+
"""
|
|
196
|
+
Parse a References header into a list of Message-IDs.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
references: Raw References header value
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of normalized Message-ID strings
|
|
203
|
+
"""
|
|
204
|
+
if not references:
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
# Split by whitespace and filter
|
|
208
|
+
refs = references.split()
|
|
209
|
+
result = []
|
|
210
|
+
|
|
211
|
+
for ref in refs:
|
|
212
|
+
normalized = normalize_message_id(ref.strip())
|
|
213
|
+
if normalized:
|
|
214
|
+
result.append(normalized)
|
|
215
|
+
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def parse_header_list(header_value: str | None) -> list[dict[str, str]]:
|
|
220
|
+
"""
|
|
221
|
+
Parse header list format like Mailgun's message-headers JSON.
|
|
222
|
+
|
|
223
|
+
Input format: [["Header-Name", "value"], ["Other", "value2"]]
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
header_value: JSON string or already parsed list
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
List of dicts with 'name' and 'value' keys
|
|
230
|
+
"""
|
|
231
|
+
if not header_value:
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
import json
|
|
235
|
+
|
|
236
|
+
if isinstance(header_value, str):
|
|
237
|
+
try:
|
|
238
|
+
parsed = json.loads(header_value)
|
|
239
|
+
except (json.JSONDecodeError, ValueError):
|
|
240
|
+
return []
|
|
241
|
+
else:
|
|
242
|
+
parsed = header_value
|
|
243
|
+
|
|
244
|
+
if not isinstance(parsed, list):
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
result = []
|
|
248
|
+
for item in parsed:
|
|
249
|
+
if isinstance(item, list) and len(item) >= 2:
|
|
250
|
+
result.append({"name": str(item[0]), "value": str(item[1])})
|
|
251
|
+
elif isinstance(item, dict) and "name" in item and "value" in item:
|
|
252
|
+
result.append({"name": str(item["name"]), "value": str(item["value"])})
|
|
253
|
+
|
|
254
|
+
return result
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def headers_list_to_dict(headers: list[dict[str, str]]) -> dict[str, str]:
|
|
258
|
+
"""
|
|
259
|
+
Convert header list to dictionary (last value wins for duplicates).
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
headers: List of {'name': ..., 'value': ...} dicts
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Dictionary mapping header names to values
|
|
266
|
+
"""
|
|
267
|
+
result: dict[str, str] = {}
|
|
268
|
+
for h in headers:
|
|
269
|
+
result[h["name"]] = h["value"]
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def get_header(
|
|
274
|
+
headers: dict[str, str] | list[dict[str, str]] | Any,
|
|
275
|
+
name: str,
|
|
276
|
+
*,
|
|
277
|
+
case_insensitive: bool = True,
|
|
278
|
+
) -> str | None:
|
|
279
|
+
"""
|
|
280
|
+
Get a header value by name from various header formats.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
headers: Headers as dict, list, or other format
|
|
284
|
+
name: Header name to find
|
|
285
|
+
case_insensitive: Whether to match case-insensitively
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Header value or None if not found
|
|
289
|
+
"""
|
|
290
|
+
if not headers:
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
if isinstance(headers, dict):
|
|
294
|
+
if case_insensitive:
|
|
295
|
+
name_lower = name.lower()
|
|
296
|
+
for k, v in headers.items():
|
|
297
|
+
if k.lower() == name_lower:
|
|
298
|
+
return v
|
|
299
|
+
return headers.get(name)
|
|
300
|
+
|
|
301
|
+
if isinstance(headers, list):
|
|
302
|
+
name_lower = name.lower() if case_insensitive else name
|
|
303
|
+
for item in headers:
|
|
304
|
+
if isinstance(item, dict):
|
|
305
|
+
item_name = item.get("name", "")
|
|
306
|
+
if case_insensitive:
|
|
307
|
+
if item_name.lower() == name_lower:
|
|
308
|
+
return item.get("value")
|
|
309
|
+
elif item_name == name:
|
|
310
|
+
return item.get("value")
|
|
311
|
+
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def format_rfc2822_date(dt: datetime | None = None) -> str:
|
|
316
|
+
"""
|
|
317
|
+
Format a datetime as RFC 2822 for email Date header.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
dt: Datetime to format (defaults to now)
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
RFC 2822 formatted date string
|
|
324
|
+
"""
|
|
325
|
+
dt = dt or datetime.utcnow()
|
|
326
|
+
return formatdate(dt.timestamp(), localtime=False, usegmt=True)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def ensure_reply_subject(subject: str | None, prefix: str = "Re: ") -> str:
|
|
330
|
+
"""
|
|
331
|
+
Ensure subject has reply prefix.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
subject: Original subject
|
|
335
|
+
prefix: Prefix to add if missing (default "Re: ")
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Subject with reply prefix
|
|
339
|
+
"""
|
|
340
|
+
if not subject:
|
|
341
|
+
return prefix.rstrip(": ") + ": "
|
|
342
|
+
|
|
343
|
+
# Check if already has Re: prefix (case-insensitive)
|
|
344
|
+
if re.match(r"^re:\s*", subject, re.IGNORECASE):
|
|
345
|
+
return subject
|
|
346
|
+
|
|
347
|
+
return prefix + subject
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass
|
|
351
|
+
class OutboundHeaders:
|
|
352
|
+
"""Headers to include in an outbound email."""
|
|
353
|
+
|
|
354
|
+
message_id: str
|
|
355
|
+
date: str
|
|
356
|
+
in_reply_to: str | None = None
|
|
357
|
+
references: str | None = None
|
|
358
|
+
|
|
359
|
+
def to_dict(self) -> dict[str, str]:
|
|
360
|
+
"""Convert to dictionary for provider API."""
|
|
361
|
+
headers = {
|
|
362
|
+
"Message-ID": self.message_id,
|
|
363
|
+
"Date": self.date,
|
|
364
|
+
}
|
|
365
|
+
if self.in_reply_to:
|
|
366
|
+
headers["In-Reply-To"] = self.in_reply_to
|
|
367
|
+
if self.references:
|
|
368
|
+
headers["References"] = self.references
|
|
369
|
+
return headers
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def build_reply_headers(
|
|
373
|
+
domain: str,
|
|
374
|
+
parent_message_id: str | None = None,
|
|
375
|
+
parent_references: list[str] | None = None,
|
|
376
|
+
*,
|
|
377
|
+
timestamp: datetime | None = None,
|
|
378
|
+
) -> OutboundHeaders:
|
|
379
|
+
"""
|
|
380
|
+
Build complete headers for a reply message.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
domain: Domain for Message-ID generation
|
|
384
|
+
parent_message_id: Message-ID of the parent message
|
|
385
|
+
parent_references: References from the parent message
|
|
386
|
+
timestamp: Optional timestamp for headers
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
OutboundHeaders with all necessary fields
|
|
390
|
+
"""
|
|
391
|
+
timestamp = timestamp or datetime.utcnow()
|
|
392
|
+
|
|
393
|
+
message_id = generate_message_id(domain, timestamp=timestamp)
|
|
394
|
+
date_header = format_rfc2822_date(timestamp)
|
|
395
|
+
|
|
396
|
+
in_reply_to = normalize_message_id(parent_message_id)
|
|
397
|
+
references = build_references_header(parent_references, parent_message_id)
|
|
398
|
+
|
|
399
|
+
return OutboundHeaders(
|
|
400
|
+
message_id=message_id,
|
|
401
|
+
date=date_header,
|
|
402
|
+
in_reply_to=in_reply_to,
|
|
403
|
+
references=references if references else None,
|
|
404
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""HTML to Markdown conversion (Verdandi)."""
|
|
2
|
+
|
|
3
|
+
import html2text
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def html_to_markdown(html: str) -> str:
|
|
7
|
+
"""Convert HTML email body to clean Markdown.
|
|
8
|
+
|
|
9
|
+
Uses html2text to convert HTML to Markdown with email-friendly settings.
|
|
10
|
+
"""
|
|
11
|
+
if not html or not html.strip():
|
|
12
|
+
return ""
|
|
13
|
+
|
|
14
|
+
h = html2text.HTML2Text()
|
|
15
|
+
# Configure for email content
|
|
16
|
+
h.ignore_links = False
|
|
17
|
+
h.ignore_images = False
|
|
18
|
+
h.ignore_emphasis = False
|
|
19
|
+
h.body_width = 0 # Don't wrap lines
|
|
20
|
+
h.unicode_snob = True # Use unicode characters
|
|
21
|
+
h.skip_internal_links = True
|
|
22
|
+
h.inline_links = True
|
|
23
|
+
h.protect_links = True
|
|
24
|
+
|
|
25
|
+
return h.handle(html).strip()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Remove reply cruft (e.g. 'On Date wrote:') from email content."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def remove_reply_cruft(content: str) -> str:
|
|
5
|
+
"""Strip common reply signatures and quoted blocks. Placeholder."""
|
|
6
|
+
if not content:
|
|
7
|
+
return ""
|
|
8
|
+
# TODO: regex for "On ... wrote:" etc.
|
|
9
|
+
return content.strip()
|