ae0813d519
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.
454 lines
17 KiB
Python
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) => ({{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }}[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()
|