from typing import Optional from dataclasses import dataclass, field from email.message import Message from imap_tools import MailBox, AND @dataclass class EmailAttachment: filename: str content_type: str data: bytes from email.utils import parsedate_to_datetime @dataclass class EmailMessage: uid: str subject: str sender: str date: str message_id: str body_text: str body_html: str date_parsed: Optional[str] = None _raw_msg: object = None _attachments: list = field(default_factory=list) _CHARSET_ORDER = ["utf-8", "cp1251", "windows-1251", "koi8-r", "iso-8859-1"] def _decode_payload(part: Message) -> str: payload = part.get_payload(decode=True) if not payload: return "" declared = part.get_content_charset() charsets = [declared] if declared else [] for cs in charsets + _CHARSET_ORDER: if not cs: continue try: text = payload.decode(cs) if "Novofon" in text or "novofon" in text or "app.novofon" in text or "my.novofon" in text: return text except (UnicodeDecodeError, LookupError): continue for cs in _CHARSET_ORDER: try: text = payload.decode(cs) if any(b > 127 for b in payload): return text except (UnicodeDecodeError, LookupError): continue return payload.decode("utf-8", errors="replace") class EmailFetcher: def __init__(self, server: str, port: int, user: str, password: str, folder: str): self.server = server self.port = port self.user = user self.password = password self.folder = folder self._mailbox: Optional[MailBox] = None def connect(self): self._mailbox = MailBox(self.server, self.port) self._mailbox.login(self.user, self.password, initial_folder=self.folder) def disconnect(self): if self._mailbox: try: self._mailbox.logout() except Exception: pass self._mailbox = None def fetch_unseen(self) -> list[EmailMessage]: if self._mailbox is None: self.connect() messages = [] for msg in self._mailbox.fetch(AND(seen=False), mark_seen=False, bulk=True): raw = msg.obj if hasattr(msg, "obj") else None body_text = self._decode_body(raw, "text/plain") if raw else (msg.text or "") body_html = self._decode_body(raw, "text/html") if raw else (msg.html or "") attachments = self._extract_attachments(raw) if raw else [] date_parsed = None raw_date = msg.headers.get("Date", [""])[0] if msg.headers else msg.date_str if raw_date: try: dt = parsedate_to_datetime(raw_date) date_parsed = dt.strftime("%Y-%m-%dT%H:%M:%SZ") except Exception: pass messages.append(EmailMessage( uid=str(msg.uid), subject=msg.subject or "", sender=msg.from_ or "", date=msg.date_str or "", date_parsed=date_parsed, message_id=msg.headers.get("Message-ID", [""])[0].strip("<>"), body_text=body_text, body_html=body_html, _raw_msg=raw, _attachments=attachments, )) return messages def mark_seen(self, uid: str): if self._mailbox: self._mailbox.flag(uid, "\\Seen", True) @staticmethod def _decode_body(raw_msg: Message, mime_type: str) -> str: for part in raw_msg.walk(): ct = part.get_content_type() cd = part.get("Content-Disposition", "") if ct == mime_type and "attachment" not in cd: return _decode_payload(part) return "" @staticmethod def _extract_attachments(raw_msg: Message) -> list[EmailAttachment]: attachments = [] for part in raw_msg.walk(): cd = part.get("Content-Disposition", "") if "attachment" not in cd: continue payload = part.get_payload(decode=True) if not payload: continue ct = part.get_content_type() if "audio" in ct: fn = part.get_filename() or f"audio_{len(attachments)}" attachments.append(EmailAttachment( filename=fn, content_type=ct, data=payload, )) return attachments