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
@@ -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()