SmartestHome/digest-engine/ingest/email_imap.py

185 lines
7.6 KiB
Python

"""IMAP mail ingestion.
Auth is a plain password via EMAIL_PASSWORD, not OAuth2/XOAUTH2: for Gmail that
means an App Password (enable 2FA, then generate one at myaccount.google.com ->
Security -> App passwords); other providers have an equivalent app-specific
password. OAuth2 would need a browser consent round-trip plus refresh-token
storage, which is far too heavy for a single-user cron-style job — App Password
is the documented, supported path for exactly this case.
"""
import email
import email.utils
import hashlib
import logging
import os
import re
from datetime import datetime, timedelta, timezone
from email.header import decode_header, make_header
from pathlib import Path
LOG = logging.getLogger(__name__)
MAX_BODY_CHARS = 2000
# Where document attachments are spooled for agenda.py to read. Inside the digest's
# own /data volume, alongside the Telegram session and the archive database.
DEFAULT_ATTACHMENT_DIR = "/data/attachments"
DOCUMENT_SUFFIXES = (".pdf", ".doc", ".docx", ".odt", ".rtf", ".txt", ".md")
DOCUMENT_TYPES = {
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.oasis.opendocument.text",
"application/rtf",
"text/plain",
}
def _decode(value):
if not value:
return ""
try:
return str(make_header(decode_header(value)))
except Exception:
return str(value)
def _body_text(message):
if message.is_multipart():
for part in message.walk():
if part.get_content_type() == "text/plain" and "attachment" not in str(
part.get("Content-Disposition", "")
):
payload = part.get_payload(decode=True)
if payload:
return payload.decode(part.get_content_charset() or "utf-8", "replace")
return ""
payload = message.get_payload(decode=True)
if not payload:
return ""
return payload.decode(message.get_content_charset() or "utf-8", "replace")
def _safe_name(filename, fallback):
"""A filename that cannot escape the spool directory. Takes the basename, keeps
only characters that are unambiguously safe, and prefixes a short hash of the
original so two "Tagesordnung.pdf"s from different senders can coexist."""
base = os.path.basename(filename or "").strip() or fallback
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", base)[:80].lstrip(".") or fallback
digest = hashlib.sha256((filename or fallback).encode("utf-8")).hexdigest()[:8]
return f"{digest}-{cleaned}"
def _attachments(message, uid):
"""Attachment metadata, plus the file itself for documents.
Documents are written to DIGEST_ATTACHMENT_DIR so `agenda.py` can read a meeting
agenda's text out of them. Everything else (images, calendar invites, the usual
signature logos) is recorded by name and type only — this is a digest, not a
mail archive, and there is no reason to spool a 4 MB photo to disk.
This writes to the digest's own data volume, exactly as `context.json` already
does. It is not a write against the mailbox: the message is fetched read-only,
the folder stays unread, and nothing is flagged, moved or deleted.
"""
if not message.is_multipart():
return []
spool = Path(os.environ.get("DIGEST_ATTACHMENT_DIR", DEFAULT_ATTACHMENT_DIR))
max_bytes = int(os.environ.get("EMAIL_MAX_ATTACHMENT_MB", "10")) * 1024 * 1024
save_documents = os.environ.get("EMAIL_SAVE_DOCUMENTS", "true").strip().lower() == "true"
found = []
for index, part in enumerate(message.walk()):
filename = part.get_filename()
disposition = str(part.get("Content-Disposition", ""))
if not filename and "attachment" not in disposition:
continue
name = _decode(filename) if filename else ""
content_type = part.get_content_type()
entry = {"filename": name, "content_type": content_type, "path": None}
is_document = name.lower().endswith(DOCUMENT_SUFFIXES) or content_type in DOCUMENT_TYPES
if save_documents and is_document:
try:
payload = part.get_payload(decode=True) or b""
if len(payload) > max_bytes:
LOG.info("email: attachment %r is %d bytes, not spooling it", name, len(payload))
else:
spool.mkdir(parents=True, exist_ok=True)
target = spool / _safe_name(name, f"uid{uid}-part{index}")
target.write_bytes(payload)
entry["path"] = str(target)
entry["bytes"] = len(payload)
except Exception:
# A document that cannot be spooled is still worth reporting by name.
LOG.warning("email: could not save attachment %r", name, exc_info=True)
found.append(entry)
return found
def fetch(lookback_hours):
host = os.environ.get("EMAIL_IMAP_HOST", "").strip()
user = os.environ.get("EMAIL_USERNAME", "").strip()
password = os.environ.get("EMAIL_PASSWORD", "")
folder = os.environ.get("EMAIL_FOLDER", "INBOX")
port = int(os.environ.get("EMAIL_IMAP_PORT", "993"))
lookback_hours = float(os.environ.get("EMAIL_LOOKBACK_HOURS", lookback_hours))
if not (host and user and password):
LOG.warning("email: EMAIL_IMAP_HOST/EMAIL_USERNAME/EMAIL_PASSWORD not all set, skipping")
return []
since = datetime.now(timezone.utc) - timedelta(hours=lookback_hours)
messages = []
try:
# Imported here, not at module scope, so a missing/broken optional
# dependency degrades this one source instead of the whole run.
from imapclient import IMAPClient
with IMAPClient(host, port=port, ssl=True) as client:
client.login(user, password)
# readonly=True is the whole point: it stops the server from setting
# \Seen on anything we FETCH. digest-engine never mutates the mailbox.
client.select_folder(folder, readonly=True)
uids = client.search(["SINCE", since.date()])
if not uids:
return []
for uid, data in client.fetch(uids, ["RFC822"]).items():
raw = data.get(b"RFC822")
if not raw:
continue
try:
parsed = email.message_from_bytes(raw)
sent_at = email.utils.parsedate_to_datetime(parsed.get("Date"))
if sent_at is not None and sent_at.tzinfo is None:
sent_at = sent_at.replace(tzinfo=timezone.utc)
if sent_at is not None and sent_at < since:
continue
messages.append(
{
"source": "email",
"uid": int(uid),
"from": _decode(parsed.get("From")),
"to": _decode(parsed.get("To")),
"subject": _decode(parsed.get("Subject")),
"timestamp": sent_at.isoformat() if sent_at else None,
"body": _body_text(parsed).strip()[:MAX_BODY_CHARS],
"attachments": _attachments(parsed, uid),
}
)
except Exception:
LOG.warning("email: could not parse message uid=%s, skipping", uid, exc_info=True)
except Exception:
LOG.warning("email: ingestion failed, returning nothing", exc_info=True)
return []
LOG.info("email: %d message(s) in the last %sh", len(messages), lookback_hours)
return messages