Files
IMAPSYNC/backend/main.py
T
Sebastian Serfling f600cd52f3 fix: recover stale jobs on worker restart, persist active page on reload
- Worker resets running/cancelling jobs to idle on startup to fix jobs stuck after Docker restart
- Frontend saves current page to localStorage so reload returns to last visited page instead of always dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:07:13 +02:00

578 lines
20 KiB
Python

import os
import sqlite3
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Depends, status, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from jose import JWTError, jwt
DB_PATH = os.environ.get("DB_PATH", "/data/imapsync.db")
LOG_DIR = os.environ.get("LOG_DIR", "/data/logs")
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
ALGORITHM = "HS256"
TOKEN_EXPIRE_HOURS = 12
os.makedirs(LOG_DIR, exist_ok=True)
def get_db():
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db():
conn = get_db()
tables = [r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
needs_migration = 'servers' not in tables and 'sync_jobs' in tables
if needs_migration:
conn.executescript("DROP TABLE IF EXISTS job_runs; DROP TABLE IF EXISTS sync_jobs;")
conn.executescript("""
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 993,
ssl INTEGER DEFAULT 1,
direction TEXT NOT NULL DEFAULT 'both',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_md5 TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS sync_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
src_server_id INTEGER NOT NULL,
src_user TEXT NOT NULL,
src_password TEXT NOT NULL,
dst_server_id INTEGER NOT NULL,
dst_user TEXT NOT NULL,
dst_password TEXT NOT NULL,
extra_args TEXT DEFAULT '',
schedule TEXT DEFAULT NULL,
enabled INTEGER DEFAULT 1,
status TEXT DEFAULT 'idle',
last_run DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by TEXT,
FOREIGN KEY (src_server_id) REFERENCES servers(id),
FOREIGN KEY (dst_server_id) REFERENCES servers(id)
);
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
finished_at DATETIME,
status TEXT DEFAULT 'running',
log_file TEXT,
messages_synced INTEGER DEFAULT 0,
messages_skipped INTEGER DEFAULT 0,
errors INTEGER DEFAULT 0,
duration_sec INTEGER DEFAULT 0,
FOREIGN KEY (job_id) REFERENCES sync_jobs(id)
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
expires_at DATETIME NOT NULL
);
""")
cur = conn.execute("SELECT COUNT(*) FROM users")
if cur.fetchone()[0] == 0:
pw_md5 = hashlib.md5("admin".encode()).hexdigest()
conn.execute(
"INSERT INTO users (username, password_md5, role) VALUES (?, ?, ?)",
("admin", pw_md5, "admin")
)
conn.commit()
conn.close()
def md5_hash(pw: str) -> str:
return hashlib.md5(pw.encode()).hexdigest()
def create_token(username: str, role: str) -> str:
expire = datetime.utcnow() + timedelta(hours=TOKEN_EXPIRE_HOURS)
payload = {"sub": username, "role": role, "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(status_code=401, detail="Token ungültig oder abgelaufen")
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
return decode_token(credentials.credentials)
def require_admin(user=Depends(get_current_user)):
if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Nur Admins erlaubt")
return user
def require_editor(user=Depends(get_current_user)):
if user.get("role") == "viewer":
raise HTTPException(status_code=403, detail="Keine Berechtigung")
return user
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
yield
app = FastAPI(title="ImapSync Manager", lifespan=lifespan)
class LoginRequest(BaseModel):
username: str
password: str
class UserCreate(BaseModel):
username: str
password: str
role: str = "viewer"
class UserUpdate(BaseModel):
password: Optional[str] = None
role: Optional[str] = None
class ServerCreate(BaseModel):
name: str
host: str
port: int = 993
ssl: bool = True
direction: str = "both"
class ServerUpdate(BaseModel):
name: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = None
ssl: Optional[bool] = None
direction: Optional[str] = None
class SyncJobCreate(BaseModel):
name: str
src_server_id: int
src_user: str
src_password: str
dst_server_id: int
dst_user: str
dst_password: str
extra_args: str = ""
schedule: Optional[str] = None
enabled: bool = True
class SyncJobUpdate(BaseModel):
name: Optional[str] = None
src_server_id: Optional[int] = None
src_user: Optional[str] = None
src_password: Optional[str] = None
dst_server_id: Optional[int] = None
dst_user: Optional[str] = None
dst_password: Optional[str] = None
extra_args: Optional[str] = None
schedule: Optional[str] = None
enabled: Optional[bool] = None
@app.get("/api/health")
def health():
return {"status": "ok", "time": datetime.utcnow().isoformat()}
@app.post("/api/auth/login")
def login(req: LoginRequest):
conn = get_db()
row = conn.execute(
"SELECT * FROM users WHERE username = ? AND password_md5 = ?",
(req.username, md5_hash(req.password))
).fetchone()
conn.close()
if not row:
raise HTTPException(status_code=401, detail="Ungültige Zugangsdaten")
token = create_token(row["username"], row["role"])
return {"token": token, "username": row["username"], "role": row["role"]}
@app.get("/api/auth/me")
def me(user=Depends(get_current_user)):
return user
@app.get("/api/users")
def list_users(user=Depends(require_admin)):
conn = get_db()
rows = conn.execute(
"SELECT id, username, role, created_at FROM users ORDER BY created_at DESC"
).fetchall()
conn.close()
return [dict(r) for r in rows]
@app.post("/api/users", status_code=201)
def create_user(req: UserCreate, user=Depends(require_admin)):
conn = get_db()
try:
conn.execute(
"INSERT INTO users (username, password_md5, role) VALUES (?, ?, ?)",
(req.username, md5_hash(req.password), req.role)
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="Benutzername bereits vergeben")
finally:
conn.close()
return {"message": f"Benutzer '{req.username}' erstellt"}
@app.put("/api/users/{user_id}")
def update_user(user_id: int, req: UserUpdate, user=Depends(require_admin)):
conn = get_db()
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
updates = {}
if req.password:
updates["password_md5"] = md5_hash(req.password)
if req.role:
updates["role"] = req.role
if updates:
set_clause = ", ".join(f"{k} = ?" for k in updates)
conn.execute(
f"UPDATE users SET {set_clause} WHERE id = ?",
(*updates.values(), user_id)
)
conn.commit()
conn.close()
return {"message": "Benutzer aktualisiert"}
@app.delete("/api/users/{user_id}")
def delete_user(user_id: int, user=Depends(require_admin)):
conn = get_db()
row = conn.execute("SELECT username FROM users WHERE id = ?", (user_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Benutzer nicht gefunden")
if row["username"] == user["sub"]:
raise HTTPException(status_code=400, detail="Eigenen Account nicht löschbar")
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
conn.close()
return {"message": "Benutzer gelöscht"}
@app.get("/api/servers")
def list_servers(user=Depends(get_current_user)):
conn = get_db()
rows = conn.execute("""
SELECT s.*,
(SELECT COUNT(*) FROM sync_jobs j WHERE j.src_server_id = s.id) as used_as_src,
(SELECT COUNT(*) FROM sync_jobs j WHERE j.dst_server_id = s.id) as used_as_dst
FROM servers s ORDER BY s.name
""").fetchall()
conn.close()
return [dict(r) for r in rows]
@app.post("/api/servers", status_code=201)
def create_server(req: ServerCreate, user=Depends(require_editor)):
conn = get_db()
cur = conn.execute(
"INSERT INTO servers (name, host, port, ssl, direction) VALUES (?, ?, ?, ?, ?)",
(req.name, req.host, req.port, int(req.ssl), req.direction)
)
conn.commit()
server_id = cur.lastrowid
conn.close()
return {"message": "Server erstellt", "id": server_id}
@app.put("/api/servers/{server_id}")
def update_server(server_id: int, req: ServerUpdate, user=Depends(require_editor)):
conn = get_db()
row = conn.execute("SELECT id FROM servers WHERE id = ?", (server_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Server nicht gefunden")
fields = {k: v for k, v in req.model_dump().items() if v is not None}
if "ssl" in fields:
fields["ssl"] = int(fields["ssl"])
if fields:
set_clause = ", ".join(f"{k} = ?" for k in fields)
conn.execute(
f"UPDATE servers SET {set_clause} WHERE id = ?",
(*fields.values(), server_id)
)
conn.commit()
conn.close()
return {"message": "Server aktualisiert"}
@app.delete("/api/servers/{server_id}")
def delete_server(server_id: int, user=Depends(require_editor)):
conn = get_db()
row = conn.execute("SELECT id FROM servers WHERE id = ?", (server_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Server nicht gefunden")
in_use = conn.execute(
"SELECT COUNT(*) FROM sync_jobs WHERE src_server_id = ? OR dst_server_id = ?",
(server_id, server_id)
).fetchone()[0]
if in_use > 0:
raise HTTPException(status_code=409, detail="Server wird noch in Jobs verwendet")
conn.execute("DELETE FROM servers WHERE id = ?", (server_id,))
conn.commit()
conn.close()
return {"message": "Server gelöscht"}
@app.get("/api/jobs")
def list_jobs(user=Depends(get_current_user)):
conn = get_db()
rows = conn.execute("""
SELECT j.*,
s1.name as src_server_name, s1.host as src_host, s1.port as src_port, s1.ssl as src_ssl,
s2.name as dst_server_name, s2.host as dst_host, s2.port as dst_port, s2.ssl as dst_ssl,
(SELECT COUNT(*) FROM job_runs r WHERE r.job_id = j.id) as run_count,
(SELECT SUM(messages_synced) FROM job_runs r WHERE r.job_id = j.id) as total_synced
FROM sync_jobs j
LEFT JOIN servers s1 ON j.src_server_id = s1.id
LEFT JOIN servers s2 ON j.dst_server_id = s2.id
ORDER BY j.created_at DESC
""").fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
d.pop("src_password", None)
d.pop("dst_password", None)
result.append(d)
return result
@app.post("/api/jobs", status_code=201)
def create_job(req: SyncJobCreate, user=Depends(require_editor)):
conn = get_db()
for sid in [req.src_server_id, req.dst_server_id]:
if not conn.execute("SELECT id FROM servers WHERE id = ?", (sid,)).fetchone():
conn.close()
raise HTTPException(status_code=404, detail=f"Server-ID {sid} nicht gefunden")
cur = conn.execute("""
INSERT INTO sync_jobs
(name, src_server_id, src_user, src_password,
dst_server_id, dst_user, dst_password,
extra_args, schedule, enabled, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
""", (req.name, req.src_server_id, req.src_user, req.src_password,
req.dst_server_id, req.dst_user, req.dst_password,
req.extra_args, req.schedule, int(req.enabled), user["sub"]))
conn.commit()
job_id = cur.lastrowid
conn.close()
return {"message": "Job erstellt", "id": job_id}
@app.get("/api/jobs/{job_id}")
def get_job(job_id: int, user=Depends(get_current_user)):
conn = get_db()
row = conn.execute("""
SELECT j.*,
s1.name as src_server_name, s1.host as src_host, s1.port as src_port, s1.ssl as src_ssl,
s2.name as dst_server_name, s2.host as dst_host, s2.port as dst_port, s2.ssl as dst_ssl
FROM sync_jobs j
LEFT JOIN servers s1 ON j.src_server_id = s1.id
LEFT JOIN servers s2 ON j.dst_server_id = s2.id
WHERE j.id = ?
""", (job_id,)).fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
d = dict(row)
if user.get("role") == "viewer":
d.pop("src_password", None)
d.pop("dst_password", None)
return d
@app.put("/api/jobs/{job_id}")
def update_job(job_id: int, req: SyncJobUpdate, user=Depends(require_editor)):
conn = get_db()
row = conn.execute("SELECT id FROM sync_jobs WHERE id = ?", (job_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
fields = {k: v for k, v in req.model_dump().items() if v is not None}
if "enabled" in fields:
fields["enabled"] = int(fields["enabled"])
if "src_server_id" in fields:
if not conn.execute("SELECT id FROM servers WHERE id = ?", (fields["src_server_id"],)).fetchone():
conn.close()
raise HTTPException(status_code=404, detail="Quell-Server nicht gefunden")
if "dst_server_id" in fields:
if not conn.execute("SELECT id FROM servers WHERE id = ?", (fields["dst_server_id"],)).fetchone():
conn.close()
raise HTTPException(status_code=404, detail="Ziel-Server nicht gefunden")
if fields:
set_clause = ", ".join(f"{k} = ?" for k in fields)
conn.execute(
f"UPDATE sync_jobs SET {set_clause} WHERE id = ?",
(*fields.values(), job_id)
)
conn.commit()
conn.close()
return {"message": "Job aktualisiert"}
@app.delete("/api/jobs/{job_id}")
def delete_job(job_id: int, user=Depends(require_editor)):
conn = get_db()
conn.execute("DELETE FROM job_runs WHERE job_id = ?", (job_id,))
conn.execute("DELETE FROM sync_jobs WHERE id = ?", (job_id,))
conn.commit()
conn.close()
return {"message": "Job gelöscht"}
@app.post("/api/jobs/{job_id}/trigger")
def trigger_job(job_id: int, user=Depends(require_editor)):
conn = get_db()
row = conn.execute("SELECT status FROM sync_jobs WHERE id = ?", (job_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if row["status"] in ("queued", "running"):
raise HTTPException(status_code=409, detail="Job läuft bereits oder ist in der Warteschlange")
conn.execute("UPDATE sync_jobs SET status = 'queued' WHERE id = ?", (job_id,))
conn.commit()
conn.close()
return {"message": "Job in Warteschlange eingereiht"}
@app.post("/api/jobs/{job_id}/stop")
def stop_job(job_id: int, user=Depends(require_editor)):
conn = get_db()
row = conn.execute("SELECT status FROM sync_jobs WHERE id = ?", (job_id,)).fetchone()
if not row:
conn.close()
raise HTTPException(status_code=404, detail="Job nicht gefunden")
if row["status"] == "queued":
conn.execute("UPDATE sync_jobs SET status = 'idle' WHERE id = ?", (job_id,))
elif row["status"] == "running":
conn.execute("UPDATE sync_jobs SET status = 'cancelling' WHERE id = ?", (job_id,))
conn.execute("""
UPDATE job_runs SET status = 'cancelled', finished_at = ?
WHERE job_id = ? AND status = 'running'
""", (datetime.utcnow().isoformat(), job_id))
conn.commit()
conn.close()
return {"message": "Job wird abgebrochen"}
@app.get("/api/jobs/{job_id}/runs")
def get_job_runs(job_id: int, limit: int = 50, user=Depends(get_current_user)):
conn = get_db()
rows = conn.execute("""
SELECT * FROM job_runs WHERE job_id = ?
ORDER BY started_at DESC LIMIT ?
""", (job_id, limit)).fetchall()
conn.close()
return [dict(r) for r in rows]
@app.get("/api/runs/{run_id}/log")
def get_run_log(run_id: int, user=Depends(get_current_user)):
conn = get_db()
row = conn.execute("SELECT log_file FROM job_runs WHERE id = ?", (run_id,)).fetchone()
conn.close()
if not row or not row["log_file"]:
raise HTTPException(status_code=404, detail="Kein Log gefunden")
log_path = os.path.join(LOG_DIR, row["log_file"])
if not os.path.exists(log_path):
raise HTTPException(status_code=404, detail="Logdatei nicht vorhanden")
with open(log_path, "r", errors="replace") as f:
return {"content": f.read()}
@app.get("/api/stats")
def get_stats(user=Depends(get_current_user)):
conn = get_db()
total_jobs = conn.execute("SELECT COUNT(*) FROM sync_jobs").fetchone()[0]
active_jobs = conn.execute("SELECT COUNT(*) FROM sync_jobs WHERE enabled=1").fetchone()[0]
running = conn.execute("SELECT COUNT(*) FROM sync_jobs WHERE status='running'").fetchone()[0]
queued = conn.execute("SELECT COUNT(*) FROM sync_jobs WHERE status='queued'").fetchone()[0]
total_runs = conn.execute("SELECT COUNT(*) FROM job_runs").fetchone()[0]
failed_runs = conn.execute("SELECT COUNT(*) FROM job_runs WHERE status='failed'").fetchone()[0]
total_synced = conn.execute("SELECT COALESCE(SUM(messages_synced),0) FROM job_runs").fetchone()[0]
total_errors = conn.execute("SELECT COALESCE(SUM(errors),0) FROM job_runs").fetchone()[0]
daily = conn.execute("""
SELECT DATE(started_at) as day,
COUNT(*) as runs,
COALESCE(SUM(messages_synced),0) as synced,
COALESCE(SUM(errors),0) as errors
FROM job_runs
WHERE started_at >= DATE('now', '-14 days')
GROUP BY day ORDER BY day
""").fetchall()
recent = conn.execute("""
SELECT r.id, r.job_id, j.name as job_name, r.started_at, r.finished_at,
r.status, r.messages_synced, r.errors, r.duration_sec
FROM job_runs r
JOIN sync_jobs j ON j.id = r.job_id
ORDER BY r.started_at DESC LIMIT 10
""").fetchall()
total_servers = conn.execute("SELECT COUNT(*) FROM servers").fetchone()[0]
conn.close()
return {
"jobs": {"total": total_jobs, "active": active_jobs, "running": running, "queued": queued},
"runs": {"total": total_runs, "failed": failed_runs},
"messages": {"synced": total_synced, "errors": total_errors},
"servers": {"total": total_servers},
"daily": [dict(d) for d in daily],
"recent_runs": [dict(r) for r in recent],
}
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/{full_path:path}", response_class=HTMLResponse)
async def serve_spa(full_path: str):
with open("static/index.html", "r") as f:
return HTMLResponse(f.read())