Skip to content

v0.4.0 - #182

Draft
pmdevelopment wants to merge 24 commits into
mainfrom
milestone/v0.4.0
Draft

v0.4.0#182
pmdevelopment wants to merge 24 commits into
mainfrom
milestone/v0.4.0

Conversation

@pmdevelopment

@pmdevelopment pmdevelopment commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Collects the work for the v0.4.0 milestone on milestone/v0.4.0, and brings it to main together.

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

PR What Closes State
#84 End-to-end encryption with OpenPGP and S/MIME (phases 1–6d) #28 merged
#188 Encryption phase 6e: single sign-on accounts, and unlocking the keyring only when needed #28 merged
#195 Encryption phase 6f: look contacts' keys up on public key servers #28 merged
#199 Encryption phase 6g: feedback from testing #28 merged
#189 Fix: keep sealed messages on the shared raw volume, so encrypted mail is sent #28 merged
#64 Show who a message really comes from, in the list and the header #153 open, conflicts with main
#67 Notice new Gmail and Outlook inbox mail within a minute without push #152 open, conflicts with main
#68 Choose which folders wait behind More #151 open, conflicts with main
#109 CardDAV: mirror address books, suggest contacts while composing #157 open
#209 Fix: replying to encrypted mail, the signature's indent, a lock in the list #28 merged
#215 Fix: the weekly blob prune spares profile pictures, signature images, backgrounds and sealed messages #214 open

Issues in the milestone without a pull request yet: #158, #99, #98, #97, #93, #48, #44, #27.

How this branch works

  • Pull requests for v0.4.0 go into milestone/v0.4.0, not main. The end-to-end suite now runs on pull requests into milestone/** (and wip/**): see the first commit on this branch, which reaches main with this PR.
  • main is merged into milestone/v0.4.0 as it moves, so this PR stays mergeable and the milestone is tested against what main has.
  • Changelog: each piece adds its entries under ## Unreleased. They become the v0.4.0 section at the release.
  • Closing issues: GitHub closes an issue only when a pull request that names it merges into the default branch. A Closes #N in a pull request into milestone/v0.4.0 closes nothing. So this description lists Closes #N for every issue whose pull request has been merged into the milestone, and is updated as they land. The linked-issue check does not need it: this PR is labelled deployment, 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 in main). The list is still kept, so that merging this closes the issues.

Closes

Closes #28

🤖 Generated with Claude Code

…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>
@pmdevelopment pmdevelopment added this to the v0.4.0 milestone Oct 16, 2026
@pmdevelopment pmdevelopment added the deployment Brings already-reviewed work to main (a milestone or release branch); needs no issue of its own label Oct 16, 2026
* 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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation area: mail Concerns mail area: gmail Concerns gmail area: outlook Concerns outlook area: imap Concerns imap area: settings Concerns settings area: install Concerns install labels Oct 16, 2026
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>
pmdevelopment and others added 7 commits October 16, 2026 15:50
… 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>
This was referenced Oct 20, 2026
pmdevelopment and others added 5 commits October 21, 2026 19:24
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>
pmdevelopment and others added 3 commits October 23, 2026 12:38
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>
@github-actions github-actions Bot added the area: calendar Concerns calendar label Oct 23, 2026
pmdevelopment and others added 6 commits October 24, 2026 14:21
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>
…345)

Closes #153

Supersedes #64.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
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>
…ne (#347)

Closes #343

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

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: calendar Concerns calendar area: gmail Concerns gmail area: imap Concerns imap area: install Concerns install area: mail Concerns mail area: outlook Concerns outlook area: settings Concerns settings deployment Brings already-reviewed work to main (a milestone or release branch); needs no issue of its own documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: S/MIME and PGP

3 participants