encryption

This commit is contained in:
Paul Lützner committed 2026-07-28 17:45:41 +02:00
1 parent 3598a37184
commit dbc02c1bfd
20 files changed
+916 -117

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ MAILER_DSN=null://null
# Base64-encoded 32-byte libsodium secretbox key used by the `encrypted_string`
# Doctrine type to encrypt account credentials at rest. SEPARATE from APP_SECRET.
# Generate a fresh one with:
# php -r 'echo base64_encode(sodium_crypto_secretbox_keygen());'
# openssl rand -base64 32
# This default is for LOCAL DEV ONLY — override with a real secret in production
# (e.g. via real env var or `symfony console secrets:set APP_ENCRYPTION_KEY`).
APP_ENCRYPTION_KEY=chHhLxNFHCRA7exigGBw08i/PWQfIxyWxn63oDUC4/s=
+120
View File
@@ -0,0 +1,120 @@
# Contributing to plMail
Development setup, test suites and console reference. For installing and running plMail, see the
[README](README.md).
## Stack
| Layer | Technology |
|---|---|
| Backend | Symfony 8, Doctrine ORM, PHP 8.4+ |
| Database | PostgreSQL 18 |
| IMAP | webklex/php-imap |
| Gmail | Gmail REST + Batch API, OAuth2 (league/oauth2-client) |
| Microsoft | Microsoft Graph API, OAuth2 |
| Async | Symfony Messenger, Doctrine transport |
| Push | Mercure hub, Gmail Cloud Pub/Sub watch, Graph subscriptions, Web Push (VAPID) |
| Interop | JMAP server (`/jmap`), authenticated with per-user app passwords |
| Frontend | AssetMapper, Tailwind v4, Hotwire Turbo, Stimulus |
| Runtime | FrankenPHP |
| Credentials | libsodium secretbox via the `encrypted_string` Doctrine type |
| Dev tooling | Docker Compose, Adminer, Mailpit |
## Development setup
```bash
cp .env .env.local
```
```bash
docker compose up --build
```
Migrations run automatically via the entrypoint. Create the first user with `app:setup`, or register
through the UI. The `imap-supervisor` and `messenger-worker` services start with the stack and
restart on failure.
## Tests
Two suites: PHPUnit for unit tests (`tests/`, mirroring `src/`) and Playwright for browser end-to-end
tests (`tests/e2e/`).
Both run against `compose.test.yaml` — a separate compose project with its own Postgres, so they never
touch the dev stack or the database holding your mail. Playwright runs on the host (Node 18+; the repo
ships an `.nvmrc`).
```bash
npm ci
```
```bash
npx playwright install chromium
```
```bash
npm run test:unit:docker
```
```bash
npm run test:e2e:docker
```
| Command | Description |
|---|---|
| `npm run test:e2e:docker:ui` | Playwright's watch UI |
| `npm run test:env:up` | Start the test stack (migrates, builds assets, seeds the E2E user) |
| `npm run test:env:down` | Stop it, keeping the database volume |
| `npm run test:env:reset` | Stop it and delete its volumes — next run rebuilds from scratch |
| `npm run test:env:logs` | Tail the test app's logs |
The test app is served at `http://127.0.0.1:8001` (override with `TEST_HTTP_PORT`). Individual specs
reseed their own fixtures, so tests are independent and re-runnable.
CI runs the same suites without Docker — see [.github/workflows/e2e.yml](.github/workflows/e2e.yml).
## README screenshots
`tests/e2e/screenshots.spec.ts` regenerates the images in `docs/screenshots/`. It asserts nothing and
is not part of the regression suite — run it deliberately, against a stack seeded with demo data:
```bash
E2E_DOCKER=1 npx playwright test screenshots.spec.ts --project=chromium --workers=1
```
Never point it at a stack holding real mail.
## Console commands
| Command | Description |
|---|---|
| `app:setup` | Create the first admin user (interactive) |
| `app:mail:sync [account-id]` | Dispatch an account-level sync for one or all active accounts |
| `app:mail:send-draft [message-id]` | Send a draft message (picker if no ID given) |
| `app:mail:test-connection` | Probe an account's IMAP/SMTP settings |
| `app:contacts:harvest [account-id]` | Harvest contact addresses from synced messages |
| `app:label:backfill [--account=ID]` | Create labels from existing mailboxes and backfill assignments |
| `app:imap:idle <mailbox-id>` | Hold an IMAP IDLE connection for a single mailbox |
| `app:imap:supervise` | Spawn and watch one `app:imap:idle` process per IDLE-enabled mailbox |
| `app:imap:test [--account=ID]` | Test an IMAP connection and folder listing |
| `app:push:renew` | Renew Gmail watches and Graph subscriptions nearing expiry |
| `app:push:generate-vapid-keys` | Generate a VAPID keypair for Web Push |
| `app:graph:diagnose` | Probe Microsoft Graph access for one account and report what works |
| `app:attachments:reclassify` | Recompute inline/attachment classification for stored parts |
| `app:jmap:prune-uploads` | Delete unreferenced JMAP blob uploads past the retention window |
| `app:user:promote <email> [--revoke]` | Grant or revoke `ROLE_ADMIN` |
| `app:monitoring:prune [--days=N]` | Prune old log entries and dead process heartbeats |
| `app:reset` | Truncate synced data — useful during development |
Schedule `app:mail:sync`, `app:push:renew` and `app:monitoring:prune` via cron or the Symfony
Scheduler as needed.
## Roadmap
- [ ] Complete the label-based architecture refactor (Label as the user-facing concept; Mailbox demoted to IMAP sync infrastructure)
- [x] Sanitize rendered HTML bodies
- [x] Full-text search
- [x] Microsoft OAuth2 / Graph send support
- [ ] Gmail-native `threadId` threading (currently RFC Message-ID based)
- [ ] Incoming IMAP flag sync over the IDLE stream
- [ ] Avatar fetching (once OAuth avatar scopes are wired)
- [ ] Nested label UI
+219 -112
View File
@@ -1,144 +1,251 @@
# pl_mail
# plMail
A self-hosted, Gmail-style mail client built with Symfony. Clone the repo, run `docker compose up`, and you get a working mail client with real-time push updates, threaded conversations, compose-and-send, and multi-account support across standard IMAP and Gmail (OAuth2) accounts.
A self-hosted mail client for your own server. Connect your IMAP, Gmail and Outlook accounts, read them
side by side in one inbox, and keep every message in a database you control.
![PHP](https://img.shields.io/badge/PHP-8.4+-777BB4?logo=php&logoColor=white)
![Symfony](https://img.shields.io/badge/Symfony-8-000000?logo=symfony&logoColor=white)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-4169E1?logo=postgresql&logoColor=white)
![License](https://img.shields.io/badge/license-AGPL--3.0-green)
## Features
![The plMail inbox](docs/screenshots/inbox.png)
- **Multi-account, multi-provider** — standard IMAP (password) and Gmail (OAuth2 + Gmail API) accounts side by side, each with independent sync
- **Compose & send** — new messages, reply, reply-all and forward; Gmail is sent through the Gmail API (Sent copy auto-filed, no SMTP needed)
- **Threaded conversations** — messages grouped by References/In-Reply-To headers, with normalized-subject fallback
- **Real-time updates** — new mail appears instantly via IMAP IDLE + Mercure push and Gmail push watches (Cloud Pub/Sub), no refresh needed
- **Contacts** — addresses harvested implicitly from synced mail, with autocomplete in the compose address fields
- **Gmail-style UI** — checkbox select, star, sender, subject + snippet, date, hover actions per row
- **Single-pane navigation** — clicking a thread loads it in-place via fetch + History API, back button works correctly
- **Turbo modal system** — add-account and other forms open in a modal without a full page load
- **Dark mode** — respects system preference, toggleable, persisted
- **Async sync** — Symfony Messenger with Doctrine transport; Gmail fetched via the Batch API to stay within quota; memory-safe batching
- **Supervised IDLE workers** — one IMAP IDLE process per enabled mailbox, watched and auto-restarted by a built-in supervisor command
- **Encrypted credentials at rest** — account passwords and OAuth tokens encrypted with a libsodium secretbox key
- **Attachments & inline images** — lazy Gmail attachment materialization; `cid:` inline images rewritten for display
## What it is
## Stack
plMail runs on a machine you own — a home server, a NAS, a small VPS — and syncs your mail into a
local PostgreSQL database. You reach it through the browser, from any device on your network or
over the internet if you expose it.
| Layer | Technology |
It is not a mail *server*. It doesn't receive mail from the outside world or host your domain.
It's the client: it connects to the mailboxes you already have and gives them one interface, one
search box, and one set of labels.
Because everything is synced locally, mail stays readable when a provider is slow or briefly
unreachable, and search runs against your own database rather than someone else's API.
## What it can do
**Your accounts, together**
- Standard IMAP mailboxes, Gmail (via Google sign-in), and Outlook / Microsoft 365 (via Microsoft sign-in)
- Any number of accounts side by side, each syncing independently
- Send from any of them, including multiple sending aliases per account
- Mailbox passwords and OAuth tokens are encrypted before they reach the database
**Reading and writing**
- Conversations grouped into threads, with unread, starred and attachment state at a glance
- Compose, reply, reply-all and forward, with a rich-text editor and contact autocomplete
- Undo send — a short grace period after hitting send, before it actually goes out
- Snooze a conversation to bring it back later
- Labels you create yourself, optionally mirrored back to Gmail
- Attachments and inline images, with the original message available when you need it
**Finding things**
- Full-text search across everything synced, ranked by relevance
- Filters that combine with it: `from:`, `to:`, `subject:`, `in:`, `has:`, `is:`, `after:`, `before:`
**Staying current**
- New mail arrives on its own — IMAP IDLE for standard mailboxes, push notifications for Gmail and Outlook
- Optional browser notifications, so you hear about mail without the tab open
- Scheduled syncing as a fallback whenever push isn't available
**Making it yours**
- Light and dark themes, following your system or set explicitly
- Custom colours and background, exportable and importable as a file
- English and German interface
- Connect other mail apps over JMAP, using per-app passwords you can revoke individually
## A look around
| | |
|---|---|
| Backend | Symfony 8, Doctrine ORM, PHP 8.4+ |
| Database | PostgreSQL 18 |
| IMAP | webklex/php-imap |
| Gmail | Gmail REST + Batch API, OAuth2 (league/oauth2-client) |
| Async | Symfony Messenger, Doctrine transport |
| Push | Mercure hub, Gmail Cloud Pub/Sub watch |
| Frontend | AssetMapper, Tailwind v4, Hotwire Turbo, Stimulus |
| Runtime | FrankenPHP |
| Dev tooling | Docker Compose, Adminer, Mailpit |
| ![Reading a thread](docs/screenshots/thread.png) | ![Writing a message](docs/screenshots/compose.png) |
| **Threaded conversations** — replies collapse into one conversation, newest expanded. | **Compose** — rich text, contact autocomplete, send from any account or alias. |
| ![Dark mode](docs/screenshots/inbox-dark.png) | ![Settings](docs/screenshots/settings.png) |
| **Dark mode** — follows your system preference, or pick one and stick with it. | **Settings** — add accounts, reorder them, choose how much history to sync. |
## Requirements
<sub>Screenshots use demo data — none of the accounts, senders or messages shown are real.</sub>
- Docker and Docker Compose
- That's it (Google OAuth credentials additionally required to connect Gmail accounts — see [Environment Variables](#environment-variables))
## Setting it up
## Getting Started
### What you need
- A machine running Docker and Docker Compose
- A few minutes
That's enough for IMAP accounts. Gmail and Outlook additionally need OAuth credentials from Google or
Microsoft — see [Connecting Gmail](#connecting-gmail) below.
### Get it running
```bash
git clone https://github.com/karatektus/pl_mail.git
cd pl_mail
cp .env .env.local # adjust DATABASE_URL, MERCURE_JWT_SECRET, APP_ENCRYPTION_KEY, etc.
docker compose up --build
```
Then open [https://localhost](https://localhost).
On first run, Doctrine migrations run automatically via the entrypoint. Create your first user (`php bin/console app:setup`, or register in the UI), then add an IMAP account or connect a Gmail account via OAuth — the first sync kicks off immediately.
To connect Gmail accounts, set `GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET` and `GMAIL_PUBSUB_TOPIC` in `.env.local` first.
## Running the Tests
Two suites: PHPUnit for unit tests (`tests/`, mirroring `src/`) and Playwright for browser
end-to-end tests (`tests/e2e/`).
Both run against `compose.test.yaml` — a separate compose project with its own Postgres, so it
never touches the dev stack or the database holding your mail. Playwright itself runs on the host
(Node 18+; the repo ships an `.nvmrc`).
```bash
npm ci # once
npx playwright install chromium
npm run test:unit:docker # PHPUnit
npm run test:e2e:docker # Playwright (boots the stack if needed)
npm run test:e2e:docker:ui # same, in Playwright's watch UI
cd pl_mail && cp .env .env.local
```
| Command | Description |
Open `.env.local` and set at least these three before starting:
| Setting | What to put there |
|---|---|
| `npm run test:env:up` | Start the test stack (migrates, builds assets, seeds the E2E user) |
| `npm run test:env:down` | Stop it, keeping the database volume |
| `npm run test:env:reset` | Stop it and delete its volumes — next run rebuilds from scratch |
| `npm run test:env:logs` | Tail the test app's logs |
| `APP_PUBLIC_URL` | The address you'll reach plMail at, e.g. `https://mail.example.com` |
| `APP_SECRET` | Any long random string |
| `APP_ENCRYPTION_KEY` | The key your mailbox credentials are encrypted with — generate one below |
The app is served at `http://127.0.0.1:8001` (override with `TEST_HTTP_PORT`). Individual specs
reseed their own fixtures, so tests are independent and re-runnable.
CI runs the same suites without Docker — see [.github/workflows/e2e.yml](.github/workflows/e2e.yml).
## Console Commands
| Command | Description |
|---|---|
| `app:setup` | Create the first admin user (interactive) |
| `app:mail:sync [account-id]` | Dispatch an account-level sync (IMAP or Gmail) for one or all active accounts |
| `app:mail:send-draft [message-id]` | Send a draft message (picker if no ID given) |
| `app:contacts:harvest [account-id]` | Harvest contact addresses from synced messages |
| `app:label:backfill [--account=ID]` | Create labels from existing mailboxes and backfill message/thread label assignments |
| `app:imap:idle <mailbox-id>` | Hold an IMAP IDLE connection for a single mailbox, dispatch sync on change |
| `app:imap:supervise` | Spawn and watch one `app:imap:idle` process per IDLE-enabled mailbox |
| `app:imap:test [--account=ID]` | Test an IMAP connection and folder listing |
| `app:gmail:renew-watches` | Renew Gmail push-notification watches expiring within 24 hours |
| `app:user:promote <email> [--revoke]` | Grant (or revoke) ROLE_ADMIN for a user |
| `app:monitoring:prune [--days=N] [--heartbeat-days=N]` | Prune old log entries and dead process heartbeats |
| `app:reset` | Truncate synced data (messages, threads, optionally mailboxes/labels) — useful during development |
The `imap-supervisor` and `messenger-worker` services start automatically with `docker compose up` and restart on failure. Schedule `app:mail:sync`, `app:gmail:renew-watches` and `app:monitoring:prune` via cron or the Symfony Scheduler as needed.
## Environment Variables
| Variable | Description | Default |
|---|---|---|
| `DATABASE_URL` | PostgreSQL connection string | see `.env` |
| `MESSENGER_TRANSPORT_DSN` | Messenger transport | `doctrine://default?auto_setup=0` |
| `MERCURE_URL` | Internal Mercure hub URL | `http://mercure/.well-known/mercure` |
| `MERCURE_PUBLIC_URL` | Browser-facing Mercure URL | `https://localhost/.well-known/mercure` |
| `MERCURE_JWT_SECRET` | Shared secret for Mercure JWT signing | — |
| `APP_SECRET` | Symfony app secret | — |
| `APP_ENCRYPTION_KEY` | Base64 libsodium key encrypting account credentials at rest (separate from `APP_SECRET`) | dev key in `.env` |
| `GOOGLE_OAUTH_CLIENT_ID` | Google OAuth client ID (required for Gmail accounts) | — |
| `GOOGLE_OAUTH_CLIENT_SECRET` | Google OAuth client secret (required for Gmail accounts) | — |
| `GMAIL_PUBSUB_TOPIC` | Cloud Pub/Sub topic used for Gmail push watches | — |
| `MAILER_DSN` | Symfony Mailer transport (system mail) | `null://null` |
Generate a fresh `APP_ENCRYPTION_KEY` with:
Generate the encryption key with:
```bash
php -r 'echo base64_encode(sodium_crypto_secretbox_keygen());'
openssl rand -base64 32
```
## Roadmap
plMail encrypts every mailbox password and OAuth token with this key before writing it to the
database, so a stolen database dump or backup discloses no credentials on its own. That cuts both
ways: **if you lose the key, the stored credentials cannot be recovered** and you'll have to re-enter
them. Back it up separately from the database — storing both in the same place defeats the purpose.
The app refuses to start without a valid key rather than quietly falling back to plain text.
- [ ] Complete the label-based architecture refactor (Label as the user-facing concept; Mailbox demoted to IMAP sync infrastructure)
- [x] Sanitize rendered HTML bodies (currently rendered raw)
- [ ] Gmail-native `threadId` threading (currently RFC Message-ID based)
- [ ] Incoming IMAP flag sync over the IDLE stream
- [ ] Microsoft OAuth2 / Graph send support
- [ ] Avatar fetching (once OAuth avatar scopes are wired)
- [ ] Full-text search
- [ ] Nested label UI
Then start everything:
```bash
docker compose up -d
```
The database is prepared automatically on first boot. Open [https://localhost](https://localhost),
create your account, and add your first mailbox.
### Adding an IMAP mailbox
**Settings → Mail accounts → Add account.** Enter the address and password; plMail recognises common
providers and fills in the server settings for you. If yours isn't recognised, enter the IMAP and SMTP
details from your provider's documentation. Test the connection, save, and the first sync starts
immediately.
Mail then arrives on its own: plMail holds an IDLE connection to each mailbox and syncs the moment
something changes.
### Connecting Gmail
Gmail needs OAuth credentials, because Google no longer allows plain password logins.
1. In the [Google Cloud Console](https://console.cloud.google.com), create a project and enable the **Gmail API**.
2. Create an **OAuth client ID** of type *Web application*, with the redirect URI:
`https://your-domain/oauth/google/callback`
3. Put the client ID and secret into `.env.local` as `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET`.
4. Restart plMail, then **Settings → Mail accounts → Add account → Sign in with Google**.
That's enough to read and send. Gmail will be checked on a schedule.
#### Instant Gmail delivery (optional)
For mail that arrives the moment it's sent, Gmail needs somewhere to publish notifications, which
means a Cloud Pub/Sub topic. This is a one-time setup for the whole instance — not per account.
In the Google Cloud Console, or via `gcloud`:
```bash
gcloud pubsub topics create gmail-push --project=YOUR_PROJECT_ID
```
Grant Google's mail service permission to publish to it:
```bash
gcloud pubsub topics add-iam-policy-binding gmail-push --project=YOUR_PROJECT_ID --member=serviceAccount:gmail-api-push@system.gserviceaccount.com --role=roles/pubsub.publisher
```
Pick a secret of your own, then tell Pub/Sub where to deliver:
```bash
gcloud pubsub subscriptions create gmail-push-sub --project=YOUR_PROJECT_ID --topic=gmail-push --push-endpoint="https://your-domain/gmail/push?token=YOUR_SECRET"
```
Finally, set both values in `.env.local` and restart:
```ini
GMAIL_PUBSUB_TOPIC=projects/YOUR_PROJECT_ID/topics/gmail-push
GMAIL_PUBSUB_VERIFICATION_TOKEN=YOUR_SECRET
```
Now turn on **Instant delivery** for the account in Settings. Two things to know: your project ID is
lowercase and may differ from the display name you chose, and the token in the subscription URL must
match `GMAIL_PUBSUB_VERIFICATION_TOKEN` exactly — plMail rejects notifications that don't carry it.
If mail still doesn't arrive instantly, the admin area's **Gmail webhooks** panel shows the topic,
the exact endpoint Pub/Sub must call, and why any notification was refused.
### Connecting Outlook / Microsoft 365
1. In the [Azure portal](https://portal.azure.com), register an application.
2. Add the redirect URI `https://your-domain/oauth/microsoft/callback`.
3. Grant these delegated Microsoft Graph permissions: `User.Read`, `Mail.ReadWrite`, `Mail.Send`,
`MailboxSettings.ReadWrite` and `offline_access`.
4. Put the client ID and secret into `.env.local` as `MICROSOFT_OAUTH_CLIENT_ID` and
`MICROSOFT_OAUTH_CLIENT_SECRET`, then restart and add the account.
`MICROSOFT_OAUTH_TENANT` decides who may sign in: `common` for both work and personal accounts,
`organizations` for work or school only, `consumers` for personal only, or a tenant ID for a single
organisation. It must match the supported account types you chose when registering the app —
a mismatch fails at consent time with `AADSTS50194` rather than at setup.
Microsoft accounts go through Graph rather than IMAP. That's deliberate: Exchange Online treats
IMAP as legacy authentication and blocks it in any tenant running Security Defaults.
Instant delivery works out of the box here, as long as `APP_PUBLIC_URL` is a public HTTPS address
Microsoft can reach — no extra cloud resources needed.
### Reaching it from outside
Put plMail behind a reverse proxy with a real certificate and set `APP_PUBLIC_URL` to that address.
Push notifications from Google and Microsoft only work over public HTTPS, so this is what enables
instant delivery.
## Settings worth knowing
Everything lives in `.env.local`.
| Setting | What it does |
|---|---|
| `APP_PUBLIC_URL` | The public address of your instance. Required for push notifications. |
| `APP_ENCRYPTION_KEY` | Encrypts stored mailbox passwords and OAuth tokens. Back it up separately. |
| `APP_SECRET` | Symfony's application secret. |
| `GOOGLE_OAUTH_CLIENT_ID` / `_SECRET` | Needed to connect Gmail accounts. |
| `GMAIL_PUBSUB_TOPIC` | Pub/Sub topic for instant Gmail delivery. |
| `GMAIL_PUBSUB_VERIFICATION_TOKEN` | Shared secret protecting the Gmail notification endpoint. |
| `MICROSOFT_OAUTH_CLIENT_ID` / `_SECRET` | Needed to connect Outlook accounts. |
| `MICROSOFT_OAUTH_TENANT` | Which Microsoft accounts may sign in. Defaults to `common`. |
| `MAILER_DSN` | Where plMail sends its own system mail, such as password resets. |
| `APP_DB_LOG_LEVEL` | How much detail is kept in the admin log browser. Defaults to `warning`. |
## Keeping an eye on it
Signed in as an administrator, **Admin** shows what the instance is doing: which background workers
are alive, whether push delivery is working for each account, OAuth tokens nearing expiry, queued and
failed jobs, database size, and a searchable log.
![The admin dashboard](docs/screenshots/admin.png)
It's the first place to look when mail stops arriving.
## Good to know
- **Search covers synced mail.** Choose how far back to sync per account in its settings; older mail
stays on the server until you widen that window.
- **Gmail labels sync one way by default.** Labels you make in plMail stay local unless you switch on
mirroring for that account.
- **Deleting an account deletes its synced mail** from plMail. Nothing is removed from the provider.
- **Back up two things, separately**: the PostgreSQL volume and `APP_ENCRYPTION_KEY`. The database
holds your mail; the key unlocks the credentials inside it. Keeping them apart is the point.
## Contributing
Bug reports and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the development
setup, the test suites and the console commands.
## License
AGPL-3.0 — see [LICENSE](LICENSE) for details.
AGPL-3.0.
+4
View File
@@ -2,6 +2,10 @@ doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
types:
# TEXT column, encrypted at rest. See App\Doctrine\Type\EncryptedStringType.
encrypted_string: App\Doctrine\Type\EncryptedStringType
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
+6
View File
@@ -19,6 +19,12 @@ services:
App\:
resource: '../src/'
# Public because Kernel::boot() fetches it straight from the container to
# hand to EncryptedStringType — Doctrine builds types statically, outside
# dependency injection, so there is no other seam.
App\Encryption\Encryptor:
public: true
App\Domain\Helper\AttachmentStorageHelper:
arguments:
$projectDir: '%kernel.project_dir%'
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Doctrine\Type;
use App\Encryption\EncryptionException;
use App\Encryption\Encryptor;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\TextType;
/**
* A TEXT column whose value is encrypted on the way in and decrypted on the
* way out, so credentials are never at rest in readable form.
*
* Use it on a property with `#[ORM\Column(type: EncryptedStringType::NAME)]`.
* The database column stays TEXT, so adopting it needs no schema migration —
* only the values change shape.
*
* ── Why a static encryptor ───────────────────────────────────────────────
* Doctrine instantiates types itself, through a static registry, and offers no
* constructor injection. The container therefore hands the Encryptor over at
* boot (see Kernel::boot). That is the standard workaround, and the reason this
* class cannot simply be a service.
*
* ── Legacy plaintext ─────────────────────────────────────────────────────
* Values written before this type was introduced carry no `enc:v1:` prefix.
* They are passed through on read rather than throwing, so an instance that
* predates encryption stays usable — you can still open the accounts page and
* delete or re-enter the affected accounts. Anything written from then on is
* encrypted, so plaintext disappears as records are rewritten. Nothing
* backfills them: the old values are readable by definition, and rewriting
* them silently would suggest a security guarantee that the backups and WAL
* segments still holding the plaintext do not support.
*/
final class EncryptedStringType extends TextType
{
public const string NAME = 'encrypted_string';
private static ?Encryptor $encryptor = null;
/**
* Called once from Kernel::boot(), since Doctrine builds types statically.
*/
public static function setEncryptor(Encryptor $encryptor): void
{
self::$encryptor = $encryptor;
}
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string
{
if (null === $value || '' === $value) {
return $value;
}
return self::encryptor()->encrypt((string) $value);
}
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?string
{
if (null === $value || '' === $value) {
return $value;
}
$value = (string) $value;
// Written before this type existed — see the class docblock.
if (false === self::encryptor()->isEncrypted($value)) {
return $value;
}
try {
return self::encryptor()->decrypt($value);
} catch (EncryptionException $e) {
// Surfaced as a Doctrine conversion error so the failure names the
// column it happened on, which matters when the cause is a changed
// APP_ENCRYPTION_KEY and every credential fails at once.
throw new ConversionException(
sprintf('Could not decrypt %s column: %s', self::NAME, $e->getMessage()),
previous: $e,
);
}
}
private static function encryptor(): Encryptor
{
if (null === self::$encryptor) {
throw new EncryptionException(
'EncryptedStringType has no Encryptor. It must be injected during kernel boot.',
);
}
return self::$encryptor;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Encryption;
use RuntimeException;
/**
* Raised when a value cannot be encrypted or decrypted — a malformed or
* missing APP_ENCRYPTION_KEY, or a ciphertext that fails its integrity check.
*
* Deliberately never carries the offending value in its message: these
* exceptions reach the logs, and the whole point of the class that throws them
* is to keep credentials out of anything readable.
*/
final class EncryptionException extends RuntimeException
{
}
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Encryption;
use SensitiveParameter;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Authenticated symmetric encryption for credentials stored in the database.
*
* libsodium secretbox (XSalsa20-Poly1305): confidentiality plus integrity, so a
* tampered ciphertext fails to open rather than decrypting to garbage. A fresh
* random nonce is generated per encryption and prepended to the ciphertext,
* which is why encrypting the same value twice yields different output — that
* is correct, and means these columns cannot be searched or compared by value.
*
* Stored format:
*
* enc:v1:<base64(nonce || ciphertext)>
*
* The prefix exists so stored values are self-describing: EncryptedStringType
* uses it to tell an encrypted value from a legacy plaintext one, and a future
* algorithm change can bump the version without ambiguity.
*/
final readonly class Encryptor
{
/** Marks a value as encrypted by this class, and with which scheme. */
public const string PREFIX = 'enc:v1:';
private string $key;
/**
* @param string $base64Key Base64-encoded 32-byte secretbox key
*
* @throws EncryptionException when the key is missing or the wrong size
*/
public function __construct(
#[SensitiveParameter]
#[Autowire(env: 'APP_ENCRYPTION_KEY')]
string $base64Key,
) {
$key = base64_decode(trim($base64Key), true);
if (false === $key) {
throw new EncryptionException(
'APP_ENCRYPTION_KEY is not valid base64. Generate one with: openssl rand -base64 32',
);
}
if (SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== strlen($key)) {
throw new EncryptionException(sprintf(
'APP_ENCRYPTION_KEY must decode to %d bytes, got %d. '
. 'Generate one with: openssl rand -base64 32',
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
strlen($key),
));
}
$this->key = $key;
}
/**
* Already-encrypted input is returned untouched, so encrypting twice is a
* no-op rather than a nested ciphertext. Doctrine can convert the same
* value more than once in a unit of work.
*/
public function encrypt(#[SensitiveParameter] string $plaintext): string
{
if ($this->isEncrypted($plaintext)) {
return $plaintext;
}
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
return self::PREFIX . base64_encode(
$nonce . sodium_crypto_secretbox($plaintext, $nonce, $this->key),
);
}
/**
* @throws EncryptionException when the value is not decryptable — a wrong
* key, or a truncated or tampered ciphertext
*/
public function decrypt(#[SensitiveParameter] string $ciphertext): string
{
if (false === $this->isEncrypted($ciphertext)) {
throw new EncryptionException('Value is not encrypted; refusing to decrypt.');
}
$raw = base64_decode(substr($ciphertext, strlen(self::PREFIX)), true);
if (false === $raw || strlen($raw) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES) {
throw new EncryptionException('Encrypted value is malformed or truncated.');
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$sealed = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$opened = sodium_crypto_secretbox_open($sealed, $nonce, $this->key);
if (false === $opened) {
throw new EncryptionException(
'Could not decrypt value. This usually means APP_ENCRYPTION_KEY '
. 'has changed since it was written.',
);
}
return $opened;
}
public function isEncrypted(string $value): bool
{
return str_starts_with($value, self::PREFIX);
}
}
+4 -3
View File
@@ -2,6 +2,7 @@
namespace App\Entity;
use App\Doctrine\Type\EncryptedStringType;
use App\Domain\Enum\MailProvider;
use App\Domain\Model\AccountModel;
use App\Domain\Enum\EmailAliasStatus;
@@ -50,7 +51,7 @@ class Account extends AccountModel
#[ORM\Column(length: 255)]
private ?string $username = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[ORM\Column(type: EncryptedStringType::NAME, nullable: true)]
private ?string $password = null;
#[ORM\Column(length: 255, nullable: true)]
@@ -68,10 +69,10 @@ class Account extends AccountModel
#[ORM\Column(length: 255, nullable: true)]
private ?string $oauthProvider = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[ORM\Column(type: EncryptedStringType::NAME, nullable: true)]
private ?string $oauthAccessToken = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[ORM\Column(type: EncryptedStringType::NAME, nullable: true)]
private ?string $oauthRefreshToken = null;
#[ORM\Column(nullable: true)]
+11
View File
@@ -11,6 +11,17 @@ class Kernel extends BaseKernel
{
use MicroKernelTrait;
/**
* Doctrine builds its types through a static registry with no access to
* the container, so EncryptedStringType is handed its Encryptor here —
* after boot, when the container exists, and before any entity is loaded.
*/
public function boot(): void
{
parent::boot();
EncryptedStringType::setEncryptor($this->container->get(Encryptor::class));
}
/**
* @return list<string> An array of allowed values for APP_ENV
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Tests\Doctrine\Type;
use App\Doctrine\Type\EncryptedStringType;
use App\Encryption\Encryptor;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Types\ConversionException;
use Doctrine\DBAL\Types\Type;
use PHPUnit\Framework\TestCase;
final class EncryptedStringTypeTest extends TestCase
{
private AbstractPlatform $platform;
private EncryptedStringType $type;
protected function setUp(): void
{
$this->platform = new PostgreSQLPlatform();
if (false === Type::hasType(EncryptedStringType::NAME)) {
Type::addType(EncryptedStringType::NAME, EncryptedStringType::class);
}
$type = Type::getType(EncryptedStringType::NAME);
self::assertInstanceOf(EncryptedStringType::class, $type);
$this->type = $type;
EncryptedStringType::setEncryptor(
new Encryptor(base64_encode(sodium_crypto_secretbox_keygen())),
);
}
public function testRoundTripsThroughTheDatabaseValue(): void
{
$stored = $this->type->convertToDatabaseValue('hunter2', $this->platform);
self::assertNotSame('hunter2', $stored);
self::assertSame('hunter2', $this->type->convertToPHPValue($stored, $this->platform));
}
public function testStoredValueIsCiphertext(): void
{
$stored = $this->type->convertToDatabaseValue('app-password', $this->platform);
self::assertIsString($stored);
self::assertStringStartsWith(Encryptor::PREFIX, $stored);
self::assertStringNotContainsString('app-password', $stored);
}
public function testNullPassesThroughBothWays(): void
{
self::assertNull($this->type->convertToDatabaseValue(null, $this->platform));
self::assertNull($this->type->convertToPHPValue(null, $this->platform));
}
public function testEmptyStringPassesThroughBothWays(): void
{
self::assertSame('', $this->type->convertToDatabaseValue('', $this->platform));
self::assertSame('', $this->type->convertToPHPValue('', $this->platform));
}
/**
* Rows written before the column was encrypted carry no prefix and must
* still be readable, or an upgraded instance cannot open its own accounts
* page to fix them.
*/
public function testLegacyPlaintextIsReadUnchanged(): void
{
self::assertSame(
'plaintext-from-before',
$this->type->convertToPHPValue('plaintext-from-before', $this->platform),
);
}
public function testUndecryptableValueRaisesAConversionError(): void
{
$stored = $this->type->convertToDatabaseValue('secret', $this->platform);
// Simulate the key having changed since the value was written.
EncryptedStringType::setEncryptor(
new Encryptor(base64_encode(sodium_crypto_secretbox_keygen())),
);
$this->expectException(ConversionException::class);
$this->expectExceptionMessageMatches('/APP_ENCRYPTION_KEY/');
$this->type->convertToPHPValue($stored, $this->platform);
}
public function testColumnIsStillText(): void
{
self::assertSame(
'TEXT',
$this->type->getSQLDeclaration([], $this->platform),
);
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Tests\Encryption;
use App\Encryption\EncryptionException;
use App\Encryption\Encryptor;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class EncryptorTest extends TestCase
{
private static function key(): string
{
return base64_encode(sodium_crypto_secretbox_keygen());
}
public function testRoundTripsAValue(): void
{
$encryptor = new Encryptor(self::key());
self::assertSame('hunter2', $encryptor->decrypt($encryptor->encrypt('hunter2')));
}
#[DataProvider('awkwardValues')]
public function testRoundTripsAwkwardValues(string $value): void
{
$encryptor = new Encryptor(self::key());
self::assertSame($value, $encryptor->decrypt($encryptor->encrypt($value)));
}
/**
* @return iterable<string, array{string}>
*/
public static function awkwardValues(): iterable
{
yield 'empty string' => [''];
yield 'utf-8' => ['pässwörd–✓'];
yield 'newlines' => ["line one\nline two"];
yield 'null byte' => ["before\0after"];
yield 'long' => [str_repeat('a', 8192)];
yield 'looks base64' => ['aGVsbG8gd29ybGQ='];
}
public function testCiphertextDoesNotContainThePlaintext(): void
{
$encrypted = new Encryptor(self::key())->encrypt('correct-horse-battery-staple');
self::assertStringNotContainsString('correct-horse-battery-staple', $encrypted);
self::assertStringStartsWith(Encryptor::PREFIX, $encrypted);
}
/**
* A fresh nonce per call, so equal inputs must not produce equal output —
* otherwise the ciphertext would leak which accounts share a password.
*/
public function testSameValueEncryptsDifferentlyEachTime(): void
{
$encryptor = new Encryptor(self::key());
self::assertNotSame($encryptor->encrypt('same'), $encryptor->encrypt('same'));
}
public function testEncryptingAnEncryptedValueIsANoOp(): void
{
$encryptor = new Encryptor(self::key());
$once = $encryptor->encrypt('value');
self::assertSame($once, $encryptor->encrypt($once));
}
public function testDecryptingWithTheWrongKeyFails(): void
{
$encrypted = new Encryptor(self::key())->encrypt('secret');
$this->expectException(EncryptionException::class);
new Encryptor(self::key())->decrypt($encrypted);
}
public function testTamperedCiphertextFails(): void
{
$encryptor = new Encryptor(self::key());
$encrypted = $encryptor->encrypt('secret');
// Flip one bit of the sealed bytes, leaving the prefix and the nonce
// structurally intact, so only the Poly1305 tag can catch it.
$raw = base64_decode(substr($encrypted, strlen(Encryptor::PREFIX)), true);
self::assertIsString($raw);
$raw[strlen($raw) - 1] = chr(ord($raw[strlen($raw) - 1]) ^ 0x01);
$tampered = Encryptor::PREFIX . base64_encode($raw);
$this->expectException(EncryptionException::class);
$encryptor->decrypt($tampered);
}
public function testDecryptingPlaintextIsRefused(): void
{
$this->expectException(EncryptionException::class);
new Encryptor(self::key())->decrypt('not-encrypted');
}
public function testRecognisesEncryptedValues(): void
{
$encryptor = new Encryptor(self::key());
self::assertTrue($encryptor->isEncrypted($encryptor->encrypt('x')));
self::assertFalse($encryptor->isEncrypted('x'));
}
public function testRejectsAKeyOfTheWrongLength(): void
{
$this->expectException(EncryptionException::class);
$this->expectExceptionMessageMatches('/must decode to 32 bytes/');
new Encryptor(base64_encode('too-short'));
}
public function testRejectsANonBase64Key(): void
{
$this->expectException(EncryptionException::class);
new Encryptor('not base64 !!!');
}
}
+85
View File
@@ -0,0 +1,85 @@
import { test, expect } from "@playwright/test";
/**
* Captures the README screenshots against the demo mailbox.
*
* Not part of the regression suite — it asserts nothing about behaviour, it
* just drives the UI to the states worth showing and writes PNGs into
* docs/screenshots/. Run it deliberately:
*
* npx playwright test screenshots.spec.ts --project=chromium
*
* It expects the demo data to be seeded already; without it the inbox is
* empty and the shots are useless.
*/
const OUT = "docs/screenshots";
test.use({ viewport: { width: 1440, height: 900 } });
test.describe("README screenshots", () => {
test("inbox", async ({ page }) => {
await page.goto("/mail/inbox");
await expect(page.locator("#message-list li").first()).toBeVisible();
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/inbox.png` });
});
test("thread", async ({ page }) => {
await page.goto("/mail/inbox");
await page
.locator("#message-list li")
.filter({ hasText: "Bookshelf dimensions" })
.first()
.click();
await expect(page.getByText("alcove is 182cm").first()).toBeVisible();
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/thread.png` });
});
test("compose", async ({ page }) => {
await page.goto("/mail/inbox");
await page.getByRole("link", { name: /compose/i }).first().click();
await page.waitForTimeout(900);
// An empty composer shows the chrome but not the point of it.
// Recipients are Tom Select chips, not a plain input.
// Pick from the autocomplete rather than pressing Enter — Enter in the
// recipient field submits the form and actually sends the message.
const dock = page.locator("#compose_dock");
const to = dock.locator(".ts-control").first();
await to.locator("input").fill("priya");
await page.locator(".ts-dropdown .option").first().click();
await dock.locator('input[name="compose[subject]"]')
.fill("Re: Bookshelf dimensions");
await dock.locator('[data-compose-toolbar-target="editor"]')
.fill(
"That clears it nicely — let's go with the 175cm unit. " +
"I'll order the trim at the same time so it all arrives together. " +
"No rush on the photos, whenever you get a chance is fine.",
);
await page.waitForTimeout(400);
await page.screenshot({ path: `${OUT}/compose.png` });
});
test("inbox dark", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" });
await page.goto("/mail/inbox");
await expect(page.locator("#message-list li").first()).toBeVisible();
await page.waitForTimeout(600);
await page.screenshot({ path: `${OUT}/inbox-dark.png` });
});
test("settings", async ({ page }) => {
await page.goto("/settings");
await page.waitForTimeout(900);
await page.screenshot({ path: `${OUT}/settings.png` });
});
test("admin", async ({ page }) => {
await page.goto("/admin");
await page.waitForTimeout(1200);
await page.screenshot({ path: `${OUT}/admin.png` });
});
});
+4 -1
View File
@@ -22,7 +22,10 @@ x-config:
app_public_url: &app_public_url ""
# -- Secrets --
app_secret: &app_secret ""
app_encryption_key: &app_encryption_key "" # base64 libsodium secretbox key (32 bytes)
# REQUIRED — the app refuses to start without it. Encrypts mailbox
# passwords and OAuth tokens at rest. Generate: openssl rand -base64 32
# Back this up separately from the database; losing it loses the credentials.
app_encryption_key: &app_encryption_key ""
db_password: &db_password ""
mercure_jwt: &mercure_jwt ""
# The password below MUST equal db_password (an anchor can't be spliced mid-string):