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()
|