Complete classic webmail organizer views
Debian Trixie package bundle / packages (push) Successful in 21m22s

This commit is contained in:
Mario Fetka
2026-07-22 04:36:18 +02:00
parent 56884a540a
commit 39406774bb
19 changed files with 1474 additions and 61 deletions
+1
View File
@@ -7,6 +7,7 @@
"public_base_url": "",
"external_https": false,
"login_domains": [],
"administrators": ["admin"],
"allow_basic_auth": true,
"trusted_proxies": ["127.0.0.1", "::1"],
"client_ip_header": "X-Forwarded-For",
@@ -127,6 +127,19 @@ class ConfigurationModelTest(unittest.TestCase):
self.assertTrue(any("web.login_domains[0]" in message
for message in messages))
def test_web_administrators_are_explicit_and_validated(self):
defaults = templates()
self.assertEqual(defaults["web"]["administrators"], ["admin"])
configs = apply_scenario(defaults, Scenario(
hostname="mail.example.test", bind_address="192.0.2.10",
domains=["example.test"], admin_password="0123456789ab",
))
configs["web"]["administrators"] = []
messages = [str(issue) for issue in validate_all(
configs, defaults, check_files=False)]
self.assertTrue(any("web.administrators" in message
for message in messages))
def test_web_login_domains_follow_hosted_mail_domains_on_sync(self):
configs = apply_scenario(templates(), Scenario(
hostname="mail.example.test", bind_address="192.0.2.10",
@@ -876,6 +876,15 @@ def _validate_web(config: Mapping[str, Any], issues: list[Issue],
_port(issues, "web", config, "port")
_validate_domains(issues, "web", "login_domains",
config.get("login_domains"))
administrators = config.get("administrators")
if not isinstance(administrators, list) or not administrators:
_issue(issues, "web", "administrators",
"expected at least one administrator login")
elif any(not isinstance(value, str) or not value.strip()
or len(value) > 320 or any(ord(char) < 32 for char in value)
for value in administrators):
_issue(issues, "web", "administrators",
"expected non-empty administrator login names")
_validate_networks(issues, "web", "trusted_proxies", config.get("trusted_proxies"), False)
header = config.get("client_ip_header")
if not isinstance(header, str) or not _HEADER_NAME.fullmatch(header):
+12 -2
View File
@@ -40,8 +40,12 @@ python_add_module(_auth_security
auth-security-module.c
)
python_add_module(_rules
rules-module.c
)
# remove 'lib' prefix from these libraries.
set_target_properties(libs bootstrap _calendar _external_accounts _qrcode _auth_security
set_target_properties(libs bootstrap _calendar _external_accounts _qrcode _auth_security _rules
PROPERTIES
PREFIX ""
)
@@ -89,7 +93,13 @@ target_link_libraries(_auth_security
${PYTHON_LIBRARIES}
)
install(TARGETS libs bootstrap _calendar _external_accounts _qrcode _auth_security DESTINATION ${PYTHON_SITELIB_PATH}/libbongo/)
target_link_libraries(_rules
bongomsgapi
bongoxpl
${PYTHON_LIBRARIES}
)
install(TARGETS libs bootstrap _calendar _external_accounts _qrcode _auth_security _rules DESTINATION ${PYTHON_SITELIB_PATH}/libbongo/)
install(FILES __init__.py DESTINATION ${PYTHON_SITELIB_PATH}/libbongo/)
if(BUILD_TESTING)
+114
View File
@@ -0,0 +1,114 @@
/****************************************************************************
*
* Copyright (c) 2006 Novell, Inc.
* All Rights Reserved.
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of version 2.1 of the GNU Lesser General Public
* License as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
****************************************************************************/
#include <Python.h>
#include <memmgr.h>
#include <nmlib.h>
#include <stdio.h>
#include <string.h>
static int
RulesPath(const char *user, char *path, size_t length)
{
const unsigned char *cursor;
size_t userLength;
int written;
if (!user || !(userLength = strlen(user)) || userLength > 320)
return 0;
for (cursor = (const unsigned char *)user; *cursor; cursor++) {
if (*cursor <= ' ' || *cursor == 0x7f || *cursor == '/' ||
*cursor == '\\' || *cursor == '"')
return 0;
}
written = snprintf(path, length, "rules/%s", user);
return written > 0 && (size_t)written < length;
}
static PyObject *
ReadRules(PyObject *self, PyObject *args)
{
const char *user;
char path[350];
char *content = NULL;
BOOL success;
(void)self;
if (!PyArg_ParseTuple(args, "s", &user))
return NULL;
if (!RulesPath(user, path, sizeof(path))) {
PyErr_SetString(PyExc_ValueError, "invalid rules owner");
return NULL;
}
Py_BEGIN_ALLOW_THREADS
success = NMAPReadConfigFile(path, &content);
Py_END_ALLOW_THREADS
if (!success || !content) {
if (content)
MemFree(content);
Py_RETURN_NONE;
}
{
PyObject *result = PyUnicode_FromString(content);
MemFree(content);
return result;
}
}
static PyObject *
ReplaceRules(PyObject *self, PyObject *args)
{
const char *user;
const char *content;
Py_ssize_t contentLength;
char path[350];
BOOL success;
(void)self;
if (!PyArg_ParseTuple(args, "ss#", &user, &content, &contentLength))
return NULL;
if (!RulesPath(user, path, sizeof(path)) || contentLength < 0 ||
contentLength > 1048576) {
PyErr_SetString(PyExc_ValueError, "invalid rules document");
return NULL;
}
Py_BEGIN_ALLOW_THREADS
success = NMAPReplaceConfigFile(path, content, (size_t)contentLength);
Py_END_ALLOW_THREADS
if (!success) {
PyErr_SetString(PyExc_RuntimeError, "could not replace rules document");
return NULL;
}
Py_RETURN_NONE;
}
static PyMethodDef Methods[] = {
{"read", ReadRules, METH_VARARGS, "Read one user's rules document."},
{"replace", ReplaceRules, METH_VARARGS, "Replace one user's rules document."},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef Module = {
PyModuleDef_HEAD_INIT, "_rules", NULL, -1, Methods,
NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC
PyInit__rules(void)
{
return PyModule_Create(&Module);
}
+110 -4
View File
@@ -39,6 +39,7 @@ import logging
import os
import posixpath
import re
import socket
import ssl
import threading
from http import HTTPStatus
@@ -75,6 +76,7 @@ from bongo_web.external_accounts import (create_account as create_external_accou
delete_account as delete_external_account,
list_accounts as list_external_accounts,
list_identities as list_external_identities)
from bongo_web.filters import (delete_filter, list_filters, save_filter)
from bongo_web.security import (SECOND_FACTOR_VALID, ExpiringSecrets,
disable_totp, enable_totp,
hosted_login_username,
@@ -115,7 +117,7 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail",
"Create and manage calendar events.",
"Create and manage contacts.",
"The secure webmail connection is being enabled.",
"Administrative functions require a server-side role check.",
"Server administration and service status.",
"Folder", "Refresh", "(no subject)", "Back",
"Load external images", "[external image blocked]",
"Compose new mail", "From", "To", "Cc", "Bcc", "Subject",
@@ -153,7 +155,34 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail",
"Review the following information and complete the required change.",
"Details", "New event", "New contact", "Title", "Start",
"End", "Location", "Description", "Name", "Phone",
"Organization", "(no title)")
"Organization", "(no title)", "Bongo system",
"Display name", "Nickname", "Honorific prefix",
"Given name", "Additional name", "Family name",
"Honorific suffix", "Birthday", "Anniversary",
"Communication", "Email addresses (one per line)",
"Phone numbers (one per line)", "Website", "Job title",
"Role", "Address", "P.O. box", "Extended address",
"Street", "Postal code", "City", "Region", "Country",
"Notes", "All-day event", "Status", "Confirmed",
"Tentative", "Cancelled", "Visibility", "Public",
"Private", "Confidential", "Show time as", "Busy", "Free",
"Categories", "Organizer email",
"Attendee email addresses (one per line)", "Recurrence rule",
"Reminder (minutes before)",
"Attendees are stored in the iCalendar event; invitation delivery is not enabled yet.",
"Filters could not be loaded.", "Filter name", "Enabled",
"Condition", "Value", "Action", "Sender contains",
"Recipient contains", "Subject contains", "Sender domain",
"Mailing list", "List-ID", "Move to folder",
"Copy to folder", "Forward to address", "Delete message",
"Forwarding address", "Language", "English", "German",
"French", "Brazilian Portuguese", "Compact lists",
"Display settings saved.",
"Server administration", "Signed in as server administrator.",
"This account is not a server administrator.",
"Server status could not be loaded.", "Account", "Host",
"Domains", "Version",
"Use the settings gear for personal settings and the task indicator for pending server work.")
SUPPORTED_LANGUAGES = ("en", "de", "fr", "pt_BR")
ASSET_PATH = re.compile(r"^/assets/(css|img)/([A-Za-z0-9._-]+)$")
ASSET_TYPES = {".css": "text/css; charset=utf-8", ".gif": "image/gif",
@@ -183,6 +212,13 @@ class BongoWebServer(ThreadingHTTPServer):
not re.fullmatch(r"[A-Za-z0-9-]+", self.client_ip_header)):
raise ValueError("client_ip_header must be a valid HTTP header name")
self.trusted_proxies = proxy_networks(config.get("trusted_proxies", []))
administrators = config.get("administrators", ["admin"])
if (not isinstance(administrators, list) or not administrators
or not all(isinstance(item, str) and item.strip()
for item in administrators)):
raise ValueError("administrators must be a non-empty string array")
self.administrators = {item.strip().casefold()
for item in administrators}
if initialize:
self.initialize_application()
@@ -419,17 +455,25 @@ class BongoWebHandler(BaseHTTPRequestHandler):
if resource and not EVENT_NAME.fullmatch(resource):
raise ValueError("invalid event resource")
resource = resource or os.urandom(16).hex() + ".ics"
existing = None
if etag:
existing, _ = read_event(
session["user"], session["store_cookie"], resource)
created, current = write_event(
session["user"], session["store_cookie"], resource,
make_event(data), if_match=etag,
make_event(data, existing), if_match=etag,
if_none_match=None if etag else "*")
else:
if resource and not CARD_NAME.fullmatch(resource):
raise ValueError("invalid contact resource")
resource = resource or os.urandom(16).hex() + ".vcf"
existing = None
if etag:
existing, _ = read_card(
session["user"], session["store_cookie"], resource)
created, current = write_card(
session["user"], session["store_cookie"], resource,
make_card(data), if_match=etag,
make_card(data, existing), if_match=etag,
if_none_match=None if etag else "*")
except (TypeError, ValueError, OverflowError, UnicodeError,
json.JSONDecodeError):
@@ -603,12 +647,42 @@ class BongoWebHandler(BaseHTTPRequestHandler):
self._empty(HTTPStatus.INTERNAL_SERVER_ERROR)
return
self._json(HTTPStatus.OK, result, {"Vary": "Cookie"})
elif path == "/api/admin/status":
session = self._session()
if session is None:
self._json(HTTPStatus.UNAUTHORIZED,
{"error": "authentication required"})
elif session["user"].casefold() not in self.server.administrators:
self._json(HTTPStatus.FORBIDDEN,
{"error": "administrator role required"})
else:
base = self.server.config.get("public_base_url", "")
self._json(HTTPStatus.OK, {
"user": session["user"],
"host": urlsplit(base).hostname or socket.getfqdn(),
"domains": self.server.config.get("login_domains", []),
"version": "@BONGO_V_STR@",
}, {"Vary": "Cookie"})
elif path.startswith("/api/mail/"):
self._mail_api(path)
elif path == "/api/calendar/events":
self._organizer_api_get("calendar")
elif path == "/api/contacts":
self._organizer_api_get("contacts")
elif path == "/api/filters":
session = self._session()
if session is None:
self._json(HTTPStatus.UNAUTHORIZED,
{"error": "authentication required"})
else:
try:
filters = list_filters(session["user"])
except RuntimeError:
logging.exception("Could not list user filters")
self._empty(HTTPStatus.INTERNAL_SERVER_ERROR)
return
self._json(HTTPStatus.OK, {"filters": filters},
{"Vary": "Cookie"})
elif path == "/api/tasks":
session = self._session()
if session is None:
@@ -784,6 +858,22 @@ class BongoWebHandler(BaseHTTPRequestHandler):
if path == "/api/contacts":
self._organizer_api_save("contacts")
return
if path == "/api/filters":
session = self._security_session()
if session is None:
return
try:
result = save_filter(session["user"], self._request_data())
except (TypeError, ValueError, OverflowError, UnicodeError,
json.JSONDecodeError):
self._json(HTTPStatus.BAD_REQUEST, {"error": "invalid filter"})
return
except RuntimeError:
logging.exception("Could not save user filter")
self._empty(HTTPStatus.INTERNAL_SERVER_ERROR)
return
self._json(HTTPStatus.CREATED, result, {"Vary": "Cookie"})
return
if path == "/api/external-accounts":
self._external_account_create()
return
@@ -1193,6 +1283,22 @@ class BongoWebHandler(BaseHTTPRequestHandler):
if path == "/api/contacts":
self._organizer_api_delete("contacts")
return
if path == "/api/filters":
session = self._security_session()
if session is None:
return
try:
rule_id = parse_qs(urlsplit(self.path).query).get("id", [""])[-1]
delete_filter(session["user"], rule_id)
except (TypeError, ValueError, FileNotFoundError):
self._empty(HTTPStatus.NOT_FOUND)
return
except RuntimeError:
logging.exception("Could not delete user filter")
self._empty(HTTPStatus.INTERNAL_SERVER_ERROR)
return
self._empty(HTTPStatus.NO_CONTENT, {"Vary": "Cookie"})
return
if path == "/api/auth/totp":
self._totp_disable()
return
+113 -10
View File
@@ -24,7 +24,7 @@
import hashlib
import re
import uuid
from datetime import date, datetime
from datetime import date, datetime, timedelta
from urllib.parse import quote, unquote
@@ -157,15 +157,44 @@ def event_to_dict(item):
"""Return the useful VEVENT fields used by the web interface."""
calendar = _vobject().readOne(item["data"].decode("utf-8", "strict"))
event = calendar.vevent
start = event.dtstart.value
alarms = event.contents.get("valarm", [])
reminder = ""
if alarms and hasattr(alarms[0], "trigger"):
trigger = alarms[0].trigger.value
if isinstance(trigger, timedelta) and trigger.total_seconds() <= 0:
reminder = str(int(-trigger.total_seconds() // 60))
def values(name):
return [str(line.value).removeprefix("mailto:")
for line in event.contents.get(name, [])]
category_values = []
for line in event.contents.get("categories", []):
if isinstance(line.value, (list, tuple)):
category_values.extend(str(value) for value in line.value)
else:
category_values.append(str(line.value))
return {
"resource": item["href"].rsplit("/", 1)[-1],
"etag": item["etag"],
"uid": _component_value(event, "uid"),
"summary": _component_value(event, "summary", "(no title)"),
"start": _component_value(event, "dtstart"),
"start": str(start),
"end": _component_value(event, "dtend"),
"all_day": isinstance(start, date) and not isinstance(start, datetime),
"location": _component_value(event, "location"),
"description": _component_value(event, "description"),
"status": _component_value(event, "status", "CONFIRMED").upper(),
"classification": _component_value(event, "class", "PUBLIC").upper(),
"transparency": _component_value(event, "transp", "OPAQUE").upper(),
"categories": ", ".join(category_values),
"url": _component_value(event, "url"),
"organizer": (values("organizer") or [""])[0],
"attendees": values("attendee"),
"recurrence": _component_value(event, "rrule"),
"reminder_minutes": reminder,
}
@@ -178,7 +207,33 @@ def _iso_date_time(value):
return datetime.fromisoformat(text.replace("Z", "+00:00"))
def make_event(data):
def _event_text(data, name, maximum):
value = str(data.get(name, "")).strip()
if len(value) > maximum or "\0" in value:
raise ValueError("event field is too long")
return value
def _addresses(data, name):
values = data.get(name, [])
if isinstance(values, str):
values = values.splitlines()
if not isinstance(values, list) or len(values) > 128:
raise ValueError("invalid event address list")
output = []
for value in values:
value = str(value).strip()
if not value:
continue
value = value.removeprefix("mailto:")
if len(value) > 320 or "@" not in value or any(
char in value for char in "\r\n\0"):
raise ValueError("invalid event email address")
output.append(value)
return output
def make_event(data, existing=None):
"""Build one standards-compliant iCalendar object from web form data."""
summary = str(data.get("summary", "")).strip()
if not summary or len(summary) > 512:
@@ -188,9 +243,21 @@ def make_event(data):
end = _iso_date_time(end_text) if end_text else None
if end is not None and type(end) is not type(start):
raise ValueError("event start and end must use the same value type")
calendar = _vobject().iCalendar()
event = calendar.add("vevent")
event.add("uid").value = str(data.get("uid") or uuid.uuid4())
if end is not None and end <= start:
raise ValueError("event end must be later than start")
vobject = _vobject()
calendar = (vobject.readOne(existing.decode("utf-8", "strict"))
if existing else vobject.iCalendar())
event = getattr(calendar, "vevent", None)
if event is None:
event = calendar.add("vevent")
for component in ("dtstamp", "summary", "dtstart", "dtend", "duration",
"location", "description", "status", "class", "transp",
"categories", "url", "organizer", "attendee", "rrule"):
event.contents.pop(component, None)
event.contents.pop("valarm", None)
if not event.contents.get("uid"):
event.add("uid").value = str(data.get("uid") or uuid.uuid4())
# vobject uses python-dateutil's UTC type to emit the required trailing Z.
from dateutil.tz import tzutc
event.add("dtstamp").value = datetime.now(tzutc())
@@ -198,12 +265,48 @@ def make_event(data):
event.add("dtstart").value = start
if end is not None:
event.add("dtend").value = end
for name, maximum in (("location", 512), ("description", 65536)):
value = str(data.get(name, "")).strip()
if len(value) > maximum:
raise ValueError("event field is too long")
for name, maximum in (("location", 512), ("description", 65536),
("url", 2048)):
value = _event_text(data, name, maximum)
if value:
event.add(name).value = value
for name, allowed, default in (
("status", {"TENTATIVE", "CONFIRMED", "CANCELLED"}, "CONFIRMED"),
("classification", {"PUBLIC", "PRIVATE", "CONFIDENTIAL"}, "PUBLIC"),
("transparency", {"OPAQUE", "TRANSPARENT"}, "OPAQUE")):
value = _event_text(data, name, 32).upper() or default
if value not in allowed:
raise ValueError("invalid event option")
event.add("class" if name == "classification" else
"transp" if name == "transparency" else name).value = value
categories = [value.strip() for value in
_event_text(data, "categories", 2048).split(",") if value.strip()]
if categories:
event.add("categories").value = categories
organizer = _event_text(data, "organizer", 320)
if organizer:
organizer = _addresses({"value": [organizer]}, "value")[0]
event.add("organizer").value = "mailto:" + organizer
for attendee in _addresses(data, "attendees"):
event.add("attendee").value = "mailto:" + attendee
recurrence = _event_text(data, "recurrence", 512).upper()
if recurrence:
if not recurrence.startswith("FREQ=") or not re.fullmatch(
r"[A-Z0-9=,;+\-]+", recurrence):
raise ValueError("invalid recurrence rule")
event.add("rrule").value = recurrence
reminder = str(data.get("reminder_minutes", "")).strip()
if reminder:
try:
minutes = int(reminder)
except ValueError as error:
raise ValueError("invalid reminder") from error
if not 0 <= minutes <= 525600:
raise ValueError("invalid reminder")
alarm = event.add("valarm")
alarm.add("action").value = "DISPLAY"
alarm.add("description").value = summary
alarm.add("trigger").value = timedelta(minutes=-minutes)
return calendar.serialize().encode("utf-8")
+109 -9
View File
@@ -25,6 +25,7 @@ import hashlib
import json
import re
import uuid
from datetime import date
from urllib.parse import quote, unquote
@@ -160,33 +161,132 @@ def card_to_dict(item):
value = card.contents["org"][0].value
organization = " ".join(str(part) for part in value) \
if isinstance(value, (list, tuple)) else str(value)
name = card.contents.get("n", [None])[0]
name_value = name.value if name is not None else None
address = card.contents.get("adr", [None])[0]
address_value = address.value if address is not None else None
def component(value, field):
return str(getattr(value, field, "") or "") if value is not None else ""
def first_value(field):
lines = card.contents.get(field, [])
return str(lines[0].value) if lines else ""
return {
"resource": item["href"].rsplit("/", 1)[-1],
"etag": item["etag"],
"uid": str(card.uid.value),
"name": str(card.fn.value),
"email": emails[0] if emails else "",
"emails": emails,
"phone": phones[0] if phones else "",
"phones": phones,
"honorific_prefix": component(name_value, "prefix"),
"given_name": component(name_value, "given"),
"additional_name": component(name_value, "additional"),
"family_name": component(name_value, "family"),
"honorific_suffix": component(name_value, "suffix"),
"nickname": first_value("nickname"),
"birthday": first_value("bday"),
"anniversary": first_value("anniversary"),
"organization": organization,
"title": first_value("title"),
"role": first_value("role"),
"url": first_value("url"),
"po_box": component(address_value, "box"),
"extended_address": component(address_value, "extended"),
"street": component(address_value, "street"),
"city": component(address_value, "city"),
"region": component(address_value, "region"),
"postal_code": component(address_value, "code"),
"country": component(address_value, "country"),
"note": first_value("note"),
}
def make_card(data):
def _text(data, field, maximum):
value = str(data.get(field, "")).strip()
if len(value) > maximum or "\0" in value:
raise ValueError("contact field is too long")
return value
def _lines(data, plural, singular, maximum):
values = data.get(plural)
if isinstance(values, str):
values = values.splitlines()
elif not isinstance(values, list):
values = [data.get(singular, "")]
output = []
for value in values:
value = str(value).strip()
if not value:
continue
if len(value) > maximum or any(char in value for char in "\r\n\0"):
raise ValueError("invalid contact field")
output.append(value)
if len(output) > 32:
raise ValueError("too many contact fields")
return output
def make_card(data, existing=None):
name = str(data.get("name", "")).strip()
if not name or len(name) > 512:
raise ValueError("invalid contact name")
card = _vobject().vCard()
card.add("uid").value = str(data.get("uid") or uuid.uuid4())
vobject = _vobject()
card = (vobject.readOne(existing.decode("utf-8", "strict"))
if existing else vobject.vCard())
for component in ("fn", "n", "nickname", "bday", "anniversary",
"email", "tel", "org", "title", "role", "url",
"adr", "note"):
card.contents.pop(component, None)
if not card.contents.get("uid"):
card.add("uid").value = str(data.get("uid") or uuid.uuid4())
card.add("fn").value = name
for field, component, maximum in (("email", "email", 320),
("phone", "tel", 128),
("organization", "org", 512)):
value = str(data.get(field, "")).strip()
if len(value) > maximum:
raise ValueError("contact field is too long")
structured_name = {
"family": _text(data, "family_name", 256),
"given": _text(data, "given_name", 256),
"additional": _text(data, "additional_name", 256),
"prefix": _text(data, "honorific_prefix", 128),
"suffix": _text(data, "honorific_suffix", 128),
}
if any(structured_name.values()):
card.add("n").value = vobject.vcard.Name(**structured_name)
for field, component, maximum in (("nickname", "nickname", 256),
("organization", "org", 512),
("title", "title", 256),
("role", "role", 256),
("url", "url", 2048),
("note", "note", 65536)):
value = _text(data, field, maximum)
if value:
line = card.add(component)
line.value = [value] if component == "org" else value
for field in ("birthday", "anniversary"):
value = _text(data, field, 32)
if value:
try:
parsed = date.fromisoformat(value)
except ValueError as error:
raise ValueError("invalid contact date") from error
card.add("bday" if field == "birthday" else field).value = parsed.isoformat()
for value in _lines(data, "emails", "email", 320):
card.add("email").value = value
for value in _lines(data, "phones", "phone", 128):
card.add("tel").value = value
address = {
"box": _text(data, "po_box", 256),
"extended": _text(data, "extended_address", 512),
"street": _text(data, "street", 512),
"city": _text(data, "city", 256),
"region": _text(data, "region", 256),
"code": _text(data, "postal_code", 64),
"country": _text(data, "country", 256),
}
if any(address.values()):
card.add("adr").value = vobject.vcard.Address(**address)
return card.serialize().encode("utf-8")
+114
View File
@@ -21,7 +21,9 @@
"""Validated user filters and compilation to Bongo's legacy rule format."""
import json
import re
import threading
ADDRESS = re.compile(r"^[^\s@<>]+@[^\s@<>]+$")
@@ -36,6 +38,9 @@ CONDITION_CODES = {
}
ACTION_CODES = {"delete": "B", "forward": "C", "copy": "D",
"move": "E", "stop": "F"}
WEB_RULE_BASE = 0x70000000
WEB_RULE_LIMIT = 0x7FFFFFFF
_STORE_LOCK = threading.RLock()
def validate_filter(value):
@@ -152,3 +157,112 @@ def compile_filter(value, rule_id, group_resolver=lambda _name: ()):
else action.get("address", "") if kind == "forward" else "")
output += _argument(ACTION_CODES[kind], argument)
return output.decode("utf-8")
def _native_rules():
try:
from libbongo import _rules
except ImportError as error:
raise RuntimeError("the native Bongo rules store is unavailable") from error
return _rules
def _document(owner, native=None):
raw = (native or _native_rules()).read(owner)
if raw is None:
return {"rules": [], "web_filters": []}
try:
document = json.loads(raw)
except (TypeError, ValueError) as error:
raise RuntimeError("invalid user rules document") from error
if not isinstance(document, dict) or not isinstance(document.get("rules", []), list):
raise RuntimeError("invalid user rules document")
filters = document.get("web_filters", [])
if not isinstance(filters, list):
raise RuntimeError("invalid web filter metadata")
document["rules"] = [value for value in document.get("rules", [])
if isinstance(value, str)]
document["web_filters"] = filters
return document
def list_filters(owner, native=None):
with _STORE_LOCK:
document = _document(owner, native)
output = []
for entry in document["web_filters"]:
if not isinstance(entry, dict):
continue
try:
rule_id = int(entry["id"])
definition = validate_filter(entry["definition"])
except (KeyError, TypeError, ValueError):
continue
if not WEB_RULE_BASE <= rule_id <= WEB_RULE_LIMIT:
continue
output.append({"id": rule_id,
"name": str(entry.get("name", "Filter"))[:256],
"enabled": bool(entry.get("enabled", True)),
"definition": definition})
return sorted(output, key=lambda entry: entry["id"])
def _write_filters(owner, document, filters, native):
previous_ids = {
int(entry["id"]) for entry in document["web_filters"]
if isinstance(entry, dict) and str(entry.get("id", "")).isdigit()
}
previous_prefixes = {f"{rule_id:08X}" for rule_id in previous_ids}
retained = [rule for rule in document["rules"]
if not (isinstance(rule, str) and len(rule) >= 8 and
rule[:8].upper() in previous_prefixes)]
compiled = [compile_filter(entry["definition"], entry["id"])
for entry in filters if entry["enabled"]]
document["rules"] = retained + compiled
document["web_filters"] = filters
(native or _native_rules()).replace(
owner, json.dumps(document, ensure_ascii=False,
separators=(",", ":"), sort_keys=True))
def save_filter(owner, payload, native=None):
if not isinstance(payload, dict):
raise ValueError("filter must be an object")
name = str(payload.get("name", "")).strip()
if not name or len(name) > 256 or "\0" in name:
raise ValueError("invalid filter name")
definition = validate_filter(payload.get("definition"))
enabled = bool(payload.get("enabled", True))
with _STORE_LOCK:
document = _document(owner, native)
filters = list_filters(owner, native)
requested_id = payload.get("id")
if requested_id in (None, ""):
rule_id = max([entry["id"] for entry in filters] +
[WEB_RULE_BASE - 1]) + 1
if rule_id > WEB_RULE_LIMIT:
raise ValueError("too many filters")
else:
rule_id = int(requested_id)
if not WEB_RULE_BASE <= rule_id <= WEB_RULE_LIMIT:
raise ValueError("invalid filter id")
entry = {"id": rule_id, "name": name, "enabled": enabled,
"definition": definition}
filters = [current for current in filters if current["id"] != rule_id]
filters.append(entry)
filters.sort(key=lambda current: current["id"])
_write_filters(owner, document, filters, native)
return entry
def delete_filter(owner, rule_id, native=None):
rule_id = int(rule_id)
if not WEB_RULE_BASE <= rule_id <= WEB_RULE_LIMIT:
raise ValueError("invalid filter id")
with _STORE_LOCK:
document = _document(owner, native)
filters = list_filters(owner, native)
updated = [entry for entry in filters if entry["id"] != rule_id]
if len(updated) == len(filters):
raise FileNotFoundError(rule_id)
_write_filters(owner, document, updated, native)
+18 -3
View File
@@ -21,7 +21,7 @@
:root { font: 14px/1.45 "Lucida Grande", Verdana, "Bitstream Vera Sans", sans-serif; color: #111; background: #e4eaf4; }
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
html, body { margin: 0; min-height: 100%; overflow-x: hidden; overflow-y: auto; }
body { background: #e7e4ef url('/assets/bg-page.png') repeat-x 0 0; }
button, input, select { font: inherit; }
button { cursor: pointer; }
@@ -46,7 +46,7 @@ button { cursor: pointer; }
/* Original Dragonfly page proportions, implemented with modern grid. */
main { min-height: 100vh; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 14.5rem minmax(0, 1fr); grid-template-rows: auto 1fr; }
header { grid-column: 1 / -1; display: flex; align-items: center; min-height: 68px; padding: 10px 15px; color: #fff; border-top: 4px solid #ffac35; background-color: #1d3980; background-image: url('/assets/top-gradient.png'); background-repeat: repeat-y; background-position: 280px center; }
header { grid-column: 1 / -1; display: flex; align-items: center; min-height: 68px; padding: 10px 15px; color: #fff; border-top: 4px solid #ffac35; background-color: #1d3980; background-image: url('/assets/top-gradient.png'); background-repeat: repeat-y; background-position: left center; }
.wordmark { display: block; margin-right: auto; }
.wordmark img { display: block; width: 240px; max-width: 38vw; height: auto; }
#current-user { font-size: 1.15rem; font-weight: bold; }
@@ -108,7 +108,9 @@ select { padding: .25rem; border: 1px solid #7f8da5; background: #fff; }
#conversation-list { margin: 1rem 0 0; padding: 0; list-style: none; border-top: 1px solid #afbdd2; }
#conversation-list li { padding: 0; border-bottom: 1px solid #ccd5e2; }
#conversation-list li.unread { font-weight: bold; background: #f4f7fb; }
#conversation-list button { display: grid; width: 100%; grid-template-columns: minmax(10rem, 1fr) 3fr auto; gap: 1rem; padding: .55rem; color: inherit; text-align: left; border: 0; background: transparent; }
#conversation-list button { display: grid; width: 100%; grid-template-columns: minmax(10rem, 1.2fr) minmax(16rem, 3fr) minmax(11rem, auto); gap: 1rem; padding: .55rem; color: inherit; text-align: left; border: 0; background: transparent; }
#conversation-list button:hover { background: #eef3f8; }
#conversation-list time { text-align: right; white-space: nowrap; }
.sender, .subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.message { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #afbdd2; }
.message-addresses { color: #59677a; overflow-wrap: anywhere; }
@@ -120,10 +122,21 @@ select { padding: .25rem; border: 1px solid #7f8da5; background: #fff; }
.organizer-list button { display: grid; width: 100%; grid-template-columns: minmax(12rem, 2fr) minmax(10rem, 1fr) minmax(10rem, 1fr); gap: 1rem; padding: .55rem; border: 0; background: transparent; color: inherit; text-align: left; }
.organizer-form { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); gap: .7rem; margin-top: 1rem; padding: .8rem; border: 1px solid #afbdd2; background: #f7f9fc; }
.organizer-form label { display: flex; flex-direction: column; gap: .2rem; }
.organizer-form label.check { flex-direction: row; align-items: center; align-self: end; }
.organizer-form label.check input { width: auto; }
.organizer-form input, .organizer-form textarea { width: 100%; }
.organizer-form textarea { min-height: 7rem; resize: vertical; }
.organizer-form .wide { grid-column: 1 / -1; }
.organizer-form h3 { margin: .4rem 0 0; padding-bottom: .25rem; color: #1d3980; border-bottom: 1px solid #ccd5e0; }
.organizer-actions { display: flex; gap: .4rem; }
.filter-list { margin: 0 0 1rem; padding: 0; list-style: none; }
.filter-list li { display: flex; justify-content: space-between; gap: .5rem; padding: .35rem 0; border-bottom: 1px solid #ccd5e0; }
.filter-list li button:first-child { flex: 1; text-align: left; }
.filter-form, .display-form { display: grid; grid-template-columns: repeat(2, minmax(12rem, 1fr)); gap: .7rem; }
.filter-form label, .display-form label { display: flex; flex-direction: column; gap: .2rem; }
.filter-form label.check, .display-form label.check { flex-direction: row; align-items: center; }
.filter-form label.check input, .display-form label.check input { width: auto; }
.compact #conversation-list button, .compact .organizer-list button { padding-top: .25rem; padding-bottom: .25rem; }
@media (max-width: 700px) {
.login { top: 20%; }
@@ -139,6 +152,8 @@ select { padding: .25rem; border: 1px solid #7f8da5; background: #fff; }
#conversation-list button { grid-template-columns: 1fr; gap: .15rem; }
.settings-view { grid-template-columns: 1fr; }
.identity-form, .signature-form, .external-account-form { grid-template-columns: 1fr; }
.organizer-form, .filter-form, .display-form { grid-template-columns: 1fr; }
.organizer-form .wide { grid-column: 1; }
.totp-enrollment { grid-template-columns: 1fr; }
.recovery-codes { columns: 1; }
}
+204 -16
View File
@@ -78,7 +78,7 @@ function render() {
mail: tr("The secure webmail connection is being enabled."),
calendar: tr("Create and manage calendar events."),
contacts: tr("Create and manage contacts."),
admin: tr("Administrative functions require a server-side role check."),
admin: tr("Server administration and service status."),
settings: tr("Personal settings"),
tasks: tr("Open tasks that require your attention.")
};
@@ -91,21 +91,53 @@ function render() {
document.querySelector("#message-compose").hidden = current !== "mail" || !composing;
document.querySelector("#calendar-view").hidden = current !== "calendar";
document.querySelector("#contacts-view").hidden = current !== "contacts";
document.querySelector("#admin-view").hidden = current !== "admin";
document.querySelector("#settings-view").hidden = current !== "settings";
document.querySelectorAll("nav a").forEach(link => link.classList.toggle("active", link.dataset.section === current));
if (current === "mail") loadFolders();
if (current === "calendar") loadCalendar();
if (current === "contacts") loadContacts();
if (current === "admin") loadAdminStatus();
if (current === "tasks") loadTasks();
if (current === "settings") renderSettings("tasks");
}
async function loadAdminStatus() {
const response = await fetch("/api/admin/status", {credentials: "same-origin"});
const status = document.querySelector("#admin-status");
const summary = document.querySelector("#admin-summary");
if (response.status === 403) {
status.textContent = tr("This account is not a server administrator.");
summary.replaceChildren();
return;
}
if (!response.ok) { status.textContent = tr("Server status could not be loaded."); return; }
const result = await response.json();
status.textContent = tr("Signed in as server administrator.");
const entries = [[tr("Account"), result.user], [tr("Host"), result.host || ""],
[tr("Domains"), (result.domains || []).join(", ")], [tr("Version"), result.version || "Bongo"]];
summary.replaceChildren(...entries.flatMap(([name, value]) => {
const term = document.createElement("dt"); term.textContent = name;
const definition = document.createElement("dd"); definition.textContent = value;
return [term, definition];
}));
}
function organizerInputValue(value) {
if (!value) return "";
const normalized = String(value).replace(" ", "T");
return normalized.length >= 16 ? normalized.slice(0, 16) : normalized;
}
function setAllDayInputTypes(allDay) {
for (const id of ["#calendar-start", "#calendar-end"]) {
const input = document.querySelector(id);
const value = input.value;
input.type = allDay ? "date" : "datetime-local";
input.value = allDay ? value.slice(0, 10) : value;
}
}
async function loadCalendar() {
const response = await fetch("/api/calendar/events", {credentials: "same-origin"});
if (!response.ok) return;
@@ -128,10 +160,17 @@ function editCalendarEvent(item = {}) {
document.querySelector("#calendar-etag").value = item.etag || "";
document.querySelector("#calendar-uid").value = item.uid || "";
document.querySelector("#calendar-summary").value = item.summary || "";
document.querySelector("#calendar-all-day").checked = Boolean(item.all_day);
setAllDayInputTypes(Boolean(item.all_day));
document.querySelector("#calendar-start").value = organizerInputValue(item.start);
document.querySelector("#calendar-end").value = organizerInputValue(item.end);
document.querySelector("#calendar-location").value = item.location || "";
document.querySelector("#calendar-description").value = item.description || "";
for (const field of ["status", "classification", "transparency", "categories", "url",
"organizer", "recurrence", "reminder_minutes"]) {
document.querySelector("#calendar-" + field.replaceAll("_", "-")).value = item[field] || "";
}
document.querySelector("#calendar-attendees").value = (item.attendees || []).join("\n");
document.querySelector("#calendar-delete").hidden = !item.resource;
form.hidden = false;
document.querySelector("#calendar-summary").focus();
@@ -143,10 +182,16 @@ async function saveCalendarEvent(event) {
etag: document.querySelector("#calendar-etag").value,
uid: document.querySelector("#calendar-uid").value,
summary: document.querySelector("#calendar-summary").value,
all_day: document.querySelector("#calendar-all-day").checked,
start: document.querySelector("#calendar-start").value,
end: document.querySelector("#calendar-end").value,
location: document.querySelector("#calendar-location").value,
description: document.querySelector("#calendar-description").value};
description: document.querySelector("#calendar-description").value,
attendees: document.querySelector("#calendar-attendees").value.split(/\r?\n/)};
for (const field of ["status", "classification", "transparency", "categories", "url",
"organizer", "recurrence", "reminder_minutes"]) {
payload[field] = document.querySelector("#calendar-" + field.replaceAll("_", "-")).value;
}
const response = await fetch("/api/calendar/events", {method: "POST", credentials: "same-origin",
headers: {"Content-Type": "application/json", "X-Bongo-CSRF": session.csrf_token}, body: JSON.stringify(payload)});
if (response.ok) { event.currentTarget.hidden = true; await loadCalendar(); }
@@ -178,9 +223,15 @@ function editContact(item = {}) {
document.querySelector("#contact-etag").value = item.etag || "";
document.querySelector("#contact-uid").value = item.uid || "";
document.querySelector("#contact-name").value = item.name || "";
document.querySelector("#contact-email").value = item.email || "";
document.querySelector("#contact-phone").value = item.phone || "";
document.querySelector("#contact-organization").value = item.organization || "";
const fields = ["nickname", "honorific_prefix", "given_name", "additional_name",
"family_name", "honorific_suffix", "birthday", "anniversary", "organization",
"title", "role", "url", "po_box", "extended_address", "street", "postal_code",
"city", "region", "country", "note"];
for (const field of fields) {
document.querySelector("#contact-" + field.replaceAll("_", "-")).value = item[field] || "";
}
document.querySelector("#contact-email").value = (item.emails || (item.email ? [item.email] : [])).join("\n");
document.querySelector("#contact-phone").value = (item.phones || (item.phone ? [item.phone] : [])).join("\n");
document.querySelector("#contact-delete").hidden = !item.resource;
document.querySelector("#contact-form").hidden = false;
document.querySelector("#contact-name").focus();
@@ -190,8 +241,15 @@ async function saveContact(event) {
event.preventDefault();
const payload = {resource: document.querySelector("#contact-resource").value,
etag: document.querySelector("#contact-etag").value, uid: document.querySelector("#contact-uid").value,
name: document.querySelector("#contact-name").value, email: document.querySelector("#contact-email").value,
phone: document.querySelector("#contact-phone").value, organization: document.querySelector("#contact-organization").value};
name: document.querySelector("#contact-name").value,
emails: document.querySelector("#contact-email").value.split(/\r?\n/),
phones: document.querySelector("#contact-phone").value.split(/\r?\n/)};
for (const field of ["nickname", "honorific_prefix", "given_name", "additional_name",
"family_name", "honorific_suffix", "birthday", "anniversary", "organization",
"title", "role", "url", "po_box", "extended_address", "street", "postal_code",
"city", "region", "country", "note"]) {
payload[field] = document.querySelector("#contact-" + field.replaceAll("_", "-")).value;
}
const response = await fetch("/api/contacts", {method: "POST", credentials: "same-origin",
headers: {"Content-Type": "application/json", "X-Bongo-CSRF": session.csrf_token}, body: JSON.stringify(payload)});
if (response.ok) { event.currentTarget.hidden = true; await loadContacts(); }
@@ -465,6 +523,129 @@ function identityForm(identity) {
return form;
}
function filterField(label, control) {
const wrapper = document.createElement("label");
wrapper.append(document.createTextNode(tr(label)), control);
return wrapper;
}
async function renderFilters(content) {
const [filterResponse, folderResponse] = await Promise.all([
fetch("/api/filters", {credentials: "same-origin"}),
fetch("/api/mail/folders", {credentials: "same-origin"})]);
if (!filterResponse.ok || !folderResponse.ok) {
content.textContent = tr("Filters could not be loaded."); return;
}
const filters = (await filterResponse.json()).filters;
const folders = (await folderResponse.json()).folders;
const heading = document.createElement("h3"); heading.textContent = tr("Filters");
const list = document.createElement("ol"); list.className = "filter-list";
const form = document.createElement("form"); form.className = "filter-form";
const id = document.createElement("input"); id.type = "hidden";
const name = document.createElement("input"); name.required = true; name.maxLength = 256;
const enabled = document.createElement("input"); enabled.type = "checkbox"; enabled.checked = true;
const condition = document.createElement("select");
[["sender", "Sender contains"], ["recipient", "Recipient contains"],
["subject", "Subject contains"], ["sender_domain", "Sender domain"],
["mailing_list", "Mailing list"], ["list_id", "List-ID"]].forEach(([value, label]) => {
const option = document.createElement("option"); option.value = value; option.textContent = tr(label); condition.append(option);
});
const conditionValue = document.createElement("input"); conditionValue.maxLength = 998;
const action = document.createElement("select");
[["move", "Move to folder"], ["copy", "Copy to folder"], ["forward", "Forward to address"], ["delete", "Delete message"]].forEach(([value, label]) => {
const option = document.createElement("option"); option.value = value; option.textContent = tr(label); action.append(option);
});
const destination = document.createElement("select");
destination.append(...folders.map(folder => {
const option = document.createElement("option"); option.value = folder.path; option.textContent = folder.name; return option;
}));
const forward = document.createElement("input"); forward.type = "email"; forward.maxLength = 320; forward.hidden = true;
const updateFields = () => {
conditionValue.hidden = condition.value === "mailing_list";
conditionValue.required = !conditionValue.hidden;
destination.hidden = !["move", "copy"].includes(action.value);
forward.hidden = action.value !== "forward";
forward.required = !forward.hidden;
};
condition.addEventListener("change", updateFields); action.addEventListener("change", updateFields);
const save = document.createElement("button"); save.type = "submit"; save.textContent = tr("Save");
form.append(id, filterField("Filter name", name), filterField("Enabled", enabled),
filterField("Condition", condition), filterField("Value", conditionValue),
filterField("Action", action), filterField("Folder", destination),
filterField("Forwarding address", forward), save);
const edit = item => {
const firstCondition = item.definition.conditions[0];
const firstAction = item.definition.actions[0];
id.value = item.id; name.value = item.name; enabled.checked = item.enabled;
condition.value = firstCondition.type; conditionValue.value = firstCondition.value || "";
action.value = firstAction.type; destination.value = firstAction.folder || destination.value;
forward.value = firstAction.address || ""; updateFields(); name.focus();
};
list.replaceChildren(...filters.map(item => {
const row = document.createElement("li");
const open = document.createElement("button"); open.type = "button";
open.textContent = `${item.enabled ? "✓" : "○"} ${item.name}`; open.addEventListener("click", () => edit(item));
const remove = document.createElement("button"); remove.type = "button"; remove.textContent = tr("Delete");
remove.addEventListener("click", async () => {
const response = await fetch(`/api/filters?id=${encodeURIComponent(item.id)}`, {method: "DELETE", credentials: "same-origin", headers: {"X-Bongo-CSRF": session.csrf_token}});
if (response.ok) renderSettings("filters");
});
row.append(open, remove); return row;
}));
form.addEventListener("submit", async event => {
event.preventDefault();
const conditionEntry = {type: condition.value};
if (condition.value !== "mailing_list") conditionEntry.value = conditionValue.value;
const actionEntry = {type: action.value};
if (["move", "copy"].includes(action.value)) actionEntry.folder = destination.value;
if (action.value === "forward") actionEntry.address = forward.value;
const actions = [actionEntry];
if (["move", "delete"].includes(action.value)) actions.push({type: "stop"});
const response = await fetch("/api/filters", {method: "POST", credentials: "same-origin",
headers: {"Content-Type": "application/json", "X-Bongo-CSRF": session.csrf_token},
body: JSON.stringify({id: id.value || null, name: name.value, enabled: enabled.checked,
definition: {match: "all", conditions: [conditionEntry], actions}})});
if (response.ok) renderSettings("filters");
});
updateFields(); content.replaceChildren(heading, list, form);
}
async function loadTranslations(language = "") {
const response = await fetch("/api/i18n" + (language ? "?lang=" + encodeURIComponent(language) : ""));
const catalog = await response.json();
messages = catalog.messages;
document.documentElement.lang = catalog.language.replace("_", "-");
document.querySelectorAll("[data-i18n]").forEach(element => {
element.textContent = tr(element.dataset.i18n);
});
return catalog.language;
}
function renderDisplaySettings(content) {
const heading = document.createElement("h3"); heading.textContent = tr("Language and display");
const form = document.createElement("form"); form.className = "display-form";
const language = document.createElement("select");
[["en", "English"], ["de", "German"], ["fr", "French"], ["pt_BR", "Brazilian Portuguese"]].forEach(([value, label]) => {
const option = document.createElement("option"); option.value = value; option.textContent = tr(label); language.append(option);
});
const documentLanguage = document.documentElement.lang.replace("-", "_");
language.value = localStorage.getItem("bongo-language") ||
(documentLanguage.toLowerCase().startsWith("pt") ? "pt_BR" : documentLanguage.split("_", 1)[0]);
const compact = document.createElement("input"); compact.type = "checkbox";
compact.checked = localStorage.getItem("bongo-compact") === "1";
const compactLabel = filterField("Compact lists", compact); compactLabel.className = "check";
const status = document.createElement("span"); status.setAttribute("role", "status");
const save = document.createElement("button"); save.type = "submit"; save.textContent = tr("Save");
form.append(filterField("Language", language), compactLabel, save, status);
form.addEventListener("submit", async event => {
event.preventDefault(); localStorage.setItem("bongo-language", language.value);
localStorage.setItem("bongo-compact", compact.checked ? "1" : "0");
document.documentElement.classList.toggle("compact", compact.checked);
await loadTranslations(language.value); status.textContent = tr("Display settings saved.");
});
content.replaceChildren(heading, form);
}
async function renderSettings(name) {
const content = document.querySelector("#settings-content");
document.querySelectorAll(".settings-nav button").forEach(button =>
@@ -582,6 +763,10 @@ async function renderSettings(name) {
content.replaceChildren(heading, list, form);
return;
}
if (name === "filters") {
await renderFilters(content);
return;
}
if (name === "identities") {
await loadIdentities();
const identityHeading = document.createElement("h3");
@@ -599,6 +784,10 @@ async function renderSettings(name) {
await renderTwoFactor(content);
return;
}
if (name === "display") {
renderDisplaySettings(content);
return;
}
const heading = document.createElement("h3");
const button = document.querySelector(`.settings-nav button[data-settings="${name}"]`);
heading.textContent = button ? button.textContent : tr("Settings");
@@ -632,12 +821,14 @@ async function loadConversations() {
row.classList.toggle("unread", message.unread > 0);
const sender = document.createElement("span");
sender.className = "sender";
sender.textContent = message.from.map(item => item.name || item.address).join(", ");
sender.textContent = message.from.map(item => item.name || item.address).join(", ") || tr("Bongo system");
const subject = document.createElement("span");
subject.className = "subject";
subject.textContent = message.subject || tr("(no subject)");
const date = document.createElement("time");
date.textContent = message.date;
const numericDate = /^\d+$/.test(String(message.date)) ? Number(message.date) * 1000 : Date.parse(message.date);
date.textContent = Number.isFinite(numericDate) ? new Date(numericDate).toLocaleString() : String(message.date || "");
if (Number.isFinite(numericDate)) date.dateTime = new Date(numericDate).toISOString();
const button = document.createElement("button");
button.type = "button";
button.addEventListener("click", () => loadConversation(message.id));
@@ -864,6 +1055,7 @@ document.querySelector("#mail-refresh").addEventListener("click", loadConversati
document.querySelector("#calendar-new").addEventListener("click", () => editCalendarEvent());
document.querySelector("#calendar-refresh").addEventListener("click", loadCalendar);
document.querySelector("#calendar-form").addEventListener("submit", saveCalendarEvent);
document.querySelector("#calendar-all-day").addEventListener("change", event => setAllDayInputTypes(event.currentTarget.checked));
document.querySelector("#calendar-delete").addEventListener("click", deleteCalendarEvent);
document.querySelector("#calendar-cancel").addEventListener("click", () => { document.querySelector("#calendar-form").hidden = true; });
document.querySelector("#contact-new").addEventListener("click", () => editContact());
@@ -916,10 +1108,6 @@ document.querySelector("#message-compose").addEventListener("submit", async even
});
window.addEventListener("hashchange", render);
window.addEventListener("popstate", render);
fetch("/api/i18n").then(response => response.json()).then(catalog => {
messages = catalog.messages;
document.documentElement.lang = catalog.language.replace("_", "-");
document.querySelectorAll("[data-i18n]").forEach(element => {
element.textContent = tr(element.dataset.i18n);
});
}).finally(() => loadSession().catch(() => render()));
document.documentElement.classList.toggle("compact", localStorage.getItem("bongo-compact") === "1");
loadTranslations(localStorage.getItem("bongo-language") || "")
.finally(() => loadSession().catch(() => render()));
+43 -3
View File
@@ -114,10 +114,21 @@
<form id="calendar-form" class="organizer-form" hidden>
<input id="calendar-resource" type="hidden"><input id="calendar-etag" type="hidden"><input id="calendar-uid" type="hidden">
<label><span data-i18n="Title">Title</span><input id="calendar-summary" required maxlength="512"></label>
<label class="check"><input id="calendar-all-day" type="checkbox"><span data-i18n="All-day event">All-day event</span></label>
<label><span data-i18n="Start">Start</span><input id="calendar-start" type="datetime-local" required></label>
<label><span data-i18n="End">End</span><input id="calendar-end" type="datetime-local"></label>
<label><span data-i18n="Location">Location</span><input id="calendar-location" maxlength="512"></label>
<label><span data-i18n="Status">Status</span><select id="calendar-status"><option value="CONFIRMED" data-i18n="Confirmed">Confirmed</option><option value="TENTATIVE" data-i18n="Tentative">Tentative</option><option value="CANCELLED" data-i18n="Cancelled">Cancelled</option></select></label>
<label><span data-i18n="Visibility">Visibility</span><select id="calendar-classification"><option value="PUBLIC" data-i18n="Public">Public</option><option value="PRIVATE" data-i18n="Private">Private</option><option value="CONFIDENTIAL" data-i18n="Confidential">Confidential</option></select></label>
<label><span data-i18n="Show time as">Show time as</span><select id="calendar-transparency"><option value="OPAQUE" data-i18n="Busy">Busy</option><option value="TRANSPARENT" data-i18n="Free">Free</option></select></label>
<label><span data-i18n="Categories">Categories</span><input id="calendar-categories" maxlength="2048" placeholder="family, work"></label>
<label><span data-i18n="Website">Website</span><input id="calendar-url" type="url" maxlength="2048"></label>
<label><span data-i18n="Organizer email">Organizer email</span><input id="calendar-organizer" type="email" maxlength="320"></label>
<label class="wide"><span data-i18n="Attendee email addresses (one per line)">Attendee email addresses (one per line)</span><textarea id="calendar-attendees" maxlength="41216"></textarea></label>
<label><span data-i18n="Recurrence rule">Recurrence rule</span><input id="calendar-recurrence" maxlength="512" placeholder="FREQ=WEEKLY;BYDAY=MO"></label>
<label><span data-i18n="Reminder (minutes before)">Reminder (minutes before)</span><input id="calendar-reminder-minutes" type="number" min="0" max="525600"></label>
<label class="wide"><span data-i18n="Description">Description</span><textarea id="calendar-description" maxlength="65536"></textarea></label>
<p class="wide muted" data-i18n="Attendees are stored in the iCalendar event; invitation delivery is not enabled yet.">Attendees are stored in the iCalendar event; invitation delivery is not enabled yet.</p>
<div class="wide organizer-actions"><button type="submit" data-i18n="Save">Save</button><button id="calendar-delete" type="button" data-i18n="Delete" hidden>Delete</button><button id="calendar-cancel" type="button" data-i18n="Cancel">Cancel</button></div>
</form>
</section>
@@ -126,13 +137,42 @@
<ol id="contact-list" class="organizer-list" aria-live="polite"></ol>
<form id="contact-form" class="organizer-form" hidden>
<input id="contact-resource" type="hidden"><input id="contact-etag" type="hidden"><input id="contact-uid" type="hidden">
<label><span data-i18n="Name">Name</span><input id="contact-name" required maxlength="512"></label>
<label><span data-i18n="Email address">Email address</span><input id="contact-email" type="email" maxlength="320"></label>
<label><span data-i18n="Phone">Phone</span><input id="contact-phone" type="tel" maxlength="128"></label>
<h3 class="wide" data-i18n="Name">Name</h3>
<label><span data-i18n="Display name">Display name</span><input id="contact-name" required maxlength="512"></label>
<label><span data-i18n="Nickname">Nickname</span><input id="contact-nickname" maxlength="256"></label>
<label><span data-i18n="Honorific prefix">Honorific prefix</span><input id="contact-honorific-prefix" maxlength="128"></label>
<label><span data-i18n="Given name">Given name</span><input id="contact-given-name" maxlength="256"></label>
<label><span data-i18n="Additional name">Additional name</span><input id="contact-additional-name" maxlength="256"></label>
<label><span data-i18n="Family name">Family name</span><input id="contact-family-name" maxlength="256"></label>
<label><span data-i18n="Honorific suffix">Honorific suffix</span><input id="contact-honorific-suffix" maxlength="128"></label>
<label><span data-i18n="Birthday">Birthday</span><input id="contact-birthday" type="date"></label>
<label><span data-i18n="Anniversary">Anniversary</span><input id="contact-anniversary" type="date"></label>
<h3 class="wide" data-i18n="Communication">Communication</h3>
<label><span data-i18n="Email addresses (one per line)">Email addresses (one per line)</span><textarea id="contact-email" maxlength="10272"></textarea></label>
<label><span data-i18n="Phone numbers (one per line)">Phone numbers (one per line)</span><textarea id="contact-phone" maxlength="4128"></textarea></label>
<label><span data-i18n="Website">Website</span><input id="contact-url" type="url" maxlength="2048"></label>
<h3 class="wide" data-i18n="Organization">Organization</h3>
<label><span data-i18n="Organization">Organization</span><input id="contact-organization" maxlength="512"></label>
<label><span data-i18n="Job title">Job title</span><input id="contact-title" maxlength="256"></label>
<label><span data-i18n="Role">Role</span><input id="contact-role" maxlength="256"></label>
<h3 class="wide" data-i18n="Address">Address</h3>
<label><span data-i18n="P.O. box">P.O. box</span><input id="contact-po-box" maxlength="256"></label>
<label><span data-i18n="Extended address">Extended address</span><input id="contact-extended-address" maxlength="512"></label>
<label class="wide"><span data-i18n="Street">Street</span><input id="contact-street" maxlength="512"></label>
<label><span data-i18n="Postal code">Postal code</span><input id="contact-postal-code" maxlength="64"></label>
<label><span data-i18n="City">City</span><input id="contact-city" maxlength="256"></label>
<label><span data-i18n="Region">Region</span><input id="contact-region" maxlength="256"></label>
<label><span data-i18n="Country">Country</span><input id="contact-country" maxlength="256"></label>
<label class="wide"><span data-i18n="Notes">Notes</span><textarea id="contact-note" maxlength="65536"></textarea></label>
<div class="wide organizer-actions"><button type="submit" data-i18n="Save">Save</button><button id="contact-delete" type="button" data-i18n="Delete" hidden>Delete</button><button id="contact-cancel" type="button" data-i18n="Cancel">Cancel</button></div>
</form>
</section>
<section id="admin-view" class="admin-view" hidden>
<h2 data-i18n="Server administration">Server administration</h2>
<p id="admin-status" role="status"></p>
<dl id="admin-summary"></dl>
<p data-i18n="Use the settings gear for personal settings and the task indicator for pending server work.">Use the settings gear for personal settings and the task indicator for pending server work.</p>
</section>
<section id="settings-view" class="settings-view" hidden>
<h2 data-i18n="Personal settings">Personal settings</h2>
<nav class="settings-nav" aria-label="Settings">
+13 -1
View File
@@ -142,12 +142,24 @@ class CalendarMutationTests(unittest.TestCase):
def test_web_event_round_trip(self):
payload = make_event({"summary": "Werkstatt", "start": "2026-07-22T09:30",
"end": "2026-07-22T10:30", "location": "Wien",
"description": "Prüfung"})
"description": "Prüfung", "status": "TENTATIVE",
"classification": "PRIVATE",
"transparency": "TRANSPARENT",
"categories": "work, family",
"organizer": "mario@example.test",
"attendees": ["one@example.test", "two@example.test"],
"recurrence": "FREQ=WEEKLY;BYDAY=WE",
"reminder_minutes": "15"})
result = event_to_dict({"href": "/calendars/u/personal/one.ics",
"etag": make_etag(payload), "data": payload})
self.assertEqual(result["resource"], "one.ics")
self.assertEqual(result["summary"], "Werkstatt")
self.assertEqual(result["start"], "2026-07-22 09:30:00")
self.assertEqual(result["status"], "TENTATIVE")
self.assertEqual(result["categories"], "work, family")
self.assertEqual(result["attendees"], ["one@example.test",
"two@example.test"])
self.assertEqual(result["reminder_minutes"], "15")
def test_web_event_requires_title_and_valid_range_types(self):
with self.assertRaises(ValueError):
+23 -3
View File
@@ -116,15 +116,35 @@ class CardMutationTests(unittest.TestCase):
self.assertNotIn(path, store.documents)
def test_web_contact_round_trip(self):
payload = make_card({"name": "Mario", "email": "mario@example.test",
"phone": "+43 1", "organization": "Bongo"})
payload = make_card({"name": "Dr Mario Test",
"honorific_prefix": "Dr", "given_name": "Mario",
"family_name": "Test", "birthday": "2000-01-02",
"emails": ["mario@example.test", "work@example.test"],
"phones": ["+43 1", "+43 2"],
"organization": "Bongo", "title": "Developer",
"street": "Main 1", "city": "Vienna",
"country": "AT"})
result = card_to_dict({"href": "/addressbooks/u/personal/one.vcf",
"etag": make_etag(payload), "data": payload})
self.assertEqual(result["resource"], "one.vcf")
self.assertEqual(result["name"], "Mario")
self.assertEqual(result["name"], "Dr Mario Test")
self.assertEqual(result["email"], "mario@example.test")
self.assertEqual(result["emails"], ["mario@example.test",
"work@example.test"])
self.assertEqual(result["family_name"], "Test")
self.assertEqual(result["birthday"], "2000-01-02")
self.assertEqual(result["city"], "Vienna")
self.assertEqual(result["organization"], "Bongo")
def test_web_contact_edit_preserves_unmanaged_vcard_fields(self):
original = make_card({"name": "Mario", "email": "old@example.test"})
text = original.decode("utf-8").replace(
"END:VCARD", "CATEGORIES:Friends\r\nEND:VCARD")
updated = make_card({"name": "Mario", "emails": ["new@example.test"]},
text.encode("utf-8"))
self.assertIn(b"CATEGORIES:Friends", updated)
self.assertIn(b"EMAIL:new@example.test", updated)
def test_web_contact_requires_name(self):
with self.assertRaises(ValueError):
make_card({"name": "", "email": "nobody@example.test"})
+48 -1
View File
@@ -19,9 +19,24 @@
# * </Novell-copyright>
# ****************************************************************************/
import json
import unittest
from bongo_web.filters import compile_filter, validate_filter
from bongo_web.filters import (WEB_RULE_BASE, compile_filter, delete_filter,
list_filters, save_filter, validate_filter)
class FakeRules:
def __init__(self, document=None):
self.document = json.dumps(document) if document is not None else None
def read(self, owner):
self.owner = owner
return self.document
def replace(self, owner, document):
self.owner = owner
self.document = document
class FilterTests(unittest.TestCase):
@@ -57,6 +72,38 @@ class FilterTests(unittest.TestCase):
validate_filter({"conditions": [{"type": "mailing_list"}],
"actions": [action]})
def test_web_filters_are_compiled_into_the_live_rules_document(self):
native = FakeRules({"rules": ["00000001Aexisting"], "other": True})
entry = save_filter("mario", {
"name": "Family", "enabled": True,
"definition": {
"conditions": [{"type": "sender", "value": "family@example.test"}],
"actions": [{"type": "move", "folder": "/mail/Family"},
{"type": "stop"}],
},
}, native)
self.assertEqual(entry["id"], WEB_RULE_BASE)
stored = json.loads(native.document)
self.assertTrue(stored["other"])
self.assertEqual(stored["rules"][0], "00000001Aexisting")
self.assertTrue(stored["rules"][1].startswith("70000000A"))
self.assertEqual(list_filters("mario", native)[0]["name"], "Family")
delete_filter("mario", WEB_RULE_BASE, native)
stored = json.loads(native.document)
self.assertEqual(stored["rules"], ["00000001Aexisting"])
self.assertEqual(stored["web_filters"], [])
def test_disabled_web_filter_is_saved_but_not_compiled(self):
native = FakeRules()
save_filter("mario", {
"name": "Later", "enabled": False,
"definition": {"conditions": [{"type": "mailing_list"}],
"actions": [{"type": "delete"}]},
}, native)
stored = json.loads(native.document)
self.assertEqual(stored["rules"], [])
self.assertFalse(stored["web_filters"][0]["enabled"])
if __name__ == "__main__":
unittest.main()