Paul LütznerandClaude Opus 5 a1af660b45 fix(search): let the words win before the vector suggests
Searching one company name returned 63 results where Gmail, over substantially
the same mail, returned 16. Sixteen were matches on words. The other forty-seven
had merely cleared the vector's similarity threshold — job alerts, a product
newsletter, an invoice, outright spam — every one correctly badged "meaning",
every one useless, and together enough to fill the first page exactly. The best
match in the mailbox, a reply whose subject AND sender both carried the term,
was on page two.

**The threshold was not mistuned.** It was measured against the twelve-message
corpus in EmbeddingPreset, where a false-positive rate of a couple of percent is
invisible, and then applied to the two thousand most recent messages, where the
same rate is forty-seven rows. No stricter fraction fixes that — the fix for "a
small fraction of two thousand is still a lot" is a cap.

So `sem_hits` takes the ten nearest and stops. The cap is INSIDE that CTE, which
is what makes it safe: the note above it argued a top-k would make
`COUNT(*) OVER ()` mean "lexical plus up to k, on page one only", and that is
true of a top-k over the UNION, which sees OFFSET. A CTE is evaluated once per
statement and the candidate window is deterministic, so the admitted set is
identical on every page and the total stays honest. The earlier reasoning was
right about the wrong position.

Ordering carried the other half, and it is why the best match was displaced
rather than merely outnumbered. A semantic hit can only be drawn from the most
recent SEMANTIC_CANDIDATES messages, so under "most recent" every one of them is
structurally newer than most of the mail it competes with — they do not tend to
win, they cannot lose. `semantic_only ASC` now leads both sort orders.

That fixes relevance too, where the disease was worse: `rank` is
`GREATEST(ts_rank, cosine)`, and measured against this schema's weights a subject
match scores 0.6079, a body match 0.1216, a token-part match 0.0608, while any
admitted vector hit is ≥ 0.42 by definition. Sorting by relevance put every
barely-admissible vector hit above every body match. Leading with provenance
means rank is only ever compared within one scale.

Nothing is removed. The meaning matches are all still there, badged, at the end.

The body-substring rescue is counted over lexical rows now rather than over the
page. It exists to catch a needle inside a token the tokenizer split the wrong
way, and forty-seven vector hits padding a page to exactly fifty made `50 < 50`
false — so the one arm that could still have found the mail never ran, and
nothing said so.

Both new tests were checked against the old behaviour and fail there: without the
cap the fixture admits two rows instead of one, and without the ordering the
newer meaning match takes first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:17:50 +02:00
2026-07-28 12:33:46 +02:00
2026-06-24 14:51:50 +02:00
2026-06-24 14:51:50 +02:00
2026-07-21 16:18:46 +02:00
2026-07-31 15:45:26 +02:00
2026-07-28 12:33:46 +02:00
2026-09-21 16:16:29 +02:00
2026-07-02 13:12:28 +02:00
2026-07-31 12:28:04 +02:00
2026-07-30 16:40:31 +02:00
2026-07-28 15:44:19 +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.
  • Filed where you drop it — drag a conversation onto a folder, a label or one of the inbox tabs, several at once if they are ticked. A folder moves it and a label just adds itself, so dropping on "Receipts" does not take the mail out of your inbox. Only the targets that will accept it light up, and a tab you choose by hand stays chosen when the next message in the thread arrives.
  • A calendar beside the mail — two-way sync with Google, Microsoft and CalDAV, a drag-to-move week grid that pans rather than squeezes on a phone, invitations answered in place, share and booking links.
  • Files where you keep them — attach from and save to Drive, Photos, OneDrive, Dropbox, Nextcloud, Immich and Paperless-ngx.
  • A language model, if you want one — point plMail at an Ollama box on your own network and it can search by meaning, draft replies, summarise a thread and sort mail into tabs. A long thread is summarised from as much of it as fits, and says so; ask for the whole conversation and it runs as a job with a context window to match. What it has been told about how you write is editable in the composer, for one attempt at a time. Off by default, each of the four switched on separately, and nothing ever leaves your network.
  • You decide what sorts your mail — header rules that answer the same way every time, or the model reading each message; and on Gmail, whether either of them is allowed to disagree with Google. Change your mind and the mailbox re-sorts itself in the background. Every message's Details panel shows what all three said and which one won, and a Report button tells your administrator when it got it wrong.
  • It tells you when something breaks — a sign-in that has expired, a calendar that stopped syncing, push that lapsed, work the queue gave up on. Each with what it means for your mail and the smallest thing that fixes it. A Google sign-in dying every week gets named for what it is — Google expires tokens for an app still in Testing — and warned about before the next one.
  • 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%