fix: PDF extraction und Speicherung korrigiert

- 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>
This commit is contained in:
Markus Kruse 2026-07-25 00:20:39 +02:00
parent 937267b507
commit 97b5a7ec45
2 changed files with 175 additions and 12 deletions

View file

@ -6,6 +6,7 @@ IMAP Email-Verarbeitung und Filterung
import imaplib import imaplib
import email import email
import os import os
import tempfile
from datetime import datetime from datetime import datetime
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@ -32,6 +33,9 @@ GMAIL_IMAP_PASSWORD = os.getenv("GMAIL_IMAP_PASSWORD", "")
# Filter-Kriterien # Filter-Kriterien
INVOICE_KEYWORDS = ["rechnung", "invoice", "receipt", "bill", "factuur"] 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: def connect_imap(host: str, port: int, user: str, password: str) -> imaplib.IMAP4_SSL:
"""Verbindet mit IMAP-Server""" """Verbindet mit IMAP-Server"""
@ -100,6 +104,36 @@ def extract_attachments(msg: email.message.Message) -> List[Dict[str, Any]]:
return attachments 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") @router.get("/search")
async def search_emails(db: Session = Depends(get_db)): async def search_emails(db: Session = Depends(get_db)):
"""Sucht nach Rechnungs-Emails bei Strato""" """Sucht nach Rechnungs-Emails bei Strato"""
@ -141,13 +175,110 @@ async def process_email(email_id: str, db: Session = Depends(get_db)):
pass 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") @router.get("/scan-all")
async def scan_all_emails(db: Session = Depends(get_db)): async def scan_all_emails(db: Session = Depends(get_db)):
"""Scant alle Emails auf Rechnungen""" """Scant alle Emails, extrahiert PDFs und verarbeitet Rechnungen"""
results = { results = {
"start_time": datetime.utcnow().isoformat(), "start_time": datetime.utcnow().isoformat(),
"processed": 0, "processed": 0,
"invoices_found": 0, "invoices_found": 0,
"pdfs_processed": 0,
"errors": [] "errors": []
} }
@ -162,10 +293,11 @@ async def scan_all_emails(db: Session = Depends(get_db)):
for email_id in email_ids: for email_id in email_ids:
email_id_str = email_id.decode() email_id_str = email_id.decode()
log_entry = db.query(EmailLog).filter(EmailLog.email_id == email_id_str).first()
if log_entry: # Prüfen ob bereits verarbeitet
continue # 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)') result, msg_data = mail.fetch(email_id, '(RFC822)')
if result != "OK": if result != "OK":
@ -174,7 +306,10 @@ async def scan_all_emails(db: Session = Depends(get_db)):
msg = email.message_from_bytes(msg_data[0][1]) msg = email.message_from_bytes(msg_data[0][1])
subject = msg.get("Subject", "") subject = msg.get("Subject", "")
from_addr = msg.get("From", "") from_addr = msg.get("From", "")
date = msg.get("Date", "") date_str = msg.get("Date", "")
# Datum parsen
email_date = parse_email_date(date_str)
# Subject decodieren # Subject decodieren
decoded_subject, encoding = decode_header(subject)[0] decoded_subject, encoding = decode_header(subject)[0]
@ -186,20 +321,30 @@ async def scan_all_emails(db: Session = Depends(get_db)):
matched_keywords = [kw for kw in INVOICE_KEYWORDS if kw in subject_lower] matched_keywords = [kw for kw in INVOICE_KEYWORDS if kw in subject_lower]
if matched_keywords: if matched_keywords:
# Email als Rechnung markieren # Email als Rechnung markieren (auch wenn keine PDF dabei ist)
log_entry = EmailLog( log_entry = EmailLog(
email_id=email_id_str, email_id=email_id_str,
email_subject=subject, email_subject=subject,
email_from=from_addr, email_from=from_addr,
email_date=date, email_date=email_date,
email_folder="INBOX", email_folder="INBOX",
matched_keywords=matched_keywords, matched_keywords=matched_keywords,
has_attachment=True, has_attachment=True,
processed=False processed=True
) )
db.add(log_entry) db.add(log_entry)
results["invoices_found"] += 1 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 results["processed"] += 1
db.commit() db.commit()
@ -208,6 +353,8 @@ async def scan_all_emails(db: Session = Depends(get_db)):
except Exception as e: except Exception as e:
results["errors"].append(f"Strato scan failed: {str(e)}") results["errors"].append(f"Strato scan failed: {str(e)}")
import traceback
results["errors"].append(traceback.format_exc())
# Gmail Mail (optional) # Gmail Mail (optional)
if GMAIL_IMAP_USER and GMAIL_IMAP_PASSWORD: if GMAIL_IMAP_USER and GMAIL_IMAP_PASSWORD:
@ -221,9 +368,9 @@ async def scan_all_emails(db: Session = Depends(get_db)):
for email_id in email_ids: for email_id in email_ids:
email_id_str = email_id.decode() email_id_str = email_id.decode()
log_entry = db.query(EmailLog).filter(EmailLog.email_id == email_id_str).first()
if log_entry: log_entry = db.query(EmailLog).filter(EmailLog.email_id == email_id_str).first()
if log_entry and log_entry.processed:
continue continue
result, msg_data = mail.fetch(email_id, '(RFC822)') result, msg_data = mail.fetch(email_id, '(RFC822)')
@ -233,6 +380,9 @@ async def scan_all_emails(db: Session = Depends(get_db)):
msg = email.message_from_bytes(msg_data[0][1]) msg = email.message_from_bytes(msg_data[0][1])
subject = msg.get("Subject", "") subject = msg.get("Subject", "")
from_addr = msg.get("From", "") from_addr = msg.get("From", "")
date_str = msg.get("Date", "")
email_date = parse_email_date(date_str)
decoded_subject, encoding = decode_header(subject)[0] decoded_subject, encoding = decode_header(subject)[0]
if isinstance(decoded_subject, bytes): if isinstance(decoded_subject, bytes):
@ -246,15 +396,23 @@ async def scan_all_emails(db: Session = Depends(get_db)):
email_id=email_id_str, email_id=email_id_str,
email_subject=subject, email_subject=subject,
email_from=from_addr, email_from=from_addr,
email_date=datetime.utcnow().isoformat(), email_date=email_date,
email_folder="INBOX", email_folder="INBOX",
matched_keywords=matched_keywords, matched_keywords=matched_keywords,
has_attachment=True, has_attachment=True,
processed=False processed=True
) )
db.add(log_entry) db.add(log_entry)
results["invoices_found"] += 1 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 results["processed"] += 1
db.commit() db.commit()
@ -263,6 +421,8 @@ async def scan_all_emails(db: Session = Depends(get_db)):
except Exception as e: except Exception as e:
results["errors"].append(f"Gmail scan failed: {str(e)}") results["errors"].append(f"Gmail scan failed: {str(e)}")
import traceback
results["errors"].append(traceback.format_exc())
results["end_time"] = datetime.utcnow().isoformat() results["end_time"] = datetime.utcnow().isoformat()
return results return results

View file

@ -18,6 +18,7 @@ services:
- SMTP_PORT=587 - SMTP_PORT=587
- SMTP_USER=${SMTP_USER} - SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_PASSWORD=${SMTP_PASSWORD}
- INVOICE_STORAGE_PATH=/tmp/invoice-storage
depends_on: depends_on:
- db - db
networks: networks:
@ -25,6 +26,7 @@ services:
volumes: volumes:
- ./backend/tests:/app/backend/tests - ./backend/tests:/app/backend/tests
- /tmp/test-invoices:/tmp/test-invoices - /tmp/test-invoices:/tmp/test-invoices
- invoice-storage:/tmp/invoice-storage
db: db:
image: postgres:16-alpine image: postgres:16-alpine
@ -45,3 +47,4 @@ networks:
volumes: volumes:
postgres-data: postgres-data:
invoice-storage: