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.
- stackraise/__init__.py +6 -0
- stackraise/ai/__init__.py +2 -0
- stackraise/ai/rpa.py +380 -0
- stackraise/ai/toolset.py +227 -0
- stackraise/app.py +23 -0
- stackraise/auth/__init__.py +2 -0
- stackraise/auth/model.py +24 -0
- stackraise/auth/service.py +240 -0
- stackraise/ctrl/__init__.py +4 -0
- stackraise/ctrl/change_stream.py +40 -0
- stackraise/ctrl/crud_controller.py +63 -0
- stackraise/ctrl/file_storage.py +68 -0
- stackraise/db/__init__.py +11 -0
- stackraise/db/adapter.py +60 -0
- stackraise/db/collection.py +292 -0
- stackraise/db/cursor.py +229 -0
- stackraise/db/document.py +282 -0
- stackraise/db/exceptions.py +9 -0
- stackraise/db/id.py +79 -0
- stackraise/db/index.py +84 -0
- stackraise/db/persistence.py +238 -0
- stackraise/db/pipeline.py +245 -0
- stackraise/db/protocols.py +141 -0
- stackraise/di.py +36 -0
- stackraise/event.py +150 -0
- stackraise/inflection.py +28 -0
- stackraise/io/__init__.py +3 -0
- stackraise/io/imap_client.py +400 -0
- stackraise/io/smtp_client.py +102 -0
- stackraise/logging.py +22 -0
- stackraise/model/__init__.py +11 -0
- stackraise/model/core.py +16 -0
- stackraise/model/dto.py +12 -0
- stackraise/model/email_message.py +88 -0
- stackraise/model/file.py +154 -0
- stackraise/model/name_email.py +45 -0
- stackraise/model/query_filters.py +231 -0
- stackraise/model/time_range.py +285 -0
- stackraise/model/validation.py +8 -0
- stackraise/templating/__init__.py +4 -0
- stackraise/templating/exceptions.py +23 -0
- stackraise/templating/image/__init__.py +2 -0
- stackraise/templating/image/model.py +51 -0
- stackraise/templating/image/processor.py +154 -0
- stackraise/templating/parser.py +156 -0
- stackraise/templating/pptx/__init__.py +3 -0
- stackraise/templating/pptx/pptx_engine.py +204 -0
- stackraise/templating/pptx/slide_renderer.py +181 -0
- stackraise/templating/tracer.py +57 -0
- stackraise-0.1.0.dist-info/METADATA +37 -0
- stackraise-0.1.0.dist-info/RECORD +52 -0
- 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 (" → ", á → á, 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 *
|
stackraise/model/core.py
ADDED
|
@@ -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
|
+
|
stackraise/model/dto.py
ADDED
|
@@ -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
|
+
|