from __future__ import annotations import base64 import binascii import imaplib import re import smtplib import ssl from collections.abc import Iterator from contextlib import contextmanager, suppress from email.parser import BytesParser from email.policy import default as email_default_policy from ccma.storage.repository import RepositoryError def _ssl_context() -> ssl.SSLContext: return ssl.create_default_context() def _smtp_client(settings: dict) -> smtplib.SMTP: host = str(settings.get("smtp_host", "")).strip() if not host: raise RepositoryError("Kein SMTP-Server konfiguriert.") port = int(settings.get("smtp_port", 587)) security = str(settings.get("smtp_security", "starttls")) client = ( smtplib.SMTP_SSL(host, port, context=_ssl_context(), timeout=30) if security == "ssl" else smtplib.SMTP(host, port, timeout=30) ) if security == "starttls": client.starttls(context=_ssl_context()) username = str(settings.get("smtp_username", "")).strip() if username: client.login(username, str(settings.get("smtp_password", ""))) return client def _imap_client(settings: dict) -> imaplib.IMAP4: host = str(settings.get("imap_host", "")).strip() if not host: raise RepositoryError("Kein IMAP-Server konfiguriert.") port = int(settings.get("imap_port", 993)) security = str(settings.get("imap_security", "ssl")) client = ( imaplib.IMAP4_SSL(host, port, timeout=30) if security == "ssl" else imaplib.IMAP4(host, port, timeout=30) ) if security == "starttls": client.starttls(_ssl_context()) username = str(settings.get("imap_username", "")).strip() if username: client.login(username, str(settings.get("imap_password", ""))) return client @contextmanager def smtp_session(settings: dict) -> Iterator[smtplib.SMTP]: """Opens one authenticated SMTP connection to reuse across several sends (a SEPA info-mail batch may cover dozens of members -- reconnecting/re-authenticating per message would be slow and can trigger provider rate limits).""" try: client = _smtp_client(settings) except (OSError, smtplib.SMTPException) as exc: raise RepositoryError(f"SMTP-Verbindung fehlgeschlagen: {exc}") from exc try: yield client finally: with suppress(OSError, smtplib.SMTPException): client.quit() @contextmanager def imap_session(settings: dict) -> Iterator[imaplib.IMAP4]: try: client = _imap_client(settings) except (OSError, imaplib.IMAP4.error) as exc: raise RepositoryError(f"IMAP-Verbindung fehlgeschlagen: {exc}") from exc try: yield client finally: with suppress(OSError, imaplib.IMAP4.error): client.logout() def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None: message = BytesParser(policy=email_default_policy).parsebytes(message_bytes) try: client.send_message(message) except (OSError, smtplib.SMTPException) as exc: raise RepositoryError(f"E-Mail konnte nicht versandt werden: {exc}") from exc def append_message(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str, flags: str) -> None: try: status, response = client.append(_encode_imap_utf7(folder), flags, None, message_bytes) except (OSError, imaplib.IMAP4.error) as exc: raise RepositoryError(f"E-Mail konnte nicht in „{folder}“ abgelegt werden: {exc}") from exc if status != "OK": detail = response[0].decode("utf-8", "replace") if response and response[0] else status raise RepositoryError(f"IMAP-Server hat die Ablage im Ordner „{folder}“ abgelehnt: {detail}") def ensure_imap_folder(client: imaplib.IMAP4, folder: str) -> None: """Creates the folder on the server if it doesn't exist yet -- lets someone type a not-yet-existing Entwürfe/Gesendet folder name and have it just appear, instead of every append failing until it's created by hand in a separate mail client.""" encoded = _encode_imap_utf7(folder) try: status, _response = client.select(encoded, readonly=True) except (OSError, imaplib.IMAP4.error) as exc: raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht geprüft werden: {exc}") from exc if status == "OK": return try: status, response = client.create(encoded) except (OSError, imaplib.IMAP4.error) as exc: raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {exc}") from exc if status != "OK": detail = response[0].decode("utf-8", "replace") if response and response[0] else status raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {detail}") _LIST_RESPONSE_RE = re.compile( rb'^\((?P[^)]*)\)\s+(?:"(?P[^"]*)"|(?P\S+))\s+(?P.+)$' ) def list_imap_folders(settings: dict) -> list[str]: """Human-readable (decoded) folder names for the Options dialog's folder picker -- fetched live from the server, so it only works once credentials are entered.""" with imap_session(settings) as client: try: status, response = client.list() except (OSError, imaplib.IMAP4.error) as exc: raise RepositoryError(f"Ordnerliste konnte nicht abgerufen werden: {exc}") from exc if status != "OK": raise RepositoryError("Ordnerliste konnte nicht abgerufen werden.") folders = [] for raw in response or []: if not raw: continue name = _parse_list_response(raw) if name: folders.append(name) return folders def _parse_list_response(raw: bytes) -> str | None: match = _LIST_RESPONSE_RE.match(raw) if not match: return None name = match.group("name").decode("utf-8", "replace").strip() if name.startswith('"') and name.endswith('"') and len(name) >= 2: name = name[1:-1] return _decode_imap_utf7(name) def _decode_imap_utf7(value: str) -> str: # Modified UTF-7 (RFC 3501 5.1.3): "&" takes the role of "+", and "," takes the # role of "/" inside the base64 run; "&-" is a literal ampersand. if "&" not in value: return value result: list[str] = [] index = 0 length = len(value) while index < length: char = value[index] if char != "&": result.append(char) index += 1 continue end = value.find("-", index + 1) if end == -1: end = length chunk = value[index + 1 : end] if chunk == "": result.append("&") else: base64_chunk = chunk.replace(",", "/") base64_chunk += "=" * (-len(base64_chunk) % 4) try: result.append(base64.b64decode(base64_chunk).decode("utf-16-be")) except (binascii.Error, UnicodeDecodeError): result.append("&" + chunk + "-") index = end + 1 return "".join(result) def _encode_imap_utf7(value: str) -> str: if "&" not in value and all(32 <= ord(char) <= 126 for char in value): return value result: list[str] = [] index = 0 length = len(value) while index < length: char = value[index] if char == "&": result.append("&-") index += 1 continue if 32 <= ord(char) <= 126: result.append(char) index += 1 continue start = index while index < length and not (32 <= ord(value[index]) <= 126): index += 1 chunk = value[start:index] encoded = base64.b64encode(chunk.encode("utf-16-be")).decode("ascii").rstrip("=") result.append("&" + encoded.replace("/", ",") + "-") return "".join(result) def test_smtp_connection(settings: dict) -> None: with smtp_session(settings): pass def test_imap_connection(settings: dict) -> None: with imap_session(settings): pass