createFromFormat() only returns false on hard parse failures.
Impossible dates such as 2026-02-31 were silently normalized
into March, so a confirmed event could land on a different day
than the booking link supplied. Non-padded values like
2026-2-3T10:00 were also accepted and reformatted.
Parse with a leading "!" so no current-date fields leak in,
require zero warnings and errors from getLastErrors(), and
round-trip the value through the format. Impossible dates,
out-of-range components and non-padded values now throw in
both the confirmation and the creation stage.
Security audit found two gaps. Sabre only quotes ICS
parameter values containing space, newline, quote, colon,
semicolon, caret, comma or plus, so a bare carriage
return in a contact name would be written into the
serialized event unquoted and break the line structure;
names with control characters are now dropped. The raw
attendee input and the search query had no length
bound, a huge POST field was fully split, iterated and
echoed back on re-render, a cheap CPU sink; both are
now capped, the query at the longest possible email
fragment.
The ICS organizer was taken from the external link's
organizer parameter. The CalDAV backend only schedules
invitations when the organizer matches the calendar
owner, so invites never went out under that value. The
event now names the authenticated user's email address;
the link organizer remains the fallback for users
without a profile email and stays visible on the
confirm page as meeting information.
The static datalist only carried the first 50 address book
contacts, so typed fragments could not match anything beyond
that. A new read-only GET /contacts/search endpoint queries
the full address book per keystroke, and a small vanilla JS
combobox shows the results and replaces only the fragment
after the last separator, so comma-separated lists keep
working. The server-rendered datalist remains as the no-JS
fallback; the JS removes the list attribute at runtime so
the native popup does not double the custom one.