Complete classic webmail organizer views
Debian Trixie package bundle / packages (push) Successful in 21m22s
Debian Trixie package bundle / packages (push) Successful in 21m22s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
+182
-4
@@ -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."
|
||||
|
||||
+183
-5
@@ -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."
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user