185 lines
7.6 KiB
Python
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
|