first commit

This commit is contained in:
Stefan Hacker 2026-03-06 08:20:07 +01:00
commit cb34aa00af
15 changed files with 3280 additions and 0 deletions

16
Dockerfile Normal file
View File

@ -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"]

0
app/__init__.py Normal file
View File

170
app/database.py Normal file
View File

@ -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]

346
app/mail_processor.py Normal file
View File

@ -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": "."}

327
app/main.py Normal file
View File

@ -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"},
)

246
app/scanner.py Normal file
View File

@ -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

73
app/scheduler.py Normal file
View File

@ -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,
}

310
app/smb_processor.py Normal file
View File

@ -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": []}

591
app/static/style.css Normal file
View File

@ -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;
}
}

42
app/templates/base.html Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LexOffice Belegimport</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<div class="nav-brand">LexOffice Belegimport</div>
<div class="nav-links">
<a href="/" class="{% if active_page == 'settings' %}active{% endif %}">Einstellungen</a>
<a href="/scan" class="{% if active_page == 'scan' %}active{% endif %}">Scan-Upload</a>
<a href="/log" class="{% if active_page == 'log' %}active{% endif %}">Verarbeitungslog</a>
</div>
<div class="nav-status">
{% if status and status.enabled %}
<span class="badge badge-success">Aktiv</span>
{% if status.next_run %}
<span class="text-muted">Nächster Lauf: {{ status.next_run }}</span>
{% endif %}
{% else %}
<span class="badge badge-inactive">Inaktiv</span>
{% endif %}
{% if status and status.is_processing %}
<span class="badge badge-warning">Verarbeitung läuft...</span>
{% endif %}
</div>
</nav>
<main>
{% if message %}
<div class="alert alert-{{ message_type or 'info' }}">
{{ message }}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>

45
app/templates/log.html Normal file
View File

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% set active_page = "log" %}
{% set message = None %}
{% block content %}
<div class="card">
<h2>Verarbeitungslog</h2>
{% if logs %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Zeitpunkt</th>
<th>Betreff</th>
<th>Absender</th>
<th>Anhänge</th>
<th>Status</th>
<th>Fehlermeldung</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
<td>{{ log.id }}</td>
<td>{{ log.timestamp }}</td>
<td>{{ log.email_subject or '-' }}</td>
<td>{{ log.email_from or '-' }}</td>
<td>{{ log.attachments_count }}</td>
<td>
{% if log.status == 'success' %}
<span class="badge badge-success">OK</span>
{% else %}
<span class="badge badge-error">Fehler</span>
{% endif %}
</td>
<td>{{ log.error_message or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">Noch keine Einträge vorhanden.</p>
{% endif %}
</div>
{% endblock %}

283
app/templates/scan.html Normal file
View File

@ -0,0 +1,283 @@
{% extends "base.html" %}
{% set active_page = "scan" %}
{% block content %}
<div class="card">
<h2>Scan-Upload</h2>
<p class="text-muted" style="margin-bottom:1rem;">
Mehrseitige PDF hochladen. Trennseiten mit QR-Code werden automatisch erkannt und die einzelnen Dokumente an LexOffice gesendet.
</p>
<!-- Upload Zone -->
<div id="uploadZone" class="upload-zone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">&#128196;</div>
<div class="upload-text">PDF hierher ziehen oder klicken zum Auswählen</div>
<div class="upload-hint">Unterstützt große Dateien (1 GB+)</div>
<input type="file" id="fileInput" accept=".pdf,application/pdf" style="display:none;">
</div>
<!-- Upload Progress -->
<div id="uploadProgress" style="display:none;">
<div class="progress-header">
<span id="uploadFilename"></span>
<span id="uploadPercent">0%</span>
</div>
<div class="progress-bar-track">
<div id="uploadBar" class="progress-bar-fill" style="width:0%"></div>
</div>
<div id="uploadStatus" class="text-muted" style="margin-top:0.3rem;font-size:0.85rem;"></div>
</div>
<!-- Processing Progress -->
<div id="processProgress" style="display:none;">
<div class="progress-header">
<span>Verarbeitung</span>
<span id="processStage"></span>
</div>
<div class="progress-bar-track">
<div id="processBar" class="progress-bar-fill progress-bar-processing" style="width:0%"></div>
</div>
<div id="processStatus" class="text-muted" style="margin-top:0.3rem;font-size:0.85rem;"></div>
</div>
<!-- Result -->
<div id="resultArea" style="display:none;"></div>
</div>
<div class="card">
<h2>Trennseiten</h2>
<p class="text-muted" style="margin-bottom:1rem;">
Trennseiten ausdrucken und zwischen die Dokumente legen, bevor der Stapel gescannt wird.
</p>
<a href="/api/separator-pdf" class="btn btn-secondary" download>Trennseiten-PDF herunterladen</a>
</div>
<script>
const CHUNK_SIZE = 10 * 1024 * 1024; // 10 MB
let currentUploadId = null;
// Drag & Drop
const zone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
zone.addEventListener('dragover', (e) => {
e.preventDefault();
zone.classList.add('upload-zone-active');
});
zone.addEventListener('dragleave', () => {
zone.classList.remove('upload-zone-active');
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('upload-zone-active');
const files = e.dataTransfer.files;
if (files.length > 0) handleFile(files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length > 0) handleFile(fileInput.files[0]);
});
function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function generateUUID() {
return crypto.randomUUID ? crypto.randomUUID() : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
async function handleFile(file) {
if (!file.name.toLowerCase().endsWith('.pdf')) {
showResult('Bitte eine PDF-Datei auswählen.', 'error');
return;
}
// Reset UI
document.getElementById('resultArea').style.display = 'none';
document.getElementById('processProgress').style.display = 'none';
zone.style.display = 'none';
const uploadId = generateUUID();
currentUploadId = uploadId;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadProgress = document.getElementById('uploadProgress');
const uploadBar = document.getElementById('uploadBar');
const uploadPercent = document.getElementById('uploadPercent');
const uploadStatus = document.getElementById('uploadStatus');
const uploadFilename = document.getElementById('uploadFilename');
uploadFilename.textContent = file.name + ' (' + formatBytes(file.size) + ')';
uploadBar.style.width = '0%';
uploadPercent.textContent = '0%';
uploadStatus.textContent = 'Upload wird gestartet...';
uploadProgress.style.display = '';
// Chunked upload
try {
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('chunk_index', i);
formData.append('total_chunks', totalChunks);
formData.append('upload_id', uploadId);
formData.append('filename', file.name);
const resp = await fetch('/api/scan-upload-chunk', {
method: 'POST',
body: formData,
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
throw new Error(errData.error || 'Upload fehlgeschlagen (HTTP ' + resp.status + ')');
}
const pct = Math.round(((i + 1) / totalChunks) * 100);
uploadBar.style.width = pct + '%';
uploadPercent.textContent = pct + '%';
uploadStatus.textContent = 'Chunk ' + (i + 1) + '/' + totalChunks + ' (' + formatBytes(end) + ' von ' + formatBytes(file.size) + ')';
}
uploadStatus.textContent = 'Upload abgeschlossen. Starte Verarbeitung...';
startProcessing(uploadId);
} catch (e) {
showResult('Upload-Fehler: ' + e.message, 'error');
resetUploadZone();
}
}
async function startProcessing(uploadId) {
const processProgress = document.getElementById('processProgress');
const processBar = document.getElementById('processBar');
const processStage = document.getElementById('processStage');
const processStatus = document.getElementById('processStatus');
processBar.style.width = '0%';
processStage.textContent = '';
processStatus.textContent = 'Starte Verarbeitung...';
processProgress.style.display = '';
try {
const resp = await fetch('/api/scan-process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ upload_id: uploadId }),
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
throw new Error(errData.error || 'Verarbeitung konnte nicht gestartet werden');
}
// Subscribe to SSE for progress
listenProgress(uploadId);
} catch (e) {
showResult('Fehler: ' + e.message, 'error');
resetUploadZone();
}
}
function listenProgress(uploadId) {
const processBar = document.getElementById('processBar');
const processStage = document.getElementById('processStage');
const processStatus = document.getElementById('processStatus');
const evtSource = new EventSource('/api/scan-status/' + uploadId);
evtSource.addEventListener('scan', (e) => {
const data = JSON.parse(e.data);
const pct = Math.round((data.current / data.total) * 100);
processBar.style.width = pct + '%';
processStage.textContent = pct + '%';
processStatus.textContent = 'Seite ' + data.current + ' von ' + data.total + ' analysiert...';
});
evtSource.addEventListener('status', (e) => {
const data = JSON.parse(e.data);
processStatus.textContent = data.message || '';
});
evtSource.addEventListener('send', (e) => {
const data = JSON.parse(e.data);
const pct = Math.round((data.current / data.total) * 100);
processBar.style.width = pct + '%';
processStage.textContent = data.current + '/' + data.total;
processStatus.textContent = 'Dokument ' + data.current + ' von ' + data.total + ' wird gesendet...';
});
evtSource.addEventListener('done', (e) => {
evtSource.close();
const data = JSON.parse(e.data);
const result = data.result || {};
let msg = '';
if (result.error) {
msg = result.error;
showResult(msg, 'error');
} else {
msg = result.documents + ' Dokument(e) erkannt';
if (result.separator_pages > 0) {
msg += ' (' + result.separator_pages + ' Trennseite(n))';
}
msg += ', ' + result.sent + ' an LexOffice gesendet';
if (result.errors > 0) {
msg += ', ' + result.errors + ' Fehler';
showResult(msg, 'warning');
} else {
showResult(msg, 'success');
}
}
document.getElementById('processProgress').style.display = 'none';
resetUploadZone();
});
evtSource.addEventListener('error', (e) => {
// Check if it's an SSE error event from our backend
try {
const data = JSON.parse(e.data);
showResult('Verarbeitungsfehler: ' + (data.message || 'Unbekannter Fehler'), 'error');
} catch {
// Connection error
showResult('Verbindung zum Server verloren.', 'error');
}
evtSource.close();
document.getElementById('processProgress').style.display = 'none';
resetUploadZone();
});
}
function showResult(message, type) {
const area = document.getElementById('resultArea');
area.innerHTML = '<div class="alert alert-' + type + '">' + escapeHtml(message) + '</div>';
area.style.display = '';
document.getElementById('uploadProgress').style.display = 'none';
}
function resetUploadZone() {
zone.style.display = '';
fileInput.value = '';
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
</script>
{% endblock %}

805
app/templates/settings.html Normal file
View File

@ -0,0 +1,805 @@
{% extends "base.html" %}
{% set active_page = "settings" %}
{% block content %}
<form id="settingsForm" method="post" action="/settings">
<div class="card">
<h2>IMAP Einstellungen (Empfang)</h2>
<div class="form-grid">
<div class="form-group">
<label for="imap_server">Server</label>
<input type="text" id="imap_server" name="imap_server"
value="{{ settings.get('imap_server', '') }}" placeholder="imap.example.com">
</div>
<div class="form-group">
<label for="imap_port">Port</label>
<input type="number" id="imap_port" name="imap_port"
value="{{ settings.get('imap_port', '993') }}">
</div>
<div class="form-group">
<label for="imap_ssl">Verschlüsselung</label>
<select id="imap_ssl" name="imap_ssl">
<option value="true" {% if settings.get('imap_ssl') == 'true' %}selected{% endif %}>SSL/TLS</option>
<option value="false" {% if settings.get('imap_ssl') == 'false' %}selected{% endif %}>Keine</option>
</select>
</div>
<div class="form-group">
<label for="imap_username">Benutzername</label>
<input type="text" id="imap_username" name="imap_username"
value="{{ settings.get('imap_username', '') }}" placeholder="user@example.com">
</div>
<div class="form-group">
<label for="imap_password">Passwort</label>
<input type="password" id="imap_password" name="imap_password"
placeholder="{% if settings.get('imap_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="testImap()">
<span class="btn-text">Verbindung testen &amp; Ordner laden</span>
<span class="btn-spinner" style="display:none;">Verbinde...</span>
</button>
</div>
</div>
<div class="card">
<h2>SMTP Einstellungen (Versand)</h2>
<div class="form-grid">
<div class="form-group">
<label for="smtp_server">Server</label>
<input type="text" id="smtp_server" name="smtp_server"
value="{{ settings.get('smtp_server', '') }}" placeholder="smtp.example.com">
</div>
<div class="form-group">
<label for="smtp_port">Port</label>
<input type="number" id="smtp_port" name="smtp_port"
value="{{ settings.get('smtp_port', '587') }}">
</div>
<div class="form-group">
<label for="smtp_ssl">Verschlüsselung</label>
<select id="smtp_ssl" name="smtp_ssl">
<option value="starttls" {% if settings.get('smtp_ssl') == 'starttls' %}selected{% endif %}>STARTTLS</option>
<option value="ssl" {% if settings.get('smtp_ssl') == 'ssl' %}selected{% endif %}>SSL/TLS</option>
<option value="none" {% if settings.get('smtp_ssl') == 'none' %}selected{% endif %}>Keine</option>
</select>
</div>
<div class="form-group">
<label for="smtp_username">Benutzername</label>
<input type="text" id="smtp_username" name="smtp_username"
value="{{ settings.get('smtp_username', '') }}" placeholder="user@example.com">
</div>
<div class="form-group">
<label for="smtp_password">Passwort</label>
<input type="password" id="smtp_password" name="smtp_password"
placeholder="{% if settings.get('smtp_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
</div>
</div>
</div>
<div class="card">
<h2>LexOffice & Ordner</h2>
<div class="form-grid">
<div class="form-group form-group-wide">
<label for="lexoffice_email">LexOffice Import-Emailadresse</label>
<input type="email" id="lexoffice_email" name="lexoffice_email"
value="{{ settings.get('lexoffice_email', '') }}" placeholder="import-xyz@lexoffice.de">
</div>
<div class="form-group">
<label for="source_folder">Eingangsordner (IMAP)</label>
<div class="input-with-btn">
<input type="text" id="source_folder" name="source_folder"
value="{{ settings.get('source_folder', 'Rechnungen') }}" placeholder="Rechnungen">
<button type="button" class="btn btn-icon" onclick="openFolderPicker('source_folder')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
<div class="form-group">
<label for="processed_folder">Verarbeitet-Ordner (IMAP)</label>
<div class="input-with-btn">
<input type="text" id="processed_folder" name="processed_folder"
value="{{ settings.get('processed_folder', 'Rechnungen/Verarbeitet') }}" placeholder="Rechnungen/Verarbeitet">
<button type="button" class="btn btn-icon" onclick="openFolderPicker('processed_folder')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="testEmail()">
<span class="btn-text">Test-Email an LexOffice senden</span>
<span class="btn-spinner" style="display:none;">Sende...</span>
</button>
</div>
</div>
<div class="card">
<h2>SMB-Freigabe (Netzlaufwerk)</h2>
<div class="form-grid">
<div class="form-group">
<label for="smb_enabled">SMB-Import</label>
<select id="smb_enabled" name="smb_enabled">
<option value="true" {% if settings.get('smb_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
<option value="false" {% if settings.get('smb_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
</select>
</div>
<div class="form-group">
<label for="smb_mode">Verarbeitungsmodus</label>
<select id="smb_mode" name="smb_mode">
<option value="forward" {% if settings.get('smb_mode', 'forward') == 'forward' %}selected{% endif %}>Direkt weiterleiten</option>
<option value="separator" {% if settings.get('smb_mode') == 'separator' %}selected{% endif %}>Trennseiten-Erkennung</option>
</select>
<small class="text-muted">Direkt: jede PDF als ein Beleg. Trennseiten: QR-Splitting wie bei Scan-Upload.</small>
</div>
<div class="form-group">
<label for="smb_server">Server</label>
<input type="text" id="smb_server" name="smb_server"
value="{{ settings.get('smb_server', '') }}" placeholder="192.168.1.100 oder nas.local">
</div>
<div class="form-group">
<label for="smb_port">Port</label>
<input type="number" id="smb_port" name="smb_port"
value="{{ settings.get('smb_port', '445') }}">
</div>
<div class="form-group">
<label for="smb_username">Benutzername</label>
<input type="text" id="smb_username" name="smb_username"
value="{{ settings.get('smb_username', '') }}" placeholder="scanner">
</div>
<div class="form-group">
<label for="smb_password">Passwort</label>
<input type="password" id="smb_password" name="smb_password"
placeholder="{% if settings.get('smb_password') %}(gespeichert){% else %}Passwort eingeben{% endif %}">
</div>
<div class="form-group">
<label for="smb_domain">Domäne</label>
<input type="text" id="smb_domain" name="smb_domain"
value="{{ settings.get('smb_domain', '') }}" placeholder="WORKGROUP (optional)">
</div>
<div class="form-group">
<label for="smb_share">Freigabename</label>
<input type="text" id="smb_share" name="smb_share"
value="{{ settings.get('smb_share', '') }}" placeholder="Scans">
</div>
<div class="form-group">
<label for="smb_source_path">Quellordner</label>
<div class="input-with-btn">
<input type="text" id="smb_source_path" name="smb_source_path"
value="{{ settings.get('smb_source_path', '') }}" placeholder="(Wurzel der Freigabe)">
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_source_path')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
<div class="form-group">
<label for="smb_processed_path">Verarbeitet-Ordner</label>
<div class="input-with-btn">
<input type="text" id="smb_processed_path" name="smb_processed_path"
value="{{ settings.get('smb_processed_path', 'Verarbeitet') }}" placeholder="Verarbeitet">
<button type="button" class="btn btn-icon" onclick="openSmbFolderPicker('smb_processed_path')" title="Ordner auswählen">&#128193;</button>
</div>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="testSmb()">
<span class="btn-text">Verbindung testen &amp; Ordner laden</span>
<span class="btn-spinner" style="display:none;">Verbinde...</span>
</button>
</div>
</div>
<div class="card">
<h2>Zeitplan</h2>
<div class="form-grid">
<div class="form-group">
<label for="interval_minutes">Abruf-Intervall (Minuten)</label>
<input type="number" id="interval_minutes" name="interval_minutes"
value="{{ settings.get('interval_minutes', '5') }}" min="1" max="1440">
</div>
<div class="form-group">
<label for="scheduler_enabled">Automatischer Abruf</label>
<select id="scheduler_enabled" name="scheduler_enabled">
<option value="true" {% if settings.get('scheduler_enabled') == 'true' %}selected{% endif %}>Aktiviert</option>
<option value="false" {% if settings.get('scheduler_enabled') != 'true' %}selected{% endif %}>Deaktiviert</option>
</select>
</div>
<div class="form-group">
<label for="fetch_since_date">Emails erst ab Datum verarbeiten</label>
<input type="date" id="fetch_since_date" name="fetch_since_date"
value="{{ settings.get('fetch_since_date', '') }}">
<small class="text-muted">Leer = alle Emails im Ordner</small>
</div>
</div>
</div>
<div class="form-actions-main">
<button type="submit" class="btn btn-primary">Einstellungen speichern</button>
<button type="button" class="btn btn-success" onclick="manualProcess()">
<span class="btn-text">Jetzt abrufen</span>
<span class="btn-spinner" style="display:none;">Verarbeite...</span>
</button>
</div>
</form>
<!-- Alert-Bereich für JS-Meldungen -->
<div id="jsAlert" class="alert" style="display:none;"></div>
{% if logs %}
<div class="card">
<h2>Letzte Verarbeitungen</h2>
<table>
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Betreff</th>
<th>Absender</th>
<th>Anhänge</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr class="{% if log.status == 'error' %}row-error{% endif %}">
<td>{{ log.timestamp }}</td>
<td>{{ log.email_subject or '-' }}</td>
<td>{{ log.email_from or '-' }}</td>
<td>{{ log.attachments_count }}</td>
<td>
{% if log.status == 'success' %}
<span class="badge badge-success">OK</span>
{% else %}
<span class="badge badge-error" title="{{ log.error_message or '' }}">Fehler</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<!-- Ordner-Auswahl Modal -->
<div id="folderModal" class="modal-overlay" style="display:none;" onclick="closeFolderModal(event)">
<div class="modal">
<div class="modal-header">
<h3 id="folderModalTitle">Ordner auswählen</h3>
<button type="button" class="modal-close" onclick="closeFolderModal()">&times;</button>
</div>
<div class="modal-body">
<div id="folderList" class="folder-list"></div>
<div id="folderLoading" class="text-muted" style="display:none;padding:1rem;">
Verbinde und lade Ordner...
</div>
<div id="folderError" class="alert alert-error" style="display:none;margin:1rem;"></div>
</div>
</div>
</div>
<!-- SMB Ordner-Auswahl Modal -->
<div id="smbFolderModal" class="modal-overlay" style="display:none;" onclick="closeSmbFolderModal(event)">
<div class="modal">
<div class="modal-header">
<h3 id="smbFolderModalTitle">SMB-Ordner auswählen</h3>
<button type="button" class="modal-close" onclick="closeSmbFolderModal()">&times;</button>
</div>
<div class="modal-body">
<div id="smbFolderList" class="folder-list"></div>
<div id="smbFolderLoading" class="text-muted" style="display:none;padding:1rem;">
Verbinde und lade Ordner...
</div>
<div id="smbFolderError" class="alert alert-error" style="display:none;margin:1rem;"></div>
</div>
</div>
</div>
<script>
let cachedFolders = null;
let cachedDelimiter = '.';
let folderTargetField = null;
let createOpenFor = null;
function showAlert(message, type) {
const el = document.getElementById('jsAlert');
el.textContent = message;
el.className = 'alert alert-' + type;
el.style.display = 'block';
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
setTimeout(() => { el.style.display = 'none'; }, 8000);
}
function setButtonLoading(btn, loading) {
const text = btn.querySelector('.btn-text');
const spinner = btn.querySelector('.btn-spinner');
if (text && spinner) {
text.style.display = loading ? 'none' : '';
spinner.style.display = loading ? '' : 'none';
}
btn.disabled = loading;
}
function getFormData() {
return new FormData(document.getElementById('settingsForm'));
}
async function testImap() {
const btn = event.currentTarget;
setButtonLoading(btn, true);
try {
const resp = await fetch('/api/test-imap', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.success) {
cachedFolders = data.folders;
cachedDelimiter = data.delimiter || '.';
showAlert('IMAP-Verbindung erfolgreich! Einstellungen gespeichert.', 'success');
showFolderModal(null);
} else {
showAlert('IMAP-Verbindung fehlgeschlagen: ' + data.error, 'error');
}
} catch (e) {
showAlert('Fehler: ' + e.message, 'error');
} finally {
setButtonLoading(btn, false);
}
}
async function testEmail() {
const btn = event.currentTarget;
setButtonLoading(btn, true);
try {
const resp = await fetch('/api/test-email', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.success) {
const addr = document.getElementById('lexoffice_email').value;
showAlert('Test-Email erfolgreich an ' + addr + ' gesendet! Einstellungen gespeichert.', 'success');
} else {
showAlert('Test-Email fehlgeschlagen: ' + data.error, 'error');
}
} catch (e) {
showAlert('Fehler: ' + e.message, 'error');
} finally {
setButtonLoading(btn, false);
}
}
async function manualProcess() {
const btn = event.currentTarget;
setButtonLoading(btn, true);
try {
const fd = getFormData();
let msgs = [];
let hasErrors = false;
// IMAP
const resp = await fetch('/api/process', { method: 'POST', body: fd });
const data = await resp.json();
if (data.error) {
msgs.push('IMAP-Fehler: ' + data.error);
hasErrors = true;
} else {
msgs.push(`IMAP: ${data.processed} weitergeleitet, ${data.skipped} übersprungen, ${data.errors} Fehler`);
if (data.errors > 0) hasErrors = true;
}
// SMB
const smbResp = await fetch('/api/process-smb', { method: 'POST', body: fd });
const smbData = await smbResp.json();
if (smbData.error) {
msgs.push('SMB-Fehler: ' + smbData.error);
hasErrors = true;
} else if (smbData.processed > 0 || smbData.errors > 0) {
msgs.push(`SMB: ${smbData.processed} weitergeleitet, ${smbData.skipped} übersprungen, ${smbData.errors} Fehler`);
if (smbData.errors > 0) hasErrors = true;
}
showAlert(msgs.join(' | '), hasErrors ? 'warning' : 'success');
setTimeout(() => location.reload(), 2000);
} catch (e) {
showAlert('Fehler: ' + e.message, 'error');
} finally {
setButtonLoading(btn, false);
}
}
function openFolderPicker(targetField) {
folderTargetField = targetField;
if (cachedFolders) {
showFolderModal(targetField);
} else {
showFolderModalLoading(targetField);
fetchFolders(targetField);
}
}
async function fetchFolders(targetField) {
try {
const resp = await fetch('/api/test-imap', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.success) {
cachedFolders = data.folders;
cachedDelimiter = data.delimiter || '.';
showFolderModal(targetField);
} else {
showFolderModalError('IMAP-Verbindung fehlgeschlagen: ' + data.error);
}
} catch (e) {
showFolderModalError('Fehler: ' + e.message);
}
}
function showFolderModalLoading(targetField) {
const modal = document.getElementById('folderModal');
document.getElementById('folderModalTitle').textContent = 'Ordner auswählen';
document.getElementById('folderList').innerHTML = '';
document.getElementById('folderLoading').style.display = '';
document.getElementById('folderError').style.display = 'none';
modal.style.display = 'flex';
}
function esc(str) {
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function showFolderModal(targetField) {
if (targetField) folderTargetField = targetField;
createOpenFor = null;
const modal = document.getElementById('folderModal');
document.getElementById('folderModalTitle').textContent = 'Ordner auswählen';
document.getElementById('folderLoading').style.display = 'none';
document.getElementById('folderError').style.display = 'none';
const list = document.getElementById('folderList');
const currentValue = folderTargetField ? document.getElementById(folderTargetField).value : '';
let html = '<div class="folder-picker-fields">';
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'source_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'source_folder\')">Eingangsordner: <strong>' + esc(document.getElementById('source_folder').value) + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (folderTargetField === 'processed_folder' ? 'active' : '') + '" onclick="switchFolderTarget(\'processed_folder\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('processed_folder').value) + '</strong></button>';
html += '</div>';
html += '<div class="folder-items">';
if (cachedFolders && cachedFolders.length > 0) {
cachedFolders.forEach(folder => {
const isSelected = folder === currentValue;
const escapedFolder = folder.replace(/'/g, "\\'");
html += '<div class="folder-row">';
html += '<button type="button" class="folder-item' + (isSelected ? ' selected' : '') + '" onclick="selectFolder(\'' + escapedFolder + '\')">';
html += '<span class="folder-icon">&#128193;</span> ' + esc(folder);
if (isSelected) html += ' <span class="badge badge-success">ausgewählt</span>';
html += '</button>';
html += '<button type="button" class="folder-add-btn" onclick="event.stopPropagation(); toggleCreateInput(\'' + escapedFolder + '\')" title="Unterordner erstellen">&#128193;+</button>';
html += '</div>';
// Placeholder for inline create input
html += '<div id="create-row-' + CSS.escape(folder) + '" class="create-inline" style="display:none;"></div>';
});
} else {
html += '<p class="text-muted" style="padding:0.5rem;">Keine Ordner gefunden.</p>';
}
html += '</div>';
list.innerHTML = html;
modal.style.display = 'flex';
}
function switchFolderTarget(field) {
folderTargetField = field;
showFolderModal(field);
}
function showFolderModalError(msg) {
document.getElementById('folderLoading').style.display = 'none';
document.getElementById('folderError').textContent = msg;
document.getElementById('folderError').style.display = '';
}
function selectFolder(folder) {
if (folderTargetField) {
document.getElementById(folderTargetField).value = folder;
}
showFolderModal(folderTargetField);
}
function toggleCreateInput(parentFolder) {
// Close any previously open create row
document.querySelectorAll('.create-inline').forEach(el => {
if (el.id !== 'create-row-' + CSS.escape(parentFolder)) {
el.style.display = 'none';
el.innerHTML = '';
}
});
const row = document.getElementById('create-row-' + CSS.escape(parentFolder));
if (!row) return;
if (row.style.display !== 'none') {
row.style.display = 'none';
row.innerHTML = '';
createOpenFor = null;
return;
}
createOpenFor = parentFolder;
row.innerHTML =
'<div class="create-folder-inline">' +
'<span class="create-folder-prefix">' + esc(parentFolder) + cachedDelimiter + '</span>' +
'<input type="text" class="create-folder-input" id="newSubfolderInput" placeholder="Name" autofocus>' +
'<button type="button" class="btn btn-primary btn-sm" onclick="doCreateFolder(\'' + parentFolder.replace(/'/g, "\\'") + '\')">OK</button>' +
'<button type="button" class="btn btn-secondary btn-sm" onclick="toggleCreateInput(\'' + parentFolder.replace(/'/g, "\\'") + '\')">Abbrechen</button>' +
'</div>' +
'<div id="createError" class="text-error" style="display:none;"></div>';
row.style.display = '';
const input = document.getElementById('newSubfolderInput');
if (input) {
input.focus();
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
doCreateFolder(parentFolder);
}
if (e.key === 'Escape') {
toggleCreateInput(parentFolder);
}
});
}
}
async function doCreateFolder(parentFolder) {
const input = document.getElementById('newSubfolderInput');
const errorEl = document.getElementById('createError');
if (!input) return;
const subName = input.value.trim();
if (!subName) {
errorEl.textContent = 'Bitte einen Namen eingeben.';
errorEl.style.display = '';
return;
}
const fullName = parentFolder + cachedDelimiter + subName;
errorEl.style.display = 'none';
input.disabled = true;
try {
const resp = await fetch('/api/create-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_name: fullName })
});
const data = await resp.json();
if (data.success) {
cachedFolders = data.folders;
if (folderTargetField) {
document.getElementById(folderTargetField).value = fullName;
}
showFolderModal(folderTargetField);
} else {
errorEl.textContent = data.error;
errorEl.style.display = '';
input.disabled = false;
}
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = '';
input.disabled = false;
}
}
function closeFolderModal(event) {
if (event && event.target !== document.getElementById('folderModal')) return;
document.getElementById('folderModal').style.display = 'none';
cachedFolders = null;
createOpenFor = null;
}
// --- SMB Folder Picker ---
let cachedSmbFolders = null;
let smbFolderTargetField = null;
async function testSmb() {
const btn = event.currentTarget;
setButtonLoading(btn, true);
try {
const resp = await fetch('/api/test-smb', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.success) {
cachedSmbFolders = data.folders;
showAlert('SMB-Verbindung erfolgreich! Einstellungen gespeichert.', 'success');
showSmbFolderModal(null);
} else {
showAlert('SMB-Verbindung fehlgeschlagen: ' + data.error, 'error');
}
} catch (e) {
showAlert('Fehler: ' + e.message, 'error');
} finally {
setButtonLoading(btn, false);
}
}
function openSmbFolderPicker(targetField) {
smbFolderTargetField = targetField;
if (cachedSmbFolders) {
showSmbFolderModal(targetField);
} else {
showSmbFolderModalLoading(targetField);
fetchSmbFolders(targetField);
}
}
async function fetchSmbFolders(targetField) {
try {
const resp = await fetch('/api/test-smb', { method: 'POST', body: getFormData() });
const data = await resp.json();
if (data.success) {
cachedSmbFolders = data.folders;
showSmbFolderModal(targetField);
} else {
showSmbFolderModalError('SMB-Verbindung fehlgeschlagen: ' + data.error);
}
} catch (e) {
showSmbFolderModalError('Fehler: ' + e.message);
}
}
function showSmbFolderModalLoading(targetField) {
const modal = document.getElementById('smbFolderModal');
document.getElementById('smbFolderList').innerHTML = '';
document.getElementById('smbFolderLoading').style.display = '';
document.getElementById('smbFolderError').style.display = 'none';
modal.style.display = 'flex';
}
function showSmbFolderModal(targetField) {
if (targetField) smbFolderTargetField = targetField;
const modal = document.getElementById('smbFolderModal');
document.getElementById('smbFolderLoading').style.display = 'none';
document.getElementById('smbFolderError').style.display = 'none';
const list = document.getElementById('smbFolderList');
const currentValue = smbFolderTargetField ? document.getElementById(smbFolderTargetField).value : '';
let html = '<div class="folder-picker-fields">';
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_source_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_source_path\')">Quellordner: <strong>' + esc(document.getElementById('smb_source_path').value || '(Wurzel)') + '</strong></button>';
html += '<button type="button" class="folder-field-btn ' + (smbFolderTargetField === 'smb_processed_path' ? 'active' : '') + '" onclick="switchSmbFolderTarget(\'smb_processed_path\')">Verarbeitet-Ordner: <strong>' + esc(document.getElementById('smb_processed_path').value) + '</strong></button>';
html += '</div>';
html += '<div class="folder-items">';
// Root option for source path
if (smbFolderTargetField === 'smb_source_path') {
const isRoot = currentValue === '';
html += '<div class="folder-row">';
html += '<button type="button" class="folder-item' + (isRoot ? ' selected' : '') + '" onclick="selectSmbFolder(\'\')">';
html += '<span class="folder-icon">&#128193;</span> (Wurzel der Freigabe)';
if (isRoot) html += ' <span class="badge badge-success">ausgewählt</span>';
html += '</button>';
html += '</div>';
}
if (cachedSmbFolders && cachedSmbFolders.length > 0) {
cachedSmbFolders.forEach(folder => {
const isSelected = folder === currentValue;
const escapedFolder = folder.replace(/'/g, "\\'");
html += '<div class="folder-row">';
html += '<button type="button" class="folder-item' + (isSelected ? ' selected' : '') + '" onclick="selectSmbFolder(\'' + escapedFolder + '\')">';
html += '<span class="folder-icon">&#128193;</span> ' + esc(folder);
if (isSelected) html += ' <span class="badge badge-success">ausgewählt</span>';
html += '</button>';
html += '<button type="button" class="folder-add-btn" onclick="event.stopPropagation(); toggleSmbCreateInput(\'' + escapedFolder + '\')" title="Unterordner erstellen">&#128193;+</button>';
html += '</div>';
html += '<div id="smb-create-row-' + CSS.escape(folder) + '" class="create-inline" style="display:none;"></div>';
});
} else {
html += '<p class="text-muted" style="padding:0.5rem;">Keine Ordner gefunden.</p>';
}
// Create new root folder option
html += '<div class="folder-row" style="border-top:1px solid var(--border);margin-top:0.5rem;padding-top:0.5rem;">';
html += '<button type="button" class="folder-item" onclick="event.stopPropagation(); toggleSmbCreateInput(\'\')" style="color:var(--primary);">';
html += '<span class="folder-icon">&#128193;+</span> Neuen Ordner erstellen';
html += '</button>';
html += '</div>';
html += '<div id="smb-create-row-root" class="create-inline" style="display:none;"></div>';
html += '</div>';
list.innerHTML = html;
modal.style.display = 'flex';
}
function switchSmbFolderTarget(field) {
smbFolderTargetField = field;
showSmbFolderModal(field);
}
function selectSmbFolder(folder) {
if (smbFolderTargetField) {
document.getElementById(smbFolderTargetField).value = folder;
}
showSmbFolderModal(smbFolderTargetField);
}
function showSmbFolderModalError(msg) {
document.getElementById('smbFolderLoading').style.display = 'none';
document.getElementById('smbFolderError').textContent = msg;
document.getElementById('smbFolderError').style.display = '';
}
function toggleSmbCreateInput(parentFolder) {
document.querySelectorAll('.create-inline[id^="smb-create-row"]').forEach(el => {
const rowId = parentFolder === '' ? 'smb-create-row-root' : 'smb-create-row-' + CSS.escape(parentFolder);
if (el.id !== rowId) {
el.style.display = 'none';
el.innerHTML = '';
}
});
const rowId = parentFolder === '' ? 'smb-create-row-root' : 'smb-create-row-' + CSS.escape(parentFolder);
const row = document.getElementById(rowId);
if (!row) return;
if (row.style.display !== 'none') {
row.style.display = 'none';
row.innerHTML = '';
return;
}
const prefix = parentFolder ? parentFolder + '/' : '';
row.innerHTML =
'<div class="create-folder-inline">' +
'<span class="create-folder-prefix">' + esc(prefix) + '</span>' +
'<input type="text" class="create-folder-input" id="newSmbSubfolderInput" placeholder="Name" autofocus>' +
'<button type="button" class="btn btn-primary btn-sm" onclick="doCreateSmbFolder(\'' + parentFolder.replace(/'/g, "\\'") + '\')">OK</button>' +
'<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSmbCreateInput(\'' + parentFolder.replace(/'/g, "\\'") + '\')">Abbrechen</button>' +
'</div>' +
'<div id="smbCreateError" class="text-error" style="display:none;"></div>';
row.style.display = '';
const input = document.getElementById('newSmbSubfolderInput');
if (input) {
input.focus();
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); doCreateSmbFolder(parentFolder); }
if (e.key === 'Escape') { toggleSmbCreateInput(parentFolder); }
});
}
}
async function doCreateSmbFolder(parentFolder) {
const input = document.getElementById('newSmbSubfolderInput');
const errorEl = document.getElementById('smbCreateError');
if (!input) return;
const subName = input.value.trim();
if (!subName) {
errorEl.textContent = 'Bitte einen Namen eingeben.';
errorEl.style.display = '';
return;
}
const fullName = parentFolder ? parentFolder + '/' + subName : subName;
errorEl.style.display = 'none';
input.disabled = true;
try {
const resp = await fetch('/api/create-smb-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_name: fullName })
});
const data = await resp.json();
if (data.success) {
cachedSmbFolders = data.folders;
if (smbFolderTargetField) {
document.getElementById(smbFolderTargetField).value = fullName;
}
showSmbFolderModal(smbFolderTargetField);
} else {
errorEl.textContent = data.error;
errorEl.style.display = '';
input.disabled = false;
}
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = '';
input.disabled = false;
}
}
function closeSmbFolderModal(event) {
if (event && event.target !== document.getElementById('smbFolderModal')) return;
document.getElementById('smbFolderModal').style.display = 'none';
cachedSmbFolders = null;
}
</script>
{% endblock %}

12
docker-compose.yml Normal file
View File

@ -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

14
requirements.txt Normal file
View File

@ -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