Files
SUST/sustApp/mail_checker.py
2026-06-25 11:19:08 +02:00

236 lines
8.2 KiB
Python

#!/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 <email> 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()