Root cause chain (evidence: 25+ failed traces on nproj/3022414): straight quote in page text ends the guided-decoding JSON string early -> grammar deadlock -> whitespace loop until context/token limit -> chat_json fails 3x (~35 min) -> IMAP conn idles out -> move_to_trash raises -> mail never trashed -> reprocessed forever. - ingest/mailer: MailBox.ensure_alive() reconnect before finalize; move_to_trash failure falls back to flag_checked, run continues - stages: page_text() feeds only div.content minus skill-tag cloud to the LLM and maps quote chars to apostrophes (the actual degeneration trigger, live-verified: extract now OK in 21 s) - llm: requirements maxItems 40; max_tokens 8192 caps degenerate runs at ~100 s instead of ~10 min Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
140 lines
4.5 KiB
Python
140 lines
4.5 KiB
Python
"""IMAP mailbox handling and SMTP sending for chancen@destengs.com."""
|
|
from __future__ import annotations
|
|
|
|
import email
|
|
import email.utils
|
|
import imaplib
|
|
import smtplib
|
|
import ssl
|
|
from email.message import EmailMessage
|
|
from email.policy import default as default_policy
|
|
|
|
IMAP_HOST, IMAP_PORT = "mail.destengs.com", 993
|
|
SMTP_HOST, SMTP_PORT = "mail.destengs.com", 587
|
|
KEYWORD = "$ProjektChecked"
|
|
|
|
|
|
class MailBox:
|
|
def __init__(self, user, password, conn=None):
|
|
self.user, self.password = user, password
|
|
self.conn = conn or self._connect()
|
|
self.conn.select("INBOX")
|
|
self._trash = None
|
|
|
|
def _connect(self):
|
|
conn = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
|
|
conn.login(self.user, self.password)
|
|
return conn
|
|
|
|
def ensure_alive(self):
|
|
"""Reconnect if the server dropped the connection (idle timeout)."""
|
|
try:
|
|
typ, _ = self.conn.noop()
|
|
if typ == "OK":
|
|
return
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
try:
|
|
self.conn.logout()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
self.conn = self._connect()
|
|
self.conn.select("INBOX")
|
|
|
|
def unchecked_uids(self):
|
|
typ, data = self.conn.uid("SEARCH", None, f"UNKEYWORD {KEYWORD}")
|
|
if typ != "OK" or not data or not data[0]:
|
|
return []
|
|
return [u.decode() for u in data[0].split()]
|
|
|
|
def fetch(self, uid):
|
|
typ, data = self.conn.uid("FETCH", uid, "(BODY.PEEK[])")
|
|
if typ != "OK" or not data or not data[0] or not isinstance(data[0], tuple):
|
|
raise RuntimeError(f"FETCH {uid} fehlgeschlagen: {typ}")
|
|
return email.message_from_bytes(data[0][1], policy=default_policy)
|
|
|
|
def flag_checked(self, uid):
|
|
self.conn.uid("STORE", uid, "+FLAGS", f"({KEYWORD})")
|
|
|
|
def trash_folder(self):
|
|
if self._trash:
|
|
return self._trash
|
|
typ, data = self.conn.list()
|
|
candidates = []
|
|
for raw in data or []:
|
|
line = raw.decode() if isinstance(raw, bytes) else str(raw)
|
|
name = line.rsplit(" ", 1)[-1].strip('"')
|
|
if "\\Trash" in line.split(")")[0]:
|
|
self._trash = name
|
|
return name
|
|
candidates.append(name)
|
|
for cand in ("Trash", "INBOX.Trash"):
|
|
if cand in candidates:
|
|
self._trash = cand
|
|
return cand
|
|
self._trash = "Trash"
|
|
return self._trash
|
|
|
|
def move_to_trash(self, uid):
|
|
folder = self.trash_folder()
|
|
typ, _ = self.conn.uid("MOVE", uid, folder)
|
|
if typ != "OK":
|
|
self.conn.uid("COPY", uid, folder)
|
|
self.conn.uid("STORE", uid, "+FLAGS", "(\\Deleted)")
|
|
self.conn.expunge()
|
|
|
|
def close(self):
|
|
try:
|
|
self.conn.logout()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _sent_folder(conn):
|
|
"""Find the server's Sent folder via special-use flag, fallback names."""
|
|
typ, data = conn.list()
|
|
candidates = []
|
|
for raw in data or []:
|
|
line = raw.decode() if isinstance(raw, bytes) else str(raw)
|
|
name = line.rsplit(" ", 1)[-1].strip('"')
|
|
if "\\Sent" in line.split(")")[0]:
|
|
return name
|
|
candidates.append(name)
|
|
for cand in ("Sent", "INBOX.Sent"):
|
|
if cand in candidates:
|
|
return cand
|
|
return "Sent"
|
|
|
|
|
|
def append_sent(user, password, msg, conn=None):
|
|
"""Store a copy of an outgoing message in the Sent folder (best effort)."""
|
|
own = conn is None
|
|
conn = conn or imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
|
|
try:
|
|
if own:
|
|
conn.login(user, password)
|
|
folder = _sent_folder(conn)
|
|
conn.append(folder, "(\\Seen)", None, msg.as_bytes())
|
|
finally:
|
|
if own:
|
|
try:
|
|
conn.logout()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def send_mail(user, password, to, subject, body):
|
|
msg = EmailMessage()
|
|
msg["From"], msg["To"], msg["Subject"] = user, to, subject
|
|
msg["Date"] = email.utils.formatdate(localtime=True)
|
|
msg["Message-ID"] = email.utils.make_msgid(domain="destengs.com")
|
|
msg.set_content(body)
|
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=30) as server:
|
|
server.starttls(context=ssl.create_default_context())
|
|
server.login(user, password)
|
|
server.send_message(msg)
|
|
try:
|
|
append_sent(user, password, msg)
|
|
except Exception: # noqa: BLE001 — copy is best effort; the mail went out
|
|
pass
|