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/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
|
+
]
|