mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
encryption
This commit is contained in:
1 parent
3598a37184
commit
dbc02c1bfd
20 files changed
+916
-117
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Features
|
||||

|
||||
|
||||
- **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 |
|
||||
|  |  |
|
||||
| **Threaded conversations** — replies collapse into one conversation, newest expanded. | **Compose** — rich text, contact autocomplete, send from any account or alias. |
|
||||
|  |  |
|
||||
| **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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 !!!');
|
||||
}
|
||||
}
|
||||
@@ -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` });
|
||||
});
|
||||
});
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user