Paul LütznerandClaude Opus 5 0aa628ff32 fix: a shell that fits the phone, a menu that fits the pane, and tests that survive the fixture
**The mobile double scrollbar.** `app.css` sets `html, body { height: 100dvh }` because mobile
browsers measure `100vh` against the viewport with the URL bar HIDDEN, and its comment says the
shells ask for `h-full`. Three did not: the mailbox, settings and admin shells were `h-screen`, so
the shell was taller than the body containing it, the body scrolled to make room, and the page grew
a second scrollbar beside the message list's own. The contract was already written down; these were
the three places not honouring it.

**The demo bar covered the last row.** It is `fixed` on purpose — a banner in the flow costs every
page a strip of reading height — but the cost is that the last row sits behind it, which on a phone
is the row you were reaching for. Scrollers get its height back as clearance.

**The Label-as menu was sliced off inside a thread.** The reading pane is `overflow-hidden` and the
panel was `absolute`, so it was cut at the pane's edge with no way to scroll to the rest — the thing
clipping it is not the thing that scrolls.

`position: fixed` does NOT fix this, which is the part worth recording: a fixed element is
positioned against the nearest ancestor carrying a transform, filter or backdrop-filter, and the
boxed layout gives the panes a backdrop blur. Measured rather than assumed — the panel reported
`top: 125px` inline and rendered at 210, ending eighty pixels below a 420px viewport. So it stays
where it is and is sized to the box that clips it, flipping above the button when there is more room
there.

**More demo data, from using the demo.** There was HTML mail, but List-Unsubscribe correctly files
it under Promotions, so the only formatted mail sat behind a tab nobody clicks and the reading pane
looked as though it could render nothing but plain text. There is now one from a person, in Primary.
And there was no conversation at all — every thread a singleton, which leaves the reading pane's
whole argument with nothing to demonstrate on.

Threading that conversation had to be done one turn at a time. MessageThreader resolves a reply's
parent with a SQL query that joins message.thread, so a parent created earlier in the same UNFLUSHED
batch is invisible to it and all four turns looked like conversation openers. The code already says
as much where Gmail meets the same wall from the other side.

**Tests that survive the fixture growing** — CODESTYLE 9.5, and three specs brought in line. An
assertion that must be edited every time the seeded mailbox gains a thread is asserting that number
rather than the behaviour, and it fails in a spec whose subject has nothing to do with the change.
Baselines where there is a "before", a relation where there is not: every message is its own thread
EXCEPT the conversation, whose turns collapse.

Proved by mutation rather than by reading: seeding a fifth thread and re-running found a third
brittle spec I had not spotted — unread-view's hard-coded "3" — which now takes the badge's own
starting value. Absence checks and own-fixture counts are left alone, and the rule says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:51:45 +02:00
…
…
2026-07-31 15:45:26 +02:00
2026-07-31 12:28:04 +02:00

plMail

Self-hosted mail with a calendar beside it. Connect the mailboxes you already have — IMAP, Gmail, Outlook — and read them side by side in one inbox, on a server you own.

PHP Symfony PostgreSQL License

📖 Read the handbook →

Installing on Docker, Linux, WSL2, macOS or a NAS · every feature in detail · connecting Gmail and Outlook, with the exact permissions to tick · how the internals work. Also on the wiki and in docs/ — all three are built from the same files.


What it is

A mail client you host yourself. It connects to the mailboxes you already have — IMAP, Gmail, Outlook — syncs them into your own PostgreSQL database, and gives them one inbox, one search box and one set of labels in the browser.

It is not a mail server: it doesn't receive mail from the internet or host your domain. Because everything is synced locally, search runs against your database, and your mail stays readable when a provider doesn't.

The plMail inbox

Highlights

  • Every account in one place — IMAP, Gmail and Outlook side by side, sending aliases included. Credentials are encrypted before they touch the database.
  • New mail arrives on its own — IMAP IDLE, Gmail and Outlook push, browser notifications.
  • Search that means it — full text plus from:, subject:, label:, has:, before: and friends, completed as you type.
  • Filters as a tree — any/all/none, nested, restated in plain English and counted against real mail before you save. Apply one to mail that arrived before it existed.
  • A calendar beside the mail — two-way sync with Google, Microsoft and CalDAV, drag-to-move week grid, invitations answered in place, share and booking links.
  • Files where you keep them — attach from and save to Drive, Photos, OneDrive, Dropbox, Nextcloud and Immich.
  • Yours — two-factor auth, per-app JMAP passwords, 41 themes, English, German and Pirate.

A look around

Reading a thread Writing a message
Threads — replies collapse into one conversation. Compose — rich text, autocomplete, undo send.
Dark mode Calendar
Themes — 41 of them, light and dark. Calendar — docked beside the mail, or its own page.
Filters Admin
Filters — counted before you save. Admin — workers, push health, logs, queue.

Screenshots use demo data — none of the accounts, senders or messages shown are real.

Install

Images are published for linux/amd64 and linux/arm64. Nothing to fill in first: the secrets are generated on first start, and the public address is asked for on the setup screen.

Docker

git clone https://github.com/karatektus/pl_mail.git
cd pl_mail
docker compose up -d

Open https://localhost and create your account.

TrueNAS SCALE

  1. Create a dataset for plMail — say yourPool/yourDataSet/plmail.
  2. Open truenas.compose.yaml and change one line to that path:
    path_data: &path_data /mnt/yourPool/yourDataSet/plmail
    
  3. Apps → Discover → ⋮ → Install via YAML, paste the file, install.

Reach it on port 30080 and put a reverse proxy in front of it. Everything else in that file works as it is; each setting explains itself in place.

Then

Add an IMAP mailbox in Settings → Mail accounts and you're done. Gmail and Outlook need OAuth credentials from Google or Microsoft first — the handbook walks through both, including the optional Pub/Sub topic for instant Gmail delivery.

Back up two things, separately: the PostgreSQL data and var/secrets/generated.env. The database holds your mail; the key inside that file unlocks the credentials in it. Keeping them apart is the point — and losing the key means the stored mailbox passwords are gone.

Contributing

Bug reports and pull requests welcome — see CONTRIBUTING.md for the dev setup, the test suites and the console commands.

License

AGPL-3.0.

S
Description
Upstream source mirror for plMail webmail client
Readme AGPL-3.0
48 MiB
0 Stars 1 Watchers 0 Forks
Languages
PHP 69.1%
JavaScript 12.9%
Twig 9.9%
TypeScript 6.7%
CSS 1.2%
Other 0.1%