commit cb34aa00af651c144432e9fad2c6f65f343f4430 Author: Stefan Hacker Date: Fri Mar 6 08:20:07 2026 +0100 first commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c3a24c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends libzbar0 && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +RUN mkdir -p /data/uploads + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..49d16d3 --- /dev/null +++ b/app/database.py @@ -0,0 +1,170 @@ +import os +import aiosqlite +from cryptography.fernet import Fernet + +DB_PATH = os.environ.get("DB_PATH", "/data/belegimport.db") + +_fernet = None + +ENCRYPTED_KEYS = {"imap_password", "smtp_password", "smb_password"} + +DEFAULT_SETTINGS = { + "imap_server": "", + "imap_port": "993", + "imap_ssl": "true", + "imap_username": "", + "imap_password": "", + "smtp_server": "", + "smtp_port": "587", + "smtp_ssl": "starttls", + "smtp_username": "", + "smtp_password": "", + "lexoffice_email": "", + "source_folder": "Rechnungen", + "processed_folder": "Rechnungen/Verarbeitet", + "interval_minutes": "5", + "scheduler_enabled": "false", + "fetch_since_date": "", + # SMB + "smb_enabled": "false", + "smb_server": "", + "smb_port": "445", + "smb_username": "", + "smb_password": "", + "smb_domain": "", + "smb_share": "", + "smb_source_path": "", + "smb_processed_path": "Verarbeitet", + "smb_mode": "forward", +} + + +async def _get_fernet() -> Fernet: + global _fernet + if _fernet is not None: + return _fernet + + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + "SELECT value FROM settings WHERE key = 'encryption_key'" + ) + row = await cursor.fetchone() + if row: + key = row[0].encode() + else: + key = Fernet.generate_key() + await db.execute( + "INSERT INTO settings (key, value) VALUES ('encryption_key', ?)", + (key.decode(),), + ) + await db.commit() + + _fernet = Fernet(key) + return _fernet + + +def _encrypt(fernet: Fernet, value: str) -> str: + if not value: + return "" + return fernet.encrypt(value.encode()).decode() + + +def _decrypt(fernet: Fernet, value: str) -> str: + if not value: + return "" + try: + return fernet.decrypt(value.encode()).decode() + except Exception: + return "" + + +async def init_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL DEFAULT '' + ) + """) + await db.execute(""" + CREATE TABLE IF NOT EXISTS processing_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + email_subject TEXT, + email_from TEXT, + attachments_count INTEGER DEFAULT 0, + status TEXT NOT NULL, + error_message TEXT + ) + """) + await db.commit() + + # Insert default settings if not present + for key, value in DEFAULT_SETTINGS.items(): + await db.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + (key, value), + ) + await db.commit() + + # Ensure encryption key exists + await _get_fernet() + + +async def get_settings() -> dict: + fernet = await _get_fernet() + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + "SELECT key, value FROM settings WHERE key != 'encryption_key'" + ) + rows = await cursor.fetchall() + + settings = {} + for key, value in rows: + if key in ENCRYPTED_KEYS: + settings[key] = _decrypt(fernet, value) + else: + settings[key] = value + return settings + + +async def save_settings(data: dict): + fernet = await _get_fernet() + async with aiosqlite.connect(DB_PATH) as db: + for key, value in data.items(): + if key == "encryption_key": + continue + store_value = _encrypt(fernet, value) if key in ENCRYPTED_KEYS else value + await db.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + (key, store_value), + ) + await db.commit() + + +async def add_log_entry( + email_subject: str, + email_from: str, + attachments_count: int, + status: str, + error_message: str = "", +): + 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), + ) + await db.commit() + + +async def get_log_entries(limit: int = 100) -> list[dict]: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM processing_log ORDER BY id DESC LIMIT ?", (limit,) + ) + rows = await cursor.fetchall() + return [dict(row) for row in rows] diff --git a/app/mail_processor.py b/app/mail_processor.py new file mode 100644 index 0000000..8d85860 --- /dev/null +++ b/app/mail_processor.py @@ -0,0 +1,346 @@ +import imaplib +import smtplib +import ssl +import email +from email import policy +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email.mime.text import MIMEText +from email import encoders +import logging + +from app.database import get_settings, add_log_entry + +logger = logging.getLogger(__name__) + + +def _connect_imap(settings: dict) -> imaplib.IMAP4_SSL | imaplib.IMAP4: + server = settings["imap_server"] + port = int(settings.get("imap_port", 993)) + use_ssl = settings.get("imap_ssl", "true") == "true" + + if use_ssl: + ctx = ssl.create_default_context() + conn = imaplib.IMAP4_SSL(server, port, ssl_context=ctx) + else: + conn = imaplib.IMAP4(server, port) + + conn.login(settings["imap_username"], settings["imap_password"]) + return conn + + +def _connect_smtp(settings: dict) -> smtplib.SMTP | smtplib.SMTP_SSL: + server = settings["smtp_server"] + port = int(settings.get("smtp_port", 587)) + mode = settings.get("smtp_ssl", "starttls") + + if mode == "ssl": + ctx = ssl.create_default_context() + conn = smtplib.SMTP_SSL(server, port, context=ctx) + else: + conn = smtplib.SMTP(server, port) + if mode == "starttls": + ctx = ssl.create_default_context() + conn.starttls(context=ctx) + + conn.login(settings["smtp_username"], settings["smtp_password"]) + return conn + + +def _extract_attachments(msg: email.message.Message) -> list[tuple[str, bytes]]: + attachments = [] + for part in msg.walk(): + content_disposition = part.get("Content-Disposition", "") + if "attachment" not in content_disposition and "inline" not in content_disposition: + continue + filename = part.get_filename() + if not filename: + continue + # Decode filename if encoded + decoded_parts = email.header.decode_header(filename) + filename = "" + for data, charset in decoded_parts: + if isinstance(data, bytes): + filename += data.decode(charset or "utf-8", errors="replace") + else: + filename += data + if not filename.lower().endswith(".pdf"): + continue + payload = part.get_payload(decode=True) + if payload: + attachments.append((filename, payload)) + return attachments + + +def _build_forward_email( + from_addr: str, + to_addr: str, + original_subject: str, + original_from: str, + attachments: list[tuple[str, bytes]], +) -> MIMEMultipart: + msg = MIMEMultipart() + msg["From"] = from_addr + msg["To"] = to_addr + msg["Subject"] = f"Belegimport: {original_subject}" + + body = ( + f"Automatisch weitergeleitet von LexOffice Belegimport.\n" + f"Original-Absender: {original_from}\n" + f"Original-Betreff: {original_subject}\n" + f"Anzahl Anhänge: {len(attachments)}" + ) + msg.attach(MIMEText(body, "plain", "utf-8")) + + for filename, data in attachments: + part = MIMEBase("application", "octet-stream") + part.set_payload(data) + encoders.encode_base64(part) + part.add_header("Content-Disposition", "attachment", filename=filename) + msg.attach(part) + + return msg + + +def _ensure_folder_exists(conn: imaplib.IMAP4, folder: str): + status, _ = conn.select(f'"{folder}"') + if status != "OK": + conn.create(f'"{folder}"') + conn.subscribe(f'"{folder}"') + # Go back to INBOX to not stay in the folder + conn.select("INBOX") + + +def _move_email(conn: imaplib.IMAP4, msg_uid: bytes, dest_folder: str): + result = conn.uid("COPY", msg_uid, f'"{dest_folder}"') + if result[0] == "OK": + conn.uid("STORE", msg_uid, "+FLAGS", "(\\Deleted)") + conn.expunge() + + +async def process_mailbox() -> dict: + settings = await get_settings() + + if not settings.get("imap_server") or not settings.get("lexoffice_email"): + logger.warning("IMAP oder LexOffice-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") + lexoffice_email = settings["lexoffice_email"] + smtp_from = settings.get("smtp_username", "") + + processed = 0 + skipped = 0 + errors = 0 + imap_conn = None + smtp_conn = None + + try: + imap_conn = _connect_imap(settings) + smtp_conn = _connect_smtp(settings) + + _ensure_folder_exists(imap_conn, processed_folder) + + 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=lexoffice_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: + logger.error(f"Verbindungsfehler: {e}") + await add_log_entry( + email_subject="", + email_from="", + attachments_count=0, + status="error", + error_message=f"Verbindungsfehler: {e}", + ) + return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)} + + finally: + if imap_conn: + try: + imap_conn.logout() + except Exception: + pass + if smtp_conn: + try: + smtp_conn.quit() + except Exception: + pass + + logger.info( + f"Fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler" + ) + return {"processed": processed, "skipped": skipped, "errors": errors} + + +async def send_test_email() -> dict: + settings = await get_settings() + + if not settings.get("smtp_server") or not settings.get("lexoffice_email"): + return {"success": False, "error": "SMTP oder LexOffice-Email nicht konfiguriert"} + + try: + smtp_conn = _connect_smtp(settings) + + msg = MIMEMultipart() + msg["From"] = settings["smtp_username"] + msg["To"] = settings["lexoffice_email"] + msg["Subject"] = "LexOffice Belegimport - Test-Email" + msg.attach(MIMEText( + "Dies ist eine Test-Email vom LexOffice Belegimport Service.\n" + "Wenn Sie diese Email erhalten, funktioniert die SMTP-Verbindung.", + "plain", + "utf-8", + )) + + smtp_conn.send_message(msg) + smtp_conn.quit() + + return {"success": True} + + except Exception as e: + logger.error(f"Test-Email fehlgeschlagen: {e}") + return {"success": False, "error": str(e)} + + +async def create_imap_folder(folder_name: str) -> dict: + settings = await get_settings() + + if not settings.get("imap_server"): + return {"success": False, "error": "IMAP nicht konfiguriert"} + + if not folder_name or not folder_name.strip(): + return {"success": False, "error": "Ordnername darf nicht leer sein"} + + folder_name = folder_name.strip() + + try: + conn = _connect_imap(settings) + status, response = conn.create(f'"{folder_name}"') + if status == "OK": + conn.subscribe(f'"{folder_name}"') + conn.logout() + + if status == "OK": + return {"success": True} + else: + msg = response[0].decode() if response and isinstance(response[0], bytes) else str(response) + return {"success": False, "error": msg} + + except Exception as e: + logger.error(f"Ordner erstellen fehlgeschlagen: {e}") + return {"success": False, "error": str(e)} + + +async def test_imap_connection() -> dict: + settings = await get_settings() + + if not settings.get("imap_server"): + return {"success": False, "error": "IMAP nicht konfiguriert", "folders": []} + + try: + conn = _connect_imap(settings) + status, folder_data = conn.list() + folders = [] + delimiter = "." + if status == "OK": + for item in folder_data: + decoded = item.decode() if isinstance(item, bytes) else item + # Parse IMAP LIST response: (\\flags) "delimiter" "name" + parts = decoded.split('"') + if len(parts) >= 4: + # parts[1] is the delimiter, parts[3] is the folder name + if not delimiter or delimiter == ".": + delimiter = parts[1] + folders.append(parts[-2]) + elif len(parts) >= 2: + folders.append(parts[-1].strip()) + conn.logout() + return {"success": True, "folders": sorted(folders), "delimiter": delimiter} + + except Exception as e: + logger.error(f"IMAP-Test fehlgeschlagen: {e}") + return {"success": False, "error": str(e), "folders": [], "delimiter": "."} diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..733b4e7 --- /dev/null +++ b/app/main.py @@ -0,0 +1,327 @@ +import asyncio +import logging +import os +import uuid +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.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.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 + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + start_scheduler() + settings = await get_settings() + interval = int(settings.get("interval_minutes", 5)) + enabled = settings.get("scheduler_enabled", "false") == "true" + configure_job(interval, enabled) + logger.info("LexOffice Belegimport gestartet") + yield + logger.info("LexOffice Belegimport beendet") + + +app = FastAPI(title="LexOffice Belegimport", lifespan=lifespan) +app.mount("/static", StaticFiles(directory="app/static"), name="static") +templates = Jinja2Templates(directory="app/templates") + + +@app.get("/", response_class=HTMLResponse) +async def index(request: Request): + settings = await get_settings() + logs = await get_log_entries(limit=20) + status = get_scheduler_status() + return templates.TemplateResponse("settings.html", { + "request": request, + "settings": settings, + "logs": logs, + "status": status, + "message": None, + "message_type": None, + }) + + +async def _save_form_settings(request: Request) -> dict: + """Extract form data and save settings. Returns the saved data dict.""" + form = await request.form() + current = await get_settings() + + data = { + "imap_server": form.get("imap_server", ""), + "imap_port": form.get("imap_port", "993"), + "imap_ssl": form.get("imap_ssl", "true"), + "imap_username": form.get("imap_username", ""), + "imap_password": form.get("imap_password") or current.get("imap_password", ""), + "smtp_server": form.get("smtp_server", ""), + "smtp_port": form.get("smtp_port", "587"), + "smtp_ssl": form.get("smtp_ssl", "starttls"), + "smtp_username": form.get("smtp_username", ""), + "smtp_password": form.get("smtp_password") or current.get("smtp_password", ""), + "lexoffice_email": form.get("lexoffice_email", ""), + "source_folder": form.get("source_folder", "Rechnungen"), + "processed_folder": form.get("processed_folder", "Rechnungen/Verarbeitet"), + "interval_minutes": form.get("interval_minutes", "5"), + "scheduler_enabled": form.get("scheduler_enabled", "false"), + "fetch_since_date": form.get("fetch_since_date", ""), + # SMB + "smb_enabled": form.get("smb_enabled", "false"), + "smb_server": form.get("smb_server", ""), + "smb_port": form.get("smb_port", "445"), + "smb_username": form.get("smb_username", ""), + "smb_password": form.get("smb_password") or current.get("smb_password", ""), + "smb_domain": form.get("smb_domain", ""), + "smb_share": form.get("smb_share", ""), + "smb_source_path": form.get("smb_source_path", ""), + "smb_processed_path": form.get("smb_processed_path", "Verarbeitet"), + "smb_mode": form.get("smb_mode", "forward"), + } + + await save_settings(data) + + interval = int(data["interval_minutes"]) + enabled = data["scheduler_enabled"] == "true" + configure_job(interval, enabled) + + return data + + +@app.post("/settings", response_class=HTMLResponse) +async def save(request: Request): + await _save_form_settings(request) + + settings = await get_settings() + logs = await get_log_entries(limit=20) + status = get_scheduler_status() + return templates.TemplateResponse("settings.html", { + "request": request, + "settings": settings, + "logs": logs, + "status": status, + "message": "Einstellungen gespeichert", + "message_type": "success", + }) + + +@app.post("/api/save-settings") +async def api_save_settings(request: Request): + await _save_form_settings(request) + return JSONResponse({"success": True}) + + +@app.post("/api/test-imap") +async def api_test_imap(request: Request): + # Save settings first, then test + await _save_form_settings(request) + result = await test_imap_connection() + return JSONResponse(result) + + +@app.post("/api/test-email") +async def api_test_email(request: Request): + # Save settings first, then test + await _save_form_settings(request) + result = await send_test_email() + return JSONResponse(result) + + +@app.post("/api/process") +async def api_process(request: Request): + # Save settings first, then process + await _save_form_settings(request) + result = await process_mailbox() + return JSONResponse(result) + + +@app.post("/api/create-folder") +async def api_create_folder(request: Request): + body = await request.json() + folder_name = body.get("folder_name", "") + result = await create_imap_folder(folder_name) + if result["success"]: + # Return updated folder list + folders_result = await test_imap_connection() + result["folders"] = folders_result.get("folders", []) + return JSONResponse(result) + + +@app.post("/api/test-smb") +async def api_test_smb(request: Request): + await _save_form_settings(request) + result = await test_smb_connection() + return JSONResponse(result) + + +@app.post("/api/process-smb") +async def api_process_smb(request: Request): + await _save_form_settings(request) + result = await process_smb_share() + return JSONResponse(result) + + +@app.post("/api/create-smb-folder") +async def api_create_smb_folder(request: Request): + body = await request.json() + folder_name = body.get("folder_name", "") + result = await create_smb_folder(folder_name) + if result["success"]: + folders_result = await list_smb_folders() + result["folders"] = folders_result.get("folders", []) + return JSONResponse(result) + + +@app.get("/log", response_class=HTMLResponse) +async def log_page(request: Request): + logs = await get_log_entries(limit=500) + return templates.TemplateResponse("log.html", { + "request": request, + "logs": logs, + }) + + +@app.get("/api/status") +async def api_status(): + return get_scheduler_status() + + +# --- Scan Upload --- + +# In-memory progress store for SSE +_scan_progress: dict[str, list[dict]] = {} + + +@app.get("/scan", response_class=HTMLResponse) +async def scan_page(request: Request): + return templates.TemplateResponse("scan.html", {"request": request}) + + +@app.post("/api/scan-upload-chunk") +async def scan_upload_chunk( + request: Request, + file: UploadFile = Form(...), + chunk_index: int = Form(...), + total_chunks: int = Form(...), + upload_id: str = Form(...), + filename: str = Form(...), +): + # Validate upload_id is UUID-like to prevent path traversal + try: + uuid.UUID(upload_id) + except ValueError: + return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400) + + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + part_path = UPLOAD_DIR / f"{upload_id}.part" + + # Append chunk to file + mode = "ab" if chunk_index > 0 else "wb" + content = await file.read() + with open(part_path, mode) as f: + f.write(content) + + # If last chunk, rename to .pdf + if chunk_index >= total_chunks - 1: + pdf_path = UPLOAD_DIR / f"{upload_id}.pdf" + part_path.rename(pdf_path) + return JSONResponse({"status": "complete", "upload_id": upload_id}) + + return JSONResponse({"status": "ok", "chunk": chunk_index}) + + +@app.post("/api/scan-process") +async def scan_process(request: Request): + body = await request.json() + upload_id = body.get("upload_id", "") + + try: + uuid.UUID(upload_id) + except ValueError: + return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400) + + pdf_path = UPLOAD_DIR / f"{upload_id}.pdf" + if not pdf_path.exists(): + return JSONResponse({"error": "Upload nicht gefunden"}, status_code=404) + + # Initialize progress + _scan_progress[upload_id] = [] + + def progress_callback(stage, current, total, message=None): + entry = {"stage": stage, "current": current, "total": total} + if message: + entry["message"] = message + _scan_progress.setdefault(upload_id, []).append(entry) + + # Process in background task + async def _process(): + try: + result = await process_scanned_pdf(str(pdf_path), progress_callback) + _scan_progress.setdefault(upload_id, []).append({ + "stage": "done", "result": result + }) + except Exception as e: + logger.error(f"Scan-Verarbeitung fehlgeschlagen: {e}") + _scan_progress.setdefault(upload_id, []).append({ + "stage": "error", "message": str(e) + }) + finally: + # Cleanup uploaded file + try: + pdf_path.unlink(missing_ok=True) + except Exception: + pass + + asyncio.create_task(_process()) + return JSONResponse({"status": "processing", "upload_id": upload_id}) + + +@app.get("/api/scan-status/{upload_id}") +async def scan_status_sse(upload_id: str): + try: + uuid.UUID(upload_id) + except ValueError: + return JSONResponse({"error": "Ungültige Upload-ID"}, status_code=400) + + async def event_generator(): + seen = 0 + while True: + entries = _scan_progress.get(upload_id, []) + while seen < len(entries): + entry = entries[seen] + seen += 1 + + import json + yield {"event": entry.get("stage", "status"), "data": json.dumps(entry)} + + if entry.get("stage") in ("done", "error"): + # Cleanup progress data + _scan_progress.pop(upload_id, None) + return + + await asyncio.sleep(0.3) + + return EventSourceResponse(event_generator()) + + +@app.get("/api/separator-pdf") +async def separator_pdf(): + pdf_bytes = generate_separator_pdf() + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": "attachment; filename=Trennseite_LexOffice.pdf"}, + ) diff --git a/app/scanner.py b/app/scanner.py new file mode 100644 index 0000000..db7413e --- /dev/null +++ b/app/scanner.py @@ -0,0 +1,246 @@ +import io +import os +import asyncio +import logging +from pathlib import Path + +import fitz # PyMuPDF +from PIL import Image +from pyzbar.pyzbar import decode as decode_qr +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 + +logger = logging.getLogger(__name__) + +SEPARATOR_QR_CONTENT = "LEXOFFICE-TRENNUNG" +UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "/data/uploads")) + + +def detect_separator_pages(pdf_path: str, progress_callback=None) -> list[int]: + """Scan each page for QR codes. Returns list of page indices that are separators.""" + separator_pages = [] + doc = fitz.open(pdf_path) + total = len(doc) + + for page_num in range(total): + if progress_callback: + progress_callback("scan", page_num + 1, total) + + page = doc[page_num] + # Render page as image at 150 DPI (enough for QR detection) + pix = page.get_pixmap(dpi=150) + img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) + + # Scan for QR codes + codes = decode_qr(img) + for code in codes: + try: + data = code.data.decode("utf-8") + except Exception: + continue + if data == SEPARATOR_QR_CONTENT: + separator_pages.append(page_num) + logger.debug(f"Trennseite erkannt auf Seite {page_num + 1}") + break + + doc.close() + return separator_pages + + +def split_pdf(pdf_path: str, separator_pages: list[int]) -> list[bytes]: + """Split PDF at separator pages. Separator pages are excluded from output.""" + reader = PdfReader(pdf_path) + total_pages = len(reader.pages) + separator_set = set(separator_pages) + + documents = [] + current_writer = None + + for page_num in range(total_pages): + if page_num in separator_set: + # This is a separator page - finalize current document if any + if current_writer and len(current_writer.pages) > 0: + buf = io.BytesIO() + current_writer.write(buf) + documents.append(buf.getvalue()) + current_writer = None + continue + + # Regular page - add to current document + if current_writer is None: + current_writer = PdfWriter() + current_writer.add_page(reader.pages[page_num]) + + # Don't forget the last document (after the last separator or if no separator at end) + if current_writer and len(current_writer.pages) > 0: + buf = io.BytesIO() + current_writer.write(buf) + documents.append(buf.getvalue()) + + return documents + + +async def process_scanned_pdf(pdf_path: str, progress_callback=None) -> dict: + """Full pipeline: detect separators, split, send each document to LexOffice.""" + settings = await get_settings() + + if not settings.get("smtp_server") or not settings.get("lexoffice_email"): + return {"error": "SMTP oder LexOffice-Email nicht konfiguriert", "total_pages": 0, "documents": 0, "sent": 0, "errors": 1} + + # Step 1: Detect separator pages (CPU-bound, run in thread) + if progress_callback: + progress_callback("status", 0, 0, "Analysiere PDF...") + + separator_pages = await asyncio.to_thread( + detect_separator_pages, pdf_path, progress_callback + ) + + reader = PdfReader(pdf_path) + total_pages = len(reader.pages) + + if not separator_pages: + # No separators found - treat entire PDF as one document + if progress_callback: + progress_callback("status", 0, 0, "Keine Trennseiten gefunden - sende gesamte PDF als ein Dokument") + + # Step 2: Split PDF + if progress_callback: + progress_callback("status", 0, 0, f"{len(separator_pages)} Trennseite(n) erkannt, splitte PDF...") + + documents = await asyncio.to_thread(split_pdf, pdf_path, separator_pages) + + if not documents: + return {"error": "Keine Dokumente nach dem Splitting gefunden", "total_pages": total_pages, "documents": 0, "sent": 0, "errors": 1} + + # Step 3: Send each document to LexOffice + if progress_callback: + progress_callback("status", 0, 0, f"{len(documents)} Dokument(e) erkannt, starte Versand...") + + sent = 0 + errors = 0 + smtp_conn = None + + try: + smtp_conn = _connect_smtp(settings) + + for i, doc_bytes in enumerate(documents): + try: + if progress_callback: + progress_callback("send", i + 1, len(documents)) + + filename = f"Scan_Dokument_{i + 1}.pdf" + msg = _build_forward_email( + from_addr=settings["smtp_username"], + to_addr=settings["lexoffice_email"], + original_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}", + original_from="Scan-Upload", + attachments=[(filename, doc_bytes)], + ) + smtp_conn.send_message(msg) + sent += 1 + + await add_log_entry( + email_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}", + email_from="Scan-Upload", + attachments_count=1, + status="success", + ) + + except Exception as e: + errors += 1 + logger.error(f"Fehler beim Senden von Dokument {i + 1}: {e}") + await add_log_entry( + email_subject=f"Scan-Upload Dokument {i + 1}/{len(documents)}", + email_from="Scan-Upload", + attachments_count=1, + status="error", + error_message=str(e), + ) + + except Exception as e: + logger.error(f"SMTP-Verbindungsfehler: {e}") + return { + "error": f"SMTP-Verbindungsfehler: {e}", + "total_pages": total_pages, + "documents": len(documents), + "sent": sent, + "errors": errors + 1, + } + finally: + if smtp_conn: + try: + smtp_conn.quit() + except Exception: + pass + + return { + "total_pages": total_pages, + "separator_pages": len(separator_pages), + "documents": len(documents), + "sent": sent, + "errors": errors, + } + + +def _centered_textbox(page, y, text, fontsize, color): + """Insert centered text using textbox across full page width.""" + rect = fitz.Rect(40, y - fontsize, 555, y + fontsize) + page.insert_textbox( + rect, text, + fontsize=fontsize, + fontname="helv", + color=color, + align=fitz.TEXT_ALIGN_CENTER, + ) + + +def generate_separator_pdf() -> bytes: + """Generate a printable A4 PDF with QR code for use as separator page.""" + # Generate QR code image + qr = qrcode.QRCode( + version=2, + error_correction=ERROR_CORRECT_H, + box_size=10, + border=4, + ) + qr.add_data(SEPARATOR_QR_CONTENT) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB") + + # Create A4 PDF with PyMuPDF + doc = fitz.open() + page = doc.new_page(width=595, height=842) # A4 in points + + # Title text + _centered_textbox(page, 120, "TRENNSEITE", 36, (0, 0, 0)) + _centered_textbox(page, 170, "LexOffice Belegimport", 16, (0.4, 0.4, 0.4)) + + # Insert QR code image centered + qr_bytes = io.BytesIO() + qr_img.save(qr_bytes, format="PNG") + qr_bytes.seek(0) + + qr_size = 200 + x_center = (595 - qr_size) / 2 + y_center = 250 + rect = fitz.Rect(x_center, y_center, x_center + qr_size, y_center + qr_size) + page.insert_image(rect, stream=qr_bytes.getvalue()) + + # Description text below QR + _centered_textbox(page, y_center + qr_size + 40, "Dieses Blatt zwischen Dokumente legen.", 14, (0.3, 0.3, 0.3)) + _centered_textbox(page, y_center + qr_size + 70, "Es wird beim Scan-Upload automatisch erkannt und entfernt.", 12, (0.4, 0.4, 0.4)) + + # Dashed line border + border_rect = fitz.Rect(40, 40, 555, 802) + page.draw_rect(border_rect, color=(0.7, 0.7, 0.7), width=1) + + # Bottom info + _centered_textbox(page, 770, "--- Nicht entfernen - wird automatisch erkannt ---", 10, (0.5, 0.5, 0.5)) + + pdf_bytes = doc.tobytes() + doc.close() + return pdf_bytes diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..1806af6 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,73 @@ +import asyncio +import logging +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger + +from app.mail_processor import process_mailbox +from app.smb_processor import process_smb_share + +logger = logging.getLogger(__name__) + +JOB_ID = "mail_processor" + +scheduler = AsyncIOScheduler() +_is_processing = False + + +async def _run_processor(): + global _is_processing + if _is_processing: + logger.info("Verarbeitung läuft bereits, überspringe diesen Durchlauf") + return + _is_processing = True + try: + logger.info("Starte automatische Email-Verarbeitung...") + result = await process_mailbox() + logger.info(f"Email-Verarbeitung abgeschlossen: {result}") + + logger.info("Starte automatische SMB-Verarbeitung...") + smb_result = await process_smb_share() + logger.info(f"SMB-Verarbeitung abgeschlossen: {smb_result}") + except Exception as e: + logger.error(f"Fehler bei automatischer Verarbeitung: {e}") + finally: + _is_processing = False + + +def start_scheduler(): + if not scheduler.running: + scheduler.start() + logger.info("Scheduler gestartet") + + +def configure_job(interval_minutes: int, enabled: bool): + existing = scheduler.get_job(JOB_ID) + if existing: + scheduler.remove_job(JOB_ID) + + if enabled and interval_minutes > 0: + scheduler.add_job( + _run_processor, + trigger=IntervalTrigger(minutes=interval_minutes), + id=JOB_ID, + name="Email-Verarbeitung", + replace_existing=True, + ) + logger.info(f"Scheduler konfiguriert: alle {interval_minutes} Minuten") + else: + logger.info("Scheduler deaktiviert") + + +def get_scheduler_status() -> dict: + job = scheduler.get_job(JOB_ID) + if job and job.next_run_time: + return { + "enabled": True, + "next_run": job.next_run_time.strftime("%d.%m.%Y %H:%M:%S"), + "is_processing": _is_processing, + } + return { + "enabled": False, + "next_run": None, + "is_processing": _is_processing, + } diff --git a/app/smb_processor.py b/app/smb_processor.py new file mode 100644 index 0000000..c60eff1 --- /dev/null +++ b/app/smb_processor.py @@ -0,0 +1,310 @@ +import os +import asyncio +import logging +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.scanner import detect_separator_pages, split_pdf + +logger = logging.getLogger(__name__) + + +def _smb_register_session(settings: dict) -> str: + """Register SMB session and return the UNC base path \\\\server\\share.""" + server = settings["smb_server"] + port = int(settings.get("smb_port", 445)) + username = settings.get("smb_username", "") + password = settings.get("smb_password", "") + domain = settings.get("smb_domain", "") + + if domain: + auth_username = f"{domain}\\{username}" + else: + auth_username = username + + smbclient.register_session( + server, + port=port, + username=auth_username, + password=password, + connection_timeout=10, + ) + + share = settings["smb_share"] + return f"\\\\{server}\\{share}" + + +def _smb_unc_path(base: str, *parts: str) -> str: + """Join UNC path segments using backslash.""" + result = base + for p in parts: + if p: + result = result.rstrip("\\") + "\\" + p.replace("/", "\\").strip("\\") + return result + + +def _ensure_smb_folder(path: str): + """Create SMB directory if it does not exist.""" + try: + smbclient.stat(path) + except OSError: + smbclient.makedirs(path, exist_ok=True) + + +def _list_pdf_files(source_path: str) -> list[str]: + """Return list of .pdf filenames in the SMB source directory.""" + try: + entries = smbclient.listdir(source_path) + except OSError: + return [] + return sorted( + e for e in entries if e.lower().endswith(".pdf") and not e.startswith(".") + ) + + +def _read_smb_file(filepath: str) -> bytes: + """Read a file from SMB share into memory.""" + with smbclient.open_file(filepath, mode="rb") as f: + return f.read() + + +def _move_smb_file(source: str, dest_dir: str, filename: str): + """Move a file on the SMB share to the destination directory.""" + dest = _smb_unc_path(dest_dir, filename) + # Handle duplicate filenames + try: + smbclient.stat(dest) + name, ext = os.path.splitext(filename) + counter = 1 + while True: + new_name = f"{name}_{counter}{ext}" + dest = _smb_unc_path(dest_dir, new_name) + try: + smbclient.stat(dest) + counter += 1 + except OSError: + break + except OSError: + pass + smbclient.rename(source, dest) + + +def _list_smb_folders_recursive( + base_path: str, max_depth: int = 3, _current_depth: int = 0, _prefix: str = "" +) -> list[str]: + """Recursively list folders on the SMB share, returning relative paths.""" + folders = [] + try: + for entry in smbclient.scandir(base_path): + if entry.is_dir() and not entry.name.startswith("."): + rel_path = f"{_prefix}/{entry.name}" if _prefix else entry.name + folders.append(rel_path) + if _current_depth < max_depth - 1: + sub_path = _smb_unc_path(base_path, entry.name) + folders.extend( + _list_smb_folders_recursive( + sub_path, max_depth, _current_depth + 1, rel_path + ) + ) + except OSError: + pass + return folders + + +async def process_smb_share() -> dict: + """Process PDF files from SMB share - main pipeline.""" + settings = await get_settings() + + if settings.get("smb_enabled") != "true": + return {"processed": 0, "skipped": 0, "errors": 0} + + 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("lexoffice_email"): + return {"processed": 0, "skipped": 0, "errors": 0, "error": "LexOffice-Email nicht konfiguriert"} + + mode = settings.get("smb_mode", "forward") + smtp_from = settings.get("smtp_username", "") + lexoffice_email = settings["lexoffice_email"] + + 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) + + 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=lexoffice_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=lexoffice_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: + logger.error(f"SMB-Verbindungsfehler: {e}") + try: + await add_log_entry( + email_subject="", + email_from="SMB-Import", + attachments_count=0, + status="error", + error_message=f"SMB-Verbindungsfehler: {e}", + ) + except Exception: + pass + return {"processed": processed, "skipped": skipped, "errors": errors + 1, "error": str(e)} + + finally: + if smtp_conn: + try: + smtp_conn.quit() + except Exception: + pass + + logger.info(f"SMB fertig: {processed} verarbeitet, {skipped} übersprungen, {errors} Fehler") + return {"processed": processed, "skipped": skipped, "errors": errors} + + +async def test_smb_connection() -> dict: + """Test SMB connection and return folder list.""" + settings = await get_settings() + + if not settings.get("smb_server") or not settings.get("smb_share"): + return {"success": False, "error": "SMB-Server oder Freigabe nicht konfiguriert", "folders": []} + + try: + base_path = await asyncio.to_thread(_smb_register_session, settings) + folders = await asyncio.to_thread(_list_smb_folders_recursive, base_path, 3) + return {"success": True, "folders": sorted(folders)} + + except Exception as e: + logger.error(f"SMB-Test fehlgeschlagen: {e}") + return {"success": False, "error": str(e), "folders": []} + + +async def create_smb_folder(folder_path: str) -> dict: + """Create a folder on the SMB share.""" + settings = await get_settings() + + if not settings.get("smb_server") or not settings.get("smb_share"): + return {"success": False, "error": "SMB nicht konfiguriert"} + + if not folder_path or not folder_path.strip(): + return {"success": False, "error": "Ordnername darf nicht leer sein"} + + folder_path = folder_path.strip() + + try: + base_path = await asyncio.to_thread(_smb_register_session, settings) + full_path = _smb_unc_path(base_path, folder_path) + await asyncio.to_thread(smbclient.makedirs, full_path, True) + return {"success": True} + + except Exception as e: + logger.error(f"SMB-Ordner erstellen fehlgeschlagen: {e}") + return {"success": False, "error": str(e)} + + +async def list_smb_folders() -> dict: + """Return current folder list from SMB share.""" + settings = await get_settings() + if not settings.get("smb_server") or not settings.get("smb_share"): + return {"folders": []} + try: + base_path = await asyncio.to_thread(_smb_register_session, settings) + folders = await asyncio.to_thread(_list_smb_folders_recursive, base_path, 3) + return {"folders": sorted(folders)} + except Exception: + return {"folders": []} diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..1c9a4fc --- /dev/null +++ b/app/static/style.css @@ -0,0 +1,591 @@ +:root { + --primary: #0066cc; + --primary-hover: #0052a3; + --success: #28a745; + --success-hover: #218838; + --warning: #ffc107; + --error: #dc3545; + --bg: #f5f7fa; + --card-bg: #ffffff; + --text: #333333; + --text-muted: #6c757d; + --border: #dee2e6; + --nav-bg: #1a1a2e; + --nav-text: #ffffff; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +nav { + background: var(--nav-bg); + color: var(--nav-text); + padding: 0 2rem; + display: flex; + align-items: center; + gap: 2rem; + height: 56px; +} + +.nav-brand { + font-weight: 700; + font-size: 1.1rem; + white-space: nowrap; +} + +.nav-links { + display: flex; + gap: 0.5rem; +} + +.nav-links a { + color: rgba(255, 255, 255, 0.7); + text-decoration: none; + padding: 0.4rem 0.8rem; + border-radius: 4px; + transition: background 0.2s; +} + +.nav-links a:hover, +.nav-links a.active { + color: #fff; + background: rgba(255, 255, 255, 0.15); +} + +.nav-status { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.85rem; +} + +main { + max-width: 900px; + margin: 2rem auto; + padding: 0 1rem; +} + +.card { + background: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.card h2 { + font-size: 1.1rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.form-group-wide { + grid-column: 1 / -1; +} + +.form-group label { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-muted); +} + +.form-group input, +.form-group select { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.95rem; + background: #fff; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15); +} + +.form-actions { + margin-top: 1rem; +} + +.form-actions-main { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.btn { + padding: 0.55rem 1.2rem; + border: none; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.btn-primary { + background: var(--primary); + color: #fff; +} + +.btn-primary:hover { + background: var(--primary-hover); +} + +.btn-secondary { + background: #e9ecef; + color: var(--text); +} + +.btn-secondary:hover { + background: #dde0e3; +} + +.btn-success { + background: var(--success); + color: #fff; +} + +.btn-success:hover { + background: var(--success-hover); +} + +.badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 10px; + font-size: 0.78rem; + font-weight: 600; +} + +.badge-success { + background: #d4edda; + color: #155724; +} + +.badge-error { + background: #f8d7da; + color: #721c24; +} + +.badge-warning { + background: #fff3cd; + color: #856404; +} + +.badge-inactive { + background: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.7); +} + +.alert { + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1.5rem; + font-size: 0.9rem; +} + +.alert-success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.alert-error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.alert-warning { + background: #fff3cd; + color: #856404; + border: 1px solid #ffeeba; +} + +.alert-info { + background: #d1ecf1; + color: #0c5460; + border: 1px solid #bee5eb; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} + +th, td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +th { + font-weight: 600; + color: var(--text-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.row-error { + background: #fff5f5; +} + +.text-muted { + color: var(--text-muted); +} + +small.text-muted { + font-size: 0.78rem; +} + +/* Input with button (folder picker) */ +.input-with-btn { + display: flex; + gap: 0.25rem; +} + +.input-with-btn input { + flex: 1; + min-width: 0; +} + +.btn-icon { + padding: 0.4rem 0.6rem; + background: #e9ecef; + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + line-height: 1; + transition: background 0.2s; +} + +.btn-icon:hover { + background: #dde0e3; +} + +/* Button loading state */ +.btn:disabled { + opacity: 0.7; + cursor: wait; +} + +.btn-spinner { + font-style: italic; +} + +/* Modal overlay */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: var(--card-bg); + border-radius: 10px; + width: 90%; + max-width: 550px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-size: 1rem; + margin: 0; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-muted); + padding: 0 0.25rem; + line-height: 1; +} + +.modal-close:hover { + color: var(--text); +} + +.modal-body { + overflow-y: auto; + padding: 0.5rem; +} + +/* Folder picker fields (toggle between source/processed) */ +.folder-picker-fields { + display: flex; + gap: 0.5rem; + padding: 0.5rem 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); + margin-bottom: 0.5rem; +} + +.folder-field-btn { + flex: 1; + padding: 0.5rem 0.75rem; + background: #f8f9fa; + border: 2px solid var(--border); + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + text-align: left; + transition: border-color 0.2s, background 0.2s; +} + +.folder-field-btn:hover { + border-color: var(--primary); + background: #e8f0fe; +} + +.folder-field-btn.active { + border-color: var(--primary); + background: #e8f0fe; +} + +.folder-field-btn strong { + display: block; + font-size: 0.9rem; + margin-top: 0.15rem; + color: var(--primary); +} + +/* Folder list items */ +.folder-items { + display: flex; + flex-direction: column; + gap: 0; + padding: 0.25rem; +} + +.folder-row { + display: flex; + align-items: center; +} + +.folder-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.75rem; + background: none; + border: 1px solid transparent; + border-radius: 5px; + cursor: pointer; + font-size: 0.9rem; + text-align: left; + flex: 1; + min-width: 0; + transition: background 0.15s; +} + +.folder-item:hover { + background: #f0f4f8; + border-color: var(--border); +} + +.folder-item.selected { + background: #e8f5e9; + border-color: var(--success); +} + +.folder-icon { + font-size: 1.1rem; +} + +/* Folder add button (+) */ +.folder-add-btn { + flex-shrink: 0; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + font-size: 0.85rem; + padding: 0.35rem 0.5rem; + color: var(--text-muted); + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} + +.folder-add-btn:hover { + background: #e8f0fe; + border-color: var(--primary); + color: var(--primary); +} + +/* Inline create subfolder */ +.create-inline { + padding: 0 0.5rem 0.4rem 2.2rem; +} + +.create-folder-inline { + display: flex; + align-items: center; + gap: 0.35rem; + background: #f8f9fa; + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.4rem 0.5rem; +} + +.create-folder-prefix { + font-size: 0.82rem; + color: var(--text-muted); + white-space: nowrap; +} + +.create-folder-input { + flex: 1; + min-width: 60px; + padding: 0.3rem 0.5rem; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 0.88rem; +} + +.create-folder-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15); +} + +.btn-sm { + padding: 0.3rem 0.7rem; + font-size: 0.82rem; +} + +.text-error { + color: var(--error); + font-size: 0.8rem; + padding: 0.25rem 0.5rem 0; +} + +/* Upload zone */ +.upload-zone { + border: 2px dashed var(--border); + border-radius: 8px; + padding: 2.5rem 1.5rem; + text-align: center; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.upload-zone:hover, +.upload-zone-active { + border-color: var(--primary); + background: #f0f6ff; +} + +.upload-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.upload-text { + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.25rem; +} + +.upload-hint { + font-size: 0.82rem; + color: var(--text-muted); +} + +/* Progress bars */ +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.88rem; + font-weight: 600; + margin-bottom: 0.4rem; + margin-top: 1rem; +} + +.progress-bar-track { + width: 100%; + height: 10px; + background: #e9ecef; + border-radius: 5px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background: var(--primary); + border-radius: 5px; + transition: width 0.3s ease; +} + +.progress-bar-processing { + background: var(--success); +} + +@media (max-width: 640px) { + nav { + flex-wrap: wrap; + height: auto; + padding: 0.75rem 1rem; + gap: 0.5rem; + } + + .nav-status { + width: 100%; + margin-left: 0; + } + + .form-grid { + grid-template-columns: 1fr; + } + + .form-actions-main { + flex-direction: column; + } +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..ffa3618 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,42 @@ + + + + + + LexOffice Belegimport + + + + + +
+ {% if message %} +
+ {{ message }} +
+ {% endif %} + + {% block content %}{% endblock %} +
+ + diff --git a/app/templates/log.html b/app/templates/log.html new file mode 100644 index 0000000..422adb1 --- /dev/null +++ b/app/templates/log.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% set active_page = "log" %} +{% set message = None %} + +{% block content %} +
+

Verarbeitungslog

+ {% if logs %} + + + + + + + + + + + + + + {% for log in logs %} + + + + + + + + + + {% endfor %} + +
IDZeitpunktBetreffAbsenderAnhängeStatusFehlermeldung
{{ log.id }}{{ log.timestamp }}{{ log.email_subject or '-' }}{{ log.email_from or '-' }}{{ log.attachments_count }} + {% if log.status == 'success' %} + OK + {% else %} + Fehler + {% endif %} + {{ log.error_message or '-' }}
+ {% else %} +

Noch keine Einträge vorhanden.

+ {% endif %} +
+{% endblock %} diff --git a/app/templates/scan.html b/app/templates/scan.html new file mode 100644 index 0000000..5105d07 --- /dev/null +++ b/app/templates/scan.html @@ -0,0 +1,283 @@ +{% extends "base.html" %} +{% set active_page = "scan" %} + +{% block content %} +
+

Scan-Upload

+

+ Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente an LexOffice gesendet. +

+ + +
+
📄
+
PDF hierher ziehen oder klicken zum Auswählen
+
Unterstützt große Dateien (1 GB+)
+ +
+ + + + + + + + + +
+ +
+

Trennseiten

+

+ Trennseiten ausdrucken und zwischen die Dokumente legen, bevor der Stapel gescannt wird. +

+ Trennseiten-PDF herunterladen +
+ + +{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 0000000..1d03bb2 --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,805 @@ +{% extends "base.html" %} +{% set active_page = "settings" %} + +{% block content %} +
+
+

IMAP Einstellungen (Empfang)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+

SMTP Einstellungen (Versand)

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

LexOffice & Ordner

+
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
+ +
+

SMB-Freigabe (Netzlaufwerk)

+
+
+ + +
+
+ + + Direkt: jede PDF als ein Beleg. Trennseiten: QR-Splitting wie bei Scan-Upload. +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
+
+ +
+

Zeitplan

+
+
+ + +
+
+ + +
+
+ + + Leer = alle Emails im Ordner +
+
+
+ +
+ + +
+
+ + + + +{% if logs %} +
+

Letzte Verarbeitungen

+ + + + + + + + + + + + {% for log in logs %} + + + + + + + + {% endfor %} + +
ZeitpunktBetreffAbsenderAnhängeStatus
{{ log.timestamp }}{{ log.email_subject or '-' }}{{ log.email_from or '-' }}{{ log.attachments_count }} + {% if log.status == 'success' %} + OK + {% else %} + Fehler + {% endif %} +
+
+{% endif %} + + + + + + + + +{% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fbaad61 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + belegimport: + build: . + container_name: lexoffice-belegimport + ports: + - "8081:8000" + volumes: + - ./data:/data + environment: + - DB_PATH=/data/belegimport.db + - TZ=Europe/Berlin + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..058fd2d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.6 +uvicorn==0.34.0 +jinja2==3.1.5 +python-multipart==0.0.20 +aiosqlite==0.20.0 +apscheduler==3.11.0 +cryptography==44.0.0 +pypdf==5.1.0 +Pillow==11.1.0 +pyzbar==0.1.9 +PyMuPDF==1.25.3 +qrcode==8.0 +sse-starlette==2.2.1 +smbprotocol==1.14.0