#!/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 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( models.Members.member_email == None, 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 #TODO: Re: Mail als gelesen markieren return True except Exception as e: logging.error(f"SMTP Fehler: {e}") return False def main(): while True: try: mail = connect_imap() status, 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) # 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()