Commit Graph
18 Commits
Author SHA1 Message Date
Claude 044aa01846 Reported mail is one feature: one list, one triage, one export you choose
Two kinds of report — a mail an extractor should have understood and did not,
and a mail somebody found in the wrong tab — were two tables behind two panels
with two export buttons. That split made sense to whoever wrote them and to
nobody using them: an admin opening this section is asking "what is still mine
to do", and that question does not have two answers.

The tables stay apart, because the evidence really is different: a wrong tab is
a disagreement between three classifiers, a missed insight is a mail body a
parser could not read, and making them inherit would put a dozen always-null
columns on each row and tie a schema change on one to the other for ever. What
is unified is everything above them. ReportedMail carries the handful of fields
a triage list is built from and the entity alongside for the fold and the
export; ReportedMailProvider is the only place that knows there are two tables;
the panel, the buttons and the file are written against the pair.

Category reports gained handledAt, which is what makes them belong in the same
list — a worklist you cannot cross anything off is not a worklist. Before this
the only way to stop looking at one was to delete every one of them, taking the
evidence for a rule away at the moment somebody might want to check it again.

The export's scope becomes the selection. "Everything, or only the unhandled
part" answers the question an admin has on their first visit and none of the
ones they have afterwards; the one that comes up is "these six, the ones about
the shop". So the list filters — by status, and by the problem itself, with the
chips derived from the rows because for a wrong tab the problem IS a pair of
categories and only this install knows which pairs it has produced — and the
export takes what is ticked. Filtering never unticks anything: a row hidden by
a chip is out of the selection while it is hidden and comes back ticked, so a
selection built across two chips survives. An empty post still means everything,
which is what a browser without JavaScript sends.

Each record now carries its kind, every field its kind has, and the same
one-line rendering the panel shows and the copy button copies. The document is
plmail.reported-mail version 2 — a new name rather than a bumped version alone,
so a consumer written against version 1 fails on the name instead of half-reading
a file whose records it only understands some of.

The row is a <div> and not a <label>, though a label around the whole row would
give the checkbox a better hit target: a label may not contain a <form> or a
<details> and both are there. Chromium happens not to forward label activation
from a nested button, which means the invalid version works right up until the
row is read by something that is not Chromium.

Verified on a running stack: six reports of both kinds in one list, the status
chip hiding the handled one, select-all writing five keys into the export form,
a problem chip narrowing that to one without losing the other four, and the
export returning scope:selection with the full record and its line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:21:11 +02:00
Claude d42012cedf Report the category the tabs actually sort by, and the evidence behind it
The first real miscategorisation report read `filed:updates should:updates`
about a conversation its owner was reading in Primary. Both halves of that were
wrong in the same way: `filed` was `message.category`, and the mailbox lists
threads and filters the tabs on `thread.category`. A thread takes one category
from its newest message and a pin overrides it outright, so on any conversation
with more than one message the two are free to differ — and it is the thread
that decides which tab somebody is looking at.

So the report described a column nobody can see, and the popover's "category"
line did too. Both now read the thread, falling back to the message for one not
yet threaded, and both say so when the two disagree: the panel explains that
the conversation takes its category from its newest message, which is the
difference between a panel that looks broken and one that is informative.

The report row was also too thin to act on. It carried the four verdicts and
none of the inputs they came from, so a line could be entirely true and still
leave nobody able to say what to change. Added: the message's own category when
its thread disagrees, whether a pin is holding it, whether the reader had asked
for the provider to be overruled, whether the model was asked at all
(`model:-` could not tell "never asked" from "asked, no answer"), whether there
was any plain text for it to read, and List-Id's value — the one bulk header
whose value is a rule-writing input rather than a tracking link.

The card moves from Admin → AI into Reported mail, where somebody who has just
pressed Report goes looking for it, and is grouped by `filed → should` rather
than listed by date: twenty reports are rarely twenty problems, and the fix for
one group is not the fix for another. Each group and each row can be selected,
and the selection can be copied or downloaded as a text file — so whoever is
changing one rule can take the reports about that rule and leave the rest.

A report that agrees with where the mail already is now 409s rather than
landing as a row saying nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:42:46 +02:00
Claude 93a8f55897 fix: three defects the e2e suite caught in the preceding commits
A recipient-less draft took the whole Drafts page down. A draft is saved the
moment its body clears the minimum length, which is routinely BEFORE a recipient
has been typed, so toAddresses is null on an ordinary draft and |map on null is
a 500. Found by compose.spec, several steps from the change, because an earlier
test in that file leaves exactly such a draft behind. DraftRowRenderTest is the
fast version of that catch.

The theme picker painted a different accent ink than the reload did: deriving
--rgb-accent-ink by luminance on the server while the client-side switcher kept
the old threshold meant the accent's text changed colour on refresh. The rule
now exists once, in two languages, with the JS naming the PHP it mirrors.

account-scope.spec selected the folder toggle by its exact aria-label, which the
previous commit deliberately reworded so that three accounts no longer give
three controls a screen reader cannot tell apart. It selects on the controller
target now — the thing that makes it that button, rather than the wording, which
is free to improve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:35:42 +02:00
Claude e3da8c5ba6 chore: drop the scratch probe scripts from the branch
They were the harness for verifying this work against a live stack — a curl
login, a search prober, a one-off phpunit container — and belong to the
investigation, not to the repository.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:42:41 +02:00
Claude 30a71adac3 fix: satisfy phpstan, and stop a third test asserting the old date arrangement
coerce() now returns DateTimeImmutable rather than DateTimeInterface: the
interface has no setTimezone(), and narrowing it also removes the chance of
editing a mutable DateTime the template handed us just by rendering it.

The hostile-query test asserted only that a search returned an array, which
phpstan rightly called always-true. It asserts counts now, which is the actual
claim: punctuation is stripped and the words around it still work, and a query
made of nothing but punctuation finds nothing rather than everything — the
latter being what `!` would do if it reached to_tsquery as an operator.

PublicCalendarStyleTest built its expected date with format('j M Y'), the same
way the two tests fixed in the previous commit did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:40:18 +02:00
Claude b5123475bd feat(lists): sort control on every list, recipients in Drafts, a real favicon
U-03: the order switch existed only on search. Folder lists take one too, over a
ListSortOrder of newest/oldest — deliberately NOT SearchSortOrder, because
relevance has no meaning without a query and would be a dead entry in every
menu. The choice is remembered per session and across every list: per view would
have Inbox and Trash disagreeing about a preference expressed once, and storing
it on the user would make "let me read this backlog forwards" permanent.

The menu partial is now shared and takes its options and route as parameters;
the tiebreaker travels with the order on the enum, so pagination cannot come
apart the way SearchSortOrder's docblock describes.

U-02: Drafts listed "ich" as the correspondent on every row, which is true and
useless — every draft is from you. The row shows who it is FOR.

U-01: the favicon was an emoji data URI, so plMail's tab showed whatever
envelope the reader's OS draws. It is the product's own mark now, as an SVG
that stays sharp at 16px, with the existing PNG behind it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:35:58 +02:00
Claude a351ed2a65 fix(i18n): render dates through the reader's locale, not PHP's date()
I-04/I-05: calendar column heads read MON TUE WED over a German interface and
mail was stamped "Aug 3, 13:04", because PHP's date() has no locale — date('D')
is "Mon" for every reader there has ever been.

|intl_date takes an ICU skeleton rather than a pattern: it says which fields to
show and lets the locale arrange them, which is the part that actually differs.
German writes the day before the month, so translating the month name and
keeping "Aug 3" would have been the same bug with better vocabulary.

Times deliberately do not go through it. Whether 13:04 is written "1:04 pm" is a
preference and lives in ClockGlobal; templates showing both now compose the two.

Two tests asserted the old English wording, one by building it with the very
format() call being removed. ICU's en writes "Aug 4, 2026" where the app used to
hand-arrange "4 Aug 2026"; the expectations follow the locale now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:26:43 +02:00
Claude 1836e673e7 fix(a11y): name account controls per account, stop hardcoding white on accent
A-04: every account rendered a folder toggle labelled "Toggle account folders",
so three accounts gave three controls a screen reader reads identically. The
address is in the label now, and the account link is named explicitly rather
than by a `truncate` span that CSS can cut mid-address.

A-07 (second half): computing --rgb-accent-ink by luminance achieves nothing
while 63 call sites paint `text-white` on `bg-accent` directly. They now use
text-accent-ink and inherit the measured choice. The two left behind are white
over a black gradient and over a danger fill, which are not the accent.

H-05: the search box kept a stale query when a folder was clicked — the input
survives the Turbo visit and a folder page has no `q` to overwrite it with, so
the box claimed a filter the list below it had not applied. It now tracks `?q=`
in the address bar, which is also right on the way back in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:20:54 +02:00
Claude 5ff870c3b7 fix(search,routing,a11y): prefix+substring search, page clamps, 404s, contrast
H-04 search found only exact whole words. The tsvector tokenizer emits
"help.wirhub.de" as one host lexeme, so "wirhub" is a substring of a lexeme and
no full-text query can reach it, while "Testmai" needs a prefix operator
websearch_to_tsquery has no way to express. Free text now compiles to three
OR-ed passes — websearch as before, a to_tsquery with :* per term, and an ILIKE
needle for single terms of 3+ characters — with pg_trgm GIN indexes so the third
is index-backed. Deliberate websearch syntax (quotes, OR, negation) suppresses
the extra passes rather than widening what was narrowed on purpose.

H-06 page numbers past the end rendered "201-193 of 193" over an empty list.
The six role views were six copies of the same eight lines, so they are now one.

H-07 /mail/thread/abc and ?tab=quatsch were 500s: a non-numeric id reaching the
entity resolver, and MessageCategory::from on a URL parameter. Error pages are
branded and localised, and deliberately standalone — the app shell asks for a
session, which an anonymous 404 does not have.

A-01/A-02 <html> had no lang at all, and the viewport forbade zoom.

A-07 accent ink was effectively always white (2.34:1 on the reported pink); it
is now chosen by WCAG relative luminance on gamma-expanded channels, scoring
both candidates instead of guessing at a threshold. Every theme's ink ramp now
clears 4.5:1 on its own surface and still runs strongest-to-faintest.

I-02 search.title existed in no catalogue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:09:13 +02:00
Claude Fable 5 e453cc9516 feat(ui): every select is a Tom Select, themed from the palette tokens
`select-field` got the closed control right — appearance:none, the app's fill,
the app's chevron — and could never get the popup, because the popup is the one
part of a form control CSS does not reach. color-scheme was the last thing left
to try and it only picks light or dark: the list is still the platform's font,
the platform's row height and the platform's highlight, sitting in front of a
themed app in five of the six themes.

Compose already had the answer for its address fields. This is that same widget
on every other single-select in the app, so there is one dropdown here instead
of two.

- assets/controllers/ui/select_controller.js progressively enhances a native
  <select>. The element stays in the DOM as the source of truth, so forms post
  identically, `onchange="…requestSubmit()"` and `data-action="change->…"` keep
  firing, and no-JS renders the native control the stylesheet still styles.
- The panel is parented to <body>: every call site is inside something that
  clips. It flips above the control when there is no room below and caps itself
  to the room there is, which Tom Select does not do on its own, and closes on
  scroll rather than being left behind by the pane it was anchored to.
- Short lists lose the search box entirely, so a five-item picker behaves like
  the native control it replaced instead of blinking a text caret.
- tom-select.css now skins .single as the twin of `select-field` — same fill,
  border, radius, chevron and height, the last one measured per call site so
  h-8 rows stay h-8. No per-theme rules: the six themes differ only in what the
  tokens resolve to.
- The rule editor builds five selects in JavaScript and was missed by the last
  sweep; it stamps the attributes itself now.
- Compose's font pickers are converted too, frameless so the format bar keeps
  its character. Their labels were the lookup keys, which is why they were
  English in a German UI; the values carry the keys now.
- "No results found" arrives from Twig |trans (EN/DE/en_PI).

Left native, deliberately: compose's multi-select address fields, which have a
richer Tom Select of their own; the provider preset, enhanced by UX Autocomplete
because it fetches its choices; and compose's sr-only account select, which is
never shown. PageRendersTest now sweeps every page for a select that is none of
those and carries no widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:33:09 +02:00
Claude Fable 5 03a0d6b877 A config backup knows its own operator
Three changes to the config-backup subsystem, made together because they
land on the same two pages and the same document.

**Users, and everything they configured, join the backup.** Restoring onto
a fresh install used to end at "this installation still has no users —
create the administrator account to finish", which is a backup of a server
rather than of an installation. Document version 2 adds a `users` section,
keyed by email because that is what an import matches on. Per person: the
password hash, the TOTP secret and its recovery codes, app passwords, mail
accounts with their IMAP/SMTP and OAuth credentials, aliases, per-user
integrations with their secrets, filters, labels, calendars and the share
links and booking pages whose token digests keep published URLs working.

Everything encrypted at rest leaves decrypted inside the envelope and is
re-encrypted under the target's key on the way in, exactly as the database
section already did — that is the only way a credential survives a move to
a host with a different APP_ENCRYPTION_KEY. Deliberately absent: trusted
devices and push subscriptions (grants to one browser or one phone, and a
trusted device is a skipped second factor), label bindings and sync state
(re-derived, or about a host that is not this one), avatars and background
images (filenames in a volume this file does not carry), and mail, calendar
entries and contacts, which have not moved an inch.

Import policy: a user this install does not have is created whole; a user
it does have is left completely alone, whole. Skipping is the design and
not a caution — a backup is a photograph of a moment that has passed, and
applying a stale one to a live account resets today's password to
February's with no undo and nobody told. Soft-deleted users count as
present. The ids inside a filter, a calendar or a booking page name rows in
the *source* database, so the restore builds a map as it creates rows and
rewrites every reference through it; a reference to something the file did
not carry is dropped rather than guessed at.

The /install restore page now has two endings and asks the guard which one
it is in: sign in as somebody the file restored, naming the administrator,
or — for a document that carried no users — create the administrator as
before.

**MAILER_DSN and MESSENGER_TRANSPORT_DSN leave the export.** Machine-local
deployment choices that compose supplies a default for, in DATABASE_URL's
category: the target's relay and queue are the ones that match the
containers running beside it. Old backups carrying them classify as
External and stay inert.

**The restore result page stops listing non-tasks.** A restored value
already identical to the live one is dropped from "These are yours to do"
and counted in one muted line; the APP_ENCRYPTION_KEY block becomes a
collapsed disclosure that still carries the line the one operator who needs
it came for; and when nothing is actionable the page says so and puts the
way out first.

Two robustness fixes fell out: planning no longer hydrates every user to
build a comparison (per-email now), and neither the existence check nor the
comparison can 500 a review page on an install whose encryption key was
rotated without re-encrypting.

No schema changes — the backup is a file format, not tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:02:15 +02:00
Claude 21f9f022ff Give every native select one look instead of six
<select> outside the form theme rendered the platform control: its own
chevron, its own radius, its own metrics, sitting in a settings pane full
of app-styled fields. Underneath that were six separate attempts at a fix
— two border colours, three backgrounds, and three that reached for a
hardcoded ring-indigo-500 and so ignored whatever accent colour the user
had chosen. Several also used `border-field`, which is the field's fill
rather than its border, the mistake _list_toolbar already has a note about.

One `select-field` utility now carries the look, alongside pane, btn-quiet
and the other composed utilities in app.css, and every boxed select in
settings, admin and the calendar wears it. `appearance: none` drops the
platform control and the chevron comes back as a background image, which
is the only place a <select> lets CSS put one — same path as the form
theme's SVG, so they match.

The form theme's select becomes the same utility, and its chevron wrapper
div goes: one definition rather than a template's and a stylesheet's,
which is how six of these appeared in the first place.

Autocomplete fields stay exempt, and now more thoroughly. Tom Select
copies the select's classes onto .ts-wrapper, where a field's padding
indents the control instead of the text inside it — and it needs nothing,
because .ts-wrapper.single .ts-control already draws that box from the
same tokens.

Colours come from the --rgb-*/--*-alpha tokens, so all six themes follow.
The chevron's grey is the one fixed value, because a data: URI cannot read
a custom property; --rgb-ink-faint spans 108–170 across the themes, so one
mid grey sits within a few percent of every one of them.

Compose's toolbar selects are deliberately left bare — they are inline
text controls, not fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 13:38:41 +02:00
Claude 7c5a2031fa Make the file picker's trigger look like a button
Tailwind v4's preflight resets ::file-selector-button the same way it
resets everything else — transparent, unpadded, borderless, no radius —
so "Choose file" rendered as a phrase in a line of body text and people
read past it looking for the control.

The three visible file inputs did each carry `file:` utilities, in three
different looks: the ICS import had a real border, while the /install
restore and the admin backup import had `file:bg-raised file:border-0`,
which is a 4%-opacity tint and no edge. That is why this came in as
"unstyled" rather than as "inconsistent".

Styled once in the base layer instead, on the same reasoning the
`cursor: pointer` rule above it already gives: it reaches every file input
there will ever be, rather than the ones that remembered the utilities.
The per-template stacks are removed, so there is one definition. Built
from the --rgb-*/--*-alpha tokens rather than fixed colours, so all six
themes and both light and dark follow without a second rule.

The secondary control's look, not the accent one: picking a file is a step
towards the button the form is actually asking for, and two accent buttons
in one form is two primary actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 13:31:40 +02:00
Claude 3909b25443 Group the settings nav instead of listing thirteen equal links
The order was historical — Mail accounts, Profile, Security, General,
Aliases, … — so finding anything meant reading all thirteen. Now four
labelled groups in the order someone new meets them: themselves, their
mail, their calendar, and what else can reach the account.

Security moves to the end. It was third, between Profile and General,
which put the most consequential section in the middle of unrelated ones;
at the end it sits with the other two ways into the account, app passwords
and integrations, and closes the list. Sharing stays with Calendars rather
than joining that group: a shared link is a calendar feature, and somebody
asking "who can see my calendar" starts at the calendar.

The anchors become a loop over a declared list. The copy-paste they
replace had already drifted — Aliases and Appearance shared the palette
icon, and Aliases tinted it a different colour when active. Aliases gets
fa-user-tag; the active tint is now one rule for all thirteen.

Headings are desktop-only: on mobile the nav is a horizontal wrapping
strip, where a heading per group costs more width than the links under it.
They are aria-hidden with the same word on the list they label, so a
screen reader hears the group name once rather than twice, and one <nav>
stays one landmark.

Adds settings.nav.group.{personal,mail,calendar,access} in EN, DE and
en_PI — the settings nav already carries en_PI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 13:27:07 +02:00
Claude 287d8aab0b Stop one browser collecting a trusted-device row per login
Settings → Security listed the same browser a dozen times, one line more
every day. Remembering was working — the cookie reached the browser and
the code prompt was correctly skipped — but scheb asks the trusted-device
manager to add a device from two places, and only one of them is the
checkbox. TrustedDeviceCondition calls addTrustedDevice() on *every* login
of an already-trusted browser, because that is what `extend_lifetime`
means to a manager whose grant is a signed cookie: reissue it with a later
expiry. Ours inserted a row instead, and reissued a fresh secret with it,
so the previous row was orphaned the moment it was replaced and sat in the
list until it expired thirty days later.

The remember-me path reaches the same condition with no form involved, so
a browser reopened in the morning minted one too — which is where the
same-minute pairs came from.

addTrustedDevice() now renews the grant the presented cookie already
resolves to, rather than inserting a sibling: expiry back out to a full
lifetime, on the row and on the cookie both, same secret. Matching is on
the secret and nothing else — two machines behind one NAT address report
the same user agent and the same address, and folding those together would
trust a computer nobody ticked the box on. Revoked grants are still not
reusable; expired ones are, since expiry is the clock running out and
revocation is the user saying no.

No schema change: the renewal writes columns that already exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 13:23:37 +02:00
Claude 1195a9d7a3 Sort search results by date, and let the reader say otherwise
Search answered in ts_rank order and only that, so a keyword that matched
a mail from 2004 and two from 2026 put them on the page in an order with
no relation to when any of them arrived. Read as a page that had ignored
the question. The default is now newest-first — what the reader expected,
and what every mail client they already use does — with relevance kept as
the other position of a switch beside the pagination.

The switch is two links into the same route inside the list frame, so
changing order is one navigation rather than a write followed by a
reload, and the controller remembers the choice as a side effect of
answering it. Remembered in the user's settings bag, beside the sidebar's
expanded account and the calendar pane's mode, because it is the same
kind of thing: one string of view state that nothing queries by and that
should follow the user to their other devices. Switching starts again at
page one — page four of one order is page four of nothing in the other —
while paging carries the order in the URL.

Both orders now end in thread_id DESC, which fixes a bug that predates
the switch. `ORDER BY rank DESC, last_message_at DESC` is not a total
order, and LIMIT/OFFSET over a partial one is free to break ties
differently per page: measured on 20k tied rows, three pages of 50
returned 148 distinct rows — two shown twice, two shown never. Not exotic
either, since ts_rank is degenerate for a query that stems to nothing and
ties every row in the result.

JMAP's EmailQueryRunner compiles its own ordering and is untouched.
2026-08-07 18:06:27 +02:00
Claude 2bc984c2d5 Draw one meeting once, on every surface that lists occurrences
A screenshot of "Happening Soon" showed the same meeting on two
consecutive rows: the copy an extractor read out of the invitation onto
the account's calendar, wearing the participants icon, and the copy the
provider mirrored onto a Remote calendar, wearing the plain clock. Both
rows are correct in the database — two remote objects with their own
remoteId, etag and sync state — and they carry the organiser's UID, so
EventClusterer has recognised them as one meeting since it existed. The
grid draws them as one merged chip. This panel read occurrences and drew
a line each, which is the worse place for the duplication to land: a grid
at least draws its two chips inside one visibly shared hour, while a list
of twelve lines simply claims there are two things.

Display-only, then. Nothing had materialised that should not have.

So the clusterer stops being the grid's. Every surface that turns
occurrence rows into a list a person reads goes through it:

  Happening Soon collapses, and the SQL cap is over-read so the slice can
  be taken afterwards — twelve rows has to mean twelve meetings, not
  twelve rows of which six are duplicates. HappeningSoonRow now carries
  its cluster, so the template wears the same conic-gradient dot the
  merged chip does; a list that silently merges is indistinguishable from
  one that lost something. Its kind and its provenance are read off the
  cluster's EXTRACTED member rather than off whichever row the query
  returned first, or the icon and the "why is this on my calendar?" link
  would appear and disappear with the sort order.

  The shared calendar page and its .ics feed collapse, before redaction
  rather than after: merging afterwards would compare blocks the
  redaction made deliberately indistinguishable, and every meeting in an
  hour would fold into one. Publishing a meeting twice is not a leak, but
  it is a lie about how busy the owner is, and it files as two meetings
  in a subscriber's client.

  The alert sweep folds on user + UID + start + trigger instant. The
  delivery ledger could not do it — its unique constraint is keyed on the
  EVENT, which is precisely the thing there are two of — so one meeting
  sent two notifications at the same second. Keyed on the USER because
  the sweep is global and a UID is the organiser's, and on the TRIGGER
  rather than the alert because alert keys are minted per row.

Left alone, deliberately: the topbar dot already answers with the first
occurrence and returns; booking availability subtracts busy intervals and
subtracting one twice removes the same slot; the calendar .ics export is
one calendar per document, where a UID is already unique; and JMAP
answers with the ids of the rows it holds, which a client must be able to
get back.

And an extracted copy under a UID of its own stays a second meeting.
Collapsing that would mean matching on title and time, which turns a
weekly 1:1 held with two different people at the same hour into one chip
— a meeting quietly disappearing from a calendar, which is the worst
shape a calendar bug takes. Dismissal answers those, not a wider merge.
2026-08-07 17:34:35 +02:00
Claude a3a122dc85 Store the attachments an Email/set update names
`attachments` was in EmailPatchApplier::DRAFT_PROPERTIES, so the patch was
accepted, and JmapDraftWriter::update() never read the key. Email/set answered
`updated`, nothing was stored, and a following Email/get showed an empty list
and hasAttachment: false — while a subject in the same patch had applied. A
phone composer, which autosaves and so always has a draft before a file is
picked, had to delete and recreate the draft to attach anything, handing the
user a new Email id every time.

Update now goes through the same attachment path create does, with JMAP
whole-value semantics: the array is the complete set the draft should end up
with, a part left out is removed (bytes as well as row), and a part re-listed
by the `p-` blobId Email/get handed out is kept where it is — same id, no
second upload of bytes the server already holds. A rename on such a part is
applied rather than ignored, because filename reaches the download endpoint and
the outgoing MIME part.

create() and update() share one implementation rather than two copies of blob
resolution: create is the whole-value case with no existing parts. That also
makes create refuse a non-array `attachments` instead of ignoring it, and makes
both paths derive hasAttachment through DraftAttachmentService::syncFlag()
rather than assigning it.

A blobId that will not resolve — malformed, expired, or another account's — is
refused with invalidProperties, and the plan is built before a byte is written,
so a refused update leaves the draft exactly as it was, subject included.

phpstan-baseline.neon loses one entry (the `$stored > 0` comparison went with
the rewrite); none were added.
2026-08-06 08:45:44 +02:00