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,275 @@
1
+ """Event models for webhooks.
2
+
3
+ Event models for webhook notifications and internal event tracking.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING, Any, Literal
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ if TYPE_CHECKING:
13
+ from nornweave.models.message import Message
14
+ from nornweave.models.thread import ThreadItem
15
+
16
+
17
+ class EventType(str, Enum):
18
+ """
19
+ Event types for webhooks.
20
+
21
+ Supported event types for webhooks and notifications.
22
+ """
23
+
24
+ MESSAGE_RECEIVED = "message.received"
25
+ MESSAGE_SENT = "message.sent"
26
+ MESSAGE_DELIVERED = "message.delivered"
27
+ MESSAGE_BOUNCED = "message.bounced"
28
+ MESSAGE_COMPLAINED = "message.complained"
29
+ MESSAGE_REJECTED = "message.rejected"
30
+ DOMAIN_VERIFIED = "domain.verified"
31
+ # Legacy event types for backwards compatibility
32
+ THREAD_NEW_MESSAGE = "thread.new_message"
33
+ INBOX_CREATED = "inbox.created"
34
+ INBOX_DELETED = "inbox.deleted"
35
+
36
+
37
+ class Recipient(BaseModel):
38
+ """
39
+ Recipient with delivery status.
40
+
41
+ Recipient with delivery status information.
42
+ """
43
+
44
+ address: str = Field(..., description="Recipient address")
45
+ status: str = Field(..., description="Recipient status")
46
+
47
+ model_config = {"extra": "forbid"}
48
+
49
+
50
+ class SendEvent(BaseModel):
51
+ """
52
+ Event data for message sent.
53
+
54
+ Send event payload with recipient list.
55
+ """
56
+
57
+ inbox_id: str
58
+ thread_id: str
59
+ message_id: str
60
+ timestamp: datetime
61
+ recipients: list[str] = Field(..., description="Sent recipients")
62
+
63
+ model_config = {"extra": "forbid"}
64
+
65
+
66
+ class DeliveryEvent(BaseModel):
67
+ """
68
+ Event data for message delivered.
69
+
70
+ Delivery confirmation event payload.
71
+ """
72
+
73
+ inbox_id: str
74
+ thread_id: str
75
+ message_id: str
76
+ timestamp: datetime
77
+ recipients: list[str] = Field(..., description="Delivered recipients")
78
+
79
+ model_config = {"extra": "forbid"}
80
+
81
+
82
+ class BounceEvent(BaseModel):
83
+ """
84
+ Event data for message bounced.
85
+
86
+ Bounce event payload with type and affected recipients.
87
+ """
88
+
89
+ inbox_id: str
90
+ thread_id: str
91
+ message_id: str
92
+ timestamp: datetime
93
+ type: str = Field(..., description="Bounce type (hard/soft)")
94
+ sub_type: str = Field(..., description="Bounce sub-type")
95
+ recipients: list[Recipient] = Field(..., description="Bounced recipients with status")
96
+
97
+ model_config = {"extra": "forbid"}
98
+
99
+
100
+ class ComplaintEvent(BaseModel):
101
+ """
102
+ Event data for spam complaint.
103
+
104
+ Spam complaint event payload.
105
+ """
106
+
107
+ inbox_id: str
108
+ thread_id: str
109
+ message_id: str
110
+ timestamp: datetime
111
+ type: str = Field(..., description="Complaint type")
112
+ sub_type: str = Field(..., description="Complaint sub-type")
113
+ recipients: list[str] = Field(..., description="Complained recipients")
114
+
115
+ model_config = {"extra": "forbid"}
116
+
117
+
118
+ class RejectEvent(BaseModel):
119
+ """
120
+ Event data for message rejected.
121
+
122
+ Rejection event payload with reason.
123
+ """
124
+
125
+ inbox_id: str
126
+ thread_id: str
127
+ message_id: str
128
+ timestamp: datetime
129
+ reason: str = Field(..., description="Reject reason")
130
+
131
+ model_config = {"extra": "forbid"}
132
+
133
+
134
+ class MessageReceivedEvent(BaseModel):
135
+ """
136
+ Webhook event for message received.
137
+
138
+ Webhook payload for inbound message received.
139
+ """
140
+
141
+ type: Literal["event"] = "event"
142
+ event_type: Literal["message.received"] = "message.received"
143
+ event_id: str
144
+ message: Message
145
+ thread: ThreadItem
146
+
147
+ model_config = {"extra": "forbid"}
148
+
149
+
150
+ class MessageSentEvent(BaseModel):
151
+ """
152
+ Webhook event for message sent.
153
+
154
+ Webhook payload for message sent.
155
+ """
156
+
157
+ type: Literal["event"] = "event"
158
+ event_type: Literal["message.sent"] = "message.sent"
159
+ event_id: str
160
+ send: SendEvent
161
+
162
+ model_config = {"extra": "forbid"}
163
+
164
+
165
+ class MessageDeliveredEvent(BaseModel):
166
+ """
167
+ Webhook event for message delivered.
168
+
169
+ Webhook payload for message delivered.
170
+ """
171
+
172
+ type: Literal["event"] = "event"
173
+ event_type: Literal["message.delivered"] = "message.delivered"
174
+ event_id: str
175
+ delivery: DeliveryEvent
176
+
177
+ model_config = {"extra": "forbid"}
178
+
179
+
180
+ class MessageBouncedEvent(BaseModel):
181
+ """
182
+ Webhook event for message bounced.
183
+
184
+ Webhook payload for message bounced.
185
+ """
186
+
187
+ type: Literal["event"] = "event"
188
+ event_type: Literal["message.bounced"] = "message.bounced"
189
+ event_id: str
190
+ bounce: BounceEvent
191
+
192
+ model_config = {"extra": "forbid"}
193
+
194
+
195
+ class MessageComplainedEvent(BaseModel):
196
+ """
197
+ Webhook event for spam complaint.
198
+
199
+ Webhook payload for spam complaint.
200
+ """
201
+
202
+ type: Literal["event"] = "event"
203
+ event_type: Literal["message.complained"] = "message.complained"
204
+ event_id: str
205
+ complaint: ComplaintEvent
206
+
207
+ model_config = {"extra": "forbid"}
208
+
209
+
210
+ class MessageRejectedEvent(BaseModel):
211
+ """
212
+ Webhook event for message rejected.
213
+
214
+ Webhook payload for message rejected.
215
+ """
216
+
217
+ type: Literal["event"] = "event"
218
+ event_type: Literal["message.rejected"] = "message.rejected"
219
+ event_id: str
220
+ reject: RejectEvent
221
+
222
+ model_config = {"extra": "forbid"}
223
+
224
+
225
+ # Type alias for all webhook events
226
+ WebhookEvent = (
227
+ MessageReceivedEvent
228
+ | MessageSentEvent
229
+ | MessageDeliveredEvent
230
+ | MessageBouncedEvent
231
+ | MessageComplainedEvent
232
+ | MessageRejectedEvent
233
+ )
234
+
235
+
236
+ # Legacy models for backwards compatibility
237
+ class EventCreate(BaseModel):
238
+ """Payload to create an event (id and created_at set by storage)."""
239
+
240
+ type: EventType = Field(..., description="Event type")
241
+ payload: dict[str, Any] = Field(
242
+ default_factory=dict,
243
+ description="Event payload",
244
+ )
245
+
246
+ model_config = {"extra": "forbid"}
247
+
248
+
249
+ class Event(BaseModel):
250
+ """Event entity with id and timestamp."""
251
+
252
+ id: str = Field(..., description="Event id")
253
+ type: EventType = Field(..., description="Event type")
254
+ created_at: datetime = Field(default_factory=datetime.utcnow)
255
+ payload: dict[str, Any] = Field(
256
+ default_factory=dict,
257
+ description="Event payload",
258
+ )
259
+ # Additional fields for structured events
260
+ inbox_id: str | None = Field(None, description="Associated inbox ID")
261
+ thread_id: str | None = Field(None, description="Associated thread ID")
262
+ message_id: str | None = Field(None, description="Associated message ID")
263
+
264
+ model_config = {"extra": "forbid"}
265
+
266
+
267
+ class ListEventsResponse(BaseModel):
268
+ """Response for listing events."""
269
+
270
+ count: int = Field(..., description="Total count of events")
271
+ limit: int | None = Field(None, description="Limit applied to results")
272
+ next_page_token: str | None = Field(None, description="Token for next page")
273
+ events: list[Event] = Field(..., description="Events ordered by timestamp descending")
274
+
275
+ model_config = {"extra": "forbid"}
@@ -0,0 +1,33 @@
1
+ """Inbox model."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class InboxBase(BaseModel):
9
+ """Shared inbox fields."""
10
+
11
+ email_address: str = Field(..., description="Full email address for this inbox")
12
+ name: str | None = Field(None, description="Human-readable name")
13
+ provider_config: dict[str, Any] = Field(
14
+ default_factory=dict,
15
+ description="Provider-specific metadata (e.g. route_id, domain, webhook_id)",
16
+ )
17
+
18
+
19
+ class InboxCreate(BaseModel):
20
+ """Payload to create an inbox."""
21
+
22
+ name: str = Field(..., min_length=1, description="Display name")
23
+ email_username: str = Field(..., min_length=1, description="Local part (e.g. support)")
24
+
25
+ model_config = {"extra": "forbid"}
26
+
27
+
28
+ class Inbox(InboxBase):
29
+ """Inbox entity with id."""
30
+
31
+ id: str = Field(..., description="Unique inbox id (e.g. UUID)")
32
+
33
+ model_config = {"extra": "forbid"}
@@ -0,0 +1,284 @@
1
+ """Message models.
2
+
3
+ Message models for email content and metadata.
4
+ """
5
+
6
+ from datetime import datetime # noqa: TC003 - needed at runtime for Pydantic
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ if TYPE_CHECKING:
13
+ from nornweave.models.attachment import Attachment, AttachmentMeta, SendAttachment
14
+
15
+
16
+ class MessageDirection(str, Enum):
17
+ """Message direction."""
18
+
19
+ INBOUND = "inbound"
20
+ OUTBOUND = "outbound"
21
+
22
+
23
+ class MessageItem(BaseModel):
24
+ """
25
+ Message summary for list views (without full body content).
26
+
27
+ Summary model for message list views.
28
+ """
29
+
30
+ inbox_id: str = Field(..., description="ID of inbox")
31
+ thread_id: str = Field(..., description="ID of thread")
32
+ message_id: str = Field(..., description="ID of message")
33
+ labels: list[str] = Field(default_factory=list, description="Labels of message")
34
+ timestamp: datetime = Field(..., description="Time at which message was sent or drafted")
35
+ from_address: str = Field(..., alias="from", description="Sender address")
36
+ to: list[str] = Field(..., description="Recipient addresses")
37
+ cc: list[str] | None = Field(None, description="CC recipient addresses")
38
+ bcc: list[str] | None = Field(None, description="BCC recipient addresses")
39
+ subject: str | None = Field(None, description="Subject of message")
40
+ preview: str | None = Field(None, description="Text preview of message")
41
+ attachments: list[AttachmentMeta] | None = Field(None, description="Attachments in message")
42
+ in_reply_to: str | None = Field(None, description="Message-ID of message being replied to")
43
+ references: list[str] | None = Field(
44
+ None, description="Message-IDs of previous messages in thread"
45
+ )
46
+ headers: dict[str, str] | None = Field(None, description="Headers in message")
47
+ size: int = Field(..., description="Size of message in bytes")
48
+ updated_at: datetime = Field(..., description="Time at which message was last updated")
49
+ created_at: datetime = Field(..., description="Time at which message was created")
50
+
51
+ model_config = {"extra": "forbid", "populate_by_name": True}
52
+
53
+
54
+ class Message(BaseModel):
55
+ """
56
+ Full message model with body content.
57
+
58
+ Complete message model with body content.
59
+ """
60
+
61
+ inbox_id: str = Field(..., description="ID of inbox")
62
+ thread_id: str = Field(..., description="ID of thread")
63
+ message_id: str = Field(..., alias="id", description="ID of message")
64
+ labels: list[str] = Field(default_factory=list, description="Labels of message")
65
+ timestamp: datetime | None = Field(
66
+ None, description="Time at which message was sent or drafted"
67
+ )
68
+ from_address: str | None = Field(None, alias="from", description="Sender address")
69
+ reply_to: list[str] | None = Field(None, description="Reply-to addresses")
70
+ to: list[str] = Field(default_factory=list, description="Recipient addresses")
71
+ cc: list[str] | None = Field(None, description="CC recipient addresses")
72
+ bcc: list[str] | None = Field(None, description="BCC recipient addresses")
73
+ subject: str | None = Field(None, description="Subject of message")
74
+ preview: str | None = Field(None, description="Text preview of message")
75
+ text: str | None = Field(None, alias="content_raw", description="Plain text body of message")
76
+ html: str | None = Field(None, description="HTML body of message")
77
+ extracted_text: str | None = Field(
78
+ None,
79
+ alias="content_clean",
80
+ description="Extracted new text content (without quoted replies)",
81
+ )
82
+ extracted_html: str | None = Field(
83
+ None, description="Extracted new HTML content (without quoted replies)"
84
+ )
85
+ attachments: list[Attachment] | None = Field(None, description="Attachments in message")
86
+ in_reply_to: str | None = Field(None, description="Message-ID of message being replied to")
87
+ references: list[str] | None = Field(
88
+ None, description="Message-IDs of previous messages in thread"
89
+ )
90
+ headers: dict[str, str] | None = Field(
91
+ None, alias="metadata", description="All headers in message"
92
+ )
93
+ size: int = Field(0, description="Size of message in bytes")
94
+ direction: MessageDirection = Field(
95
+ default=MessageDirection.INBOUND, description="Inbound or outbound"
96
+ )
97
+ provider_message_id: str | None = Field(None, description="Provider's Message-ID header")
98
+ updated_at: datetime | None = Field(None, description="Time at which message was last updated")
99
+ created_at: datetime | None = Field(None, description="Time at which message was created")
100
+
101
+ model_config = {"populate_by_name": True}
102
+
103
+ @property
104
+ def id(self) -> str:
105
+ """Alias for message_id for compatibility."""
106
+ return self.message_id
107
+
108
+ @property
109
+ def content_raw(self) -> str | None:
110
+ """Alias for text for backwards compatibility."""
111
+ return self.text
112
+
113
+ @property
114
+ def content_clean(self) -> str | None:
115
+ """Alias for extracted_text for backwards compatibility."""
116
+ return self.extracted_text
117
+
118
+ @property
119
+ def metadata(self) -> dict[str, str] | None:
120
+ """Alias for headers for backwards compatibility."""
121
+ return self.headers
122
+
123
+
124
+ class ListMessagesResponse(BaseModel):
125
+ """
126
+ Response for listing messages.
127
+
128
+ Paginated response for message listing.
129
+ """
130
+
131
+ count: int = Field(..., description="Total count of messages")
132
+ limit: int | None = Field(None, description="Limit applied to results")
133
+ next_page_token: str | None = Field(None, description="Token for next page")
134
+ messages: list[MessageItem] = Field(..., description="Messages ordered by timestamp descending")
135
+
136
+ model_config = {"extra": "forbid"}
137
+
138
+
139
+ class SendMessageRequest(BaseModel):
140
+ """
141
+ Request to send a new message.
142
+
143
+ Request payload for sending a new message.
144
+ """
145
+
146
+ labels: list[str] | None = None
147
+ reply_to: str | list[str] | None = Field(None, description="Reply-to address(es)")
148
+ to: str | list[str] | None = Field(None, description="Recipient address(es)")
149
+ cc: str | list[str] | None = Field(None, description="CC address(es)")
150
+ bcc: str | list[str] | None = Field(None, description="BCC address(es)")
151
+ subject: str | None = None
152
+ text: str | None = Field(None, description="Plain text body")
153
+ html: str | None = Field(None, description="HTML body")
154
+ attachments: list[SendAttachment] | None = None
155
+ headers: dict[str, str] | None = None
156
+
157
+ model_config = {"extra": "forbid"}
158
+
159
+ def get_to_list(self) -> list[str]:
160
+ """Get 'to' as a list."""
161
+ if self.to is None:
162
+ return []
163
+ return [self.to] if isinstance(self.to, str) else list(self.to)
164
+
165
+ def get_cc_list(self) -> list[str]:
166
+ """Get 'cc' as a list."""
167
+ if self.cc is None:
168
+ return []
169
+ return [self.cc] if isinstance(self.cc, str) else list(self.cc)
170
+
171
+ def get_bcc_list(self) -> list[str]:
172
+ """Get 'bcc' as a list."""
173
+ if self.bcc is None:
174
+ return []
175
+ return [self.bcc] if isinstance(self.bcc, str) else list(self.bcc)
176
+
177
+ def get_reply_to_list(self) -> list[str]:
178
+ """Get 'reply_to' as a list."""
179
+ if self.reply_to is None:
180
+ return []
181
+ return [self.reply_to] if isinstance(self.reply_to, str) else list(self.reply_to)
182
+
183
+
184
+ class SendMessageResponse(BaseModel):
185
+ """
186
+ Response after sending a message.
187
+
188
+ Response after sending a message.
189
+ """
190
+
191
+ message_id: str
192
+ thread_id: str
193
+
194
+ model_config = {"extra": "forbid"}
195
+
196
+
197
+ class ReplyToMessageRequest(BaseModel):
198
+ """
199
+ Request to reply to a specific message.
200
+
201
+ Request payload for replying to a message.
202
+ """
203
+
204
+ labels: list[str] | None = None
205
+ reply_to: str | list[str] | None = None
206
+ to: str | list[str] | None = Field(None, description="Override recipients")
207
+ cc: str | list[str] | None = None
208
+ bcc: str | list[str] | None = None
209
+ reply_all: bool | None = Field(
210
+ None, description="Reply to all recipients of the original message"
211
+ )
212
+ text: str | None = None
213
+ html: str | None = None
214
+ attachments: list[SendAttachment] | None = None
215
+ headers: dict[str, str] | None = None
216
+
217
+ model_config = {"extra": "forbid"}
218
+
219
+ def get_to_list(self) -> list[str]:
220
+ """Get 'to' as a list."""
221
+ if self.to is None:
222
+ return []
223
+ return [self.to] if isinstance(self.to, str) else list(self.to)
224
+
225
+
226
+ class UpdateMessageRequest(BaseModel):
227
+ """
228
+ Request to update message labels.
229
+
230
+ Request payload for updating message labels.
231
+ """
232
+
233
+ add_labels: list[str] | None = Field(None, description="Labels to add to message")
234
+ remove_labels: list[str] | None = Field(None, description="Labels to remove from message")
235
+
236
+ model_config = {"extra": "forbid"}
237
+
238
+
239
+ # Legacy compatibility models (for existing codebase)
240
+ class MessageBase(BaseModel):
241
+ """Shared message fields (legacy compatibility)."""
242
+
243
+ thread_id: str = Field(..., description="Thread id")
244
+ inbox_id: str = Field(..., description="Inbox id")
245
+ provider_message_id: str | None = Field(None, description="Provider Message-ID header")
246
+ direction: MessageDirection = Field(..., description="Inbound or outbound")
247
+ content_raw: str = Field("", description="Original HTML/plain text")
248
+ content_clean: str = Field("", description="LLM-ready Markdown")
249
+ metadata: dict[str, Any] = Field(
250
+ default_factory=dict,
251
+ description="Headers, token counts, sentiment, etc.",
252
+ )
253
+
254
+
255
+ class MessageCreate(BaseModel):
256
+ """Payload to send a message (outbound via API)."""
257
+
258
+ inbox_id: str
259
+ to: list[str] = Field(..., min_length=1)
260
+ subject: str = Field(..., min_length=1)
261
+ body: str = Field(..., description="Markdown body")
262
+ reply_to_thread_id: str | None = None
263
+ cc: list[str] | None = None
264
+ bcc: list[str] | None = None
265
+ attachments: list[SendAttachment] | None = None
266
+
267
+ model_config = {"extra": "forbid"}
268
+
269
+
270
+ class MessageInCreate(BaseModel):
271
+ """Payload to create a message on ingestion (inbound webhook)."""
272
+
273
+ thread_id: str = Field(..., description="Thread id")
274
+ inbox_id: str = Field(..., description="Inbox id")
275
+ provider_message_id: str | None = Field(None, description="Provider Message-ID header")
276
+ direction: MessageDirection = Field(..., description="Inbound or outbound")
277
+ content_raw: str = Field("", description="Original HTML/plain text")
278
+ content_clean: str = Field("", description="LLM-ready Markdown")
279
+ metadata: dict[str, Any] = Field(
280
+ default_factory=dict,
281
+ description="Headers, token counts, sentiment, etc.",
282
+ )
283
+
284
+ model_config = {"extra": "forbid"}