mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
update
This commit is contained in:
1 parent
b4bf06a8f3
commit
cd3c78c205
24 files changed
+1486
-269
No files matched your search
Generated
+4
@@ -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">
|
||||
|
||||
Generated
+4
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) . '…';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user