This commit is contained in:
Paul Lützner committed 2026-07-21 13:40:57 +02:00
1 parent b4bf06a8f3
commit cd3c78c205
24 files changed
+1486 -269

No files matched your search

Generated
+4
View File
@@ -176,6 +176,10 @@
<path value="$PROJECT_DIR$/vendor/guzzlehttp/guzzle" />
<path value="$PROJECT_DIR$/vendor/league/oauth2-client" />
<path value="$PROJECT_DIR$/vendor/league/oauth2-google" />
<path value="$PROJECT_DIR$/vendor/symfony/html-sanitizer" />
<path value="$PROJECT_DIR$/vendor/league/uri" />
<path value="$PROJECT_DIR$/vendor/league/uri-interfaces" />
<path value="$PROJECT_DIR$/vendor/tijsverkoyen/css-to-inline-styles" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4">
+4
View File
@@ -162,6 +162,10 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-factory" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-message" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/uri" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/uri-interfaces" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/html-sanitizer" />
<excludeFolder url="file://$MODULE_DIR$/vendor/tijsverkoyen/css-to-inline-styles" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
+50 -75
View File
@@ -1,6 +1,6 @@
# pl_mail
A self-hosted, Gmail-style mail client built with Symfony. Clone the repo, run `docker compose up`, and you have a fully working IMAP mail client with real-time push updates, threaded conversations, and multi-account support.
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.
![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)
@@ -9,25 +9,30 @@ A self-hosted, Gmail-style mail client built with Symfony. Clone the repo, run `
## Features
- **Multi-account IMAP** — add as many accounts as you like, each with independent folder sync
- **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, no refresh needed
- **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 in localStorage
- **Async message sync** — Symfony Messenger with Doctrine transport, batched and memory-safe (2 GB limit, `em->clear()` between batches)
- **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
## Stack
| Layer | Technology |
|---|---|
| Backend | Symfony 8, Doctrine ORM, PHP 8.4+ |
| Database | PostgreSQL (SQLite optional) |
| Database | PostgreSQL 18 |
| IMAP | webklex/php-imap |
| Gmail | Gmail REST + Batch API, OAuth2 (league/oauth2-client) |
| Async | Symfony Messenger, Doctrine transport |
| Push | Mercure (built into FrankenPHP) |
| Push | Mercure hub, Gmail Cloud Pub/Sub watch |
| Frontend | AssetMapper, Tailwind v4, Hotwire Turbo, Stimulus |
| Runtime | FrankenPHP |
| Dev tooling | Docker Compose, Adminer, Mailpit |
@@ -35,81 +40,41 @@ A self-hosted, Gmail-style mail client built with Symfony. Clone the repo, run `
## Requirements
- Docker and Docker Compose
- That's it
- That's it (Google OAuth credentials additionally required to connect Gmail accounts — see [Environment Variables](#environment-variables))
## Getting Started
```bash
git clone https://github.com/karatektus/pl_mail.git
cd pl_mail
cp .env .env.local # adjust DATABASE_URL, MERCURE_JWT_SECRET, etc.
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. Register a user, add an IMAP account, and the first sync kicks off immediately.
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.
## Console Commands
| Command | Description |
|---|---|
| `app:imap:sync-mailboxes` | Discover and sync folder list for all active accounts |
| `app:imap:sync-messages` | Fetch new messages for all sync-enabled mailboxes |
| `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:reset` | Truncate message, thread, and mailbox tables (useful during development) |
| `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.
## Architecture
```
Browser
│ Turbo / fetch + History API
▼
FrankenPHP (Symfony)
│ dispatches
▼
Symfony Messenger ──► SyncMailboxMessageHandler
│ syncs via
▼
MessageSyncer (webklex/php-imap)
│ publishes via
▼
Mercure Hub ──► Browser (Turbo Stream)
IMAP IDLE
app:imap:supervise
└─ app:imap:idle [mailbox 1] ─┐
└─ app:imap:idle [mailbox 2] ─┼──► dispatches SyncMailboxMessage
└─ app:imap:idle [mailbox N] ─┘
```
## Project Structure
```
src/
├── Command/ # Console commands (sync, idle, supervise, reset)
├── Controller/ # MailController, AccountController
├── Domain/
│ ├── Enum/ # MessageTab, ThreadingMethod
│ └── Helper/ # ImapConnectionFactory, AttachmentStorageHelper
├── Entity/ # Account, Mailbox, Message, MessageThread, MessagePart
├── Form/ # Account add form
├── Message/ # SyncMailboxMessage
├── MessageHandler/ # SyncMailboxMessageHandler
├── Repository/ # Doctrine repositories
├── Service/Imap/ # MailboxSyncer, MessageSyncer, MessageThreader
└── Twig/ # AccountsGlobal (global sidebar accounts)
templates/
├── _layout/ # Base layout, mail shell
├── _partials/ # Topbar, sidebar, modal, message row
└── mail/ # Inbox, mailbox, thread, account form views
assets/controllers/ # Stimulus controllers
```
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
@@ -117,22 +82,32 @@ assets/controllers/ # Stimulus controllers
|---|---|---|
| `DATABASE_URL` | PostgreSQL connection string | see `.env` |
| `MESSENGER_TRANSPORT_DSN` | Messenger transport | `doctrine://default?auto_setup=0` |
| `MERCURE_URL` | Internal Mercure hub URL | `https://mercure/.well-known/mercure` |
| `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:
```bash
php -r 'echo base64_encode(sodium_crypto_secretbox_keygen());'
```
## Roadmap
- [ ] Unified inbox view (repository ready, template pending)
- [ ] Toolbar: select-all, refresh, pagination
- [ ] Star / archive / delete / mark-read backend endpoints
- [ ] Sanitize body HTML (XSS — currently srcdoc iframe)
- [ ] Flag sync pass (flags only captured on initial sync)
- [ ] OAuth2 (Gmail, Outlook)
- [ ] Compose and send
- [ ] Search
- [ ] Threading smoke test against real mail
- [ ] 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
## License
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["body", "snippet", "toggleBtn", "chevron", "shadowHost"];
static targets = ["body", "snippet", "toggleBtn", "chevron"];
static values = { expanded: Boolean };
connect() {
@@ -21,30 +21,4 @@ export default class extends Controller {
}
}
shadowHostTargetConnected(host) {
const html = host.dataset.html;
if (!html) {
return;
}
const shadow = host.attachShadow({ mode: "open" });
// Styles are scoped entirely inside the shadow root — zero bleed into
// the parent page, and the parent page's Tailwind cannot leak in either.
const style = document.createElement("style");
style.textContent = [
"*, *::before, *::after { box-sizing: border-box; }",
"body { margin: 0; padding: 0; font-family: sans-serif; font-size: 14px; line-height: 1.5; color: #111; word-wrap: break-word; overflow-wrap: break-word; }",
"img { max-width: 100%; height: auto; display: block; }",
"a { color: #2563eb; }",
"pre, blockquote { overflow-x: auto; }",
"table { max-width: 100%; border-collapse: collapse; }",
].join(" ");
shadow.appendChild(style);
const wrapper = document.createElement("div");
wrapper.innerHTML = html;
shadow.appendChild(wrapper);
}
}
+6
View File
@@ -73,6 +73,12 @@ services:
start_period: 60s
volumes:
- database_data:/var/lib/postgresql
command:
- "postgres"
- "-c"
- "shared_preload_libraries=pg_stat_statements"
- "-c"
- "pg_stat_statements.track=top"
mercure:
image: dunglas/mercure
+2
View File
@@ -25,6 +25,7 @@
"symfony/flex": "^2",
"symfony/form": "8.1.*",
"symfony/framework-bundle": "8.1.*",
"symfony/html-sanitizer": "8.1.*",
"symfony/http-client": "8.1.*",
"symfony/intl": "8.1.*",
"symfony/mailer": "8.1.*",
@@ -49,6 +50,7 @@
"symfony/web-link": "8.1.*",
"symfony/yaml": "8.1.*",
"symfonycasts/tailwind-bundle": "^0.14.0",
"tijsverkoyen/css-to-inline-styles": "^2.4",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0",
"webklex/php-imap": "^6.2"
Generated
+379 -70
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "25b734262146938a3da5806db6d75dd5",
"content-hash": "7402529ad79390c5a85219a8dedbe7c5",
"packages": [
{
"name": "carbonphp/carbon-doctrine-types",
@@ -2402,6 +2402,188 @@
},
"time": "2026-03-23T14:25:21+00:00"
},
{
"name": "league/uri",
"version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri.git",
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4",
"reference": "08cf38e3924d4f56238125547b5720496fac8fd4",
"shasum": ""
},
"require": {
"league/uri-interfaces": "^7.8.1",
"php": "^8.1",
"psr/http-factory": "^1"
},
"conflict": {
"league/uri-schemes": "^1.0"
},
"suggest": {
"ext-bcmath": "to improve IPV4 host parsing",
"ext-dom": "to convert the URI into an HTML anchor tag",
"ext-fileinfo": "to create Data URI from file contennts",
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"ext-uri": "to use the PHP native URI class",
"jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
"league/uri-components": "to provide additional tools to manipulate URI objects components",
"league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
"php-64bit": "to improve IPV4 host parsing",
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
}
},
"autoload": {
"psr-4": {
"League\\Uri\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ignace Nyamagana Butera",
"email": "nyamsprod@gmail.com",
"homepage": "https://nyamsprod.com"
}
],
"description": "URI manipulation library",
"homepage": "https://uri.thephpleague.com",
"keywords": [
"URN",
"data-uri",
"file-uri",
"ftp",
"hostname",
"http",
"https",
"middleware",
"parse_str",
"parse_url",
"psr-7",
"query-string",
"querystring",
"rfc2141",
"rfc3986",
"rfc3987",
"rfc6570",
"rfc8141",
"uri",
"uri-template",
"url",
"ws"
],
"support": {
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
"source": "https://github.com/thephpleague/uri/tree/7.8.1"
},
"funding": [
{
"url": "https://github.com/sponsors/nyamsprod",
"type": "github"
}
],
"time": "2026-03-15T20:22:25+00:00"
},
{
"name": "league/uri-interfaces",
"version": "7.8.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/uri-interfaces.git",
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928",
"reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928",
"shasum": ""
},
"require": {
"ext-filter": "*",
"php": "^8.1",
"psr/http-message": "^1.1 || ^2.0"
},
"suggest": {
"ext-bcmath": "to improve IPV4 host parsing",
"ext-gmp": "to improve IPV4 host parsing",
"ext-intl": "to handle IDN host with the best performance",
"php-64bit": "to improve IPV4 host parsing",
"rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
"symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "7.x-dev"
}
},
"autoload": {
"psr-4": {
"League\\Uri\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ignace Nyamagana Butera",
"email": "nyamsprod@gmail.com",
"homepage": "https://nyamsprod.com"
}
],
"description": "Common tools for parsing and resolving RFC3987/RFC3986 URI",
"homepage": "https://uri.thephpleague.com",
"keywords": [
"data-uri",
"file-uri",
"ftp",
"hostname",
"http",
"https",
"parse_str",
"parse_url",
"psr-7",
"query-string",
"querystring",
"rfc3986",
"rfc3987",
"rfc6570",
"uri",
"url",
"ws"
],
"support": {
"docs": "https://uri.thephpleague.com",
"forum": "https://thephpleague.slack.com",
"issues": "https://github.com/thephpleague/uri-src/issues",
"source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1"
},
"funding": [
{
"url": "https://github.com/sponsors/nyamsprod",
"type": "github"
}
],
"time": "2026-03-08T20:05:35+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -3979,6 +4161,75 @@
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/css-selector",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd",
"reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd",
"shasum": ""
},
"require": {
"php": ">=8.4.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\CssSelector\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Jean-François Simon",
"email": "jeanfrancois.simon@sensiolabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/dependency-injection",
"version": "v8.1.0",
@@ -5153,6 +5404,78 @@
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/html-sanitizer",
"version": "v8.1.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/html-sanitizer.git",
"reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/09e1f2f9a3c8dcdca072587dc71999c1921c07cb",
"reference": "09e1f2f9a3c8dcdca072587dc71999c1921c07cb",
"shasum": ""
},
"require": {
"ext-dom": "*",
"league/uri": "^6.5|^7.0",
"php": ">=8.4.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\HtmlSanitizer\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an object-oriented API to sanitize untrusted HTML input for safe insertion into a document's DOM.",
"homepage": "https://symfony.com",
"keywords": [
"Purifier",
"html",
"sanitizer"
],
"support": {
"source": "https://github.com/symfony/html-sanitizer/tree/v8.1.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-06-06T11:11:44+00:00"
},
{
"name": "symfony/http-client",
"version": "v8.1.0",
@@ -9544,6 +9867,61 @@
],
"time": "2026-06-21T15:46:15+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
"reference": "f0292ccf0ec75843d65027214426b6b163b48b41"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41",
"reference": "f0292ccf0ec75843d65027214426b6b163b48b41",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"php": "^7.4 || ^8.0",
"symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^8.5.21 || ^9.5.10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"TijsVerkoyen\\CssToInlineStyles\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Tijs Verkoyen",
"email": "css_to_inline_styles@verkoyen.eu",
"role": "Developer"
}
],
"description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.",
"homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
"support": {
"issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
"source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0"
},
"time": "2025-12-02T11:56:42+00:00"
},
{
"name": "twig/extra-bundle",
"version": "v3.24.0",
@@ -11863,75 +12241,6 @@
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/css-selector",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd",
"reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd",
"shasum": ""
},
"require": {
"php": ">=8.4.1"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\CssSelector\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Jean-François Simon",
"email": "jeanfrancois.simon@sensiolabs.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/debug-bundle",
"version": "v8.1.0",
+1 -1
View File
@@ -655,7 +655,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* uuid47_secret?: scalar|Param|null, // A high-entropy secret used by the "uuid47_transformer" service. Defaults to "kernel.secret". // Default: null
* },
* html_sanitizer?: bool|array{ // HtmlSanitizer configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* sanitizers?: array<string, array{ // Default: []
* default_action?: "drop"|"block"|"allow"|Param, // Defines how the sanitizer must behave by default.
* allow_safe_elements?: bool|Param, // Allows "safe" elements and attributes. // Default: false
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260721105115 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE message ADD body_html_safe TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE message DROP body_html_safe');
}
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Command\Backfill;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
/**
* A single, self-contained backfill unit. Each task owns its own iteration and
* batching — different tasks touch different entities, so there is no shared
* loop to factor out.
*/
#[AutoconfigureTag('app.backfill_task')]
interface BackfillTaskInterface
{
/** CLI key, e.g. "safe-html". */
public function getName(): string;
/** One-line description shown in the task listing. */
public function getDescription(): string;
/** Returns a Command exit code (0 = success). */
public function run(SymfonyStyle $io): int;
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Command\Backfill;
use App\Entity\Message;
use App\Repository\MessageRepository;
use App\Service\Mail\MailBodySanitizer;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Populates Message::bodyHtmlSafe for messages that have an HTML body but no
* sanitized copy yet — the render path reads bodyHtmlSafe exclusively.
*
* Keyset pagination by id (not offset, not a shrinking IS NULL cursor) so the
* scan can flush + clear each batch without a server-side cursor, and can't
* loop on rows the sanitizer legitimately leaves null (whitespace-only bodies).
*/
final readonly class SafeHtmlBackfillTask implements BackfillTaskInterface
{
private const int BATCH_SIZE = 100;
public function __construct(
private MessageRepository $messageRepository,
private MailBodySanitizer $bodySanitizer,
private EntityManagerInterface $em,
) {}
public function getName(): string
{
return 'safe-html';
}
public function getDescription(): string
{
return 'Regenerate Message.bodyHtmlSafe for messages with an HTML body but no sanitized copy.';
}
public function run(SymfonyStyle $io): int
{
$total = $this->countPending();
if (0 === $total) {
$io->success('Nothing to backfill — every HTML body already has a sanitized copy.');
return Command::SUCCESS;
}
$io->progressStart($total);
$lastId = 0;
$processed = 0;
while (true) {
$messages = $this->pendingBatch($lastId);
if (count($messages) === 0) {
break;
}
foreach ($messages as $message) {
$lastId = (int) $message->getId();
$this->bodySanitizer->sanitize($message);
++$processed;
$io->progressAdvance();
}
$this->em->flush();
$this->em->clear();
}
$io->progressFinish();
$io->success(sprintf('Backfilled %d message(s).', $processed));
return Command::SUCCESS;
}
private function countPending(): int
{
return (int) $this->pendingQueryBuilder(0)
->select('COUNT(m.id)')
->getQuery()
->getSingleScalarResult();
}
/**
* @return list<Message>
*/
private function pendingBatch(int $afterId): array
{
return $this->pendingQueryBuilder($afterId)
->orderBy('m.id', 'ASC')
->setMaxResults(self::BATCH_SIZE)
->getQuery()
->getResult();
}
private function pendingQueryBuilder(int $afterId): \Doctrine\ORM\QueryBuilder
{
return $this->messageRepository->createQueryBuilder('m')
->andWhere('m.id > :afterId')
->andWhere('m.bodyHtml IS NOT NULL')
->andWhere("m.bodyHtml <> ''")
->andWhere('m.bodyHtmlSafe IS NULL')
->setParameter('afterId', $afterId);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Command\Backfill\BackfillTaskInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
/**
* One-off backfills against existing data.
*
* Data is disposable, but some regenerations (like the sanitized HTML body)
* are far cheaper than a full delete-and-resync — this is where they live.
*
* Adding a task: implement BackfillTaskInterface. It is auto-tagged and picked
* up here with no config. Run `app:backfill` with no argument to list them.
*/
#[AsCommand(
name: 'app:backfill',
description: 'Run a one-off backfill task against existing data.',
)]
final class BackfillCommand extends Command
{
/** @var array<string, BackfillTaskInterface> */
private array $tasks = [];
/**
* @param iterable<BackfillTaskInterface> $tasks
*/
public function __construct(
#[AutowireIterator('app.backfill_task')]
iterable $tasks,
) {
parent::__construct();
foreach ($tasks as $task) {
$this->tasks[$task->getName()] = $task;
}
}
protected function configure(): void
{
$this
->addArgument('task', InputArgument::OPTIONAL, 'The backfill task to run')
->setHelp('Run "app:backfill" with no argument to list available tasks.');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$name = $input->getArgument('task');
if (null === $name) {
$this->listTasks($io);
return Command::SUCCESS;
}
if (false === isset($this->tasks[$name])) {
$io->error(sprintf('Unknown backfill task "%s".', $name));
$this->listTasks($io);
return Command::FAILURE;
}
$task = $this->tasks[$name];
$io->title(sprintf('Backfill: %s', $task->getName()));
$io->text($task->getDescription());
$io->newLine();
return $task->run($io);
}
private function listTasks(SymfonyStyle $io): void
{
if (count($this->tasks) === 0) {
$io->warning('No backfill tasks are registered.');
return;
}
$io->title('Available backfill tasks');
$io->listing(array_map(
static fn(BackfillTaskInterface $task): string => sprintf('%s — %s', $task->getName(), $task->getDescription()),
array_values($this->tasks),
));
$io->text('Run: <info>app:backfill <task></info>');
}
}
@@ -6,6 +6,7 @@ namespace App\Controller\Admin;
use App\Repository\LogEntryRepository;
use App\Service\Monitoring\AdminMonitoringService;
use App\Service\Monitoring\DbPerformanceService;
use App\Service\Monitoring\QueueMonitor;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -17,6 +18,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
final class AdminDashboardController extends AbstractController
{
private const array SECTIONS = ['system', 'database', 'logs'];
private const int LOGS_PER_PAGE = 100;
/** Monolog numeric levels offered as minimum-level filters. */
@@ -32,12 +34,21 @@ final class AdminDashboardController extends AbstractController
private readonly AdminMonitoringService $monitoring,
private readonly QueueMonitor $queueMonitor,
private readonly LogEntryRepository $logEntryRepository,
private readonly DbPerformanceService $dbPerformance,
) {}
#[Route('', name: 'dashboard')]
public function index(): Response
public function index(Request $request): Response
{
return $this->render('admin/index.html.twig');
$section = (string) $request->query->get('section', 'system');
if (false === in_array($section, self::SECTIONS, true)) {
$section = 'system';
}
return $this->render('admin/index.html.twig', [
'section' => $section,
]);
}
/**
@@ -89,6 +100,32 @@ final class AdminDashboardController extends AbstractController
]);
}
#[Route('/db', name: 'db')]
public function db(): Response
{
return $this->render('admin/_db_frame.html.twig', [
'statStatementsAvailable' => $this->dbPerformance->isStatStatementsAvailable(),
'slowestByMean' => $this->dbPerformance->slowestByMean(),
'heaviestByTotal' => $this->dbPerformance->heaviestByTotal(),
'activeQueries' => $this->dbPerformance->activeQueries(),
'gauges' => $this->dbPerformance->healthGauges(),
]);
}
#[Route('/db/reset-stats', name: 'db_reset', methods: ['POST'])]
public function resetDbStats(Request $request): Response
{
$token = (string) $request->request->get('_token', '');
if (false === $this->isCsrfTokenValid('admin_db_reset', $token)) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->dbPerformance->resetStatStatements();
return $this->redirectToRoute('app_admin_dashboard');
}
#[Route('/failed/{id}/retry', name: 'failed_retry', methods: ['POST'])]
public function retryFailed(Request $request, string $id): Response
{
+15
View File
@@ -102,6 +102,9 @@ class Message extends MessageModel
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $bodyHtml = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $bodyHtmlSafe = null;
/**
* @var Collection<int, MessagePart>
*/
@@ -551,6 +554,18 @@ class Message extends MessageModel
return $this->labels->contains($label);
}
public function getBodyHtmlSafe(): ?string
{
return $this->bodyHtmlSafe;
}
public function setBodyHtmlSafe(?string $bodyHtmlSafe): static
{
$this->bodyHtmlSafe = $bodyHtmlSafe;
return $this;
}
public function getAccount(): ?Account
{
if (null !== $this->mailbox) {
@@ -15,6 +15,7 @@ use App\Service\Gmail\GmailMessageBuilder;
use App\Service\HarvestContactsService;
use App\Service\Imap\MessageThreader;
use App\Service\Mail\GmailApiClient;
use App\Service\Mail\MailBodySanitizer;
use App\Service\Mail\SyncNotifier;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -38,6 +39,7 @@ final readonly class SyncGmailMessageBatchHandler
private HarvestContactsService $harvestService,
private SyncNotifier $syncNotifier,
private MessageBusInterface $bus,
private MailBodySanitizer $sanitizer,
private EntityManagerInterface $em,
private LoggerInterface $logger,
) {}
@@ -184,7 +186,10 @@ final readonly class SyncGmailMessageBatchHandler
$this->em->flush();
foreach ($built as $item) {
$this->sanitizer->sanitize($item['message']);
try {
$this->messageThreader->assignThread($item['message'], $item['account']);
} catch (\Throwable $e) {
+3
View File
@@ -9,6 +9,7 @@ use App\Entity\Message;
use App\Entity\MessagePart;
use App\Repository\MailboxRepository;
use App\Repository\MessageRepository;
use App\Service\Mail\MailBodySanitizer;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -26,6 +27,7 @@ class MessageSyncer
private readonly LoggerInterface $logger,
private readonly MessageThreader $messageThreader,
private readonly MessageRepository $messageRepository,
private readonly MailBodySanitizer $sanitizer,
) {}
public function syncMailbox(Mailbox $mailbox, Client $client): void
@@ -148,6 +150,7 @@ class MessageSyncer
// Pass 2 — assign threads now that all messages exist in DB
foreach ($messages as $message) {
$this->sanitizer->sanitize($message);
try {
$this->messageThreader->assignThread(
$message,
+165
View File
@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Entity\Message;
use Psr\Log\LoggerInterface;
use Symfony\Component\HtmlSanitizer\HtmlSanitizer;
use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use TijsVerkoyen\CssToInlineStyles\CssToInlineStyles;
/**
* Produces the render-ready, isolated HTML stored on Message::bodyHtmlSafe.
*
* Runs once at ingest (both syncers), AFTER the message and its MessageParts
* have been flushed — inline `cid:` refs need the parts' IDs to resolve.
*
* The pipeline mirrors how Gmail renders mail inline in its own DOM rather than
* in an iframe:
* 1. Resolve `cid:` references (img src + url() in CSS) to our lazy
* attachment route.
* 2. Flatten <style> blocks onto elements as inline styles — the inline
* styles become the sole carrier of the email's visual design.
* 3. Sanitize: drop scripts / forms / iframes / <style> / classes, force
* links to open away from the app, keep the inline styles.
*/
final readonly class MailBodySanitizer
{
/**
* Symfony's HtmlSanitizer truncates input past this length. The default is
* 20 KB, which most real emails exceed — set generously (Gmail itself clips
* around 102 KB).
*/
private const int MAX_INPUT_LENGTH = 2_000_000;
public function __construct(
private UrlGeneratorInterface $urlGenerator,
private LoggerInterface $logger,
)
{
}
public function sanitize(Message $message): void
{
$html = $message->getBodyHtml();
if (null === $html || '' === trim($html)) {
$message->setBodyHtmlSafe(null);
return;
}
$html = $this->resolveCids($html, $message);
$html = $this->inlineStyles($html);
$html = $this->buildSanitizer()->sanitize($html);
$message->setBodyHtmlSafe($html);
}
/**
* Rewrite every `cid:` reference — img src and url(...) inside CSS — to the
* lazy attachment route. Absolute-PATH (not URL) so no request/host context
* is needed inside the worker.
*/
private function resolveCids(string $html, Message $message): string
{
$map = [];
foreach ($message->getMessageParts() as $part) {
$cid = $part->getContentId();
if (null === $cid || '' === $cid) {
continue;
}
$map[strtolower($cid)] = $this->urlGenerator->generate(
'app_mail_attachment',
['id' => $part->getId()],
UrlGeneratorInterface::ABSOLUTE_PATH,
);
}
if (count($map) === 0) {
return $html;
}
return (string)preg_replace_callback(
'/cid:([^"\'\)\s>]+)/i',
static function (array $m) use ($map): string {
$cid = strtolower(trim($m[1]));
return $map[$cid] ?? ('cid:' . $m[1]);
},
$html,
);
}
private function inlineStyles(string $html): string
{
// CssToInlineStyles converts each selector to XPath and runs it. An exotic
// or malformed selector yields invalid XPath, which DOMXPath emits as a
// warning — and Symfony's handler promotes that to an exception, aborting
// inlining for the entire message. Swallow warnings for the duration so the
// library skips only the offending selector; genuine failures still fall
// through to the catch and sanitize the raw body.
set_error_handler(static fn(): bool => true, E_WARNING);
try {
return new CssToInlineStyles()->convert($html);
} catch (\Throwable $e) {
$this->logger->warning('MailBodySanitizer: CSS inlining failed, sanitizing raw body', [
'error' => $e->getMessage(),
]);
return $html;
} finally {
restore_error_handler();
}
}
private function buildSanitizer(): HtmlSanitizer
{
$config = new HtmlSanitizerConfig()
// Structurally-safe baseline (text, lists, tables, …).
->allowSafeElements()
// Drop these entirely — content included — so nothing leaks as text.
// <style> is redundant after inlining; the rest are never wanted.
->dropElement('head')
->dropElement('title')
->dropElement('style')
->dropElement('script')
->dropElement('iframe')
->dropElement('object')
->dropElement('embed')
->dropElement('form')
// Images: our resolved attachment route + remote/data sources.
->allowElement('img', ['src', 'alt', 'title', 'width', 'height', 'style'])
// Links, forced to open away from the app.
->allowElement('a', ['href', 'title', 'style'])
->forceAttribute('a', 'target', '_blank')
->forceAttribute('a', 'rel', 'noopener noreferrer')
// Presentational table attributes emails still lean on.
->allowAttribute('bgcolor', '*')
->allowAttribute('align', '*')
->allowAttribute('valign', '*')
->allowAttribute('width', '*')
->allowAttribute('height', '*')
// The inlined styles ARE the visual design — must survive on every
// element the inliner touched (td, span, div, p, …). Deliberately
// NOT allowing class/id: stripping them is Gmail-faithful and avoids
// email selectors colliding with the app's own CSS in the shared DOM.
->allowAttribute('style', '*')
// URL policy. http stays allowed; upgrading/proxying remote images
// is a render-time concern (image proxy), not an ingest one.
->allowLinkSchemes(['https', 'http', 'mailto', 'tel'])
->allowRelativeLinks()
->allowMediaSchemes(['https', 'http', 'data'])
->allowRelativeMedias()
->withMaxInputLength(self::MAX_INPUT_LENGTH);
return new HtmlSanitizer($config);
}
}
@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Service\Monitoring;
use Doctrine\DBAL\Connection;
/**
* Postgres performance read-model for the admin dashboard.
*
* Slow-query aggregation relies on the pg_stat_statements contrib extension
* (one-time shared_preload_libraries + CREATE EXTENSION). Everything else —
* live activity, cache-hit ratio, connections, transaction stats — uses core
* catalog views and works unconditionally. When the extension is absent the
* slow-query methods return an empty set and isStatStatementsAvailable()
* reports false so the UI can prompt to enable it.
*/
final class DbPerformanceService
{
/** Only surface statements slower than this mean (ms) to cut noise. */
private const float SLOW_MEAN_MS = 5.0;
/** Display cap for query text — full text is rarely needed on a board. */
private const int QUERY_PREVIEW_LEN = 400;
private ?bool $statStatementsAvailable = null;
public function __construct(
private readonly Connection $connection,
) {}
public function isStatStatementsAvailable(): bool
{
if (null !== $this->statStatementsAvailable) {
return $this->statStatementsAvailable;
}
try {
$available = $this->connection->fetchOne(
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements')",
);
$this->statStatementsAvailable = (bool) $available;
} catch (\Throwable) {
$this->statStatementsAvailable = false;
}
return $this->statStatementsAvailable;
}
/**
* Top statements by mean execution time (the "which queries are slow"
* view), scoped to the current database.
*
* @return list<array{query: string, calls: int, meanMs: float, maxMs: float, totalMs: float, rows: int, hitPct: float|null}>
*/
public function slowestByMean(int $limit = 20): array
{
return $this->fetchStatements('mean_exec_time', $limit, true);
}
/**
* Top statements by cumulative time (the "where does the DB spend its
* time overall" view — a fast query called millions of times lands here
* but not in slowestByMean).
*
* @return list<array{query: string, calls: int, meanMs: float, maxMs: float, totalMs: float, rows: int, hitPct: float|null}>
*/
public function heaviestByTotal(int $limit = 20): array
{
return $this->fetchStatements('total_exec_time', $limit, false);
}
/**
* Currently executing (non-idle) backends, longest-running first. Needs
* no extension — this is live activity, the thing to look at when the DB
* feels stuck right now.
*
* @return list<array{pid: int, state: string, waitEvent: string|null, durationSeconds: int|null, query: string}>
*/
public function activeQueries(): array
{
try {
$rows = $this->connection->fetchAllAssociative(
"SELECT
pid,
state,
wait_event_type,
wait_event,
EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_seconds,
query
FROM pg_stat_activity
WHERE datname = current_database()
AND pid <> pg_backend_pid()
AND state IS DISTINCT FROM 'idle'
AND query <> ''
ORDER BY duration_seconds DESC NULLS LAST
LIMIT 25",
);
} catch (\Throwable) {
return [];
}
$active = [];
foreach ($rows as $row) {
$waitEvent = null;
if (null !== $row['wait_event']) {
$waitEvent = trim(((string) ($row['wait_event_type'] ?? '')) . ':' . (string) $row['wait_event'], ':');
}
$active[] = [
'pid' => (int) $row['pid'],
'state' => (string) $row['state'],
'waitEvent' => '' !== (string) $waitEvent ? $waitEvent : null,
'durationSeconds' => null !== $row['duration_seconds'] ? (int) $row['duration_seconds'] : null,
'query' => $this->truncate((string) $row['query']),
];
}
return $active;
}
/**
* Database-wide health gauges from pg_stat_database for the current DB.
*
* @return array{connections: int, cacheHitPct: float|null, commits: int, rollbacks: int, rollbackPct: float|null, deadlocks: int, tempFiles: int, tempBytes: int}
*/
public function healthGauges(): array
{
$empty = [
'connections' => 0,
'cacheHitPct' => null,
'commits' => 0,
'rollbacks' => 0,
'rollbackPct' => null,
'deadlocks' => 0,
'tempFiles' => 0,
'tempBytes' => 0,
];
try {
$row = $this->connection->fetchAssociative(
"SELECT
numbackends,
xact_commit,
xact_rollback,
blks_read,
blks_hit,
deadlocks,
temp_files,
temp_bytes
FROM pg_stat_database
WHERE datname = current_database()",
);
} catch (\Throwable) {
return $empty;
}
if (false === $row) {
return $empty;
}
$blksHit = (int) $row['blks_hit'];
$blksRead = (int) $row['blks_read'];
$totalBlk = $blksHit + $blksRead;
$commits = (int) $row['xact_commit'];
$rollbacks = (int) $row['xact_rollback'];
$totalTxn = $commits + $rollbacks;
return [
'connections' => (int) $row['numbackends'],
'cacheHitPct' => $totalBlk > 0 ? round(($blksHit / $totalBlk) * 100, 2) : null,
'commits' => $commits,
'rollbacks' => $rollbacks,
'rollbackPct' => $totalTxn > 0 ? round(($rollbacks / $totalTxn) * 100, 2) : null,
'deadlocks' => (int) $row['deadlocks'],
'tempFiles' => (int) $row['temp_files'],
'tempBytes' => (int) $row['temp_bytes'],
];
}
/**
* Reset the pg_stat_statements accumulators so the next reading measures
* a fresh window. No-op (returns false) when the extension is absent.
*/
public function resetStatStatements(): bool
{
if (false === $this->isStatStatementsAvailable()) {
return false;
}
try {
$this->connection->executeStatement('SELECT pg_stat_statements_reset()');
return true;
} catch (\Throwable) {
return false;
}
}
// ── Private ───────────────────────────────────────────────────────────────
/**
* @return list<array{query: string, calls: int, meanMs: float, maxMs: float, totalMs: float, rows: int, hitPct: float|null}>
*/
private function fetchStatements(string $orderColumn, int $limit, bool $applyMeanFloor): array
{
if (false === $this->isStatStatementsAvailable()) {
return [];
}
// shared_blks_hit/read may be absent on very old versions; COALESCE
// guards the hit-ratio expression regardless.
$meanFloor = true === $applyMeanFloor
? 'AND s.mean_exec_time >= :floor'
: '';
$sql = "SELECT
s.query,
s.calls,
s.mean_exec_time,
s.max_exec_time,
s.total_exec_time,
s.rows,
CASE
WHEN (COALESCE(s.shared_blks_hit, 0) + COALESCE(s.shared_blks_read, 0)) > 0
THEN (COALESCE(s.shared_blks_hit, 0)::float
/ (COALESCE(s.shared_blks_hit, 0) + COALESCE(s.shared_blks_read, 0))) * 100
ELSE NULL
END AS hit_pct
FROM pg_stat_statements s
JOIN pg_database d ON d.oid = s.dbid
WHERE d.datname = current_database()
AND s.query NOT LIKE '%pg_stat_statements%'
{$meanFloor}
ORDER BY s.{$orderColumn} DESC
LIMIT :limit";
$params = ['limit' => $limit];
if (true === $applyMeanFloor) {
$params['floor'] = self::SLOW_MEAN_MS;
}
try {
$rows = $this->connection->fetchAllAssociative($sql, $params);
} catch (\Throwable) {
// Column-name mismatch on an unexpected PG version, or a
// permissions issue — degrade to empty rather than 500.
return [];
}
$statements = [];
foreach ($rows as $row) {
$statements[] = [
'query' => $this->truncate((string) $row['query']),
'calls' => (int) $row['calls'],
'meanMs' => round((float) $row['mean_exec_time'], 2),
'maxMs' => round((float) $row['max_exec_time'], 2),
'totalMs' => round((float) $row['total_exec_time'], 2),
'rows' => (int) $row['rows'],
'hitPct' => null !== $row['hit_pct'] ? round((float) $row['hit_pct'], 1) : null,
];
}
return $statements;
}
private function truncate(string $query): string
{
$query = trim(preg_replace('/\s+/', ' ', $query) ?? $query);
if (mb_strlen($query) <= self::QUERY_PREVIEW_LEN) {
return $query;
}
return mb_substr($query, 0, self::QUERY_PREVIEW_LEN) . '…';
}
}
-63
View File
@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Entity\Message;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
final class MailBodyExtension extends AbstractExtension
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
) {}
public function getFilters(): array
{
return [
new TwigFilter('resolve_cid', [$this, 'resolveCid']),
];
}
/**
* Rewrite `cid:` references in an email body to point at our attachment
* route, so inline images load lazily through AttachmentResolver.
*/
public function resolveCid(?string $html, Message $message): string
{
if (null === $html || '' === $html) {
return (string) $html;
}
$map = [];
foreach ($message->getMessageParts() as $part) {
$cid = $part->getContentId();
if (null === $cid || '' === $cid) {
continue;
}
$map[strtolower($cid)] = $this->urlGenerator->generate(
'app_mail_attachment',
['id' => $part->getId()],
);
}
if (count($map) === 0) {
return $html;
}
return (string) preg_replace_callback(
'/(["\'])cid:([^"\']+)\1/i',
static function (array $m) use ($map): string {
$quote = $m[1];
$cid = strtolower(trim($m[2]));
return $quote . ($map[$cid] ?? ('cid:' . $m[2])) . $quote;
},
$html,
);
}
}
+150
View File
@@ -0,0 +1,150 @@
{# Bare frame — src + auto-refresh live on the placeholder in index.html.twig
(a src on the returned frame causes a refetch loop). #}
{% macro card_open(icon, title) %}
<section class="rounded-2xl border border-black/[0.06] dark:border-white/10 bg-white/60 dark:bg-gray-900/40 overflow-hidden">
<div class="flex items-center gap-2.5 px-5 h-11 border-b border-black/[0.06] dark:border-white/10">
<i class="fa-solid {{ icon }} text-xs text-zinc-400" aria-hidden="true"></i>
<h2 class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{{ title }}</h2>
</div>
{% endmacro %}
{% macro duration_ms(ms) %}
{% if ms >= 1000 %}{{ (ms / 1000)|number_format(2) }}s{% else %}{{ ms|number_format(2) }}ms{% endif %}
{% endmacro %}
{% import _self as ui %}
<turbo-frame id="admin-db">
<div class="space-y-6">
{# ── Health gauges ─────────────────────────────────────────────── #}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div class="rounded-2xl border border-black/[0.06] dark:border-white/10 bg-white/60 dark:bg-gray-900/40 px-4 py-3">
<p class="text-xs text-zinc-400 dark:text-zinc-500">{{ 'admin.db.connections'|trans }}</p>
<p class="mt-1 text-lg font-semibold text-zinc-800 dark:text-zinc-100">{{ gauges.connections }}</p>
</div>
<div class="rounded-2xl border border-black/[0.06] dark:border-white/10 bg-white/60 dark:bg-gray-900/40 px-4 py-3">
<p class="text-xs text-zinc-400 dark:text-zinc-500">{{ 'admin.db.cache_hit'|trans }}</p>
<p class="mt-1 text-lg font-semibold {{ gauges.cacheHitPct is not null and gauges.cacheHitPct < 99 ? 'text-amber-600 dark:text-amber-400' : 'text-zinc-800 dark:text-zinc-100' }}">
{{ gauges.cacheHitPct is not null ? gauges.cacheHitPct ~ '%' : '—' }}
</p>
</div>
<div class="rounded-2xl border border-black/[0.06] dark:border-white/10 bg-white/60 dark:bg-gray-900/40 px-4 py-3">
<p class="text-xs text-zinc-400 dark:text-zinc-500">{{ 'admin.db.rollback_ratio'|trans }}</p>
<p class="mt-1 text-lg font-semibold {{ gauges.rollbackPct is not null and gauges.rollbackPct > 5 ? 'text-amber-600 dark:text-amber-400' : 'text-zinc-800 dark:text-zinc-100' }}">
{{ gauges.rollbackPct is not null ? gauges.rollbackPct ~ '%' : '—' }}
</p>
</div>
<div class="rounded-2xl border border-black/[0.06] dark:border-white/10 bg-white/60 dark:bg-gray-900/40 px-4 py-3">
<p class="text-xs text-zinc-400 dark:text-zinc-500">{{ 'admin.db.deadlocks'|trans }}</p>
<p class="mt-1 text-lg font-semibold {{ gauges.deadlocks > 0 ? 'text-red-600 dark:text-red-400' : 'text-zinc-800 dark:text-zinc-100' }}">{{ gauges.deadlocks }}</p>
</div>
</div>
{# ── Slowest by mean ───────────────────────────────────────────── #}
{{ ui.card_open('fa-gauge-high', 'admin.db.slow_mean.heading'|trans) }}
<div class="flex items-center gap-2 px-5 py-2 border-b border-black/[0.04] dark:border-white/5">
<span class="text-xs text-zinc-400 dark:text-zinc-500">{{ 'admin.db.slow_mean.hint'|trans }}</span>
{% if statStatementsAvailable %}
<form method="post" action="{{ path('app_admin_db_reset') }}" data-turbo-frame="_top"
data-turbo-confirm="{{ 'admin.db.reset_confirm'|trans }}" class="ml-auto">
<input type="hidden" name="_token" value="{{ csrf_token('admin_db_reset') }}">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 h-7 rounded-lg text-xs font-medium bg-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-zinc-600 dark:text-zinc-300 transition-colors cursor-pointer">
<i class="fa-solid fa-eraser" aria-hidden="true"></i>
{{ 'admin.db.reset'|trans }}
</button>
</form>
{% endif %}
</div>
{% if statStatementsAvailable == false %}
<div class="px-5 py-4 text-sm text-zinc-500 dark:text-zinc-400">
<p class="flex items-center gap-2">
<i class="fa-solid fa-circle-info text-amber-500" aria-hidden="true"></i>
{{ 'admin.db.extension_missing'|trans }}
</p>
<pre class="mt-2 p-3 rounded-lg bg-zinc-50 dark:bg-zinc-950/60 border border-black/[0.04] dark:border-white/5 text-xs text-zinc-600 dark:text-zinc-300 overflow-x-auto">shared_preload_libraries = 'pg_stat_statements' # postgresql.conf, then restart
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;</pre>
</div>
{% elseif slowestByMean|length == 0 %}
<p class="px-5 py-4 text-sm text-zinc-400 dark:text-zinc-500">{{ 'admin.db.slow_mean.empty'|trans }}</p>
{% else %}
{{ _self.statement_table(slowestByMean, 'meanMs') }}
{% endif %}
</section>
{# ── Heaviest by total time ────────────────────────────────────── #}
{% if statStatementsAvailable and heaviestByTotal|length > 0 %}
{{ ui.card_open('fa-weight-hanging', 'admin.db.heavy_total.heading'|trans) }}
<p class="px-5 py-2 border-b border-black/[0.04] dark:border-white/5 text-xs text-zinc-400 dark:text-zinc-500">{{ 'admin.db.heavy_total.hint'|trans }}</p>
{{ _self.statement_table(heaviestByTotal, 'totalMs') }}
</section>
{% endif %}
{# ── Live activity ─────────────────────────────────────────────── #}
{{ ui.card_open('fa-bolt', 'admin.db.active.heading'|trans) }}
{% if activeQueries|length == 0 %}
<p class="px-5 py-4 text-sm text-zinc-400 dark:text-zinc-500">{{ 'admin.db.active.empty'|trans }}</p>
{% else %}
<div class="divide-y divide-black/[0.04] dark:divide-white/5">
{% for row in activeQueries %}
<div class="px-5 py-3 text-sm">
<div class="flex items-center gap-3 text-xs">
<span class="font-mono text-zinc-400 dark:text-zinc-500">pid {{ row.pid }}</span>
<span class="px-1.5 py-0.5 rounded-md bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-300">{{ row.state }}</span>
{% if row.waitEvent %}
<span class="text-amber-600 dark:text-amber-400 font-mono">{{ row.waitEvent }}</span>
{% endif %}
<span class="ml-auto font-mono {{ row.durationSeconds is not null and row.durationSeconds > 5 ? 'text-red-600 dark:text-red-400' : 'text-zinc-500 dark:text-zinc-400' }}">
{{ row.durationSeconds is not null ? row.durationSeconds ~ 's' : '—' }}
</span>
</div>
<pre class="mt-1.5 text-xs text-zinc-700 dark:text-zinc-200 font-mono whitespace-pre-wrap break-all">{{ row.query }}</pre>
</div>
{% endfor %}
</div>
{% endif %}
</section>
</div>
</turbo-frame>
{# ── Shared statement table ────────────────────────────────────────────── #}
{% macro statement_table(rows, highlight) %}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-zinc-400 dark:text-zinc-500">
<th class="px-5 py-2 font-medium">{{ 'admin.db.col.query'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.calls'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.mean'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.max'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.total'|trans }}</th>
<th class="px-2 py-2 font-medium text-right">{{ 'admin.db.col.rows'|trans }}</th>
<th class="px-2 py-2 font-medium text-right pr-5">{{ 'admin.db.col.hit'|trans }}</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr class="border-t border-black/[0.04] dark:border-white/5 text-zinc-700 dark:text-zinc-300 align-top">
<td class="px-5 py-2">
<code class="text-xs text-zinc-600 dark:text-zinc-300 break-all">{{ row.query }}</code>
</td>
<td class="px-2 py-2 text-xs text-right tabular-nums">{{ row.calls|number_format }}</td>
<td class="px-2 py-2 text-xs text-right tabular-nums {{ highlight == 'meanMs' ? 'font-semibold text-zinc-900 dark:text-zinc-100' : '' }}">{{ row.meanMs|number_format(2) }}</td>
<td class="px-2 py-2 text-xs text-right tabular-nums">{{ row.maxMs|number_format(2) }}</td>
<td class="px-2 py-2 text-xs text-right tabular-nums {{ highlight == 'totalMs' ? 'font-semibold text-zinc-900 dark:text-zinc-100' : '' }}">{{ (row.totalMs / 1000)|number_format(2) }}s</td>
<td class="px-2 py-2 text-xs text-right tabular-nums">{{ row.rows|number_format }}</td>
<td class="px-2 py-2 text-xs text-right pr-5 tabular-nums {{ row.hitPct is not null and row.hitPct < 90 ? 'text-amber-600 dark:text-amber-400' : '' }}">
{{ row.hitPct is not null ? row.hitPct ~ '%' : '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endmacro %}
+69 -19
View File
@@ -3,6 +3,9 @@
{% block title %}{{ 'admin.title'|trans }} — {{ 'app.name'|trans }}{% endblock %}
{% block body %}
{% set navActive = 'flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium bg-black/[0.04] dark:bg-white/10 text-zinc-800 dark:text-zinc-100' %}
{% set navInactive = 'flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-zinc-500 dark:text-zinc-400 hover:bg-black/[0.03] dark:hover:bg-white/5 transition-colors' %}
<div class="h-screen overflow-hidden bg-gradient-to-br from-blue-50 via-slate-100 to-indigo-100 dark:from-slate-950 dark:via-slate-900 dark:to-indigo-950"
data-controller="sidebar-drawer">
@@ -36,30 +39,77 @@
<div class="flex items-center gap-3 px-6 h-14 border-b border-black/[0.06] dark:border-white/10 shrink-0">
<i class="fa-solid fa-gauge-high text-zinc-400" aria-hidden="true"></i>
<h1 class="text-base font-semibold text-zinc-800 dark:text-zinc-100">{{ 'admin.title'|trans }}</h1>
<span class="ml-auto text-xs text-zinc-400 dark:text-zinc-500">{{ 'admin.refresh_hint'|trans }}</span>
{% if section != 'logs' %}
<span class="ml-auto text-xs text-zinc-400 dark:text-zinc-500">{{ 'admin.refresh_hint'|trans }}</span>
{% endif %}
</div>
{# Scrollable body #}
{# Scrollable body: section nav + content.
Two grid children only — nav and ONE content wrapper. #}
<div class="flex-1 overflow-y-auto">
<div class="flex flex-col max-w-5xl mx-auto px-6 py-6 space-y-6">
<div class="grid grid-cols-1 md:grid-cols-[180px_1fr] gap-6 px-6 py-6">
<turbo-frame id="admin-live"
src="{{ path('app_admin_live') }}"
loading="lazy"
data-controller="auto-refresh"
data-auto-refresh-interval-value="10000">
<div class="py-10 text-center text-sm text-zinc-400 dark:text-zinc-500">
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
</div>
</turbo-frame>
{# Section nav #}
<nav class="flex md:flex-col gap-1 flex-wrap">
<a href="{{ path('app_admin_dashboard', { section: 'system' }) }}"
class="{{ section == 'system' ? navActive : navInactive }}">
<i class="fa-solid fa-heart-pulse w-4 {{ section == 'system' ? 'text-zinc-400' : '' }}" aria-hidden="true"></i>
{{ 'admin.nav.system'|trans }}
</a>
<a href="{{ path('app_admin_dashboard', { section: 'database' }) }}"
class="{{ section == 'database' ? navActive : navInactive }}">
<i class="fa-solid fa-database w-4 {{ section == 'database' ? 'text-zinc-400' : '' }}" aria-hidden="true"></i>
{{ 'admin.nav.database'|trans }}
</a>
<a href="{{ path('app_admin_dashboard', { section: 'logs' }) }}"
class="{{ section == 'logs' ? navActive : navInactive }}">
<i class="fa-solid fa-scroll w-4 {{ section == 'logs' ? 'text-zinc-400' : '' }}" aria-hidden="true"></i>
{{ 'admin.nav.logs'|trans }}
</a>
</nav>
<turbo-frame id="admin-logs"
src="{{ path('app_admin_logs') }}"
loading="lazy">
<div class="py-10 text-center text-sm text-zinc-400 dark:text-zinc-500">
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
</div>
</turbo-frame>
{# Single content wrapper — second (and last) grid child.
Only the active section's frame is rendered, so only
one loads/polls at a time. #}
<div class="min-w-0">
{% if section == 'database' %}
<turbo-frame id="admin-db"
src="{{ path('app_admin_db') }}"
loading="lazy"
data-controller="auto-refresh"
data-auto-refresh-interval-value="20000">
<div class="py-10 text-center text-sm text-zinc-400 dark:text-zinc-500">
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
</div>
</turbo-frame>
{% elseif section == 'logs' %}
<turbo-frame id="admin-logs"
src="{{ path('app_admin_logs') }}"
loading="lazy">
<div class="py-10 text-center text-sm text-zinc-400 dark:text-zinc-500">
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
</div>
</turbo-frame>
{% else %}
<turbo-frame id="admin-live"
src="{{ path('app_admin_live') }}"
loading="lazy"
data-controller="auto-refresh"
data-auto-refresh-interval-value="10000">
<div class="py-10 text-center text-sm text-zinc-400 dark:text-zinc-500">
<i class="fa-solid fa-circle-notch fa-spin mr-2" aria-hidden="true"></i>{{ 'admin.loading'|trans }}
</div>
</turbo-frame>
{% endif %}
</div>
</div>
</div>
+4 -3
View File
@@ -32,15 +32,16 @@
</p>
</div>
{% if message.bodyHtml %}
<div class="prose prose-sm dark:prose-invert max-w-none">
{{ message.bodyHtml|resolve_cid(message)|raw }}
{% if message.bodyHtmlSafe %}
<div class="rounded-lg bg-white text-gray-900 p-5 overflow-x-auto">
{{ message.bodyHtmlSafe|raw }}
</div>
{% else %}
<div class="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300">
{{ message.bodyText }}
</div>
{% endif %}
{% if message.hasAttachments %}
<div class="mt-6 pt-4 border-t border-gray-100 dark:border-gray-800">
<p class="text-xs font-medium text-gray-400 dark:text-gray-600 mb-2">{{ 'message.attachments'|trans }}</p>
+9 -9
View File
@@ -97,15 +97,15 @@
class="{{ isExpanded ? '' : 'hidden' }}"
>
<div class="text-sm text-gray-700 dark:text-gray-300 mb-4">
{% if message.bodyHtml %}
<div
data-thread-message-target="shadowHost"
data-html="{{ message.bodyHtml|resolve_cid(message)|e('html_attr') }}"
class="w-full"
></div>
{% else %}
<pre class="whitespace-pre-wrap font-sans">{{ message.bodyText }}</pre>
{% endif %}
<div class="mb-4">
{% if message.bodyHtmlSafe %}
<div class="rounded-lg bg-white text-gray-900 p-4 overflow-x-auto">
{{ message.bodyHtmlSafe|raw }}
</div>
{% else %}
<pre class="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">{{ message.bodyText }}</pre>
{% endif %}
</div>
</div>
{% if message.hasAttachments %}
+30
View File
@@ -52,6 +52,36 @@ admin:
page: 'Page'
newer: 'Newer'
older: 'Older'
db:
connections: 'Connections'
cache_hit: 'Cache hit'
rollback_ratio: 'Rollback ratio'
deadlocks: 'Deadlocks'
extension_missing: 'pg_stat_statements is not enabled — slow-query aggregation is unavailable. Enable it once:'
reset: 'Reset stats'
reset_confirm: 'Reset all collected query statistics?'
slow_mean:
heading: 'Slowest queries (by mean time)'
hint: 'Statements averaging ≥ 5ms, current database.'
empty: 'No statements above the threshold yet.'
heavy_total:
heading: 'Heaviest queries (by total time)'
hint: 'Cumulative time across all calls — where the DB spends its time overall.'
active:
heading: 'Live activity'
empty: 'No active queries right now.'
col:
query: 'Query'
calls: 'Calls'
mean: 'Mean ms'
max: 'Max ms'
total: 'Total'
rows: 'Rows'
hit: 'Cache'
nav:
system: System
database: Database
logs: Logs
login:
page_title: Sign in
tagline: Sign in to your account