236 lines
8.2 KiB
Python
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()
|