Complete web mail organizer views
Debian Trixie package bundle / packages (push) Successful in 21m53s
Debian Trixie package bundle / packages (push) Successful in 21m53s
This commit is contained in:
@@ -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)"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user