mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
748e1cdcdfac01097e19897beb1b21691c1d66c0
13
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
748e1cdcdf |
feat(demo): a privacy notice, and two fixes found by using the demo
**The privacy notice gets its own page and its own link.** A section inside the Impressum is not one — it has to be reachable in its own right — and it is required even though this instance asks for nothing. A web server writes the caller's IP address into a log on every request and an IP address is personal data, so "we collect almost nothing" is a statement that has to be made rather than a reason to omit the page. Written from what the software does rather than from a template. The cookies named are the three it actually sets, observed on a real visit; the claim that nothing reaches a third party is the content security policy, which permits connections to this server only; and the retention period is read off APP_DEMO_TTL through DemoMode::ttlDescription() rather than typed into the prose, because a notice stating a period the reaper does not honour is worse than one saying nothing, and two copies drift the first time somebody tunes the setting. APP_DEMO_PRIVACY_HOST names whoever runs the machine, who is a processor. Unset, the page says so in red — the same rule the Impressum follows, for the same reason: a legal notice naming nobody looks compliant from a distance. **Pressing Receive twice looked broken.** The second press appeared to do nothing for up to thirteen seconds. _refreshList() coalesces bursts through a fifteen-second floor measured from the LAST refresh, so the press right after a delivery was the one certain to be inside it. The floor is right — a sync publishes one event per mailbox per account, and it turns eight fetches into one — but it was being applied to somebody who had just pressed a button and was watching for the result. refreshNow() skips it; onAccountSynced() still does not. The difference is whether anybody is waiting. **The user menu still wore its own tooltip.** The previous fix stopped a NEW bubble appearing over an open menu but left the one already on screen, which is why it vanished only when the pointer left. There is a document-level click listener whose whole job is to take it down, and ui--user-menu#toggle calls stopPropagation(), so the click never reached it. The listener runs in the CAPTURE phase now — at the document, before the event reaches its target, where no handler can hide a click from it. The same reason the scroll listener above it already uses capture. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e9a441333 |
fix: an install page a demo cannot close, and eleven flashes nobody translated
Two bugs found by using the thing. **A demo visitor could lock an install out of /install.** InstallGuard closes that page as soon as a user exists, and demo mode mints one per arrival — so on a fresh demo instance the first passer-by or crawler took the install page away with them, permanently, before the operator had made themselves an administrator. Nothing announced it either: the page 404s, which is exactly what it does on a correctly installed instance, so the symptom is indistinguishable from working. Both the guard and createFirstAdmin's advisory-locked re-check now count real users only. **Flash messages reached the page as raw translation keys.** The flash bag on this install holds sentences, not keys — the toast region prints what it finds, and cannot translate, because most of these interpolate a name or a count only the controller has. Eleven call sites across TwoFactorController, SecurityStepHandler and AdminDashboardController pushed keys instead, so a rejected code produced a red toast reading "two_factor.flash.code_rejected". The strings existed and had been translated into all three locales months ago; nothing looked them up. A static test now scans src/ for flash calls passing a dotted literal, because nothing about that failure is visible in review — the call site reads exactly like a correct one. That fix broke two E2E tests, which is the interesting part: they asserted on "Two-factor authentication is on" and had been matching the panel heading ALONE because the toast beside it was showing a key. Translating it made both match and strict mode rightly refused. They were passing because of the bug. **Demo mode, from running one publicly.** The account form opened in a turbo-frame, so the guard's redirect had nowhere to land and the modal span forever — it now renders a panel saying why the form is not coming. The receive button was a full browser navigation, which reloaded the page on top of mail Mercure had already delivered AND wiped motion.js's in-memory set of seen rows, so the whole list replayed its entrance; it is a stream touching only the demo bar now, and mail simply arrives. Plus an Impressum, because a public demo in Germany needs one — blank by default and loudly saying so, since § 5 TMG wants a real operator and a notice naming nobody looks compliant without being so. **Interface, all reported from use.** The snooze menu's two distant options were taller than the near ones, because anything past two days carries the date and "Sat, Aug 29, 08:00" wrapped inside min-w-56. Clicking a dropdown trigger focused it, and focus shows a tooltip — so opening the user menu painted its own hint across the menu. The eight recovery codes ran together on one line: they were joined with real newlines and <code> collapses those without whitespace-pre-line, so nothing was rendering what was already there. Confirming a 2FA code gave no sign anything was happening, and the reply carries codes shown exactly once. Layout was the last <select> on a panel where every other short choice is a segmented group. Solar and paper put a white field on a warm off-white surface, which under the boxed layout's translucency left the search box with no visible edge. Read receipts had a tab of their own coming — the card holds nothing else, and signatures left the alias tab before it for the same reasons. And /settings opened on its fourth entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
231d0ab0da |
feat(demo): a public demo mode, with mail that arrives on demand
plMail is hard to show. It is a mail client, so everything interesting only
happens once there is mail — and a stranger cannot reasonably be asked to hand
a demo server their IMAP password to see it. APP_DEMO_MODE fills that gap: mail
that is nobody's, and a button that makes more of it arrive.
One switch for the whole install, not a per-account flag. The promise is that
nothing reaches the network, and that is only checkable if there is one place
to check; a per-account flag would move the promise into every outbound path
individually, where it holds until the first one that forgets to ask. Read
through `default:` so an install that never heard of the variable is a normal
install — the accident worth designing against is a real deployment becoming a
demo, not the reverse.
**A mailbox per visitor.** /demo provisions a throwaway user, signs them in and
drops them in a seeded inbox. One user each rather than one shared account: two
people arriving at once would otherwise be marking each other's mail read and
reading each other's drafts, and the product would look broken while working
perfectly. app:demo:reap deletes them on a timer.
**Receiving.** A bar at the foot of the page walks a scripted queue — a reply,
an invoice with a real attachment, a delay notice, an HTML newsletter — and
wraps at the end, naming what comes next. Delivery goes through the real
PostIngestPipeline rather than writing rows by hand, because what is being
demonstrated is that plMail *does* something when mail arrives: it threads, it
categorises, it runs the filters the visitor just built, and it appears without
a reload. A hand-written row would put mail on screen and none of that would be
true, which is a worse demo than no button.
**Sending.** DemoMailSender is registered above every real sender and claims
every account while demo mode is on, so SmtpMailSender is never asked — "a demo
cannot reach a relay" becomes a property of the wiring rather than a rule each
sender has to remember. It answers a few seconds later, from whoever the visitor
addressed, on the thread they are still looking at.
**What is switched off**: adding or editing a mail account, OAuth and CalDAV
connect, calendar subscribe — a stranger typing their real credentials into a
box on someone else's server has handed their mail away, and there is no version
of that form that is safe to show. Mail sync, push renewal and calendar sync
no-op, since a demo's accounts point at documentation domains. Everything else
stays clickable: locking down all the non-mail surfaces produces a tour of a
product with the interesting doors locked.
Two things this needed that did not exist. The demo mailbox moved out of
SeedDemoMailboxCommand into DemoMailbox, shared with the provisioner, so the
README screenshots and the hosted demo cannot drift into being pictures of
different things. And deleting a user — this project had no such path, because
a self-hosted install's users leave by having their password changed. The
eraser discovers owned classes from Doctrine's metadata rather than a list:
twenty entities point at User today under two different property names, and a
hand-written list would be right until the next entity was added, at which
point the reaper would start failing on a foreign key at three in the morning.
compose.demo.yaml hardcodes APP_DEMO_MODE rather than interpolating it, and
ComposeEnvironmentTest now holds it to that. Compose reads Symfony's committed
.env, which ships the variable as 0, so `${APP_DEMO_MODE:-1}` would have
resolved to 0 for everyone who cloned the repo — and the failure is the bad
kind, where the stack comes up healthy and simply is not a demo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d3e4eced03 |
fix: three corrections the suite caught, one of them a recipient bug
**The shared dropdown escape was too broad.** Sending every ui--dropdown menu to the top layer fixed the ones trapped in a pane and broke the ones deliberately anchored to the box they live in — the compose window's emoji picker is centred ON the window and asserted to stay inside it. It is opt-in now (`data-ui--dropdown-escapes-value`), and the snooze menu on a mail row asks for it because it opens inside the mail pane. **The recipient dropdown is NOT reparented to <body>, and that is now written down.** `dropdownParent: 'body'` fixes the reported symptom in one line — the compose window grows to make room for the suggestion panel instead of the panel overlaying it — and it reintroduces something worse. Subject sits directly under To, so an overlaid panel covers it, and a click aimed at Subject lands on whichever suggestion is under the pointer and enrols it. That is a real report: it silently added a third party to a message addressed to someone else, and three specs in compose-recipients.spec.ts exist to keep it fixed. They caught this. So the panel stays in flow and the window growing is the price, with the reasoning in the code so the next person does not spend the same hour. The way out is a layout change — the panel opening where it cannot cover the next field — not a parent change. **The session guard swallowed too much.** It cancelled every 401 and 403, not just the redirect that would have swapped a login form into the shell, so the compose dock's own "your session has expired, reload and sign in" — said in the place the user just clicked — never rendered. Only the redirect is cancelled now; a bare 401 travels on to whoever asked for it, who usually has something more specific to say. compose-safety's held-shape test sets its own setting and restores it in an afterEach rather than an afterAll, so a failure there cannot leave the worker's user on a setting the rest of the file does not expect. Full Playwright suite: 563 passed, 0 failed. That is 563 rather than the 517 a green run used to report — the exclusive project depends on the main one, so every flake was also skipping those 19 specs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c3080d00a |
fix(security): an SVG attachment is not an image, and a shipped install gets headers
CODE-REVIEW S-02 (high), S-04, S-08, S-05, S-06. S-02 — three controllers decided what may render inline with `str_starts_with($contentType, 'image/')`, and `image/svg+xml` satisfies it while executing script as a top-level document. nosniff does not help: nothing is mislabelled, the type genuinely is what it says. The type comes from the MIME headers of an incoming mail. Reachable from the mail itself. MailBodySanitizer::resolveCids() rewrites EVERY `cid:` in the document rather than only `img src`, and the sanitiser allows relative links — so `<a href="cid:logo">Rechnung ansehen</a>` has the part id filled in by the application, and the click opens the attachment route without the `?download=1` that every template adds. Now an allow-list (InlineDisposition), which is the difference between "everything not yet known to be dangerous" and "the formats actually wanted", plus `default-src 'none'; sandbox` on the response — so a later widening of the list is a bug rather than a vulnerability. Parameters are stripped before comparing, because `image/svg+xml; charset=utf-8` would otherwise walk past the one entry meant to stop it. S-08 pulled along: the JMAP download had the same prefix test. Guarded today only by the jmap firewall being stateless and wanting a header a browser navigation does not send, which is a thin thing to depend on. S-04 pulled along: the file-picker preview carried no Content-Disposition at all — implicitly inline, in our origin, with a type chosen by Nextcloud, Immich, Dropbox, OneDrive or Drive. A file an attacker drops in a shared folder is a realistic shape for the setups these integrations exist for. A non-image now falls back to the placeholder rather than to a download: a download prompt fired by a thumbnail is worse than a grey box. S-05 — the Caddyfile now sends HSTS, X-Frame-Options, nosniff, Referrer-Policy and Permissions-Policy, and drops the Server banner. None of these existed anywhere in the repository, so the ones a live instance showed were its operator's proxy — while the README's own install instructions are `docker compose up -d` and "open https://localhost". `defer`, so the per-response policies on the attachment and image-proxy routes still win. Referrer-Policy is the one that matters most for a mail client: it sends its users to strangers' links all day. S-06 — mercure subscribe defaults to [] rather than '*'. Never reached today, since both cookie-minting paths pass explicit topics, but a future createCookie($request) without a topic argument would have issued a subscription to every user's mail events and looked entirely ordinary in review. Also: mailpit removed from the dev override. MailerInterface is injected in no service in src/ — plMail's own outgoing mail leaves through the user's account via MailSenderRegistry — and the override never set MAILER_DSN either, so the container has been catching nothing. Both configuration pages said the dev stack pointed at it; they now say what is actually true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9f9f2c9820 |
fix(backup): APP_SECRET and TRUSTED_PROXIES leave the export
Every other name left this inventory because it describes the machine. APP_SECRET left because of what a restored copy would do. Take stock of what this codebase derives from kernel.secret: remember-me is the signature-based handler, so the sixty-day cookie is signed with it; sessions and CSRF tokens die with the session; JMAP signs with the JWT pems, whose contents travel separately; URI signing and uuid47 are unused. Nothing on disk or in the database is encrypted under it -- that is APP_ENCRYPTION_KEY's job, and it does travel. So restoring it changed exactly one thing: a REMEMBERME cookie minted by the source install kept verifying on the target. Both halves of that check are in a v2 backup now that users travel -- the signature covers the app secret and the password hash -- so a browser signed in to the machine the file came from was signed in to the machine it was restored onto, without anybody deciding that. Cookies are not scoped by port, so two stacks on one host hand each other's browsers straight through. TRUSTED_PROXIES goes for DATABASE_URL's reason instead: the addresses whose X-Forwarded-* headers this install believes are a fact about what sits in front of THIS container. KeptDeliberately and External respectively, so old backups carrying either classify and stay inert. Found while reproducing a 403 that turned out to be a double submit of the 2FA form (previous commit). The stale-cookie hypothesis was falsified as the cause of that -- a pre-restore remember-me cookie was correctly challenged for a second factor, it did not bypass anything -- but reproducing it is what showed the cookie surviving the restore at all, which is the property this commit removes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d1739e6c96 |
fix(backup): MERCURE_JWT_SECRET leaves the export
It pairs the app with the hub container beside it, and the hub reads it only at container start -- so a restored value re-keys one half of a running pair and every EventSource dies until the whole stack restarts, which is how this was found. Old backups carrying it classify External, like the DSNs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
7e3ba2b9c0 |
Reset the topbar globals per request, stop exporting the database's own keys, and offer a password
Three fixes riding one pass: LogAlertGlobal memoised per request and was reset per request only under PHP-FPM. FrankenPHP keeps the service alive, so the first badge a worker computed was the badge every later request got - an emptied log table with a yellow outline that survives refresh, seen in the wild. All four stateful Twig globals now implement ResetInterface like InviteReader always has; SidebarCounts and CalendarGlobal could additionally leak one user's counts into another user's topbar. POSTGRES_PASSWORD, postgres_password and DATABASE_URL leave the backup: machine-local, initdb-consumed, never applicable on a restore target. Old backups that carry them still import - recognised and classified external, not silently dropped. The export form gains an optional generate button: fills both fields, puts the password on the clipboard and says so; where the browser withholds its clipboard the fields unmask instead, because a masked password nobody was told is not a backup anyone can open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
567db06844 |
Restore a config backup by writing it, not by printing it
The import classified every environment value as unwritable and handed
back two dozen lines to paste into .env.local. That was built on a
premise that is wrong for the way plMail is deployed: nobody hand-edits
those. frankenphp/generate-secrets.sh mints them on first run into
var/secrets/generated.env, every service mounts the volume that file is
on, the entrypoint loads it before it execs the server, and the app
process can write it. So the import writes it.
What the entrypoint actually promises, and what everything here follows
from: a real environment variable wins over the generated file, which
wins over .env.local and .env, with an empty value counting as absent at
every level. The shell (load_generated_secrets) and PHP
(config/bootstrap_generated_secrets.php) apply exactly the same rule.
ConfigBackupObstacle — "why can plMail not write this" — becomes
ConfigBackupDisposition, "what becomes of this": applied,
applied-on-restart, shadowed-by-compose, external, kept-deliberately,
not-writable. The restart instruction is now one sentence for the whole
plan rather than one line per value, because it is one action.
Three fates survive, each forced by the deployment rather than by
caution:
- POSTGRES_PASSWORD, the postgres_password file and DATABASE_URL stay
external. generate-secrets.sh does rewrite that file from
generated.env on every boot, but the Postgres image reads
POSTGRES_PASSWORD_FILE only at initdb — on a database that already
exists the role keeps the password it was created with, so writing
one without the other is a stack that cannot authenticate.
- APP_ENCRYPTION_KEY is kept on purpose: the import re-encrypts the
backup's credentials under the key in force here, and swapping it
underneath them would make the rows it just wrote unreadable. It is
a note now, not a chore — the line is still offered for the operator
who is also restoring the old database.
- A name pinned non-empty in the process environment beats the
generated file at the next start. Detected by comparing the live
value against what the file holds, because the entrypoint exports
that file's own contents and "is it in getenv" distinguishes
nothing. On the stock compose.yaml that is three names.
The /install path is the one this was for: upload and password are the
whole job, everything lands in this instance's own secrets file and
database, and the page asks for one restart instead of a wall.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
1adac1fe40 |
Carry an instance's configuration in one password-sealed file
Export from the admin area: env secrets, the JWT keypair and the database-held provider credentials - FCM included, decrypted inside the envelope because the restore target holds a different encryption key - sealed with Argon2id and secretbox at parameters a Raspberry Pi can afford, in a documented format a php one-liner can open without plMail. Import shows its plan before touching anything: what it can write is written and re-encrypted under this instance's key, what it cannot - process environment, the Postgres role's own password - is rendered as the exact lines to place by hand. The install wizard offers the same restore before the first account exists, behind the same guard that closes /install forever afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b4e3cb6afb |
Correct what translating the handbook found wrong in it
Six defects, all found by somebody reading a page closely enough to
render it in another language. Each is fixed in English and in German
together, and the German markers are re-hashed only where the German
prose now says what the corrected English says — re-hashing alone would
mark a stale translation as current, which is the one thing that check
exists to prevent.
calendar-model.md claimed the materialiser honours "exactly four
things in a patch" above a table of five, and its
traps section named a different four. The truth
is five keys read, of which four are the patch
EventInstanceEditor writes; `excluded` is not a
patch field at all and is spelled by
RecurrenceRuleConverter::exclusionOverrides(),
because the one override value whose only job is
to be exactly right should not have two places
that must be right about it.
CLIENT_DEVELOPMENT.md said snooze was "not exposed over JMAP today" and
offered itself as the canonical "ask, don't work
around" case — while Thread/set has been
implementing exactly that. Somebody asked. Its
methods table was also missing Thread/set,
SearchSnippet/get and all four calendar methods,
which the same page announces two screens above.
install/docker.md described the missing blob volumes as a live
defect. They were added to compose.yaml hours
after that page was written — by the report that
found them — and nobody updated the page. It now
documents ten volumes and keeps the story as
history, with a pointer to the CHANGELOG note for
anyone upgrading past it.
providers/imap-smtp.md named a form label no interface shows. The
hardcoded labels in AccountType are overridden by
the Twig template, so the fields are "Email
address" and "Username".
settings.nav.calendars had no German, so one item in a German settings
menu rendered in English.
CONTRIBUTING.md said "four translators" where there were six.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4d103cfbdf |
Write the handbook, and make it fail the build when it goes stale
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.
The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.
Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.
Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.
compose.yaml mounted no volume for var/attachments, var/raw
or var/uploads. Workers write those during sync
and the web container serves them back, so each
kept its own copy in its own layer — downloads
404, and everything died on a recreate. The
override and the TrueNAS file both mount them;
the file the README tells an operator to run did
not. The same omission cost real data once
before. See the CHANGELOG for moving what is
currently stranded.
app:calendar:materialise new, nightly. Occurrences are drawn when an
event is saved and nothing moved that window
again, so a weekly standup ran out of dates in
two years and its reminders stopped with them —
silently. The query it runs had existed since
the materialiser did, documented as exactly this
sweep, and had never been called.
Microsoft wizard asked for three of the nine permissions plMail
requests, and a token already issued is not
upgraded when the rest are added later.
GmailWatchService told you to grant Pub/Sub Publisher to
gmail-api@system.gserviceaccount.com. The
account is gmail-api-push@; a topic granted
exactly what the docblock asked for never
delivered a notification.
IcsExporter said it wrote no VALARM. It has written them
since alerts landed.
CLIENT_DEVELOPMENT.md said there was no JMAP calendar API and that
SearchSnippet/get was absent. Both shipped.
CONTRIBUTING said only app:calendar:push registers a push
channel; subscribing has done it since the
subscribe flow learned to.
Three docblocks named getters removed long ago, or described a
method two below the one they sat on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|