- db-Parameter in extract_and_process_attachments hinzugefügt - INVOICE_STORAGE_PATH in docker-compose.yml gesetzt - Ablagepfad: /tmp/invoice-storage im Container - Alte DB-Einträge aktualisiert Co-Authored-By: Claude <noreply@anthropic.com>
428 lines
14 KiB
Python
428 lines
14 KiB
Python
"""
|
|
C-Stone Invoice Check — Email Router
|
|
IMAP Email-Verarbeitung und Filterung
|
|
"""
|
|
|
|
import imaplib
|
|
import email
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime
|
|
from typing import List, Optional, Dict, Any
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
from email.header import decode_header
|
|
|
|
from backend.database import get_db
|
|
from backend.models import EmailLog, Invoice
|
|
|
|
router = APIRouter()
|
|
|
|
# IMAP Konfiguration
|
|
IMAP_HOST = os.getenv("IMAP_HOST", "imap.strato.de")
|
|
IMAP_PORT = int(os.getenv("IMAP_PORT", 993))
|
|
IMAP_USER = os.getenv("IMAP_USER", "")
|
|
IMAP_PASSWORD = os.getenv("IMAP_PASSWORD", "")
|
|
|
|
# Gmail Konfiguration
|
|
GMAIL_IMAP_HOST = os.getenv("GMAIL_IMAP_HOST", "imap.gmail.com")
|
|
GMAIL_IMAP_PORT = int(os.getenv("GMAIL_IMAP_PORT", 993))
|
|
GMAIL_IMAP_USER = os.getenv("GMAIL_IMAP_USER", "")
|
|
GMAIL_IMAP_PASSWORD = os.getenv("GMAIL_IMAP_PASSWORD", "")
|
|
|
|
# Filter-Kriterien
|
|
INVOICE_KEYWORDS = ["rechnung", "invoice", "receipt", "bill", "factuur"]
|
|
|
|
# Ablagepfad (Evo-X2) - optional
|
|
INVOICE_STORAGE_PATH = os.getenv("INVOICE_STORAGE_PATH", "/tmp/invoice-storage")
|
|
|
|
|
|
def connect_imap(host: str, port: int, user: str, password: str) -> imaplib.IMAP4_SSL:
|
|
"""Verbindet mit IMAP-Server"""
|
|
mail = imaplib.IMAP4_SSL(host, port)
|
|
mail.login(user, password)
|
|
return mail
|
|
|
|
|
|
def search_invoices(mail: imaplib.IMAP4_SSL, folder: str = "INBOX") -> List[str]:
|
|
"""Sucht nach Emails mit Rechnungs-Stichworten"""
|
|
mail.select(folder)
|
|
result, data = mail.search(None, 'ALL')
|
|
if result != "OK":
|
|
return []
|
|
|
|
email_ids = data[0].split()
|
|
matching_ids = []
|
|
|
|
for email_id in email_ids:
|
|
result, msg_data = mail.fetch(email_id, '(RFC822)')
|
|
if result != "OK":
|
|
continue
|
|
|
|
msg = email.message_from_bytes(msg_data[0][1])
|
|
subject = msg.get("Subject", "")
|
|
from_addr = msg.get("From", "")
|
|
|
|
# Subject decodieren
|
|
decoded_subject, encoding = decode_header(subject)[0]
|
|
if isinstance(decoded_subject, bytes):
|
|
subject = decoded_subject.decode(encoding or "utf-8")
|
|
|
|
# Prüfen auf Keywords
|
|
subject_lower = subject.lower()
|
|
if any(kw in subject_lower for kw in INVOICE_KEYWORDS):
|
|
matching_ids.append(email_id.decode())
|
|
|
|
return matching_ids
|
|
|
|
|
|
def extract_attachments(msg: email.message.Message) -> List[Dict[str, Any]]:
|
|
"""Extrahiert Anhänge aus Email"""
|
|
attachments = []
|
|
for part in msg.walk():
|
|
if part.get_content_maintype() == 'multipart':
|
|
continue
|
|
if part.get('Content-Disposition') is None:
|
|
continue
|
|
|
|
filename = part.get_filename()
|
|
if filename:
|
|
decoded_filename, encoding = decode_header(filename)[0]
|
|
if isinstance(decoded_filename, bytes):
|
|
filename = decoded_filename.decode(encoding or "utf-8")
|
|
|
|
content_type = part.get_content_type()
|
|
payload = part.get_payload(decode=True)
|
|
|
|
attachments.append({
|
|
"filename": filename,
|
|
"content_type": content_type,
|
|
"data": payload,
|
|
"size": len(payload) if payload else 0
|
|
})
|
|
|
|
return attachments
|
|
|
|
|
|
def parse_email_date(date_str: str) -> Optional[datetime]:
|
|
"""Parses various email date formats"""
|
|
if not date_str:
|
|
return None
|
|
|
|
from email.utils import parsedate_to_datetime
|
|
|
|
try:
|
|
# Versuche email.utils parsedate_to_datetime
|
|
return parsedate_to_datetime(date_str)
|
|
except:
|
|
pass
|
|
|
|
# Fallback: Versuche verschiedene Formate
|
|
formats = [
|
|
"%a, %d %b %Y %H:%M:%S %z",
|
|
"%a, %d %b %Y %H:%M:%S %z (%Z)",
|
|
"%d %b %Y %H:%M:%S %z",
|
|
"%Y-%m-%d %H:%M:%S",
|
|
]
|
|
|
|
for fmt in formats:
|
|
try:
|
|
return datetime.strptime(date_str.strip(), fmt)
|
|
except:
|
|
continue
|
|
|
|
return None
|
|
|
|
|
|
@router.get("/search")
|
|
async def search_emails(db: Session = Depends(get_db)):
|
|
"""Sucht nach Rechnungs-Emails bei Strato"""
|
|
results = {
|
|
"processed": 0,
|
|
"invoices_found": 0,
|
|
"errors": []
|
|
}
|
|
|
|
try:
|
|
mail = connect_imap(IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASSWORD)
|
|
|
|
# Alle Ordner durchsuchen
|
|
for folder in ["INBOX", "Sent", "Gesendet"]:
|
|
try:
|
|
email_ids = search_invoices(mail, folder)
|
|
results[f"{folder}_count"] = len(email_ids)
|
|
|
|
for email_id in email_ids:
|
|
# Verarbeitung auslagern
|
|
pass
|
|
|
|
except Exception as e:
|
|
results["errors"].append(f"Folder {folder}: {str(e)}")
|
|
|
|
mail.logout()
|
|
results["processed"] = len(email_ids) if 'email_ids' in dir() else 0
|
|
|
|
except Exception as e:
|
|
results["errors"].append(f"IMAP Connection failed: {str(e)}")
|
|
|
|
return results
|
|
|
|
|
|
@router.get("/process/{email_id}")
|
|
async def process_email(email_id: str, db: Session = Depends(get_db)):
|
|
"""Verarbeitet einzelne Email"""
|
|
# TODO: Implementierung
|
|
pass
|
|
|
|
|
|
def extract_and_process_attachments(msg: email.message.Message, email_id: str, db: Session) -> List[Dict[str, Any]]:
|
|
"""Extrahiert und verarbeitet PDF-Anhänge aus Email"""
|
|
processed = []
|
|
|
|
for part in msg.walk():
|
|
if part.get_content_maintype() == 'multipart':
|
|
continue
|
|
if part.get('Content-Disposition') is None:
|
|
continue
|
|
|
|
filename = part.get_filename()
|
|
if not filename:
|
|
continue
|
|
|
|
decoded_filename, encoding = decode_header(filename)[0]
|
|
if isinstance(decoded_filename, bytes):
|
|
filename = decoded_filename.decode(encoding or "utf-8")
|
|
|
|
content_type = part.get_content_type()
|
|
|
|
# Nur PDFs verarbeiten
|
|
if content_type != 'application/pdf' and not filename.lower().endswith('.pdf'):
|
|
continue
|
|
|
|
payload = part.get_payload(decode=True)
|
|
if not payload:
|
|
continue
|
|
|
|
# Temporäre Datei erstellen
|
|
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
|
|
tmp.write(payload)
|
|
tmp_path = tmp.name
|
|
|
|
try:
|
|
# PDF verarbeiten
|
|
from backend.routers.invoices import extract_pdf_data, get_storage_path
|
|
|
|
pdf_data = extract_pdf_data(tmp_path)
|
|
|
|
if "error" not in pdf_data and pdf_data.get("amount"):
|
|
# Rechnung speichern
|
|
tax_type = pdf_data.get("tax_info", {}).get("tax_type", "unbekannt")
|
|
tax_rate = pdf_data.get("tax_info", {}).get("tax_rate", 0)
|
|
|
|
# Speicherpfad erstellen
|
|
storage_year = 2026 # Default, könnte aus Rechnungsdatum extrahiert werden
|
|
storage_path = get_storage_path(storage_year, tax_type)
|
|
|
|
# PDF in Zielverzeichnis kopieren (optional)
|
|
if INVOICE_STORAGE_PATH:
|
|
os.makedirs(storage_path, exist_ok=True)
|
|
|
|
# Dateiname generieren
|
|
invoice_number = pdf_data.get("invoice_number") or "unbekannt"
|
|
file_name = f"Rechnung_{invoice_number}_{email_id}.pdf"
|
|
dest_path = os.path.join(storage_path, file_name)
|
|
|
|
import shutil
|
|
shutil.copy(tmp_path, dest_path)
|
|
|
|
# In DB speichern
|
|
invoice = Invoice(
|
|
email_id=email_id,
|
|
file_path=dest_path,
|
|
file_name=file_name,
|
|
storage_year=storage_year,
|
|
storage_path=storage_path.replace(INVOICE_STORAGE_PATH, ""),
|
|
invoice_number=invoice_number,
|
|
invoice_amount=pdf_data.get("amount"),
|
|
issuer_name=pdf_data.get("issuer"),
|
|
invoice_tax_type=tax_type,
|
|
invoice_tax_rate=tax_rate,
|
|
processed=True,
|
|
confidence_score=pdf_data.get("tax_info", {}).get("confidence", 0)
|
|
)
|
|
db.add(invoice)
|
|
db.commit()
|
|
|
|
processed.append({
|
|
"filename": filename,
|
|
"invoice_number": invoice_number,
|
|
"amount": pdf_data.get("amount"),
|
|
"tax_type": tax_type,
|
|
"saved_to": dest_path
|
|
})
|
|
|
|
finally:
|
|
# Temporäre Datei löschen
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except:
|
|
pass
|
|
|
|
return processed
|
|
|
|
|
|
@router.get("/scan-all")
|
|
async def scan_all_emails(db: Session = Depends(get_db)):
|
|
"""Scant alle Emails, extrahiert PDFs und verarbeitet Rechnungen"""
|
|
results = {
|
|
"start_time": datetime.utcnow().isoformat(),
|
|
"processed": 0,
|
|
"invoices_found": 0,
|
|
"pdfs_processed": 0,
|
|
"errors": []
|
|
}
|
|
|
|
# Strato Mail
|
|
try:
|
|
mail = connect_imap(IMAP_HOST, IMAP_PORT, IMAP_USER, IMAP_PASSWORD)
|
|
mail.select("INBOX")
|
|
|
|
result, data = mail.search(None, 'ALL')
|
|
if result == "OK":
|
|
email_ids = data[0].split()
|
|
|
|
for email_id in email_ids:
|
|
email_id_str = email_id.decode()
|
|
|
|
# Prüfen ob bereits verarbeitet
|
|
log_entry = db.query(EmailLog).filter(EmailLog.email_id == email_id_str).first()
|
|
if log_entry and log_entry.processed:
|
|
continue
|
|
|
|
result, msg_data = mail.fetch(email_id, '(RFC822)')
|
|
if result != "OK":
|
|
continue
|
|
|
|
msg = email.message_from_bytes(msg_data[0][1])
|
|
subject = msg.get("Subject", "")
|
|
from_addr = msg.get("From", "")
|
|
date_str = msg.get("Date", "")
|
|
|
|
# Datum parsen
|
|
email_date = parse_email_date(date_str)
|
|
|
|
# Subject decodieren
|
|
decoded_subject, encoding = decode_header(subject)[0]
|
|
if isinstance(decoded_subject, bytes):
|
|
subject = decoded_subject.decode(encoding or "utf-8")
|
|
|
|
# Prüfen auf Keywords
|
|
subject_lower = subject.lower()
|
|
matched_keywords = [kw for kw in INVOICE_KEYWORDS if kw in subject_lower]
|
|
|
|
if matched_keywords:
|
|
# Email als Rechnung markieren (auch wenn keine PDF dabei ist)
|
|
log_entry = EmailLog(
|
|
email_id=email_id_str,
|
|
email_subject=subject,
|
|
email_from=from_addr,
|
|
email_date=email_date,
|
|
email_folder="INBOX",
|
|
matched_keywords=matched_keywords,
|
|
has_attachment=True,
|
|
processed=True
|
|
)
|
|
db.add(log_entry)
|
|
results["invoices_found"] += 1
|
|
|
|
# PDFs extrahieren und verarbeiten
|
|
pdfs = extract_and_process_attachments(msg, email_id_str, db)
|
|
results["pdfs_processed"] += len(pdfs)
|
|
|
|
# Log aktualisieren mit Ergebnis
|
|
if pdfs and log_entry:
|
|
log_entry.processing_result = f"PDFs processed: {len(pdfs)}"
|
|
log_entry.processed = True
|
|
db.commit()
|
|
|
|
results["processed"] += 1
|
|
|
|
db.commit()
|
|
|
|
mail.logout()
|
|
|
|
except Exception as e:
|
|
results["errors"].append(f"Strato scan failed: {str(e)}")
|
|
import traceback
|
|
results["errors"].append(traceback.format_exc())
|
|
|
|
# Gmail Mail (optional)
|
|
if GMAIL_IMAP_USER and GMAIL_IMAP_PASSWORD:
|
|
try:
|
|
mail = connect_imap(GMAIL_IMAP_HOST, GMAIL_IMAP_PORT, GMAIL_IMAP_USER, GMAIL_IMAP_PASSWORD)
|
|
mail.select("INBOX")
|
|
|
|
result, data = mail.search(None, 'ALL')
|
|
if result == "OK":
|
|
email_ids = data[0].split()
|
|
|
|
for email_id in email_ids:
|
|
email_id_str = email_id.decode()
|
|
|
|
log_entry = db.query(EmailLog).filter(EmailLog.email_id == email_id_str).first()
|
|
if log_entry and log_entry.processed:
|
|
continue
|
|
|
|
result, msg_data = mail.fetch(email_id, '(RFC822)')
|
|
if result != "OK":
|
|
continue
|
|
|
|
msg = email.message_from_bytes(msg_data[0][1])
|
|
subject = msg.get("Subject", "")
|
|
from_addr = msg.get("From", "")
|
|
date_str = msg.get("Date", "")
|
|
|
|
email_date = parse_email_date(date_str)
|
|
|
|
decoded_subject, encoding = decode_header(subject)[0]
|
|
if isinstance(decoded_subject, bytes):
|
|
subject = decoded_subject.decode(encoding or "utf-8")
|
|
|
|
subject_lower = subject.lower()
|
|
matched_keywords = [kw for kw in INVOICE_KEYWORDS if kw in subject_lower]
|
|
|
|
if matched_keywords:
|
|
log_entry = EmailLog(
|
|
email_id=email_id_str,
|
|
email_subject=subject,
|
|
email_from=from_addr,
|
|
email_date=email_date,
|
|
email_folder="INBOX",
|
|
matched_keywords=matched_keywords,
|
|
has_attachment=True,
|
|
processed=True
|
|
)
|
|
db.add(log_entry)
|
|
results["invoices_found"] += 1
|
|
|
|
pdfs = extract_and_process_attachments(msg, email_id_str, db)
|
|
results["pdfs_processed"] += len(pdfs)
|
|
|
|
if pdfs:
|
|
log_entry.processing_result = f"PDFs processed: {len(pdfs)}"
|
|
log_entry.processed = True
|
|
db.commit()
|
|
|
|
results["processed"] += 1
|
|
|
|
db.commit()
|
|
|
|
mail.logout()
|
|
|
|
except Exception as e:
|
|
results["errors"].append(f"Gmail scan failed: {str(e)}")
|
|
import traceback
|
|
results["errors"].append(traceback.format_exc())
|
|
|
|
results["end_time"] = datetime.utcnow().isoformat()
|
|
return results
|