#13 Add mail_checker.py
This commit is contained in:
@@ -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 <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)
|
||||
|
||||
# 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()
|
||||
Reference in New Issue
Block a user