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 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 WORKDIR /app
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install chromium
COPY app/ ./app/ COPY app/ ./app/
RUN mkdir -p /data/uploads RUN mkdir -p /data/uploads /data/amazon_session
EXPOSE 8000 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 from cryptography.fernet import Fernet
DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db") DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db")
SCHEMA_VERSION = 2 SCHEMA_VERSION = 8
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_fernet = None _fernet = None
ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password"} ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password", "amazon_password"}
DEFAULT_SETTINGS = { DEFAULT_SETTINGS = {
"imap_server": "", "imap_server": "",
@ -24,8 +24,12 @@ DEFAULT_SETTINGS = {
"smtp_username": "", "smtp_username": "",
"smtp_password": "", "smtp_password": "",
"import_email": "", "import_email": "",
"import_email_eingang": "",
"import_email_ausgang": "",
"source_folder": "Rechnungen", "source_folder": "Rechnungen",
"processed_folder": "Rechnungen/Verarbeitet", "processed_folder": "Rechnungen/Verarbeitet",
"source_folder_ausgang": "",
"processed_folder_ausgang": "",
"interval_minutes": "5", "interval_minutes": "5",
"scheduler_enabled": "false", "scheduler_enabled": "false",
"fetch_since_date": "", "fetch_since_date": "",
@ -39,7 +43,18 @@ DEFAULT_SETTINGS = {
"smb_share": "", "smb_share": "",
"smb_source_path": "", "smb_source_path": "",
"smb_processed_path": "Verarbeitet", "smb_processed_path": "Verarbeitet",
"smb_source_path_ausgang": "",
"smb_processed_path_ausgang": "",
"smb_mode": "forward", "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, email_from TEXT,
attachments_count INTEGER DEFAULT 0, attachments_count INTEGER DEFAULT 0,
status TEXT NOT NULL, 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() await db.commit()
@ -150,10 +168,72 @@ async def _run_migrations(db: aiosqlite.Connection, current_version: int):
await db.commit() await db.commit()
await _set_schema_version(db, 2) await _set_schema_version(db, 2)
# --- Future migrations go here --- if current_version < 3:
# if current_version < 3: logger.info("Migration v3: Amazon-Plattform hinzugefügt")
# logger.info("Migration v3: ...") await db.execute("""
# await _set_schema_version(db, 3) 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() await db.commit()
@ -176,7 +256,18 @@ async def init_db():
email_from TEXT, email_from TEXT,
attachments_count INTEGER DEFAULT 0, attachments_count INTEGER DEFAULT 0,
status TEXT NOT NULL, 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() await db.commit()
@ -235,19 +326,29 @@ async def save_settings(data: dict):
await db.commit() 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( async def add_log_entry(
email_subject: str, email_subject: str,
email_from: str, email_from: str,
attachments_count: int, attachments_count: int,
status: str, status: str,
error_message: str = "", error_message: str = "",
sent_to: str = "",
smtp_log: str = "",
beleg_type: str = "eingang",
): ):
async with aiosqlite.connect(DB_PATH) as db: async with aiosqlite.connect(DB_PATH) as db:
await db.execute( await db.execute(
"""INSERT INTO processing_log """INSERT INTO processing_log
(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 (?, ?, ?, ?, ?)""", 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),
) )
await db.commit() await db.commit()
@ -260,3 +361,46 @@ async def get_log_entries(limit: int = 100) -> list[dict]:
) )
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [dict(row) for row in rows] 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 from email import encoders
import logging 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__) 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: def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4:
server = settings["imap_server"] server = settings["imap_server"]
port = int(settings.get("imap_port", 993)) 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() conn.expunge()
async def process_mailbox() -> dict: async def _process_folder(
settings = await get_settings() imap_conn, smtp_conn, settings: dict,
source_folder: str, processed_folder: str,
if not settings.get("imap_server") or not settings.get("import_email"): import_email: str, beleg_type: str, fetch_since: str,
logger.warning("IMAP oder Import-Email nicht konfiguriert") ) -> dict:
return {"processed": 0, "skipped": 0, "errors": 0, "error": "Nicht konfiguriert"} """Process one IMAP folder pair. Returns counts dict."""
source_folder = settings.get("source_folder", "INBOX")
processed_folder = settings.get("processed_folder", "INBOX/Verarbeitet")
import_email = settings["import_email"]
smtp_from = settings.get("smtp_username", "") smtp_from = settings.get("smtp_username", "")
processed = 0 processed = 0
skipped = 0 skipped = 0
errors = 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 imap_conn = None
smtp_conn = None smtp_conn = None
@ -140,92 +255,31 @@ async def process_mailbox() -> dict:
imap_conn = _connect_imap(settings) imap_conn = _connect_imap(settings)
smtp_conn = _connect_smtp(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}"') # Ausgangsbelege (optional)
if status != "OK": import_email_ausgang = get_import_email(settings, "ausgang")
raise Exception(f"Ordner '{source_folder}' konnte nicht geöffnet werden") source_ausgang = settings.get("source_folder_ausgang", "")
processed_ausgang = settings.get("processed_folder_ausgang", "")
# Build IMAP search criteria if import_email_ausgang and source_ausgang:
search_criteria = "ALL" if not processed_ausgang:
fetch_since = settings.get("fetch_since_date", "") processed_ausgang = source_ausgang + "/Verarbeitet"
if fetch_since: result = await _process_folder(
try: imap_conn, smtp_conn, settings,
from datetime import datetime source_ausgang, processed_ausgang,
dt = datetime.strptime(fetch_since, "%Y-%m-%d") import_email_ausgang, "ausgang", fetch_since,
imap_date = dt.strftime("%d-%b-%Y") )
search_criteria = f'(SINCE {imap_date})' for k in total:
except ValueError: total[k] += result[k]
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
except Exception as e: except Exception as e:
logger.error(f"Verbindungsfehler: {e}") logger.error(f"Verbindungsfehler: {e}")
@ -236,7 +290,7 @@ async def process_mailbox() -> dict:
status="error", status="error",
error_message=f"Verbindungsfehler: {e}", 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: finally:
if imap_conn: if imap_conn:
@ -250,36 +304,52 @@ async def process_mailbox() -> dict:
except Exception: except Exception:
pass pass
logger.info( logger.info(f"Fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
f"Fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler" return total
)
return {"processed": processed, "skipped": skipped, "errors": errors}
async def send_test_email() -> dict: async def send_test_email() -> dict:
settings = await get_settings() settings = await get_settings()
if not settings.get("smtp_server") or not settings.get("import_email"): import_email_eingang = get_import_email(settings, "eingang")
return {"success": False, "error": "SMTP oder Import-Email nicht konfiguriert"} 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: try:
smtp_conn = _connect_smtp(settings) smtp_conn = _connect_smtp(settings)
smtp_logs = []
# Test Eingangsbelege
msg = MIMEMultipart() msg = MIMEMultipart()
msg["From"] = settings["smtp_username"] msg["From"] = settings["smtp_username"]
msg["To"] = settings["import_email"] msg["To"] = import_email_eingang
msg["Subject"] = "Belegimport - Test-Email" msg["Subject"] = "Belegimport - Test-Email (Eingangsbelege)"
msg.attach(MIMEText( msg.attach(MIMEText(
"Dies ist eine Test-Email vom Belegimport Service.\n" "Dies ist eine Test-Email vom Belegimport Service.\n"
"Wenn Sie diese Email erhalten, funktioniert die SMTP-Verbindung.", "Ziel: Eingangsbelege",
"plain", "plain", "utf-8",
"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() smtp_conn.quit()
return {"success": True, "smtp_log": "\n".join(smtp_logs)}
return {"success": True}
except Exception as e: except Exception as e:
logger.error(f"Test-Email fehlgeschlagen: {e}") logger.error(f"Test-Email fehlgeschlagen: {e}")

View File

@ -6,19 +6,34 @@ from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request, Form, UploadFile 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sse_starlette.sse import EventSourceResponse 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.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.scheduler import start_scheduler, configure_job, get_scheduler_status
from app.scanner import process_scanned_pdf, generate_separator_pdf, UPLOAD_DIR 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.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( 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", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -44,6 +59,11 @@ templates = Jinja2Templates(directory="app/templates")
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def index(request: Request): 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() settings = await get_settings()
logs = await get_log_entries(limit=20) logs = await get_log_entries(limit=20)
status = get_scheduler_status() status = get_scheduler_status()
@ -74,8 +94,12 @@ async def _save_form_settings(request: Request) -> dict:
"smtp_username": form.get("smtp_username", ""), "smtp_username": form.get("smtp_username", ""),
"smtp_password": form.get("smtp_password") or current.get("smtp_password", ""), "smtp_password": form.get("smtp_password") or current.get("smtp_password", ""),
"import_email": form.get("import_email", ""), "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"), "source_folder": form.get("source_folder", "Rechnungen"),
"processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"), "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"), "interval_minutes": form.get("interval_minutes", "5"),
"scheduler_enabled": form.get("scheduler_enabled", "false"), "scheduler_enabled": form.get("scheduler_enabled", "false"),
"fetch_since_date": form.get("fetch_since_date", ""), "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_share": form.get("smb_share", ""),
"smb_source_path": form.get("smb_source_path", ""), "smb_source_path": form.get("smb_source_path", ""),
"smb_processed_path": form.get("smb_processed_path", "Verarbeitet"), "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"), "smb_mode": form.get("smb_mode", "forward"),
# Debug
"debug_save_amazon_pdfs": form.get("debug_save_amazon_pdfs", "false"),
} }
await save_settings(data) 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") @app.get("/api/status")
async def api_status(): async def api_status():
return get_scheduler_status() return get_scheduler_status()
@ -247,6 +281,7 @@ async def scan_upload_chunk(
async def scan_process(request: Request): async def scan_process(request: Request):
body = await request.json() body = await request.json()
upload_id = body.get("upload_id", "") upload_id = body.get("upload_id", "")
beleg_type = body.get("beleg_type", "eingang")
try: try:
uuid.UUID(upload_id) uuid.UUID(upload_id)
@ -269,7 +304,7 @@ async def scan_process(request: Request):
# Process in background task # Process in background task
async def _process(): async def _process():
try: 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({ _scan_progress.setdefault(upload_id, []).append({
"stage": "done", "result": result "stage": "done", "result": result
}) })
@ -325,3 +360,113 @@ async def separator_pdf():
media_type="application/pdf", media_type="application/pdf",
headers={"Content-Disposition": "attachment; filename=Trennseite.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 import qrcode
from qrcode.constants import ERROR_CORRECT_H from qrcode.constants import ERROR_CORRECT_H
from app.database import get_settings, add_log_entry from app.database import get_settings, add_log_entry, get_import_email
from app.mail_processor import _connect_smtp, _build_forward_email from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -84,11 +84,12 @@ def split_pdf(pdf_path: str, separator_pages: list[int]) -> list[bytes]:
return documents 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.""" """Full pipeline: detect separators, split, send each document via email."""
settings = await get_settings() 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} 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) # 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" filename = f"Scan_Dokument_{i + 1}.pdf"
msg = _build_forward_email( msg = _build_forward_email(
from_addr=settings["smtp_username"], 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_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}",
original_from="Scan-Upload", original_from="Scan-Upload",
attachments=[(filename, doc_bytes)], attachments=[(filename, doc_bytes)],
) )
smtp_conn.send_message(msg) smtp_log = _send_with_log(smtp_conn, msg)
sent += 1 sent += 1
await add_log_entry( await add_log_entry(
@ -148,6 +149,9 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
email_from="Scan-Upload", email_from="Scan-Upload",
attachments_count=1, attachments_count=1,
status="success", status="success",
sent_to=import_email,
smtp_log=smtp_log,
beleg_type=beleg_type,
) )
except Exception as e: except Exception as e:
@ -159,6 +163,8 @@ async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict:
attachments_count=1, attachments_count=1,
status="error", status="error",
error_message=str(e), error_message=str(e),
sent_to=import_email,
beleg_type=beleg_type,
) )
except Exception as e: 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.mail_processor import process_mailbox
from app.smb_processor import process_smb_share from app.smb_processor import process_smb_share
from app.amazon_processor import process_amazon
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -21,6 +22,7 @@ async def _run_processor():
return return
_is_processing = True _is_processing = True
try: try:
# Email and SMB first - these are fast and must not be blocked by Amazon
logger.info("Starte automatische Email-Verarbeitung...") logger.info("Starte automatische Email-Verarbeitung...")
result = await process_mailbox() result = await process_mailbox()
logger.info(f"Email-Verarbeitung abgeschlossen: {result}") logger.info(f"Email-Verarbeitung abgeschlossen: {result}")
@ -28,6 +30,16 @@ async def _run_processor():
logger.info("Starte automatische SMB-Verarbeitung...") logger.info("Starte automatische SMB-Verarbeitung...")
smb_result = await process_smb_share() smb_result = await process_smb_share()
logger.info(f"SMB-Verarbeitung abgeschlossen: {smb_result}") 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: except Exception as e:
logger.error(f"Fehler bei automatischer Verarbeitung: {e}") logger.error(f"Fehler bei automatischer Verarbeitung: {e}")
finally: finally:

View File

@ -5,8 +5,8 @@ import tempfile
import smbclient import smbclient
from app.database import get_settings, add_log_entry from app.database import get_settings, add_log_entry, get_import_email
from app.mail_processor import _connect_smtp, _build_forward_email from app.mail_processor import _connect_smtp, _build_forward_email, _send_with_log
from app.scanner import detect_separator_pages, split_pdf from app.scanner import detect_separator_pages, split_pdf
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -114,6 +114,119 @@ def _list_smb_folders_recursive(
return folders 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: async def process_smb_share() -> dict:
"""Process PDF files from SMB share - main pipeline.""" """Process PDF files from SMB share - main pipeline."""
settings = await get_settings() 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"): if not settings.get("smb_server") or not settings.get("smb_share"):
return {"processed": 0, "skipped": 0, "errors": 0, "error": "SMB nicht konfiguriert"} 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"} return {"processed": 0, "skipped": 0, "errors": 0, "error": "Import-Email nicht konfiguriert"}
mode = settings.get("smb_mode", "forward") mode = settings.get("smb_mode", "forward")
smtp_from = settings.get("smtp_username", "") total = {"processed": 0, "skipped": 0, "errors": 0}
import_email = settings["import_email"]
processed = 0
skipped = 0
errors = 0
smtp_conn = None smtp_conn = None
try: try:
base_path = await asyncio.to_thread(_smb_register_session, settings) 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) smtp_conn = _connect_smtp(settings)
for filename in pdf_files: # Eingangsbelege
file_path = _smb_unc_path(source_path, filename) source = settings.get("smb_source_path", "")
try: processed_rel = settings.get("smb_processed_path", "Verarbeitet")
pdf_data = await asyncio.to_thread(_read_smb_file, file_path) 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": # Ausgangsbelege (optional)
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: import_email_ausgang = get_import_email(settings, "ausgang")
tmp.write(pdf_data) source_ausgang = settings.get("smb_source_path_ausgang", "")
tmp_path = tmp.name processed_ausgang = settings.get("smb_processed_path_ausgang", "")
try: if import_email_ausgang and source_ausgang:
separator_pages = await asyncio.to_thread( if not processed_ausgang:
detect_separator_pages, tmp_path, None processed_ausgang = source_ausgang + "/Verarbeitet"
) result = await _process_smb_folder(
documents = await asyncio.to_thread( smtp_conn, settings, base_path,
split_pdf, tmp_path, separator_pages source_ausgang, processed_ausgang,
) import_email_ausgang, "ausgang", mode,
finally: )
os.unlink(tmp_path) for k in total:
total[k] += result[k]
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
except Exception as e: except Exception as e:
logger.error(f"SMB-Verbindungsfehler: {e}") logger.error(f"SMB-Verbindungsfehler: {e}")
@ -244,7 +287,7 @@ async def process_smb_share() -> dict:
) )
except Exception: except Exception:
pass pass
return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)} return {**total, "errors": total["errors"] + 1, "error": str(e)}
finally: finally:
if smtp_conn: if smtp_conn:
@ -253,8 +296,8 @@ async def process_smb_share() -> dict:
except Exception: except Exception:
pass pass
logger.info(f"SMB fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler") logger.info(f"SMB fertig: {total['processed']} verarbeitet, {total['skipped']} übersprungen, {total['errors']} Fehler")
return {"processed": processed, "skipped": skipped, "errors": errors} return total
async def test_smb_connection() -> dict: async def test_smb_connection() -> dict:

View File

@ -200,6 +200,11 @@ main {
color: #856404; color: #856404;
} }
.badge-info {
background: #d1ecf1;
color: #0c5460;
}
.badge-inactive { .badge-inactive {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
@ -236,6 +241,16 @@ main {
border: 1px solid #bee5eb; 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 { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;

View File

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

View File

@ -1,21 +1,30 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "log" %} {% set active_page = "log" %}
{% set main_class = "main-wide" %}
{% set message = None %} {% set message = None %}
{% block content %} {% block content %}
<div class="card"> <div class="card card-table">
<h2>Verarbeitungslog</h2> <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 %} {% if logs %}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>Zeitpunkt</th> <th>Zeitpunkt</th>
<th>Art</th>
<th>Betreff</th> <th>Betreff</th>
<th>Absender</th> <th>Absender</th>
<th>Anhänge</th> <th>Anhänge</th>
<th>Gesendet an</th>
<th>Status</th> <th>Status</th>
<th>Fehlermeldung</th> <th>Fehlermeldung</th>
<th>SMTP</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -23,9 +32,17 @@
<tr class="{% if log.status == 'error' %}row-error{% endif %}"> <tr class="{% if log.status == 'error' %}row-error{% endif %}">
<td>{{ log.id }}</td> <td>{{ log.id }}</td>
<td>{{ log.timestamp }}</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_subject or '-' }}</td>
<td>{{ log.email_from or '-' }}</td> <td>{{ log.email_from or '-' }}</td>
<td>{{ log.attachments_count }}</td> <td>{{ log.attachments_count }}</td>
<td>{{ log.sent_to or '-' }}</td>
<td> <td>
{% if log.status == 'success' %} {% if log.status == 'success' %}
<span class="badge badge-success">OK</span> <span class="badge badge-success">OK</span>
@ -34,6 +51,12 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ log.error_message or '-' }}</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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -42,4 +65,54 @@
<p class="text-muted">Noch keine Einträge vorhanden.</p> <p class="text-muted">Noch keine Einträge vorhanden.</p>
{% endif %} {% endif %}
</div> </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 %} {% 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. Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente gesendet.
</p> </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 --> <!-- Upload Zone -->
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()"> <div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">&#128196;</div> <div class="upload-icon">&#128196;</div>
@ -175,7 +182,7 @@ async function startProcessing(uploadId) {
const resp = await fetch('/api/scan-process', { const resp = await fetch('/api/scan-process', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (!resp.ok) {

View File

@ -77,13 +77,14 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Import & Ordner</h2> <h2>Import - Eingangsbelege</h2>
<div class="form-grid"> <div class="form-grid">
<div class="form-group form-group-wide"> <div class="form-group form-group-wide">
<label for="import_email">Import-Emailadresse</label> <label for="import_email_eingang">Import-Email Eingangsbelege</label>
<input type="email" id="import_email" name="import_email" <input type="email" id="import_email_eingang" name="import_email_eingang"
value="{{ settings.get('import_email', '') }}" placeholder="import@example.com"> value="{{ settings.get('import_email_eingang', '') or settings.get('import_email', '') }}" placeholder="eingang@buchhaltung.example.com">
</div> </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"> <div class="form-group">
<label for="source_folder">Eingangsordner (IMAP)</label> <label for="source_folder">Eingangsordner (IMAP)</label>
<div class="input-with-btn"> <div class="input-with-btn">
@ -101,6 +102,34 @@
</div> </div>
</div> </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"> <div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="testEmail()"> <button type="button" class="btn btn-secondary" onclick="testEmail()">
<span class="btn-text">Test-Email senden</span> <span class="btn-text">Test-Email senden</span>
@ -158,7 +187,7 @@
value="{{ settings.get('smb_share', '') }}" placeholder="Scans"> value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="smb_source_path">Quellordner</label> <label for="smb_source_path">Quellordner Eingangsbelege</label>
<div class="input-with-btn"> <div class="input-with-btn">
<input type="text" id="smb_source_path" name="smb_source_path" <input type="text" id="smb_source_path" name="smb_source_path"
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)"> value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
@ -166,13 +195,29 @@
</div> </div>
</div> </div>
<div class="form-group"> <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"> <div class="input-with-btn">
<input type="text" id="smb_processed_path" name="smb_processed_path" <input type="text" id="smb_processed_path" name="smb_processed_path"
value="{{ settings.get('smb_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet"> 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> <button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path')" title="Ordner auswählen">&#128193;</button>
</div> </div>
</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>
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="testSmb()"> <button type="button" class="btn btn-secondary" onclick="testSmb()">
@ -206,6 +251,20 @@
</div> </div>
</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"> <div class="form-actions-main">
<button type="submit" class="btn btn-primary">Einstellungen speichern</button> <button type="submit" class="btn btn-primary">Einstellungen speichern</button>
<button type="button" class="btn btn-success" onclick="manualProcess()"> <button type="button" class="btn btn-success" onclick="manualProcess()">
@ -228,6 +287,7 @@
<th>Betreff</th> <th>Betreff</th>
<th>Absender</th> <th>Absender</th>
<th>Anhänge</th> <th>Anhänge</th>
<th>Art</th>
<th>Status</th> <th>Status</th>
</tr> </tr>
</thead> </thead>
@ -238,6 +298,13 @@
<td>{{ log.email_subject or '-' }}</td> <td>{{ log.email_subject or '-' }}</td>
<td>{{ log.email_from or '-' }}</td> <td>{{ log.email_from or '-' }}</td>
<td>{{ log.attachments_count }}</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> <td>
{% if log.status == 'success' %} {% if log.status == 'success' %}
<span class="badge badge-success">OK</span> <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 resp = await fetch('/api/test-email', { method: 'POST', body: getFormData() });
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
const addr = document.getElementById('import_email').value; const eingang = document.getElementById('import_email_eingang').value;
showAlert('Test-Email erfolgreich an ' + addr + ' gesendet! Einstellungen gespeichert.', 'success'); 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 { } else {
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error'); showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
} }
@ -447,8 +517,10 @@ function showFolderModal(targetField) {
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : ''; const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
let html = '<div class="folder-picker-fields">'; 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 === '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\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('processed_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>';
html += '<div class="folder-items">'; html += '<div class="folder-items">';
if (cachedFolders && cachedFolders.length > 0) { if (cachedFolders && cachedFolders.length > 0) {
@ -651,8 +723,10 @@ function showSmbFolderModal(targetField) {
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : ''; const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
let html = '<div class="folder-picker-fields">'; 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_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\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</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>';
html += '<div class="folder-items">'; html += '<div class="folder-items">';

View File

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

View File

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