added amazon importer and logging smtp

This commit is contained in:
duffyduck 2026-03-20 16:22:38 +01:00
parent 9fdada5dbe
commit a4e39332c7
16 changed files with 2619 additions and 255 deletions

View File

@ -1,15 +1,39 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends libzbar0 && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
libzbar0 \
# Chromium dependencies for Playwright
libglib2.0-0t64 \
libnss3 \
libnspr4 \
libatk1.0-0t64 \
libatk-bridge2.0-0t64 \
libcups2t64 \
libdrm2 \
libexpat1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libxkbcommon0 \
libpango-1.0-0 \
libcairo2 \
libasound2t64 \
libatspi2.0-0t64 \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install chromium
COPY app/ ./app/
RUN mkdir -p /data/uploads
RUN mkdir -p /data/uploads /data/amazon_session
EXPOSE 8000

1384
app/amazon_processor.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,13 +4,13 @@ import aiosqlite
from cryptography.fernet import Fernet
DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db")
SCHEMA_VERSION = 2
SCHEMA_VERSION = 8
logger = logging.getLogger(__name__)
_fernet = None
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password"}
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password", "amazon_password"}
DEFAULT_SETTINGS = {
"imap_server": "",
@ -24,8 +24,12 @@ DEFAULT_SETTINGS = {
"smtp_username": "",
"smtp_password": "",
"import_email": "",
"import_email_eingang": "",
"import_email_ausgang": "",
"source_folder": "Rechnungen",
"processed_folder": "Rechnungen/Verarbeitet",
"source_folder_ausgang": "",
"processed_folder_ausgang": "",
"interval_minutes": "5",
"scheduler_enabled": "false",
"fetch_since_date": "",
@ -39,7 +43,18 @@ DEFAULT_SETTINGS = {
"smb_share": "",
"smb_source_path": "",
"smb_processed_path": "Verarbeitet",
"smb_source_path_ausgang": "",
"smb_processed_path_ausgang": "",
"smb_mode": "forward",
# Amazon
"amazon_enabled": "false",
"amazon_email": "",
"amazon_password": "",
"amazon_domain": "amazon.de",
"amazon_last_sync": "",
"amazon_since_date": "",
# Debug
"debug_save_amazon_pdfs": "false",
}
@ -121,7 +136,10 @@ async def _run_migrations(db: aiosqlite.Connection, current_version: int):
email_from TEXT,
attachments_count INTEGER DEFAULT 0,
status TEXT NOT NULL,
error_message TEXT
error_message TEXT,
sent_to TEXT DEFAULT '',
smtp_log TEXT DEFAULT '',
beleg_type TEXT DEFAULT 'eingang'
)
""")
await db.commit()
@ -150,10 +168,72 @@ async def _run_migrations(db: aiosqlite.Connection, current_version: int):
await db.commit()
await _set_schema_version(db, 2)
# --- Future migrations go here ---
# if current_version < 3:
# logger.info("Migration v3: ...")
# await _set_schema_version(db, 3)
if current_version < 3:
logger.info("Migration v3: Amazon-Plattform hinzugefügt")
await db.execute("""
CREATE TABLE IF NOT EXISTS amazon_downloaded (
order_id TEXT PRIMARY KEY,
downloaded_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
)
""")
await db.commit()
await _set_schema_version(db, 3)
if current_version < 4:
logger.info("Migration v4: sent_to Spalte im Verarbeitungslog")
await db.execute("""
ALTER TABLE processing_log ADD COLUMN sent_to TEXT DEFAULT ''
""")
await db.commit()
await _set_schema_version(db, 4)
if current_version < 5:
logger.info("Migration v5: SMTP-Protokoll im Verarbeitungslog")
await db.execute("""
ALTER TABLE processing_log ADD COLUMN smtp_log TEXT DEFAULT ''
""")
await db.commit()
await _set_schema_version(db, 5)
if current_version < 6:
logger.info("Migration v6: Per-Invoice Tracking statt per-Order")
try:
await db.execute("""
ALTER TABLE amazon_downloaded ADD COLUMN invoice_url TEXT DEFAULT ''
""")
except Exception:
pass # column already exists
await db.commit()
await _set_schema_version(db, 6)
if current_version < 8:
logger.info("Migration v7/8: Eingangs-/Ausgangsbelege Unterscheidung")
# Add beleg_type column to processing_log (check if it exists first)
cursor = await db.execute("PRAGMA table_info(processing_log)")
columns = [row[1] for row in await cursor.fetchall()]
if "beleg_type" not in columns:
await db.execute("""
ALTER TABLE processing_log ADD COLUMN beleg_type TEXT DEFAULT 'eingang'
""")
logger.info(" beleg_type Spalte hinzugefügt")
# Migrate import_email -> import_email_eingang
cursor = await db.execute(
"SELECT value FROM settings WHERE key = 'import_email'"
)
row = await cursor.fetchone()
if row and row[0]:
cursor2 = await db.execute(
"SELECT value FROM settings WHERE key = 'import_email_eingang'"
)
row2 = await cursor2.fetchone()
if not row2 or not row2[0]:
await db.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('import_email_eingang', ?)",
(row[0],),
)
logger.info(" import_email nach import_email_eingang übertragen")
await db.commit()
await _set_schema_version(db, 8)
await db.commit()
@ -176,7 +256,18 @@ async def init_db():
email_from TEXT,
attachments_count INTEGER DEFAULT 0,
status TEXT NOT NULL,
error_message TEXT
error_message TEXT,
sent_to TEXT DEFAULT '',
smtp_log TEXT DEFAULT '',
beleg_type TEXT DEFAULT 'eingang'
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS amazon_downloaded (
order_id TEXT NOT NULL,
downloaded_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
invoice_url TEXT DEFAULT '',
PRIMARY KEY (order_id, invoice_url)
)
""")
await db.commit()
@ -235,19 +326,29 @@ async def save_settings(data: dict):
await db.commit()
def get_import_email(settings: dict, beleg_type: str = "eingang") -> str:
"""Resolve the correct import email address based on document type."""
if beleg_type == "ausgang":
return settings.get("import_email_ausgang", "")
return settings.get("import_email_eingang", "") or settings.get("import_email", "")
async def add_log_entry(
email_subject: str,
email_from: str,
attachments_count: int,
status: str,
error_message: str = "",
sent_to: str = "",
smtp_log: str = "",
beleg_type: str = "eingang",
):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"""INSERT INTO processing_log
(email_subject, email_from, attachments_count, status, error_message)
VALUES (?, ?, ?, ?, ?)""",
(email_subject, email_from, attachments_count, status, error_message),
(email_subject, email_from, attachments_count, status, error_message, sent_to, smtp_log, beleg_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(email_subject, email_from, attachments_count, status, error_message, sent_to, smtp_log, beleg_type),
)
await db.commit()
@ -260,3 +361,46 @@ async def get_log_entries(limit: int = 100) -> list[dict]:
)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def clear_log_entries() -> int:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT COUNT(*) FROM processing_log")
count = (await cursor.fetchone())[0]
await db.execute("DELETE FROM processing_log")
await db.commit()
return count
async def is_invoice_downloaded(order_id: str, invoice_url: str = "") -> bool:
"""Check if a specific invoice has been downloaded.
If invoice_url is given, check per-URL. Otherwise check per order_id."""
async with aiosqlite.connect(DB_PATH) as db:
if invoice_url:
cursor = await db.execute(
"SELECT 1 FROM amazon_downloaded WHERE order_id = ? AND invoice_url = ?",
(order_id, invoice_url),
)
else:
cursor = await db.execute(
"SELECT 1 FROM amazon_downloaded WHERE order_id = ?", (order_id,)
)
return await cursor.fetchone() is not None
async def mark_invoice_downloaded(order_id: str, invoice_url: str = ""):
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"INSERT OR IGNORE INTO amazon_downloaded (order_id, invoice_url) VALUES (?, ?)",
(order_id, invoice_url),
)
await db.commit()
async def reset_downloaded_invoices() -> int:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("SELECT COUNT(*) FROM amazon_downloaded")
count = (await cursor.fetchone())[0]
await db.execute("DELETE FROM amazon_downloaded")
await db.commit()
return count

View File

@ -9,11 +9,30 @@ from email.mime.text import MIMEText
from email import encoders
import logging
from app.database import get_settings, add_log_entry
from app.database import get_settings, add_log_entry, get_import_email
logger = logging.getLogger(__name__)
def _send_with_log(smtp_conn: smtplib.SMTP, msg) -> str:
"""Send email and capture SMTP protocol exchange."""
log_lines = []
original_print_debug = smtp_conn._print_debug
def _capture(*args):
log_lines.append(" ".join(str(a) for a in args))
smtp_conn._print_debug = _capture
old_level = smtp_conn.debuglevel
smtp_conn.set_debuglevel(1)
try:
smtp_conn.send_message(msg)
finally:
smtp_conn.set_debuglevel(old_level)
smtp_conn._print_debug = original_print_debug
return "\n".join(log_lines)
def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
server = settings["imap_server"]
port = int(settings.get("imap_port", 993))
@ -118,21 +137,117 @@ def _move_email(conn: imaplib.IMAP4, msg_uid: bytes, dest_folder: str):
conn.expunge()
async def process_mailbox() -> dict:
settings = await get_settings()
if not settings.get("imap_server") or not settings.get("import_email"):
logger.warning("IMAP oder Import-Email nicht konfiguriert")
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"}
source_folder = settings.get("source_folder", "INBOX")
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
import_email = settings["import_email"]
async def _process_folder(
imap_conn, smtp_conn, settings: dict,
source_folder: str, processed_folder: str,
import_email: str, beleg_type: str, fetch_since: str,
) -> dict:
"""Process one IMAP folder pair. Returns counts dict."""
smtp_from = settings.get("smtp_username", "")
processed = 0
skipped = 0
errors = 0
_ensure_folder_exists(imap_conn, processed_folder)
status, _ = imap_conn.select(f'"{source_folder}"')
if status != "OK":
logger.warning(f"Ordner '{source_folder}' konnte nicht geöffnet werden")
return {"processed": 0, "skipped": 0, "errors": 0}
search_criteria = "ALL"
if fetch_since:
try:
from datetime import datetime
dt = datetime.strptime(fetch_since, "%Y-%m-%d")
imap_date = dt.strftime("%d-%b-%Y")
search_criteria = f'(SINCE {imap_date})'
except ValueError:
logger.warning(f"Ungültiges Datum: {fetch_since}, verwende ALL")
status, data = imap_conn.uid("SEARCH", None, search_criteria)
if status != "OK" or not data[0]:
logger.info(f"Keine Emails im Ordner '{source_folder}' ({beleg_type})")
return {"processed": 0, "skipped": 0, "errors": 0}
msg_uids = data[0].split()
logger.info(f"{len(msg_uids)} Email(s) im Ordner '{source_folder}' ({beleg_type})")
for msg_uid in msg_uids:
subject = "?"
from_addr = "?"
try:
status, msg_data = imap_conn.uid("FETCH", msg_uid, "(RFC822)")
if status != "OK":
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email, policy=policy.default)
subject = str(msg.get("Subject", "(Kein Betreff)"))
from_addr = str(msg.get("From", "(Unbekannt)"))
attachments = _extract_attachments(msg)
if not attachments:
skipped += 1
logger.debug(f"Übersprungen (keine Anhänge): {subject}")
continue
forward_msg = _build_forward_email(
from_addr=smtp_from,
to_addr=import_email,
original_subject=subject,
original_from=from_addr,
attachments=attachments,
)
smtp_log = _send_with_log(smtp_conn, forward_msg)
imap_conn.select(f'"{source_folder}"')
_move_email(imap_conn, msg_uid, processed_folder)
imap_conn.select(f'"{source_folder}"')
processed += 1
logger.info(f"Verarbeitet ({beleg_type}): {subject} ({len(attachments)} Anhänge)")
await add_log_entry(
email_subject=subject,
email_from=from_addr,
attachments_count=len(attachments),
status="success",
sent_to=import_email,
smtp_log=smtp_log,
beleg_type=beleg_type,
)
except Exception as e:
errors += 1
logger.error(f"Fehler bei Email UID {msg_uid}: {e}")
try:
await add_log_entry(
email_subject=subject,
email_from=from_addr,
attachments_count=0,
status="error",
error_message=str(e),
beleg_type=beleg_type,
)
except Exception:
pass
return {"processed": processed, "skipped": skipped, "errors": errors}
async def process_mailbox() -> dict:
settings = await get_settings()
import_email_eingang = get_import_email(settings, "eingang")
if not settings.get("imap_server") or not import_email_eingang:
logger.warning("IMAP oder Import-Email nicht konfiguriert")
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"}
fetch_since = settings.get("fetch_since_date", "")
total = {"processed": 0, "skipped": 0, "errors": 0}
imap_conn = None
smtp_conn = None
@ -140,92 +255,31 @@ async def process_mailbox() -> dict:
imap_conn = _connect_imap(settings)
smtp_conn = _connect_smtp(settings)
_ensure_folder_exists(imap_conn, processed_folder)
# Eingangsbelege
source = settings.get("source_folder", "INBOX")
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
result = await _process_folder(
imap_conn, smtp_conn, settings,
source, processed_folder,
import_email_eingang, "eingang", fetch_since,
)
for k in total:
total[k] += result[k]
status, _ = imap_conn.select(f'"{source_folder}"')
if status != "OK":
raise Exception(f"Ordner '{source_folder}' konnte nicht geöffnet werden")
# Build IMAP search criteria
search_criteria = "ALL"
fetch_since = settings.get("fetch_since_date", "")
if fetch_since:
try:
from datetime import datetime
dt = datetime.strptime(fetch_since, "%Y-%m-%d")
imap_date = dt.strftime("%d-%b-%Y")
search_criteria = f'(SINCE {imap_date})'
except ValueError:
logger.warning(f"Ungültiges Datum: {fetch_since}, verwende ALL")
status, data = imap_conn.uid("SEARCH", None, search_criteria)
if status != "OK" or not data[0]:
logger.info("Keine Emails im Ordner gefunden")
return {"processed": 0, "skipped": 0, "errors": 0}
msg_uids = data[0].split()
logger.info(f"{len(msg_uids)} Email(s) im Ordner '{source_folder}' gefunden")
for msg_uid in msg_uids:
try:
status, msg_data = imap_conn.uid("FETCH", msg_uid, "(RFC822)")
if status != "OK":
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email, policy=policy.default)
subject = str(msg.get("Subject", "(Kein Betreff)"))
from_addr = str(msg.get("From", "(Unbekannt)"))
attachments = _extract_attachments(msg)
if not attachments:
skipped += 1
logger.debug(f"Übersprungen (keine Anhänge): {subject}")
continue
forward_msg = _build_forward_email(
from_addr=smtp_from,
to_addr=import_email,
original_subject=subject,
original_from=from_addr,
attachments=attachments,
)
smtp_conn.send_message(forward_msg)
# Re-select source folder before move (in case _ensure_folder changed it)
imap_conn.select(f'"{source_folder}"')
_move_email(imap_conn, msg_uid, processed_folder)
# Re-select after expunge to keep UIDs valid
imap_conn.select(f'"{source_folder}"')
processed += 1
logger.info(
f"Verarbeitet: {subject} ({len(attachments)} Anhänge)"
)
await add_log_entry(
email_subject=subject,
email_from=from_addr,
attachments_count=len(attachments),
status="success",
)
except Exception as e:
errors += 1
logger.error(f"Fehler bei Email UID {msg_uid}: {e}")
try:
await add_log_entry(
email_subject=subject if "subject" in dir() else "?",
email_from=from_addr if "from_addr" in dir() else "?",
attachments_count=0,
status="error",
error_message=str(e),
)
except Exception:
pass
# Ausgangsbelege (optional)
import_email_ausgang = get_import_email(settings, "ausgang")
source_ausgang = settings.get("source_folder_ausgang", "")
processed_ausgang = settings.get("processed_folder_ausgang", "")
if import_email_ausgang and source_ausgang:
if not processed_ausgang:
processed_ausgang = source_ausgang + "/Verarbeitet"
result = await _process_folder(
imap_conn, smtp_conn, settings,
source_ausgang, processed_ausgang,
import_email_ausgang, "ausgang", fetch_since,
)
for k in total:
total[k] += result[k]
except Exception as e:
logger.error(f"Verbindungsfehler: {e}")
@ -236,7 +290,7 @@ async def process_mailbox() -> dict:
status="error",
error_message=f"Verbindungsfehler: {e}",
)
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
return {**total, "errors": total["errors"] + 1, "error": str(e)}
finally:
if imap_conn:
@ -250,36 +304,52 @@ async def process_mailbox() -> dict:
except Exception:
pass
logger.info(
f"Fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler"
)
return {"processed": processed, "skipped": skipped, "errors": errors}
logger.info(f"Fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
return total
async def send_test_email() -> dict:
settings = await get_settings()
if not settings.get("smtp_server") or not settings.get("import_email"):
return {"success": False, "error": "SMTP oder Import-Email nicht konfiguriert"}
import_email_eingang = get_import_email(settings, "eingang")
import_email_ausgang = get_import_email(settings, "ausgang")
if not settings.get("smtp_server") or not import_email_eingang:
return {"success": False, "error": "SMTP oder Import-Email (Eingang) nicht konfiguriert"}
try:
smtp_conn = _connect_smtp(settings)
smtp_logs = []
# Test Eingangsbelege
msg = MIMEMultipart()
msg["From"] = settings["smtp_username"]
msg["To"] = settings["import_email"]
msg["Subject"] = "Belegimport - Test-Email"
msg["To"] = import_email_eingang
msg["Subject"] = "Belegimport - Test-Email (Eingangsbelege)"
msg.attach(MIMEText(
"Dies ist eine Test-Email vom Belegimport Service.\n"
"Wenn Sie diese Email erhalten, funktioniert die SMTP-Verbindung.",
"plain",
"utf-8",
"Ziel: Eingangsbelege",
"plain", "utf-8",
))
smtp_logs.append("=== Eingangsbelege ===")
smtp_logs.append(_send_with_log(smtp_conn, msg))
# Test Ausgangsbelege (if configured)
if import_email_ausgang:
msg2 = MIMEMultipart()
msg2["From"] = settings["smtp_username"]
msg2["To"] = import_email_ausgang
msg2["Subject"] = "Belegimport - Test-Email (Ausgangsbelege)"
msg2.attach(MIMEText(
"Dies ist eine Test-Email vom Belegimport Service.\n"
"Ziel: Ausgangsbelege",
"plain", "utf-8",
))
smtp_logs.append("=== Ausgangsbelege ===")
smtp_logs.append(_send_with_log(smtp_conn, msg2))
smtp_conn.send_message(msg)
smtp_conn.quit()
return {"success": True}
return {"success": True, "smtp_log": "\n".join(smtp_logs)}
except Exception as e:
logger.error(f"Test-Email fehlgeschlagen: {e}")

View File

@ -6,19 +6,34 @@ from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, Form, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sse_starlette.sse import EventSourceResponse
from app.database import init_db, get_settings, save_settings, get_log_entries
from app.database import init_db, get_settings, save_settings, get_log_entries, clear_log_entries, reset_downloaded_invoices
from app.mail_processor import process_mailbox, send_test_email, test_imap_connection, create_imap_folder
from app.scheduler import start_scheduler, configure_job, get_scheduler_status
from app.scanner import process_scanned_pdf, generate_separator_pdf, UPLOAD_DIR
from app.smb_processor import process_smb_share, test_smb_connection, create_smb_folder, list_smb_folders
from app.amazon_processor import (
start_login as amazon_start_login,
submit_otp as amazon_submit_otp,
get_login_state as amazon_get_login_state,
check_session_valid as amazon_check_session,
clear_session as amazon_clear_session,
process_amazon,
start_interactive_login as amazon_start_interactive,
get_browser_screenshot as amazon_get_screenshot,
send_browser_click as amazon_browser_click,
send_browser_type as amazon_browser_type,
send_browser_key as amazon_browser_key,
close_interactive_login as amazon_close_interactive,
is_interactive_login_active as amazon_login_active,
)
logging.basicConfig(
level=logging.INFO,
level=getattr(logging, os.environ.get("LOG_LEVEL", "INFO").upper(), logging.INFO),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
@ -44,6 +59,11 @@ templates = Jinja2Templates(directory="app/templates")
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("scan.html", {"request": request})
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
settings = await get_settings()
logs = await get_log_entries(limit=20)
status = get_scheduler_status()
@ -74,8 +94,12 @@ async def _save_form_settings(request: Request) -> dict:
"smtp_username": form.get("smtp_username", ""),
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
"import_email": form.get("import_email", ""),
"import_email_eingang": form.get("import_email_eingang", ""),
"import_email_ausgang": form.get("import_email_ausgang", ""),
"source_folder": form.get("source_folder", "Rechnungen"),
"processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"),
"source_folder_ausgang": form.get("source_folder_ausgang", ""),
"processed_folder_ausgang": form.get("processed_folder_ausgang", ""),
"interval_minutes": form.get("interval_minutes", "5"),
"scheduler_enabled": form.get("scheduler_enabled", "false"),
"fetch_since_date": form.get("fetch_since_date", ""),
@ -89,7 +113,11 @@ async def _save_form_settings(request: Request) -> dict:
"smb_share": form.get("smb_share", ""),
"smb_source_path": form.get("smb_source_path", ""),
"smb_processed_path": form.get("smb_processed_path", "Verarbeitet"),
"smb_source_path_ausgang": form.get("smb_source_path_ausgang", ""),
"smb_processed_path_ausgang": form.get("smb_processed_path_ausgang", ""),
"smb_mode": form.get("smb_mode", "forward"),
# Debug
"debug_save_amazon_pdfs": form.get("debug_save_amazon_pdfs", "false"),
}
await save_settings(data)
@ -194,6 +222,12 @@ async def log_page(request: Request):
})
@app.post("/api/clear-log")
async def api_clear_log():
count = await clear_log_entries()
return JSONResponse({"success": True, "count": count})
@app.get("/api/status")
async def api_status():
return get_scheduler_status()
@ -247,6 +281,7 @@ async def scan_upload_chunk(
async def scan_process(request: Request):
body = await request.json()
upload_id = body.get("upload_id", "")
beleg_type = body.get("beleg_type", "eingang")
try:
uuid.UUID(upload_id)
@ -269,7 +304,7 @@ async def scan_process(request: Request):
# Process in background task
async def _process():
try:
result = await process_scanned_pdf(str(pdf_path), progress_callback)
result = await process_scanned_pdf(str(pdf_path), progress_callback, beleg_type=beleg_type)
_scan_progress.setdefault(upload_id, []).append({
"stage": "done", "result": result
})
@ -325,3 +360,113 @@ async def separator_pdf():
media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=Trennseite.pdf"},
)
# --- Plattformen (Amazon) ---
@app.get("/platforms", response_class=HTMLResponse)
async def platforms_page(request: Request):
settings = await get_settings()
status = get_scheduler_status()
return templates.TemplateResponse("platforms.html", {
"request": request,
"settings": settings,
"status": status,
"message": None,
"message_type": None,
})
@app.post("/api/amazon-settings")
async def api_amazon_settings(request: Request):
body = await request.json()
current = await get_settings()
data = {
"amazon_enabled": body.get("amazon_enabled", "false"),
"amazon_domain": body.get("amazon_domain", "amazon.de"),
"amazon_email": body.get("amazon_email", ""),
"amazon_password": body.get("amazon_password") or current.get("amazon_password", ""),
"amazon_since_date": body.get("amazon_since_date", ""),
}
await save_settings(data)
return JSONResponse({"success": True})
@app.get("/api/amazon-status")
async def api_amazon_status():
valid = await amazon_check_session()
login_active = amazon_login_active()
return JSONResponse({"session_valid": valid, "login_active": login_active})
@app.post("/api/amazon-login")
async def api_amazon_login():
"""Start interactive browser login."""
await amazon_start_interactive()
return JSONResponse({"success": True})
@app.get("/api/amazon-login-state")
async def api_amazon_login_state():
return JSONResponse(amazon_get_login_state())
@app.get("/api/amazon-browser-screenshot")
async def api_amazon_browser_screenshot():
img = await amazon_get_screenshot()
if img is None:
return JSONResponse({"error": "Kein Browser offen"}, status_code=404)
return Response(content=img, media_type="image/png")
@app.post("/api/amazon-browser-click")
async def api_amazon_browser_click(request: Request):
body = await request.json()
await amazon_browser_click(int(body["x"]), int(body["y"]))
return JSONResponse({"success": True})
@app.post("/api/amazon-browser-type")
async def api_amazon_browser_type(request: Request):
body = await request.json()
await amazon_browser_type(body["text"])
return JSONResponse({"success": True})
@app.post("/api/amazon-browser-key")
async def api_amazon_browser_key(request: Request):
body = await request.json()
await amazon_browser_key(body["key"])
return JSONResponse({"success": True})
@app.post("/api/amazon-login-close")
async def api_amazon_login_close():
await amazon_close_interactive()
return JSONResponse({"success": True})
@app.post("/api/amazon-otp")
async def api_amazon_otp(request: Request):
body = await request.json()
ok = await amazon_submit_otp(body.get("code", ""))
return JSONResponse({"success": ok})
@app.post("/api/amazon-logout")
async def api_amazon_logout():
await amazon_clear_session()
return JSONResponse({"success": True})
@app.post("/api/amazon-process")
async def api_amazon_process():
result = await process_amazon()
return JSONResponse(result)
@app.post("/api/amazon-reset")
async def api_amazon_reset():
count = await reset_downloaded_invoices()
return JSONResponse({"success": True, "count": count})

View File

@ -11,8 +11,8 @@ from pypdf import PdfReader, PdfWriter
import qrcode
from qrcode.constants import ERROR_CORRECT_H
from app.database import get_settings, add_log_entry
from app.mail_processor import _connect_smtp, _build_forward_email
from app.database import get_settings, add_log_entry, get_import_email
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
logger = logging.getLogger(__name__)
@ -84,11 +84,12 @@ def split_pdf(pdf_path: str, separator_pages: list[int]) -> list[bytes]:
return documents
async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
async def process_scanned_pdf(pdf_path: str, progress_callback=None, beleg_type: str = "eingang") -> dict:
"""Full pipeline: detect separators, split, send each document via email."""
settings = await get_settings()
if not settings.get("smtp_server") or not settings.get("import_email"):
import_email = get_import_email(settings, beleg_type)
if not settings.get("smtp_server") or not import_email:
return {"error": "SMTP oder Import-Email nicht konfiguriert", "total_pages": 0, "documents": 0, "sent": 0, "errors": 1}
# Step 1: Detect separator pages (CPU-bound, run in thread)
@ -135,12 +136,12 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
filename = f"Scan_Dokument_{i + 1}.pdf"
msg = _build_forward_email(
from_addr=settings["smtp_username"],
to_addr=settings["import_email"],
to_addr=import_email,
original_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
original_from="Scan-Upload",
attachments=[(filename, doc_bytes)],
)
smtp_conn.send_message(msg)
smtp_log = _send_with_log(smtp_conn, msg)
sent += 1
await add_log_entry(
@ -148,6 +149,9 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
email_from="Scan-Upload",
attachments_count=1,
status="success",
sent_to=import_email,
smtp_log=smtp_log,
beleg_type=beleg_type,
)
except Exception as e:
@ -159,6 +163,8 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
attachments_count=1,
status="error",
error_message=str(e),
sent_to=import_email,
beleg_type=beleg_type,
)
except Exception as e:

View File

@ -5,6 +5,7 @@ from apscheduler.triggers.interval import IntervalTrigger
from app.mail_processor import process_mailbox
from app.smb_processor import process_smb_share
from app.amazon_processor import process_amazon
logger = logging.getLogger(__name__)
@ -21,6 +22,7 @@ async def _run_processor():
return
_is_processing = True
try:
# Email and SMB first - these are fast and must not be blocked by Amazon
logger.info("Starte automatische Email-Verarbeitung...")
result = await process_mailbox()
logger.info(f"Email-Verarbeitung abgeschlossen: {result}")
@ -28,6 +30,16 @@ async def _run_processor():
logger.info("Starte automatische SMB-Verarbeitung...")
smb_result = await process_smb_share()
logger.info(f"SMB-Verarbeitung abgeschlossen: {smb_result}")
# Amazon separately with timeout - must not block next scheduler runs
logger.info("Starte automatische Amazon-Verarbeitung...")
try:
amazon_result = await asyncio.wait_for(process_amazon(), timeout=300)
logger.info(f"Amazon-Verarbeitung abgeschlossen: {amazon_result}")
except asyncio.TimeoutError:
logger.error("Amazon-Verarbeitung nach 5 Minuten abgebrochen (Timeout)")
except Exception as e:
logger.error(f"Fehler bei Amazon-Verarbeitung: {e}")
except Exception as e:
logger.error(f"Fehler bei automatischer Verarbeitung: {e}")
finally:

View File

@ -5,8 +5,8 @@ import tempfile
import smbclient
from app.database import get_settings, add_log_entry
from app.mail_processor import _connect_smtp, _build_forward_email
from app.database import get_settings, add_log_entry, get_import_email
from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
from app.scanner import detect_separator_pages, split_pdf
logger = logging.getLogger(__name__)
@ -114,6 +114,119 @@ def _list_smb_folders_recursive(
return folders
async def _process_smb_folder(
smtp_conn, settings: dict, base_path: str,
source_rel: str, processed_rel: str,
import_email: str, beleg_type: str, mode: str,
) -> dict:
"""Process one SMB folder pair. Returns counts dict."""
smtp_from = settings.get("smtp_username", "")
processed = 0
skipped = 0
errors = 0
source_path = _smb_unc_path(base_path, source_rel)
processed_path = _smb_unc_path(base_path, processed_rel)
await asyncio.to_thread(_ensure_smb_folder, processed_path)
pdf_files = await asyncio.to_thread(_list_pdf_files, source_path)
if not pdf_files:
logger.info(f"Keine PDF-Dateien im SMB-Ordner '{source_rel}' ({beleg_type})")
return {"processed": 0, "skipped": 0, "errors": 0}
logger.info(f"{len(pdf_files)} PDF-Datei(en) im SMB-Ordner '{source_rel}' ({beleg_type})")
for filename in pdf_files:
file_path = _smb_unc_path(source_path, filename)
try:
pdf_data = await asyncio.to_thread(_read_smb_file, file_path)
if mode == "separator":
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp.write(pdf_data)
tmp_path = tmp.name
try:
separator_pages = await asyncio.to_thread(
detect_separator_pages, tmp_path, None
)
documents = await asyncio.to_thread(
split_pdf, tmp_path, separator_pages
)
finally:
os.unlink(tmp_path)
if not documents:
skipped += 1
continue
smtp_log_parts = []
for i, doc_bytes in enumerate(documents):
doc_filename = f"{os.path.splitext(filename)[0]}_Teil_{i + 1}.pdf"
subject = f"SMB-Import: {filename} (Dokument {i + 1}/{len(documents)})"
msg = _build_forward_email(
from_addr=smtp_from,
to_addr=import_email,
original_subject=subject,
original_from="SMB-Import",
attachments=[(doc_filename, doc_bytes)],
)
smtp_log_parts.append(_send_with_log(smtp_conn, msg))
await add_log_entry(
email_subject=f"SMB: {filename}",
email_from="SMB-Import",
attachments_count=len(documents),
status="success",
sent_to=import_email,
smtp_log="\n---\n".join(smtp_log_parts),
beleg_type=beleg_type,
)
logger.info(
f"SMB verarbeitet ({beleg_type}): {filename} -> {len(documents)} Dokument(e)"
)
else:
msg = _build_forward_email(
from_addr=smtp_from,
to_addr=import_email,
original_subject=f"SMB-Import: {filename}",
original_from="SMB-Import",
attachments=[(filename, pdf_data)],
)
smtp_log = _send_with_log(smtp_conn, msg)
await add_log_entry(
email_subject=f"SMB: {filename}",
email_from="SMB-Import",
attachments_count=1,
status="success",
sent_to=import_email,
smtp_log=smtp_log,
beleg_type=beleg_type,
)
logger.info(f"SMB verarbeitet ({beleg_type}): {filename}")
await asyncio.to_thread(_move_smb_file, file_path, processed_path, filename)
processed += 1
except Exception as e:
errors += 1
logger.error(f"Fehler bei SMB-Datei {filename}: {e}")
try:
await add_log_entry(
email_subject=f"SMB: {filename}",
email_from="SMB-Import",
attachments_count=0,
status="error",
error_message=str(e),
beleg_type=beleg_type,
)
except Exception:
pass
return {"processed": processed, "skipped": skipped, "errors": errors}
async def process_smb_share() -> dict:
"""Process PDF files from SMB share - main pipeline."""
settings = await get_settings()
@ -124,113 +237,43 @@ async def process_smb_share() -> dict:
if not settings.get("smb_server") or not settings.get("smb_share"):
return {"processed": 0, "skipped": 0, "errors": 0, "error": "SMB nicht konfiguriert"}
if not settings.get("import_email"):
import_email_eingang = get_import_email(settings, "eingang")
if not import_email_eingang:
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Import-Email nicht konfiguriert"}
mode = settings.get("smb_mode", "forward")
smtp_from = settings.get("smtp_username", "")
import_email = settings["import_email"]
processed = 0
skipped = 0
errors = 0
total = {"processed": 0, "skipped": 0, "errors": 0}
smtp_conn = None
try:
base_path = await asyncio.to_thread(_smb_register_session, settings)
source_path = _smb_unc_path(base_path, settings.get("smb_source_path", ""))
processed_path = _smb_unc_path(base_path, settings.get("smb_processed_path", "Verarbeitet"))
await asyncio.to_thread(_ensure_smb_folder, processed_path)
pdf_files = await asyncio.to_thread(_list_pdf_files, source_path)
if not pdf_files:
logger.info("Keine PDF-Dateien im SMB-Ordner gefunden")
return {"processed": 0, "skipped": 0, "errors": 0}
logger.info(f"{len(pdf_files)} PDF-Datei(en) im SMB-Ordner gefunden")
smtp_conn = _connect_smtp(settings)
for filename in pdf_files:
file_path = _smb_unc_path(source_path, filename)
try:
pdf_data = await asyncio.to_thread(_read_smb_file, file_path)
# Eingangsbelege
source = settings.get("smb_source_path", "")
processed_rel = settings.get("smb_processed_path", "Verarbeitet")
result = await _process_smb_folder(
smtp_conn, settings, base_path,
source, processed_rel,
import_email_eingang, "eingang", mode,
)
for k in total:
total[k] += result[k]
if mode == "separator":
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp.write(pdf_data)
tmp_path = tmp.name
try:
separator_pages = await asyncio.to_thread(
detect_separator_pages, tmp_path, None
)
documents = await asyncio.to_thread(
split_pdf, tmp_path, separator_pages
)
finally:
os.unlink(tmp_path)
if not documents:
skipped += 1
continue
for i, doc_bytes in enumerate(documents):
doc_filename = f"{os.path.splitext(filename)[0]}_Teil_{i + 1}.pdf"
subject = f"SMB-Import: {filename} (Dokument {i + 1}/{len(documents)})"
msg = _build_forward_email(
from_addr=smtp_from,
to_addr=import_email,
original_subject=subject,
original_from="SMB-Import",
attachments=[(doc_filename, doc_bytes)],
)
smtp_conn.send_message(msg)
await add_log_entry(
email_subject=f"SMB: {filename}",
email_from="SMB-Import",
attachments_count=len(documents),
status="success",
)
logger.info(
f"SMB verarbeitet: {filename} -> {len(documents)} Dokument(e) "
f"({len(separator_pages)} Trennseite(n))"
)
else:
msg = _build_forward_email(
from_addr=smtp_from,
to_addr=import_email,
original_subject=f"SMB-Import: {filename}",
original_from="SMB-Import",
attachments=[(filename, pdf_data)],
)
smtp_conn.send_message(msg)
await add_log_entry(
email_subject=f"SMB: {filename}",
email_from="SMB-Import",
attachments_count=1,
status="success",
)
logger.info(f"SMB verarbeitet: {filename}")
await asyncio.to_thread(_move_smb_file, file_path, processed_path, filename)
processed += 1
except Exception as e:
errors += 1
logger.error(f"Fehler bei SMB-Datei {filename}: {e}")
try:
await add_log_entry(
email_subject=f"SMB: {filename}",
email_from="SMB-Import",
attachments_count=0,
status="error",
error_message=str(e),
)
except Exception:
pass
# Ausgangsbelege (optional)
import_email_ausgang = get_import_email(settings, "ausgang")
source_ausgang = settings.get("smb_source_path_ausgang", "")
processed_ausgang = settings.get("smb_processed_path_ausgang", "")
if import_email_ausgang and source_ausgang:
if not processed_ausgang:
processed_ausgang = source_ausgang + "/Verarbeitet"
result = await _process_smb_folder(
smtp_conn, settings, base_path,
source_ausgang, processed_ausgang,
import_email_ausgang, "ausgang", mode,
)
for k in total:
total[k] += result[k]
except Exception as e:
logger.error(f"SMB-Verbindungsfehler: {e}")
@ -244,7 +287,7 @@ async def process_smb_share() -> dict:
)
except Exception:
pass
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)}
return {**total, "errors": total["errors"] + 1, "error": str(e)}
finally:
if smtp_conn:
@ -253,8 +296,8 @@ async def process_smb_share() -> dict:
except Exception:
pass
logger.info(f"SMB fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler")
return {"processed": processed, "skipped": skipped, "errors": errors}
logger.info(f"SMB fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
return total
async def test_smb_connection() -> dict:

View File

@ -200,6 +200,11 @@ main {
color: #856404;
}
.badge-info {
background: #d1ecf1;
color: #0c5460;
}
.badge-inactive {
background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.7);
@ -236,6 +241,16 @@ main {
border: 1px solid #bee5eb;
}
/* Allow cards with tables to scroll horizontally */
.card-table {
overflow-x: auto;
}
/* Wider main container for pages with large tables */
.main-wide {
max-width: 95%;
}
table {
width: 100%;
border-collapse: collapse;

View File

@ -10,8 +10,9 @@
<nav>
<div class="nav-brand">Belegimport</div>
<div class="nav-links">
<a href="/" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
<a href="/scan" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
<a href="/" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
<a href="/settings" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
<a href="/platforms" class="{% if active_page == 'platforms' %}active{% endif %}">Plattformen</a>
<a href="/log" class="{% if active_page == 'log' %}active{% endif %}">Verarbeitungslog</a>
</div>
<div class="nav-status">
@ -29,7 +30,7 @@
</div>
</nav>
<main>
<main class="{% if main_class is defined %}{{ main_class }}{% endif %}">
{% if message %}
<div class="alert alert-{{ message_type or 'info' }}">
{{ message }}

View File

@ -1,21 +1,30 @@
{% extends "base.html" %}
{% set active_page = "log" %}
{% set main_class = "main-wide" %}
{% set message = None %}
{% block content %}
<div class="card">
<h2>Verarbeitungslog</h2>
<div class="card card-table">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.5rem;border-bottom:1px solid var(--border);">
<h2 style="margin:0;border:none;padding:0;">Verarbeitungslog</h2>
{% if logs %}
<button type="button" class="btn btn-secondary btn-small" onclick="clearLog()">Log leeren</button>
{% endif %}
</div>
{% if logs %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Zeitpunkt</th>
<th>Art</th>
<th>Betreff</th>
<th>Absender</th>
<th>Anhänge</th>
<th>Gesendet an</th>
<th>Status</th>
<th>Fehlermeldung</th>
<th>SMTP</th>
</tr>
</thead>
<tbody>
@ -23,9 +32,17 @@
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
<td>{{ log.id }}</td>
<td>{{ log.timestamp }}</td>
<td>
{% if log.get('beleg_type', 'eingang') == 'ausgang' %}
<span class="badge badge-warning">Ausgang</span>
{% else %}
<span class="badge badge-info">Eingang</span>
{% endif %}
</td>
<td>{{ log.email_subject or '-' }}</td>
<td>{{ log.email_from or '-' }}</td>
<td>{{ log.attachments_count }}</td>
<td>{{ log.sent_to or '-' }}</td>
<td>
{% if log.status == 'success' %}
<span class="badge badge-success">OK</span>
@ -34,6 +51,12 @@
{% endif %}
</td>
<td>{{ log.error_message or '-' }}</td>
<td>
{% if log.smtp_log %}
<button type="button" class="btn btn-small btn-secondary" onclick="showSmtpLog({{ log.id }})">Anzeigen</button>
<script>window._smtpLogs = window._smtpLogs || {}; window._smtpLogs[{{ log.id }}] = {{ log.smtp_log | tojson }};</script>
{% else %}-{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@ -42,4 +65,54 @@
<p class="text-muted">Noch keine Einträge vorhanden.</p>
{% endif %}
</div>
<!-- SMTP Log Modal -->
<div id="smtpModal" class="modal-overlay" style="display:none;" onclick="if(event.target===this)closeSmtpModal()">
<div class="modal" style="max-width:700px;">
<div class="modal-header">
<h3>SMTP-Protokoll</h3>
<button type="button" class="modal-close" onclick="closeSmtpModal()">&times;</button>
</div>
<div class="modal-body" style="padding:1.25rem;background:var(--bg,#f5f5f5);">
<pre id="smtpModalBody" style="margin:0;font-size:0.8rem;white-space:pre-wrap;word-break:break-all;"></pre>
</div>
</div>
</div>
<style>
.btn-small {
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
}
</style>
<script>
function showSmtpLog(logId) {
const log = window._smtpLogs && window._smtpLogs[logId];
if (!log) return;
document.getElementById('smtpModalBody').textContent = log;
document.getElementById('smtpModal').style.display = 'flex';
}
function closeSmtpModal() {
document.getElementById('smtpModal').style.display = 'none';
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeSmtpModal();
});
async function clearLog() {
if (!confirm('Verarbeitungslog wirklich leeren?')) return;
try {
const resp = await fetch('/api/clear-log', {method: 'POST'});
const data = await resp.json();
if (data.success) {
location.reload();
}
} catch (e) {
alert('Fehler: ' + e.message);
}
}
</script>
{% endblock %}

View File

@ -0,0 +1,363 @@
{% extends "base.html" %}
{% set active_page = "platforms" %}
{% block content %}
{% if message %}
<div class="alert alert-{{ message_type or 'info' }}">{{ message }}</div>
{% endif %}
<div class="card">
<h2>Amazon Business - Einstellungen</h2>
<div class="form-grid">
<div class="form-group">
<label for="amazon_enabled">Status</label>
<select id="amazon_enabled" name="amazon_enabled">
<option value="false" {% if settings.get('amazon_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
<option value="true" {% if settings.get('amazon_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
</select>
</div>
<div class="form-group">
<label for="amazon_domain">Amazon-Domain</label>
<select id="amazon_domain" name="amazon_domain">
<option value="amazon.de" {% if settings.get('amazon_domain') == 'amazon.de' %}selected{% endif %}>amazon.de</option>
<option value="amazon.at" {% if settings.get('amazon_domain') == 'amazon.at' %}selected{% endif %}>amazon.at</option>
<option value="amazon.com" {% if settings.get('amazon_domain') == 'amazon.com' %}selected{% endif %}>amazon.com</option>
</select>
</div>
<div class="form-group">
<label for="amazon_email">Amazon E-Mail</label>
<input type="email" id="amazon_email" name="amazon_email"
value="{{ settings.get('amazon_email', '') }}"
placeholder="email@example.com">
</div>
<div class="form-group">
<label for="amazon_password">Amazon Passwort</label>
<input type="password" id="amazon_password" name="amazon_password"
placeholder="{% if settings.get('amazon_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
</div>
<div class="form-group">
<label for="amazon_since_date">Rechnungen ab Datum</label>
<input type="date" id="amazon_since_date" name="amazon_since_date"
value="{{ settings.get('amazon_since_date', '') }}">
<small class="text-muted">Leer = letzte 30 Tage</small>
</div>
<div class="form-group" style="align-self:end;">
{% if settings.get('amazon_last_sync') %}
<small class="text-muted">Letzter Abruf: {{ settings.get('amazon_last_sync') }}</small>
{% endif %}
</div>
</div>
<div class="form-actions" style="margin-top:1rem;">
<button type="button" class="btn btn-primary" onclick="saveAmazonSettings()">Einstellungen speichern</button>
</div>
<div id="settingsMsg" style="margin-top:0.75rem;"></div>
</div>
<div class="card">
<h2>Anmeldung &amp; Abruf</h2>
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem;">
<span>Session:</span>
<span id="sessionBadge" class="badge badge-inactive">Wird geprüft...</span>
</div>
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
<button type="button" id="btnLogin" class="btn btn-primary" onclick="doLogin()">Bei Amazon anmelden</button>
<button type="button" id="btnLogout" class="btn btn-secondary" onclick="doLogout()" style="display:none;">Session löschen</button>
<button type="button" id="btnProcess" class="btn btn-success" onclick="doProcess()" style="display:none;">Jetzt Rechnungen abrufen</button>
<button type="button" class="btn btn-secondary" onclick="doReset()">Importierte zurücksetzen</button>
</div>
<div id="processMsg" style="margin-top:0.75rem;"></div>
</div>
<!-- Interactive Browser Modal -->
<div id="browserModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:1000;overflow:auto;">
<div style="max-width:1340px;margin:2rem auto;background:var(--card-bg);border-radius:8px;padding:1.5rem;position:relative;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<h3 style="margin:0;">Amazon Login - Interaktiver Browser</h3>
<button type="button" class="btn btn-secondary" onclick="closeBrowser()" style="padding:0.25rem 0.75rem;">Schließen</button>
</div>
<div id="browserInfo" class="alert alert-info" style="margin-bottom:1rem;">
Klicken und tippen Sie im Browser-Bild, um sich bei Amazon anzumelden. CAPTCHAs und 2FA können Sie direkt lösen.
</div>
<div style="position:relative;display:inline-block;border:1px solid var(--border-color);cursor:crosshair;">
<img id="browserImg" src="" alt="Browser" style="display:block;max-width:100%;height:auto;"
onclick="onBrowserClick(event)">
</div>
<div style="margin-top:1rem;display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<input type="text" id="browserInput" placeholder="Text eingeben und Enter drücken..."
style="flex:1;min-width:200px;padding:0.5rem;border:1px solid var(--border-color);border-radius:4px;background:var(--input-bg);color:var(--text-color);"
onkeydown="onBrowserKeydown(event)">
<button type="button" class="btn btn-primary" onclick="sendBrowserText()">Senden</button>
<button type="button" class="btn btn-secondary" onclick="sendKey('Tab')">Tab</button>
<button type="button" class="btn btn-secondary" onclick="sendKey('Escape')">Esc</button>
<button type="button" class="btn btn-secondary" onclick="sendKey('Backspace')">&#9003;</button>
</div>
</div>
</div>
<script>
// --- Settings ---
async function saveAmazonSettings() {
const btn = event.target;
btn.disabled = true;
btn.textContent = 'Speichert...';
const msgEl = document.getElementById('settingsMsg');
const data = {
amazon_enabled: document.getElementById('amazon_enabled').value,
amazon_domain: document.getElementById('amazon_domain').value,
amazon_email: document.getElementById('amazon_email').value,
amazon_password: document.getElementById('amazon_password').value,
amazon_since_date: document.getElementById('amazon_since_date').value,
};
try {
const resp = await fetch('/api/amazon-settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
const result = await resp.json();
if (result.success) {
msgEl.innerHTML = '<div class="alert alert-success">Einstellungen gespeichert</div>';
if (data.amazon_password) {
document.getElementById('amazon_password').value = '';
document.getElementById('amazon_password').placeholder = '(gespeichert)';
}
} else {
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(result.error || 'Fehler') + '</div>';
}
} catch (e) {
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
} finally {
btn.disabled = false;
btn.textContent = 'Einstellungen speichern';
}
}
// --- Session Status ---
async function checkSession() {
const badge = document.getElementById('sessionBadge');
try {
const resp = await fetch('/api/amazon-status');
const data = await resp.json();
if (data.login_active) {
badge.className = 'badge badge-warning';
badge.textContent = 'Login läuft...';
document.getElementById('btnLogout').style.display = 'none';
document.getElementById('btnProcess').style.display = 'none';
} else if (data.session_valid) {
badge.className = 'badge badge-success';
badge.textContent = 'Angemeldet';
document.getElementById('btnLogout').style.display = '';
document.getElementById('btnProcess').style.display = '';
} else {
badge.className = 'badge badge-inactive';
badge.textContent = 'Nicht angemeldet';
document.getElementById('btnLogout').style.display = 'none';
document.getElementById('btnProcess').style.display = 'none';
}
} catch (e) {
badge.className = 'badge badge-inactive';
badge.textContent = 'Unbekannt';
}
}
// --- Interactive Browser Login ---
let screenshotInterval = null;
let loginPollInterval = null;
async function doLogin() {
const msgEl = document.getElementById('processMsg');
msgEl.innerHTML = '<div class="alert alert-info">Browser wird gestartet...</div>';
try {
await fetch('/api/amazon-login', {method: 'POST'});
// Open browser modal
document.getElementById('browserModal').style.display = '';
msgEl.innerHTML = '';
startScreenshotPolling();
startLoginStatePolling();
} catch (e) {
msgEl.innerHTML = '<div class="alert alert-error">Browser konnte nicht gestartet werden</div>';
}
}
function startScreenshotPolling() {
if (screenshotInterval) clearInterval(screenshotInterval);
refreshScreenshot();
screenshotInterval = setInterval(refreshScreenshot, 1500);
}
async function refreshScreenshot() {
try {
const resp = await fetch('/api/amazon-browser-screenshot');
if (resp.ok) {
const blob = await resp.blob();
document.getElementById('browserImg').src = URL.createObjectURL(blob);
}
} catch (e) {}
}
function startLoginStatePolling() {
if (loginPollInterval) clearInterval(loginPollInterval);
loginPollInterval = setInterval(async () => {
try {
const resp = await fetch('/api/amazon-login-state');
const data = await resp.json();
const info = document.getElementById('browserInfo');
if (data.status === 'logged_in') {
info.className = 'alert alert-success';
info.textContent = 'Erfolgreich angemeldet! Sie können das Fenster schließen.';
} else if (data.status === 'login_failed') {
info.className = 'alert alert-error';
info.textContent = data.message || 'Login fehlgeschlagen';
} else if (data.status === 'interactive') {
info.className = 'alert alert-info';
info.textContent = data.message || 'Bitte im Browser anmelden...';
}
} catch (e) {}
}, 2000);
}
async function onBrowserClick(event) {
const img = event.target;
const rect = img.getBoundingClientRect();
// Scale click coordinates to actual browser viewport (1280x800)
const scaleX = 1280 / img.clientWidth;
const scaleY = 800 / img.clientHeight;
const x = Math.round((event.clientX - rect.left) * scaleX);
const y = Math.round((event.clientY - rect.top) * scaleY);
try {
await fetch('/api/amazon-browser-click', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({x, y}),
});
setTimeout(refreshScreenshot, 500);
} catch (e) {}
}
async function sendBrowserText() {
const input = document.getElementById('browserInput');
const text = input.value;
if (!text) return;
try {
await fetch('/api/amazon-browser-type', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text}),
});
input.value = '';
setTimeout(refreshScreenshot, 500);
} catch (e) {}
}
async function sendKey(key) {
try {
await fetch('/api/amazon-browser-key', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key}),
});
setTimeout(refreshScreenshot, 500);
} catch (e) {}
}
function onBrowserKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault();
sendBrowserText();
// Also send Enter to the browser
sendKey('Enter');
}
}
async function closeBrowser() {
if (screenshotInterval) { clearInterval(screenshotInterval); screenshotInterval = null; }
if (loginPollInterval) { clearInterval(loginPollInterval); loginPollInterval = null; }
document.getElementById('browserModal').style.display = 'none';
try {
await fetch('/api/amazon-login-close', {method: 'POST'});
} catch (e) {}
checkSession();
}
// --- Logout ---
async function doLogout() {
if (!confirm('Amazon-Session wirklich löschen? Sie müssen sich danach neu anmelden.')) return;
const btn = document.getElementById('btnLogout');
btn.disabled = true;
try {
await fetch('/api/amazon-logout', {method: 'POST'});
document.getElementById('processMsg').innerHTML = '<div class="alert alert-info">Session gelöscht</div>';
} catch (e) {
document.getElementById('processMsg').innerHTML = '<div class="alert alert-error">Fehler</div>';
} finally {
btn.disabled = false;
checkSession();
}
}
// --- Process ---
async function doProcess() {
const btn = document.getElementById('btnProcess');
const msgEl = document.getElementById('processMsg');
btn.disabled = true;
btn.textContent = 'Rechnungen werden abgerufen...';
msgEl.innerHTML = '';
try {
const resp = await fetch('/api/amazon-process', {method: 'POST'});
const data = await resp.json();
if (data.error) {
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error) + '</div>';
} else if (data.processed > 0 || data.errors > 0) {
let msg = data.processed + ' Rechnung(en) importiert';
if (data.skipped > 0) msg += ', ' + data.skipped + ' übersprungen';
if (data.errors > 0) msg += ', ' + data.errors + ' Fehler';
const cls = data.errors > 0 ? 'warning' : 'success';
msgEl.innerHTML = '<div class="alert alert-' + cls + '">' + escapeHtml(msg) + '</div>';
} else {
msgEl.innerHTML = '<div class="alert alert-info">Keine neuen Rechnungen gefunden</div>';
}
} catch (e) {
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
} finally {
btn.disabled = false;
btn.textContent = 'Jetzt Rechnungen abrufen';
checkSession();
}
}
// --- Reset ---
async function doReset() {
if (!confirm('Alle als importiert markierten Rechnungen zurücksetzen? Beim nächsten Abruf werden alle Rechnungen erneut heruntergeladen und gesendet.')) return;
const msgEl = document.getElementById('processMsg');
try {
const resp = await fetch('/api/amazon-reset', {method: 'POST'});
const data = await resp.json();
if (data.success) {
msgEl.innerHTML = '<div class="alert alert-info">' + data.count + ' Bestellung(en) zurückgesetzt</div>';
} else {
msgEl.innerHTML = '<div class="alert alert-error">' + escapeHtml(data.error || 'Fehler') + '</div>';
}
} catch (e) {
msgEl.innerHTML = '<div class="alert alert-error">Verbindungsfehler</div>';
}
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
// Initial check
checkSession();
</script>
{% endblock %}

View File

@ -8,6 +8,13 @@
Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente gesendet.
</p>
<!-- Belegart -->
<div style="margin-bottom:1rem;display:flex;gap:1rem;align-items:center;">
<label style="margin:0;font-weight:600;">Belegart:</label>
<label style="margin:0;cursor:pointer;"><input type="radio" name="beleg_type" value="eingang" checked> Eingangsbeleg (Einkauf)</label>
<label style="margin:0;cursor:pointer;"><input type="radio" name="beleg_type" value="ausgang"> Ausgangsbeleg (Verkauf/Gutschrift)</label>
</div>
<!-- Upload Zone -->
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">&#128196;</div>
@ -175,7 +182,7 @@ async function startProcessing(uploadId) {
const resp = await fetch('/api/scan-process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upload_id: uploadId }),
body: JSON.stringify({ upload_id: uploadId, beleg_type: document.querySelector('input[name="beleg_type"]:checked').value }),
});
if (!resp.ok) {

View File

@ -77,13 +77,14 @@
</div>
<div class="card">
<h2>Import & Ordner</h2>
<h2>Import - Eingangsbelege</h2>
<div class="form-grid">
<div class="form-group form-group-wide">
<label for="import_email">Import-Emailadresse</label>
<input type="email" id="import_email" name="import_email"
value="{{ settings.get('import_email', '') }}" placeholder="import@example.com">
<label for="import_email_eingang">Import-Email Eingangsbelege</label>
<input type="email" id="import_email_eingang" name="import_email_eingang"
value="{{ settings.get('import_email_eingang', '') or settings.get('import_email', '') }}" placeholder="eingang@buchhaltung.example.com">
</div>
<input type="hidden" id="import_email" name="import_email" value="{{ settings.get('import_email_eingang', '') or settings.get('import_email', '') }}">
<div class="form-group">
<label for="source_folder">Eingangsordner (IMAP)</label>
<div class="input-with-btn">
@ -101,6 +102,34 @@
</div>
</div>
</div>
</div>
<div class="card">
<h2>Import - Ausgangsbelege <small style="font-weight:normal;color:var(--text-muted);">(optional)</small></h2>
<div class="form-grid">
<div class="form-group form-group-wide">
<label for="import_email_ausgang">Import-Email Ausgangsbelege</label>
<input type="email" id="import_email_ausgang" name="import_email_ausgang"
value="{{ settings.get('import_email_ausgang', '') }}" placeholder="ausgang@buchhaltung.example.com">
<small class="text-muted">Leer lassen wenn keine Ausgangsbelege importiert werden sollen</small>
</div>
<div class="form-group">
<label for="source_folder_ausgang">Eingangsordner Ausgangsbelege (IMAP)</label>
<div class="input-with-btn">
<input type="text" id="source_folder_ausgang" name="source_folder_ausgang"
value="{{ settings.get('source_folder_ausgang', '') }}" placeholder="Ausgangsrechnungen">
<button type="button" class="btn btn-icon" onclick="openFolderPicker('source_folder_ausgang')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
<div class="form-group">
<label for="processed_folder_ausgang">Verarbeitet-Ordner Ausgangsbelege (IMAP)</label>
<div class="input-with-btn">
<input type="text" id="processed_folder_ausgang" name="processed_folder_ausgang"
value="{{ settings.get('processed_folder_ausgang', '') }}" placeholder="Ausgangsrechnungen/Verarbeitet">
<button type="button" class="btn btn-icon" onclick="openFolderPicker('processed_folder_ausgang')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="testEmail()">
<span class="btn-text">Test-Email senden</span>
@ -158,7 +187,7 @@
value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
</div>
<div class="form-group">
<label for="smb_source_path">Quellordner</label>
<label for="smb_source_path">Quellordner Eingangsbelege</label>
<div class="input-with-btn">
<input type="text" id="smb_source_path" name="smb_source_path"
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
@ -166,13 +195,29 @@
</div>
</div>
<div class="form-group">
<label for="smb_processed_path">Verarbeitet-Ordner</label>
<label for="smb_processed_path">Verarbeitet-Ordner Eingangsbelege</label>
<div class="input-with-btn">
<input type="text" id="smb_processed_path" name="smb_processed_path"
value="{{ settings.get('smb_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
<div class="form-group">
<label for="smb_source_path_ausgang">Quellordner Ausgangsbelege</label>
<div class="input-with-btn">
<input type="text" id="smb_source_path_ausgang" name="smb_source_path_ausgang"
value="{{ settings.get('smb_source_path_ausgang', '') }}" placeholder="(optional)">
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_source_path_ausgang')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
<div class="form-group">
<label for="smb_processed_path_ausgang">Verarbeitet-Ordner Ausgangsbelege</label>
<div class="input-with-btn">
<input type="text" id="smb_processed_path_ausgang" name="smb_processed_path_ausgang"
value="{{ settings.get('smb_processed_path_ausgang', '') }}" placeholder="(optional)">
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path_ausgang')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="testSmb()">
@ -206,6 +251,20 @@
</div>
</div>
<div class="card">
<h2>Debug</h2>
<div class="form-grid">
<div class="form-group">
<label for="debug_save_amazon_pdfs">Amazon-PDFs zwischenspeichern</label>
<select id="debug_save_amazon_pdfs" name="debug_save_amazon_pdfs">
<option value="false" {% if settings.get('debug_save_amazon_pdfs') != 'true' %}selected{% endif %}>Aus</option>
<option value="true" {% if settings.get('debug_save_amazon_pdfs') == 'true' %}selected{% endif %}>An</option>
</select>
<small class="text-muted">Speichert heruntergeladene Amazon-Rechnungen in /data/uploads/amazon_invoices/</small>
</div>
</div>
</div>
<div class="form-actions-main">
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
<button type="button" class="btn btn-success" onclick="manualProcess()">
@ -228,6 +287,7 @@
<th>Betreff</th>
<th>Absender</th>
<th>Anhänge</th>
<th>Art</th>
<th>Status</th>
</tr>
</thead>
@ -238,6 +298,13 @@
<td>{{ log.email_subject or '-' }}</td>
<td>{{ log.email_from or '-' }}</td>
<td>{{ log.attachments_count }}</td>
<td>
{% if log.get('beleg_type', 'eingang') == 'ausgang' %}
<span class="badge badge-warning">Ausgang</span>
{% else %}
<span class="badge badge-info">Eingang</span>
{% endif %}
</td>
<td>
{% if log.status == 'success' %}
<span class="badge badge-success">OK</span>
@ -343,8 +410,11 @@ async function testEmail() {
const resp = await fetch('/api/test-email', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.success) {
const addr = document.getElementById('import_email').value;
showAlert('Test-Email erfolgreich an ' + addr + ' gesendet! Einstellungen gespeichert.', 'success');
const eingang = document.getElementById('import_email_eingang').value;
const ausgang = document.getElementById('import_email_ausgang').value;
let targets = eingang;
if (ausgang) targets += ' + ' + ausgang;
showAlert('Test-Email erfolgreich an ' + targets + ' gesendet! Einstellungen gespeichert.', 'success');
} else {
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
}
@ -447,8 +517,10 @@ function showFolderModal(targetField) {
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
let html = '<div class="folder-picker-fields">';
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder\')">Eingangsordner: <strong>' + esc(document.getElementById('source_folder').value) + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('processed_folder').value) + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder\')">Eingang Quelle: <strong>' + esc(document.getElementById('source_folder').value) + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder\')">Eingang Verarbeitet: <strong>' + esc(document.getElementById('processed_folder').value) + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder_ausgang' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder_ausgang\')">Ausgang Quelle: <strong>' + esc(document.getElementById('source_folder_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder_ausgang' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder_ausgang\')">Ausgang Verarbeitet: <strong>' + esc(document.getElementById('processed_folder_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
html += '</div>';
html += '<div class="folder-items">';
if (cachedFolders && cachedFolders.length > 0) {
@ -651,8 +723,10 @@ function showSmbFolderModal(targetField) {
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
let html = '<div class="folder-picker-fields">';
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path\')">Quellordner: <strong>' + esc(document.getElementById('smb_source_path').value || '(Wurzel)') + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path\')">Eingang Quelle: <strong>' + esc(document.getElementById('smb_source_path').value || '(Wurzel)') + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path\')">Eingang Verarbeitet: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path_ausgang' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path_ausgang\')">Ausgang Quelle: <strong>' + esc(document.getElementById('smb_source_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path_ausgang' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path_ausgang\')">Ausgang Verarbeitet: <strong>' + esc(document.getElementById('smb_processed_path_ausgang').value || '(nicht gesetzt)') + '</strong></button>';
html += '</div>';
html += '<div class="folder-items">';

View File

@ -9,4 +9,5 @@ services:
environment:
- DB_PATH=/data/belegimport.db
- TZ=Europe/Berlin
- LOG_LEVEL=DEBUG
restart: unless-stopped

View File

@ -12,3 +12,5 @@ PyMuPDF==1.25.3
qrcode==8.0
sse-starlette==2.2.1
smbprotocol==1.14.0
playwright==1.49.1
playwright-stealth==2.0.2