- Клиент Novofon DATA API (JSON-RPC, get.calls_report, чанки по 90 дней) - faster-whisper (модель turbo) на CUDA с разделением стереоканалов - Маркировка спикеров: левый канал = caller, правый = callee - Directus: коллекции calls, calls_74951284933, contacts - CLI: process, process-file, export, daemon (polling 5 мин) - Docker Compose для Directus + PostgreSQL - Архив email-парсинга в old/
147 lines
4.6 KiB
Python
147 lines
4.6 KiB
Python
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
|