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/urdr/orm.py ADDED
@@ -0,0 +1,641 @@
1
+ """SQLAlchemy ORM models for Urðr storage layer."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+ from sqlalchemy import (
8
+ JSON,
9
+ DateTime,
10
+ ForeignKey,
11
+ Index,
12
+ Integer,
13
+ LargeBinary,
14
+ String,
15
+ Text,
16
+ func,
17
+ )
18
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
19
+
20
+ from nornweave.models.attachment import (
21
+ Attachment as PydanticAttachment,
22
+ )
23
+ from nornweave.models.attachment import (
24
+ AttachmentDisposition,
25
+ AttachmentMeta,
26
+ )
27
+ from nornweave.models.event import Event as PydanticEvent
28
+ from nornweave.models.event import EventType
29
+ from nornweave.models.inbox import Inbox as PydanticInbox
30
+ from nornweave.models.message import Message as PydanticMessage
31
+ from nornweave.models.message import MessageDirection
32
+ from nornweave.models.thread import Thread as PydanticThread
33
+
34
+
35
+ def generate_uuid() -> str:
36
+ """Generate a new UUID string."""
37
+ return str(uuid.uuid4())
38
+
39
+
40
+ class Base(DeclarativeBase):
41
+ """SQLAlchemy declarative base."""
42
+
43
+ pass
44
+
45
+
46
+ class InboxORM(Base):
47
+ """Inbox table."""
48
+
49
+ __tablename__ = "inboxes"
50
+
51
+ id: Mapped[str] = mapped_column(
52
+ String(36),
53
+ primary_key=True,
54
+ default=generate_uuid,
55
+ )
56
+ email_address: Mapped[str] = mapped_column(
57
+ String(255),
58
+ unique=True,
59
+ nullable=False,
60
+ index=True,
61
+ )
62
+ name: Mapped[str | None] = mapped_column(String(255), nullable=True)
63
+ provider_config: Mapped[dict[str, Any]] = mapped_column(
64
+ JSON,
65
+ nullable=False,
66
+ default=dict,
67
+ )
68
+
69
+ # Relationships
70
+ threads: Mapped[list[ThreadORM]] = relationship(
71
+ "ThreadORM",
72
+ back_populates="inbox",
73
+ cascade="all, delete-orphan",
74
+ )
75
+ messages: Mapped[list[MessageORM]] = relationship(
76
+ "MessageORM",
77
+ back_populates="inbox",
78
+ cascade="all, delete-orphan",
79
+ )
80
+
81
+ def to_pydantic(self) -> PydanticInbox:
82
+ """Convert ORM model to Pydantic model."""
83
+ return PydanticInbox(
84
+ id=self.id,
85
+ email_address=self.email_address,
86
+ name=self.name,
87
+ provider_config=self.provider_config or {},
88
+ )
89
+
90
+ @classmethod
91
+ def from_pydantic(cls, inbox: PydanticInbox) -> InboxORM:
92
+ """Create ORM model from Pydantic model."""
93
+ return cls(
94
+ id=inbox.id,
95
+ email_address=inbox.email_address,
96
+ name=inbox.name,
97
+ provider_config=inbox.provider_config,
98
+ )
99
+
100
+
101
+ class ThreadORM(Base):
102
+ """Thread table for email conversation grouping."""
103
+
104
+ __tablename__ = "threads"
105
+
106
+ id: Mapped[str] = mapped_column(
107
+ String(36),
108
+ primary_key=True,
109
+ default=generate_uuid,
110
+ )
111
+ inbox_id: Mapped[str] = mapped_column(
112
+ String(36),
113
+ ForeignKey("inboxes.id", ondelete="CASCADE"),
114
+ nullable=False,
115
+ )
116
+
117
+ # Labels
118
+ labels: Mapped[list[str]] = mapped_column(
119
+ JSON,
120
+ nullable=False,
121
+ default=list,
122
+ )
123
+
124
+ # Timestamps
125
+ timestamp: Mapped[datetime] = mapped_column(
126
+ DateTime(timezone=True),
127
+ nullable=False,
128
+ default=datetime.utcnow,
129
+ )
130
+ received_timestamp: Mapped[datetime | None] = mapped_column(
131
+ DateTime(timezone=True),
132
+ nullable=True,
133
+ )
134
+ sent_timestamp: Mapped[datetime | None] = mapped_column(
135
+ DateTime(timezone=True),
136
+ nullable=True,
137
+ )
138
+ created_at: Mapped[datetime] = mapped_column(
139
+ DateTime(timezone=True),
140
+ nullable=False,
141
+ server_default=func.now(),
142
+ )
143
+ updated_at: Mapped[datetime] = mapped_column(
144
+ DateTime(timezone=True),
145
+ nullable=False,
146
+ server_default=func.now(),
147
+ onupdate=func.now(),
148
+ )
149
+
150
+ # Participants
151
+ senders: Mapped[list[str]] = mapped_column(
152
+ JSON,
153
+ nullable=False,
154
+ default=list,
155
+ )
156
+ recipients: Mapped[list[str]] = mapped_column(
157
+ JSON,
158
+ nullable=False,
159
+ default=list,
160
+ )
161
+ participant_hash: Mapped[str | None] = mapped_column(
162
+ String(64),
163
+ nullable=True,
164
+ )
165
+
166
+ # Content
167
+ subject: Mapped[str | None] = mapped_column(Text, nullable=True)
168
+ normalized_subject: Mapped[str | None] = mapped_column(
169
+ String(512),
170
+ nullable=True,
171
+ index=True,
172
+ )
173
+ preview: Mapped[str | None] = mapped_column(String(255), nullable=True)
174
+
175
+ # Stats
176
+ last_message_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
177
+ message_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
178
+ size: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
179
+
180
+ # Legacy compatibility
181
+ last_message_at: Mapped[datetime | None] = mapped_column(
182
+ DateTime(timezone=True),
183
+ nullable=True,
184
+ )
185
+
186
+ # Relationships
187
+ inbox: Mapped[InboxORM] = relationship("InboxORM", back_populates="threads")
188
+ messages: Mapped[list[MessageORM]] = relationship(
189
+ "MessageORM",
190
+ back_populates="thread",
191
+ cascade="all, delete-orphan",
192
+ )
193
+
194
+ # Indexes for performance
195
+ __table_args__ = (
196
+ Index("ix_threads_inbox_last_message", "inbox_id", timestamp.desc()),
197
+ Index("ix_threads_inbox_participant_hash", "inbox_id", "participant_hash"),
198
+ Index("ix_threads_inbox_normalized_subject", "inbox_id", "normalized_subject"),
199
+ )
200
+
201
+ def to_pydantic(self) -> PydanticThread:
202
+ """Convert ORM model to Pydantic model."""
203
+ return PydanticThread(
204
+ inbox_id=self.inbox_id,
205
+ thread_id=self.id,
206
+ labels=self.labels or [],
207
+ timestamp=self.timestamp or self.last_message_at, # Can be None
208
+ received_timestamp=self.received_timestamp,
209
+ sent_timestamp=self.sent_timestamp,
210
+ senders=self.senders or [],
211
+ recipients=self.recipients or [],
212
+ subject=self.subject,
213
+ preview=self.preview,
214
+ attachments=None, # Load separately if needed
215
+ last_message_id=self.last_message_id,
216
+ message_count=self.message_count,
217
+ size=self.size,
218
+ updated_at=self.updated_at,
219
+ created_at=self.created_at,
220
+ participant_hash=self.participant_hash,
221
+ normalized_subject=self.normalized_subject,
222
+ )
223
+
224
+ @classmethod
225
+ def from_pydantic(cls, thread: PydanticThread) -> ThreadORM:
226
+ """Create ORM model from Pydantic model."""
227
+ return cls(
228
+ id=thread.thread_id,
229
+ inbox_id=thread.inbox_id,
230
+ labels=thread.labels,
231
+ timestamp=thread.timestamp,
232
+ received_timestamp=thread.received_timestamp,
233
+ sent_timestamp=thread.sent_timestamp,
234
+ senders=thread.senders,
235
+ recipients=thread.recipients,
236
+ subject=thread.subject,
237
+ normalized_subject=thread.normalized_subject,
238
+ preview=thread.preview,
239
+ last_message_id=thread.last_message_id,
240
+ message_count=thread.message_count,
241
+ size=thread.size,
242
+ participant_hash=thread.participant_hash,
243
+ last_message_at=thread.timestamp, # Legacy compatibility
244
+ )
245
+
246
+
247
+ class MessageORM(Base):
248
+ """Message table for email content and metadata."""
249
+
250
+ __tablename__ = "messages"
251
+
252
+ id: Mapped[str] = mapped_column(
253
+ String(36),
254
+ primary_key=True,
255
+ default=generate_uuid,
256
+ )
257
+ thread_id: Mapped[str] = mapped_column(
258
+ String(36),
259
+ ForeignKey("threads.id", ondelete="CASCADE"),
260
+ nullable=False,
261
+ )
262
+ inbox_id: Mapped[str] = mapped_column(
263
+ String(36),
264
+ ForeignKey("inboxes.id", ondelete="CASCADE"),
265
+ nullable=False,
266
+ )
267
+
268
+ # Labels
269
+ labels: Mapped[list[str]] = mapped_column(
270
+ JSON,
271
+ nullable=False,
272
+ default=list,
273
+ )
274
+
275
+ # Timestamps
276
+ timestamp: Mapped[datetime] = mapped_column(
277
+ DateTime(timezone=True),
278
+ nullable=False,
279
+ default=datetime.utcnow,
280
+ )
281
+ created_at: Mapped[datetime] = mapped_column(
282
+ DateTime(timezone=True),
283
+ nullable=False,
284
+ server_default=func.now(),
285
+ )
286
+ updated_at: Mapped[datetime] = mapped_column(
287
+ DateTime(timezone=True),
288
+ nullable=False,
289
+ server_default=func.now(),
290
+ onupdate=func.now(),
291
+ )
292
+
293
+ # Addresses
294
+ from_address: Mapped[str | None] = mapped_column(String(512), nullable=True)
295
+ reply_to_addresses: Mapped[list[str] | None] = mapped_column(
296
+ JSON,
297
+ nullable=True,
298
+ )
299
+ to_addresses: Mapped[list[str]] = mapped_column(
300
+ JSON,
301
+ nullable=False,
302
+ default=list,
303
+ )
304
+ cc_addresses: Mapped[list[str] | None] = mapped_column(
305
+ JSON,
306
+ nullable=True,
307
+ )
308
+ bcc_addresses: Mapped[list[str] | None] = mapped_column(
309
+ JSON,
310
+ nullable=True,
311
+ )
312
+
313
+ # Content
314
+ subject: Mapped[str | None] = mapped_column(Text, nullable=True)
315
+ preview: Mapped[str | None] = mapped_column(String(255), nullable=True)
316
+ text: Mapped[str | None] = mapped_column(Text, nullable=True)
317
+ html: Mapped[str | None] = mapped_column(Text, nullable=True)
318
+ extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True)
319
+ extracted_html: Mapped[str | None] = mapped_column(Text, nullable=True)
320
+
321
+ # Threading headers
322
+ in_reply_to: Mapped[str | None] = mapped_column(String(512), nullable=True)
323
+ references: Mapped[list[str] | None] = mapped_column(
324
+ JSON,
325
+ nullable=True,
326
+ )
327
+ headers: Mapped[dict[str, str] | None] = mapped_column(
328
+ JSON,
329
+ nullable=True,
330
+ )
331
+
332
+ # Metadata
333
+ size: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
334
+ direction: Mapped[str] = mapped_column(
335
+ String(20),
336
+ nullable=False,
337
+ )
338
+ provider_message_id: Mapped[str | None] = mapped_column(
339
+ String(512),
340
+ nullable=True,
341
+ index=True,
342
+ )
343
+
344
+ # Legacy fields for backwards compatibility
345
+ content_raw: Mapped[str] = mapped_column(Text, nullable=False, default="")
346
+ content_clean: Mapped[str] = mapped_column(Text, nullable=False, default="")
347
+ message_metadata: Mapped[dict[str, Any]] = mapped_column(
348
+ "metadata",
349
+ JSON,
350
+ nullable=False,
351
+ default=dict,
352
+ )
353
+
354
+ # Relationships
355
+ thread: Mapped[ThreadORM] = relationship("ThreadORM", back_populates="messages")
356
+ inbox: Mapped[InboxORM] = relationship("InboxORM", back_populates="messages")
357
+ attachments: Mapped[list[AttachmentORM]] = relationship(
358
+ "AttachmentORM",
359
+ back_populates="message",
360
+ cascade="all, delete-orphan",
361
+ )
362
+
363
+ # Indexes for performance
364
+ __table_args__ = (
365
+ Index("ix_messages_thread_created", "thread_id", "created_at"),
366
+ Index("ix_messages_inbox_created", "inbox_id", "created_at"),
367
+ Index("ix_messages_inbox_id", "inbox_id"),
368
+ Index(
369
+ "ix_messages_inbox_provider_msg",
370
+ "inbox_id",
371
+ "provider_message_id",
372
+ unique=True,
373
+ postgresql_where=("provider_message_id IS NOT NULL"),
374
+ ),
375
+ Index("ix_messages_timestamp", "timestamp"),
376
+ )
377
+
378
+ def to_pydantic(self) -> PydanticMessage:
379
+ """Convert ORM model to Pydantic model.
380
+
381
+ Note: Attachments are not loaded by default to avoid lazy loading issues.
382
+ Use explicit queries or eager loading if attachments are needed.
383
+ """
384
+ return PydanticMessage(
385
+ inbox_id=self.inbox_id,
386
+ thread_id=self.thread_id,
387
+ message_id=self.id,
388
+ labels=self.labels or [],
389
+ timestamp=self.timestamp,
390
+ from_address=self.from_address,
391
+ reply_to=self.reply_to_addresses,
392
+ to=self.to_addresses or [],
393
+ cc=self.cc_addresses,
394
+ bcc=self.bcc_addresses,
395
+ subject=self.subject,
396
+ preview=self.preview,
397
+ text=self.text or self.content_raw,
398
+ html=self.html,
399
+ extracted_text=self.extracted_text or self.content_clean,
400
+ extracted_html=self.extracted_html,
401
+ attachments=None, # Loaded separately to avoid lazy loading
402
+ in_reply_to=self.in_reply_to,
403
+ references=self.references,
404
+ headers=self.headers or self.message_metadata,
405
+ size=self.size,
406
+ direction=MessageDirection(self.direction),
407
+ provider_message_id=self.provider_message_id,
408
+ updated_at=self.updated_at,
409
+ created_at=self.created_at,
410
+ )
411
+
412
+ @classmethod
413
+ def from_pydantic(cls, message: PydanticMessage) -> MessageORM:
414
+ """Create ORM model from Pydantic model."""
415
+ return cls(
416
+ id=message.message_id,
417
+ thread_id=message.thread_id,
418
+ inbox_id=message.inbox_id,
419
+ labels=message.labels,
420
+ timestamp=message.timestamp,
421
+ from_address=message.from_address,
422
+ reply_to_addresses=message.reply_to,
423
+ to_addresses=message.to,
424
+ cc_addresses=message.cc,
425
+ bcc_addresses=message.bcc,
426
+ subject=message.subject,
427
+ preview=message.preview,
428
+ text=message.text,
429
+ html=message.html,
430
+ extracted_text=message.extracted_text,
431
+ extracted_html=message.extracted_html,
432
+ in_reply_to=message.in_reply_to,
433
+ references=message.references,
434
+ headers=message.headers,
435
+ size=message.size,
436
+ direction=message.direction.value,
437
+ provider_message_id=message.provider_message_id,
438
+ # Legacy fields
439
+ content_raw=message.text or "",
440
+ content_clean=message.extracted_text or "",
441
+ message_metadata=message.headers or {},
442
+ )
443
+
444
+
445
+ class AttachmentORM(Base):
446
+ """Attachment storage model."""
447
+
448
+ __tablename__ = "attachments"
449
+
450
+ id: Mapped[str] = mapped_column(
451
+ String(36),
452
+ primary_key=True,
453
+ default=generate_uuid,
454
+ )
455
+ message_id: Mapped[str] = mapped_column(
456
+ String(36),
457
+ ForeignKey("messages.id", ondelete="CASCADE"),
458
+ nullable=False,
459
+ )
460
+
461
+ # File metadata
462
+ filename: Mapped[str] = mapped_column(String(512), nullable=False)
463
+ content_type: Mapped[str] = mapped_column(String(255), nullable=False)
464
+ size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
465
+
466
+ # Disposition
467
+ disposition: Mapped[str] = mapped_column(
468
+ String(20),
469
+ nullable=False,
470
+ default="attachment",
471
+ )
472
+ content_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
473
+
474
+ # Storage options
475
+ # Option 1: Store content in database (for small files or simple deployments)
476
+ content: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
477
+
478
+ # Option 2: Store in external storage (filesystem/S3/GCS)
479
+ storage_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
480
+ storage_backend: Mapped[str | None] = mapped_column(String(50), nullable=True)
481
+ content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
482
+
483
+ # Timestamps
484
+ created_at: Mapped[datetime] = mapped_column(
485
+ DateTime(timezone=True),
486
+ nullable=False,
487
+ server_default=func.now(),
488
+ )
489
+
490
+ # Relationships
491
+ message: Mapped[MessageORM] = relationship("MessageORM", back_populates="attachments")
492
+
493
+ __table_args__ = (
494
+ Index("ix_attachments_message_id", "message_id"),
495
+ Index("ix_attachments_content_id", "content_id"),
496
+ )
497
+
498
+ def to_pydantic(self) -> PydanticAttachment:
499
+ """Convert ORM model to Pydantic Attachment."""
500
+ return PydanticAttachment(
501
+ attachment_id=self.id,
502
+ filename=self.filename,
503
+ size=self.size_bytes,
504
+ content_type=self.content_type,
505
+ content_disposition=AttachmentDisposition(self.disposition),
506
+ content_id=self.content_id,
507
+ )
508
+
509
+ def to_meta(self) -> AttachmentMeta:
510
+ """Convert ORM model to AttachmentMeta (lightweight)."""
511
+ return AttachmentMeta(
512
+ attachment_id=self.id,
513
+ filename=self.filename,
514
+ content_type=self.content_type,
515
+ size=self.size_bytes,
516
+ disposition=AttachmentDisposition(self.disposition),
517
+ content_id=self.content_id,
518
+ )
519
+
520
+ @classmethod
521
+ def from_pydantic(
522
+ cls,
523
+ attachment: PydanticAttachment,
524
+ message_id: str,
525
+ *,
526
+ content: bytes | None = None,
527
+ storage_path: str | None = None,
528
+ storage_backend: str | None = None,
529
+ ) -> AttachmentORM:
530
+ """Create ORM model from Pydantic model."""
531
+ return cls(
532
+ id=attachment.attachment_id,
533
+ message_id=message_id,
534
+ filename=attachment.filename or "unnamed",
535
+ content_type=attachment.content_type or "application/octet-stream",
536
+ size_bytes=attachment.size,
537
+ disposition=attachment.content_disposition.value
538
+ if attachment.content_disposition
539
+ else "attachment",
540
+ content_id=attachment.content_id,
541
+ content=content,
542
+ storage_path=storage_path,
543
+ storage_backend=storage_backend,
544
+ )
545
+
546
+
547
+ class EventORM(Base):
548
+ """Event table for webhook notifications and tracking."""
549
+
550
+ __tablename__ = "events"
551
+
552
+ id: Mapped[str] = mapped_column(
553
+ String(36),
554
+ primary_key=True,
555
+ default=generate_uuid,
556
+ )
557
+ event_type: Mapped[str] = mapped_column(
558
+ String(50),
559
+ nullable=False,
560
+ )
561
+
562
+ # References
563
+ inbox_id: Mapped[str | None] = mapped_column(
564
+ String(36),
565
+ ForeignKey("inboxes.id", ondelete="SET NULL"),
566
+ nullable=True,
567
+ )
568
+ thread_id: Mapped[str | None] = mapped_column(
569
+ String(36),
570
+ ForeignKey("threads.id", ondelete="SET NULL"),
571
+ nullable=True,
572
+ )
573
+ message_id: Mapped[str | None] = mapped_column(
574
+ String(36),
575
+ ForeignKey("messages.id", ondelete="SET NULL"),
576
+ nullable=True,
577
+ )
578
+
579
+ # Timestamps
580
+ timestamp: Mapped[datetime] = mapped_column(
581
+ DateTime(timezone=True),
582
+ nullable=False,
583
+ default=datetime.utcnow,
584
+ )
585
+ created_at: Mapped[datetime] = mapped_column(
586
+ DateTime(timezone=True),
587
+ nullable=False,
588
+ server_default=func.now(),
589
+ )
590
+
591
+ # Event-specific data
592
+ payload: Mapped[dict[str, Any]] = mapped_column(
593
+ JSON,
594
+ nullable=False,
595
+ default=dict,
596
+ )
597
+
598
+ # Legacy field name mapping
599
+ type: Mapped[str] = mapped_column(
600
+ String(50),
601
+ nullable=False,
602
+ default="",
603
+ )
604
+
605
+ # Indexes
606
+ __table_args__ = (
607
+ Index("ix_events_event_type", "event_type"),
608
+ Index("ix_events_inbox_id", "inbox_id"),
609
+ Index("ix_events_timestamp", timestamp.desc()),
610
+ Index("ix_events_created_at", created_at.desc()),
611
+ Index("ix_events_type_created", "event_type", created_at.desc()),
612
+ )
613
+
614
+ def to_pydantic(self) -> PydanticEvent:
615
+ """Convert ORM model to Pydantic model."""
616
+ # Use event_type if available, fall back to type for legacy
617
+ event_type_value = self.event_type or self.type
618
+ return PydanticEvent(
619
+ id=self.id,
620
+ type=EventType(event_type_value),
621
+ created_at=self.created_at,
622
+ payload=self.payload or {},
623
+ inbox_id=self.inbox_id,
624
+ thread_id=self.thread_id,
625
+ message_id=self.message_id,
626
+ )
627
+
628
+ @classmethod
629
+ def from_pydantic(cls, event: PydanticEvent) -> EventORM:
630
+ """Create ORM model from Pydantic model."""
631
+ return cls(
632
+ id=event.id,
633
+ event_type=event.type.value,
634
+ type=event.type.value, # Legacy compatibility
635
+ timestamp=event.created_at,
636
+ created_at=event.created_at,
637
+ payload=event.payload,
638
+ inbox_id=event.inbox_id,
639
+ thread_id=event.thread_id,
640
+ message_id=event.message_id,
641
+ )
@@ -0,0 +1,45 @@
1
+ """Verdandi (The Loom): Ingestion engine.
2
+
3
+ The ingestion engine handles:
4
+ - Email parsing from provider webhooks
5
+ - HTML sanitization and Markdown conversion
6
+ - Thread resolution (JWZ algorithm)
7
+ - Attachment processing
8
+ - Content extraction (quote/signature removal)
9
+ """
10
+
11
+ from nornweave.verdandi.attachments import (
12
+ parse_mime_attachments,
13
+ validate_attachments,
14
+ )
15
+ from nornweave.verdandi.content import (
16
+ extract_content,
17
+ generate_preview,
18
+ init_talon,
19
+ )
20
+ from nornweave.verdandi.headers import (
21
+ build_reply_headers,
22
+ generate_message_id,
23
+ parse_email_address,
24
+ )
25
+ from nornweave.verdandi.threading import (
26
+ normalize_subject,
27
+ resolve_thread,
28
+ )
29
+
30
+ __all__ = [
31
+ # Header utilities
32
+ "build_reply_headers",
33
+ # Content extraction
34
+ "extract_content",
35
+ "generate_message_id",
36
+ "generate_preview",
37
+ "init_talon",
38
+ # Threading
39
+ "normalize_subject",
40
+ "parse_email_address",
41
+ # Attachment handling
42
+ "parse_mime_attachments",
43
+ "resolve_thread",
44
+ "validate_attachments",
45
+ ]