#!/usr/bin/env python3 from email.mime.multipart import MIMEMultipart import imaplib import email from email.utils import parseaddr from email.header import decode_header from unicodedata import name import psycopg2 from psycopg2.extras import RealDictCursor import requests import json import time import logging from typing import Optional from sqlalchemy import extract, or_ from models.database import engine, SessionLocal import models.models as models import re # Konfiguration (anpassen!) CONFIG = { 'imap_server': 'imap.gmx.net', # z.B. imap.gmx.net, outlook.office365.com 'imap_port': 993, 'username': 'sv.stthomas@gmx.at', 'password': '[Jy010]Q&7te2qR&2x0d', # App-Passwort! 'db_url': 'postgresql://user:pass@localhost:5432/deine_db', # psycopg2.connect() 'ollama_url': 'http://localhost:11434/api/generate', # Optional 'ollama_model': 'mistral', # Oder llama3 'categories': ['invoice', 'inquiry', 'spam', 'other'], # Für KI-Prompt 'poll_interval': 300, # Sekunden 'smtp_server': 'mail.gmx.net', # smtp.gmx.com oder mail.gmx.net 'smtp_port': 587, # TLS, oder 465 SSL 'smtp_username': 'zisco@gmx.at', 'smtp_password': '[Jy010]Q&7te2qR&2x0d', # App-Passwort! 'from_email': 'sv.stthomas@gmx.at', # Absender-Name } logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') def connect_imap() -> imaplib.IMAP4_SSL: """IMAP verbinden, readonly für SEEN-Flag-Schutz.""" mail = imaplib.IMAP4_SSL(CONFIG['imap_server'], CONFIG['imap_port']) mail.login(CONFIG['username'], CONFIG['password']) mail.select('INBOX', readonly=True) # Kein SEEN setzen![web:14][web:17] return mail def parse_email(msg_data: bytes) -> dict: """Mail parsen: Sender, Subject, Body.""" msg = email.message_from_bytes(msg_data) sender = decode_header(msg['From'])[0][0] if isinstance(sender, bytes): sender = sender.decode() subject = decode_header(msg['Subject'])[0][0] if isinstance(subject, bytes): subject = subject.decode() body = "" if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == 'text/plain': body = part.get_payload(decode=True).decode(errors='ignore') break else: body = msg.get_payload(decode=True).decode(errors='ignore') return { 'message_id': msg['Message-ID'], 'sender': sender, 'subject': subject, 'body_text': body[:5000] # Limit für LLM } def classify_with_ollama(body: str) -> str: """Optionale lokale KI-Klassifikation.""" try: prompt = f"""Klassifiziere diese Mail in eine Kategorie: {', '.join(CONFIG['categories'])}. Nur die Kategorie als JSON-Antwort: {{"category": "invoice"}}""" payload = { 'model': CONFIG['ollama_model'], 'prompt': prompt + "\n\nMail: " + body, 'stream': False } resp = requests.post(CONFIG['ollama_url'], json=payload, timeout=30) result = resp.json()['response'] category = json.loads(result)['category'] return category if category in CONFIG['categories'] else 'other' except Exception as e: logging.error(f"Ollama Fehler: {e}") return 'unknown' def clean_sender_name(name): if not name: return "Unknown", "Unknown" # 1. Handle CamelCase (e.g., 'HubertHaider' -> 'Hubert Haider') # This looks for a lowercase letter followed by an uppercase letter name = re.sub(r'([a-z])([A-Z])', r'\1 \2', name) # 2. Split by the first space found parts = name.split(maxsplit=1) first_name = parts[0] last_name = parts[1] if len(parts) > 1 else "" return first_name, last_name def save_to_postgres(first_name: str, sur_name: str, address: str): try: # find by name db = SessionLocal() member = db.query(models.Members).filter( or_(models.Members.member_email.is_(None),models.Members.member_email == ''), models.Members.member_firstname == first_name, models.Members.member_surname == sur_name, ).first() if member: member.member_email = address db.commit() logging.info(f"Updated Member ID {member.id} with email {address}") else: logging.warning(f"No member with name {first_name} {sur_name} found or mail {address} already stored!") except Exception as e: logging.error(f"DB Fehler: {e}") finally: if 'db' in locals(): db.close() import smtplib from email.mime.text import MIMEText from email.message import EmailMessage def send_confirmation(sender_email: str, sender_firstname: str, message_id: str, subject: str): msg = MIMEMultipart() msg['Subject'] = f'Re: {subject}' # Re: für Threading msg['From'] = CONFIG['from_email'] msg['To'] = sender_email msg['In-Reply-To'] = message_id # Threading![web:45] msg['References'] = message_id body = f""" Hallo {sender_firstname}, vielen Dank für Deine Mail! Sie wurde erfolgreich empfangen und verarbeitet. Du erhältst zukünftig Deine Zahlscheine per Mail. Mit freundlichen Grüßen, Dein lokaler Sportverein KI-Assistent """ msg.attach(MIMEText(body, 'plain')) try: with smtplib.SMTP(CONFIG['smtp_server'], CONFIG['smtp_port']) as smtp: smtp.starttls() # TLS smtp.login(CONFIG['smtp_username'], CONFIG['smtp_password']) #smtp.send_message(msg) recipients = [sender_email, CONFIG['from_email']] # Add from_email as BCC smtp.sendmail(CONFIG['from_email'], recipients, msg.as_string()) logging.info(f"Bestätigung gesendet an {sender_email}") #TODO: In DB markieren als bestätigt return True except Exception as e: logging.error(f"SMTP Fehler: {e}") return False def main(): while True: try: mail = connect_imap() _, messages = mail.search(None, 'UNSEEN', 'SUBJECT', 'zahlschein') # Nur neue mit Subject![web:11][web:20] mail_ids = messages[0].split() for mail_id in mail_ids: # [-5:] letzte 5 neue status, data = mail.fetch(mail_id, '(RFC822)') raw_email = data[0][1] msg = email.message_from_bytes(raw_email) from_header = msg.get("From") # 1. Extract Address safely address = parseaddr(from_header)[1] # 2. Extract and Decode Name safely decoded_parts = email.header.decode_header(from_header) name_parts = [] for content, charset in decoded_parts: if isinstance(content, bytes): name_parts.append(content.decode(charset or 'utf-8', errors='replace')) else: name_parts.append(content) # Clean the name (remove the part if parseaddr left it in the name) full_name = "".join(name_parts) clean_name = parseaddr(full_name)[0] or "Subscriber" print(f"Processing: {clean_name} <{address}>") # clean sender name first_name, sur_name = clean_sender_name(clean_name) save_to_postgres(first_name, sur_name, address) mail.select('INBOX', readonly=False) mail.store(mail_id, '+FLAGS', '\\Seen') mail.select('INBOX', readonly=True) # TODO: Move thread to SUST-Folder # Send confirmation reply msg_id = msg['Message-ID'] subject = msg['Subject'] or '' if send_confirmation(address, first_name, msg_id, subject): # Optional: In DB markieren als bestätigt pass mail.logout() except Exception as e: logging.error(f"Fehler: {e}") time.sleep(CONFIG['poll_interval']) if __name__ == '__main__': main()