import imaplib import smtplib import ssl import email from email import policy from email.mime.multipart import MIMEMultipart from email.mime.base import MIMEBase from email.mime.text import MIMEText from email import encoders import logging from app.database import get_settings, add_log_entry, get_import_email logger = logging.getLogger(__name__) def _send_with_log(smtp_conn: smtplib.SMTP, msg) -> str: """Send email and capture SMTP protocol exchange.""" log_lines = [] original_print_debug = smtp_conn._print_debug def _capture(*args): log_lines.append(" ".join(str(a) for a in args)) smtp_conn._print_debug = _capture old_level = smtp_conn.debuglevel smtp_conn.set_debuglevel(1) try: smtp_conn.send_message(msg) finally: smtp_conn.set_debuglevel(old_level) smtp_conn._print_debug = original_print_debug return "\n".join(log_lines) def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4: server = settings["imap_server"] port = int(settings.get("imap_port", 993)) use_ssl = settings.get("imap_ssl", "true") == "true" if use_ssl: ctx = ssl.create_default_context() conn = imaplib.IMAP4_SSL(server, port, ssl_context=ctx) else: conn = imaplib.IMAP4(server, port) conn.login(settings["imap_username"], settings["imap_password"]) return conn def _connect_smtp(settings: dict) -> smtplib.SMTP | smtplib.SMTP_SSL: server = settings["smtp_server"] port = int(settings.get("smtp_port", 587)) mode = settings.get("smtp_ssl", "starttls") if mode == "ssl": ctx = ssl.create_default_context() conn = smtplib.SMTP_SSL(server, port, context=ctx) else: conn = smtplib.SMTP(server, port) if mode == "starttls": ctx = ssl.create_default_context() conn.starttls(context=ctx) conn.login(settings["smtp_username"], settings["smtp_password"]) return conn def _extract_attachments(msg: email.message.Message) -> list[tuple[str, bytes]]: attachments = [] for part in msg.walk(): content_disposition = part.get("Content-Disposition", "") if "attachment" not in content_disposition and "inline" not in content_disposition: continue filename = part.get_filename() if not filename: continue # Decode filename if encoded decoded_parts = email.header.decode_header(filename) filename = "" for data, charset in decoded_parts: if isinstance(data, bytes): filename += data.decode(charset or "utf-8", errors="replace") else: filename += data if not filename.lower().endswith(".pdf"): continue payload = part.get_payload(decode=True) if payload: attachments.append((filename, payload)) return attachments def _build_forward_email( from_addr: str, to_addr: str, original_subject: str, original_from: str, attachments: list[tuple[str, bytes]], ) -> MIMEMultipart: msg = MIMEMultipart() msg["From"] = from_addr msg["To"] = to_addr msg["Subject"] = f"Belegimport: {original_subject}" body = ( f"Automatisch weitergeleitet von Belegimport.\n" f"Original-Absender: {original_from}\n" f"Original-Betreff: {original_subject}\n" f"Anzahl Anhänge: {len(attachments)}" ) msg.attach(MIMEText(body, "plain", "utf-8")) for filename, data in attachments: part = MIMEBase("application", "octet-stream") part.set_payload(data) encoders.encode_base64(part) part.add_header("Content-Disposition", "attachment", filename=filename) msg.attach(part) return msg def _ensure_folder_exists(conn: imaplib.IMAP4, folder: str): status, _ = conn.select(f'"{folder}"') if status != "OK": conn.create(f'"{folder}"') conn.subscribe(f'"{folder}"') # Go back to INBOX to not stay in the folder conn.select("INBOX") def _move_email(conn: imaplib.IMAP4, msg_uid: bytes, dest_folder: str): result = conn.uid("COPY", msg_uid, f'"{dest_folder}"') if result[0] == "OK": conn.uid("STORE", msg_uid, "+FLAGS", "(\\Deleted)") conn.expunge() async def _process_folder( imap_conn, smtp_conn, settings: dict, source_folder: str, processed_folder: str, import_email: str, beleg_type: str, fetch_since: str, ) -> dict: """Process one IMAP folder pair. Returns counts dict.""" smtp_from = settings.get("smtp_username", "") processed = 0 skipped = 0 errors = 0 _ensure_folder_exists(imap_conn, processed_folder) status, _ = imap_conn.select(f'"{source_folder}"') if status != "OK": logger.warning(f"Ordner '{source_folder}' konnte nicht geöffnet werden") return {"processed": 0, "skipped": 0, "errors": 0} search_criteria = "ALL" if fetch_since: try: from datetime import datetime dt = datetime.strptime(fetch_since, "%Y-%m-%d") imap_date = dt.strftime("%d-%b-%Y") search_criteria = f'(SINCE {imap_date})' except ValueError: logger.warning(f"Ungültiges Datum: {fetch_since}, verwende ALL") status, data = imap_conn.uid("SEARCH", None, search_criteria) if status != "OK" or not data[0]: logger.info(f"Keine Emails im Ordner '{source_folder}' ({beleg_type})") return {"processed": 0, "skipped": 0, "errors": 0} msg_uids = data[0].split() logger.info(f"{len(msg_uids)} Email(s) im Ordner '{source_folder}' ({beleg_type})") for msg_uid in msg_uids: subject = "?" from_addr = "?" try: status, msg_data = imap_conn.uid("FETCH", msg_uid, "(RFC822)") if status != "OK": continue raw_email = msg_data[0][1] msg = email.message_from_bytes(raw_email, policy=policy.default) subject = str(msg.get("Subject", "(Kein Betreff)")) from_addr = str(msg.get("From", "(Unbekannt)")) attachments = _extract_attachments(msg) if not attachments: skipped += 1 logger.debug(f"Übersprungen (keine Anhänge): {subject}") continue forward_msg = _build_forward_email( from_addr=smtp_from, to_addr=import_email, original_subject=subject, original_from=from_addr, attachments=attachments, ) smtp_log = _send_with_log(smtp_conn, forward_msg) imap_conn.select(f'"{source_folder}"') _move_email(imap_conn, msg_uid, processed_folder) imap_conn.select(f'"{source_folder}"') processed += 1 logger.info(f"Verarbeitet ({beleg_type}): {subject} ({len(attachments)} Anhänge)") await add_log_entry( email_subject=subject, email_from=from_addr, attachments_count=len(attachments), status="success", sent_to=import_email, smtp_log=smtp_log, beleg_type=beleg_type, ) except Exception as e: errors += 1 logger.error(f"Fehler bei Email UID {msg_uid}: {e}") try: await add_log_entry( email_subject=subject, email_from=from_addr, attachments_count=0, status="error", error_message=str(e), beleg_type=beleg_type, ) except Exception: pass return {"processed": processed, "skipped": skipped, "errors": errors} async def process_mailbox() -> dict: settings = await get_settings() import_email_eingang = get_import_email(settings, "eingang") if not settings.get("imap_server") or not import_email_eingang: logger.warning("IMAP oder Import-Email nicht konfiguriert") return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"} fetch_since = settings.get("fetch_since_date", "") total = {"processed": 0, "skipped": 0, "errors": 0} imap_conn = None smtp_conn = None try: imap_conn = _connect_imap(settings) smtp_conn = _connect_smtp(settings) # Eingangsbelege source = settings.get("source_folder", "INBOX") processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet") result = await _process_folder( imap_conn, smtp_conn, settings, source, processed_folder, import_email_eingang, "eingang", fetch_since, ) for k in total: total[k] += result[k] # Ausgangsbelege (optional) import_email_ausgang = get_import_email(settings, "ausgang") source_ausgang = settings.get("source_folder_ausgang", "") processed_ausgang = settings.get("processed_folder_ausgang", "") if import_email_ausgang and source_ausgang: if not processed_ausgang: processed_ausgang = source_ausgang + "/Verarbeitet" result = await _process_folder( imap_conn, smtp_conn, settings, source_ausgang, processed_ausgang, import_email_ausgang, "ausgang", fetch_since, ) for k in total: total[k] += result[k] except Exception as e: logger.error(f"Verbindungsfehler: {e}") await add_log_entry( email_subject="", email_from="", attachments_count=0, status="error", error_message=f"Verbindungsfehler: {e}", ) return {**total, "errors": total["errors"] + 1, "error": str(e)} finally: if imap_conn: try: imap_conn.logout() except Exception: pass if smtp_conn: try: smtp_conn.quit() except Exception: pass logger.info(f"Fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler") return total async def send_test_email() -> dict: settings = await get_settings() import_email_eingang = get_import_email(settings, "eingang") import_email_ausgang = get_import_email(settings, "ausgang") if not settings.get("smtp_server") or not import_email_eingang: return {"success": False, "error": "SMTP oder Import-Email (Eingang) nicht konfiguriert"} try: smtp_conn = _connect_smtp(settings) smtp_logs = [] # Test Eingangsbelege msg = MIMEMultipart() msg["From"] = settings["smtp_username"] msg["To"] = import_email_eingang msg["Subject"] = "Belegimport - Test-Email (Eingangsbelege)" msg.attach(MIMEText( "Dies ist eine Test-Email vom Belegimport Service.\n" "Ziel: Eingangsbelege", "plain", "utf-8", )) smtp_logs.append("=== Eingangsbelege ===") smtp_logs.append(_send_with_log(smtp_conn, msg)) # Test Ausgangsbelege (if configured) if import_email_ausgang: msg2 = MIMEMultipart() msg2["From"] = settings["smtp_username"] msg2["To"] = import_email_ausgang msg2["Subject"] = "Belegimport - Test-Email (Ausgangsbelege)" msg2.attach(MIMEText( "Dies ist eine Test-Email vom Belegimport Service.\n" "Ziel: Ausgangsbelege", "plain", "utf-8", )) smtp_logs.append("=== Ausgangsbelege ===") smtp_logs.append(_send_with_log(smtp_conn, msg2)) smtp_conn.quit() return {"success": True, "smtp_log": "\n".join(smtp_logs)} except Exception as e: logger.error(f"Test-Email fehlgeschlagen: {e}") return {"success": False, "error": str(e)} async def create_imap_folder(folder_name: str) -> dict: settings = await get_settings() if not settings.get("imap_server"): return {"success": False, "error": "IMAP nicht konfiguriert"} if not folder_name or not folder_name.strip(): return {"success": False, "error": "Ordnername darf nicht leer sein"} folder_name = folder_name.strip() try: conn = _connect_imap(settings) status, response = conn.create(f'"{folder_name}"') if status == "OK": conn.subscribe(f'"{folder_name}"') conn.logout() if status == "OK": return {"success": True} else: msg = response[0].decode() if response and isinstance(response[0], bytes) else str(response) return {"success": False, "error": msg} except Exception as e: logger.error(f"Ordner erstellen fehlgeschlagen: {e}") return {"success": False, "error": str(e)} async def test_imap_connection() -> dict: settings = await get_settings() if not settings.get("imap_server"): return {"success": False, "error": "IMAP nicht konfiguriert", "folders": []} try: conn = _connect_imap(settings) status, folder_data = conn.list() folders = [] delimiter = "." if status == "OK": for item in folder_data: decoded = item.decode() if isinstance(item, bytes) else item # Parse IMAP LIST response: (\\flags) "delimiter" "name" parts = decoded.split('"') if len(parts) >= 4: # parts[1] is the delimiter, parts[3] is the folder name if not delimiter or delimiter == ".": delimiter = parts[1] folders.append(parts[-2]) elif len(parts) >= 2: folders.append(parts[-1].strip()) conn.logout() return {"success": True, "folders": sorted(folders), "delimiter": delimiter} except Exception as e: logger.error(f"IMAP-Test fehlgeschlagen: {e}") return {"success": False, "error": str(e), "folders": [], "delimiter": "."}