stackraise 0.1.0__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 (52) hide show
  1. stackraise/__init__.py +6 -0
  2. stackraise/ai/__init__.py +2 -0
  3. stackraise/ai/rpa.py +380 -0
  4. stackraise/ai/toolset.py +227 -0
  5. stackraise/app.py +23 -0
  6. stackraise/auth/__init__.py +2 -0
  7. stackraise/auth/model.py +24 -0
  8. stackraise/auth/service.py +240 -0
  9. stackraise/ctrl/__init__.py +4 -0
  10. stackraise/ctrl/change_stream.py +40 -0
  11. stackraise/ctrl/crud_controller.py +63 -0
  12. stackraise/ctrl/file_storage.py +68 -0
  13. stackraise/db/__init__.py +11 -0
  14. stackraise/db/adapter.py +60 -0
  15. stackraise/db/collection.py +292 -0
  16. stackraise/db/cursor.py +229 -0
  17. stackraise/db/document.py +282 -0
  18. stackraise/db/exceptions.py +9 -0
  19. stackraise/db/id.py +79 -0
  20. stackraise/db/index.py +84 -0
  21. stackraise/db/persistence.py +238 -0
  22. stackraise/db/pipeline.py +245 -0
  23. stackraise/db/protocols.py +141 -0
  24. stackraise/di.py +36 -0
  25. stackraise/event.py +150 -0
  26. stackraise/inflection.py +28 -0
  27. stackraise/io/__init__.py +3 -0
  28. stackraise/io/imap_client.py +400 -0
  29. stackraise/io/smtp_client.py +102 -0
  30. stackraise/logging.py +22 -0
  31. stackraise/model/__init__.py +11 -0
  32. stackraise/model/core.py +16 -0
  33. stackraise/model/dto.py +12 -0
  34. stackraise/model/email_message.py +88 -0
  35. stackraise/model/file.py +154 -0
  36. stackraise/model/name_email.py +45 -0
  37. stackraise/model/query_filters.py +231 -0
  38. stackraise/model/time_range.py +285 -0
  39. stackraise/model/validation.py +8 -0
  40. stackraise/templating/__init__.py +4 -0
  41. stackraise/templating/exceptions.py +23 -0
  42. stackraise/templating/image/__init__.py +2 -0
  43. stackraise/templating/image/model.py +51 -0
  44. stackraise/templating/image/processor.py +154 -0
  45. stackraise/templating/parser.py +156 -0
  46. stackraise/templating/pptx/__init__.py +3 -0
  47. stackraise/templating/pptx/pptx_engine.py +204 -0
  48. stackraise/templating/pptx/slide_renderer.py +181 -0
  49. stackraise/templating/tracer.py +57 -0
  50. stackraise-0.1.0.dist-info/METADATA +37 -0
  51. stackraise-0.1.0.dist-info/RECORD +52 -0
  52. stackraise-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,400 @@
1
+ from __future__ import annotations
2
+
3
+ from email.message import Message
4
+ import imaplib
5
+ import html
6
+ from asyncio import get_event_loop
7
+ from contextlib import asynccontextmanager
8
+ from dataclasses import dataclass
9
+ from email import policy
10
+ from email.parser import BytesParser
11
+ from email.utils import parsedate_to_datetime
12
+ from functools import wraps
13
+ from ssl import SSLContext
14
+ from typing import Optional, Sequence
15
+ from datetime import datetime, timezone
16
+
17
+ import stackraise.model as model
18
+ from aioimaplib import IMAP4_SSL as InnerImapSslClient
19
+ #from pydantic import BaseModel
20
+
21
+ # TODO: implementar utilizando aioimaplib
22
+
23
+
24
+ class ImapError(Exception): ...
25
+
26
+
27
+ def in_executor(fn):
28
+ @wraps(fn)
29
+ async def wrapper(*args, **kwargs):
30
+ loop = get_event_loop()
31
+ return await loop.run_in_executor(None, fn, *args, **kwargs)
32
+
33
+ return wrapper
34
+
35
+
36
+ @dataclass
37
+ class InboxInfo:
38
+ exists: int
39
+ recent: int
40
+ uidvalidity: int
41
+ #uidvalidity: bool
42
+ flags: list[str]
43
+
44
+
45
+ import re
46
+
47
+ _SELECT_PATTERNS = {
48
+ "exists": re.compile(r"(\d+)\s+EXISTS", re.I),
49
+ "recent": re.compile(r"(\d+)\s+RECENT", re.I),
50
+ "uidvalidity": re.compile(r"UIDVALIDITY\s+(\d+)", re.I),
51
+ "uidnext": re.compile(r"UIDNEXT\s+(\d+)", re.I),
52
+ "highestmodseq": re.compile(r"HIGHESTMODSEQ\s+(\d+)", re.I),
53
+ "flags_block": re.compile(r"FLAGS\s*\((.*?)\)", re.I), # captura lo de dentro de (...)
54
+ }
55
+
56
+ _GMAIL_QUOTE_RX = re.compile(
57
+ r"(?is)<blockquote[^>]*class=[\"']?gmail_quote[^>]*>.*?</blockquote>"
58
+ )
59
+
60
+ #Quizás esto solo sirva para el formato de GMAIL
61
+ _REPLY_SEP_RX = re.compile(r"""(?imx)
62
+ ^On\s.+\swrote:\s*$|
63
+ ^El\s+\w{3},.+\sescribi[oó]:\s*$|
64
+ ^De:\s.*$|
65
+ ^From:\s.*$|
66
+ ^-----Original Message-----\s*$|
67
+ ^_{2,}\s*Forwarded message\s*_{2,}\s*$
68
+ """)
69
+
70
+
71
+ class ImapClient:
72
+ class Settings(model.Base):
73
+ server: str
74
+ timeout: int = 10
75
+ username: str
76
+ password: str
77
+
78
+ def __init__(self, settings: Settings):
79
+ self._settings = settings
80
+ self._ssl_context = SSLContext()
81
+
82
+ @asynccontextmanager
83
+ async def session(self):
84
+ session = self.Session(self)
85
+ async with session:
86
+ yield session
87
+
88
+ class Session:
89
+ def __init__(self, client: ImapClient):
90
+ self._client = client
91
+ self._inner = InnerImapSslClient(
92
+ client._settings.server,
93
+ ssl_context=client._ssl_context,
94
+ timeout=10,
95
+ )
96
+
97
+ async def __aenter__(self):
98
+ await self._inner.wait_hello_from_server()
99
+ await self._inner.login(
100
+ self._client._settings.username, self._client._settings.password
101
+ )
102
+
103
+ async def __aexit__(self, exc_type, exc_value, traceback):
104
+ await self._inner.logout()
105
+
106
+ @staticmethod
107
+ def _parse_select_payload(payload: list[bytes]) -> dict:
108
+ info = {
109
+ "exists": 0,
110
+ "recent": 0,
111
+ "uidvalidity": 0,
112
+ "uidnext": None,
113
+ "highestmodseq": None,
114
+ "flags": [],
115
+ }
116
+ for raw in payload or []:
117
+ line = raw.decode(errors="ignore")
118
+
119
+ # números
120
+ for key in ("exists", "recent", "uidvalidity", "uidnext", "highestmodseq"):
121
+ m = _SELECT_PATTERNS[key].search(line)
122
+ if m:
123
+ info[key] = int(m.group(1))
124
+
125
+ # flags
126
+ mflags = _SELECT_PATTERNS["flags_block"].search(line)
127
+ if mflags:
128
+ # separa por espacios sin romper backslashes
129
+ flags_text = mflags.group(1).strip()
130
+ # ejemplo: "\Answered \Flagged \Draft \Deleted \Seen $NotPhishing $Phishing"
131
+ if flags_text:
132
+ info["flags"] = flags_text.split()
133
+
134
+ return info
135
+
136
+ @staticmethod
137
+ def _extract_rfc822_bytes(payload):
138
+ """
139
+ Find the RFC822 message bytes in a FETCH payload.
140
+ Handles common shapes returned by imaplib/aioimaplib:
141
+ - [ (b'1 (RFC822 {n}', b'...raw...'), b')' ]
142
+ - [ b'1 (RFC822 {n}', b'...raw...', b')' ]
143
+ - dict-like with a bytes value
144
+ """
145
+ # list/tuple payload
146
+ if isinstance(payload, (list, tuple)):
147
+ for part in payload:
148
+ # tuple: (meta, bytes)
149
+ if isinstance(part, tuple) and len(part) >= 2 and isinstance(part[1], (bytes, bytearray)):
150
+ return bytes(part[1])
151
+ # raw bytes as a separate entry
152
+ if isinstance(part, (bytes, bytearray)):
153
+ # heuristics: looks like a full message if it has headers/body separator
154
+ if b"\r\n\r\n" in part or part.startswith(b"From:") or part.startswith(b"Received:"):
155
+ return bytes(part)
156
+
157
+ # dict payload (some clients)
158
+ if isinstance(payload, dict):
159
+ for _, val in payload.items():
160
+ if isinstance(val, (bytes, bytearray)):
161
+ return bytes(val)
162
+
163
+ # nothing found
164
+ raise ImapError("Cannot find RFC822 bytes in FETCH payload")
165
+
166
+ @staticmethod
167
+ def _html_to_text(segment: str) -> str:
168
+ """Helper function to convert HTML to plain text."""
169
+ if not segment:
170
+ return ""
171
+ # quita quoted reply de Gmail
172
+ segment = _GMAIL_QUOTE_RX.sub("", segment)
173
+ # saltos de línea básicos
174
+ segment = re.sub(r"(?i)<br\s*/?>", "\n", segment)
175
+ segment = re.sub(r"(?i)</p\s*>", "\n\n", segment)
176
+ # elimina el resto de etiquetas
177
+ segment = re.sub(r"<[^>]+>", "", segment)
178
+ # desescapa entidades (&quot; → ", &aacute; → á, etc.)
179
+ segment = html.unescape(segment)
180
+ # normaliza espacios
181
+ segment = re.sub(r"[ \t]+", " ", segment)
182
+ segment = re.sub(r"\n{3,}", "\n\n", segment).strip()
183
+ return segment
184
+
185
+
186
+ @staticmethod
187
+ def _pick_body_part(msg: Message, prefer: Sequence[str] = ("plain", "html")) -> Message:
188
+ """
189
+ Returns a Message that is text/plain or text/html according to preference.
190
+ If there's no multipart, returns msg.
191
+ """
192
+ # Nuevo API con policy.default
193
+ part = msg.get_body(preferencelist=list(prefer))
194
+ if part:
195
+ return part
196
+ # fallback: busca a mano
197
+ if msg.is_multipart():
198
+ for p in msg.walk():
199
+ ctype = (p.get_content_maintype(), p.get_content_subtype())
200
+ if ctype == ("text", "plain"):
201
+ return p
202
+ for p in msg.walk():
203
+ ctype = (p.get_content_maintype(), p.get_content_subtype())
204
+ if ctype == ("text", "html"):
205
+ return p
206
+ return msg
207
+
208
+
209
+ @staticmethod
210
+ def _strip_quoted_plaintext(s: str) -> str:
211
+ """
212
+ Removes quoted text from replies/forwards in plain text:
213
+ - Cuts from known separators (Gmail/Outlook)
214
+ - Discards quoted lines that start with '>'
215
+ """
216
+ if not s:
217
+ return ""
218
+ lines = s.splitlines()
219
+ out = []
220
+ for line in lines:
221
+ # if we find a thread separator, cut there
222
+ if _REPLY_SEP_RX.match(line):
223
+ break
224
+ # ignore quote lines like "> ..."
225
+ if line.strip().startswith(">"):
226
+ continue
227
+ out.append(line)
228
+ text = "\n".join(out).strip()
229
+ # collapse excessive line breaks
230
+ text = re.sub(r"\n{3,}", "\n\n", text)
231
+ return text
232
+
233
+
234
+ async def select(self, mailbox: str):
235
+ status, payload = await self._inner.select(mailbox)
236
+ if status != "OK":
237
+ txt = ", ".join(e.decode() for e in (payload or []))
238
+ raise ImapError(f"IMAPError '{status}' selecting mailbox '{mailbox}': {txt}")
239
+
240
+ info = self._parse_select_payload(payload)
241
+
242
+ # log opcional
243
+ print(
244
+ f"Mailbox selected successfully: EXISTS={info['exists']} RECENT={info['recent']} "
245
+ f"UIDVALIDITY={info['uidvalidity']} FLAGS={info['flags']}"
246
+ )
247
+
248
+ return InboxInfo(
249
+ exists=info["exists"],
250
+ recent=info["recent"],
251
+ uidvalidity=info["uidvalidity"],
252
+ flags=info["flags"],
253
+ )
254
+
255
+
256
+ async def search(self, *criteria):
257
+ # match await self._inner.search(None, *criteria):
258
+ # case "OK", messages:
259
+ # return messages[0].split() if messages[0] else []
260
+ # case err, payload:
261
+ # payload = ", ".join(e.decode() for e in payload)
262
+ # raise ImapError(
263
+ # f"IMAPError '{err}' searching emails with criteria {criteria}: {payload}"
264
+ # )
265
+ def _tokenize(x):
266
+ if isinstance(x, (list, tuple)):
267
+ for y in x:
268
+ yield from _tokenize(y)
269
+ elif isinstance(x, str):
270
+ for t in x.split():
271
+ yield t
272
+ else:
273
+ yield str(x)
274
+
275
+ final_args = list(_tokenize(criteria)) # SIN charset None
276
+ status, messages = await self._inner.search(*final_args)
277
+
278
+ if status == "OK":
279
+ ids = messages[0].split() if messages and messages[0] else []
280
+ # devuelve siempre str
281
+ return [i.decode() if isinstance(i, (bytes, bytearray)) else str(i) for i in ids]
282
+ else:
283
+ payload = ", ".join(e.decode() for e in (messages or []))
284
+ raise ImapError(
285
+ f"IMAPError '{status}' searching emails with criteria {criteria}: {payload}"
286
+ )
287
+
288
+
289
+ async def tag(self, email_id, command: str, flags: str):
290
+ match await self._inner.store(email_id, "+X-GM-LABELS", flags):
291
+ case "OK", payload:
292
+ pass
293
+ case err, payload:
294
+ payload = ", ".join(e.decode() for e in payload)
295
+ raise ImapError(
296
+ f"IMAPError '{err}' tagging email '{email_id}' with '{command} {flags}': {payload}"
297
+ )
298
+
299
+
300
+ async def fetch(
301
+ self,
302
+ email_id: str,
303
+ body_content_type_preference: Sequence[str] = ("plain", "html"),
304
+ ) -> model.EmailMessage.WithEmbeddedAttachments:
305
+ """
306
+ Fetches an email by its ID and returns its content and attachments.
307
+ Args:
308
+ email_id (str): The ID of the email to fetch.
309
+ body_content_type_preference (Sequence[str]): A sequence of content types to prefer when extracting
310
+ the body of the email. The first matching content type will be used.
311
+ Returns:
312
+ model.EmailMessage.WithEmbeddedAttachments: An object containing the email's metadata, body, and attachments.
313
+
314
+ Note:
315
+ This method uses the IMAP FETCH command to retrieve the email content and attachments.
316
+ Attachments are parsed from the email content and returned as a list of File objects separately,
317
+ so they can be stored or processed independently.
318
+ """
319
+ status, payload = await self._inner.fetch(str(email_id), "(RFC822)")
320
+ if status != "OK":
321
+ payload_text = ", ".join(
322
+ (p.decode() if isinstance(p, (bytes, bytearray)) else str(p)) for p in (payload or [])
323
+ )
324
+ raise ImapError(f"IMAPError '{status}' fetching email '{email_id}': {payload_text}")
325
+
326
+ # Extract raw RFC822 bytes safely
327
+ raw_bytes = self._extract_rfc822_bytes(payload)
328
+ if not isinstance(raw_bytes, (bytes, bytearray)):
329
+ raise ImapError(f"FETCH returned no RFC822 bytes for {email_id}; got {type(raw_bytes)}: {repr(raw_bytes)[:200]}")
330
+
331
+ email_content = BytesParser(policy=policy.default).parsebytes(raw_bytes)
332
+
333
+ attachments = []
334
+
335
+ for part in email_content.walk():
336
+ if part.get_content_maintype() == "multipart":
337
+ continue
338
+ if part.get("Content-Disposition") is None:
339
+ continue
340
+ if (filename := part.get_filename()) is not None:
341
+ attachments.append(
342
+ model.File.new(
343
+ filename=filename,
344
+ content_type=part.get_content_type(),
345
+ content=part.get_payload(decode=True),
346
+ disposition=part.get("Content-Disposition"),
347
+ )
348
+ )
349
+
350
+ body_part = self._pick_body_part(email_content, prefer=body_content_type_preference)
351
+ raw_body = body_part.get_content()
352
+
353
+ if body_part.get_content_type() == "text/html":
354
+ body_text = self._html_to_text(raw_body)
355
+ body_text = self._strip_quoted_plaintext(body_text)
356
+ else:
357
+ body_text = self._strip_quoted_plaintext(raw_body)
358
+
359
+ date_header = email_content.get("date")
360
+ dt = parsedate_to_datetime(date_header) if date_header else datetime.now(timezone.utc)
361
+
362
+ return model.EmailMessage.WithEmbeddedAttachments(
363
+ date=dt,
364
+ sender=model.NameEmail.from_str(email_content.get("from").strip()),
365
+ to=_parse_name_email_list(email_content.get("to", "")),
366
+ cc=_parse_name_email_list(email_content.get("cc", "")),
367
+ subject=email_content.get("subject"),
368
+ # body=email_content.get_body(
369
+ # body_content_type_preference
370
+ # ).get_payload(),
371
+ body=body_text,
372
+ attachments=attachments,
373
+ )
374
+
375
+
376
+
377
+
378
+ def _parse_name_email_list(s: str):
379
+ return [model.NameEmail.from_str(e.strip()) for e in s.split(",")] if s else []
380
+
381
+
382
+ if __name__ == "__main__":
383
+
384
+
385
+ async def main():
386
+
387
+ imap = ImapClient(
388
+ ImapClient.Settings(
389
+ server="imap.gmail.com",
390
+ username="proyectoleitat@gmail.com",
391
+ password="fjej yfzg xgon pohk",
392
+ )
393
+ )
394
+
395
+ async with imap.session():
396
+ inbox = await imap.select("INBOX")
397
+ print(inbox)
398
+ email_ids = await imap.search('NOT X-GM-LABELS "PROCESSED"')
399
+ print("EMAIL IDS", email_ids)
400
+ email = await imap.fetch(email_ids[0])
@@ -0,0 +1,102 @@
1
+ import asyncio
2
+ from email.message import EmailMessage
3
+ from logging import getLogger as get_logger
4
+ from smtplib import SMTP_SSL, SMTPException
5
+
6
+ import stackraise.di as di
7
+ import stackraise.model as model
8
+ from aiosmtplib import send
9
+
10
+ log = get_logger(__name__)
11
+
12
+ class SMTPClient(di.Singleton):
13
+ """SMTP client for sending emails using SMTP over SSL."""
14
+ class Settings(model.Base):
15
+ server: str
16
+ username: str
17
+ password: str
18
+
19
+ def __init__(self, settings: Settings):
20
+ self.settings = settings
21
+
22
+ self._inner: SMTP_SSL = None
23
+ self._context_counter = 0
24
+
25
+ async def connect(self):
26
+ try:
27
+ assert self._inner == None, f"SMTP client already connected"
28
+ smpt_client = SMTP_SSL(self.settings.server)
29
+ smpt_client.login(self.settings.username, self.settings.password)
30
+ self._inner = smpt_client
31
+ except SMTPException as e:
32
+ log.info(f"Failed to connect to SMTP server: {e}")
33
+
34
+ async def disconnect(self):
35
+ if self._inner:
36
+ self._inner.quit()
37
+ self._inner = None
38
+
39
+ async def __aenter__(self):
40
+ self._context_counter += 1
41
+ await self.connect()
42
+ return self
43
+
44
+ async def __aexit__(self, exc_type, exc_value, traceback):
45
+ self._context_counter -= 1
46
+ if self._context_counter == 0:
47
+ await self.disconnect()
48
+ if exc_type:
49
+ log.error(f"Error during SMTP operation: {exc_value}")
50
+
51
+ async def send_email(self, email: model.EmailMessage | model.EmailMessage.WithEmbeddedAttachments):
52
+
53
+ if not isinstance(email, model.EmailMessage.WithEmbeddedAttachments):
54
+ email = await email.fetch_attachments()
55
+
56
+
57
+ sender = str(email.sender if email.sender is not None else self.settings.username)
58
+
59
+ to = [str(r) for r in email.to]
60
+
61
+ # Create a standard RFC email message
62
+ msg = EmailMessage()
63
+ msg["From"] = sender
64
+ msg["To"] = ", ".join(to)
65
+ msg["Subject"] = email.subject
66
+ msg.set_content(email.body)
67
+
68
+
69
+ for attachment in email.attachments:
70
+ content = await attachment.content()
71
+ content_type = attachment.content_type or "application/octet-stream"
72
+ maintype, subtype = content_type.split('/')
73
+ msg.add_attachment(
74
+ content,
75
+ maintype=maintype,
76
+ subtype=subtype,
77
+ filename=attachment.filename
78
+ )
79
+
80
+ await send(msg, hostname=self.settings.server,
81
+ #port=465, # Default port for SMTP over SSL
82
+ username=self.settings.username,
83
+ password=self.settings.password,
84
+ use_tls=True
85
+ )
86
+
87
+ # loop = asyncio.get_running_loop()
88
+ # await loop.run_in_executor(
89
+ # None, # None = default thread pool
90
+ # lambda: self._inner.sendmail(sender, to, msg.as_string())
91
+ # )
92
+
93
+
94
+
95
+
96
+ # try:
97
+ # if not self._inner:
98
+ # log.info("Connection not established. Call connect() first.")
99
+ # return
100
+
101
+ # except SMTPException as e:
102
+ # log.info(f"Failed to send email: {e}")
stackraise/logging.py ADDED
@@ -0,0 +1,22 @@
1
+ import logging
2
+ from os import getenv
3
+ from rich.logging import RichHandler
4
+
5
+ LEVEL_NAMES = logging.getLevelNamesMapping()
6
+ LOG_LEVEL = LEVEL_NAMES.get(getenv('LOG_LEVEL', 'WARNING').upper())
7
+
8
+ logging.basicConfig(
9
+ level=logging.INFO,
10
+ )
11
+
12
+ # logging.basicConfig(
13
+ # #level="ERROR",
14
+ # level="INFO",
15
+ # format="%(message)s",
16
+ # datefmt="[%X]",
17
+ # handlers=[RichHandler()],
18
+ # )
19
+
20
+
21
+ def get_logger(name: str) -> logging.Logger:
22
+ return logging.getLogger(name)
@@ -0,0 +1,11 @@
1
+ from pydantic import Field, Discriminator, computed_field, Field
2
+
3
+ from .core import *
4
+ from .query_filters import *
5
+ from .dto import *
6
+
7
+ from .time_range import *
8
+ from .file import *
9
+ from .email_message import *
10
+ from .name_email import *
11
+ from .validation import *
@@ -0,0 +1,16 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+ from stackraise import inflection
3
+
4
+ __all__ = ["Base", 'Field']
5
+
6
+ class Base(BaseModel):
7
+ model_config = ConfigDict(
8
+ populate_by_name=True,
9
+ use_enum_values=True,
10
+ validate_default=True,
11
+ validate_assignment=True,
12
+ serialize_by_alias=True,
13
+ validate_by_alias=True,
14
+ alias_generator=inflection.to_camelcase,
15
+ )
16
+
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+ from.query_filters import QueryFilters
3
+ from .core import Base
4
+
5
+ class DtoMeta(type(Base)):
6
+ @property
7
+ def QueryFilters(cls) -> type[QueryFilters]:
8
+ return QueryFilters.for_model(cls)
9
+
10
+ class Dto(Base, metaclass=DtoMeta):
11
+ ...
12
+
@@ -0,0 +1,88 @@
1
+ from typing import Annotated, Optional
2
+ from unittest.mock import Base
3
+
4
+ from .core import Base, Field
5
+ from .dto import Dto
6
+ from .file import File
7
+ from .name_email import NameEmail
8
+ from .time_range import DateTime
9
+
10
+
11
+ class _EmailMessageBase(Base):
12
+ subject: Annotated[
13
+ Optional[str],
14
+ Field(default=None),
15
+ ]
16
+ body: Annotated[
17
+ str,
18
+ Field(),
19
+ ]
20
+ date: Annotated[
21
+ DateTime,
22
+ Field(),
23
+ ]
24
+ sender: Annotated[
25
+ NameEmail,
26
+ Field(),
27
+ ]
28
+ to: Annotated[
29
+ list[NameEmail],
30
+ Field(default_factory=list),
31
+ ]
32
+ cc: Annotated[
33
+ list[NameEmail],
34
+ Field(default_factory=list),
35
+ ]
36
+
37
+ class EmailMessage(_EmailMessageBase):
38
+
39
+
40
+ attachment: Annotated[
41
+ list[File.Ref],
42
+ Field(default_factory=list),
43
+ ]
44
+
45
+ async def attachments(self):
46
+ for attachment_ref in self.attachment:
47
+ yield await attachment_ref.fetch()
48
+
49
+ async def fetch_attachments(self):
50
+ files = [await ref.fetch() for ref in self.attachment]
51
+ return self.WithEmbeddedAttachments(
52
+ subject=self.subject,
53
+ body=self.body,
54
+ date=self.date,
55
+ sender=self.sender,
56
+ to=self.to,
57
+ cc=self.cc,
58
+ attachments=files,
59
+ )
60
+
61
+
62
+ class WithEmbeddedAttachments(_EmailMessageBase, Dto):
63
+ attachments: Annotated[
64
+ list[File],
65
+ Field(default_factory=list),
66
+ ]
67
+
68
+ async def commit_attachments(self):
69
+ attachments: list[File.Ref] = []
70
+ for attachment in self.attachments:
71
+ file = await attachment.store()
72
+ attachments.append(file.ref)
73
+
74
+ return EmailMessage(
75
+ subject=self.subject,
76
+ body=self.body,
77
+ date=self.date,
78
+ sender=self.sender,
79
+ to=self.to,
80
+ cc=self.cc,
81
+ attachment=attachments,
82
+ )
83
+
84
+
85
+
86
+
87
+
88
+