Expose external mail provider presets
This commit is contained in:
@@ -103,3 +103,5 @@ msgid "App passwords"
|
||||
msgstr "App-Passwörter"
|
||||
msgid "Language and display"
|
||||
msgstr "Sprache und Darstellung"
|
||||
msgid "Provider"
|
||||
msgstr "Anbieter"
|
||||
|
||||
@@ -1471,3 +1471,5 @@ msgstr "Langue et affichage"
|
||||
|
||||
#~ msgid "trash"
|
||||
#~ msgstr "Trash"
|
||||
msgid "Provider"
|
||||
msgstr "Fournisseur"
|
||||
|
||||
@@ -956,3 +956,5 @@ msgstr "Senhas de aplicativos"
|
||||
|
||||
msgid "Language and display"
|
||||
msgstr "Idioma e aparência"
|
||||
msgid "Provider"
|
||||
msgstr "Provedor"
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user