The label and integration name caches lived as long as the worker, so a
renamed label kept its old name in rule sentences. Implements
ResetInterface.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each rendered message lazy-loaded its labels and asked the allowlist and
the contact table on its own: three queries per message. forThreadView()
fetch-joins the labels and loads parts for attachment messages in one
more statement; ThreadSenderFacts (reset per request) answers trusted
sender and correspondent for the whole thread with two IN() reads.
ThreadViewQueryBudgetTest: 2 messages 20 -> 16 queries, 10 messages
44 -> 16.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dozen sidebar/list aggregates per page and the per-minute snooze sweep
had only account_id to work with. Adds partial indexes for unread, starred
and snoozed threads and (account_id, last_message_at DESC, id DESC) for the
list ORDER BY. Applied against the test database in a rolled-back
transaction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
walk() kept every message of every batch managed, so memory grew with the
mailbox and each flush re-checked all earlier batches. Clear after each
batch and re-find the rule, like RunBulkStatusHandler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every list page fetch-joined every message of every thread on it, bodies,
headers and search vector included, to read a sender and a date off each
and a snippet off the last. ThreadRows now fetches only the row fields
of all messages plus the two body columns of each row's latest message;
the snippet is flattened once per row. Rendering is unchanged.
Measured by ThreadListQueryBudgetTest at 50 rows: Message entities
hydrated 171 -> 0; queries 24 -> 25 (inbox visit), 13 -> 14 (frame).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compose--compose never declared or read it, so the attribute promised a
hook that did not exist.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 keys existed in messages.en.yaml and not in messages.en_PI.yaml —
the config backup, push, reset and restarting admin screens, the
session-expired and confirm dialogs, notification devices and more —
so en_PI users read them in plain English. All translated, placeholders
kept, inserted where English has them. A parity test now fails when a
new English key is missing from de or en_PI.
The template asking for account.oauth.wip is dropped rather than the key
added: its text said OAuth was in progress, and the key was removed on
purpose when Google and Microsoft sign-in shipped.
26 keys nothing references any more are removed from all three
catalogues, each re-checked for dynamic lookups first. calendar.soon.now
is kept: unused, but added with the clock feature and possibly wanted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration picker's status line, the connection test's request
failure, the layout's default "Welcome!" title and the demo pages'
operator warnings were English in every locale. The picker and the
connection test get their words as Stimulus values from Twig; the demo
warnings keep the variable names as identifiers beside a translated
sentence. Also adds demo.privacy.processing_title, which the privacy
page used and no locale had.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy handler read event.currentTarget after an await, when it is
already null, so it copied and then threw instead of confirming. The
button is held from the start and a failure says so. The summary table's
Message ID / Created at / From / To / Subject headers were English in
every locale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inside a conversation the panel was painted under the navbar: the pane's
backdrop-filter traps it whatever its z-index. It now opens in the top
layer as popover="manual", positioned by hand, the way the account menu
does; manual because auto's light dismiss is what broke the options the
first time. disconnect() runs the whole close, so a menu re-rendered
while open no longer leaves scroll/resize listeners on window, and the
check target is declared. The skipped spec is a test again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chevron's rotation was the only sign whether a message was open; the
button said the same thing either way. It now carries aria-expanded and
a translated Expand/Collapse label that follows the state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Translated off-screen, the drawer stayed tabbable and readable, so a
keyboard or screen-reader user on a phone went through the whole sidebar
invisibly. It is inert while closed, opening moves focus into it, closing
returns focus to the burger, and the burger reports aria-expanded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The queue browser dropped a filter change while a page loaded, so the
input and the list disagreed; it now aborts the request it replaces. The
rule preview draws only its newest answer. Search's contact lookup
returned [] when superseded, which wiped the newer suggestions; it now
returns null and the caller bails. The account order posts one write at
a time with only the latest order, and puts the rows back when a write
fails instead of leaving the page contradicting its own toast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
open() added a keydown listener on the opener's instance and close()
removed the closer's, usually #modal-backdrop's, so every dialog left one
behind that called preventDefault on every Escape afterwards. One
module-level listener now, removed by whichever instance closes, and
Escape is only claimed while there is a dialog to close.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The toolbar passed the ids as _bulkPost's first argument, so it posted to
/status/bulk/[object Array]; and the bulk route never allowed "snooze"
anyway. Both answered 404 behind a toast. The route now snoozes (or,
without a wake time, wakes) each conversation through ThreadSnoozeService,
and a whole-view snooze runs as a background job carrying its wake time,
with a Snooze and a Wake kind for the indicator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The enforced script-src drops an unnonced inline script silently. The
booking form's hours, the booking page's visitor zone and the restart
page's way back become Stimulus controllers (they arrive through Turbo,
where no nonce can follow); the public layout's theme bootstrap gets the
nonce. The public layout and the error page also called importmap('app')
without the nonce and linked no stylesheets since app.js stopped
importing CSS, so they ran unstyled and without JavaScript. Layouts now
carry a csp-nonce meta for Turbo. A static test keeps it that way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
putResource looked the event up by the href's name after importing it,
so a client that named a new resource anything but its UID got a 409 for
a write that had already happened. The document's UID is now read before
writing: a create under another name is stored and answered 201 with a
Location, and only a UID change or a UID clash is refused - before any
write. If-None-Match: * and If-Match on a missing resource answer 412.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Recurring all-day events wrote EXDATE and RECURRENCE-ID (and a moved
instance's times) as UTC date-times, which match no instance of a DATE
series; they are now VALUE=DATE, in CalDAV pushes and .ics downloads.
- Files named TZIDs without VTIMEZONE components, which RFC 5545
requires. VTimeZoneBuilder writes one from PHP's zone table for each
TZID used, covering the span the events do; a calendar download writes
one per zone at the end of the stream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A VEVENT with RECURRENCE-ID shares the series' UID and was reconciled as
an update of the series: a CANCEL for one date cancelled every date, and
the extraction runner deduplicated a master and its exceptions down to
whichever came first. Instance claims now carry their recurrence id, are
kept apart by the runner, and are filed on the series as a recurrence
override - an exclusion for a cancellation, a start/duration/title patch
otherwise. Links queued earlier in the same message are reused so the
unique (event, message, extractor) constraint holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three ways CalendarEventWriter rebuilt away what it should have kept:
- Saving or dragging a series replaced its rule with the dropdown's bare
frequency and dropped properties no field carries (freeBusyStatus, a
verbatim plmail:rrule), and pushed that to the remote. A choice matching
the stored frequency, or the new 'keep the current rule' option shown
for rules the dropdown cannot express, now keeps the stored rule, and
the stored object is passed as the base of the rebuild.
- Moving a series left its overrides keyed at the old start, so cancelled
and moved instances came back. Keys and patch starts now shift by the
same wall-clock delta; keys the rule no longer visits are dropped.
- Drivers only stated alerts/participants/overrides when non-empty, and
the writer kept stored values when a key was absent, so removals made
remotely never arrived. Drivers now state them as [] where the remote
object is authoritative, and an explicit [] clears the stored value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The alert sweep read one page of 500 candidates ordered by start over a
month-long, instance-wide window and stopped, so an alert set days ahead
sat behind the next day's short reminders until it aged out of the
lookback. The candidates are now read with keyset paging to the end of the
window on every sweep.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The synthetic UID hashed the occurrence row id, and the materialiser
recreates those rows on every edit and in the nightly sweep, so
subscribers re-imported the whole feed daily. It is now derived from the
event id and the instance's recurrence id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 412 from the remote was thrown as a resync out of the pusher, failing the
run before the pull that could fetch the newer copy, so every later run hit
the same 412. The pusher now keeps that row pending and carries on; the
pull applies the remote's version and the row settles.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A settled target of 0 from the source host told the target's Gmail sync
that its still-empty mailbox was fully imported, so existing mail was
never fetched. It is a counter about the other host's sync, like
backfill_ran_at, and now sits on the excluded list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The day walk started at the window's own time of day, so a window ending
earlier in the day than it began stopped one day short. Slots are now set
with setTime() on the local date rather than midnight plus N minutes, so
the configured hours cannot drift on a changeover day.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MessagePriority::fromHeaders() existed but was never called; priority was only
written for outgoing mail. MessageSyncer now fills Message::$priority from the
Importance / X-Priority headers when it builds an inbound message, and leaves
it null when the sender stated none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every folder sync ends with countUnseenForMailbox(), `mailbox_id = ? AND
seen_at IS NULL`, which visited every row of the folder to test seen_at. A
partial index on message(mailbox_id) WHERE seen_at IS NULL holds only the
unread rows. Migration-only, like the table's other partial indexes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
syncMailbox() loaded every UID the folder had ever stored to skip the few the
`lastSeenUid+1:*` range can return. findSyncedUids() now takes the mark and
returns only UIDs above it, and processBatch() treats anything at or below the
mark as handled, which also covers the clamped `*` naming a lower UID once the
newest message was deleted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
repairRelocated() probed every row whose Message-ID was also filed in another
folder. A message that genuinely lives in two folders survived every probe,
collapse() returned 0, and it was back on the work list next sync, costing a
round trip per copy per poll and filling the 100-row slice so real ghosts
behind it waited. Confirmed copies now get message.copies_confirmed_at (new
migration) and are left out of findMessageIdsAlsoFiledElsewhere() for 30 days.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delete used webklex's delete(expunge: true), a plain EXPUNGE that also removes
every message another client merely flagged \Deleted. It now sets \Deleted and
sends UID EXPUNGE for the one UID when the server has UIDPLUS, and leaves the
message flagged without expunging otherwise.
The move re-resolve fallback put the stored Message-ID of inbound mail into
`HEADER Message-ID "<...>"`, and webklex does not escape it, so a quote, a
backslash or CR/LF in a sender's Message-ID could inject search syntax or a
second command. Such ids (anything outside printable ASCII without `"` and
`\`) are no longer searched for; the next sync reconciles the row anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SentCopyReconciler::claim() matched any unlocated row by Message-ID from any
folder, so the INBOX copy of a mail Cc'd to yourself (which often arrives
before the Sent copy) claimed the row the send path had filed in Sent. The
message left Sent and the Sent copy came back as a second row. Rows waiting
for their Sent copy (in the Sent mailbox or none, no gmailId) are now only
claimable when the folder being synced is Sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The catch around buildMessage() logged and moved on without holding the
high-water mark, so lastSeenUid passed the failed UID and it was never fetched
again. The clamp that did exist for empty fetches was per batch and got
overwritten by the next batch's higher UIDs. Now the lowest skipped UID is
tracked for the whole run, both failure paths hold the mark below it, and a
per-mailbox counter (mailbox.failed_uid / failed_uid_attempts, new migration)
lets one message that always fails go after five syncs so it cannot block the
folder.
A present but unparseable Date header made webklex throw while building the
message, failing the fetch for the whole folder. The client now configures a
fallback date (the epoch), and MessageSyncer::dateFromHeader() reads that, or
any throw, as "no date" and uses the arrival time.
Attachment parts are persisted only after the message is fully built; before,
a second attachment that threw left the first part managed and pointing at an
unpersisted message, and the batch flush failed on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MailboxSyncer removed every stored folder the server's LIST did not name, and
message.mailbox_id cascades, so an empty or partial LIST, or a rename done in
another client, deleted every message of those folders in one poll.
Now an empty listing is refused outright, a listing that would leave out most
of the stored folders is refused like VanishedMessageReconciler's bulk guard,
and a folder that is really gone is only marked (mailbox.missing_since and
missing_syncs, new migration) and skipped by the sync loop. It is removed only
after three consecutive listings and 24 hours, and its messages go through
MessageEraser so JMAP clients get a destroyed change for each. A new folder
with the same special use or the same UIDVALIDITY as exactly one missing
folder is treated as a rename: the row keeps its mail and takes the new path.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Folder roles were detected by English name only, so GMX/web.de "Gesendet",
Exchange "Sent Items", "Deleted Items" and "Junk E-mail" had no role and the
send path silently skipped appending to Sent. The syncer now reads the RFC 6154
\Sent \Trash \Junk \Drafts \Archive attributes off the LIST response (Folder
throws them away, so the listing is read from the connection), and falls back
to a name list widened with German and Exchange names. A role the server gave
by attribute is not handed out again by name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test instantiated the migration class directly, which static
analysis cannot resolve since migrations are not autoloaded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jmap_change_log grew for the life of the install; pruneOlderThan() had
no caller. app:jmap:prune-changes now runs nightly from the maintenance
schedule. It keeps the newest row of every account and object type so a
quiet type's state token never falls back to 0; clients below the new
floor already get cannotCalculateChanges and resync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every call ran two full-account aggregates over labelled mail, including
the ones asking for names and roles only. A grain is now read only when
one of its properties is requested, and only for the named mailboxes
when ids are given.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every call read and sorted every matching message of the account for PHP
to slice a page from, and received_at had no index. OFFSET/LIMIT now go
into the query; a collapsed list is collapsed first with DISTINCT ON so
position still counts threads; total is a separate COUNT, skipped when
the client sends calculateTotal: false. New index on
(account_id, received_at DESC NULLS LAST, id DESC) for the default sort.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
RFC 8621 7.5 has the server report the patch as an Email/set response
of its own, after the EmailSubmission/set one and under its callId. It
was a custom updatedEmails key no client reads, so their Email state
never heard it had moved; a refused patch also failed the whole call
after the send was queued, and is now that Email's notUpdated.
The reference resolver stopped at the first response with a matching
callId, so the second response of such a call could not be referenced;
it now matches on callId and name together.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The draft content was flushed and the keyword change propagated to the
provider before the mailbox ids were resolved, so a bad id answered
notUpdated for a patch that had partly landed. Mailbox ids are now
resolved before any mutation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The draft was flushed before its attachments were resolved, so a bad
blobId answered notCreated for a draft that now existed and that the
client had no id for. Attachments are planned before anything is saved,
as update() already did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The change log's identity value is the state token and is drawn at
INSERT, so two writers of one account could commit out of order and a
client holding the later token never saw the earlier change. A BEFORE
INSERT trigger now takes a per-account advisory xact lock and draws the
number after it. A trigger because Doctrine fires no event inside its
flush transaction before the inserts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gmail_id and graph_id are unique per account only, yet the batch handler
and the Graph delta syncer looked rows up by the bare id across the whole
install, so a collision enriched, relabelled, flagged or erased another
user's message. Graph lookups now carry the account; the Gmail one is
scoped to the owning user, because a Gmail message may be attributed to a
sibling account of the same user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
array_search() returned false for a deleted id that was not added in the
same history window, and unset($refs[false]) removed key 0 - the first
new message. The refs are now keyed by Gmail id and unset by id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Six state-changing compose routes checked ownership and nothing else, so
any page the user visited could call off a send, discard a draft or add
and remove attachments. They now require the shared 'ajax' token, and
every fetch() caller in the compose controllers sends it in the
X-CSRF-Token header (unschedule's caller already did).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Attachments were served under the Content-Type the sender wrote, so a
text/javascript part on our own origin satisfied script-src 'self'.
The web and JMAP download routes now label anything outside the inline
allow-list application/octet-stream. And a filename carrying / or \
made makeDisposition() throw, a 500 on download; those characters and
control characters are replaced before the header is built, with the
UTF-8 filename* kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/compose/edit/{id} and /compose/draft/{id} checked ownership only, so an
inbound mail could be opened in the composer, which prints the stored
bodyHtml with |raw into the app's own document: a link to the edit route
ran the sender's script. Both routes now answer 404 for anything that is
not an unsent draft, and the body handed to the editor goes through the
composed-body sanitiser instead of trusting the stored field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>