diff --git a/docs/roadmap.md b/docs/roadmap.md index 1aef110..e2887ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -59,12 +59,25 @@ simple redesign: validated command-line/TUI tools; - improve the composer, quoting, contact selection, mailing-list actions, search, timezone handling, diagnostics, and accessible error messages; +- introduce one user-defined tag model with names, colours, icons, ordering, + and assignments across mail, calendar events, contacts, and tasks; expose + mail tags as IMAP keywords while retaining the standard flagged/starred + marker for old clients; +- offer a unified activity view where messages, appointments, contacts, and + actionable notices can be filtered and marked consistently, while retaining + their distinct Store types and protocol semantics; - complete calendar sharing, invitations, free/busy, delegation, and resource calendars such as rooms; - provide clear restart/reload indications, service health, queue summaries, scanner state, useful logs, and safe certificate deployment hooks; - finish maintained translations and the gettext workflow. +The Bulwark Webmail information architecture is a useful 0.8 UX reference for +the mail, calendar, contacts, settings, and administration surfaces. Bongo +will adapt the interaction model to its own Store and protocols, with an +independent implementation and Bongo-compatible artwork/licensing; the 0.7 +server does not acquire a JMAP or Stalwart dependency from that reference. + The classic Bongo/Dragonfly visual identity and usable existing artwork stay available. A small progressive-enhancement library such as HTMX may be used only when its stable release, licence, packaging, and long-term maintenance diff --git a/po/bongo-web/de.po b/po/bongo-web/de.po index 041dcc6..acdacf8 100644 --- a/po/bongo-web/de.po +++ b/po/bongo-web/de.po @@ -218,3 +218,155 @@ msgid "Organization" msgstr "Organisation" msgid "(no title)" msgstr "(kein Titel)" +msgid "Bongo system" +msgstr "Bongo-System" +msgid "Server administration and service status." +msgstr "Serververwaltung und Dienststatus." +msgid "Server administration" +msgstr "Serververwaltung" +msgid "Signed in as server administrator." +msgstr "Als Serveradministrator angemeldet." +msgid "This account is not a server administrator." +msgstr "Dieses Konto ist kein Serveradministrator." +msgid "Server status could not be loaded." +msgstr "Der Serverstatus konnte nicht geladen werden." +msgid "Account" +msgstr "Konto" +msgid "Host" +msgstr "Host" +msgid "Domains" +msgstr "Domänen" +msgid "Version" +msgstr "Version" +msgid "Use the settings gear for personal settings and the task indicator for pending server work." +msgstr "Das Zahnrad öffnet persönliche Einstellungen; die Aufgabenanzeige zeigt noch ausstehende Serverarbeiten." +msgid "Nickname" +msgstr "Spitzname" +msgid "Honorific prefix" +msgstr "Namenspräfix" +msgid "Given name" +msgstr "Vorname" +msgid "Additional name" +msgstr "Weitere Vornamen" +msgid "Family name" +msgstr "Nachname" +msgid "Honorific suffix" +msgstr "Namenssuffix" +msgid "Birthday" +msgstr "Geburtstag" +msgid "Anniversary" +msgstr "Jahrestag" +msgid "Communication" +msgstr "Kommunikation" +msgid "Email addresses (one per line)" +msgstr "E-Mail-Adressen (eine pro Zeile)" +msgid "Phone numbers (one per line)" +msgstr "Telefonnummern (eine pro Zeile)" +msgid "Website" +msgstr "Webseite" +msgid "Job title" +msgstr "Position" +msgid "Role" +msgstr "Rolle" +msgid "Address" +msgstr "Adresse" +msgid "P.O. box" +msgstr "Postfach" +msgid "Extended address" +msgstr "Adresszusatz" +msgid "Street" +msgstr "Straße" +msgid "Postal code" +msgstr "Postleitzahl" +msgid "City" +msgstr "Ort" +msgid "Region" +msgstr "Region/Bundesland" +msgid "Country" +msgstr "Land" +msgid "Notes" +msgstr "Notizen" +msgid "All-day event" +msgstr "Ganztägiger Termin" +msgid "Status" +msgstr "Status" +msgid "Confirmed" +msgstr "Bestätigt" +msgid "Tentative" +msgstr "Vorläufig" +msgid "Cancelled" +msgstr "Abgesagt" +msgid "Visibility" +msgstr "Sichtbarkeit" +msgid "Public" +msgstr "Öffentlich" +msgid "Private" +msgstr "Privat" +msgid "Confidential" +msgstr "Vertraulich" +msgid "Show time as" +msgstr "Zeit anzeigen als" +msgid "Busy" +msgstr "Beschäftigt" +msgid "Free" +msgstr "Frei" +msgid "Categories" +msgstr "Kategorien" +msgid "Organizer email" +msgstr "E-Mail des Organisators" +msgid "Attendee email addresses (one per line)" +msgstr "E-Mail-Adressen der Teilnehmer (eine pro Zeile)" +msgid "Recurrence rule" +msgstr "Wiederholungsregel" +msgid "Reminder (minutes before)" +msgstr "Erinnerung (Minuten vorher)" +msgid "Attendees are stored in the iCalendar event; invitation delivery is not enabled yet." +msgstr "Teilnehmer werden im iCalendar-Termin gespeichert; der Versand von Einladungen ist noch nicht aktiviert." +msgid "Filters could not be loaded." +msgstr "Die Filter konnten nicht geladen werden." +msgid "Filter name" +msgstr "Filtername" +msgid "Enabled" +msgstr "Aktiviert" +msgid "Condition" +msgstr "Bedingung" +msgid "Value" +msgstr "Wert" +msgid "Action" +msgstr "Aktion" +msgid "Sender contains" +msgstr "Absender enthält" +msgid "Recipient contains" +msgstr "Empfänger enthält" +msgid "Subject contains" +msgstr "Betreff enthält" +msgid "Sender domain" +msgstr "Absenderdomäne" +msgid "Mailing list" +msgstr "Mailingliste" +msgid "List-ID" +msgstr "List-ID" +msgid "Move to folder" +msgstr "In Ordner verschieben" +msgid "Copy to folder" +msgstr "In Ordner kopieren" +msgid "Forward to address" +msgstr "An Adresse weiterleiten" +msgid "Delete message" +msgstr "Nachricht löschen" +msgid "Forwarding address" +msgstr "Weiterleitungsadresse" +msgid "Language" +msgstr "Sprache" +msgid "English" +msgstr "Englisch" +msgid "German" +msgstr "Deutsch" +msgid "French" +msgstr "Französisch" +msgid "Brazilian Portuguese" +msgstr "Brasilianisches Portugiesisch" +msgid "Compact lists" +msgstr "Kompakte Listen" +msgid "Display settings saved." +msgstr "Darstellungseinstellungen gespeichert." diff --git a/po/bongo-web/fr.po b/po/bongo-web/fr.po index a961894..e02096b 100644 --- a/po/bongo-web/fr.po +++ b/po/bongo-web/fr.po @@ -569,7 +569,7 @@ msgstr "" #: src/www/js/Login.js:110 #, fuzzy msgid "Language" -msgstr "Langue:" +msgstr "Langue" #: src/www/js/Login.js:111 msgid "Remember me" @@ -772,11 +772,11 @@ msgstr "" #: src/www/js/OccurrenceEditor.js:450 #, fuzzy msgid "Location" -msgstr "Location" +msgstr "Lieu" #: src/www/js/OccurrenceEditor.js:451 msgid "Notes" -msgstr "" +msgstr "Notes" #: src/www/js/OccurrenceEditor.js:584 msgid "Repeat:" @@ -943,7 +943,7 @@ msgstr "" #: src/www/js/Widgets.js:1118 msgid "Region" -msgstr "" +msgstr "Région" #: src/www/js/Widgets.js:1118 msgid "Timezone" @@ -1548,3 +1548,181 @@ msgid "Disable two-factor authentication" msgstr "Désactiver l’authentification à deux facteurs" msgid "Update clients using the old login name" msgstr "Mettre à jour les clients qui utilisent encore l’ancien identifiant" +msgid "Create and manage calendar events." +msgstr "Créer et gérer des événements de calendrier." +msgid "Message" +msgstr "Message" +msgid "Create and manage contacts." +msgstr "Créer et gérer des contacts." +msgid "Open tasks that require your attention." +msgstr "Tâches ouvertes qui nécessitent votre attention." +msgid "Review the following information and complete the required change." +msgstr "Vérifiez les informations suivantes et effectuez la modification demandée." +msgid "Details" +msgstr "Détails" +msgid "New event" +msgstr "Nouvel événement" +msgid "New contact" +msgstr "Nouveau contact" +msgid "Title" +msgstr "Titre" +msgid "Start" +msgstr "Début" +msgid "End" +msgstr "Fin" +msgid "Description" +msgstr "Description" +msgid "Name" +msgstr "Nom" +msgid "Phone" +msgstr "Téléphone" +msgid "Organization" +msgstr "Organisation" +msgid "(no title)" +msgstr "(sans titre)" +msgid "Bongo system" +msgstr "Système Bongo" +msgid "Server administration and service status." +msgstr "Administration du serveur et état des services." +msgid "Server administration" +msgstr "Administration du serveur" +msgid "Signed in as server administrator." +msgstr "Connecté en tant qu’administrateur du serveur." +msgid "This account is not a server administrator." +msgstr "Ce compte n’est pas administrateur du serveur." +msgid "Server status could not be loaded." +msgstr "Impossible de charger l’état du serveur." +msgid "Account" +msgstr "Compte" +msgid "Host" +msgstr "Hôte" +msgid "Domains" +msgstr "Domaines" +msgid "Version" +msgstr "Version" +msgid "Use the settings gear for personal settings and the task indicator for pending server work." +msgstr "Utilisez la roue dentée pour les réglages personnels et l’indicateur de tâches pour les travaux serveur en attente." +msgid "Nickname" +msgstr "Surnom" +msgid "Honorific prefix" +msgstr "Préfixe honorifique" +msgid "Given name" +msgstr "Prénom" +msgid "Additional name" +msgstr "Deuxième prénom" +msgid "Family name" +msgstr "Nom de famille" +msgid "Honorific suffix" +msgstr "Suffixe honorifique" +msgid "Birthday" +msgstr "Date de naissance" +msgid "Anniversary" +msgstr "Anniversaire" +msgid "Communication" +msgstr "Communication" +msgid "Email addresses (one per line)" +msgstr "Adresses e-mail (une par ligne)" +msgid "Phone numbers (one per line)" +msgstr "Numéros de téléphone (un par ligne)" +msgid "Website" +msgstr "Site web" +msgid "Job title" +msgstr "Fonction" +msgid "Role" +msgstr "Rôle" +msgid "Address" +msgstr "Adresse" +msgid "P.O. box" +msgstr "Boîte postale" +msgid "Extended address" +msgstr "Complément d’adresse" +msgid "Street" +msgstr "Rue" +msgid "Postal code" +msgstr "Code postal" +msgid "City" +msgstr "Ville" +msgid "Country" +msgstr "Pays" +msgid "All-day event" +msgstr "Événement sur toute la journée" +msgid "Status" +msgstr "État" +msgid "Confirmed" +msgstr "Confirmé" +msgid "Tentative" +msgstr "Provisoire" +msgid "Cancelled" +msgstr "Annulé" +msgid "Visibility" +msgstr "Visibilité" +msgid "Public" +msgstr "Public" +msgid "Private" +msgstr "Privé" +msgid "Confidential" +msgstr "Confidentiel" +msgid "Show time as" +msgstr "Afficher la période comme" +msgid "Busy" +msgstr "Occupé" +msgid "Free" +msgstr "Libre" +msgid "Categories" +msgstr "Catégories" +msgid "Organizer email" +msgstr "E-mail de l’organisateur" +msgid "Attendee email addresses (one per line)" +msgstr "Adresses e-mail des participants (une par ligne)" +msgid "Recurrence rule" +msgstr "Règle de récurrence" +msgid "Reminder (minutes before)" +msgstr "Rappel (minutes avant)" +msgid "Attendees are stored in the iCalendar event; invitation delivery is not enabled yet." +msgstr "Les participants sont enregistrés dans l’événement iCalendar ; l’envoi des invitations n’est pas encore activé." +msgid "Filters could not be loaded." +msgstr "Impossible de charger les filtres." +msgid "Filter name" +msgstr "Nom du filtre" +msgid "Enabled" +msgstr "Activé" +msgid "Condition" +msgstr "Condition" +msgid "Value" +msgstr "Valeur" +msgid "Action" +msgstr "Action" +msgid "Sender contains" +msgstr "L’expéditeur contient" +msgid "Recipient contains" +msgstr "Le destinataire contient" +msgid "Subject contains" +msgstr "L’objet contient" +msgid "Sender domain" +msgstr "Domaine de l’expéditeur" +msgid "Mailing list" +msgstr "Liste de diffusion" +msgid "List-ID" +msgstr "List-ID" +msgid "Move to folder" +msgstr "Déplacer vers le dossier" +msgid "Copy to folder" +msgstr "Copier vers le dossier" +msgid "Forward to address" +msgstr "Transférer à l’adresse" +msgid "Delete message" +msgstr "Supprimer le message" +msgid "Forwarding address" +msgstr "Adresse de transfert" +msgid "English" +msgstr "Anglais" +msgid "German" +msgstr "Allemand" +msgid "French" +msgstr "Français" +msgid "Brazilian Portuguese" +msgstr "Portugais brésilien" +msgid "Compact lists" +msgstr "Listes compactes" +msgid "Display settings saved." +msgstr "Paramètres d’affichage enregistrés." diff --git a/po/bongo-web/pt_BR.po b/po/bongo-web/pt_BR.po index 9963ce5..728cb69 100644 --- a/po/bongo-web/pt_BR.po +++ b/po/bongo-web/pt_BR.po @@ -551,7 +551,7 @@ msgstr "" #: src/www/js/Login.js:110 msgid "Language" -msgstr "Língua" +msgstr "Idioma" #: src/www/js/Login.js:111 msgid "Remember me" @@ -738,8 +738,6 @@ msgid "Repeat" msgstr "Repetir" #: src/www/js/OccurrenceEditor.js:450 -msgid "Location" -msgstr "Local" #: src/www/js/OccurrenceEditor.js:451 msgid "Notes" @@ -903,8 +901,6 @@ msgid "Add..." msgstr "Adicionar..." #: src/www/js/Widgets.js:1118 -msgid "Region" -msgstr "Região" #: src/www/js/Widgets.js:1118 msgid "Timezone" @@ -1033,3 +1029,185 @@ msgid "Disable two-factor authentication" msgstr "Desativar a autenticação de dois fatores" msgid "Update clients using the old login name" msgstr "Atualizar clientes que ainda usam o nome de login antigo" +msgid "Create and manage calendar events." +msgstr "Criar e gerenciar eventos do calendário." +msgid "Message" +msgstr "Mensagem" +msgid "Create and manage contacts." +msgstr "Criar e gerenciar contatos." +msgid "Open tasks that require your attention." +msgstr "Tarefas abertas que exigem sua atenção." +msgid "Review the following information and complete the required change." +msgstr "Revise as informações a seguir e conclua a alteração necessária." +msgid "Details" +msgstr "Detalhes" +msgid "New event" +msgstr "Novo evento" +msgid "New contact" +msgstr "Novo contato" +msgid "Title" +msgstr "Título" +msgid "Start" +msgstr "Início" +msgid "End" +msgstr "Fim" +msgid "Location" +msgstr "Local" +msgid "Description" +msgstr "Descrição" +msgid "Name" +msgstr "Nome" +msgid "Phone" +msgstr "Telefone" +msgid "Organization" +msgstr "Organização" +msgid "(no title)" +msgstr "(sem título)" +msgid "Bongo system" +msgstr "Sistema Bongo" +msgid "Server administration and service status." +msgstr "Administração do servidor e estado dos serviços." +msgid "Server administration" +msgstr "Administração do servidor" +msgid "Signed in as server administrator." +msgstr "Conectado como administrador do servidor." +msgid "This account is not a server administrator." +msgstr "Esta conta não é administradora do servidor." +msgid "Server status could not be loaded." +msgstr "Não foi possível carregar o estado do servidor." +msgid "Account" +msgstr "Conta" +msgid "Host" +msgstr "Servidor" +msgid "Domains" +msgstr "Domínios" +msgid "Version" +msgstr "Versão" +msgid "Use the settings gear for personal settings and the task indicator for pending server work." +msgstr "Use a engrenagem para configurações pessoais e o indicador de tarefas para trabalhos pendentes no servidor." +msgid "Nickname" +msgstr "Apelido" +msgid "Honorific prefix" +msgstr "Prefixo honorífico" +msgid "Given name" +msgstr "Nome" +msgid "Additional name" +msgstr "Nome adicional" +msgid "Family name" +msgstr "Sobrenome" +msgid "Honorific suffix" +msgstr "Sufixo honorífico" +msgid "Birthday" +msgstr "Data de nascimento" +msgid "Anniversary" +msgstr "Aniversário" +msgid "Communication" +msgstr "Comunicação" +msgid "Email addresses (one per line)" +msgstr "Endereços de e-mail (um por linha)" +msgid "Phone numbers (one per line)" +msgstr "Números de telefone (um por linha)" +msgid "Website" +msgstr "Site" +msgid "Job title" +msgstr "Cargo" +msgid "Role" +msgstr "Função" +msgid "Address" +msgstr "Endereço" +msgid "P.O. box" +msgstr "Caixa postal" +msgid "Extended address" +msgstr "Complemento" +msgid "Street" +msgstr "Rua" +msgid "Postal code" +msgstr "Código postal" +msgid "City" +msgstr "Cidade" +msgid "Region" +msgstr "Região" +msgid "Country" +msgstr "País" +msgid "All-day event" +msgstr "Evento de dia inteiro" +msgid "Status" +msgstr "Estado" +msgid "Confirmed" +msgstr "Confirmado" +msgid "Tentative" +msgstr "Provisório" +msgid "Cancelled" +msgstr "Cancelado" +msgid "Visibility" +msgstr "Visibilidade" +msgid "Public" +msgstr "Público" +msgid "Private" +msgstr "Privado" +msgid "Confidential" +msgstr "Confidencial" +msgid "Show time as" +msgstr "Mostrar horário como" +msgid "Busy" +msgstr "Ocupado" +msgid "Free" +msgstr "Livre" +msgid "Categories" +msgstr "Categorias" +msgid "Organizer email" +msgstr "E-mail do organizador" +msgid "Attendee email addresses (one per line)" +msgstr "Endereços de e-mail dos participantes (um por linha)" +msgid "Recurrence rule" +msgstr "Regra de recorrência" +msgid "Reminder (minutes before)" +msgstr "Lembrete (minutos antes)" +msgid "Attendees are stored in the iCalendar event; invitation delivery is not enabled yet." +msgstr "Os participantes são armazenados no evento iCalendar; o envio de convites ainda não está ativado." +msgid "Filters could not be loaded." +msgstr "Não foi possível carregar os filtros." +msgid "Filter name" +msgstr "Nome do filtro" +msgid "Enabled" +msgstr "Ativado" +msgid "Condition" +msgstr "Condição" +msgid "Value" +msgstr "Valor" +msgid "Action" +msgstr "Ação" +msgid "Sender contains" +msgstr "O remetente contém" +msgid "Recipient contains" +msgstr "O destinatário contém" +msgid "Subject contains" +msgstr "O assunto contém" +msgid "Sender domain" +msgstr "Domínio do remetente" +msgid "Mailing list" +msgstr "Lista de discussão" +msgid "List-ID" +msgstr "List-ID" +msgid "Move to folder" +msgstr "Mover para a pasta" +msgid "Copy to folder" +msgstr "Copiar para a pasta" +msgid "Forward to address" +msgstr "Encaminhar para o endereço" +msgid "Delete message" +msgstr "Excluir mensagem" +msgid "Forwarding address" +msgstr "Endereço de encaminhamento" +msgid "English" +msgstr "Inglês" +msgid "German" +msgstr "Alemão" +msgid "French" +msgstr "Francês" +msgid "Brazilian Portuguese" +msgstr "Português do Brasil" +msgid "Compact lists" +msgstr "Listas compactas" +msgid "Display settings saved." +msgstr "Configurações de exibição salvas." diff --git a/src/apps/config/config/web b/src/apps/config/config/web index 01eba36..ac88d06 100644 --- a/src/apps/config/config/web +++ b/src/apps/config/config/web @@ -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", diff --git a/src/apps/config/tests/test_configuration.py b/src/apps/config/tests/test_configuration.py index f3daf80..776c1b8 100644 --- a/src/apps/config/tests/test_configuration.py +++ b/src/apps/config/tests/test_configuration.py @@ -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", diff --git a/src/libs/python/bongo/configuration/model.py b/src/libs/python/bongo/configuration/model.py index b332b38..829eddf 100644 --- a/src/libs/python/bongo/configuration/model.py +++ b/src/libs/python/bongo/configuration/model.py @@ -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): diff --git a/src/libs/python/libbongo/CMakeLists.txt b/src/libs/python/libbongo/CMakeLists.txt index 5583d21..7b92cd1 100644 --- a/src/libs/python/libbongo/CMakeLists.txt +++ b/src/libs/python/libbongo/CMakeLists.txt @@ -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) diff --git a/src/libs/python/libbongo/rules-module.c b/src/libs/python/libbongo/rules-module.c new file mode 100644 index 0000000..2449717 --- /dev/null +++ b/src/libs/python/libbongo/rules-module.c @@ -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 +#include +#include + +#include +#include + +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); +} diff --git a/src/www/bongo-web.py.in b/src/www/bongo-web.py.in index e3a5995..644b1d8 100644 --- a/src/www/bongo-web.py.in +++ b/src/www/bongo-web.py.in @@ -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 diff --git a/src/www/bongo_web/calendar_store.py b/src/www/bongo_web/calendar_store.py index b902a8d..05e7941 100644 --- a/src/www/bongo_web/calendar_store.py +++ b/src/www/bongo_web/calendar_store.py @@ -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") diff --git a/src/www/bongo_web/card_store.py b/src/www/bongo_web/card_store.py index ce42a4a..9e9d03b 100644 --- a/src/www/bongo_web/card_store.py +++ b/src/www/bongo_web/card_store.py @@ -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") diff --git a/src/www/bongo_web/filters.py b/src/www/bongo_web/filters.py index 476544f..23917c5 100644 --- a/src/www/bongo_web/filters.py +++ b/src/www/bongo_web/filters.py @@ -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) diff --git a/src/www/static/app.css b/src/www/static/app.css index 4ab4a80..f23f4e1 100644 --- a/src/www/static/app.css +++ b/src/www/static/app.css @@ -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; } } diff --git a/src/www/static/app.js b/src/www/static/app.js index 7b9e2de..c37ef81 100644 --- a/src/www/static/app.js +++ b/src/www/static/app.js @@ -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())); diff --git a/src/www/static/index.html b/src/www/static/index.html index d78d41a..8608aa2 100644 --- a/src/www/static/index.html +++ b/src/www/static/index.html @@ -114,10 +114,21 @@ @@ -126,13 +137,42 @@
    +