Files
mailcat/manage_routes_web.py
T
wienen ae0813d519 Add route management web interface
Prompts used since previous commit:
- Kun je ook een webinterface maken waarmee ik de regels kan beheren?
- Ik wil dat de webinterface benaderbaar is vanaf mijn hele interne netwerk. En ik wil alle regels kunnen beheren. Dus ook de bestaande ingebouwde routes.
- 1. basic auth
- 2. 4321

Special observations:
- Domain routes moved from Python source to domain_routes.json so the web UI can manage all previous built-in domain routes.
- web_config.json remains ignored; live sorter processes must be restarted after route edits to pick up changed rules.
2026-07-05 00:19:53 +02:00

454 lines
17 KiB
Python

"""Small Basic Auth web UI for managing mailcat domain routes."""
from __future__ import annotations
import argparse
import base64
import html
import json
import os
import secrets
import shutil
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from mail_routes import DOMAIN_ROUTES_FILE, domain_lookup, domain_routes, normalize_domain_route
DEFAULT_CONFIG_FILE = Path(__file__).parent / "web_config.json"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 4321
def validate_routes(raw_routes: list[dict]) -> list[dict]:
"""Return normalized route dictionaries and reject duplicate domains."""
if not isinstance(raw_routes, list):
raise ValueError("Routes must be a list")
normalized: list[dict] = []
seen: dict[str, int] = {}
for index, raw_rule in enumerate(raw_routes):
domains, mailbox = normalize_domain_route(raw_rule)
for domain in domains:
if domain in seen:
other = seen[domain] + 1
raise ValueError(f"Domain {domain!r} is already used by route {other}")
seen[domain] = index
normalized.append({"domains": domains, "mailbox": mailbox})
return normalized
def load_routes(path: Path = DOMAIN_ROUTES_FILE) -> list[dict]:
raw = json.loads(path.read_text(encoding="utf-8"))
return validate_routes(raw)
def save_routes(routes: list[dict], path: Path = DOMAIN_ROUTES_FILE) -> list[dict]:
normalized = validate_routes(routes)
tmp_path = path.with_name(f".{path.name}.tmp")
backup_path = path.with_suffix(f"{path.suffix}.bak")
tmp_path.write_text(json.dumps(normalized, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
if path.exists():
shutil.copy2(path, backup_path)
os.replace(tmp_path, path)
domain_routes.cache_clear()
domain_lookup.cache_clear()
return normalized
def load_web_config(path: Path) -> tuple[str, str]:
config = {}
if path.exists():
config = json.loads(path.read_text(encoding="utf-8"))
username = os.environ.get("MAILCAT_WEB_USER") or config.get("username")
password = os.environ.get("MAILCAT_WEB_PASSWORD") or config.get("password")
if not username or not password:
raise SystemExit(
f"Missing web credentials. Create {path} or set MAILCAT_WEB_USER and MAILCAT_WEB_PASSWORD."
)
return str(username), str(password)
def basic_auth_ok(header: str | None, username: str, password: str) -> bool:
if not header or not header.startswith("Basic "):
return False
try:
decoded = base64.b64decode(header[6:], validate=True).decode("utf-8")
supplied_user, supplied_password = decoded.split(":", 1)
except Exception:
return False
return secrets.compare_digest(supplied_user, username) and secrets.compare_digest(
supplied_password, password
)
def page_html() -> bytes:
return f"""<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mailcat routes</title>
<style>
:root {{
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f6f8;
color: #20242a;
}}
body {{ margin: 0; }}
header {{
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 20px;
background: #17324d;
color: #fff;
}}
h1 {{ font-size: 18px; line-height: 1.2; margin: 0; font-weight: 650; }}
main {{ padding: 18px 20px 28px; max-width: 1280px; margin: 0 auto; }}
.toolbar {{
display: grid;
grid-template-columns: minmax(180px, 1fr) auto auto;
gap: 10px;
align-items: center;
margin-bottom: 14px;
}}
input, textarea {{
width: 100%;
box-sizing: border-box;
font: inherit;
border: 1px solid #b9c1cc;
border-radius: 6px;
padding: 8px 10px;
background: #fff;
color: #20242a;
}}
textarea {{ min-height: 72px; resize: vertical; }}
button {{
border: 1px solid #245782;
background: #245782;
color: #fff;
border-radius: 6px;
padding: 8px 12px;
font: inherit;
cursor: pointer;
white-space: nowrap;
}}
button.secondary {{ background: #fff; color: #245782; }}
button.danger {{ background: #8c2634; border-color: #8c2634; }}
button:disabled {{ opacity: .55; cursor: default; }}
.layout {{ display: grid; grid-template-columns: 1fr 360px; gap: 18px; align-items: start; }}
table {{ width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #d7dce2; }}
th, td {{ text-align: left; vertical-align: top; border-bottom: 1px solid #e2e6eb; padding: 8px 10px; }}
th {{ background: #edf1f5; font-size: 13px; color: #34495e; }}
tr.selected {{ background: #eaf4ff; }}
td.index {{ width: 56px; color: #596775; }}
td.actions {{ width: 96px; text-align: right; }}
.panel {{ background: #fff; border: 1px solid #d7dce2; padding: 14px; }}
.panel h2 {{ margin: 0 0 12px; font-size: 15px; }}
label {{ display: block; margin: 10px 0 6px; font-size: 13px; color: #34495e; }}
.form-actions {{ display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }}
.status {{ min-height: 20px; font-size: 13px; color: #34495e; }}
.error {{ color: #8c2634; }}
.domains {{ line-height: 1.45; }}
.mailbox {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }}
@media (max-width: 860px) {{
.layout {{ grid-template-columns: 1fr; }}
.toolbar {{ grid-template-columns: 1fr; }}
main {{ padding: 14px; }}
}}
</style>
</head>
<body>
<header>
<h1>Mailcat routes</h1>
<div id="count"></div>
</header>
<main>
<div class="toolbar">
<input id="search" type="search" placeholder="Zoek domein of map">
<button id="newButton" type="button" class="secondary">Nieuwe regel</button>
<button id="reloadButton" type="button" class="secondary">Herladen</button>
</div>
<div class="layout">
<table>
<thead><tr><th>#</th><th>Domeinen</th><th>Map</th><th></th></tr></thead>
<tbody id="routesBody"></tbody>
</table>
<section class="panel">
<h2 id="formTitle">Nieuwe regel</h2>
<form id="routeForm">
<input id="routeIndex" type="hidden">
<label for="domains">Domeinen, een per regel of komma-gescheiden</label>
<textarea id="domains" required></textarea>
<label for="mailbox">Doelmap zonder INBOX.</label>
<input id="mailbox" required placeholder="Diensten.AI.OpenAI">
<div class="form-actions">
<button id="saveButton" type="submit">Opslaan</button>
<button id="deleteButton" type="button" class="danger" disabled>Verwijderen</button>
<button id="clearButton" type="button" class="secondary">Leegmaken</button>
</div>
</form>
<p id="status" class="status"></p>
</section>
</div>
</main>
<script>
let routes = [];
let selectedIndex = null;
const $ = (id) => document.getElementById(id);
function splitDomains(value) {{
return value.split(/[\\n,]/).map((item) => item.trim()).filter(Boolean);
}}
function setStatus(text, isError = false) {{
$("status").textContent = text;
$("status").className = isError ? "status error" : "status";
}}
function clearForm() {{
selectedIndex = null;
$("routeIndex").value = "";
$("domains").value = "";
$("mailbox").value = "";
$("formTitle").textContent = "Nieuwe regel";
$("deleteButton").disabled = true;
renderRoutes();
}}
function editRoute(index) {{
const route = routes[index];
selectedIndex = index;
$("routeIndex").value = String(index);
$("domains").value = route.domains.join("\\n");
$("mailbox").value = route.mailbox;
$("formTitle").textContent = `Regel ${{index + 1}} bewerken`;
$("deleteButton").disabled = false;
renderRoutes();
}}
async function requestJson(url, options = {{}}) {{
const response = await fetch(url, {{
headers: {{ "Content-Type": "application/json", ...(options.headers || {{}}) }},
...options,
}});
const payload = await response.json().catch(() => ({{ error: response.statusText }}));
if (!response.ok) throw new Error(payload.error || response.statusText);
return payload;
}}
async function loadRoutes() {{
const payload = await requestJson("/api/routes");
routes = payload.routes;
$("count").textContent = `${{routes.length}} regels`;
renderRoutes();
setStatus("Geladen.");
}}
function renderRoutes() {{
const needle = $("search").value.trim().toLowerCase();
const rows = routes
.map((route, index) => ({{ route, index }}))
.filter(({{ route }}) => !needle || route.mailbox.toLowerCase().includes(needle) || route.domains.join(" ").toLowerCase().includes(needle))
.map(({{ route, index }}) => `
<tr class="${{selectedIndex === index ? "selected" : ""}}">
<td class="index">${{index + 1}}</td>
<td class="domains">${{route.domains.map(escapeHtml).join("<br>")}}</td>
<td class="mailbox">${{escapeHtml(route.mailbox)}}</td>
<td class="actions"><button type="button" class="secondary" onclick="editRoute(${{index}})">Bewerk</button></td>
</tr>`);
$("routesBody").innerHTML = rows.join("");
}}
function escapeHtml(value) {{
return value.replace(/[&<>"']/g, (ch) => ({{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }}[ch]));
}}
$("routeForm").addEventListener("submit", async (event) => {{
event.preventDefault();
const route = {{ domains: splitDomains($("domains").value), mailbox: $("mailbox").value.trim() }};
const indexValue = $("routeIndex").value;
try {{
const method = indexValue === "" ? "POST" : "PUT";
const url = indexValue === "" ? "/api/routes" : `/api/routes/${{indexValue}}`;
const payload = await requestJson(url, {{ method, body: JSON.stringify(route) }});
routes = payload.routes;
clearForm();
setStatus("Opgeslagen.");
}} catch (error) {{
setStatus(error.message, true);
}}
}});
$("deleteButton").addEventListener("click", async () => {{
if (selectedIndex === null) return;
if (!confirm(`Regel ${{selectedIndex + 1}} verwijderen?`)) return;
try {{
const payload = await requestJson(`/api/routes/${{selectedIndex}}`, {{ method: "DELETE" }});
routes = payload.routes;
clearForm();
setStatus("Verwijderd.");
}} catch (error) {{
setStatus(error.message, true);
}}
}});
$("search").addEventListener("input", renderRoutes);
$("newButton").addEventListener("click", clearForm);
$("clearButton").addEventListener("click", clearForm);
$("reloadButton").addEventListener("click", loadRoutes);
loadRoutes().catch((error) => setStatus(error.message, true));
</script>
</body>
</html>
""".encode("utf-8")
class RouteHandler(BaseHTTPRequestHandler):
routes_file = DOMAIN_ROUTES_FILE
username = ""
password = ""
def log_message(self, fmt: str, *args: object) -> None:
print(f"{self.address_string()} - {fmt % args}")
def _require_auth(self) -> bool:
if basic_auth_ok(self.headers.get("Authorization"), self.username, self.password):
return True
self.send_response(HTTPStatus.UNAUTHORIZED)
self.send_header("WWW-Authenticate", 'Basic realm="mailcat"')
self.send_header("Content-Length", "0")
self.end_headers()
return False
def _send(self, status: HTTPStatus, body: bytes, content_type: str) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _json(self, status: HTTPStatus, payload: dict) -> None:
self._send(
status,
json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8",
)
def _error(self, status: HTTPStatus, message: str) -> None:
self._json(status, {"error": message})
def _read_route_body(self) -> dict:
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
payload = json.loads(body.decode("utf-8"))
if not isinstance(payload, dict):
raise ValueError("Request body must contain one route object")
return payload
def _route_index(self) -> int | None:
parts = [part for part in urlparse(self.path).path.split("/") if part]
if len(parts) != 3 or parts[:2] != ["api", "routes"]:
return None
try:
return int(parts[2])
except ValueError:
return None
def do_GET(self) -> None:
if not self._require_auth():
return
path = urlparse(self.path).path
if path == "/":
self._send(HTTPStatus.OK, page_html(), "text/html; charset=utf-8")
return
if path == "/api/routes":
try:
self._json(HTTPStatus.OK, {"routes": load_routes(self.routes_file)})
except Exception as exc:
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
return
self._error(HTTPStatus.NOT_FOUND, "Not found")
def do_POST(self) -> None:
if not self._require_auth():
return
if urlparse(self.path).path != "/api/routes":
self._error(HTTPStatus.NOT_FOUND, "Not found")
return
try:
routes = load_routes(self.routes_file)
routes.append(self._read_route_body())
self._json(HTTPStatus.CREATED, {"routes": save_routes(routes, self.routes_file)})
except json.JSONDecodeError:
self._error(HTTPStatus.BAD_REQUEST, "Invalid JSON")
except ValueError as exc:
self._error(HTTPStatus.BAD_REQUEST, str(exc))
except Exception as exc:
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def do_PUT(self) -> None:
if not self._require_auth():
return
index = self._route_index()
if index is None:
self._error(HTTPStatus.NOT_FOUND, "Not found")
return
try:
routes = load_routes(self.routes_file)
if index < 0 or index >= len(routes):
raise ValueError("Route index out of range")
routes[index] = self._read_route_body()
self._json(HTTPStatus.OK, {"routes": save_routes(routes, self.routes_file)})
except json.JSONDecodeError:
self._error(HTTPStatus.BAD_REQUEST, "Invalid JSON")
except ValueError as exc:
self._error(HTTPStatus.BAD_REQUEST, str(exc))
except Exception as exc:
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def do_DELETE(self) -> None:
if not self._require_auth():
return
index = self._route_index()
if index is None:
self._error(HTTPStatus.NOT_FOUND, "Not found")
return
try:
routes = load_routes(self.routes_file)
if index < 0 or index >= len(routes):
raise ValueError("Route index out of range")
del routes[index]
self._json(HTTPStatus.OK, {"routes": save_routes(routes, self.routes_file)})
except ValueError as exc:
self._error(HTTPStatus.BAD_REQUEST, str(exc))
except Exception as exc:
self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
def main() -> None:
parser = argparse.ArgumentParser(description="Run the mailcat route management web UI.")
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG_FILE)
parser.add_argument("--routes-file", type=Path, default=DOMAIN_ROUTES_FILE)
args = parser.parse_args()
username, password = load_web_config(args.config)
RouteHandler.routes_file = args.routes_file
RouteHandler.username = username
RouteHandler.password = password
server = ThreadingHTTPServer((args.host, args.port), RouteHandler)
print(f"mailcat route web listening on http://{html.escape(args.host)}:{args.port}/")
server.serve_forever()
if __name__ == "__main__":
main()