Publish latest from release tags, add a changelog, unstale the roadmap

latest followed the default branch, so every push to main went straight
to the tag the README tells people to pull — including work in progress,
and migrations that run automatically on boot. It now moves only on a v*
tag; main still publishes under its own name for anyone wanting the tip.

The roadmap listed three finished things as open, including the label
refactor and the nested label UI, both of which shipped. A roadmap that
under-reports is worse than none: it is the document a contributor reads
to decide what to pick up. Corrected, with the genuinely open items
described accurately and a note asking that it be kept honest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-07-31 12:28:42 +02:00
1 parent ddccc8fa2e
commit dc0647f23f
5 files changed
+84 -7

No files matched your search

+13 -1
View File
@@ -3,6 +3,9 @@ name: Build and push Docker image
on: on:
push: push:
branches: [main] branches: [main]
# Releases. `latest` moves only when one of these is pushed — see the
# tag rules in the merge job.
tags: ['v*']
pull_request: pull_request:
branches: [main] branches: [main]
@@ -122,10 +125,19 @@ jobs:
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# `latest` used to follow the default branch, so every push
# to main went straight to the tag the README tells people to
# pull — including work in progress, and migrations that run
# automatically on boot. It now moves only on a release tag.
#
# `main` still publishes, under its own name, for anyone who
# wants the tip.
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=sha,prefix=sha- type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Create manifest list and push - name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests working-directory: ${{ runner.temp }}/digests
+55
View File
@@ -0,0 +1,55 @@
# Changelog
Notable changes per release. plMail runs its migrations automatically on boot,
so anything that changes the schema irreversibly is called out explicitly.
The published image tags: `latest` follows the most recent release below,
`main` follows the tip of the default branch, and `sha-…` pins one commit.
## Unreleased
### Added
- **Snooze.** Put a conversation away and have it come back later — from the
message row, the list toolbar, or a JMAP client via the `Thread/set`
extension. Snoozed conversations get their own sidebar entry and view.
`app:mail:wake-snoozed` runs every minute to bring them back.
- **JMAP draft attachments.** `Email/set` now accepts uploaded blobs in
`attachments`, per RFC 8621; drafts created over JMAP previously dropped them
silently.
- **Login throttling.** Five attempts per account per fifteen minutes on the
password form, and a separate limit on the two-factor code form — which the
firewall's throttling does not cover, and which is the form an attacker
reaches holding a stolen password.
- **PHPStan** at level 5, with a baseline, in CI. **Dependabot** for composer,
npm and GitHub Actions.
- **LICENSE.** The project has always said AGPL-3.0 in its README; the licence
text is now actually distributed with it, and `composer.json` no longer
contradicts it by claiming "proprietary".
### Fixed
- **Snoozing from the web UI did nothing but set a timer.** No labels moved and
nothing propagated, so the conversation stayed in the inbox — locally and at
the provider — while its row vanished from the list. The sweep would then
"wake" a thread that had never left.
- **The snooze button on a message row cleared a snooze instead of setting
one**, and the toolbar's snoozed everything to a hardcoded 8am tomorrow.
- **"Test connection" in account settings crashed** with an `ArgumentCountError`
whenever the form was incomplete or the password blank — the two cases it
exists to report.
- **The admin Graph category sync caught an exception class that does not
exist**, so Graph failures propagated instead of degrading quietly.
- **JMAP `Email/set` rejected valid mailbox patches.** Current `mailboxIds` were
emitted as label ids where the protocol expects per-account binding ids; since
both are autoincrement ints, a patch removing one mailbox failed with "No such
Mailbox".
### Changed
- `latest` is published from release tags only. It previously followed every
push to `main`, which meant the tag the README tells people to pull could
carry unreviewed work and automatic schema migrations.
- The login form no longer shows a "forgot password?" link. There is no reset
flow — recovery is a console command — and the link was an `href="#"` that did
nothing.
+12 -5
View File
@@ -317,11 +317,18 @@ each.
## Roadmap ## Roadmap
- [ ] Complete the label-based architecture refactor (Label as the user-facing concept; Mailbox demoted to IMAP sync infrastructure) - [x] Complete the label-based architecture refactor (Label as the user-facing concept; Mailbox demoted to IMAP sync infrastructure)
- [x] Sanitize rendered HTML bodies - [x] Sanitize rendered HTML bodies
- [x] Full-text search - [x] Full-text search
- [x] Microsoft OAuth2 / Graph send support - [x] Microsoft OAuth2 / Graph send support
- [ ] Gmail-native `threadId` threading (currently RFC Message-ID based) - [x] Nested label UI — `Label::$parent`, `LabelRepository::findVisibleTreeForUser()`, and the recursive tree in `_partials/_sidebar.html.twig`
- [ ] Incoming IMAP flag sync over the IDLE stream - [x] Snooze — `ThreadSnoozeService`, `app:mail:wake-snoozed`, and the `Thread/set` JMAP extension
- [ ] Avatar fetching (once OAuth avatar scopes are wired) - [ ] Gmail-native `threadId` threading (the id is carried on `Message`, but threading is still RFC Message-ID based)
- [ ] Nested label UI - [ ] Incoming IMAP flag sync over the IDLE stream — flags currently travel outward only, so marking a message read in another client does not reflect back
- [ ] Avatar fetching (partially done: `Service/User/AvatarFromIntegration.php`; still needs OAuth avatar scopes)
- [ ] Per-identity signatures — no `signature` field exists on `User`, `Account` or `EmailAlias`
- [ ] Password reset (recovery is console-only today, which is why the login form no longer offers a link)
> Keep this honest. Three items above were finished long before anyone ticked
> them, and a roadmap that under-reports is worse than none — it is the document
> a contributor reads to decide what to pick up.
+1 -1
View File
@@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>, * }>,
* }, * },
* rate_limiter?: bool|array{ // Rate limiter configuration * rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: true
* limiters?: array<string, array{ // Default: [] * limiters?: array<string, array{ // Default: []
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
+3
View File
@@ -62,6 +62,9 @@
"config/packages/http_discovery.yaml" "config/packages/http_discovery.yaml"
] ]
}, },
"phpstan/phpstan": {
"version": "2.2.7"
},
"phpunit/phpunit": { "phpunit/phpunit": {
"version": "13.2", "version": "13.2",
"recipe": { "recipe": {