Expose external mail provider presets

This commit is contained in:
Mario Fetka
2026-07-16 16:24:50 +02:00
parent fea320b90c
commit e53392592e
7 changed files with 143 additions and 0 deletions
+2
View File
@@ -103,3 +103,5 @@ msgid "App passwords"
msgstr "App-Passwörter"
msgid "Language and display"
msgstr "Sprache und Darstellung"
msgid "Provider"
msgstr "Anbieter"
+2
View File
@@ -1471,3 +1471,5 @@ msgstr "Langue et affichage"
#~ msgid "trash"
#~ msgstr "Trash"
msgid "Provider"
msgstr "Fournisseur"
+2
View File
@@ -956,3 +956,5 @@ msgstr "Senhas de aplicativos"
msgid "Language and display"
msgstr "Idioma e aparência"
msgid "Provider"
msgstr "Provedor"
+13
View File
@@ -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":
+54
View File
@@ -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}
+28
View File
@@ -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");
+42
View File
@@ -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()