Repository navigation
v0.4.0 - #182
Draft
pmdevelopment wants to merge 24 commits into
Draft
v0.4.0#182pmdevelopment wants to merge 24 commits into
pmdevelopment wants to merge 24 commits into
Conversation
…ches A release's work is now collected on a milestone branch (milestone/v0.4.0) before it reaches main, and the suite only ran on pull requests into main, so nothing proposed for the milestone was tested. `milestone/**` joins `main`, and `wip/**` comes along from wip/28, where it already runs the suite on the phases of #28 — the same block on both, so they merge without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Stretch passwords in the browser so the server never sees them The login form now stretches the password with PBKDF2-SHA256 (600 000 rounds, the Bitwarden construction) and posts only an auth hash derived from the master key; the server hashes that like any password. Changing the password in Settings works the same way. This is phase 1 of the end-to-end encryption in #28: the key that will unlock a user's private keys is derived from the same master key, so a server that never sees the password cannot derive it. Salt and iteration count are stored per user and handed out by POST /login/prelogin. Unknown addresses get a deterministic HMAC salt. Existing accounts move over on their next sign-in, which sends the password one last time beside the auth hash; that sign-in ends the account's other sessions once. A moved account no longer accepts the password itself. Where crypto.subtle is missing (plain http on a LAN), @noble/hashes is the fallback. A password chosen by an administrator, on the create form or with app:user:password, now works once: the person picks their own before anything else is reachable. --no-change-required skips that for an operator letting themselves back in. Config backups carry the salt, the iteration count and the flag. Three columns on the user table; the migration is reversible in the schema, but going down leaves moved accounts unable to sign in until app:user:password resets them. Part of #28. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Link the changelog entry to its pull request Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Make, keep and recover each person's OpenPGP keys in the browser Phase 2 of #28. On the first sign-in after the update the browser makes a keyring: a user key (32 random bytes, AES-256-GCM-wrapped under a key derived from the password), an OpenPGP key per sending address whose passphrase is wrapped under the user key, and a device copy for this browser. The server stores the blobs and cannot open any of them. Signing in unlocks the keyring for the whole session. The browser wraps the password-derived key under a random unlock key kept in localStorage and posts only the unlock key, which the session holds. A session not opened by the login form unlocks with the password via POST /keyring/unlock. Settings > Encryption adds a recovery phrase (BIP39, shown once, downloadable with the revocation certificates), known devices that reopen the keys after a reset, import of an existing key, export of all keys as one armored file, renewing keys and marking them compromised. Keys need a secure context; on plain http the page says so. The server keeps the rules a stale tab could break: one keyring at a time, keys only for owned addresses, one primary per address, an unseen generated key is replaced rather than kept unless its user key is locked, and a password change in Settings must carry every active key re-wrapped or it is refused. app:user:password and the forced change lock the keys instead. Removing a user deletes their keyring, and config backups carry it. Four new tables; the migration is reversible but going down deletes every key. Part of #28. Stacked on #84. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Link the keyring changelog entry to its pull request Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Updated changelog * Run the end-to-end suite on pull requests into wip branches A feature built in phases on a wip/ branch is reviewed one phase at a time, each phase a pull request into that branch. Those pull requests now get the suite too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let a password change wait for the keyring, and stop the phrase toast A password changed right after the first sign-in could race the keyring being made in the background: the form re-wrapped no keys, a key appeared a moment later, and the server rightly refused the change. The form now waits for the page's keyring first. The keyring singleton keeps the config of every caller, so the password form connecting first no longer loses the user id the boot controller supplies. The missing-recovery-phrase toast shared the toast stack with whatever the person had just done, such as a send's undo. It is gone; Settings > Encryption says it instead. The toasts for reopened and locked keys stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Recognise, check and read encrypted and signed mail Phase 3 of #28. Every provider's builder classifies a message as PGP or S/MIME, encrypted or signed, before it is first written, and keeps no body for encrypted mail, so the generated search vector never indexes ciphertext. Wrapping parts (signature.asc, smime.p7s, the ciphertext) are no longer stored as attachments. Signed mail is checked on the enrich transport: S/MIME with OpenSSL against the system CAs, PGP with ext-gnupg in a throwaway GnuPG home against the person's own keys and an Autocrypt key. A signature from another address than From is invalid. Encrypted PGP mail, PGP/MIME or inline, is decrypted in the browser from the byte-exact raw source (new /mail/message/{id}/raw), sanitized with DOMPurify and shown in the same sandboxed frame, built from the same document partial as server-rendered mail. Remote images stay blocked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep contacts' keys, look them up, and warn when one changes Phase 4 of #28. A new contact_key table holds, per person, the public keys of the people they write with, and ContactKeys holds the one rule that makes collecting them safe: trust on first use. The first usable key for an address is used; a different one arriving later, from mail, WKD or an import, is recorded as changed and used for nothing until the person accepts it, and never in place of a pinned key. A partial unique index keeps it to one current key per address. Pinning is the person saying they compared the fingerprint; there is no web of trust. gpg decides what is usable: a self-signed user ID for the address, not revoked or expired. Keys are collected after ingest on the enrich transport: an Autocrypt header only when addr= is From, attached keys from the raw source only with a user ID for From, never for the person's own mail. WKD is asked through the server, in the two URL shapes only, from a validated domain: HTTPS, no redirects, no private addresses (resolved, pinned and checked again on connect), a short timeout, a size limit, cached answers and a per-person rate limit. Signature checks use the keys: own or pinned is valid, unpinned is untrusted, any other key of the sender's is the new KeyChanged. The browser applies the same rules inside encrypted mail. A change to a sender's keys re-checks their newest signed mail. Settings -> Encryption lists the keys, with import, lookup, pin, accept, remove and per- recipient preferences; the reading banner offers lookup, trust, or the new key. Config backups carry the keys. VKS is left out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Write encrypted mail: seal drafts and build the message in the browser Phase 5 of #28. The compose window asks which recipients have keys while they are typed and encrypts by itself when all of them do; a toolbar switch and Encrypt/Sign under More options decide for one message. A draft that is to be encrypted is sealed in the browser before every save, to the person's own keys, and the server keeps no body of it. Attachments are encrypted before upload, inline images stay inside the sealed draft, and a reply quotes encrypted mail decrypted in the browser. On send the browser builds the MIME entity and encrypts it (PGP/MIME, signed inside, to every recipient and the sender), signs it, or leaves it plain for a sealed draft going out unencrypted. The server sends it byte for byte as the body of the usual Email, so every sender, undo and scheduled send work unchanged. SealGuard refuses a built message whose recipients or keys changed, when it arrives and again when it leaves. Every outgoing message carries an Autocrypt header with the sender's key; four switches under Settings -> Encryption -> Sending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep decrypted mail on the server, if an account chooses to Phase 3b of #28. Per account and off by default, under Settings -> Encryption -> Decrypted mail on this server: a browser with the keyring open decrypts that account's encrypted mail in the background, and on reading, and hands the inner MIME message to the server. The server parses and sanitises it itself, stores the attachments, keeps the decrypted copy beside the ciphertext, and gives it what ingest gives any mail: search, insights, date proposals, events, and the rules while it is unread. The models are a separate switch. DecryptedMailPolicy is asked by the embedder, the classifier, thread summaries and the writing assistant; a model host outside the installation's own network needs a consent for that host. Switching off takes the decrypted text and everything made from it back out, on the bulk queue. APP_E2E_SERVER_PLAINTEXT=forbid turns the option off for the whole installation. Also restores the Firewalls heading that phase 5 swallowed in the English security model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give the plMail app what it needs to read and send encrypted mail Phase 6a of #28: the server side of pl_mail_android#9. JMAP gains urn:plmail:params:jmap:keys: Keyring/get (the keyring still wrapped, without the recovery copies), ContactKey/get, ContactKey/lookup (the web composer's answer, now RecipientKeys), DecryptedMail/pending and DecryptedMail/store, and crypto fields on Email. Email/import (RFC 8621 4.8) brings an uploaded message in the way sync would. A $draft import is also kept as the message to send, byte for byte; an encrypted one names the keys it was encrypted to and is checked by SealGuard at import and at sending. Pairing hands over the keyring when the browser has it open: the user keys are sealed under a transfer key that only the QR code's fragment carries, so the browser draws that QR code itself (uqr), and the app gets the sealed copy with its app password. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the recording sender's mail on the sender, where PHPStan can see it is read Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Require ext-gnupg PGP signatures are checked with it (SignatureVerifier), and the image has carried it since phase 3; composer.json now says so, as concept section 16 asked. The Pages job, which only renders Markdown, installs without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep remember-me tokens on the server, so signing out holds A remember-me cookie that carries only its own signature stays valid until it expires, and signing out only asks the browser to delete it. A request already on its way with it - the page's own background fetches - reached the server after the sign-out and signed the person straight back in. The tokens are kept on the server now (Symfony's DoctrineTokenProvider, table rememberme_token), one per device. Signing out deletes this device's token, so a late cookie opens nothing; other devices stay signed in. Symfony's CacheTokenVerifier keeps parallel requests that rotate the token from looking like theft. Existing signed cookies are no longer read: people who relied on "Keep me signed in" sign in once more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Encrypt the subject of encrypted mail Phase 6b of #28: header protection as RFC 9788 describes and Thunderbird does. The composer puts Subject, From, To and Cc inside the encrypted entity, marked protected-headers="v1", and the outer subject is "...". The row keeps the real one, which the server knew from the draft. Reading, the decrypted subject shows in the banner and replaces a placeholder heading, and a reply's "Re: ..." is completed with it; an account that keeps decrypted mail takes it for the row. On by default, under Settings -> Encryption -> Sending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read, write and sign S/MIME, beside OpenPGP Phase 6c of #28. A person's own certificate comes from a PKCS#12 file opened in the browser (PKI.js): its PKCS#8 key is wrapped under the user key with the new purpose plmail/smime-key, and the server stores the certificate, its chain and the wrapped key (SmimeCertificate), reading addresses, fingerprint and dates from the certificate itself. Settings -> Encryption lists, exports again as .p12 and removes them; a newer certificate for the same addresses retires the older. They travel in the config backup and in Keyring/get. S/MIME-encrypted mail opens in the reading pane through the same path as OpenPGP: openEncrypted() now opens EnvelopedData with the person's certificates, checks the signature inside, and hands the entity to the same parser, sanitizer, frame and decrypted-mail hand-over. RSA PKCS#1 v1.5 key transport is done by hand (blinded BigInt), since WebCrypto cannot; OAEP through WebCrypto, AES-CBC through @noble/ciphers. Contacts' certificates (ContactCertificate) are kept from good signatures by the sender: SignatureVerifier for signed mail, and a new endpoint the reading pane asks about a signature inside encrypted mail. One that chains to a system CA is verified and renews itself; anything else is trust on first use. RecipientKeys / ContactKey/lookup report them per recipient. The composer encrypts with OpenPGP when every recipient has a key, otherwise with S/MIME when every recipient has a usable certificate, the sender has one for the From address and there is no Bcc. It signs (detached SignedData, SHA-256) then envelopes (AES-256-CBC, RSA PKCS#1 v1.5) to every recipient and the sender, through phase 5's sealed path; SealGuard checks each certificate is still current and unexpired, and the signer still the person's, at sealing and at sending. Plain mail is S/MIME-signed when nobody else on it has an OpenPGP key. Revocation (CRL/OCSP) is not checked. Two tables, reversible migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Publish people's keys through the Web Key Directory Phase 6d of the end-to-end encryption concept in issue #28. plMail answers the OpenPGP Web Key Directory for the installation's own domains, so Thunderbird, Proton, `gpg --locate-keys` and plMail's own WkdLookup find a person's key by their address. WkdController both shapes of the draft — advanced (/.well-known/openpgpkey/<domain>/hu/<hash>) and direct (/hu/<hash>, domain from the Host) — and their policy files. Outside every firewall (`security: false`, like /branding/): no session, no CSRF, no login redirect. Binary key, application/octet-stream, CORS *, public for ten minutes with an ETag. Every miss is one 404. WkdDirectory decides, in three layers that can only say no: APP_WKD_DOMAINS (empty by default, no directory); only the person's sign-in address, the one an administrator set; and the person's own switch. The key is SendingKey's, unexpired; a replaced or revoked one stops at once. Serving marks it as handed out (firstUsedAt). WkdKeyExport reduces the key to that address's user ID with the gpg binary in a throwaway home, reads it back with PublicKeyReader, caches it. WkdSettings… Settings → Encryption → Web Key Directory: per address the state, the URLs, an on/off switch and a check from outside through WkdLookup (past its cache); setup steps for administrators. WkdLookup's hash and domain shape are now public and shared rather than copied. Keyring/get carries `wkd`, so the app can show what is published. The opt-out lives in the settings bag and travels in the config backup. A `wkd_serve` limiter (300 an hour per IP) slows walking a list of names. Only the sign-in address is published because every other address is the person's own claim: a manual alias, and an IMAP account's typed address and server. Publishing those would let anybody with an account put a key under somebody else's address. One thing fell out: gpg 2.4's `mail` property matches nothing in a keep-uid export filter, so every key came out without a user ID. The filter matches on `uid` instead, and the read-back check would refuse to publish if that ever regressed. No database change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read the #28 form fields with getString() instead of casting InputBag::getString() is the same (string) $this->get() with the type in its name and '' as its default; get() already refuses an array or object with a 400. Only the fields the #28 work added: the login and prelogin, contacts' keys, the encryption settings and WKD. The casts upstream had before are left for a tidy-up of their own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin FrankenPHP 1.13.1, and check that gpg starts before using it The floating `1-php8.4` tag went back from 1.13.1 to 1.13.0 when 1.13.1 was withdrawn. Under the FrankenPHP server of 1.13.0 (and 1.12.7) gpgme starts `gpgconf --version` but reads nothing back, so every `new gnupg()` in a web request throws "Setting engine info failed" — the CLI in the same image is fine. Contact-key import and the WKD route answered 500, and the e2e runs of #10 failed on exactly that. GnupgHome::available() now starts gpg once per process instead of only asking whether the extension is loaded, so a broken gpg degrades the way a missing extension always did: signatures stay unchecked, a key is not read, nothing is published over WKD — no 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Run gpg through Symfony Process instead of ext-gnupg, and unpin FrankenPHP The pin to FrankenPHP 1.13.1 held a release that has been withdrawn. What broke under 1.12.7 and 1.13.0 was gpgme, not gpg: inside the FrankenPHP server it starts gpgconf and reads nothing back, so every `new gnupg()` in a web request threw "Setting engine info failed". proc_open() in the same process works, and WkdKeyExport already ran the gpg binary that way. So all of it does now. GnupgHome runs gpg in its throwaway home through Symfony Process, with a timeout, and reads gpg's machine interface: PublicKeyReader takes the keys from `--with-colons` listings, and SignatureVerifier takes the outcome from `--verify --status-fd` lines (GOODSIG/VALIDSIG good, ERRSIG code 9 missing, every other outcome invalid), importing all the keys it checks against in one gpg run. One tightening on the way: a revoked or expired user ID no longer counts as an address of the signer's key. ext-gnupg is gone from composer.json and the image; the gpg binary from Debian's gnupg package is what is required now, and the Dockerfile takes the floating `1-php8.4` again. New tests for the colon listing and for revoked keys, a good signature without the sender's address, and inline signatures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fit the #28 work to what main brought in What the merge left to do, found by main's own tests: - French: every #28 string, 283 of them, in messages.fr and validators.fr, which main added while #28 was in the fork. - The misfiled-body repair (main) counted a sealed draft's encrypted attachment as a body webklex had misfiled when its name happened to be eight characters, and would have unpacked one named like a hash into the draft. Sealed parts are ciphertext and never a body; both the query and the per-part check leave them out. - An account without a user (main's sender-name tests build one) sent no mail: the Autocrypt header and the attached key asked that user for its settings. Without one there is no key to share. - NewPasswordType, the page where somebody replaces a password an administrator chose, sets a login password; main's test that only login forms have password inputs now knows it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the importmap's packages between CI runs, and retry their fetch Every job of the E2E workflow ran `composer install`, whose post-install importmap:install fetched all 45 packages from cdn.jsdelivr.net: about 270 requests per run. jsDelivr builds a `+esm` bundle on request, and now and then one times out ("Idle timeout reached for .../openpgp@6.3.2/+esm", "Failed to connect to cdn.jsdelivr.net"), which failed the job before a test ran. assets/vendor/ is now cached, keyed by importmap.php. importmap:install skips a package already there at the version importmap.php names, so the CDN is asked once per change to the importmap. When it still fails, the install is tried up to three times; a retry finds vendor/ in place and only reruns the scripts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit 78c6688) * Run the end-to-end suite on pull requests into milestone branches #84 now goes into milestone/v0.4.0, which neither `main` nor `wip/**` matched, so it got no suite. The same block as on milestone/v0.4.0, so the two merge without a conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pmdevelopment
added a commit
that referenced
this pull request
Oct 16, 2026
A milestone branch on its way to main (milestone/v0.4.0, #182) collects work that was agreed and reviewed piece by piece, each piece closing its own issue. It has no issue of its own to name, and listing the pieces' issues only works once each has landed, so the required linked-issue check refused it. `deployment` joins `bug`, `chore` and `dependencies` as a label that needs none. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
… them (#189) * Keep sealed messages on the shared raw volume, so the worker can send them Encrypted mail stayed in Drafts, and the worker logged "a message built in the browser was not sent: the built message is missing". The message the browser built was written to var/sealed/, which compose.yaml mounts nowhere: it stayed in the web container's own filesystem, and the send job runs in the worker. Sealed drafts and decrypted copies were kept the same way, and went with the container on every redeploy. SealedMessageStorage now writes under raw/sealed/, inside the raw volume every container shares, as avatars and signatures live inside uploads/. It still reads and deletes the old place, for an install whose var/ is one directory. The test stack mounts the whole checkout, so nothing there could see this. StorageVolumesTest asks each storage to write and checks the file lands under a directory compose.yaml's x-app-storage shares; against the old path it fails for all three kinds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the fallback to var/sealed/ The milestone runs on one test install, so nothing written to the old place needs to stay readable; the sealed storage has one place again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…when needed (#188) Phase 6e of #28, as proposed in the issue and decided there: an encryption passphrase is the keyring's root for an account without a password, and one lazy unlock dialog serves every session the password form did not open. KeyringRoot what the keyring hangs off: the password, or an encryption passphrase with its own salt and count (keyring_root, passphrase_salt, passphrase_iterations). An account without a usable password is on the passphrase from the moment it sets encryption up, and stays there if it is later given a password. Keyring writes the root with the first user key; takes a passphrase only on that root; changePassphrase() re-wraps all or nothing; lockAll() and applyPasswordChange() leave a passphrase keyring alone, so app:user:password, the forced change and a Settings password change no longer reach it. UnlockController POST /keyring/unlock/session keeps the browser's half without a proof, on the passphrase only: the half opens nothing on its own. The password keeps its checked, throttled POST /keyring/unlock. keyring.js setUpPassphrase(), changePassphrase(), a passphrase unlock that is right when a user key opens, and requireOpen(): one shared dialog, asked when an encrypted message, a sealed or sealing draft, Settings → Encryption or pairing needs the keys. Nothing is asked at sign-in; the background decrypter waits for crypto:keyring. Settings "Set up encryption" and "Encryption passphrase" cards; the inline unlock form becomes the dialog. Keyring/get and GET /keyring name the root and its KDF parameters (root, rootKdf), and the config backup carries them. For the browser specs, app:test:remember-me mints a REMEMBERME cookie through the firewall's own handler (test environment only) and app:test:seed-user --without-password writes the account single sign-on would. AdminUserManagementTest's "no password field" now allows the unlock dialog's own box, which is for the admin's secret. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Give single sign-on accounts encryption, and unlock the keyring only when needed Phase 6e of #28, as proposed in the issue and decided there: an encryption passphrase is the keyring's root for an account without a password, and one lazy unlock dialog serves every session the password form did not open. KeyringRoot what the keyring hangs off: the password, or an encryption passphrase with its own salt and count (keyring_root, passphrase_salt, passphrase_iterations). An account without a usable password is on the passphrase from the moment it sets encryption up, and stays there if it is later given a password. Keyring writes the root with the first user key; takes a passphrase only on that root; changePassphrase() re-wraps all or nothing; lockAll() and applyPasswordChange() leave a passphrase keyring alone, so app:user:password, the forced change and a Settings password change no longer reach it. UnlockController POST /keyring/unlock/session keeps the browser's half without a proof, on the passphrase only: the half opens nothing on its own. The password keeps its checked, throttled POST /keyring/unlock. keyring.js setUpPassphrase(), changePassphrase(), a passphrase unlock that is right when a user key opens, and requireOpen(): one shared dialog, asked when an encrypted message, a sealed or sealing draft, Settings → Encryption or pairing needs the keys. Nothing is asked at sign-in; the background decrypter waits for crypto:keyring. Settings "Set up encryption" and "Encryption passphrase" cards; the inline unlock form becomes the dialog. Keyring/get and GET /keyring name the root and its KDF parameters (root, rootKdf), and the config backup carries them. For the browser specs, app:test:remember-me mints a REMEMBERME cookie through the firewall's own handler (test environment only) and app:test:seed-user --without-password writes the account single sign-on would. AdminUserManagementTest's "no password field" now allows the unlock dialog's own box, which is for the admin's secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Look contacts' keys up on public key servers when their domain has none Phase 6f of #28. plMail found a recipient's key only on their own domain (WKD), in their mail, or by import; the many people who publish theirs on keys.openpgp.org or the Mailvelope key server and have no WKD were never found, and mail to them went out unencrypted. Every lookup now asks those servers after WKD has nothing usable. KeyFetcher the request rules taken out of WkdLookup rather than copied: HTTPS only, no redirects, the host resolved, refused if private and pinned, NoPrivateNetworkHttpClient, the 4/8 s limits, 256 KiB while streaming, the cache (a day found, an hour not) and the one wkd_lookup budget. WkdLookup now builds its URLs and goes through it. KeyServerLookup the servers APP_KEY_SERVERS lists, in that order, each speaking its own HTTPS API through a KeyServerInterface (OpenPgpOrgKeyServer: VKS by-email; MailvelopeKeyServer: JSON publicKeyArmored). The first key with a user ID for exactly the address, not revoked, not expired, wins. Unknown host names are dropped with a warning; an empty list, or the person's switch off, requests nothing at all. ContactKeys lookUp() asks WKD first and the key servers only after it, and records what it finds through the same trust on first use: current and unpinned when new, changed beside a key plMail already holds, and the sender's signatures checked again. It answers a ContactKeyLookup saying where the key came from. ContactKeyOrigin a keyserver case, with the server's host in contact_key.origin_host (migration Version20261019090000). Callers the lookup form, the banner's "Look up the key", /contact-keys/recipients and ContactKey/lookup all say where a key came from; the list shows "From the key server keys.openpgp.org"; RecipientKeys and ContactKey/get carry origin and originHost. Settings "Also look on public key servers" in the contacts' keys frame, on by default, naming the servers and what a lookup tells them (crypto.key_servers, in the config backup). APP_KEY_SERVERS defaults to both servers. It is read as env(APP_KEY_SERVERS) rather than through `default:`, which treats an empty variable as an absent one and would have made "none" mean "both". The shared limit goes from 30 to 60 in ten minutes, because every place asked counts once and an address nobody has a key for now costs three. In the test environment KeyServerLookup fetches through KeyServerStub, which answers both APIs from files app:test:seed-key-server plants and resolves no DNS; nothing in the suite reaches a real key server. contact-keys.spec.ts's "Public key" locator is exact now, since the new switch's label contains the words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…o keys.openpgp.org (#199) * Give single sign-on accounts encryption, and unlock the keyring only when needed Phase 6e of #28, as proposed in the issue and decided there: an encryption passphrase is the keyring's root for an account without a password, and one lazy unlock dialog serves every session the password form did not open. KeyringRoot what the keyring hangs off: the password, or an encryption passphrase with its own salt and count (keyring_root, passphrase_salt, passphrase_iterations). An account without a usable password is on the passphrase from the moment it sets encryption up, and stays there if it is later given a password. Keyring writes the root with the first user key; takes a passphrase only on that root; changePassphrase() re-wraps all or nothing; lockAll() and applyPasswordChange() leave a passphrase keyring alone, so app:user:password, the forced change and a Settings password change no longer reach it. UnlockController POST /keyring/unlock/session keeps the browser's half without a proof, on the passphrase only: the half opens nothing on its own. The password keeps its checked, throttled POST /keyring/unlock. keyring.js setUpPassphrase(), changePassphrase(), a passphrase unlock that is right when a user key opens, and requireOpen(): one shared dialog, asked when an encrypted message, a sealed or sealing draft, Settings → Encryption or pairing needs the keys. Nothing is asked at sign-in; the background decrypter waits for crypto:keyring. Settings "Set up encryption" and "Encryption passphrase" cards; the inline unlock form becomes the dialog. Keyring/get and GET /keyring name the root and its KDF parameters (root, rootKdf), and the config backup carries them. For the browser specs, app:test:remember-me mints a REMEMBERME cookie through the firewall's own handler (test environment only) and app:test:seed-user --without-password writes the account single sign-on would. AdminUserManagementTest's "no password field" now allows the unlock dialog's own box, which is for the admin's secret. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Look contacts' keys up on public key servers when their domain has none Phase 6f of #28. plMail found a recipient's key only on their own domain (WKD), in their mail, or by import; the many people who publish theirs on keys.openpgp.org or the Mailvelope key server and have no WKD were never found, and mail to them went out unencrypted. Every lookup now asks those servers after WKD has nothing usable. KeyFetcher the request rules taken out of WkdLookup rather than copied: HTTPS only, no redirects, the host resolved, refused if private and pinned, NoPrivateNetworkHttpClient, the 4/8 s limits, 256 KiB while streaming, the cache (a day found, an hour not) and the one wkd_lookup budget. WkdLookup now builds its URLs and goes through it. KeyServerLookup the servers APP_KEY_SERVERS lists, in that order, each speaking its own HTTPS API through a KeyServerInterface (OpenPgpOrgKeyServer: VKS by-email; MailvelopeKeyServer: JSON publicKeyArmored). The first key with a user ID for exactly the address, not revoked, not expired, wins. Unknown host names are dropped with a warning; an empty list, or the person's switch off, requests nothing at all. ContactKeys lookUp() asks WKD first and the key servers only after it, and records what it finds through the same trust on first use: current and unpinned when new, changed beside a key plMail already holds, and the sender's signatures checked again. It answers a ContactKeyLookup saying where the key came from. ContactKeyOrigin a keyserver case, with the server's host in contact_key.origin_host (migration Version20261019090000). Callers the lookup form, the banner's "Look up the key", /contact-keys/recipients and ContactKey/lookup all say where a key came from; the list shows "From the key server keys.openpgp.org"; RecipientKeys and ContactKey/get carry origin and originHost. Settings "Also look on public key servers" in the contacts' keys frame, on by default, naming the servers and what a lookup tells them (crypto.key_servers, in the config backup). APP_KEY_SERVERS defaults to both servers. It is read as env(APP_KEY_SERVERS) rather than through `default:`, which treats an empty variable as an absent one and would have made "none" mean "both". The shared limit goes from 30 to 60 in ten minutes, because every place asked counts once and an address nobody has a key for now costs three. In the test environment KeyServerLookup fetches through KeyServerStub, which answers both APIs from files app:test:seed-key-server plants and resolves no DNS; nothing in the suite reaches a real key server. contact-keys.spec.ts's "Public key" locator is exact now, since the new switch's label contains the words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Publish your own key to keys.openpgp.org, and answer the feedback from testing Phase 6g of end-to-end encryption (#28), the open points from testing the milestone-v0.4.0 build: - Settings cards carry their own bottom margin (settings/_card), so no section can render two cards flush again; the card sweep now measures all 26 sections, held to SettingsController::SECTIONS, on desktop and phone. The templates tree inset is fixed with it. - The encryption cards' fields, the unlock dialog and the decrypted-mail checkboxes draw from _partials/_field_classes, which the form theme uses too. - Check from outside asks the WKD advanced and direct method each on its own, past the cache, and shows both verdicts: current key, another key, nothing, or not reachable (KeyFetcher::probe()). - Every key lookup asks the key servers after WKD; verified end to end for test@pluetzner.de. The composer now looks up a recipient it first checked while the keyring was shut. In the test environment WKD goes through the stand-in too, so no test asks real DNS. - The contact key import's file upload is tested. - An encrypted draft's row lines up: the wide row no longer draws an empty labels line. - Publish an address key to keys.openpgp.org (VKS upload and request-verify) after a confirmation, with the state per address kept (key_publication), refreshed, offered again after Renew, and the revocation uploaded when a published key is marked compromised. - An attached public key that is the sender's own is not listed as an attachment and does not count for hasAttachments; the crypto banner says it, with its fingerprint, and offers to keep it. In encrypted mail the browser does the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main's v0.3.6 and the twelve commits since, so #182 merges cleanly again. The one conflict was the top of the CHANGELOG: the #28 entries stay first under Unreleased, then main's new unreleased ones, and what main released stays under v0.3.6. No migration names collide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e list (#209) * Show a reply to encrypted mail as being sent, then open it once it has gone Reported from the milestone-v0.4.0 build: a reply to encrypted mail did not appear in the conversation as sent and never opened there. The conversation showed it as a sealed draft — "only a browser with your keys can read it" — and nothing ever said otherwise until the page was reloaded. From the popped-out compose window it did not appear at all. Two causes, one per path: _message_body A message the browser built is sealed or blanked while the send job holds it, and its raw source is the bytes that go out, so there is nothing to read until it has gone. It rendered as a sealed draft (or as an empty plain message, for a reply sent before an autosave sealed it). It now says it is being sent and asks the keyring for nothing, keyed on outgoingSeal, which is cleared in the same flush that stores the raw source. _send_outcome Nothing re-rendered the conversation once the worker had sent it. The worker's outcome stream now replaces exactly that message — `targets` with :has([data-sending-sealed]) — with a frame that loads it in the person's own request (ComposeController::settledRow()), where the keyring opens it like any encrypted mail of theirs. A refused send comes back as its draft. popOut() The window moved into the dock lost its thread, and _sent.stream only touches the conversation a window says it belongs to. It keeps it now. The sidebar's Drafts count falls once the send job has run, in both send-feedback settings; the new spec holds it. The suite had no way to finish a send: no worker takes the send queue. app:test:finish-send runs the real SendMessageHandler for one message and dispatches the worker's handled event, against RecordingMailSender, a test-only provider that claims only the accounts the suite seeds and writes the mail to var/test-sent. app:test:seed-mail --sendable gives the seeded account an address to send from, and app:test:seed-crypto-mail --from a sender. encryption-reply-sent.spec.ts inline and dock, optimistic and hold: being sent and not locked, then decrypted in place; Drafts back to 0. SealedSendTest the sending note, the outcome stream's target, and the settled row after the send job. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Send a signature's blockquote as flush as the editor shows it Reported from the milestone-v0.4.0 build: a signature whose name sits in a <blockquote> is flush left in the editor and indented in the sent mail — in the Sent copy's reading pane and in whatever the recipient reads it with. The editor's stylesheet (Tailwind's preflight) zeroes a blockquote's margin; nothing styles the mail that goes out, so every reader falls back to the browser's 40px either side. And the blockquote was taken for quoted text on the way out: SignatureProvider block() writes margin:0 on every blockquote of the signature that does not state a margin shorthand of its own — the only thing that travels with the mail. Here rather than at storage, so signatures already saved are put right too. DraftPersister plainTextBody() cut at the first <blockquote> for drafts from before data-quoted, signature included: the text/plain part ended before the signature. The signature's blockquotes become plain blocks before the cut. encryption textOf() quoted every blockquote with "> " in an encrypted message's text part; not the signature's. compose _typedLength() no longer drops the signature's blockquote with the quotes; it goes with the signature, while that is untouched. compose-signature-sent.spec.ts the computed margin in the editor against the Sent copy's frame, plain and encrypted; the HTML and text that went out; a reply with a quote. SignatureProviderTest, DraftPersisterTest Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Put a lock in front of an encrypted message's subject, not a placeholder after it Asked for while testing the milestone-v0.4.0 build: an encrypted message's row read its subject followed by the placeholder preview "Encrypted message". The row now wears a lock in front of the subject, in the row's own icon set and with the words for a screen reader, and the preview is left to what the message says when the server holds it. MessageSnippet no placeholder: no body is an empty preview. It needs no translator any more. ThreadRow `encrypted`, the newest message's, like the snippet, from the crypto column the snippet query already reads; a bare Message row asks the message. _thread_row the lock inside the subject's own span, so it truncates with it and moves nothing else: phase 6g's alignment is measured against that span, and holds. _thread_message the collapsed message's preview line drops the placeholder too; the lock beside the sender stays. Every list draws its rows from the one partial — inbox, folders, labels, search, Drafts (sealed drafts), conversations, the phone row. A message whose decrypted copy is kept here keeps the lock and gets its real preview. `message.crypto.snippet` is gone from all four catalogues; nothing uses it. encryption-list-lock.spec.ts inbox, search and 390px: the lock first in the subject, named, no placeholder; alignment at 1280, 1700 and 2200px. encryption-sending, -reading and -decrypted specs follow the row. MessageSnippetTest, SealedSendTest (the Drafts row). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The weekly app:prune:blobs walked the whole of var/uploads, var/attachments and var/raw, and deleted every file older than a week that no row of its three columns named. Other storages keep files beside those on the same volumes — profile pictures in uploads/avatars/, signature images in uploads/signatures/, backgrounds in uploads/backgrounds/, and since #189 sealed messages in raw/sealed/ — and none of them has a row there. Every Sunday at 05:00 they were taken for orphans, deleted, and their emptied directories removed. Seen on a test install as a missing profile picture and signature image. The sweep now walks only the numbered directories directly under each root: the per-account ones UploadStorage, AttachmentStorageHelper and RawMessageStorage write. A directory that is not a number belongs to somebody else, including one added later, and is left alone; so is the emptied-directory cleanup. PruneBlobsCommandTest runs the command against a project directory of its own: files in avatars, signatures, backgrounds and raw/sealed survive, orphans in account directories go, and a referenced or recent file stays. Against the old code it fails on the first of them. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
DanversKara
added a commit
to DanversKara/pl_mail_bearermail
that referenced
this pull request
Oct 19, 2026
* What's new opens after an update, with a way to send feedback (karatektus#166) * What's new opens after an update, with a way to send feedback The release notes from CHANGELOG.md, read from the image, in a dialog that opens by itself the first time somebody uses plMail after an update and never on a fresh install. A ? in the top bar opens it again, with a dot while something is unseen; "Don't show this after updates" silences it, and Settings -> General -> What's new undoes that. Highlights are marked in the changelog with an HTML comment and may carry a screenshot taken by tests/e2e/whats-new-screenshots.spec.ts. Send feedback is a mail from one of the person's own accounts to APP_FEEDBACK_TO (feedback@pluetzner.de by default), with an optional rating and a screenshot of the page behind the dialog. Existing users are recorded as having seen 0.3.1 by a migration; anyone created later starts on the release running at their first request. Closes karatektus#165. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Plain changelog headlines from 0.3.2 on, and highlights with screenshots Every bold headline users see under "New to you" now says what changed in a few words. Each release gets a handful of highlights, eight of them with a picture cropped to the card or pane they are about, taken by tests/e2e/whats-new-screenshots.spec.ts. Relative links in the notes keep their words and lose the target, and the parsed changelog's cache key carries a format version so a new image never serves the previous one's parse. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep highlight pictures for ten releases, and none that nothing names Every file in public/whats-new ships in every image. ChangelogReaderTest now fails on a picture no highlight names, and on one whose release is more than ten back; the release pull request that crosses the line deletes it and leaves the entry a highlight without a picture. Documented in CONTRIBUTING.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Credit karatektus#166 in the changelog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Match the Back button by its whole name in the back-navigation specs Playwright matches a string name as a substring, so "Back" also found the new "Help and feedback" button in the top bar, which comes first, and the specs opened its menu instead of going back to the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * What's new: the ? menu above the calendar, main builds, and feedback with a screenshot (karatektus#167) * The ? menu draws above the calendar, and a main build announces its newest release The menu was an ordinary dropdown inside the header's z-20 stacking context, so the calendar's day headings painted over it. It now opens in the top layer through ui--user-menu, like the account menu, and closes when an item is chosen. A build that is not a tag (main, sha-…, a checkout) counted as no release, so installs that follow main never saw the dialog. It now counts as the newest tagged release in its own changelog; Unreleased is still never shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Feedback with a screenshot stays in the dialog, whatever comes back Sending with a screenshot closed the dialog without the sent screen. The form is now posted with fetch() and its answer drawn into the dialog; anything that is not the dialog (a proxy refusing a large body, a server error) is said in the form with its status, and the dialog stays. The screenshot is a JPEG at most 1600 px wide instead of a full-size PNG, a fraction of the size, so a reverse proxy's default body limit is far away. The From is the first field of the account that is an address; an account with none is refused in the form instead of throwing at send time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Find the open conversation's toolbar by type in the keyboard-shortcuts spec The spec looked for the conversation by id alone, and the messages inside it carry the same attribute with their own ids. On CI a message id equalled the conversation's, so two elements matched and the strict locator failed on the first try and the retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * What's new: a screenshot grows to fill the dialog instead of opening a tab (karatektus#181) Clicking a highlight's screenshot opened the PNG in a new tab. It now grows out of its thumbnail until it fills the What's new dialog, with an X; the X, Escape or Back shrink it back, and What's new stays open. Back goes through a history entry of its own (assets/history_layer.js), imported first in app.js so its popstate listener runs before Turbo's, which would otherwise restore the page and close the dialog. Closes karatektus#179 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Store a UIDVALIDITY above 2147483647 (karatektus#174) mailbox.uid_validity becomes a BIGINT, so IMAP folders whose UIDVALIDITY is above the signed INT range sync again. Reversible migration; the mapping stays ?int (DBAL 4 hydrates a bigint as an int). Closes karatektus#171 Co-Authored-By: Philippe Delodder <philippe@delodder.be> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * A conversation no longer splits when its replies are synced first (karatektus#176) Threading looked only from a reply to the message it answers, so replies synced before that message (INBOX before Sent Items) opened threads of their own and stayed there. MessageThreader now also looks the other way: after a message gets its thread, mail that already names its Message-ID in In-Reply-To or References is folded in by the new ThreadMerger. The older thread survives, keeping its JMAP id, summary, insights, star, snooze, labels and pinned tab. A GIN expression index makes the reverse lookup an index probe. Review follow-up: moved mail is added to the surviving thread's collection so a label rule later in the same batch keeps its labels, plus tests for that, bracketed legacy references and provider-keyed survivors. Closes karatektus#172 Co-authored-by: Philippe Delodder <philippe@delodder.be> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let a deployment pull request through without an issue of its own (karatektus#183) A milestone branch on its way to main (milestone/v0.4.0, karatektus#182) collects work that was agreed and reviewed piece by piece, each piece closing its own issue. It has no issue of its own to name, and listing the pieces' issues only works once each has landed, so the required linked-issue check refused it. `deployment` joins `bug`, `chore` and `dependencies` as a label that needs none. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * A label a rule applies to new mail stays on its conversation (karatektus#185) Rules run after threading and before the batch's flush, and ThreadLabelSynchronizer took the union over $thread->messages, which did not yet hold the message the threader had just attached. A rule's label was taken straight off the conversation again. The sync now counts the messages that point at the thread in memory (collection, identity map and pending inserts). Closes karatektus#184 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Release v0.3.6 (karatektus#194) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Leave a message another Gmail account holds with that account (karatektus#196) * Leave a message another Gmail account holds with that account With several Gmail accounts connected, mail forwarded from one to another reaches the second still Delivered-To the first and is attributed there. Both merges in SyncGmailMessageBatchHandler then found the first account's own row by Message-ID and wrote the second mailbox's Gmail id over its own: they were written for a sibling that syncs over IMAP, and the sibling map is every active account. The first account's sync then stored the message a second time, and a later merge by Message-ID picked the other of the two rows and gave it an id its twin still held: a duplicate key on uniq_message_gmail_id_account, the whole batch failed, and every retry fetched it again until Gmail answered 429 (discussion karatektus#168). A row is now looked up by its Gmail id before either merge. A merge only gives an id to a row of a non-Gmail account that has none yet, or has this carrier's; anything else is recorded as held elsewhere, so the import counts it and stops asking for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Link the changelog entry to its PR Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * What's new: one X on a full-size screenshot (karatektus#197) The picture brought its own X into the corner of the dialog body, just below the modal's X. It is gone; while a picture is up, the modal's X is relabelled "Close the screenshot" and its first press puts the picture back, the second closes What's new, as Escape already did. Closes karatektus#187 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Calendar: an event that ends when it starts stays on its day (karatektus#198) An end equal to the start (or a zero DURATION) is a stated instant and now gets one minute instead of the nominal hour, so a 23:59 reminder no longer runs past midnight and shows again on the next day. The nominal hour for an event with no end at all now stops at the midnight of the day it starts on, in DTSTART's own zone. Closes karatektus#170 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Outlook's <![endif]--> no longer leaks into a message as text (karatektus#178) Closes karatektus#175. Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com> * Drop a header block the server repeats at the top of the body (karatektus#177) Closes karatektus#173. Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com> * Compose: allow a send addressed to Bcc only (karatektus#200) A send with nobody in To was refused with "Add at least one recipient before sending", even with addresses in Cc or Bcc. The window's guard counted To, Cc and Bcc; the server's Count(min: 1) rule in the send group counted To only. The rule is now a Callback on the To field that counts all three, read off the bound form since the fields are unmapped. A send with no recipient at all is still refused; a draft may still be saved with none. Closes karatektus#201. Co-Authored-By: jo2904 <josephdegaullier@gmx.fr> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bump the minor-and-patch group with 13 updates (karatektus#193) Bumps the minor-and-patch group with 13 updates: | Package | From | To | | --- | --- | --- | | [doctrine/orm](https://github.com/doctrine/orm) | `3.7.4` | `3.7.5` | | [symfony/asset-mapper](https://github.com/symfony/asset-mapper) | `8.1.8` | `8.1.9` | | [symfony/console](https://github.com/symfony/console) | `8.1.8` | `8.1.9` | | [symfony/framework-bundle](https://github.com/symfony/framework-bundle) | `8.1.8` | `8.1.9` | | [symfony/http-client](https://github.com/symfony/http-client) | `8.1.8` | `8.1.9` | | [symfony/mercure-bundle](https://github.com/symfony/mercure-bundle) | `0.5.0` | `0.6.1` | | [symfony/process](https://github.com/symfony/process) | `8.1.7` | `8.1.9` | | [symfony/stimulus-bundle](https://github.com/symfony/stimulus-bundle) | `3.5.1` | `3.6.0` | | [symfony/string](https://github.com/symfony/string) | `8.1.7` | `8.1.9` | | [symfony/twig-bundle](https://github.com/symfony/twig-bundle) | `8.1.7` | `8.1.9` | | [symfony/ux-autocomplete](https://github.com/symfony/ux-autocomplete) | `3.5.1` | `3.6.0` | | [symfony/ux-turbo](https://github.com/symfony/ux-turbo) | `3.5.1` | `3.6.0` | | [symfony/web-profiler-bundle](https://github.com/symfony/web-profiler-bundle) | `8.1.8` | `8.1.9` | Updates `doctrine/orm` from 3.7.4 to 3.7.5 - [Release notes](https://github.com/doctrine/orm/releases) - [Commits](doctrine/orm@3.7.4...3.7.5) Updates `symfony/asset-mapper` from 8.1.8 to 8.1.9 - [Release notes](https://github.com/symfony/asset-mapper/releases) - [Changelog](https://github.com/symfony/asset-mapper/blob/8.2/CHANGELOG.md) - [Commits](symfony/asset-mapper@v8.1.8...v8.1.9) Updates `symfony/console` from 8.1.8 to 8.1.9 - [Release notes](https://github.com/symfony/console/releases) - [Changelog](https://github.com/symfony/console/blob/8.2/CHANGELOG.md) - [Commits](symfony/console@v8.1.8...v8.1.9) Updates `symfony/framework-bundle` from 8.1.8 to 8.1.9 - [Release notes](https://github.com/symfony/framework-bundle/releases) - [Changelog](https://github.com/symfony/framework-bundle/blob/8.2/CHANGELOG.md) - [Commits](symfony/framework-bundle@v8.1.8...v8.1.9) Updates `symfony/http-client` from 8.1.8 to 8.1.9 - [Release notes](https://github.com/symfony/http-client/releases) - [Changelog](https://github.com/symfony/http-client/blob/8.2/CHANGELOG.md) - [Commits](symfony/http-client@v8.1.8...v8.1.9) Updates `symfony/mercure-bundle` from 0.5.0 to 0.6.1 - [Release notes](https://github.com/symfony/mercure-bundle/releases) - [Changelog](https://github.com/symfony/mercure-bundle/blob/main/CHANGELOG.md) - [Commits](symfony/mercure-bundle@v0.5.0...v0.6.1) Updates `symfony/process` from 8.1.7 to 8.1.9 - [Release notes](https://github.com/symfony/process/releases) - [Changelog](https://github.com/symfony/process/blob/8.2/CHANGELOG.md) - [Commits](symfony/process@v8.1.7...v8.1.9) Updates `symfony/stimulus-bundle` from 3.5.1 to 3.6.0 - [Release notes](https://github.com/symfony/stimulus-bundle/releases) - [Changelog](https://github.com/symfony/stimulus-bundle/blob/3.x/CHANGELOG.md) - [Commits](symfony/stimulus-bundle@v3.5.1...v3.6.0) Updates `symfony/string` from 8.1.7 to 8.1.9 - [Release notes](https://github.com/symfony/string/releases) - [Changelog](https://github.com/symfony/string/blob/8.2/CHANGELOG.md) - [Commits](symfony/string@v8.1.7...v8.1.9) Updates `symfony/twig-bundle` from 8.1.7 to 8.1.9 - [Release notes](https://github.com/symfony/twig-bundle/releases) - [Changelog](https://github.com/symfony/twig-bundle/blob/8.2/CHANGELOG.md) - [Commits](symfony/twig-bundle@v8.1.7...v8.1.9) Updates `symfony/ux-autocomplete` from 3.5.1 to 3.6.0 - [Release notes](https://github.com/symfony/ux-autocomplete/releases) - [Changelog](https://github.com/symfony/ux-autocomplete/blob/3.x/CHANGELOG.md) - [Commits](symfony/ux-autocomplete@v3.5.1...v3.6.0) Updates `symfony/ux-turbo` from 3.5.1 to 3.6.0 - [Release notes](https://github.com/symfony/ux-turbo/releases) - [Changelog](https://github.com/symfony/ux-turbo/blob/3.x/CHANGELOG.md) - [Commits](symfony/ux-turbo@v3.5.1...v3.6.0) Updates `symfony/web-profiler-bundle` from 8.1.8 to 8.1.9 - [Release notes](https://github.com/symfony/web-profiler-bundle/releases) - [Changelog](https://github.com/symfony/web-profiler-bundle/blob/8.2/CHANGELOG.md) - [Commits](symfony/web-profiler-bundle@v8.1.8...v8.1.9) --- updated-dependencies: - dependency-name: doctrine/orm dependency-version: 3.7.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: symfony/asset-mapper dependency-version: 8.1.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: symfony/console dependency-version: 8.1.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: symfony/framework-bundle dependency-version: 8.1.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: symfony/http-client dependency-version: 8.1.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: symfony/mercure-bundle dependency-version: 0.6.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: symfony/process dependency-version: 8.1.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: symfony/stimulus-bundle dependency-version: 3.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: symfony/string dependency-version: 8.1.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: symfony/twig-bundle dependency-version: 8.1.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: symfony/ux-autocomplete dependency-version: 3.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: symfony/ux-turbo dependency-version: 3.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: symfony/web-profiler-bundle dependency-version: 8.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump actions/labeler from 5 to 7 (karatektus#192) Bumps the actions group with 1 update: [actions/labeler](https://github.com/actions/labeler). Updates `actions/labeler` from 5 to 7 - [Release notes](https://github.com/actions/labeler/releases) - [Commits](actions/labeler@v5...v7) --- updated-dependencies: - dependency-name: actions/labeler dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump @types/node from 26.6.4 to 26.6.5 (karatektus#191) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 26.6.4 to 26.6.5. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.6.5 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump @playwright/test from 1.63.0 to 1.64.0 in the playwright group across 1 directory (karatektus#190) * Bump @playwright/test Bumps the playwright group with 1 update in the / directory: [@playwright/test](https://github.com/microsoft/playwright). Updates `@playwright/test` from 1.63.0 to 1.64.0 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](microsoft/playwright@v1.63.0...v1.64.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-version: 1.64.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: playwright ... Signed-off-by: dependabot[bot] <support@github.com> * E2E: abort the stubbed reconnect navigation instead of answering 204 Chromium 156 (Playwright 1.64) reports a navigation that ends in a 204 as a finished request that commits nothing, where Chromium 155 reported it as failed (net::ERR_ABORTED). Playwright's click waits for the navigation it started to commit or fail, so with 1.64 the click never returned and the test timed out reading "" off a closing page. An abort is reported as a failure by every Chromium and keeps the document just the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * E2E: touch beside the open drawer, after it has stopped sliding The drawer-open pull touched the middle of the rows (x~196) right after `inert` came off, at the start of the drawer's 200ms slide. Once the 256px drawer has arrived, that point is the drawer, not the backdrop, so the hit-test only passed while it beat the transition; slower CI runners lost that race on every branch from 2026-10-16. Wait for the drawer to rest, then touch the backdrop strip to its right, and pull at the same point once it is closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Paul Lützner <karatektus@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Top bar on phones: Contacts goes to the drawer (it is there already), making room for upstream's ? menu diag: report failing PHPUnit tests (temporary) * Tests: What's new feedback goes to APP_FEEDBACK_TO (set in .env.test); drop PHPUnit diag --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Paul Lützner <karatektus@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com> Co-authored-by: Philippe Delodder <philippe@delodder.be> Co-authored-by: Sven <sj@pmdevelopment.de> Co-authored-by: jo2904 <59180897+jo2904@users.noreply.github.com> Co-authored-by: jo2904 <josephdegaullier@gmx.fr> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Brings in #190, #191, #224, #210, #220, #225, #218, #217 and #228. Conflicts: - PruneBlobsCommand and its test: #228 (main) and #215 (milestone) both fixed #214. Kept #228, which also checks each store's file layout and covers everything #215 did, raw/sealed included. - AttachmentController: a sealed part stays application/octet-stream; otherwise the octet-stream sniffing from #220 applies. - LoginFormAuthenticator: the single-sign-on-only refusal (#210) runs before the auth_hash/password handling of the KDF login. - CHANGELOG, CONTRIBUTING, docs: both sides kept, one #214 entry, DE translated-from hashes recomputed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings #227 (In-Reply-To and References on a reply) into the milestone, which fixes #281 there. Conflicts: MessageSendService (main's send retry from #242 next to the sealed send path; threading headers are written on the outer headers, so sealed mail carries them too) and the prune-blobs row in the architecture docs. SendRetryTest builds the service with the milestone's extra dependencies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the v0.3.8 release notes into the milestone. Its push rebuilds milestone-v0.4.0 on the refreshed FrankenPHP base, PHP 8.4.27 (CVE-2026-109618/9, #280); the last build ran on 8.4.26. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merging main for v0.3.7 (3637b2c) took main's rename of "## Unreleased" to "## v0.3.7" and with it filed every entry still waiting for v0.4.0 (encryption phases 1-6g, the password stretching, the signature indent fix) under a release that never had them. They are under "## Unreleased" again; the released sections now read exactly as on main. The milestone's own wordings of #213 and #214, which main rewrote for the release, are dropped in favour of main's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The switch posts as it is clicked and redraws the contact-keys frame when it answers; the radio shows checked at once. "Asks no key server once the person switches them off" looked a key up straight after, so the two answers raced for the frame, and when the switch's came last it wiped the lookup's notice (E2E on 0805208, both attempts). "Offers no publishing once the person switches key servers off" reloads straight after, which can cancel the post. Both now wait for the lookup hint that names no key server, which only the switch's answer draws. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Copy the build in the Administration header with one click (#313) The version chip beside the Administration title is now a button with a clipboard icon. A click copies the label and the commit with one space between them (milestone-v0.4.0 0805208) through the ui--clipboard controller, so the plain-HTTP fallback applies too, and the icon turns into a tick to confirm it. The text is trimmed to cap height and baseline, so it lines up with the icon instead of riding high in the monospace font's em box. Closes #313 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say in the handbook that the version chip copies itself (#313) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts, resolved: - CHANGELOG.md: the milestone's "## Unreleased" stays on top, main's "## v0.3.9" follows unchanged; no entry is in both. - docs/de/*: translated-from headers set to the sha1 of the merged EN files. - Attachment preview (#295): a sealed part still gets no viewer, now for images and videos too (preview, video route, attachment_preview_kind). - MessagePurger: main's version, which hands each message to MessageEraser (#300); the sealed copies the milestone deleted there are deleted by MessageEraser, which keeps both SealedMessageStorage and main's logger. MessageEraserTest passes the sealed storage. - Snippet bodies: the query reads category (main) and crypto (milestone); ThreadRows passes the category on and keeps the lock. - SessionBuilder: keysCapabilities() moved out from between the contacts docblock and its method; contactsCapabilities() takes main's $base. - SettingsController::SECTIONS and settings-card-insets.spec.ts: encryption plus main's swipe-actions and contacts, in the controller's order. - Imports in ComposeController, User and MessageSnippetTest: both sides. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add Simplified Chinese interface translations * Follow existing localization test conventions * Complete Simplified Chinese where French already reached The pieces the French locale wired up that Chinese had not: - Emoji search in Chinese: assets/emoji/zh/data.json from emoji-picker-element-data@1.8.0 (cldr), the release the vendored de and fr files match byte for byte; the compose window picks it for zh. - The contact assistant writes its reasons in Simplified Chinese and reads numbers without a country code as Chinese ones. - README lists the language; SheetEntryTest and SwipeActionTest check it. CatalogueParityTest now also checks that every translation, in every domain and language, keeps its English message's placeholders. The catalogue test this PR first had did that for Chinese only and was dropped; all four languages pass. messages.zh_CN.yaml follows the English key order (content unchanged, checked with Symfony's loader), and mailed-by reads 发送域 beside 签名域. Changelog entry credits the author and is a highlight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Paul Lützner <karatektus@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The grids format day keys ("2026-11-06") with Twig's |date. Without a
zone argument it reads the string as midnight UTC and converts it into
the reader's zone, which west of UTC is the evening before. Day and
week headings read "FRI 5" under Nov 6 (the weekday comes from
intl_date(..., false) and was right), and the month view numbered every
cell a day early, shaded Friday and Saturday as the weekend and dimmed
the 1st as last month's.
Every |date of a day key now passes false, keeping the zone the string
was parsed in. The template comment that said Twig already did this is
corrected. CalendarDayKeysWestOfUtcTest renders the day, week and month
views for a Los Angeles reader; all three fail without the fix.
Closes #325
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The command wrote the message and its thread without recording a JMAP change, so no state token moved: an open EventSource got no StateChange and Email/changes found nothing. On the phone that looked like an app that does not notice new mail (karatektus/pl_mail_android#43). It now records the delivery through MailChangeRecorder as ingest does: the Email as created, its thread as touched, and an arrival in the Inbox. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…gs without reloading (#346) Settings → General → Sidebar chooses which system folders sit behind More (default: Snoozed, Archive, Spam and Trash; the Inbox stays on top). Settings forms now save through Turbo and morph the page in place instead of reloading it; the language and the 12/24-hour clock still reload. Closes #151. Supersedes #68. Co-Authored-By: mani <15911342+Flouss@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #48 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Add POP3 accounts A protocol choice on the account form, a POP3 connection written over a socket, and a syncer that hands the RETR bytes to the same parser the IMAP path uses. Squashed from #291: the original commit and its five fixes (the Inbox binding, the label repairs, the import marker). * POP3: ledger table, per-session numbering, and switching protocol in place The review of #291 found three ways the first version lost or duplicated mail, and this reworks the syncer around them: - A UIDL is recorded as stored only once its row is committed, one message at a time. Failures are recorded as attempts, retried, given up on after three, and never deleted from the server. - Every session asks for its own numbering, and the delete marks are made only in the last session, for UIDLs recorded as stored. - The ledger is a pop3_uidl table pruned to what the server lists, instead of a 5,000-entry list in the settings bag that re-fetched the oldest mail of any larger mailbox on every sync. Mail comes in newest first. A server's -ERR on one message is that message's failure; a dropped connection ends the sync. The label repairs for the contributor's own deployment are gone. Switching an existing account between IMAP and POP3 keeps its mail: IncomingProtocolSwitch drops every server address the way a UIDVALIDITY change does, and the next sync recognises the rows by Message-ID (a TOP-based adoption pass for POP3, SentCopyReconciler::claim() for IMAP). ImapConnectionFactory refuses POP3 accounts, IDLE and the importer skip them. Also: translated section heading on the form, presets switch back to IMAP, zh_CN strings and sorted keys, POP3 ports in the connection test's hints, the forked socket test reports its verdict instead of exiting into PHPUnit, a handbook section and a changelog fragment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * POP3: the protocol as a two-segment toggle, and a delete mode that saves The protocol choice wears the settings pages' two-segment control instead of two stacked radios each starred as required. The port moves between 993 and 995 with it while it still holds a default, and the host hint follows. The delete mode never saved: the field returns the enum case and the listener passed it to fromSetting(), which reads anything but a string as Keep. Found by the new browser spec, which saves a POP3 account with Delete, reopens it, and switches it to IMAP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fix: widen calendar_event.remote_id, which real CalDAV hrefs overflow Connecting a Baïkal calendar failed the whole pull with `SQLSTATE[22001]: value too long for type character varying(255)`, and Doctrine reported it as a generic query exception with nothing saying which column or which resource was at fault. The column holds a mirrored event's absolute href, so its length is the server's to decide. Baïkal names resources after the event, and Google exported events produce 258-character hrefs — six of 765 in the calendar that found this, one of which was enough to abort the entire sync. TEXT rather than a larger varchar: the value is opaque, nothing indexes it, and another number would only move the cliff. down() refuses to narrow while longer rows exist rather than truncating, because a shortened href points at a resource that does not exist. Co-Authored-By: Claude Code <noreply@anthropic.com> * Test: the remote_id widening, against the live catalogue and a real href Checks the column is unbounded and that a 258-character Baikal href round-trips. The fixture builds its own user and calendar rows inside the test transaction: an earlier version borrowed existing rows and skipped when the database was empty, which asserted nothing on the test stack. Co-Authored-By: Claude Code <noreply@anthropic.com> * remote_id: the index does exist, and the migration runs after the milestone's idx_calendar_event_calendar_remote covers the column; the comments said nothing indexed it. Re-dated so it sorts after the migrations already on milestone/v0.4.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Protocol switch: recognise mail without a Message-ID by its headers A message with no Message-ID came back as a second row after an account switched protocol. It is now matched by sender, subject and Date header — MessageSyncer::fingerprintOf(), read exactly as the row was built — on both sides: the POP3 adoption pass and a new SentCopyReconciler::claimByFingerprint() in the IMAP store loop. Only rows with no server address are candidates. A POP3 ledger row now names the message it stands for, and a row already named is not offered again, so two look-alike copies on the server are one adopted and one fetched rather than both taken for one row (which the delete mode would then have removed from the server). The IMAP side gets the same from the UID the claim gives the row. Also: a changelog entry for the remote_id fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: mmssix <mmssix@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Collects the work for the v0.4.0 milestone on
milestone/v0.4.0, and brings it tomaintogether.Draft until the milestone is complete. Each piece is reviewed as its own pull request into
milestone/v0.4.0. Once merged there, it shows up here.Pull requests into the milestone
mainmainmainIssues in the milestone without a pull request yet: #158, #99, #98, #97, #93, #48, #44, #27.
How this branch works
milestone/v0.4.0, notmain. The end-to-end suite now runs on pull requests intomilestone/**(andwip/**): see the first commit on this branch, which reachesmainwith this PR.mainis merged intomilestone/v0.4.0as it moves, so this PR stays mergeable and the milestone is tested against whatmainhas.## Unreleased. They become the v0.4.0 section at the release.Closes #Nin a pull request intomilestone/v0.4.0closes nothing. So this description listsCloses #Nfor every issue whose pull request has been merged into the milestone, and is updated as they land. Thelinked-issuecheck does not need it: this PR is labelleddeployment, which Let a deployment pull request through without an issue of its own #183 adds to the labels that need no issue of their own (the check passes once Let a deployment pull request through without an issue of its own #183 is inmain). The list is still kept, so that merging this closes the issues.Closes
Closes #28
🤖 Generated with Claude Code