call-to-text/old/email_fetcher.py
ed0ss 7f03949593 Первая версия: транскрипция звонков из Novofon DATA API в Directus
- Клиент 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/
2026-06-26 12:44:50 +03:00

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