From 8804d21c322a756d50ac30b18fa7b6aca27ce3aa Mon Sep 17 00:00:00 2001 From: Markus Zimmerberger Date: Mon, 9 Feb 2026 15:45:46 +0100 Subject: [PATCH] #13 Add mail_checker.py --- sustApp/mail_checker.py | 234 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 sustApp/mail_checker.py diff --git a/sustApp/mail_checker.py b/sustApp/mail_checker.py new file mode 100644 index 0000000..6f13310 --- /dev/null +++ b/sustApp/mail_checker.py @@ -0,0 +1,234 @@ +#!/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()