From 1dfb76462ec4ccdb5660e202138f96ff3968bdd1 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Wed, 22 Jul 2026 04:05:20 +0200 Subject: [PATCH] Complete web mail organizer views --- po/bongo-web/de.po | 32 ++++ src/agents/store/mail.c | 7 + src/agents/store/properties.c | 11 +- src/agents/store/query-builder.c | 9 +- .../bongo/storetool/InteractiveCommands.py | 33 +++- src/www/bongo-web.py.in | 142 +++++++++++++- src/www/bongo_web/calendar_store.py | 69 +++++++ src/www/bongo_web/card_store.py | 40 ++++ src/www/static/app.css | 17 +- src/www/static/app.js | 177 +++++++++++++++++- src/www/static/index.html | 26 +++ src/www/tests/test_calendar_store.py | 20 +- src/www/tests/test_card_store.py | 17 +- 13 files changed, 572 insertions(+), 28 deletions(-) diff --git a/po/bongo-web/de.po b/po/bongo-web/de.po index 850c82d..041dcc6 100644 --- a/po/bongo-web/de.po +++ b/po/bongo-web/de.po @@ -186,3 +186,35 @@ msgid "Disable two-factor authentication" msgstr "Zwei-Faktor-Authentifizierung deaktivieren" msgid "Update clients using the old login name" msgstr "Clients aktualisieren, die noch den alten Anmeldenamen verwenden" +msgid "Create and manage calendar events." +msgstr "Kalendertermine erstellen und verwalten." +msgid "Create and manage contacts." +msgstr "Kontakte erstellen und verwalten." +msgid "Open tasks that require your attention." +msgstr "Offene Aufgaben, die Ihre Aufmerksamkeit benötigen." +msgid "Review the following information and complete the required change." +msgstr "Prüfen Sie die folgenden Informationen und führen Sie die erforderliche Änderung durch." +msgid "Details" +msgstr "Details" +msgid "New event" +msgstr "Neuer Termin" +msgid "New contact" +msgstr "Neuer Kontakt" +msgid "Title" +msgstr "Titel" +msgid "Start" +msgstr "Beginn" +msgid "End" +msgstr "Ende" +msgid "Location" +msgstr "Ort" +msgid "Description" +msgstr "Beschreibung" +msgid "Name" +msgstr "Name" +msgid "Phone" +msgstr "Telefon" +msgid "Organization" +msgstr "Organisation" +msgid "(no title)" +msgstr "(kein Titel)" diff --git a/src/agents/store/mail.c b/src/agents/store/mail.c index 3be8614..9b181fa 100644 --- a/src/agents/store/mail.c +++ b/src/agents/store/mail.c @@ -70,6 +70,7 @@ StoreProcessIncomingMail(StoreClient *client, pid_t childpid; char readbuffer[4096]; char *message_subject = NULL; + char *message_from = NULL; memset(&conversation_data, 0, sizeof(StoreConversationData)); memset(&conversation, 0, sizeof(conversation)); @@ -223,6 +224,8 @@ StoreProcessIncomingMail(StoreClient *client, if (strcmp(readbuffer, "nmap.mail.subject") == 0) { message_subject = MemStrdup(pair); + } else if (strcmp(readbuffer, "bongo.from") == 0) { + message_from = MemStrdup(pair); } } break; @@ -274,6 +277,9 @@ StoreProcessIncomingMail(StoreClient *client, goto finish; } } + if (message_from) { + SetDocProp(client, &conversation, "bongo.from", message_from); + } // Link new mail to conversation if (StoreObjectLink(client, &conversation, document)) { // couldn't put the link in place, erk. @@ -284,6 +290,7 @@ StoreProcessIncomingMail(StoreClient *client, finish: if (message_subject) MemFree(message_subject); + if (message_from) MemFree(message_from); if (spipe) { ConnClose(spipe); ConnFree(spipe); diff --git a/src/agents/store/properties.c b/src/agents/store/properties.c index 7881ad0..70a5269 100644 --- a/src/agents/store/properties.c +++ b/src/agents/store/properties.c @@ -33,10 +33,10 @@ const StorePropValName StorePropTable[] = { { STORE_PROP_MAIL_PARENTMESSAGEID, "nmap.mail.parentmessageid", STORE_PROPTABLE_NONE, NULL }, { STORE_PROP_MAIL_SENT, "nmap.mail.sent", STORE_PROPTABLE_NONE, NULL }, { STORE_PROP_MAIL_SUBJECT, "nmap.mail.subject", STORE_PROPTABLE_NONE, NULL }, - { STORE_PROP_CONVERSATION_COUNT, "nmap.conversation.count", STORE_PROPTABLE_NONE, NULL }, - { STORE_PROP_CONVERSATION_DATE, "nmap.conversation.date", STORE_PROPTABLE_NONE, NULL }, - { STORE_PROP_CONVERSATION_SUBJECT, "nmap.conversation.subject", STORE_PROPTABLE_NONE, NULL }, - { STORE_PROP_CONVERSATION_UNREAD, "nmap.conversation.unread", STORE_PROPTABLE_NONE, NULL }, + { STORE_PROP_CONVERSATION_COUNT, "nmap.conversation.count", STORE_PROPTABLE_CONV, NULL }, + { STORE_PROP_CONVERSATION_DATE, "nmap.conversation.date", STORE_PROPTABLE_CONV, "date" }, + { STORE_PROP_CONVERSATION_SUBJECT, "nmap.conversation.subject", STORE_PROPTABLE_CONV, "subject" }, + { STORE_PROP_CONVERSATION_UNREAD, "nmap.conversation.unread", STORE_PROPTABLE_CONV, "unread" }, { 0, 0, STORE_PROPTABLE_NONE, NULL } }; @@ -128,6 +128,9 @@ StorePropertyFixup(StorePropInfo *prop) case STORE_PROPTABLE_SO: prop->table_name = "so"; break; + case STORE_PROPTABLE_CONV: + prop->table_name = "c"; + break; default: // no other tables used at this point. prop->table_name = NULL; diff --git a/src/agents/store/query-builder.c b/src/agents/store/query-builder.c index 3b677e5..403c686 100644 --- a/src/agents/store/query-builder.c +++ b/src/agents/store/query-builder.c @@ -207,6 +207,9 @@ QueryBuilderAddProperty(QueryBuilder *builder, char const *property, BOOL output newprop->index = builder->properties->len; StorePropertyFixup(newprop); + if (newprop->table == STORE_PROPTABLE_CONV) { + builder->linkin_conversations = TRUE; + } newprop->output = output; g_ptr_array_add(builder->properties, newprop); @@ -218,7 +221,11 @@ QueryBuilderPropertyToColumn(QueryBuilder *builder, BongoStringBuilder *sb, Stor { if (builder == NULL) return -1; - if ((prop->table_name != NULL) && (prop->column != NULL)) { + if (prop->type == STORE_PROP_CONVERSATION_COUNT) { + BongoStringBuilderAppend(sb, + "(SELECT COUNT(*) FROM links conversation_links " + "WHERE conversation_links.doc_guid = so.guid)"); + } else if ((prop->table_name != NULL) && (prop->column != NULL)) { BongoStringBuilderAppendF(sb, "%s.%s", prop->table_name, prop->column); } else { BongoStringBuilderAppendF(sb, "prop_%d.value", prop->index); diff --git a/src/apps/storetool/bongo/storetool/InteractiveCommands.py b/src/apps/storetool/bongo/storetool/InteractiveCommands.py index 27ebbe6..77a9753 100644 --- a/src/apps/storetool/bongo/storetool/InteractiveCommands.py +++ b/src/apps/storetool/bongo/storetool/InteractiveCommands.py @@ -3,7 +3,6 @@ import os import select import socket import sys -import telnetlib import time try: @@ -16,8 +15,36 @@ from bongo.Contact import Contact from bongo.store.CommandStream import CommandStream from bongo.store.StoreConnection import Auth -class BongoTelnet(telnetlib.Telnet): - """A Telnet class made for talking to Bongo Stores. Uses readline.""" +class BongoTelnet: + """Small interactive transport for the Store protocol. + + The Store endpoint is a line protocol, not Telnet. Using the removed + :mod:`telnetlib` module only supplied a socket and made storetool fail to + start on Python 3.13 and newer. + """ + def __init__(self, host, port): + self._socket = socket.create_connection((host, int(port))) + + def get_socket(self): + return self._socket + + def fileno(self): + return self._socket.fileno() + + def write(self, value): + if isinstance(value, str): + value = value.encode("utf-8") + self._socket.sendall(value) + + def read_eager(self): + value = self._socket.recv(65536) + if not value: + raise EOFError + return value.decode("utf-8", "replace") + + def close(self): + self._socket.close() + def mt_interact(self): """Multithreaded version of interact().""" import _thread diff --git a/src/www/bongo-web.py.in b/src/www/bongo-web.py.in index 967f61e..e3a5995 100644 --- a/src/www/bongo-web.py.in +++ b/src/www/bongo-web.py.in @@ -48,13 +48,15 @@ from urllib.parse import parse_qs, urlsplit from bongo_web.auth import (LoginLimiter, authenticate, renew, revoke, valid_csrf) -from bongo_web.calendar_store import (CalendarNotFound, CalendarPathError, +from bongo_web.calendar_store import (EVENT_NAME, CalendarNotFound, CalendarPathError, PreconditionFailed, delete_event, - list_events, parse_calendar_path, read_event, + event_to_dict, list_events, make_event, + parse_calendar_path, read_event, write_event) -from bongo_web.card_store import (CardNotFound, CardPathError, +from bongo_web.card_store import (CARD_NAME, CardNotFound, CardPathError, CardPreconditionFailed, delete_card, - list_cards, parse_card_path, read_card, + card_to_dict, list_cards, make_card, + parse_card_path, read_card, write_card) from bongo_web.dav import (card_multistatus, event_multistatus, multistatus, options_headers, parse_card_report, parse_report, @@ -110,8 +112,8 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail", "Calendar", "Contacts", "Administration", "Mail, calendar and contacts", "Login failed.", "Too many login attempts. Please try again later.", - "Calendar access is available through CalDAV.", - "Contact access is available through CardDAV.", + "Create and manage calendar events.", + "Create and manage contacts.", "The secure webmail connection is being enabled.", "Administrative functions require a server-side role check.", "Folder", "Refresh", "(no subject)", "Back", @@ -146,7 +148,12 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail", "Copy all recovery codes", "Copied.", "Could not copy recovery codes.", "Disabling two-factor authentication requires your password and a current authentication or recovery code.", - "Disable two-factor authentication") + "Disable two-factor authentication", + "Open tasks that require your attention.", + "Review the following information and complete the required change.", + "Details", "New event", "New contact", "Title", "Start", + "End", "Location", "Description", "Name", "Phone", + "Organization", "(no title)") 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", @@ -371,6 +378,111 @@ class BongoWebHandler(BaseHTTPRequestHandler): return self._json(HTTPStatus.OK, result, {"Vary": "Cookie"}) + def _organizer_api_get(self, kind): + session = self._session() + if session is None: + self._json(HTTPStatus.UNAUTHORIZED, + {"error": "authentication required"}, {"Vary": "Cookie"}) + return + try: + if kind == "calendar": + values = [event_to_dict(item) for item in list_events( + session["user"], session["store_cookie"])] + result = {"events": values} + else: + values = [card_to_dict(item) for item in list_cards( + session["user"], session["store_cookie"])] + result = {"contacts": values} + except (CalendarNotFound, CardNotFound): + self._empty(HTTPStatus.NOT_FOUND) + return + except (RuntimeError, ValueError, UnicodeError): + logging.exception("Could not serve %s web request", kind) + self._empty(HTTPStatus.INTERNAL_SERVER_ERROR) + return + self._json(HTTPStatus.OK, result, {"Vary": "Cookie"}) + + def _organizer_api_save(self, kind): + session = self._session() + if session is None: + self._json(HTTPStatus.UNAUTHORIZED, + {"error": "authentication required"}) + return + if not valid_csrf(session, self.headers.get("X-Bongo-CSRF", "")): + self._json(HTTPStatus.FORBIDDEN, {"error": "invalid CSRF token"}) + return + try: + data = self._request_data() + resource = str(data.get("resource", "")).strip() + etag = str(data.get("etag", "")).strip() or None + if kind == "calendar": + if resource and not EVENT_NAME.fullmatch(resource): + raise ValueError("invalid event resource") + resource = resource or os.urandom(16).hex() + ".ics" + created, current = write_event( + session["user"], session["store_cookie"], resource, + make_event(data), 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" + created, current = write_card( + session["user"], session["store_cookie"], resource, + make_card(data), if_match=etag, + if_none_match=None if etag else "*") + except (TypeError, ValueError, OverflowError, UnicodeError, + json.JSONDecodeError): + self._empty(HTTPStatus.BAD_REQUEST) + return + except (PreconditionFailed, CardPreconditionFailed): + self._empty(HTTPStatus.PRECONDITION_FAILED) + return + except RuntimeError: + logging.exception("Could not save %s web object", kind) + self._empty(HTTPStatus.INTERNAL_SERVER_ERROR) + return + self._json(HTTPStatus.CREATED if created else HTTPStatus.OK, + {"resource": resource, "etag": current}, {"Vary": "Cookie"}) + + def _organizer_api_delete(self, kind): + session = self._session() + if session is None: + self._json(HTTPStatus.UNAUTHORIZED, + {"error": "authentication required"}) + return + if not valid_csrf(session, self.headers.get("X-Bongo-CSRF", "")): + self._json(HTTPStatus.FORBIDDEN, {"error": "invalid CSRF token"}) + return + query = parse_qs(urlsplit(self.path).query) + resource = query.get("resource", [""])[-1] + etag = query.get("etag", [None])[-1] + try: + if kind == "calendar": + if not EVENT_NAME.fullmatch(resource): + raise ValueError("invalid event resource") + delete_event(session["user"], session["store_cookie"], + resource, etag) + else: + if not CARD_NAME.fullmatch(resource): + raise ValueError("invalid contact resource") + delete_card(session["user"], session["store_cookie"], + resource, etag) + except (TypeError, ValueError): + self._empty(HTTPStatus.BAD_REQUEST) + return + except (CalendarNotFound, CardNotFound): + self._empty(HTTPStatus.NOT_FOUND) + return + except (PreconditionFailed, CardPreconditionFailed): + self._empty(HTTPStatus.PRECONDITION_FAILED) + return + except RuntimeError: + logging.exception("Could not delete %s web object", kind) + self._empty(HTTPStatus.INTERNAL_SERVER_ERROR) + return + self._empty(HTTPStatus.NO_CONTENT, {"Vary": "Cookie"}) + def _session_token(self): try: cookies = SimpleCookie(self.headers.get("Cookie", "")) @@ -493,6 +605,10 @@ class BongoWebHandler(BaseHTTPRequestHandler): self._json(HTTPStatus.OK, result, {"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/tasks": session = self._session() if session is None: @@ -662,6 +778,12 @@ class BongoWebHandler(BaseHTTPRequestHandler): if path == "/api/mail/send": self._mail_send() return + if path == "/api/calendar/events": + self._organizer_api_save("calendar") + return + if path == "/api/contacts": + self._organizer_api_save("contacts") + return if path == "/api/external-accounts": self._external_account_create() return @@ -1065,6 +1187,12 @@ class BongoWebHandler(BaseHTTPRequestHandler): def do_DELETE(self): path = self._path() + if path == "/api/calendar/events": + self._organizer_api_delete("calendar") + return + if path == "/api/contacts": + self._organizer_api_delete("contacts") + return if path == "/api/auth/totp": self._totp_disable() return diff --git a/src/www/bongo_web/calendar_store.py b/src/www/bongo_web/calendar_store.py index ad8d7d8..b902a8d 100644 --- a/src/www/bongo_web/calendar_store.py +++ b/src/www/bongo_web/calendar_store.py @@ -23,6 +23,8 @@ import hashlib import re +import uuid +from datetime import date, datetime from urllib.parse import quote, unquote @@ -138,6 +140,73 @@ def _close(client): pass +def _vobject(): + try: + import vobject + except ImportError as error: + raise RuntimeError("the system vobject package is required") from error + return vobject + + +def _component_value(component, name, default=""): + value = getattr(component, name, None) + return str(value.value) if value is not None else default + + +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 + 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"), + "end": _component_value(event, "dtend"), + "location": _component_value(event, "location"), + "description": _component_value(event, "description"), + } + + +def _iso_date_time(value): + text = str(value).strip() + if not text or len(text) > 64: + raise ValueError("invalid event date") + if "T" not in text: + return date.fromisoformat(text) + return datetime.fromisoformat(text.replace("Z", "+00:00")) + + +def make_event(data): + """Build one standards-compliant iCalendar object from web form data.""" + summary = str(data.get("summary", "")).strip() + if not summary or len(summary) > 512: + raise ValueError("invalid event summary") + start = _iso_date_time(data.get("start", "")) + end_text = str(data.get("end", "")).strip() + 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()) + # 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()) + event.add("summary").value = summary + 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") + if value: + event.add(name).value = value + return calendar.serialize().encode("utf-8") + + def write_event(user, store_cookie, event, payload, if_match=None, if_none_match=None, client_factory=open_store, to_json=ical_to_json, to_ical=json_to_ical): diff --git a/src/www/bongo_web/card_store.py b/src/www/bongo_web/card_store.py index 8e88973..ce42a4a 100644 --- a/src/www/bongo_web/card_store.py +++ b/src/www/bongo_web/card_store.py @@ -24,6 +24,7 @@ import hashlib import json import re +import uuid from urllib.parse import quote, unquote @@ -150,6 +151,45 @@ def _close(client): pass +def card_to_dict(item): + card = _vobject().readOne(item["data"].decode("utf-8", "strict")) + emails = [str(line.value) for line in card.contents.get("email", [])] + phones = [str(line.value) for line in card.contents.get("tel", [])] + organization = "" + if card.contents.get("org"): + value = card.contents["org"][0].value + organization = " ".join(str(part) for part in value) \ + if isinstance(value, (list, tuple)) else str(value) + 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 "", + "phone": phones[0] if phones else "", + "organization": organization, + } + + +def make_card(data): + 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()) + 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") + if value: + line = card.add(component) + line.value = [value] if component == "org" else value + return card.serialize().encode("utf-8") + + def read_card(user, store_cookie, card, client_factory=open_store, converter=json_to_vcard): client = None diff --git a/src/www/static/app.css b/src/www/static/app.css index 43f4407..4ab4a80 100644 --- a/src/www/static/app.css +++ b/src/www/static/app.css @@ -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: #1d3980 url('/assets/top-gradient.png') repeat-y 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: 280px 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; } @@ -55,6 +55,11 @@ header { grid-column: 1 / -1; display: flex; align-items: center; min-height: 68 .task-panel { margin: .5rem 0 1rem; padding: .8rem; border: 1px solid #d5b64c; background: #fffbea; } .task-panel h2 { margin-top: 0; } .task-panel ol { margin: 0; padding-left: 1.5rem; } +.task-title { padding: 0; border: 0; background: transparent; color: #1d3980; font-weight: bold; text-decoration: underline; } +.task-detail { margin-top: 1rem; padding: .8rem; border-top: 1px solid #d5b64c; background: #fff; } +.task-detail dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .3rem .8rem; } +.task-detail dt { font-weight: bold; } +.task-detail dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; } .task { margin: .4rem 0; } .task > span { margin: 0 .8rem; color: #555; } .task.critical { border-left: 4px solid #b00020; padding-left: .5rem; } @@ -109,6 +114,16 @@ select { padding: .25rem; border: 1px solid #7f8da5; background: #fff; } .message-addresses { color: #59677a; overflow-wrap: anywhere; } .message-text { white-space: pre-wrap; overflow-wrap: anywhere; } .message-html { width: 100%; min-height: 20rem; border: 1px solid #afbdd2; background: #fff; } +.organizer-toolbar { display: flex; gap: .4rem; margin-bottom: .8rem; } +.organizer-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid #afbdd2; } +.organizer-list li { border-bottom: 1px solid #ccd5e2; } +.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 input, .organizer-form textarea { width: 100%; } +.organizer-form textarea { min-height: 7rem; resize: vertical; } +.organizer-form .wide { grid-column: 1 / -1; } +.organizer-actions { display: flex; gap: .4rem; } @media (max-width: 700px) { .login { top: 20%; } diff --git a/src/www/static/app.js b/src/www/static/app.js index 9e9162b..7b9e2de 100644 --- a/src/www/static/app.js +++ b/src/www/static/app.js @@ -31,6 +31,7 @@ let openConversation = null; let composing = false; let pendingLoginToken = null; let tasks = []; +let tasksOpen = false; let senderSettings = {identities: [], signatures: []}; const tr = text => messages[text] || text; @@ -58,6 +59,7 @@ function navigateWithinApplication(event) { if (destination.origin !== location.origin || !["/mail", "/admin", "/settings"].includes(destination.pathname)) return; event.preventDefault(); + tasksOpen = false; openConversation = null; composing = false; history.pushState(null, "", destination.pathname + destination.hash); @@ -71,26 +73,138 @@ function render() { if (!session) return; document.querySelector("#current-user").textContent = session.user; populateSenderSelect(); - const names = {mail: tr("Mail"), calendar: tr("Calendar"), contacts: tr("Contacts"), admin: tr("Administration"), settings: tr("Settings")}; + const names = {mail: tr("Mail"), calendar: tr("Calendar"), contacts: tr("Contacts"), admin: tr("Administration"), settings: tr("Settings"), tasks: tr("Tasks")}; const messages = { mail: tr("The secure webmail connection is being enabled."), - calendar: tr("Calendar access is available through CalDAV."), - contacts: tr("Contact access is available through CardDAV."), + calendar: tr("Create and manage calendar events."), + contacts: tr("Create and manage contacts."), admin: tr("Administrative functions require a server-side role check."), - settings: tr("Personal settings") + settings: tr("Personal settings"), + tasks: tr("Open tasks that require your attention.") }; - const current = section(); + const current = tasksOpen ? "tasks" : section(); document.querySelector("#section-title").textContent = names[current]; document.querySelector("#section-message").textContent = messages[current]; + document.querySelector("#task-panel").hidden = current !== "tasks"; document.querySelector("#mail-browser").hidden = current !== "mail" || Boolean(openConversation) || composing; document.querySelector("#message-view").hidden = current !== "mail" || !openConversation || composing; document.querySelector("#message-compose").hidden = current !== "mail" || !composing; + document.querySelector("#calendar-view").hidden = current !== "calendar"; + document.querySelector("#contacts-view").hidden = current !== "contacts"; 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 === "tasks") loadTasks(); if (current === "settings") renderSettings("tasks"); } +function organizerInputValue(value) { + if (!value) return ""; + const normalized = String(value).replace(" ", "T"); + return normalized.length >= 16 ? normalized.slice(0, 16) : normalized; +} + +async function loadCalendar() { + const response = await fetch("/api/calendar/events", {credentials: "same-origin"}); + if (!response.ok) return; + const events = (await response.json()).events; + document.querySelector("#calendar-list").replaceChildren(...events.map(item => { + const row = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + for (const value of [item.summary || tr("(no title)"), item.start, item.location]) { + const span = document.createElement("span"); span.textContent = value || ""; button.append(span); + } + button.addEventListener("click", () => editCalendarEvent(item)); + row.append(button); return row; + })); +} + +function editCalendarEvent(item = {}) { + const form = document.querySelector("#calendar-form"); + document.querySelector("#calendar-resource").value = item.resource || ""; + document.querySelector("#calendar-etag").value = item.etag || ""; + document.querySelector("#calendar-uid").value = item.uid || ""; + document.querySelector("#calendar-summary").value = item.summary || ""; + 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 || ""; + document.querySelector("#calendar-delete").hidden = !item.resource; + form.hidden = false; + document.querySelector("#calendar-summary").focus(); +} + +async function saveCalendarEvent(event) { + event.preventDefault(); + const payload = {resource: document.querySelector("#calendar-resource").value, + etag: document.querySelector("#calendar-etag").value, + uid: document.querySelector("#calendar-uid").value, + summary: document.querySelector("#calendar-summary").value, + start: document.querySelector("#calendar-start").value, + end: document.querySelector("#calendar-end").value, + location: document.querySelector("#calendar-location").value, + description: document.querySelector("#calendar-description").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(); } +} + +async function deleteCalendarEvent() { + const resource = document.querySelector("#calendar-resource").value; + const etag = document.querySelector("#calendar-etag").value; + const response = await fetch(`/api/calendar/events?resource=${encodeURIComponent(resource)}&etag=${encodeURIComponent(etag)}`, + {method: "DELETE", credentials: "same-origin", headers: {"X-Bongo-CSRF": session.csrf_token}}); + if (response.ok) { document.querySelector("#calendar-form").hidden = true; await loadCalendar(); } +} + +async function loadContacts() { + const response = await fetch("/api/contacts", {credentials: "same-origin"}); + if (!response.ok) return; + const contacts = (await response.json()).contacts; + document.querySelector("#contact-list").replaceChildren(...contacts.map(item => { + const row = document.createElement("li"); const button = document.createElement("button"); button.type = "button"; + for (const value of [item.name, item.email, item.organization || item.phone]) { + const span = document.createElement("span"); span.textContent = value || ""; button.append(span); + } + button.addEventListener("click", () => editContact(item)); row.append(button); return row; + })); +} + +function editContact(item = {}) { + document.querySelector("#contact-resource").value = item.resource || ""; + 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 || ""; + document.querySelector("#contact-delete").hidden = !item.resource; + document.querySelector("#contact-form").hidden = false; + document.querySelector("#contact-name").focus(); +} + +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}; + 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(); } +} + +async function deleteContact() { + const resource = document.querySelector("#contact-resource").value; + const etag = document.querySelector("#contact-etag").value; + const response = await fetch(`/api/contacts?resource=${encodeURIComponent(resource)}&etag=${encodeURIComponent(etag)}`, + {method: "DELETE", credentials: "same-origin", headers: {"X-Bongo-CSRF": session.csrf_token}}); + if (response.ok) { document.querySelector("#contact-form").hidden = true; await loadContacts(); } +} + function populateSenderSelect() { const select = document.querySelector("#compose-from"); const previous = select.value; @@ -611,8 +725,11 @@ async function loadTasks() { document.querySelector("#task-list").replaceChildren(...tasks.map(task => { const item = document.createElement("li"); item.className = "task " + task.severity; - const title = document.createElement("strong"); + const title = document.createElement("button"); + title.type = "button"; + title.className = "task-title"; title.textContent = task.title; + title.addEventListener("click", () => showTaskDetail(task)); const details = document.createElement("span"); if (Array.isArray(task.details.clients)) { details.textContent = task.details.clients.map(client => { @@ -643,6 +760,36 @@ async function loadTasks() { })); } +function taskDetailEntries(value, prefix = "") { + const entries = []; + if (value && typeof value === "object" && !Array.isArray(value)) { + for (const [key, child] of Object.entries(value)) { + entries.push(...taskDetailEntries(child, prefix ? `${prefix}.${key}` : key)); + } + } else { + const displayed = Array.isArray(value) ? value.map(item => + typeof item === "object" ? JSON.stringify(item, null, 2) : String(item)).join("\n") : String(value ?? ""); + entries.push([prefix || tr("Details"), displayed]); + } + return entries; +} + +function showTaskDetail(task) { + const detail = document.querySelector("#task-detail"); + detail.className = "task-detail"; + const heading = document.createElement("h3"); heading.textContent = task.title; + const explanation = document.createElement("p"); + explanation.textContent = task.description || task.details?.description || tr("Review the following information and complete the required change."); + const values = document.createElement("dl"); + for (const [name, value] of taskDetailEntries(task.details || {})) { + const term = document.createElement("dt"); term.textContent = name; + const definition = document.createElement("dd"); definition.textContent = value; + values.append(term, definition); + } + detail.replaceChildren(heading, explanation, values); + detail.hidden = false; +} + async function setTaskState(id, state) { const response = await fetch("/api/tasks/state", { method: "POST", credentials: "same-origin", @@ -703,10 +850,10 @@ document.querySelector("#logout").addEventListener("click", async () => { render(); }); document.querySelector("#task-button").addEventListener("click", event => { - const panel = document.querySelector("#task-panel"); - panel.hidden = !panel.hidden; - event.currentTarget.setAttribute("aria-expanded", String(!panel.hidden)); - if (!panel.hidden) loadTasks(); + tasksOpen = !tasksOpen; + event.currentTarget.setAttribute("aria-expanded", String(tasksOpen)); + document.querySelector("#task-detail").hidden = true; + render(); }); document.querySelectorAll(".settings-nav button").forEach(button => button.addEventListener("click", () => renderSettings(button.dataset.settings))); @@ -714,6 +861,16 @@ document.querySelectorAll("a[data-section]").forEach(link => link.addEventListener("click", navigateWithinApplication)); document.querySelector("#mail-folder").addEventListener("change", loadConversations); document.querySelector("#mail-refresh").addEventListener("click", loadConversations); +document.querySelector("#calendar-new").addEventListener("click", () => editCalendarEvent()); +document.querySelector("#calendar-refresh").addEventListener("click", loadCalendar); +document.querySelector("#calendar-form").addEventListener("submit", saveCalendarEvent); +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()); +document.querySelector("#contacts-refresh").addEventListener("click", loadContacts); +document.querySelector("#contact-form").addEventListener("submit", saveContact); +document.querySelector("#contact-delete").addEventListener("click", deleteContact); +document.querySelector("#contact-cancel").addEventListener("click", () => { document.querySelector("#contact-form").hidden = true; }); document.querySelector("#message-back").addEventListener("click", () => { openConversation = null; render(); }); document.querySelector("#load-remote-images").addEventListener("click", () => renderConversation(true)); document.querySelector("#compose-new").addEventListener("click", () => { diff --git a/src/www/static/index.html b/src/www/static/index.html index ecde796..d78d41a 100644 --- a/src/www/static/index.html +++ b/src/www/static/index.html @@ -81,6 +81,7 @@

Tasks

No open tasks.

    +