Complete web mail organizer views
Debian Trixie package bundle / packages (push) Successful in 21m53s

This commit is contained in:
Mario Fetka
2026-07-22 04:05:20 +02:00
parent 6c3922bab0
commit 1dfb76462e
13 changed files with 572 additions and 28 deletions
+32
View File
@@ -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)"
+7
View File
@@ -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);
+7 -4
View File
@@ -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;
+8 -1
View File
@@ -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);
@@ -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
+135 -7
View File
@@ -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
+69
View File
@@ -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):
+40
View File
@@ -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
+16 -1
View File
@@ -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%; }
+167 -10
View File
@@ -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", () => {
+26
View File
@@ -81,6 +81,7 @@
<h2 data-i18n="Tasks">Tasks</h2>
<p id="task-empty" data-i18n="No open tasks.">No open tasks.</p>
<ol id="task-list"></ol>
<article id="task-detail" hidden></article>
</section>
<div id="mail-browser" hidden>
<button id="compose-new" class="small" type="button" data-i18n="Compose new mail">Compose new mail</button>
@@ -107,6 +108,31 @@
</tbody>
</table>
</form>
<section id="calendar-view" class="organizer-view" hidden>
<div class="organizer-toolbar"><button id="calendar-new" type="button" data-i18n="New event">New event</button><button id="calendar-refresh" type="button" data-i18n="Refresh">Refresh</button></div>
<ol id="calendar-list" class="organizer-list" aria-live="polite"></ol>
<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><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 class="wide"><span data-i18n="Description">Description</span><textarea id="calendar-description" maxlength="65536"></textarea></label>
<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>
<section id="contacts-view" class="organizer-view" hidden>
<div class="organizer-toolbar"><button id="contact-new" type="button" data-i18n="New contact">New contact</button><button id="contacts-refresh" type="button" data-i18n="Refresh">Refresh</button></div>
<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>
<label><span data-i18n="Organization">Organization</span><input id="contact-organization" maxlength="512"></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="settings-view" class="settings-view" hidden>
<h2 data-i18n="Personal settings">Personal settings</h2>
<nav class="settings-nav" aria-label="Settings">
+19 -1
View File
@@ -23,7 +23,8 @@ import unittest
from bongo_web.calendar_store import (CalendarNotFound, CalendarPathError,
PreconditionFailed, delete_event,
etag_matches, event_href, ical_to_json,
etag_matches, event_href, event_to_dict,
ical_to_json, make_event,
list_events, make_etag, parse_calendar_path,
write_event)
@@ -138,6 +139,23 @@ class CalendarMutationTests(unittest.TestCase):
self.assertEqual(list_events("u", "c",
client_factory=self.factory(store)), [])
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"})
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")
def test_web_event_requires_title_and_valid_range_types(self):
with self.assertRaises(ValueError):
make_event({"summary": "", "start": "2026-07-22T09:30"})
with self.assertRaises(ValueError):
make_event({"summary": "x", "start": "2026-07-22",
"end": "2026-07-22T10:30"})
if __name__ == "__main__":
unittest.main()
+16 -1
View File
@@ -23,7 +23,8 @@ import unittest
from bongo_web.card_store import (CardNotFound, CardPathError,
CardPreconditionFailed, card_href,
delete_card, make_etag, parse_card_path,
card_to_dict, delete_card, make_card, make_etag,
parse_card_path,
vcard_to_json, write_card)
@@ -114,6 +115,20 @@ class CardMutationTests(unittest.TestCase):
converter=lambda value, uid: b"old card")
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"})
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["email"], "mario@example.test")
self.assertEqual(result["organization"], "Bongo")
def test_web_contact_requires_name(self):
with self.assertRaises(ValueError):
make_card({"name": "", "email": "nobody@example.test"})
if __name__ == "__main__":
unittest.main()