diff --git a/po/bongo-web/de.po b/po/bongo-web/de.po index 55e08f5..0658aa5 100644 --- a/po/bongo-web/de.po +++ b/po/bongo-web/de.po @@ -103,3 +103,5 @@ msgid "App passwords" msgstr "App-Passwörter" msgid "Language and display" msgstr "Sprache und Darstellung" +msgid "Provider" +msgstr "Anbieter" diff --git a/po/bongo-web/fr.po b/po/bongo-web/fr.po index 240dc6e..e23ca7b 100644 --- a/po/bongo-web/fr.po +++ b/po/bongo-web/fr.po @@ -1471,3 +1471,5 @@ msgstr "Langue et affichage" #~ msgid "trash" #~ msgstr "Trash" +msgid "Provider" +msgstr "Fournisseur" diff --git a/po/bongo-web/pt_BR.po b/po/bongo-web/pt_BR.po index 1fe1fb5..3c23d04 100644 --- a/po/bongo-web/pt_BR.po +++ b/po/bongo-web/pt_BR.po @@ -956,3 +956,5 @@ msgstr "Senhas de aplicativos" msgid "Language and display" msgstr "Idioma e aparência" +msgid "Provider" +msgstr "Provedor" diff --git a/src/www/bongo-web.py.in b/src/www/bongo-web.py.in index 32738c9..3970047 100644 --- a/src/www/bongo-web.py.in +++ b/src/www/bongo-web.py.in @@ -40,11 +40,13 @@ from bongo_web.session import (COOKIE_NAME, SessionStore, from bongo_web.tasks import TaskStore from bongo_web.mail_store import list_conversations, list_folders, read_conversation from bongo_web.compose import send_message +from bongo_web.providers import load_provider_presets DEFAULT_CONFIG = "@SYSCONF_INSTALL_DIR@/bongo/bongo-web.json" STATIC_DIR = "@DATA_INSTALL_DIR@/web" LOCALE_DIR = "@LOCALEDIR@" +PROVIDER_PRESETS = "@DATA_INSTALL_DIR@/providers/provider-presets.json" SERVER_NAME = "Bongo" STATIC_FILES = { "/assets/app.css": ("app.css", "text/css; charset=utf-8"), @@ -80,6 +82,7 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail", "Tasks", "No open tasks.", "Ignore", "Disable", "Done", "Settings", "Personal settings", "Open tasks", "Filters", "External mail accounts", + "Provider", "Sender identities", "Two-factor authentication", "App passwords", "Language and display") SUPPORTED_LANGUAGES = ("en", "de", "fr", "pt_BR") @@ -111,6 +114,8 @@ class BongoWebServer(ThreadingHTTPServer): config.get("login_window", 300)) self.tasks = TaskStore(config.get("task_database", "/var/lib/bongo/web/tasks.db")) + self.providers = load_provider_presets( + config.get("provider_presets", PROVIDER_PRESETS)) class BongoWebHandler(BaseHTTPRequestHandler): @@ -361,6 +366,14 @@ class BongoWebHandler(BaseHTTPRequestHandler): {"tasks": self.server.tasks.list( session["user"], include_closed=include_closed)}, {"Vary": "Cookie"}) + elif path == "/api/external-accounts/providers": + session = self._session() + if session is None: + self._json(HTTPStatus.UNAUTHORIZED, + {"error": "authentication required"}) + else: + self._json(HTTPStatus.OK, self.server.providers, + {"Vary": "Cookie"}) elif path == "/.well-known/caldav": self._dav_redirect("calendars") elif path == "/.well-known/carddav": diff --git a/src/www/bongo_web/providers.py b/src/www/bongo_web/providers.py new file mode 100644 index 0000000..cad5f18 --- /dev/null +++ b/src/www/bongo_web/providers.py @@ -0,0 +1,54 @@ +"""Validated public metadata for external mail provider setup.""" + +from __future__ import annotations + +import json +import os + +MAX_PRESET_SIZE = 1024 * 1024 +PROTOCOLS = ("imap", "pop3", "smtp") +TLS_MODES = {"implicit", "starttls"} + + +def load_provider_presets(path: str) -> dict: + if os.path.getsize(path) > MAX_PRESET_SIZE: + raise ValueError("provider preset file is too large") + with open(path, "r", encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict) or not isinstance(value.get("providers"), list): + raise ValueError("provider preset document has an invalid shape") + providers = [] + seen = set() + for source in value["providers"]: + if not isinstance(source, dict): + raise ValueError("provider entry must be an object") + provider_id = source.get("id") + name = source.get("name") + if (not isinstance(provider_id, str) or not provider_id.isascii() or + not provider_id.replace("-", "").isalnum() or + not isinstance(name, str) or not name or provider_id in seen): + raise ValueError("provider identity is invalid or duplicated") + seen.add(provider_id) + provider = {"id": provider_id, "name": name, + "domains": list(source.get("domains", [])), + "authentication": list(source.get("authentication", []))} + for protocol in PROTOCOLS: + endpoint = source.get(protocol) + if endpoint is None: + continue + if (not isinstance(endpoint, dict) or + not isinstance(endpoint.get("host"), str) or + not isinstance(endpoint.get("port"), int) or + not 1 <= endpoint["port"] <= 65535 or + endpoint.get("tls") not in TLS_MODES): + raise ValueError(f"invalid {protocol} endpoint for {provider_id}") + provider[protocol] = {key: endpoint[key] + for key in ("host", "port", "tls")} + for field in ("preferred_authentication", "username", "setup_note", + "special_mode"): + if isinstance(source.get(field), str): + provider[field] = source[field] + providers.append(provider) + return {"schema_version": value.get("schema_version", 1), + "reviewed": value.get("reviewed", ""), + "providers": providers} diff --git a/src/www/static/app.js b/src/www/static/app.js index 3063a1e..9cab671 100644 --- a/src/www/static/app.js +++ b/src/www/static/app.js @@ -63,6 +63,34 @@ async function renderSettings(name) { content.replaceChildren(list); return; } + if (name === "external-accounts") { + const response = await fetch("/api/external-accounts/providers", {credentials: "same-origin"}); + if (!response.ok) return; + const result = await response.json(); + const heading = document.createElement("h3"); + heading.textContent = tr("External mail accounts"); + const label = document.createElement("label"); + label.textContent = tr("Provider"); + const select = document.createElement("select"); + select.append(...result.providers.map(provider => { + const option = document.createElement("option"); + option.value = provider.id; + option.textContent = provider.name; + return option; + })); + const details = document.createElement("pre"); + const showProvider = () => { + const provider = result.providers.find(item => item.id === select.value); + const endpoints = ["imap", "pop3", "smtp"].filter(name => provider[name]).map(name => + `${name.toUpperCase()}: ${provider[name].host}:${provider[name].port} (${provider[name].tls})`); + details.textContent = endpoints.join("\n"); + }; + select.addEventListener("change", showProvider); + label.append(select); + showProvider(); + content.replaceChildren(heading, label, details); + return; + } const heading = document.createElement("h3"); const button = document.querySelector(`.settings-nav button[data-settings="${name}"]`); heading.textContent = button ? button.textContent : tr("Settings"); diff --git a/src/www/tests/test_providers.py b/src/www/tests/test_providers.py new file mode 100644 index 0000000..b79dc6f --- /dev/null +++ b/src/www/tests/test_providers.py @@ -0,0 +1,42 @@ +import json +import os +import tempfile +import unittest + +from bongo_web.providers import load_provider_presets + + +class ProviderPresetTests(unittest.TestCase): + def write(self, value): + handle, path = tempfile.mkstemp() + with os.fdopen(handle, "w", encoding="utf-8") as stream: + json.dump(value, stream) + self.addCleanup(os.unlink, path) + return path + + def test_returns_only_setup_metadata(self): + path = self.write({"schema_version": 1, "providers": [{ + "id": "example", "name": "Example", "domains": ["example.test"], + "imap": {"host": "imap.example.test", "port": 993, + "tls": "implicit"}, + "authentication": ["app_password"], "source": "https://private/", + "unexpected_secret": "must not leave the server"}]}) + provider = load_provider_presets(path)["providers"][0] + self.assertEqual(provider["imap"]["port"], 993) + self.assertNotIn("unexpected_secret", provider) + self.assertNotIn("source", provider) + + def test_rejects_duplicate_and_invalid_endpoints(self): + duplicate = {"providers": [{"id": "same", "name": "One"}, + {"id": "same", "name": "Two"}]} + with self.assertRaises(ValueError): + load_provider_presets(self.write(duplicate)) + invalid = {"providers": [{"id": "bad", "name": "Bad", + "imap": {"host": "x", "port": 0, + "tls": "none"}}]} + with self.assertRaises(ValueError): + load_provider_presets(self.write(invalid)) + + +if __name__ == "__main__": + unittest.main()