From 76f890a49e3298a25cd9ca45fd055f8cb90ac4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Thu, 2 Jul 2026 13:12:28 +0200 Subject: [PATCH] init --- .dockerignore | 20 + .env | 22 +- .gitignore | 7 + .idea/.gitignore | 10 + .idea/inspectionProfiles/Project_Default.xml | 21 + .idea/laravel-idea-personal.xml | 10 + .idea/laravel-idea.xml | 12 + .idea/modules.xml | 8 + .idea/php.xml | 196 + .idea/pl_mail.iml | 160 + .idea/symfony2.xml | 6 + .idea/vcs.xml | 6 + Dockerfile | 95 + README.md | 139 + assets/app.js | 23 + .../controllers/account_expand_controller.js | 72 + assets/controllers/account_form_controller.js | 39 + assets/controllers/mail_pane_controller.js | 104 + assets/controllers/mercure_controller.js | 33 + assets/controllers/message_row_controller.js | 49 + assets/controllers/modal_controller.js | 108 + .../controllers/thread_message_controller.js | 49 + assets/controllers/toast_controller.js | 29 + assets/styles/app.css | 6 +- compose.override.yaml | 68 +- compose.override.yaml.dist | 70 + compose.prod.yaml | 10 + compose.yaml | 95 +- composer.json | 10 +- composer.lock | 6078 +++++++++++------ config/bundles.php | 3 + config/packages/doctrine.yaml | 1 + config/packages/mercure.yaml | 9 + config/packages/messenger.yaml | 1 + config/packages/security.yaml | 35 +- config/packages/symfonycasts_tailwind.yaml | 2 + config/packages/twig.yaml | 4 + config/reference.php | 67 +- config/services.yaml | 5 +- frankenphp/Caddyfile | 32 + frankenphp/conf.d/10-app.ini | 19 + frankenphp/conf.d/20-app.dev.ini | 12 + frankenphp/conf.d/20-app.prod.ini | 2 + frankenphp/docker-entrypoint.sh | 51 + frankenphp/worker.Caddyfile | 4 + migrations/Version20260625123836.php | 35 + migrations/Version20260626091910.php | 65 + migrations/Version20260629130603.php | 35 + migrations/Version20260629131149.php | 31 + migrations/Version20260629131312.php | 31 + migrations/Version20260630134115.php | 33 + migrations/Version20260630151944.php | 31 + migrations/Version20260701094216.php | 31 + src/Command/ImapIdleCommand.php | 181 + src/Command/ImapSuperviseCommand.php | 265 + src/Command/ResetImapDataCommand.php | 66 + src/Command/SetupCommand.php | 73 + src/Command/SyncMailboxesCommand.php | 61 + src/Command/SyncMessagesCommand.php | 88 + src/Controller/AccountController.php | 51 + .../Admin/User/DefaultController.php | 112 + src/Controller/DefaultController.php | 38 + src/Controller/MailController.php | 188 + src/Controller/SecurityController.php | 60 + src/Domain/Enum/MessageTab.php | 13 + src/Domain/Enum/ThreadingMethod.php | 9 + src/Domain/Helper/AttachmentStorageHelper.php | 69 + src/Domain/Helper/ImapConnectionFactory.php | 42 + src/Domain/Model/UserEntityModel.php | 82 + src/Entity/Account.php | 391 ++ src/Entity/Mailbox.php | 309 + src/Entity/Message.php | 429 ++ src/Entity/MessagePart.php | 143 + src/Entity/MessageThread.php | 250 + src/Entity/User.php | 259 + src/Enum/AuthType.php | 14 + src/Enum/ConnectionEncryption.php | 15 + src/Enum/MailboxRole.php | 21 + src/Form/AccountType.php | 109 + src/Kernel.php | 3 + src/Message/SyncMailboxMessage.php | 10 + .../SyncMailboxMessageHandler.php | 62 + src/Repository/AccountRepository.php | 43 + src/Repository/MailboxRepository.php | 65 + src/Repository/MessagePartRepository.php | 43 + src/Repository/MessageRepository.php | 73 + src/Repository/MessageThreadRepository.php | 72 + src/Repository/UserRepository.php | 115 + src/Security/LoginFormAuthenticator.php | 155 + src/Security/UserProvider.php | 91 + src/Service/Imap/MailboxSyncer.php | 113 + src/Service/Imap/MessageSyncer.php | 196 + src/Service/Imap/MessageThreader.php | 180 + src/Twig/AccountsGlobal.php | 28 + src/Twig/AssetsTwigExtension.php | 50 + src/Twig/Vendor/DotEnvTwigExtension.php | 24 + src/Twig/Vendor/PhpTwigExtension.php | 48 + symfony.lock | 33 + tailwind.config.js | 11 + templates/_layout/_mailbox.html.twig | 51 + templates/_layout/app.html.twig | 43 + templates/_partials/_account_list.html.twig | 42 + templates/_partials/_message_row.html.twig | 98 + templates/_partials/_modal.html.twig | 102 + templates/_partials/_modal_form.html.twig | 82 + templates/_partials/_sidebar.html.twig | 83 + templates/_partials/_thread_row.html.twig | 176 + templates/_partials/_toast.html.twig | 21 + templates/_partials/_topbar.html.twig | 70 + .../_partials/modal_form_theme.html.twig | 183 + templates/account/_form_modal.html.twig | 165 + .../account/_form_success.stream.html.twig | 21 + templates/base.html.twig | 24 - templates/broadcast/Account.stream.html.twig | 22 + templates/broadcast/Mailbox.stream.html.twig | 22 + templates/broadcast/Message.stream.html.twig | 22 + .../broadcast/MessagePart.stream.html.twig | 22 + .../broadcast/MessageThread.stream.html.twig | 22 + templates/default/index.html.twig | 303 + templates/mail/_account_folders.html.twig | 15 + templates/mail/_message_content.html.twig | 54 + templates/mail/_thread_content.html.twig | 129 + templates/mail/drafts.html.twig | 10 + templates/mail/inbox.html.twig | 29 + templates/mail/mailbox.html.twig | 16 + templates/mail/sent.html.twig | 10 + templates/mail/starred.html.twig | 6 + templates/mail/thread.html.twig | 12 + templates/mail/trash.html.twig | 10 + templates/security/login.html.twig | 97 + tests/Controller/MailControllerTest.php | 16 + 131 files changed, 12493 insertions(+), 2332 deletions(-) create mode 100644 .dockerignore create mode 100644 .idea/.gitignore create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/laravel-idea-personal.xml create mode 100644 .idea/laravel-idea.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/php.xml create mode 100644 .idea/pl_mail.iml create mode 100644 .idea/symfony2.xml create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 assets/controllers/account_expand_controller.js create mode 100644 assets/controllers/account_form_controller.js create mode 100644 assets/controllers/mail_pane_controller.js create mode 100644 assets/controllers/mercure_controller.js create mode 100644 assets/controllers/message_row_controller.js create mode 100644 assets/controllers/modal_controller.js create mode 100644 assets/controllers/thread_message_controller.js create mode 100644 assets/controllers/toast_controller.js create mode 100644 compose.override.yaml.dist create mode 100644 compose.prod.yaml create mode 100644 config/packages/mercure.yaml create mode 100644 config/packages/symfonycasts_tailwind.yaml create mode 100644 frankenphp/Caddyfile create mode 100644 frankenphp/conf.d/10-app.ini create mode 100644 frankenphp/conf.d/20-app.dev.ini create mode 100644 frankenphp/conf.d/20-app.prod.ini create mode 100644 frankenphp/docker-entrypoint.sh create mode 100644 frankenphp/worker.Caddyfile create mode 100644 migrations/Version20260625123836.php create mode 100644 migrations/Version20260626091910.php create mode 100644 migrations/Version20260629130603.php create mode 100644 migrations/Version20260629131149.php create mode 100644 migrations/Version20260629131312.php create mode 100644 migrations/Version20260630134115.php create mode 100644 migrations/Version20260630151944.php create mode 100644 migrations/Version20260701094216.php create mode 100644 src/Command/ImapIdleCommand.php create mode 100644 src/Command/ImapSuperviseCommand.php create mode 100644 src/Command/ResetImapDataCommand.php create mode 100644 src/Command/SetupCommand.php create mode 100644 src/Command/SyncMailboxesCommand.php create mode 100644 src/Command/SyncMessagesCommand.php create mode 100644 src/Controller/AccountController.php create mode 100644 src/Controller/Admin/User/DefaultController.php create mode 100644 src/Controller/DefaultController.php create mode 100644 src/Controller/MailController.php create mode 100644 src/Controller/SecurityController.php create mode 100644 src/Domain/Enum/MessageTab.php create mode 100644 src/Domain/Enum/ThreadingMethod.php create mode 100644 src/Domain/Helper/AttachmentStorageHelper.php create mode 100644 src/Domain/Helper/ImapConnectionFactory.php create mode 100644 src/Domain/Model/UserEntityModel.php create mode 100644 src/Entity/Account.php create mode 100644 src/Entity/Mailbox.php create mode 100644 src/Entity/Message.php create mode 100644 src/Entity/MessagePart.php create mode 100644 src/Entity/MessageThread.php create mode 100644 src/Entity/User.php create mode 100644 src/Enum/AuthType.php create mode 100644 src/Enum/ConnectionEncryption.php create mode 100644 src/Enum/MailboxRole.php create mode 100644 src/Form/AccountType.php create mode 100644 src/Message/SyncMailboxMessage.php create mode 100644 src/MessageHandler/SyncMailboxMessageHandler.php create mode 100644 src/Repository/AccountRepository.php create mode 100644 src/Repository/MailboxRepository.php create mode 100644 src/Repository/MessagePartRepository.php create mode 100644 src/Repository/MessageRepository.php create mode 100644 src/Repository/MessageThreadRepository.php create mode 100644 src/Repository/UserRepository.php create mode 100644 src/Security/LoginFormAuthenticator.php create mode 100644 src/Security/UserProvider.php create mode 100644 src/Service/Imap/MailboxSyncer.php create mode 100644 src/Service/Imap/MessageSyncer.php create mode 100644 src/Service/Imap/MessageThreader.php create mode 100644 src/Twig/AccountsGlobal.php create mode 100644 src/Twig/AssetsTwigExtension.php create mode 100644 src/Twig/Vendor/DotEnvTwigExtension.php create mode 100644 src/Twig/Vendor/PhpTwigExtension.php create mode 100644 tailwind.config.js create mode 100644 templates/_layout/_mailbox.html.twig create mode 100644 templates/_layout/app.html.twig create mode 100644 templates/_partials/_account_list.html.twig create mode 100644 templates/_partials/_message_row.html.twig create mode 100644 templates/_partials/_modal.html.twig create mode 100644 templates/_partials/_modal_form.html.twig create mode 100644 templates/_partials/_sidebar.html.twig create mode 100644 templates/_partials/_thread_row.html.twig create mode 100644 templates/_partials/_toast.html.twig create mode 100644 templates/_partials/_topbar.html.twig create mode 100644 templates/_partials/modal_form_theme.html.twig create mode 100644 templates/account/_form_modal.html.twig create mode 100644 templates/account/_form_success.stream.html.twig delete mode 100644 templates/base.html.twig create mode 100644 templates/broadcast/Account.stream.html.twig create mode 100644 templates/broadcast/Mailbox.stream.html.twig create mode 100644 templates/broadcast/Message.stream.html.twig create mode 100644 templates/broadcast/MessagePart.stream.html.twig create mode 100644 templates/broadcast/MessageThread.stream.html.twig create mode 100644 templates/default/index.html.twig create mode 100644 templates/mail/_account_folders.html.twig create mode 100644 templates/mail/_message_content.html.twig create mode 100644 templates/mail/_thread_content.html.twig create mode 100644 templates/mail/drafts.html.twig create mode 100644 templates/mail/inbox.html.twig create mode 100644 templates/mail/mailbox.html.twig create mode 100644 templates/mail/sent.html.twig create mode 100644 templates/mail/starred.html.twig create mode 100644 templates/mail/thread.html.twig create mode 100644 templates/mail/trash.html.twig create mode 100644 templates/security/login.html.twig create mode 100644 tests/Controller/MailControllerTest.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d515c268 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.git/ +.github/ +.idea/ +**/*.log +docker-compose.override.yml +compose.override.yaml +.env.local +.env.*.local +.env.local.php +var/ +vendor/ +public/assets/ +public/bundles/ +assets/vendor/ +.phpunit.cache/ +phpunit.xml +.dockerignore +Dockerfile +**/Thumbs.db +**/.DS_Store diff --git a/.env b/.env index 5450e908..d352eabd 100644 --- a/.env +++ b/.env @@ -33,7 +33,7 @@ DEFAULT_URI=http://localhost # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" -DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8" +DATABASE_URL="postgresql://app:!ChangeMe!@database:5432/app?serverVersion=18&charset=utf8" ###< doctrine/doctrine-bundle ### ###> symfony/messenger ### @@ -46,3 +46,23 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 ###> symfony/mailer ### MAILER_DSN=null://null ###< symfony/mailer ### + +###> app/encryption ### +# Base64-encoded 32-byte libsodium secretbox key used by the `encrypted_string` +# Doctrine type to encrypt account credentials at rest. SEPARATE from APP_SECRET. +# Generate a fresh one with: +# php -r 'echo base64_encode(sodium_crypto_secretbox_keygen());' +# This default is for LOCAL DEV ONLY — override with a real secret in production +# (e.g. via real env var or `symfony console secrets:set APP_ENCRYPTION_KEY`). +APP_ENCRYPTION_KEY=chHhLxNFHCRA7exigGBw08i/PWQfIxyWxn63oDUC4/s= +###< app/encryption ### +APP_DEV_USER_EMAIL= +APP_DEV_USER_PASSWORD= + +###> symfony/mercure-bundle ### +# See https://symfony.com/doc/current/mercure.html#configuration +# The URL of the Mercure hub, used by the app to publish updates (can be a local URL) +MERCURE_URL=http://mercure/.well-known/mercure +MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure +MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" +###< symfony/mercure-bundle ### diff --git a/.gitignore b/.gitignore index 40af00d8..78ad478a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,10 @@ /public/assets/ /assets/vendor/ ###< symfony/asset-mapper ### + +# IDE +.idea/ +.vscode/ + +# Docker override +docker-compose.override.yml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..3575a727 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..a16a3858 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,21 @@ + + + + \ No newline at end of file diff --git a/.idea/laravel-idea-personal.xml b/.idea/laravel-idea-personal.xml new file mode 100644 index 00000000..39c3c139 --- /dev/null +++ b/.idea/laravel-idea-personal.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/laravel-idea.xml b/.idea/laravel-idea.xml new file mode 100644 index 00000000..8cb617ed --- /dev/null +++ b/.idea/laravel-idea.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..2c382ed1 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml new file mode 100644 index 00000000..42a32031 --- /dev/null +++ b/.idea/php.xml @@ -0,0 +1,196 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/pl_mail.iml b/.idea/pl_mail.iml new file mode 100644 index 00000000..e759430a --- /dev/null +++ b/.idea/pl_mail.iml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/symfony2.xml b/.idea/symfony2.xml new file mode 100644 index 00000000..2dfac5e5 --- /dev/null +++ b/.idea/symfony2.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..c8397c94 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..9b588e36 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,95 @@ +#syntax=docker/dockerfile:1 + +# Versions +FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream + +# The different stages of this Dockerfile are meant to be built into separate images +# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage +# https://docs.docker.com/compose/compose-file/#target + + +# Base FrankenPHP image +FROM frankenphp_upstream AS frankenphp_base + +WORKDIR /app + +VOLUME /app/var/ + +# persistent / runtime deps +# hadolint ignore=DL3008 +RUN apt-get update && apt-get install -y --no-install-recommends \ + acl \ + file \ + gettext \ + git \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + install-php-extensions \ + @composer \ + apcu \ + intl \ + opcache \ + zip \ + pdo_pgsql \ + pgsql \ + pcntl \ + ; + +# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser +ENV COMPOSER_ALLOW_SUPERUSER=1 + + +COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/conf.d/ +COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint +COPY --link frankenphp/Caddyfile /etc/frankenphp/Caddyfile + +ENTRYPOINT ["docker-entrypoint"] + +HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 +CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile" ] + +# Dev FrankenPHP image +FROM frankenphp_base AS frankenphp_dev + +ENV APP_ENV=dev XDEBUG_MODE=off +ENV FRANKENPHP_WORKER_CONFIG=watch + +RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" + +RUN set -eux; \ + install-php-extensions \ + xdebug \ + ; + +COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/conf.d/ + +CMD [ "frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile", "--watch" ] + +# Prod FrankenPHP image +FROM frankenphp_base AS frankenphp_prod + +ENV APP_ENV=prod +ENV FRANKENPHP_CONFIG="import worker.Caddyfile" + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" + +COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/conf.d/ +COPY --link frankenphp/worker.Caddyfile /etc/frankenphp/worker.Caddyfile + +# prevent the reinstallation of vendors at every changes in the source code +COPY --link composer.* symfony.* ./ +RUN set -eux; \ + composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress + +# copy sources +COPY --link . ./ +RUN rm -Rf frankenphp/ + +RUN set -eux; \ + mkdir -p var/cache var/log; \ + composer dump-autoload --classmap-authoritative --no-dev; \ + composer dump-env prod; \ + composer run-script --no-dev post-install-cmd; \ + php bin/console tailwind:build --minify; \ + chmod +x bin/console; sync; diff --git a/README.md b/README.md new file mode 100644 index 00000000..d5931907 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# 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. + +![PHP](https://img.shields.io/badge/PHP-8.4+-777BB4?logo=php&logoColor=white) +![Symfony](https://img.shields.io/badge/Symfony-8-000000?logo=symfony&logoColor=white) +![PostgreSQL](https://img.shields.io/badge/PostgreSQL-18-4169E1?logo=postgresql&logoColor=white) +![License](https://img.shields.io/badge/license-AGPL--3.0-green) + +## Features + +- **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 +- **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) +- **Supervised IDLE workers** — one IMAP IDLE process per enabled mailbox, watched and auto-restarted by a built-in supervisor command + +## Stack + +| Layer | Technology | +|---|---| +| Backend | Symfony 8, Doctrine ORM, PHP 8.4+ | +| Database | PostgreSQL (SQLite optional) | +| IMAP | webklex/php-imap | +| Async | Symfony Messenger, Doctrine transport | +| Push | Mercure (built into FrankenPHP) | +| Frontend | AssetMapper, Tailwind v4, Hotwire Turbo, Stimulus | +| Runtime | FrankenPHP | +| Dev tooling | Docker Compose, Adminer, Mailpit | + +## Requirements + +- Docker and Docker Compose +- That's it + +## 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. +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. + +## 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:imap:idle ` | 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) | + +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 +``` + +## Environment Variables + +| Variable | Description | Default | +|---|---|---| +| `DATABASE_URL` | PostgreSQL connection string | see `.env` | +| `MESSENGER_TRANSPORT_DSN` | Messenger transport | `doctrine://default?auto_setup=0` | +| `MERCURE_URL` | Internal Mercure hub URL | `https://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 | — | + +## 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 + +## License + +AGPL-3.0 — see [LICENSE](LICENSE) for details. diff --git a/assets/app.js b/assets/app.js index e56396c1..4c17ea1c 100644 --- a/assets/app.js +++ b/assets/app.js @@ -8,3 +8,26 @@ import './stimulus_bootstrap.js'; import './styles/app.css'; console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); + +document.addEventListener('turbo:load', function() { + const html = document.documentElement; + const isDark = html.classList.contains('dark'); + document.getElementById('icon-sun').classList.toggle('hidden', !isDark); + document.getElementById('icon-moon').classList.toggle('hidden', isDark); + + document.getElementById('theme-toggle').addEventListener('click', function () { + const nowDark = html.classList.toggle('dark'); + localStorage.setItem('theme', nowDark ? 'dark' : 'light'); + document.getElementById('icon-sun').classList.toggle('hidden', !nowDark); + document.getElementById('icon-moon').classList.toggle('hidden', nowDark); + + }); + + + const btn = document.getElementById('user-menu-btn'); + const menu = document.getElementById('user-menu'); + + btn.addEventListener('click', function () { + menu.classList.toggle('hidden'); + }); +}); diff --git a/assets/controllers/account_expand_controller.js b/assets/controllers/account_expand_controller.js new file mode 100644 index 00000000..1d21e7de --- /dev/null +++ b/assets/controllers/account_expand_controller.js @@ -0,0 +1,72 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["frame", "chevron", "link"]; // add "link" target + static values = { foldersUrl: String, active: Boolean }; + + connect() { + this._onOtherExpanded = this._handleOtherExpanded.bind(this); + this._onTurboLoad = this.highlightActive.bind(this); + + window.addEventListener("account-expand:opened", this._onOtherExpanded); + document.addEventListener("turbo:load", this._onTurboLoad); + this.element.addEventListener('turbo:frame-load', () => this.highlightActive()); + + if (this.activeValue && !this.frameTarget.src) { + this.frameTarget.src = this.foldersUrlValue; + } + + this.highlightActive(); + } + + disconnect() { + window.removeEventListener("account-expand:opened", this._onOtherExpanded); + document.removeEventListener("turbo:load", this._onTurboLoad); + } + + toggle(event) { + const frame = this.frameTarget; + const chevron = this.chevronTarget; + const isHidden = frame.classList.contains("hidden"); + + if (isHidden) { + window.dispatchEvent(new CustomEvent("account-expand:opened", { + detail: { element: this.element } + })); + + frame.classList.remove("hidden"); + chevron.classList.add("rotate-90"); + + if (!frame.src) { + frame.src = this.foldersUrlValue; + } + } else { + frame.classList.add("hidden"); + chevron.classList.remove("rotate-90"); + } + } + + stop(event) { + event.stopPropagation(); + } + + _handleOtherExpanded(event) { + if (event.detail.element !== this.element) { + this.frameTarget.classList.add("hidden"); + this.chevronTarget.classList.remove("rotate-90"); + } + } + + highlightActive() { + this.linkTargets.forEach((link) => { + const isActive = window.location.pathname === new URL(link.href).pathname; + link.classList.toggle('bg-blue-50', isActive); + link.classList.toggle('dark:bg-blue-950/40', isActive); + link.classList.toggle('text-blue-700', isActive); + link.classList.toggle('dark:text-blue-400', isActive); + link.classList.toggle('font-medium', isActive); + link.classList.toggle('text-gray-500', !isActive); + link.classList.toggle('dark:text-gray-400', !isActive); + }); + } +} diff --git a/assets/controllers/account_form_controller.js b/assets/controllers/account_form_controller.js new file mode 100644 index 00000000..099a1bff --- /dev/null +++ b/assets/controllers/account_form_controller.js @@ -0,0 +1,39 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["tab", "panel", "submitBtn"]; + + connect() { + console.log('panels:', this.panelTargets.length); + console.log('tabs:', this.tabTargets.length); + console.log('submitBtn:', this.hasSubmitBtnTarget); + this._activate("imap"); + } + + switchTab(event) { + const panel = event.currentTarget.dataset.panel; + this._activate(panel); + } + + _activate(panelName) { + this.tabTargets.forEach(tab => { + const isActive = tab.dataset.panel === panelName; + tab.classList.toggle("border-blue-500", isActive); + tab.classList.toggle("text-blue-600", isActive); + tab.classList.toggle("dark:text-blue-400", isActive); + tab.classList.toggle("border-transparent", !isActive); + tab.classList.toggle("text-zinc-500", !isActive); + tab.classList.toggle("dark:text-zinc-400", !isActive); + tab.classList.toggle("hover:text-zinc-700", !isActive); + tab.classList.toggle("dark:hover:text-zinc-200", !isActive); + }); + + this.panelTargets.forEach(panel => { + panel.classList.toggle("hidden", panel.dataset.panel !== panelName); + }); + + if (this.hasSubmitBtnTarget) { + this.submitBtnTarget.classList.toggle("hidden", panelName === "oauth"); + } + } +} diff --git a/assets/controllers/mail_pane_controller.js b/assets/controllers/mail_pane_controller.js new file mode 100644 index 00000000..0001e26b --- /dev/null +++ b/assets/controllers/mail_pane_controller.js @@ -0,0 +1,104 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["list", "reading"]; + static values = { open: Boolean , mailBoxId: Number}; + + connect() { + console.log("[mail-pane] connected"); + this._listUrl = this.openValue ? null : window.location.href; + this._onPopState = this._handlePopState.bind(this); + window.addEventListener("popstate", this._onPopState); + + // Restore correct visual state on direct load / refresh + if (this.openValue) { + this._showReading(); + } else { + this._showList(); + } + } + disconnect() { + window.removeEventListener("popstate", this._onPopState); + } + + async open(event) { + event.preventDefault(); + + const link = event.currentTarget; + const url = link.href; + + // Remember where to go back to, if we don't already know. + if (!this._listUrl) { + this._listUrl = window.location.href; + } + + await this._loadMessage(url); + + history.pushState({ mailPaneOpen: true }, "", url); + } + + close(event) { + if (event) { + event.preventDefault(); + } + + this._showList(); + + if (this._listUrl) { + history.pushState({ mailPaneOpen: false }, "", this._listUrl); + } else { + history.back(); + } + } + + async _handlePopState(event) { + const state = event.state; + + if (state && state.mailPaneOpen) { + await this._loadMessage(window.location.href); + } else { + this._showList(); + } + } + + async _loadMessage(url) { + const response = await fetch(url, { + headers: { "X-Requested-With": "fetch" }, + }); + + if (!response.ok) { + window.location.href = url; // fall back to a real navigation on failure + return; + } + + const html = await response.text(); + this.readingTarget.innerHTML = html; + this._showReading(); + } + + _showReading() { + this.listTarget.classList.add("hidden"); + this.readingTarget.classList.remove("hidden"); + } + + _showList() { + this.readingTarget.classList.add("hidden"); + this.listTarget.classList.remove("hidden"); + } + + onMailboxSynced(event) { + const data = event.detail; + const list = document.getElementById("message-list"); + this.dispatch("new-mail", { detail: data }); + console.log("[mail-pane] mailbox syncing", data.mailboxId, list.dataset.mailboxId); + // If we're currently viewing the affected mailbox, refresh the list + if (list.dataset.mailboxId && true === JSON.parse(list.dataset.mailboxId).includes(data.mailboxId)) { + this._refreshList(); + } + } + + _refreshList() { + console.log("[mail-pane] refreshing list"); + Turbo.visit(window.location.href, { action: "replace" }); + } +} diff --git a/assets/controllers/mercure_controller.js b/assets/controllers/mercure_controller.js new file mode 100644 index 00000000..1a2e394d --- /dev/null +++ b/assets/controllers/mercure_controller.js @@ -0,0 +1,33 @@ +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller { + static values = { + url: String, + }; + + connect() { + this._es = new EventSource(this.urlValue, {withCredentials: true}); + + this._es.onmessage = (event) => { + console.log("[mercure] message received:", event.data); + const data = JSON.parse(event.data); + this._handleUpdate(data); + }; + + this._es.onerror = (error) => { + console.error("[mercure] EventSource error:", error); + }; + } + + disconnect() { + if (this._es) { + this._es.close(); + } + } + + _handleUpdate(data) { + if (data.type === "mailbox.synced") { + this.dispatch("mailbox-synced", {detail: data}); + } + } +} diff --git a/assets/controllers/message_row_controller.js b/assets/controllers/message_row_controller.js new file mode 100644 index 00000000..8168d989 --- /dev/null +++ b/assets/controllers/message_row_controller.js @@ -0,0 +1,49 @@ +import { Controller } from "@hotwired/stimulus"; + +// Handles per-row interactions in the message list: checkbox, star toggle, +// and hover quick-actions (archive, delete, snooze, mark read/unread). +// +// All action methods currently only stop the click from bubbling up to the +// row's navigation link. None of them call the backend yet — that wiring +// lands once the corresponding routes exist (star is closest, since +// Message.starredAt already exists in the schema). +export default class extends Controller { + static values = { id: Number }; + + // Prevent the checkbox/star/hover-action click from also triggering + // the row's . + stop(event) { + event.stopPropagation(); + } + + toggleSelect(event) { + event.stopPropagation(); + // TODO: track selected message ids for bulk actions (archive, delete, etc.) + } + + toggleStar(event) { + event.stopPropagation(); + // TODO: POST/PATCH to a "star" endpoint, then toggle the icon's + // filled state and color locally for instant feedback. + } + + archive(event) { + event.stopPropagation(); + // TODO: wire up once an "archive" concept/mailbox exists. + } + + delete(event) { + event.stopPropagation(); + // TODO: wire up once trash semantics are implemented. + } + + snooze(event) { + event.stopPropagation(); + // TODO: wire up once snooze is implemented. + } + + markRead(event) { + event.stopPropagation(); + // TODO: wire up once a "mark as read/unread" endpoint exists. + } +} diff --git a/assets/controllers/modal_controller.js b/assets/controllers/modal_controller.js new file mode 100644 index 00000000..ef0a09fb --- /dev/null +++ b/assets/controllers/modal_controller.js @@ -0,0 +1,108 @@ +import { Controller } from "@hotwired/stimulus" + +/** + * Modal controller + * + * Usage on a trigger button: + * + * + * Or fire open() directly from Turbo events / other controllers. + */ +export default class extends Controller { + static values = { + src: String, // URL to load into the turbo-frame + title: String, // Optional title shown in the modal header + } + + connect() { + this._onSubmitEnd = this._handleSubmitEnd.bind(this); + this.element.addEventListener("turbo:submit-end", this._onSubmitEnd); + this._onKeydown = this._handleKeydown.bind(this) + } + + disconnect() { + this.element.removeEventListener("turbo:submit-end", this._onSubmitEnd); + document.removeEventListener("keydown", this._onKeydown) + } + + // ── Public API ────────────────────────────────────────────────────────────── + + open(event) { + event?.preventDefault() + + const frame = this._frame + const dialog = this._dialog + + if (!frame || !dialog) { + console.warn("[modal] #modal turbo-frame or [data-modal-dialog] not found in DOM") + return + } + + // Allow overriding src/title from the triggering element's data attributes + const triggerSrc = event?.currentTarget?.dataset?.modalSrcValue ?? this.srcValue + const triggerTitle = event?.currentTarget?.dataset?.modalTitleValue ?? this.titleValue + + // Update the frame title if present + const titleEl = dialog.querySelector("[data-modal-title]") + if (titleEl && triggerTitle) titleEl.textContent = triggerTitle + + // Point the turbo-frame at the form URL and let Turbo do the fetch + frame.src = triggerSrc + + // Show the modal + dialog.removeAttribute("hidden") + document.body.classList.add("overflow-hidden") + document.addEventListener("keydown", this._onKeydown) + + // Move focus into the dialog after Turbo has rendered the frame + frame.addEventListener("turbo:frame-load", () => this._focusFirst(dialog), { once: true }) + } + + close(event) { + event?.preventDefault() + + const frame = this._frame + const dialog = this._dialog + + if (!dialog) return + + dialog.setAttribute("hidden", "") + document.body.classList.remove("overflow-hidden") + document.removeEventListener("keydown", this._onKeydown) + + // Clear the frame so the next open always fetches fresh + if (frame) frame.src = "" + } + + backdropClick(event) { + // Only close when clicking the backdrop itself, not its children + if (event.target === event.currentTarget) this.close(event) + } + + // ── Private ───────────────────────────────────────────────────────────────── + + get _frame() { return document.getElementById("modal") } + get _dialog() { return document.querySelector("[data-modal-dialog]") } + + _handleKeydown(event) { + if (event.key === "Escape") this.close(event) + } + + _focusFirst(container) { + const el = container.querySelector( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ) + el?.focus() + } + + _handleSubmitEnd(event) { + if (event.detail.success) { + this.close(); + } + } +} diff --git a/assets/controllers/thread_message_controller.js b/assets/controllers/thread_message_controller.js new file mode 100644 index 00000000..c46bb396 --- /dev/null +++ b/assets/controllers/thread_message_controller.js @@ -0,0 +1,49 @@ + +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["body", "snippet", "toggleBtn", "chevron", "iframe"]; + static values = { expanded: Boolean }; + + connect() { + // expanded value is set from Twig via data-message-bubble-expanded-value + } + + toggle(event) { + this.expandedValue = !this.expandedValue; + } + + expandedValueChanged() { + this.bodyTarget.classList.toggle("hidden", !this.expandedValue); + this.snippetTarget.classList.toggle("hidden", this.expandedValue); + + if (this.hasChevronTarget) { + this.chevronTarget.classList.toggle("rotate-180", this.expandedValue); + } + } + + iframeTargetConnected(iframe) { + const setup = () => { + const doc = iframe.contentDocument; + if (!doc) { + return; + } + const style = doc.createElement("style"); + style.textContent = "body { margin: 0; padding: 8px; overflow: visible; word-wrap: break-word; } img { max-width: 100%; }"; + doc.head.appendChild(style); + + const resize = () => { + const h = doc.body.scrollHeight; + iframe.style.height = h + "px"; + }; + + new ResizeObserver(resize).observe(doc.body); + resize(); + }; + if (iframe.contentDocument && iframe.contentDocument.readyState === "complete") { + setup(); + } else { + iframe.addEventListener("load", setup, { once: true }); + } + } +} diff --git a/assets/controllers/toast_controller.js b/assets/controllers/toast_controller.js new file mode 100644 index 00000000..f423af02 --- /dev/null +++ b/assets/controllers/toast_controller.js @@ -0,0 +1,29 @@ +// assets/controllers/toast_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { duration: { type: Number, default: 4000 } } + + connect() { + // Animate in + this.element.style.opacity = "0" + this.element.style.transform = "translateY(8px)" + requestAnimationFrame(() => { + this.element.style.transition = "opacity 200ms, transform 200ms" + this.element.style.opacity = "1" + this.element.style.transform = "translateY(0)" + }) + + this._timer = setTimeout(() => this.dismiss(), this.durationValue) + } + + disconnect() { + clearTimeout(this._timer) + } + + dismiss() { + this.element.style.opacity = "0" + this.element.style.transform = "translateY(8px)" + this.element.addEventListener("transitionend", () => this.element.remove(), { once: true }) + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index dd6181a1..2ea9a8c0 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1,3 +1,3 @@ -body { - background-color: skyblue; -} +@import "tailwindcss"; + +@variant dark (&:where(.dark, .dark *)); diff --git a/compose.override.yaml b/compose.override.yaml index 8dc54dec..9a451994 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -1,18 +1,70 @@ - services: -###> doctrine/doctrine-bundle ### + php: + build: + context: . + target: frankenphp_dev + volumes: + - ./:/app + - ./frankenphp/Caddyfile:/etc/frankenphp/Caddyfile:ro + - ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/conf.d/20-app.dev.ini:ro + environment: + APP_ENV: ${APP_ENV:-dev} + XDEBUG_MODE: "${XDEBUG_MODE:-off}" + extra_hosts: + - host.docker.internal:host-gateway + tty: true + + imap-supervisor: + build: + context: . + target: frankenphp_dev + volumes: + - ./:/app + environment: + APP_ENV: ${APP_ENV:-dev} + + messenger-worker: + build: + context: . + target: frankenphp_dev + volumes: + - ./:/app + environment: + APP_ENV: ${APP_ENV:-dev} + database: ports: - - "5432" -###< doctrine/doctrine-bundle ### + - "5432:5432" -###> symfony/mailer ### mailer: image: axllent/mailpit ports: - - "1025" - - "8025" + - "1025:1025" + - "8025:8025" environment: MP_SMTP_AUTH_ACCEPT_ANY: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1 -###< symfony/mailer ### + + adminer: + image: adminer + restart: unless-stopped + ports: + - "8080:8080" + environment: + ADMINER_DEFAULT_SERVER: database + + tailwind: + build: + context: . + target: frankenphp_dev + volumes: + - .:/app + command: php bin/console tailwind:build --watch + depends_on: + - php + + ###> symfony/mercure-bundle ### + mercure: + ports: + - "80" +###< symfony/mercure-bundle ### diff --git a/compose.override.yaml.dist b/compose.override.yaml.dist new file mode 100644 index 00000000..9a451994 --- /dev/null +++ b/compose.override.yaml.dist @@ -0,0 +1,70 @@ +services: + php: + build: + context: . + target: frankenphp_dev + volumes: + - ./:/app + - ./frankenphp/Caddyfile:/etc/frankenphp/Caddyfile:ro + - ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/conf.d/20-app.dev.ini:ro + environment: + APP_ENV: ${APP_ENV:-dev} + XDEBUG_MODE: "${XDEBUG_MODE:-off}" + extra_hosts: + - host.docker.internal:host-gateway + tty: true + + imap-supervisor: + build: + context: . + target: frankenphp_dev + volumes: + - ./:/app + environment: + APP_ENV: ${APP_ENV:-dev} + + messenger-worker: + build: + context: . + target: frankenphp_dev + volumes: + - ./:/app + environment: + APP_ENV: ${APP_ENV:-dev} + + database: + ports: + - "5432:5432" + + mailer: + image: axllent/mailpit + ports: + - "1025:1025" + - "8025:8025" + environment: + MP_SMTP_AUTH_ACCEPT_ANY: 1 + MP_SMTP_AUTH_ALLOW_INSECURE: 1 + + adminer: + image: adminer + restart: unless-stopped + ports: + - "8080:8080" + environment: + ADMINER_DEFAULT_SERVER: database + + tailwind: + build: + context: . + target: frankenphp_dev + volumes: + - .:/app + command: php bin/console tailwind:build --watch + depends_on: + - php + + ###> symfony/mercure-bundle ### + mercure: + ports: + - "80" +###< symfony/mercure-bundle ### diff --git a/compose.prod.yaml b/compose.prod.yaml new file mode 100644 index 00000000..717a1ee1 --- /dev/null +++ b/compose.prod.yaml @@ -0,0 +1,10 @@ +# Production overrides. Use with: +# docker compose -f compose.yaml -f compose.prod.yaml up -d --wait +services: + php: + build: + context: . + target: frankenphp_prod + environment: + APP_ENV: prod + APP_SECRET: ${APP_SECRET} diff --git a/compose.yaml b/compose.yaml index 89c74d18..85b8fb2f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,11 +1,59 @@ - +# yaml-language-server: $schema=vendor/symfony/dependency-injection/Loader/schema/services.schema.json services: -###> doctrine/doctrine-bundle ### + php: + image: ${IMAGES_PREFIX:-}app-php + restart: unless-stopped + environment: + SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 + DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-18}&charset=${POSTGRES_CHARSET:-utf8} + MAILER_DSN: ${MAILER_DSN:-null://null} + MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-doctrine://default?auto_setup=0} + volumes: + - caddy_data:/data + - caddy_config:/config + ports: + - target: 80 + published: ${HTTP_PORT:-80} + protocol: tcp + - target: 443 + published: ${HTTPS_PORT:-443} + protocol: tcp + - target: 443 + published: ${HTTP3_PORT:-443} + protocol: udp + depends_on: + database: + condition: service_healthy + + imap-supervisor: + image: ${IMAGES_PREFIX:-}app-php + restart: unless-stopped + command: php bin/console app:imap:supervise + stop_grace_period: 30s + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-18}&charset=${POSTGRES_CHARSET:-utf8} + MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-doctrine://default?auto_setup=0} + depends_on: + database: + condition: service_healthy + + messenger-worker: + image: ${IMAGES_PREFIX:-}app-php + restart: unless-stopped + command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M + stop_grace_period: 15s + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-18}&charset=${POSTGRES_CHARSET:-utf8} + MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-doctrine://default?auto_setup=0} + depends_on: + database: + condition: service_healthy + + ###> doctrine/doctrine-bundle ### database: - image: postgres:${POSTGRES_VERSION:-16}-alpine + image: postgres:${POSTGRES_VERSION:-18}-alpine environment: POSTGRES_DB: ${POSTGRES_DB:-app} - # You should definitely change the password in production POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} POSTGRES_USER: ${POSTGRES_USER:-app} healthcheck: @@ -14,12 +62,39 @@ services: retries: 5 start_period: 60s volumes: - - database_data:/var/lib/postgresql/data:rw - # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! - # - ./docker/db/data:/var/lib/postgresql/data:rw -###< doctrine/doctrine-bundle ### + - database_data:/var/lib/postgresql:rw + ###< doctrine/doctrine-bundle ### + + ###> symfony/mercure-bundle ### + mercure: + image: dunglas/mercure + restart: unless-stopped + ports: + - "3000:80" + environment: + SERVER_NAME: ':80' + MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!' + MERCURE_EXTRA_DIRECTIVES: | + cors_origins https://localhost + command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile + healthcheck: + test: ["CMD", "curl", "-f", "https://localhost/healthz"] + timeout: 5s + retries: 5 + start_period: 60s + volumes: + - mercure_data:/data + - mercure_config:/config +###< symfony/mercure-bundle ### volumes: -###> doctrine/doctrine-bundle ### + caddy_data: + caddy_config: + ###> doctrine/doctrine-bundle ### database_data: -###< doctrine/doctrine-bundle ### + ###< doctrine/doctrine-bundle ### + ###> symfony/mercure-bundle ### + mercure_data: + mercure_config: +###< symfony/mercure-bundle ### diff --git a/composer.json b/composer.json index 5e83df81..1e8aeac1 100644 --- a/composer.json +++ b/composer.json @@ -7,9 +7,12 @@ "php": ">=8.4", "ext-ctype": "*", "ext-iconv": "*", + "ext-imap": "*", + "ddeboer/imap": "^1.23", "doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6", + "knplabs/knp-paginator-bundle": "*", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpdoc-parser": "^2.3", "symfony/asset": "8.1.*", @@ -24,6 +27,7 @@ "symfony/http-client": "8.1.*", "symfony/intl": "8.1.*", "symfony/mailer": "8.1.*", + "symfony/mercure-bundle": "^0.4.2", "symfony/mime": "8.1.*", "symfony/monolog-bundle": "^3.0|^4.0", "symfony/notifier": "8.1.*", @@ -37,12 +41,16 @@ "symfony/string": "8.1.*", "symfony/translation": "8.1.*", "symfony/twig-bundle": "8.1.*", + "symfony/uid": "8.1.*", "symfony/ux-turbo": "^3.2", "symfony/validator": "8.1.*", "symfony/web-link": "8.1.*", "symfony/yaml": "8.1.*", + "symfonycasts/tailwind-bundle": "^0.14.0", "twig/extra-bundle": "^2.12|^3.0", - "twig/twig": "^2.12|^3.0" + "twig/twig": "^2.12|^3.0", + "webklex/php-imap": "^6.2", + "ext-pcntl": "*" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 573b8773..bf5a8a37 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,77 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "46ab4131ce0862853c1dec03b5b40d34", + "content-hash": "c2b6d7470e59db8850710eacfc5e598b", "packages": [ + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, { "name": "composer/semver", "version": "3.4.4", @@ -83,6 +152,83 @@ ], "time": "2025-08-20T19:15:30+00:00" }, + { + "name": "ddeboer/imap", + "version": "1.23.0", + "source": { + "type": "git", + "url": "https://github.com/ddeboer/imap.git", + "reference": "3c47a731b1c009a4e8266094c409761a41c6d17b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ddeboer/imap/zipball/3c47a731b1c009a4e8266094c409761a41c6d17b", + "reference": "3c47a731b1c009a4e8266094c409761a41c6d17b", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-iconv": "*", + "ext-imap": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "php": "~8.4.0 || ~8.5.0" + }, + "require-dev": { + "egulias/email-validator": "^4.0.4", + "friendsofphp/php-cs-fixer": "^3.89.0", + "phpstan/phpstan": "^2.1.31", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpstan/phpstan-strict-rules": "^2.0.7", + "phpunit/phpunit": "^12.4.1", + "symfony/mime": "^7.3.4 || ^8.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ddeboer\\Imap\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "David de Boer", + "email": "david@ddeboer.nl" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com" + }, + { + "name": "Community contributors", + "homepage": "https://github.com/ddeboer/imap/graphs/contributors" + } + ], + "description": "Object-oriented IMAP for PHP", + "keywords": [ + "email", + "imap", + "mail" + ], + "support": { + "issues": "https://github.com/ddeboer/imap/issues", + "source": "https://github.com/ddeboer/imap/tree/1.23.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2025-12-01T07:53:04+00:00" + }, { "name": "doctrine/collections", "version": "2.6.0", @@ -1265,6 +1411,623 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "illuminate/collections", + "version": "v13.18.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "8f10727c854250bd7c4c50cd04610722dd3007b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/8f10727c854250bd7c4c50cd04610722dd3007b5", + "reference": "8f10727c854250bd7c4c50cd04610722dd3007b5", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "php": "^8.3", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36" + }, + "suggest": { + "illuminate/http": "Required to convert collections to API resources (^13.0).", + "symfony/var-dumper": "Required to use the dump method (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-25T16:46:05+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v13.18.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/7f1ef52d9a346f829421b296adfb7644a951b216", + "reference": "7f1ef52d9a346f829421b296adfb7644a951b216", + "shasum": "" + }, + "require": { + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-02-25T16:07:55+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v13.18.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "a108b67e086a933e92abebe69bb8e15c1218d3c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/a108b67e086a933e92abebe69bb8e15c1218d3c8", + "reference": "a108b67e086a933e92abebe69bb8e15c1218d3c8", + "shasum": "" + }, + "require": { + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-25T18:32:48+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v13.18.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "reference": "59b5b5f3cf290a91db8cf6cd3d35ff56978bc057", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-04-29T09:35:06+00:00" + }, + { + "name": "illuminate/pagination", + "version": "v13.18.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/pagination.git", + "reference": "20f10920affbea754c374c0eea99b17256c41573" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/pagination/zipball/20f10920affbea754c374c0eea99b17256c41573", + "reference": "20f10920affbea754c374c0eea99b17256c41573", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/support": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Pagination\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Pagination package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-25T16:46:05+00:00" + }, + { + "name": "illuminate/reflection", + "version": "v13.18.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/reflection.git", + "reference": "178cdb5eb08c5369ae6b71518e557bed68e74577" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/178cdb5eb08c5369ae6b71518e557bed68e74577", + "reference": "178cdb5eb08c5369ae6b71518e557bed68e74577", + "shasum": "" + }, + "require": { + "illuminate/collections": "^13.0", + "illuminate/contracts": "^13.0", + "php": "^8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Reflection package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-18T15:47:27+00:00" + }, + { + "name": "illuminate/support", + "version": "v13.18.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "2be3f10e603d0503d33c6d91c9f6484640b5a8ac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/2be3f10e603d0503d33c6d91c9f6484640b5a8ac", + "reference": "2be3f10e603d0503d33c6d91c9f6484640b5a8ac", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^13.0", + "illuminate/conditionable": "^13.0", + "illuminate/contracts": "^13.0", + "illuminate/macroable": "^13.0", + "illuminate/reflection": "^13.0", + "nesbot/carbon": "^3.8.4", + "php": "^8.3", + "symfony/polyfill-php85": "^1.36", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^13.0).", + "laravel/serializable-closure": "Required to use the once function (^2.0.10).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.4 || ^8.0).", + "symfony/uid": "Required to use Str::ulid() (^7.4 || ^8.0).", + "symfony/var-dumper": "Required to use the dd function (^7.4 || ^8.0).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-06-28T16:54:25+00:00" + }, + { + "name": "knplabs/knp-components", + "version": "v5.2.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/knp-components.git", + "reference": "eabf39263fff305c0024820c3736e5b03e7edf50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/knp-components/zipball/eabf39263fff305c0024820c3736e5b03e7edf50", + "reference": "eabf39263fff305c0024820c3736e5b03e7edf50", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/event-dispatcher-contracts": "^3.0" + }, + "conflict": { + "doctrine/dbal": "<3.8" + }, + "require-dev": { + "doctrine/dbal": "^3.8 || ^4.0", + "doctrine/mongodb-odm": "^2.5.5", + "doctrine/orm": "^2.13 || ^3.0", + "doctrine/phpcr-odm": "^1.8 || ^2.0", + "ext-pdo_sqlite": "*", + "jackalope/jackalope-doctrine-dbal": "^1.12 || ^2.0", + "phpunit/phpunit": "^10.5 || ^11.3", + "propel/propel1": "^1.7", + "ruflin/elastica": "^7.0", + "solarium/solarium": "^6.0", + "symfony/http-foundation": "^5.4.38 || ^6.4.4 || ^7.0", + "symfony/http-kernel": "^5.4.38 || ^6.4.4 || ^7.0", + "symfony/property-access": "^5.4.38 || ^6.4.4 || ^7.0" + }, + "suggest": { + "doctrine/common": "to allow usage pagination with Doctrine ArrayCollection", + "doctrine/mongodb-odm": "to allow usage pagination with Doctrine ODM MongoDB", + "doctrine/orm": "to allow usage pagination with Doctrine ORM", + "doctrine/phpcr-odm": "to allow usage pagination with Doctrine ODM PHPCR", + "propel/propel1": "to allow usage pagination with Propel ORM", + "ruflin/elastica": "to allow usage pagination with ElasticSearch Client", + "solarium/solarium": "to allow usage pagination with Solarium Client", + "symfony/http-foundation": "to retrieve arguments from Request", + "symfony/property-access": "to allow sorting arrays" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Component\\": "src/Knp/Component" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "https://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/KnpLabs/knp-components/contributors" + } + ], + "description": "Knplabs component library", + "homepage": "https://github.com/KnpLabs/knp-components", + "keywords": [ + "components", + "knp", + "knplabs", + "pager", + "paginator" + ], + "support": { + "issues": "https://github.com/KnpLabs/knp-components/issues", + "source": "https://github.com/KnpLabs/knp-components/tree/v5.2.0" + }, + "time": "2025-03-20T07:35:37+00:00" + }, + { + "name": "knplabs/knp-paginator-bundle", + "version": "v6.10.0", + "source": { + "type": "git", + "url": "https://github.com/KnpLabs/KnpPaginatorBundle.git", + "reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/KnpLabs/KnpPaginatorBundle/zipball/8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41", + "reference": "8d41f8ed47d880f8fa569389ffa4fecfbc5b8d41", + "shasum": "" + }, + "require": { + "knplabs/knp-components": "^4.4 || ^5.0", + "php": "^8.1", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^6.4 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.12", + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.2", + "symfony/templating": "^6.4 || ^7.0 || ^8.0", + "symfony/translation": "^6.4 || ^7.0 || ^8.0", + "twig/twig": "^3.0" + }, + "suggest": { + "symfony/translation": "To use the templates", + "twig/twig": "To use the templates" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Knp\\Bundle\\PaginatorBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KnpLabs Team", + "homepage": "https://knplabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/KnpLabs/KnpPaginatorBundle/contributors" + } + ], + "description": "Paginator bundle for Symfony to automate pagination and simplify sorting and other features", + "homepage": "https://github.com/KnpLabs/KnpPaginatorBundle", + "keywords": [ + "bundle", + "knp", + "knplabs", + "pager", + "pagination", + "paginator", + "symfony" + ], + "support": { + "issues": "https://github.com/KnpLabs/KnpPaginatorBundle/issues", + "source": "https://github.com/KnpLabs/KnpPaginatorBundle/tree/v6.10.0" + }, + "time": "2025-11-29T09:14:09+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -1369,240 +2132,109 @@ "time": "2026-01-02T08:56:05+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.13.4", + "name": "nesbot/carbon", + "version": "3.13.0", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/40f6618f052df16b545f626fbf9a878e6497d16a", + "reference": "40f6618f052df16b545f626fbf9a878e6497d16a", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" + "provide": { + "psr/clock-implementation": "1.0" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" }, + "bin": [ + "bin/carbon" + ], "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" + "Carbon\\": "src/Carbon/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "date", + "datetime", + "time" ], "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", "type": "tidelift" } ], - "time": "2025-08-01T08:46:24+00:00" - }, - { - "name": "nikic/php-parser", - "version": "v5.7.0", - "source": { - "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" - }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" - }, - "bin": [ - "bin/php-parse" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "PhpParser\\": "lib/PhpParser" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Nikita Popov" - } - ], - "description": "A PHP parser written in PHP", - "keywords": [ - "parser", - "php" - ], - "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" - }, - "time": "2025-12-06T11:56:16+00:00" - }, - { - "name": "phar-io/manifest", - "version": "2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" - }, - "funding": [ - { - "url": "https://github.com/theseer", - "type": "github" - } - ], - "time": "2024-03-03T12:33:53+00:00" - }, - { - "name": "phar-io/version", - "version": "3.2.1", - "source": { - "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" - }, - { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" - } - ], - "description": "Library for handling version information and constraints", - "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" - }, - "time": "2022-02-21T01:04:05+00:00" + "time": "2026-06-18T13:49:15+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -1827,481 +2459,6 @@ }, "time": "2026-01-25T14:56:51+00:00" }, - { - "name": "phpunit/php-code-coverage", - "version": "14.2.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83", - "reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xmlwriter": "*", - "nikic/php-parser": "^5.7.0", - "php": ">=8.4", - "phpunit/php-text-template": "^6.0", - "sebastian/complexity": "^6.0", - "sebastian/environment": "^9.3.2", - "sebastian/git-state": "^1.0", - "sebastian/lines-of-code": "^5.0.1", - "sebastian/version": "^7.0", - "theseer/tokenizer": "^2.0.1" - }, - "require-dev": { - "phpunit/phpunit": "^13.2.0" - }, - "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "14.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", - "keywords": [ - "coverage", - "testing", - "xunit" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", - "type": "tidelift" - } - ], - "time": "2026-06-08T11:50:38+00:00" - }, - { - "name": "phpunit/php-file-iterator", - "version": "7.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", - "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "7.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", - "keywords": [ - "filesystem", - "iterator" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:33:26+00:00" - }, - { - "name": "phpunit/php-invoker", - "version": "7.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", - "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^13.0" - }, - "suggest": { - "ext-pcntl": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "7.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", - "keywords": [ - "process" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:34:47+00:00" - }, - { - "name": "phpunit/php-text-template", - "version": "6.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", - "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:36:37+00:00" - }, - { - "name": "phpunit/php-timer", - "version": "9.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", - "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "9.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", - "keywords": [ - "timer" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:37:53+00:00" - }, - { - "name": "phpunit/phpunit", - "version": "13.2.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba", - "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xml": "*", - "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.4", - "phar-io/manifest": "^2.0.4", - "phar-io/version": "^3.2.1", - "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.2.2", - "phpunit/php-file-iterator": "^7.0.0", - "phpunit/php-invoker": "^7.0.0", - "phpunit/php-text-template": "^6.0.0", - "phpunit/php-timer": "^9.0.0", - "sebastian/cli-parser": "^5.0.0", - "sebastian/comparator": "^8.3.0", - "sebastian/diff": "^9.0", - "sebastian/environment": "^9.3.2", - "sebastian/exporter": "^8.1.0", - "sebastian/file-filter": "^1.0", - "sebastian/git-state": "^1.0", - "sebastian/global-state": "^9.0.1", - "sebastian/object-enumerator": "^8.0.0", - "sebastian/recursion-context": "^8.0.0", - "sebastian/type": "^7.0.1", - "sebastian/version": "^7.0.0", - "staabm/side-effects-detector": "^1.0.5" - }, - "bin": [ - "phpunit" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "13.2-dev" - } - }, - "autoload": { - "files": [ - "src/Framework/Assert/Functions.php" - ], - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", - "keywords": [ - "phpunit", - "testing", - "xunit" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.1" - }, - "funding": [ - { - "url": "https://phpunit.de/sponsoring.html", - "type": "other" - } - ], - "time": "2026-06-15T13:14:22+00:00" - }, { "name": "psr/cache", "version": "3.0.0", @@ -2609,1163 +2766,55 @@ "time": "2024-09-11T13:17:53+00:00" }, { - "name": "sebastian/cli-parser", - "version": "5.0.0", + "name": "psr/simple-cache", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", - "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", "shasum": "" }, "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-master": "3.0.x-dev" } }, "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "psr-4": { + "Psr\\SimpleCache\\": "src/" } - ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", - "support": { - "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:39:44+00:00" - }, - { - "name": "sebastian/comparator", - "version": "8.3.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "c025fc7604afab3f195fab7cdaf72327331af241" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", - "reference": "c025fc7604afab3f195fab7cdaf72327331af241", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-mbstring": "*", - "php": ">=8.4", - "sebastian/diff": "^9.0", - "sebastian/exporter": "^8.1.0" - }, - "require-dev": { - "phpunit/phpunit": "^13.2" - }, - "suggest": { - "ext-bcmath": "For comparing BcMath\\Number objects" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "8.3-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - } - ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", - "keywords": [ - "comparator", - "compare", - "equality" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/8.3.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", - "type": "tidelift" - } - ], - "time": "2026-06-05T03:06:45+00:00" - }, - { - "name": "sebastian/complexity", - "version": "6.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "c5651c795c98093480df79350cb050813fc7a2f3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", - "reference": "c5651c795c98093480df79350cb050813fc7a2f3", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.0", - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", - "support": { - "issues": "https://github.com/sebastianbergmann/complexity/issues", - "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:41:32+00:00" - }, - { - "name": "sebastian/diff", - "version": "9.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", - "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.2", - "symfony/process": "^7.4.13" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "9.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" - } - ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", - "type": "tidelift" - } - ], - "time": "2026-06-05T03:04:51+00:00" - }, - { - "name": "sebastian/environment", - "version": "9.3.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", - "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.1.11" - }, - "suggest": { - "ext-posix": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "9.3-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "https://github.com/sebastianbergmann/environment", - "keywords": [ - "Xdebug", - "environment", - "hhvm" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", - "type": "tidelift" - } - ], - "time": "2026-05-25T13:41:38+00:00" - }, - { - "name": "sebastian/exporter", - "version": "8.1.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", - "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "php": ">=8.4", - "sebastian/recursion-context": "^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^13.1.10" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "8.1-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", - "type": "tidelift" - } - ], - "time": "2026-05-21T11:50:56+00:00" - }, - { - "name": "sebastian/file-filter", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/file-filter.git", - "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", - "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library for filtering files", - "homepage": "https://github.com/sebastianbergmann/file-filter", - "support": { - "issues": "https://github.com/sebastianbergmann/file-filter/issues", - "security": "https://github.com/sebastianbergmann/file-filter/security/policy", - "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", - "type": "tidelift" - } - ], - "time": "2026-04-22T07:20:04+00:00" - }, - { - "name": "sebastian/git-state", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/git-state.git", - "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", - "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library for describing the state of a Git checkout", - "homepage": "https://github.com/sebastianbergmann/git-state", - "support": { - "issues": "https://github.com/sebastianbergmann/git-state/issues", - "security": "https://github.com/sebastianbergmann/git-state/security/policy", - "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", - "type": "tidelift" - } - ], - "time": "2026-03-21T12:54:28+00:00" - }, - { - "name": "sebastian/global-state", - "version": "9.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", - "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "sebastian/object-reflector": "^6.0", - "sebastian/recursion-context": "^8.0" - }, - "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^13.1.13" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "9.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Snapshotting of global state", - "homepage": "https://www.github.com/sebastianbergmann/global-state", - "keywords": [ - "global state" - ], - "support": { - "issues": "https://github.com/sebastianbergmann/global-state/issues", - "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", - "type": "tidelift" - } - ], - "time": "2026-06-01T15:11:33+00:00" - }, - { - "name": "sebastian/lines-of-code", - "version": "5.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", - "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", - "shasum": "" - }, - "require": { - "nikic/php-parser": "^5.7.0", - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.1.10" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", - "support": { - "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", - "type": "tidelift" - } - ], - "time": "2026-05-19T16:23:37+00:00" - }, - { - "name": "sebastian/object-enumerator", - "version": "8.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", - "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", - "shasum": "" - }, - "require": { - "php": ">=8.4", - "sebastian/object-reflector": "^6.0", - "sebastian/recursion-context": "^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "8.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", - "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:46:36+00:00" - }, - { - "name": "sebastian/object-reflector", - "version": "6.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", - "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", - "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:47:13+00:00" - }, - { - "name": "sebastian/recursion-context", - "version": "8.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", - "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "8.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", - "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:51:28+00:00" - }, - { - "name": "sebastian/type", - "version": "7.0.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fee0309275847fefd7636167085e379c1dbf6990" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", - "reference": "fee0309275847fefd7636167085e379c1dbf6990", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "require-dev": { - "phpunit/phpunit": "^13.1.10" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "7.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", - "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/type", - "type": "tidelift" - } - ], - "time": "2026-05-20T06:49:11+00:00" - }, - { - "name": "sebastian/version", - "version": "7.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", - "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", - "shasum": "" - }, - "require": { - "php": ">=8.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "7.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", - "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" - }, - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, - { - "url": "https://tidelift.com/funding/github/packagist/sebastian/version", - "type": "tidelift" - } - ], - "time": "2026-02-06T04:52:52+00:00" - }, - { - "name": "staabm/side-effects-detector", - "version": "1.0.5", - "source": { - "type": "git", - "url": "https://github.com/staabm/side-effects-detector.git", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", - "reference": "d8334211a140ce329c13726d4a715adbddd0a163", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.6", - "phpunit/phpunit": "^9.6.21", - "symfony/var-dumper": "^5.4.43", - "tomasvotruba/type-coverage": "1.0.0", - "tomasvotruba/unused-public": "1.0.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "lib/" - ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "A static analysis tool to detect side effects in PHP code", - "keywords": [ - "static analysis" - ], - "support": { - "issues": "https://github.com/staabm/side-effects-detector/issues", - "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" - }, - "funding": [ + "authors": [ { - "url": "https://github.com/staabm", - "type": "github" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "time": "2024-10-20T05:08:20+00:00" + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" }, { "name": "symfony/asset", @@ -3918,78 +2967,6 @@ ], "time": "2026-05-29T05:06:50+00:00" }, - { - "name": "symfony/browser-kit", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/browser-kit.git", - "reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/74e18e582cdda0eca35f7c74e1e48e62f0ede853", - "reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/dom-crawler": "^7.4|^8.0" - }, - "require-dev": { - "symfony/css-selector": "^7.4|^8.0", - "symfony/http-client": "^7.4|^8.0", - "symfony/mime": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\BrowserKit\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/browser-kit/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/cache", "version": "v8.1.0", @@ -4424,150 +3401,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", - "source": { - "type": "git", - "url": "https://github.com/symfony/debug-bundle.git", - "reference": "2da1f202b38f646dbee032529cfd8e727cd12cd1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/2da1f202b38f646dbee032529cfd8e727cd12cd1", - "reference": "2da1f202b38f646dbee032529cfd8e727cd12cd1", - "shasum": "" - }, - "require": { - "composer-runtime-api": ">=2.1", - "ext-xml": "*", - "php": ">=8.4.1", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/twig-bridge": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" - }, - "require-dev": { - "symfony/web-profiler-bundle": "^7.4|^8.0" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "Symfony\\Bundle\\DebugBundle\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/debug-bundle/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", @@ -4902,76 +3735,6 @@ ], "time": "2026-05-29T05:06:50+00:00" }, - { - "name": "symfony/dom-crawler", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6", - "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6", - "shasum": "" - }, - "require": { - "php": ">=8.4.1", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-mbstring": "^1.0" - }, - "require-dev": { - "symfony/css-selector": "^7.4|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases DOM navigation for HTML and XML documents", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/dom-crawler/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/dotenv", "version": "v8.1.0", @@ -6351,59 +5114,50 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "symfony/maker-bundle", - "version": "v1.67.0", + "name": "symfony/mercure", + "version": "v0.7.2", "source": { "type": "git", - "url": "https://github.com/symfony/maker-bundle.git", - "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" + "url": "https://github.com/symfony/mercure.git", + "reference": "3ba1d19c9792d6bf66cf6cb4412ea289e9a42565" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", - "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "url": "https://api.github.com/repos/symfony/mercure/zipball/3ba1d19c9792d6bf66cf6cb4412ea289e9a42565", + "reference": "3ba1d19c9792d6bf66cf6cb4412ea289e9a42565", "shasum": "" }, "require": { - "composer-runtime-api": "^2.1", - "doctrine/inflector": "^2.0", - "nikic/php-parser": "^5.0", "php": ">=8.1", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/deprecation-contracts": "^2.2|^3", - "symfony/filesystem": "^6.4|^7.0|^8.0", - "symfony/finder": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0" - }, - "conflict": { - "doctrine/doctrine-bundle": "<2.10", - "doctrine/orm": "<2.15" + "symfony/deprecation-contracts": "^2.0|^3.0", + "symfony/http-client": "^6.4|^7.3|^8.0", + "symfony/http-foundation": "^6.4|^7.3|^8.0", + "symfony/web-link": "^6.4|^7.3|^8.0" }, "require-dev": { - "composer/semver": "^3.0", - "doctrine/doctrine-bundle": "^2.10|^3.0", - "doctrine/orm": "^2.15|^3", - "doctrine/persistence": "^3.1|^4.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0", - "symfony/security-core": "^6.4|^7.0|^8.0", - "symfony/security-http": "^6.4|^7.0|^8.0", - "symfony/yaml": "^6.4|^7.0|^8.0", - "twig/twig": "^3.0|^4.x-dev" + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^6.4|^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.3|^8.0", + "symfony/phpunit-bridge": "^7.3.4|^8.0", + "symfony/stopwatch": "^6.4|^7.3|^8.0", + "twig/twig": "^2.0|^3.0|^4.0" }, - "type": "symfony-bundle", + "suggest": { + "symfony/stopwatch": "Integration with the profiler performances" + }, + "type": "library", "extra": { + "thanks": { + "url": "https://github.com/dunglas/mercure", + "name": "dunglas/mercure" + }, "branch-alias": { - "dev-main": "1.x-dev" + "dev-main": "0.6.x-dev" } }, "autoload": { "psr-4": { - "Symfony\\Bundle\\MakerBundle\\": "src/" + "Symfony\\Component\\Mercure\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -6411,43 +5165,119 @@ "MIT" ], "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.", - "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", + "description": "Symfony Mercure Component", + "homepage": "https://symfony.com", "keywords": [ - "code generator", - "dev", - "generator", - "scaffold", - "scaffolding" + "mercure", + "push", + "sse", + "updates" ], "support": { - "issues": "https://github.com/symfony/maker-bundle/issues", - "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" + "issues": "https://github.com/symfony/mercure/issues", + "source": "https://github.com/symfony/mercure/tree/v0.7.2" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/dunglas", "type": "github" }, { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure", "type": "tidelift" } ], - "time": "2026-03-18T13:39:06+00:00" + "time": "2025-12-15T15:22:09+00:00" + }, + { + "name": "symfony/mercure-bundle", + "version": "v0.4.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure-bundle.git", + "reference": "eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3", + "reference": "eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "php": ">=8.1", + "symfony/config": "^6.4|^7.3|^8.0", + "symfony/dependency-injection": "^6.4|^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.3|^8.0", + "symfony/mercure": "*", + "symfony/web-link": "^6.4|^7.3|^8.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^7.3.4|^8.0", + "symfony/stopwatch": "^6.4|^7.3|^8.0", + "symfony/ux-turbo": "*", + "symfony/var-dumper": "^6.4|^7.3|^8.0" + }, + "suggest": { + "symfony/messenger": "To use the Messenger integration" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MercureBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MercureBundle", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure-bundle/issues", + "source": "https://github.com/symfony/mercure-bundle/tree/v0.4.2" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle", + "type": "tidelift" + } + ], + "time": "2025-11-25T12:51:49+00:00" }, { "name": "symfony/messenger", @@ -7603,6 +6433,169 @@ ], "time": "2026-05-26T02:25:22+00:00" }, + { + "name": "symfony/polyfill-php86", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "reference": "fcec68d64f46dc84e1f6ffcf2c6dda40ff3143ad", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.38.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-25T11:52:35+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.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-04-10T16:19:22+00:00" + }, { "name": "symfony/process", "version": "v8.1.0", @@ -9210,6 +8203,84 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/uid", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/7393f157a55f7e70a4de0334435c55a5a8fe749a", + "reference": "7393f157a55f7e70a4de0334435c55a5a8fe749a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/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/ux-turbo", "version": "v3.2.0", @@ -9662,91 +8733,6 @@ ], "time": "2026-05-29T05:06:50+00:00" }, - { - "name": "symfony/web-profiler-bundle", - "version": "v8.1.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "f8ccea08797a511b85a698b0da40e1b9e6461086" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f8ccea08797a511b85a698b0da40e1b9e6461086", - "reference": "f8ccea08797a511b85a698b0da40e1b9e6461086", - "shasum": "" - }, - "require": { - "composer-runtime-api": ">=2.1", - "php": ">=8.4.1", - "symfony/config": "^7.4|^8.0", - "symfony/framework-bundle": "^7.4|^8.0", - "symfony/http-kernel": "^8.1", - "symfony/routing": "^7.4|^8.0", - "symfony/twig-bundle": "^7.4|^8.0" - }, - "conflict": { - "symfony/serializer": "<7.4", - "symfony/workflow": "<7.4" - }, - "require-dev": { - "symfony/browser-kit": "^7.4|^8.0", - "symfony/console": "^7.4|^8.0", - "symfony/css-selector": "^7.4|^8.0", - "symfony/runtime": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0" - }, - "type": "symfony-bundle", - "autoload": { - "psr-4": { - "Symfony\\Bundle\\WebProfilerBundle\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides a development tool that gives detailed information about the execution of any request", - "homepage": "https://symfony.com", - "keywords": [ - "dev" - ], - "support": { - "source": "https://github.com/symfony/web-profiler-bundle/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/yaml", "version": "v8.1.0", @@ -9824,54 +8810,67 @@ "time": "2026-05-29T05:06:50+00:00" }, { - "name": "theseer/tokenizer", - "version": "2.0.1", + "name": "symfonycasts/tailwind-bundle", + "version": "v0.14.0", "source": { "type": "git", - "url": "https://github.com/theseer/tokenizer.git", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + "url": "https://github.com/SymfonyCasts/tailwind-bundle.git", + "reference": "eb839e8afa3d7dc2220ae1a7a6b8d215ccf85f83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", - "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "url": "https://api.github.com/repos/SymfonyCasts/tailwind-bundle/zipball/eb839e8afa3d7dc2220ae1a7a6b8d215ccf85f83", + "reference": "eb839e8afa3d7dc2220ae1a7a6b8d215ccf85f83", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": "^8.1" + "php": ">=8.2", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^8.1", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "Symfonycasts\\TailwindBundle\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Ryan Weaver", + "homepage": "https://symfonycasts.com" } ], - "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "description": "Delightful Tailwind Support for Symfony + AssetMapper", + "keywords": [ + "asset-mapper", + "tailwind" + ], "support": { - "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + "issues": "https://github.com/SymfonyCasts/tailwind-bundle/issues", + "source": "https://github.com/SymfonyCasts/tailwind-bundle/tree/v0.14.0" }, "funding": [ { - "url": "https://github.com/theseer", - "type": "github" + "url": "https://symfonycasts.com", + "type": "custom" } ], - "time": "2025-12-08T11:19:18+00:00" + "time": "2026-06-21T15:46:15+00:00" }, { "name": "twig/extra-bundle", @@ -10027,6 +9026,161 @@ ], "time": "2026-05-30T17:09:26+00:00" }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "webklex/php-imap", + "version": "6.2.0", + "source": { + "type": "git", + "url": "https://github.com/Webklex/php-imap.git", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Webklex/php-imap/zipball/6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "reference": "6b8ef85d621bbbaf52741b00cca8e9237e2b2e05", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-iconv": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-zip": "*", + "illuminate/pagination": ">=5.0.0", + "nesbot/carbon": "^2.62.1|^3.2.4", + "php": "^8.0.2", + "symfony/http-foundation": ">=2.8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "symfony/mime": "Recomended for better extension support", + "symfony/var-dumper": "Usefull tool for debugging" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webklex\\PHPIMAP\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Malte Goldenbaum", + "email": "github@webklex.com", + "role": "Developer" + } + ], + "description": "PHP IMAP client", + "homepage": "https://github.com/webklex/php-imap", + "keywords": [ + "imap", + "mail", + "php-imap", + "pop3", + "webklex" + ], + "support": { + "issues": "https://github.com/Webklex/php-imap/issues", + "source": "https://github.com/Webklex/php-imap/tree/6.2.0" + }, + "funding": [ + { + "url": "https://www.buymeacoffee.com/webklex", + "type": "custom" + }, + { + "url": "https://ko-fi.com/webklex", + "type": "ko_fi" + } + ], + "time": "2025-04-25T06:02:37+00:00" + }, { "name": "webmozart/assert", "version": "2.4.1", @@ -10094,17 +9248,2409 @@ "time": "2026-06-15T15:31:57+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "14.2.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83", + "reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.4", + "phpunit/php-text-template": "^6.0", + "sebastian/complexity": "^6.0", + "sebastian/environment": "^9.3.2", + "sebastian/git-state": "^1.0", + "sebastian/lines-of-code": "^5.0.1", + "sebastian/version": "^7.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^13.2.0" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "14.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-08T11:50:38+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "reference": "6e5aa1fb0a95b1703d83e721299ee18bb4e2de50", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:33:26+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "reference": "42e5c5cae0c65df12d1b1a3ab52bf3f50f244d88", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^13.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-invoker", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:34:47+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/a47af19f93f76aa3368303d752aa5272ca3299f4", + "reference": "a47af19f93f76aa3368303d752aa5272ca3299f4", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-text-template", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:36:37+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "reference": "a0e12065831f6ab0d83120dc61513eb8d9a966f6", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-timer", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:37:53+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "13.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/60da0ff1e10a0f72ee18a24117ec3b613a346bba", + "reference": "60da0ff1e10a0f72ee18a24117ec3b613a346bba", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.4.1", + "phpunit/php-code-coverage": "^14.2.2", + "phpunit/php-file-iterator": "^7.0.0", + "phpunit/php-invoker": "^7.0.0", + "phpunit/php-text-template": "^6.0.0", + "phpunit/php-timer": "^9.0.0", + "sebastian/cli-parser": "^5.0.0", + "sebastian/comparator": "^8.3.0", + "sebastian/diff": "^9.0", + "sebastian/environment": "^9.3.2", + "sebastian/exporter": "^8.1.0", + "sebastian/file-filter": "^1.0", + "sebastian/git-state": "^1.0", + "sebastian/global-state": "^9.0.1", + "sebastian/object-enumerator": "^8.0.0", + "sebastian/recursion-context": "^8.0.0", + "sebastian/type": "^7.0.1", + "sebastian/version": "^7.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "13.2-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.1" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-06-15T13:14:22+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/48a4654fa5e48c1c81214e9930048a572d4b23ca", + "reference": "48a4654fa5e48c1c81214e9930048a572d4b23ca", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:39:44+00:00" + }, + { + "name": "sebastian/comparator", + "version": "8.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c025fc7604afab3f195fab7cdaf72327331af241", + "reference": "c025fc7604afab3f195fab7cdaf72327331af241", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/diff": "^9.0", + "sebastian/exporter": "^8.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/8.3.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:06:45+00:00" + }, + { + "name": "sebastian/complexity", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/c5651c795c98093480df79350cb050813fc7a2f3", + "reference": "c5651c795c98093480df79350cb050813fc7a2f3", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/complexity", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:41:32+00:00" + }, + { + "name": "sebastian/diff", + "version": "9.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-06-05T03:04:51+00:00" + }, + { + "name": "sebastian/environment", + "version": "9.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "reference": "6c9e487c9eb706a8d258102a1c0b0a3e53e86c2e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.11" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/9.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:41:38+00:00" + }, + { + "name": "sebastian/exporter", + "version": "8.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c0d29a945f8cf82f300a05e69874508e307ca4c6", + "reference": "c0d29a945f8cf82f300a05e69874508e307ca4c6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.4", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/8.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-21T11:50:56+00:00" + }, + { + "name": "sebastian/file-filter", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/33a26f394330f6faa7684bb9cc73afb7727aae93", + "reference": "33a26f394330f6faa7684bb9cc73afb7727aae93", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-04-22T07:20:04+00:00" + }, + { + "name": "sebastian/git-state", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/git-state.git", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/git-state/zipball/792a952e0eba55b6960a48aeceb9f371aad1f76b", + "reference": "792a952e0eba55b6960a48aeceb9f371aad1f76b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for describing the state of a Git checkout", + "homepage": "https://github.com/sebastianbergmann/git-state", + "support": { + "issues": "https://github.com/sebastianbergmann/git-state/issues", + "security": "https://github.com/sebastianbergmann/git-state/security/policy", + "source": "https://github.com/sebastianbergmann/git-state/tree/1.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/git-state", + "type": "tidelift" + } + ], + "time": "2026-03-21T12:54:28+00:00" + }, + { + "name": "sebastian/global-state", + "version": "9.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "reference": "ba68ba79da690cf7eddefd3ce5b78b20b9ba9945", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^13.1.13" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/9.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:11:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "reference": "d2cff273a90c79b0eb590baa682d4b5c318bdbb7", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:23:37+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "reference": "b39ab125fd9a7434b0ecbc4202eebce11a98cfc5", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "sebastian/object-reflector": "^6.0", + "sebastian/recursion-context": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-enumerator", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:46:36+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "reference": "3ca042c2c60b0eab094f8a1b6a7093f4d4c72200", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/object-reflector", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:47:13+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/74c5af21f6a5833e91767ca068c4d3dfec15317e", + "reference": "74c5af21f6a5833e91767ca068c4d3dfec15317e", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:51:28+00:00" + }, + { + "name": "sebastian/type", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "fee0309275847fefd7636167085e379c1dbf6990" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fee0309275847fefd7636167085e379c1dbf6990", + "reference": "fee0309275847fefd7636167085e379c1dbf6990", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.1.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:49:11+00:00" + }, + { + "name": "sebastian/version", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/ad37a5552c8e2b88572249fdc19b6da7792e021b", + "reference": "ad37a5552c8e2b88572249fdc19b6da7792e021b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/version", + "type": "tidelift" + } + ], + "time": "2026-02-06T04:52:52+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/74e18e582cdda0eca35f7c74e1e48e62f0ede853", + "reference": "74e18e582cdda0eca35f7c74e1e48e62f0ede853", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/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/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", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug-bundle.git", + "reference": "2da1f202b38f646dbee032529cfd8e727cd12cd1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/2da1f202b38f646dbee032529cfd8e727cd12cd1", + "reference": "2da1f202b38f646dbee032529cfd8e727cd12cd1", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.4.1", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "require-dev": { + "symfony/web-profiler-bundle": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\DebugBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/debug-bundle/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/dom-crawler", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/77ca351474ea018daba5f2e473cbf1b9b8e72ac6", + "reference": "77ca351474ea018daba5f2e473cbf1b9b8e72ac6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/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/maker-bundle", + "version": "v1.67.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/maker-bundle.git", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1", + "doctrine/inflector": "^2.0", + "nikic/php-parser": "^5.0", + "php": ">=8.1", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/doctrine-bundle": "<2.10", + "doctrine/orm": "<2.15" + }, + "require-dev": { + "composer/semver": "^3.0", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/orm": "^2.15|^3", + "doctrine/persistence": "^3.1|^4.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/twig": "^3.0|^4.x-dev" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MakerBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.", + "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", + "keywords": [ + "code generator", + "dev", + "generator", + "scaffold", + "scaffolding" + ], + "support": { + "issues": "https://github.com/symfony/maker-bundle/issues", + "source": "https://github.com/symfony/maker-bundle/tree/v1.67.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-03-18T13:39:06+00:00" + }, + { + "name": "symfony/web-profiler-bundle", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/web-profiler-bundle.git", + "reference": "f8ccea08797a511b85a698b0da40e1b9e6461086" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/f8ccea08797a511b85a698b0da40e1b9e6461086", + "reference": "f8ccea08797a511b85a698b0da40e1b9e6461086", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "php": ">=8.4.1", + "symfony/config": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^8.1", + "symfony/routing": "^7.4|^8.0", + "symfony/twig-bundle": "^7.4|^8.0" + }, + "conflict": { + "symfony/serializer": "<7.4", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "symfony/browser-kit": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\WebProfilerBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a development tool that gives detailed information about the execution of any request", + "homepage": "https://symfony.com", + "keywords": [ + "dev" + ], + "support": { + "source": "https://github.com/symfony/web-profiler-bundle/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": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": ">=8.4", "ext-ctype": "*", - "ext-iconv": "*" + "ext-iconv": "*", + "ext-imap": "*" }, - "platform-dev": [], + "platform-dev": {}, "plugin-api-version": "2.9.0" } diff --git a/config/bundles.php b/config/bundles.php index 4e3a5607..087d6b21 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -13,4 +13,7 @@ return [ Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Symfonycasts\TailwindBundle\SymfonycastsTailwindBundle::class => ['all' => true], + Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true], + Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], ]; diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 290611c3..bcda8dc7 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -2,6 +2,7 @@ doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' + # IMPORTANT: You MUST configure your server version, # either here or in the DATABASE_URL env var (see .env file) #server_version: '16' diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml new file mode 100644 index 00000000..ff21c915 --- /dev/null +++ b/config/packages/mercure.yaml @@ -0,0 +1,9 @@ +mercure: + hubs: + default: + url: '%env(default::MERCURE_URL)%' + public_url: '%env(default::MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_JWT_SECRET)%' + publish: '*' + subscribe: '*' diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 55f252dc..390387e1 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -21,6 +21,7 @@ framework: Symfony\Component\Mailer\Messenger\SendEmailMessage: async Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\SmsMessage: async + App\Message\SyncMailboxMessage: async # Route your messages to the transports # 'App\Message\YourMessage': async diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 89640445..abc199ba 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -2,36 +2,39 @@ security: # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' - # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + app_user_provider: + id: App\Security\UserProvider + + role_hierarchy: + ROLE_ADMIN: ROLE_USER firewalls: dev: - # Ensure dev tools and static assets are always allowed - pattern: ^/(_profiler|_wdt|assets|build)/ + pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory + provider: app_user_provider + custom_authenticator: App\Security\LoginFormAuthenticator + logout: + path: app_logout - # Activate different ways to authenticate: - # https://symfony.com/doc/current/security.html#the-firewall - - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true - - # Note: Only the *first* matching rule is applied + # Easy way to control access for large sections of your site + # Note: Only the *first* access control that matches will be used access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/admin, roles: ROLE_ADMIN } + - { path: ^/login, roles: PUBLIC_ACCESS } + - { path: ^/, roles: ROLE_USER } when@test: security: password_hashers: - # Password hashers are resource-intensive by design to ensure security. - # In tests, it's safe to reduce their cost to improve performance. + # By default, password hashers are resource intensive and take time. This is + # important to generate secure password hashes. In tests however, secure hashes + # are not important, waste resources and increase test times. The following + # reduces the work factor to the lowest possible values. Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: algorithm: auto cost: 4 # Lowest possible value for bcrypt diff --git a/config/packages/symfonycasts_tailwind.yaml b/config/packages/symfonycasts_tailwind.yaml new file mode 100644 index 00000000..4358d781 --- /dev/null +++ b/config/packages/symfonycasts_tailwind.yaml @@ -0,0 +1,2 @@ +symfonycasts_tailwind: + binary_version: v4.3.1 diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3f795d92..1f575f62 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,5 +1,9 @@ twig: file_name_pattern: '*.twig' + form_themes: + - '_partials/modal_form_theme.html.twig' + globals: + accounts: '@App\Twig\AccountsGlobal' when@test: twig: diff --git a/config/reference.php b/config/reference.php index d37675b2..97bc1c97 100644 --- a/config/reference.php +++ b/config/reference.php @@ -646,7 +646,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * }>, * }, * uid?: bool|array{ // Uid configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * default_uuid_version?: 7|6|4|1|Param, // Default: 7 * name_based_uuid_version?: 5|3|Param, // Default: 5 * name_based_uuid_namespace?: scalar|Param|null, @@ -1474,6 +1474,59 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * generate_final_classes?: bool|Param, // Default: true * generate_final_entities?: bool|Param, // Default: false * } + * @psalm-type SymfonycastsTailwindConfig = array{ + * input_css?: list, + * config_file?: scalar|Param|null, // Path to the tailwind.config.js file // Default: "%kernel.project_dir%/tailwind.config.js" + * binary?: scalar|Param|null, // The tailwind binary to use instead of downloading a new one // Default: null + * binary_version?: scalar|Param|null, // Tailwind CLI version to download - null means the latest version // Default: null + * binary_platform?: "auto"|"linux-arm64"|"linux-arm64-musl"|"linux-x64"|"linux-x64-musl"|"macos-arm64"|"macos-x64"|"windows-x64"|Param, // Tailwind CLI platform to download - "auto" will try to detect the platform automatically // Default: "auto" + * postcss_config_file?: scalar|Param|null, // Path to PostCSS config file which is passed to the Tailwind CLI // Default: null + * strict_mode?: bool|Param|null, // When enabled, an exception will be thrown if there are no built assets (default: false in `test` env, true otherwise) // Default: null + * process_timeout?: int|Param, // Timeout in seconds for the Tailwind build process - use "0" to disable // Default: 60 + * } + * @psalm-type KnpPaginatorConfig = array{ + * default_options?: array{ + * sort_field_name?: scalar|Param|null, // Default: "sort" + * sort_direction_name?: scalar|Param|null, // Default: "direction" + * filter_field_name?: scalar|Param|null, // Default: "filterField" + * filter_value_name?: scalar|Param|null, // Default: "filterValue" + * page_name?: scalar|Param|null, // Default: "page" + * distinct?: bool|Param, // Default: true + * page_out_of_range?: scalar|Param|null, // Default: "ignore" + * default_limit?: scalar|Param|null, // Default: 10 + * }, + * template?: array{ + * pagination?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/sliding.html.twig" + * rel_links?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/rel_links.html.twig" + * filtration?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/filtration.html.twig" + * sortable?: scalar|Param|null, // Default: "@KnpPaginator/Pagination/sortable_link.html.twig" + * }, + * page_range?: scalar|Param|null, // Default: 5 + * page_limit?: scalar|Param|null, // Default: null + * convert_exception?: bool|Param, // Default: false + * remove_first_page_param?: bool|Param, // Default: false + * } + * @psalm-type MercureConfig = array{ + * hubs?: array, + * subscribe?: list, + * secret?: scalar|Param|null, // The JWT Secret to use. + * passphrase?: scalar|Param|null, // The JWT secret passphrase. // Default: "" + * algorithm?: scalar|Param|null, // The algorithm to use to sign the JWT // Default: "hmac.sha256" + * }, + * jwt_provider?: scalar|Param|null, // Deprecated: The child node "jwt_provider" at path "mercure.hubs..jwt_provider" is deprecated, use "jwt.provider" instead. // The ID of a service to call to generate the JSON Web Token. + * bus?: scalar|Param|null, // Name of the Messenger bus where the handler for this hub must be registered. Default to the default bus if Messenger is enabled. + * }>, + * default_hub?: scalar|Param|null, + * default_cookie_lifetime?: int|Param, // Default lifetime of the cookie containing the JWT, in seconds. Defaults to the value of "framework.session.cookie_lifetime". // Default: null + * enable_profiler?: bool|Param, // Deprecated: The child node "enable_profiler" at path "mercure.enable_profiler" is deprecated. // Enable Symfony Web Profiler integration. + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1487,6 +1540,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * twig_extra?: TwigExtraConfig, * security?: SecurityConfig, * monolog?: MonologConfig, + * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * knp_paginator?: KnpPaginatorConfig, + * mercure?: MercureConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1503,6 +1559,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * security?: SecurityConfig, * monolog?: MonologConfig, * maker?: MakerConfig, + * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * knp_paginator?: KnpPaginatorConfig, + * mercure?: MercureConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1517,6 +1576,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * twig_extra?: TwigExtraConfig, * security?: SecurityConfig, * monolog?: MonologConfig, + * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * knp_paginator?: KnpPaginatorConfig, + * mercure?: MercureConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1532,6 +1594,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * twig_extra?: TwigExtraConfig, * security?: SecurityConfig, * monolog?: MonologConfig, + * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * knp_paginator?: KnpPaginatorConfig, + * mercure?: MercureConfig, * }, * ...=$PHP_VERSION" runtime/frankenphp-symfony + composer config --json extra.symfony.docker 'true' + + cp -Rp . .. + cd - + rm -Rf tmp/ + fi + + if grep -q ^DATABASE_URL= .env; then + echo 'Waiting for database to be ready...' + ATTEMPTS_LEFT_TO_REACH_DATABASE=60 + until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do + if [ $? -eq 255 ]; then + # If the Doctrine command exits with 255, an unrecoverable error occurred + ATTEMPTS_LEFT_TO_REACH_DATABASE=0 + break + fi + sleep 1 + ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) + echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left." + done + + if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then + echo 'The database is not up or not reachable:' + echo "$DATABASE_ERROR" + exit 1 + else + echo 'The database is now ready and reachable' + fi + + if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then + php bin/console doctrine:migrations:migrate --no-interaction --all-or-nothing + fi + fi + + setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var + setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var +fi + +exec docker-php-entrypoint "$@" diff --git a/frankenphp/worker.Caddyfile b/frankenphp/worker.Caddyfile new file mode 100644 index 00000000..d384ae4c --- /dev/null +++ b/frankenphp/worker.Caddyfile @@ -0,0 +1,4 @@ +worker { + file ./public/index.php + env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime +} diff --git a/migrations/Version20260625123836.php b/migrations/Version20260625123836.php new file mode 100644 index 00000000..5bdbdbff --- /dev/null +++ b/migrations/Version20260625123836.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE "user" (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, avatar VARCHAR(255) DEFAULT NULL, name_first VARCHAR(255) NOT NULL, name_last VARCHAR(255) NOT NULL, last_login TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D649E7927C74 ON "user" (email)'); + $this->addSql('CREATE TABLE messenger_messages (id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE "user"'); + $this->addSql('DROP TABLE messenger_messages'); + } +} diff --git a/migrations/Version20260626091910.php b/migrations/Version20260626091910.php new file mode 100644 index 00000000..1a73396c --- /dev/null +++ b/migrations/Version20260626091910.php @@ -0,0 +1,65 @@ +addSql('CREATE TABLE account (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) DEFAULT NULL, imap_host VARCHAR(255) NOT NULL, imap_port INT NOT NULL, imap_encryption VARCHAR(20) NOT NULL, username VARCHAR(255) NOT NULL, password TEXT DEFAULT NULL, smtp_host VARCHAR(255) DEFAULT NULL, smtp_port INT DEFAULT NULL, smtp_encryption VARCHAR(20) DEFAULT NULL, auth_type VARCHAR(20) NOT NULL, oauth_provider VARCHAR(255) DEFAULT NULL, oauth_access_token TEXT DEFAULT NULL, oauth_refresh_token TEXT DEFAULT NULL, oauth_token_expiry TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, is_active BOOLEAN NOT NULL, last_synced_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, usr_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_7D3656A4C69D3FB ON account (usr_id)'); + $this->addSql('CREATE TABLE mailbox (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, full_path VARCHAR(500) NOT NULL, delimiter VARCHAR(5) DEFAULT NULL, special_use VARCHAR(50) DEFAULT NULL, uid_validity INT DEFAULT NULL, last_seen_uid INT DEFAULT NULL, total_messages INT DEFAULT NULL, unread_messages INT DEFAULT NULL, is_sync_enabled BOOLEAN NOT NULL, synced_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, account_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_A69FE20B9B6B5FBA ON mailbox (account_id)'); + $this->addSql('CREATE TABLE message (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, imap_uid INT NOT NULL, message_id TEXT DEFAULT NULL, subject TEXT DEFAULT NULL, from_address VARCHAR(320) DEFAULT NULL, from_name TEXT DEFAULT NULL, to_addresses JSON DEFAULT NULL, cc_addresses JSON DEFAULT NULL, bcc_addresses JSON DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, sent_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, received_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, seen_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, starred_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, in_reply_to VARCHAR(320) DEFAULT NULL, "references" TEXT DEFAULT NULL, size INT DEFAULT NULL, has_attachments BOOLEAN NOT NULL, synced_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, flags JSON DEFAULT NULL, body_text TEXT DEFAULT NULL, body_html TEXT DEFAULT NULL, mailbox_id INT NOT NULL, thread_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_B6BD307F66EC35CC ON message (mailbox_id)'); + $this->addSql('CREATE INDEX IDX_B6BD307FE2904019 ON message (thread_id)'); + $this->addSql('CREATE TABLE message_part (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, content_type VARCHAR(255) NOT NULL, filename VARCHAR(255) DEFAULT NULL, content_id VARCHAR(255) DEFAULT NULL, disposition VARCHAR(255) NOT NULL, size INT DEFAULT NULL, storage_path TEXT DEFAULT NULL, is_inline BOOLEAN NOT NULL, message_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_E09AEB1F537A1329 ON message_part (message_id)'); + $this->addSql('CREATE TABLE message_thread (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, subject TEXT DEFAULT NULL, last_message_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, message_count INT NOT NULL, unread_count INT NOT NULL, tab VARCHAR(255) DEFAULT NULL, starred_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, account_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_607D18C9B6B5FBA ON message_thread (account_id)'); + $this->addSql('CREATE TABLE message_thread_mailbox (message_thread_id INT NOT NULL, mailbox_id INT NOT NULL, PRIMARY KEY (message_thread_id, mailbox_id))'); + $this->addSql('CREATE INDEX IDX_49B25E0A8829462F ON message_thread_mailbox (message_thread_id)'); + $this->addSql('CREATE INDEX IDX_49B25E0A66EC35CC ON message_thread_mailbox (mailbox_id)'); + $this->addSql('ALTER TABLE account ADD CONSTRAINT FK_7D3656A4C69D3FB FOREIGN KEY (usr_id) REFERENCES "user" (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE mailbox ADD CONSTRAINT FK_A69FE20B9B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307F66EC35CC FOREIGN KEY (mailbox_id) REFERENCES mailbox (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE message ADD CONSTRAINT FK_B6BD307FE2904019 FOREIGN KEY (thread_id) REFERENCES message_thread (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE message_part ADD CONSTRAINT FK_E09AEB1F537A1329 FOREIGN KEY (message_id) REFERENCES message (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE message_thread ADD CONSTRAINT FK_607D18C9B6B5FBA FOREIGN KEY (account_id) REFERENCES account (id) NOT DEFERRABLE'); + $this->addSql('ALTER TABLE message_thread_mailbox ADD CONSTRAINT FK_49B25E0A8829462F FOREIGN KEY (message_thread_id) REFERENCES message_thread (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE message_thread_mailbox ADD CONSTRAINT FK_49B25E0A66EC35CC FOREIGN KEY (mailbox_id) REFERENCES mailbox (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE account DROP CONSTRAINT FK_7D3656A4C69D3FB'); + $this->addSql('ALTER TABLE mailbox DROP CONSTRAINT FK_A69FE20B9B6B5FBA'); + $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307F66EC35CC'); + $this->addSql('ALTER TABLE message DROP CONSTRAINT FK_B6BD307FE2904019'); + $this->addSql('ALTER TABLE message_part DROP CONSTRAINT FK_E09AEB1F537A1329'); + $this->addSql('ALTER TABLE message_thread DROP CONSTRAINT FK_607D18C9B6B5FBA'); + $this->addSql('ALTER TABLE message_thread_mailbox DROP CONSTRAINT FK_49B25E0A8829462F'); + $this->addSql('ALTER TABLE message_thread_mailbox DROP CONSTRAINT FK_49B25E0A66EC35CC'); + $this->addSql('DROP TABLE account'); + $this->addSql('DROP TABLE mailbox'); + $this->addSql('DROP TABLE message'); + $this->addSql('DROP TABLE message_part'); + $this->addSql('DROP TABLE message_thread'); + $this->addSql('DROP TABLE message_thread_mailbox'); + } +} diff --git a/migrations/Version20260629130603.php b/migrations/Version20260629130603.php new file mode 100644 index 00000000..4645c5e8 --- /dev/null +++ b/migrations/Version20260629130603.php @@ -0,0 +1,35 @@ +addSql("ALTER TABLE message ALTER COLUMN in_reply_to DROP DEFAULT"); + $this->addSql("ALTER TABLE message ALTER COLUMN in_reply_to TYPE json USING in_reply_to::json"); + $this->addSql('ALTER TABLE message ALTER COLUMN "references" DROP DEFAULT'); + $this->addSql('ALTER TABLE message ALTER COLUMN "references" TYPE json USING "references"::json'); + } + + public function down(Schema $schema): void + { + $this->addSql("ALTER TABLE message ALTER COLUMN in_reply_to TYPE text USING in_reply_to::text"); + $this->addSql("ALTER TABLE message ALTER COLUMN in_reply_to SET DEFAULT ''"); + $this->addSql('ALTER TABLE message ALTER COLUMN "references" TYPE text USING "references"::text'); + $this->addSql('ALTER TABLE message ALTER COLUMN "references" SET DEFAULT \'\''); + } +} diff --git a/migrations/Version20260629131149.php b/migrations/Version20260629131149.php new file mode 100644 index 00000000..c17c6320 --- /dev/null +++ b/migrations/Version20260629131149.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE message RENAME COLUMN "references" TO thread_references'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE message RENAME COLUMN thread_references TO "references"'); + } +} diff --git a/migrations/Version20260629131312.php b/migrations/Version20260629131312.php new file mode 100644 index 00000000..86a2e12b --- /dev/null +++ b/migrations/Version20260629131312.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE message ALTER thread_id DROP NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE message ALTER thread_id SET NOT NULL'); + } +} diff --git a/migrations/Version20260630134115.php b/migrations/Version20260630134115.php new file mode 100644 index 00000000..e2061b10 --- /dev/null +++ b/migrations/Version20260630134115.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE message_thread ADD threading_method VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE message_thread ADD normalized_subject VARCHAR(255) NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE message_thread DROP threading_method'); + $this->addSql('ALTER TABLE message_thread DROP normalized_subject'); + } +} diff --git a/migrations/Version20260630151944.php b/migrations/Version20260630151944.php new file mode 100644 index 00000000..7d31a4da --- /dev/null +++ b/migrations/Version20260630151944.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE message_thread ADD attachment_count INT NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE message_thread DROP attachment_count'); + } +} diff --git a/migrations/Version20260701094216.php b/migrations/Version20260701094216.php new file mode 100644 index 00000000..4a91ca92 --- /dev/null +++ b/migrations/Version20260701094216.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE mailbox ADD is_idle_enabled BOOLEAN NOT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE mailbox DROP is_idle_enabled'); + } +} diff --git a/src/Command/ImapIdleCommand.php b/src/Command/ImapIdleCommand.php new file mode 100644 index 00000000..a2c8c5d9 --- /dev/null +++ b/src/Command/ImapIdleCommand.php @@ -0,0 +1,181 @@ +warning('pcntl extension not available — signal handling disabled'); + return; + } + + pcntl_async_signals(true); + + $handler = function (int $signal) use ($io): void { + $io->text(sprintf('[%s] Received signal %d, stopping after current IDLE.', date('H:i:s'), $signal)); + $this->shouldStop = true; + }; + + pcntl_signal(SIGTERM, $handler); + pcntl_signal(SIGINT, $handler); + } + + protected function configure(): void + { + $this + ->addArgument('mailbox-id', InputArgument::REQUIRED, 'ID of the mailbox to IDLE on'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $mailboxId = (int) $input->getArgument('mailbox-id'); + $mailbox = $this->mailboxRepository->find($mailboxId); + + if ($mailbox === null) { + $io->error(sprintf('Mailbox %d not found.', $mailboxId)); + return Command::FAILURE; + } + + if (!$mailbox->isIdleEnabled() || !$mailbox->isSyncEnabled()) { + $io->error('Mailbox is not enabled for IDLE.'); + return Command::FAILURE; + } + + $this->registerSignalHandlers($io); + + $io->info(sprintf( + 'Starting IDLE on mailbox "%s" for account "%s"', + $mailbox->getName(), + $mailbox->getAccount()->getLabel(), + )); + + $retries = 0; + + while (!$this->shouldStop) { + try { + $this->idle($mailboxId, $io); + $retries = 0; + } catch (\Throwable $e) { + if ($this->shouldStop) { + break; + } + + $retries++; + $io->error(sprintf( + 'IDLE connection failed (attempt %d/%d): %s', + $retries, + self::MAX_RETRIES, + $e->getMessage(), + )); + + if ($retries >= self::MAX_RETRIES) { + $io->error('Max retries reached. Giving up.'); + return Command::FAILURE; + } + + $delay = self::RECONNECT_DELAY * $retries; + $io->note(sprintf('Reconnecting in %d seconds...', $delay)); + sleep($delay); + } + } + + $io->text(sprintf('[%s] Stopped cleanly.', date('H:i:s'))); + + return Command::SUCCESS; + } + + private function idle(int $mailboxId, SymfonyStyle $io): void + { + $mailbox = $this->mailboxRepository->find($mailboxId); + $account = $mailbox->getAccount(); + $client = ImapConnectionFactory::connect($account); + $folder = $client->getFolder($mailbox->getName()); + + if ($folder === null) { + $client->disconnect(); + throw new \RuntimeException(sprintf('Folder "%s" not found.', $mailbox->getName())); + } + + $connection = $client->getConnection(); + $connection->selectFolder($folder->path); + $connection->idle(); + + $io->text(sprintf('[%s] IDLE connection established.', date('H:i:s'))); + + $startTime = time(); + + while (true) { + if ($this->shouldStop) { + $io->text(sprintf('[%s] Shutdown requested — closing IDLE cleanly.', date('H:i:s'))); + $connection->done(); + $client->disconnect(); + return; + } + + if (time() - $startTime >= self::IDLE_TIMEOUT) { + $io->text(sprintf('[%s] IDLE timeout — reconnecting.', date('H:i:s'))); + $connection->done(); + $client->disconnect(); + return; + } + + try { + $line = $connection->nextLine(new Response(0, false)); + } catch (\Throwable $e) { + if ($this->shouldStop) { + $connection->done(); + $client->disconnect(); + return; + } + + if (str_contains($e->getMessage(), 'empty response')) { + // Stream read timed out — normal, just keep looping + continue; + } + throw $e; + } + + if (str_contains($line, 'EXISTS')) { + $io->text(sprintf('[%s] Notification received — dispatching sync.', date('H:i:s'))); + try { + $envelope = $this->bus->dispatch(new SyncMailboxMessage($mailboxId)); + $io->text(sprintf('[%s] Dispatch returned envelope.', date('H:i:s'))); + } catch (\Throwable $e) { + $io->error(sprintf('[%s] Dispatch failed: %s', date('H:i:s'), $e->getMessage())); + } + } + } + } +} diff --git a/src/Command/ImapSuperviseCommand.php b/src/Command/ImapSuperviseCommand.php new file mode 100644 index 00000000..4116ce8d --- /dev/null +++ b/src/Command/ImapSuperviseCommand.php @@ -0,0 +1,265 @@ + */ + private array $processes = []; + + /** @var array mailboxId => current backoff seconds */ + private array $backoff = []; + + /** @var array mailboxId => microtime the process was (re)started */ + private array $startedAt = []; + + /** @var array mailboxId => microtime we're allowed to restart at, after a failure */ + private array $restartNotBefore = []; + + private bool $shouldStop = false; + + public function __construct( + private readonly MailboxRepository $mailboxRepository, + private readonly LoggerInterface $logger, + #[Autowire('%kernel.project_dir%')] private readonly string $projectDir, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption( + 'interval', + null, + InputOption::VALUE_REQUIRED, + 'Seconds between reconciliation polls of the mailbox table', + (string) self::DEFAULT_POLL_INTERVAL, + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $intervalOption = $input->getOption('interval'); + $pollInterval = (int) $intervalOption; + + if ($pollInterval < 1) { + $pollInterval = self::DEFAULT_POLL_INTERVAL; + } + + $this->registerSignalHandlers($io); + + $io->title('IMAP supervisor starting'); + $io->text('Poll interval: ' . $pollInterval . 's'); + + $this->reconcile($io); + + $lastPollAt = microtime(true); + + while ($this->shouldStop === false) { + if (function_exists('pcntl_signal_dispatch')) { + pcntl_signal_dispatch(); + } + + if ($this->shouldStop === true) { + break; + } + + $this->checkChildren($io); + + $now = microtime(true); + $elapsedSincePoll = $now - $lastPollAt; + + if ($elapsedSincePoll >= $pollInterval) { + $this->reconcile($io); + $lastPollAt = microtime(true); + } + + usleep(500000); + } + + $this->shutdownAllChildren($io); + + $io->success('IMAP supervisor stopped cleanly'); + + return Command::SUCCESS; + } + + private function registerSignalHandlers(SymfonyStyle $io): void + { + if (function_exists('pcntl_async_signals') === false) { + $io->warning('pcntl extension not available — signal handling disabled'); + + return; + } + + pcntl_async_signals(true); + + $handler = function (int $signal) use ($io): void { + $io->text('Received signal ' . $signal . ', shutting down…'); + $this->shouldStop = true; + }; + + pcntl_signal(SIGTERM, $handler); + pcntl_signal(SIGINT, $handler); + } + + private function reconcile(SymfonyStyle $io): void + { + $mailboxes = $this->mailboxRepository->findIdleEnabledAndSyncEnabled(); + + $activeMailboxIds = []; + + foreach ($mailboxes as $mailbox) { + $mailboxId = $mailbox->getId(); + $activeMailboxIds[] = $mailboxId; + + if (array_key_exists($mailboxId, $this->processes) === false) { + $this->startChild($io, $mailboxId); + } + } + + foreach (array_keys($this->processes) as $trackedMailboxId) { + if (in_array($trackedMailboxId, $activeMailboxIds, true) === false) { + $io->text('Mailbox ' . $trackedMailboxId . ' no longer IDLE-enabled, stopping process'); + $this->stopChild($trackedMailboxId); + } + } + } + + private function checkChildren(SymfonyStyle $io): void + { + foreach ($this->processes as $mailboxId => $process) { + if ($process->isRunning() === true) { + $uptime = microtime(true) - $this->startedAt[$mailboxId]; + + if ($uptime >= self::BACKOFF_RESET_AFTER_UPTIME_SECONDS) { + $this->backoff[$mailboxId] = self::MIN_BACKOFF_SECONDS; + } + + continue; + } + + $exitCode = $process->getExitCode(); + $errorOutput = $process->getErrorOutput(); + + $io->warning( + 'IDLE process for mailbox ' . $mailboxId + . ' died (exit code ' . $exitCode . ')', + ); + + if ($errorOutput !== '') { + $this->logger->error('IDLE process stderr for mailbox ' . $mailboxId, [ + 'mailboxId' => $mailboxId, + 'exitCode' => $exitCode, + 'stderr' => $errorOutput, + ]); + } + + unset($this->processes[$mailboxId]); + + $notBefore = $this->restartNotBefore[$mailboxId] ?? 0.0; + + if (microtime(true) < $notBefore) { + continue; + } + + $this->startChild($io, $mailboxId); + } + } + + private function startChild(SymfonyStyle $io, int $mailboxId): void + { + $currentBackoff = $this->backoff[$mailboxId] ?? self::MIN_BACKOFF_SECONDS; + + $process = new Process( + ['php', 'bin/console', 'app:imap:idle', (string) $mailboxId], + $this->projectDir, + ); + $process->setTimeout(null); + + $process->start(function (string $type, string $buffer) use ($mailboxId): void { + $prefix = sprintf('[mailbox %d] ', $mailboxId); + $lines = explode("\n", rtrim($buffer, "\n")); + + foreach ($lines as $line) { + if ($line === '') { + continue; + } + + if ($type === Process::ERR) { + fwrite(STDERR, $prefix . $line . PHP_EOL); + } else { + fwrite(STDOUT, $prefix . $line . PHP_EOL); + } + } + }); + + $this->processes[$mailboxId] = $process; + $this->startedAt[$mailboxId] = microtime(true); + + $nextBackoff = $currentBackoff * 2; + + if ($nextBackoff > self::MAX_BACKOFF_SECONDS) { + $nextBackoff = self::MAX_BACKOFF_SECONDS; + } + + $this->backoff[$mailboxId] = $nextBackoff; + $this->restartNotBefore[$mailboxId] = microtime(true) + $currentBackoff; + + $io->text('Started IDLE process for mailbox ' . $mailboxId . ' (pid ' . $process->getPid() . ')'); + } + + private function stopChild(int $mailboxId): void + { + if (array_key_exists($mailboxId, $this->processes) === false) { + return; + } + + $process = $this->processes[$mailboxId]; + $process->signal(SIGTERM); + $process->wait(); + + unset($this->processes[$mailboxId]); + unset($this->startedAt[$mailboxId]); + unset($this->backoff[$mailboxId]); + unset($this->restartNotBefore[$mailboxId]); + } + + private function shutdownAllChildren(SymfonyStyle $io): void + { + foreach ($this->processes as $mailboxId => $process) { + $io->text('Stopping mailbox ' . $mailboxId . ' (pid ' . $process->getPid() . ')'); + $process->signal(SIGTERM); + } + + foreach ($this->processes as $mailboxId => $process) { + $process->wait(); + } + + $this->processes = []; + } +} diff --git a/src/Command/ResetImapDataCommand.php b/src/Command/ResetImapDataCommand.php new file mode 100644 index 00000000..c605ddd6 --- /dev/null +++ b/src/Command/ResetImapDataCommand.php @@ -0,0 +1,66 @@ +warning('This will permanently delete synced data from the database.'); + + $deleteMailboxes = $io->confirm( + 'Also delete mailbox structure (folders)? If no, only messages and threads will be cleared.', + false, + ); + + $io->section('Truncating tables...'); + + $connection = $this->em->getConnection(); + + // Disable FK checks while truncating + $connection->executeStatement('SET session_replication_role = replica'); + + $connection->executeStatement('TRUNCATE TABLE message_part CASCADE'); + $io->text('✓ message_part'); + + $connection->executeStatement('TRUNCATE TABLE message CASCADE'); + $io->text('✓ message'); + + $connection->executeStatement('TRUNCATE TABLE message_thread_mailbox CASCADE'); + $io->text('✓ message_thread_mailbox'); + + $connection->executeStatement('TRUNCATE TABLE message_thread CASCADE'); + $io->text('✓ message_thread'); + + if ($deleteMailboxes) { + $connection->executeStatement('TRUNCATE TABLE mailbox CASCADE'); + $io->text('✓ mailbox'); + } + + // Re-enable FK checks + $connection->executeStatement('SET session_replication_role = DEFAULT'); + + $io->success('Done. Run app:imap:sync-mailboxes and app:imap:sync-messages to re-sync.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/SetupCommand.php b/src/Command/SetupCommand.php new file mode 100644 index 00000000..16a451e2 --- /dev/null +++ b/src/Command/SetupCommand.php @@ -0,0 +1,73 @@ +entityManager = $documentManager; + $this->passwordHasher = $passwordHasher; + + $this->userRepository = $userRepository; + + parent::__construct(self::NAME); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $symfonyStyle = new SymfonyStyle($input, $output); + $symfonyStyle->title('Setup'); + + if (0 !== ($result = $this->firstUser($symfonyStyle))) { + return $result; + } + + return 0; + } + + private function firstUser(SymfonyStyle $symfonyStyle): int + { + $count = $this->userRepository->countUndeleted(); + if (0 !== $count) { + $symfonyStyle->success(sprintf('%d users already existing', $count)); + + return 0; + } + + $eMail = $symfonyStyle->ask('What\'s the first users email address?', $_ENV['APP_DEV_USER_EMAIL']); + $password = $symfonyStyle->ask('What\'s the first users password?', $_ENV['APP_DEV_USER_PASSWORD']); + + $user = new User(); + $user + ->setEmail($eMail) + ->setPassword($this->passwordHasher->hashPassword($user, $password)) + ->setNameFirst('Admin') + ->setNameLast('Istrator') + ->setRoles([User::ROLE_ADMIN]); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return 0; + } + + +} \ No newline at end of file diff --git a/src/Command/SyncMailboxesCommand.php b/src/Command/SyncMailboxesCommand.php new file mode 100644 index 00000000..947aabe5 --- /dev/null +++ b/src/Command/SyncMailboxesCommand.php @@ -0,0 +1,61 @@ +addArgument('account-id', InputArgument::OPTIONAL, 'Sync a single account by ID, omit for all'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $accountId = $input->getArgument('account-id'); + + if ($accountId !== null) { + $accounts = [$this->accountRepository->find($accountId)]; + } else { + $accounts = $this->accountRepository->findBy(['isActive' => true]); + } + + foreach ($accounts as $account) { + $io->section('Syncing mailboxes for: ' . $account->getLabel()); + + try { + $result = $this->mailboxSyncer->syncForAccount($account); + $io->success(sprintf( + 'Created %d, updated %d, deleted %d mailboxes', + $result['created'], + $result['updated'], + $result['deleted'], + )); + } catch (\Exception $e) { + $io->error($e->getMessage()); + } + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/SyncMessagesCommand.php b/src/Command/SyncMessagesCommand.php new file mode 100644 index 00000000..e64b1b0c --- /dev/null +++ b/src/Command/SyncMessagesCommand.php @@ -0,0 +1,88 @@ +addArgument('account-id', InputArgument::OPTIONAL, 'Sync a single account by ID, omit for all'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $accountId = $input->getArgument('account-id'); + + if ($accountId !== null) { + $accounts = [$this->accountRepository->find($accountId)]; + } else { + $accounts = $this->accountRepository->findBy(['isActive' => true]); + } + + foreach ($accounts as $account) { + $io->section('Syncing messages for: ' . $account->getLabel()); + + try { + $connection = ImapConnectionFactory::connect($account); + } catch (\Throwable $e) { + $io->error('Failed to connect: ' . $e->getMessage()); + continue; + } + + $mailboxes = $this->mailboxRepository->findBy([ + 'account' => $account, + 'isSyncEnabled' => true, + ]); + + if (count($mailboxes) === 0) { + $io->warning('No sync-enabled mailboxes found for this account.'); + continue; + } + + foreach ($mailboxes as $mailbox) { + $io->text('→ ' . $mailbox->getFullPath()); + + try { + $this->messageSyncer->syncMailbox($mailbox, $connection); + $io->text(' ✓ done'); + } catch (\Throwable $e) { + $io->error(sprintf( + 'Failed to sync mailbox %s: %s', + $mailbox->getFullPath(), + $e->getMessage(), + )); + } + } + + $connection->disconnect(); + } + + $io->success('Sync complete.'); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php new file mode 100644 index 00000000..3f79e237 --- /dev/null +++ b/src/Controller/AccountController.php @@ -0,0 +1,51 @@ +createForm(AccountType::class, $account, ['action' => $this->generateUrl('app_account_new')]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $account + ->setAuthType('password') + ->setIsActive(true) + ->setUsr($this->getUser()); + $this->entityManager->persist($account); + $this->entityManager->flush(); + + if ($request->getPreferredFormat() === TurboBundle::STREAM_FORMAT) { + $request->setRequestFormat(TurboBundle::STREAM_FORMAT); + return $this->render('account/_form_success.stream.html.twig', [ + 'account' => $account, + ]); + } + } + return $this->render('account/_form_modal.html.twig', [ + 'title' => 'New Account', + 'form' => $form, + ]); + } + +} diff --git a/src/Controller/Admin/User/DefaultController.php b/src/Controller/Admin/User/DefaultController.php new file mode 100644 index 00000000..4851279c --- /dev/null +++ b/src/Controller/Admin/User/DefaultController.php @@ -0,0 +1,112 @@ +entityManager = $entityManager; + $this->formFactory = $formFactory; + $this->paginator = $paginator; + $this->userRepository = $userRepository; + $this->passwordHasher = $passwordHasher; + } + + + #[ArrayShape(['pagination' => "\Knp\Component\Pager\Pagination\PaginationInterface", 'table_search' => "mixed"])] + #[Route('', 'index')] + #[Template('admin/user/default_index.html.twig')] + public function index(Request $request): array + { + $searchString = $request->query->get('search', null); + $queryBuilder = $this->userRepository->createSearchQueryBuilder($searchString); + + $pagination = $this->paginator->paginate($queryBuilder, $request->query->getInt('page', 1), $request->query->getInt('limit', 100)); + + return [ + 'pagination' => $pagination, + 'table_search' => $searchString, + ]; + } + + #[Route('/create', 'form_create', condition: "request.isXmlHttpRequest()")] + #[Route('/{id}/edit', 'form_edit', condition: "request.isXmlHttpRequest()")] + #[Template('admin/user/default_form.html.twig')] + #[ParamConverter('user', isOptional: true)] + public function form(Request $request, ?User $user = null): array|Response + { + if (null === $user) { + $user = new User(); + } + + $form = $this->formFactory->create(UserFormType::class, $user); + $form->handleRequest($request); + + if (true === $form->isSubmitted() && true === $form->isValid()) { + $user->setUpdatedAt(new DateTimeImmutable()); + + if (null !== $user->getPlainPassword()) { + $user->setPassword($this->passwordHasher->hashPassword($user, $user->getPlainPassword())); + } + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return new Response(); + } + + return [ + 'user' => $user, + 'form' => $form->createView(), + ]; + } + + #[Route('/{id}/delete', 'delete', condition: "request.isXmlHttpRequest()")] + #[ParamConverter('user')] + public function delete(User $user): Response + { + $user + ->setDeletedAt(new DateTimeImmutable()) + ->setUpdatedAt(new DateTimeImmutable()); + $deletedString = sprintf('DELETED-%s', $user->getId()); + + $user + ->setPassword(null) + ->setName($deletedString) + ->setEmail($deletedString); + + $this->entityManager->flush(); + + return new Response(); + } +} diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php new file mode 100644 index 00000000..06c3775a --- /dev/null +++ b/src/Controller/DefaultController.php @@ -0,0 +1,38 @@ +profiler = $profiler; + } + + #[Route("", 'app_default_index')] + #[Template('default/index.html.twig')] + public function index(): Response + { + return new RedirectResponse( '/mail/inbox'); + } + + /** + * @Route("/runtime.js") + * @Template("default_runtime.js.twig") + */ + public function runtime(): array + { + $this->profiler?->disable(); + + return []; + } +} diff --git a/src/Controller/MailController.php b/src/Controller/MailController.php new file mode 100644 index 00000000..b78dfd2f --- /dev/null +++ b/src/Controller/MailController.php @@ -0,0 +1,188 @@ +getUser(); + $tab = MessageTab::Primary; + $page = max(1, (int) $request->query->get('page', 1)); + + // Handle tab switching + $tabParam = $request->query->get('tab'); + if ($tabParam !== null) { + $tab = MessageTab::from($tabParam); + } + + $mailboxIds = $this->mailboxRepository->getIdsOfActiveInboxMailboxesForUser($user); + $threads = $this->threadRepository->findForUnifiedInbox($user, $tab, $page); + $total = $this->threadRepository->countForUnifiedInbox($user, $tab); + + return $this->render('mail/inbox.html.twig', [ + 'threads' => $threads, + 'mailboxIds' => $mailboxIds, + 'tab' => $tab, + 'page' => $page, + 'total' => $total, + 'per_page' => 50, + ]); + } + + #[Route('/starred', name: 'starred')] + public function starred():Response + { + return $this->render('mail/starred.html.twig', []); + } + + #[Route('/sent', name: 'sent')] + public function sent():Response + { + return $this->render('mail/sent.html.twig', []); + } + + #[Route('/drafts', name: 'drafts')] + public function drafts():Response + { + return $this->render('mail/drafts.html.twig', []); + } + + #[Route('/trash', name: 'trash')] + public function trash():Response + { + return $this->render('mail/trash.html.twig', []); + } + + #[Route('/account/{account}/folders', name: 'account_folders')] + public function accountFolders(Account $account): Response + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + // Security check — account must belong to current user + if ($account->getUsr() !== $this->getUser()) { + throw $this->createAccessDeniedException(); + } + + $mailboxes = $this->mailboxRepository->findBy( + ['account' => $account], + ['name' => 'ASC'], + ); + + return $this->render('mail/_account_folders.html.twig', [ + 'account' => $account, + 'mailboxes' => $mailboxes, + ]); + } + + #[Route('/account/{account}/{mailbox}', name: 'account_mailbox')] + public function mailbox(Account $account, string $mailbox): Response + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + if ($account->getUsr() !== $this->getUser()) { + throw $this->createAccessDeniedException(); + } + + $mailboxEntity = $this->mailboxRepository->findOneBy(['account' => $account, 'name' => $mailbox]); + + if (null === $mailboxEntity) { + throw $this->createNotFoundException(); + } + + $messages = $this->messageRepository->findByMailboxOrderedByReceivedDate($mailboxEntity); + + return $this->render('mail/mailbox.html.twig', [ + 'account' => $account, + 'mailbox' => $mailboxEntity, + 'messages' => $messages, + ]); + } + + #[Route('/message/{id}', name: 'message')] + public function message(Message $message, Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + $mailbox = $message->getMailbox(); + $account = $mailbox->getAccount(); + + if ($account->getUsr() !== $this->getUser()) { + throw $this->createAccessDeniedException(); + } + + if ($request->headers->get('X-Requested-With') === 'fetch') { + // JS fetch — return just the message content fragment + return $this->render('mail/_message_content.html.twig', [ + 'message' => $message, + ]); + } + + // Direct load / refresh / bookmark — render the full mailbox page + // with the reading pane already open + return $this->render('mail/mailbox.html.twig', [ + 'mailbox' => $mailbox, + 'account' => $account, + 'selectedMessage' => $message, + ]); + } + + + #[Route('/thread/{id}', name: 'thread')] + public function thread(MessageThread $thread, Request $request): Response + { + $this->denyAccessUnlessGranted('ROLE_USER'); + + $account = $thread->getAccount(); + if ($account->getUsr() !== $this->getUser()) { + throw $this->createAccessDeniedException(); + } + + // Get the latest message in the thread to display in the reading pane + $messages = $thread->getMessages(); + $latestMessage = null; + if (!$messages->isEmpty()) { + // Assuming messages are ordered by receivedAt; + // if not, we'd typically sort them or pick the one with max date. + // For this implementation, we'll grab the last one in the collection. + $latestMessage = $messages->last(); + } + + // A thread can belong to multiple mailboxes; we'll pick the first one for the context. + $mailbox = $thread->getMailboxes()->first(); + + if ($request->headers->get('X-Requested-With') === 'fetch') { + return $this->render('mail/_thread_content.html.twig', [ + 'thread' => $thread, + ]); + } + + return $this->render('mail/thread.html.twig', [ + 'thread' => $thread, + 'account' => $account, + ]); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 00000000..39ac3049 --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,60 @@ +authorizationChecker = $authorizationChecker; + $this->router = $router; + } + + + #[Route('/login', 'app_login')] + public function login(AuthenticationUtils $authenticationUtils): Response + { + if (true === $this->authorizationChecker->isGranted(User::ROLE_USER)) { + return new RedirectResponse($this->router->generate('app_default_index')); + } + + // get the login error if there is one + $error = $authenticationUtils->getLastAuthenticationError(); + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + $errorMessage = null; + + if (null !== $error) { + if ($error instanceof BadCredentialsException) { + $errorMessage = 'login_bad_credentials'; + } else { + /* TODO: logging */ + $errorMessage = 'login_unknown_error'; + } + } + + + return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); + } + + #[Route('/logout', 'app_logout')] + public function logout() + { + throw new LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/src/Domain/Enum/MessageTab.php b/src/Domain/Enum/MessageTab.php new file mode 100644 index 00000000..7fd9eb9e --- /dev/null +++ b/src/Domain/Enum/MessageTab.php @@ -0,0 +1,13 @@ +projectDir, + $accountId, + $mailboxId, + $messageUid, + ); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + $safeName = $this->sanitizeFilename($filename); + $path = $directory . '/' . $safeName; + + // Avoid overwriting if the same filename appears twice in one message + if (file_exists($path)) { + $safeName = pathinfo($safeName, PATHINFO_FILENAME) + . '_' . uniqid() + . '.' . pathinfo($safeName, PATHINFO_EXTENSION); + $path = $directory . '/' . $safeName; + } + + file_put_contents($path, $content); + + // Return path relative to project root for storage in DB + return sprintf( + 'var/attachments/%d/%d/%d/%s', + $accountId, + $mailboxId, + $messageUid, + $safeName, + ); + } + + public function getAbsolutePath(string $relativePath): string + { + return $this->projectDir . '/' . $relativePath; + } + + private function sanitizeFilename(string $filename): string + { + // Decode encoded filenames (e.g. =?UTF-8?B?...?=) + $decoded = imap_utf8($filename); + + // Strip directory traversal and dangerous characters + $safe = basename($decoded); + $safe = preg_replace('/[^\w.\-]/', '_', $safe); + + if ($safe === '' || $safe === '.') { + $safe = 'attachment_' . uniqid(); + } + + return $safe; + } +} diff --git a/src/Domain/Helper/ImapConnectionFactory.php b/src/Domain/Helper/ImapConnectionFactory.php new file mode 100644 index 00000000..971f64ea --- /dev/null +++ b/src/Domain/Helper/ImapConnectionFactory.php @@ -0,0 +1,42 @@ +getImapEncryption()) { + 'ssl' => 'ssl', + 'tls' => 'tls', + 'starttls' => 'starttls', + default => false, + }; + + $client = new Client(Config::make([ + 'default' => 'default', + 'accounts' => [ + 'default' => [ + 'host' => $account->getImapHost(), + 'port' => $account->getImapPort(), + 'encryption' => $encryption, + 'validate_cert' => true, + 'username' => $account->getUsername(), + 'password' => $account->getPassword(), + 'protocol' => 'imap', + ], + ], + ])); + + + $client->connect(); + + return $client; + } +} diff --git a/src/Domain/Model/UserEntityModel.php b/src/Domain/Model/UserEntityModel.php new file mode 100644 index 00000000..060b7b45 --- /dev/null +++ b/src/Domain/Model/UserEntityModel.php @@ -0,0 +1,82 @@ +getRoles()) && true === in_array($role, self::ROLES)) { + $currentRoles = $this->getRoles(); + $currentRoles[] = $role; + + $this->setRoles($currentRoles); + } + + return $this; + } + + throw new LogicException(); + } + + public function removeRole(string $role): User + { + if ($this instanceof User) { + if (true === in_array($role, $this->getRoles()) && true === in_array($role, self::ROLES)) { + $currentRoles = $this->getRoles(); + $roleKey = array_search($role, $currentRoles); + unset($currentRoles[$roleKey]); + $this->setRoles($currentRoles); + } + + return $this; + } + + throw new LogicException(); + } + + public function getLocale(): string + { + return 'de'; + } + + public function getPlainPassword(): ?string + { + return $this->plainPassword; + } + + public function getName(): string + { + if (true === $this instanceof User) { + return sprintf('%s %s', $this->getNameFirst(), $this->getNameLast()); + } + + throw new LogicException('Not a User'); + } + + public function setPlainPassword(?string $plainPassword): self + { + $this->plainPassword = $plainPassword; + + return $this; + } + + public function isDeleted(): ?bool + { + if (true === $this instanceof User) { + return $this->getDeletedAt() !== null; + } + + throw new LogicException('Not a User'); + } +} \ No newline at end of file diff --git a/src/Entity/Account.php b/src/Entity/Account.php new file mode 100644 index 00000000..83d0243b --- /dev/null +++ b/src/Entity/Account.php @@ -0,0 +1,391 @@ + + */ + #[ORM\OneToMany(targetEntity: Mailbox::class, mappedBy: 'account', orphanRemoval: true)] + private Collection $mailboxes; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: MessageThread::class, mappedBy: 'account', orphanRemoval: true)] + private Collection $messageThreads; + + public function __construct() + { + $this->mailboxes = new ArrayCollection(); + $this->messageThreads = new ArrayCollection(); + $this->setCreatedAt(new \DateTimeImmutable()); + $this->setUpdatedAt(new \DateTimeImmutable()); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUsr(): ?User + { + return $this->usr; + } + + public function setUsr(?User $usr): static + { + $this->usr = $usr; + + return $this; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(?string $label): static + { + $this->label = $label; + + return $this; + } + + public function getImapHost(): ?string + { + return $this->imapHost; + } + + public function setImapHost(string $imapHost): static + { + $this->imapHost = $imapHost; + + return $this; + } + + public function getImapPort(): ?int + { + return $this->imapPort; + } + + public function setImapPort(int $imapPort): static + { + $this->imapPort = $imapPort; + + return $this; + } + + public function getImapEncryption(): ?string + { + return $this->imapEncryption; + } + + public function setImapEncryption(string $imapEncryption): static + { + $this->imapEncryption = $imapEncryption; + + return $this; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(string $username): static + { + $this->username = $username; + + return $this; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(?string $password): static + { + $this->password = $password; + + return $this; + } + + public function getSmtpHost(): ?string + { + return $this->smtpHost; + } + + public function setSmtpHost(?string $smtpHost): static + { + $this->smtpHost = $smtpHost; + + return $this; + } + + public function getSmtpPort(): ?int + { + return $this->smtpPort; + } + + public function setSmtpPort(?int $smtpPort): static + { + $this->smtpPort = $smtpPort; + + return $this; + } + + public function getSmtpEncryption(): ?string + { + return $this->smtpEncryption; + } + + public function setSmtpEncryption(?string $smtpEncryption): static + { + $this->smtpEncryption = $smtpEncryption; + + return $this; + } + + public function getAuthType(): ?string + { + return $this->authType; + } + + public function setAuthType(string $authType): static + { + $this->authType = $authType; + + return $this; + } + + public function getOauthProvider(): ?string + { + return $this->oauthProvider; + } + + public function setOauthProvider(?string $oauthProvider): static + { + $this->oauthProvider = $oauthProvider; + + return $this; + } + + public function getOauthAccessToken(): ?string + { + return $this->oauthAccessToken; + } + + public function setOauthAccessToken(?string $oauthAccessToken): static + { + $this->oauthAccessToken = $oauthAccessToken; + + return $this; + } + + public function getOauthRefreshToken(): ?string + { + return $this->oauthRefreshToken; + } + + public function setOauthRefreshToken(?string $oauthRefreshToken): static + { + $this->oauthRefreshToken = $oauthRefreshToken; + + return $this; + } + + public function getOauthTokenExpiry(): ?\DateTimeImmutable + { + return $this->oauthTokenExpiry; + } + + public function setOauthTokenExpiry(?\DateTimeImmutable $oauthTokenExpiry): static + { + $this->oauthTokenExpiry = $oauthTokenExpiry; + + return $this; + } + + public function isActive(): ?bool + { + return $this->isActive; + } + + public function setIsActive(bool $isActive): static + { + $this->isActive = $isActive; + + return $this; + } + + public function getLastSyncedAt(): ?\DateTimeImmutable + { + return $this->lastSyncedAt; + } + + public function setLastSyncedAt(?\DateTimeImmutable $lastSyncedAt): static + { + $this->lastSyncedAt = $lastSyncedAt; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** + * @return Collection + */ + public function getMailboxes(): Collection + { + return $this->mailboxes; + } + + public function addMailbox(Mailbox $mailbox): static + { + if (!$this->mailboxes->contains($mailbox)) { + $this->mailboxes->add($mailbox); + $mailbox->setAccount($this); + } + + return $this; + } + + public function removeMailbox(Mailbox $mailbox): static + { + if ($this->mailboxes->removeElement($mailbox)) { + // set the owning side to null (unless already changed) + if ($mailbox->getAccount() === $this) { + $mailbox->setAccount(null); + } + } + + return $this; + } + + /** + * @return Collection + */ + public function getMessageThreads(): Collection + { + return $this->messageThreads; + } + + public function addMessageThread(MessageThread $messageThread): static + { + if (!$this->messageThreads->contains($messageThread)) { + $this->messageThreads->add($messageThread); + $messageThread->setAccount($this); + } + + return $this; + } + + public function removeMessageThread(MessageThread $messageThread): static + { + if ($this->messageThreads->removeElement($messageThread)) { + // set the owning side to null (unless already changed) + if ($messageThread->getAccount() === $this) { + $messageThread->setAccount(null); + } + } + + return $this; + } +} diff --git a/src/Entity/Mailbox.php b/src/Entity/Mailbox.php new file mode 100644 index 00000000..e9bb2b76 --- /dev/null +++ b/src/Entity/Mailbox.php @@ -0,0 +1,309 @@ + + */ + #[ORM\OneToMany(targetEntity: Message::class, mappedBy: 'mailbox', orphanRemoval: true)] + private Collection $messages; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: MessageThread::class, mappedBy: 'mailboxes')] + private Collection $messageThreads; + + public function __construct() + { + $this->messages = new ArrayCollection(); + $this->messageThreads = new ArrayCollection(); + $this->setCreatedAt(new \DateTimeImmutable()); + $this->setUpdatedAt(new \DateTimeImmutable()); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccount(): ?Account + { + return $this->account; + } + + public function setAccount(?Account $account): static + { + $this->account = $account; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getFullPath(): ?string + { + return $this->fullPath; + } + + public function setFullPath(string $fullPath): static + { + $this->fullPath = $fullPath; + + return $this; + } + + public function getDelimiter(): ?string + { + return $this->delimiter; + } + + public function setDelimiter(?string $delimiter): static + { + $this->delimiter = $delimiter; + + return $this; + } + + public function getSpecialUse(): ?string + { + return $this->specialUse; + } + + public function setSpecialUse(?string $specialUse): static + { + $this->specialUse = $specialUse; + + return $this; + } + + public function getUidValidity(): ?int + { + return $this->uidValidity; + } + + public function setUidValidity(?int $uidValidity): static + { + $this->uidValidity = $uidValidity; + + return $this; + } + + public function getLastSeenUid(): ?int + { + return $this->lastSeenUid; + } + + public function setLastSeenUid(?int $lastSeenUid): static + { + $this->lastSeenUid = $lastSeenUid; + + return $this; + } + + public function getTotalMessages(): ?int + { + return $this->totalMessages; + } + + public function setTotalMessages(?int $totalMessages): static + { + $this->totalMessages = $totalMessages; + + return $this; + } + + public function getUnreadMessages(): ?int + { + return $this->unreadMessages; + } + + public function setUnreadMessages(?int $unreadMessages): static + { + $this->unreadMessages = $unreadMessages; + + return $this; + } + + public function isSyncEnabled(): ?bool + { + return $this->isSyncEnabled; + } + + public function setIsSyncEnabled(bool $isSyncEnabled): static + { + $this->isSyncEnabled = $isSyncEnabled; + + return $this; + } + + public function isIdleEnabled(): bool + { + return $this->isIdleEnabled; + } + + public function setIsIdleEnabled(bool $isIdleEnabled): Mailbox + { + $this->isIdleEnabled = $isIdleEnabled; + return $this; + } + + public function getSyncedAt(): ?\DateTimeImmutable + { + return $this->syncedAt; + } + + public function setSyncedAt(?\DateTimeImmutable $syncedAt): static + { + $this->syncedAt = $syncedAt; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** + * @return Collection + */ + public function getMessages(): Collection + { + return $this->messages; + } + + public function addMessage(Message $message): static + { + if (!$this->messages->contains($message)) { + $this->messages->add($message); + $message->setMailbox($this); + } + + return $this; + } + + public function removeMessage(Message $message): static + { + if ($this->messages->removeElement($message)) { + // set the owning side to null (unless already changed) + if ($message->getMailbox() === $this) { + $message->setMailbox(null); + } + } + + return $this; + } + + /** + * @return Collection + */ + public function getMessageThreads(): Collection + { + return $this->messageThreads; + } + + public function addMessageThread(MessageThread $messageThread): static + { + if (!$this->messageThreads->contains($messageThread)) { + $this->messageThreads->add($messageThread); + $messageThread->addMailbox($this); + } + + return $this; + } + + public function removeMessageThread(MessageThread $messageThread): static + { + if ($this->messageThreads->removeElement($messageThread)) { + $messageThread->removeMailbox($this); + } + + return $this; + } +} diff --git a/src/Entity/Message.php b/src/Entity/Message.php new file mode 100644 index 00000000..31aadc9a --- /dev/null +++ b/src/Entity/Message.php @@ -0,0 +1,429 @@ + + */ + #[ORM\OneToMany(targetEntity: MessagePart::class, mappedBy: 'message', orphanRemoval: true)] + private Collection $messageParts; + + #[ORM\ManyToOne(inversedBy: 'messages')] + #[ORM\JoinColumn(nullable: true)] + private ?MessageThread $thread = null; + + public function __construct() + { + $this->messageParts = new ArrayCollection(); + $this->setCreatedAt(new \DateTimeImmutable()); + $this->setUpdatedAt(new \DateTimeImmutable()); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getMailbox(): ?Mailbox + { + return $this->mailbox; + } + + public function setMailbox(?Mailbox $mailbox): static + { + $this->mailbox = $mailbox; + + return $this; + } + + public function getImapUid(): ?int + { + return $this->imapUid; + } + + public function setImapUid(int $imapUid): static + { + $this->imapUid = $imapUid; + + return $this; + } + + public function getMessageId(): ?string + { + return $this->messageId; + } + + public function setMessageId(?string $messageId): static + { + $this->messageId = $messageId; + + return $this; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): static + { + $this->subject = $subject; + + return $this; + } + + public function getFromAddress(): ?string + { + return $this->fromAddress; + } + + public function setFromAddress(?string $fromAddress): static + { + $this->fromAddress = $fromAddress; + + return $this; + } + + public function getFromName(): ?string + { + return $this->fromName; + } + + public function setFromName(?string $fromName): static + { + $this->fromName = $fromName; + + return $this; + } + + public function getToAddresses(): ?array + { + return $this->toAddresses; + } + + public function setToAddresses(?array $toAddresses): static + { + $this->toAddresses = $toAddresses; + + return $this; + } + + public function getCcAddresses(): ?array + { + return $this->ccAddresses; + } + + public function setCcAddresses(?array $ccAddresses): static + { + $this->ccAddresses = $ccAddresses; + + return $this; + } + + public function getBccAddresses(): ?array + { + return $this->bccAddresses; + } + + public function setBccAddresses(?array $bccAddresses): static + { + $this->bccAddresses = $bccAddresses; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } + + public function getSentAt(): ?\DateTimeImmutable + { + return $this->sentAt; + } + + public function setSentAt(?\DateTimeImmutable $sentAt): static + { + $this->sentAt = $sentAt; + + return $this; + } + + public function getReceivedAt(): ?\DateTimeImmutable + { + return $this->receivedAt; + } + + public function setReceivedAt(?\DateTimeImmutable $receivedAt): static + { + $this->receivedAt = $receivedAt; + + return $this; + } + + public function getSeenAt(): ?\DateTimeImmutable + { + return $this->seenAt; + } + + public function setSeenAt(?\DateTimeImmutable $seenAt): static + { + $this->seenAt = $seenAt; + + return $this; + } + + public function getStarredAt(): ?\DateTimeImmutable + { + return $this->starredAt; + } + + public function setStarredAt(?\DateTimeImmutable $starredAt): static + { + $this->starredAt = $starredAt; + + return $this; + } + + public function getInReplyTo(): ?array + { + return $this->inReplyTo; + } + + public function setInReplyTo(?array $inReplyTo): static + { + $this->inReplyTo = $inReplyTo; + + return $this; + } + + public function getReferences(): ?array + { + return $this->references; + } + + public function setReferences(?array $references): static + { + $this->references = $references; + + return $this; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function setSize(?int $size): static + { + $this->size = $size; + + return $this; + } + + public function hasAttachments(): ?bool + { + return $this->hasAttachments; + } + + public function setHasAttachments(bool $hasAttachments): static + { + $this->hasAttachments = $hasAttachments; + + return $this; + } + + public function getSyncedAt(): ?\DateTimeImmutable + { + return $this->syncedAt; + } + + public function setSyncedAt(?\DateTimeImmutable $syncedAt): static + { + $this->syncedAt = $syncedAt; + + return $this; + } + + public function getFlags(): ?array + { + return $this->flags; + } + + public function setFlags(?array $flags): static + { + $this->flags = $flags; + + return $this; + } + + public function getBodyText(): ?string + { + return $this->bodyText; + } + + public function setBodyText(?string $bodyText): static + { + $this->bodyText = $bodyText; + + return $this; + } + + public function getBodyHtml(): ?string + { + return $this->bodyHtml; + } + + public function setBodyHtml(?string $bodyHtml): static + { + $this->bodyHtml = $bodyHtml; + + return $this; + } + + /** + * @return Collection + */ + public function getMessageParts(): Collection + { + return $this->messageParts; + } + + public function addMessagePart(MessagePart $messagePart): static + { + if (!$this->messageParts->contains($messagePart)) { + $this->messageParts->add($messagePart); + $messagePart->setMessage($this); + } + + return $this; + } + + public function removeMessagePart(MessagePart $messagePart): static + { + if ($this->messageParts->removeElement($messagePart)) { + // set the owning side to null (unless already changed) + if ($messagePart->getMessage() === $this) { + $messagePart->setMessage(null); + } + } + + return $this; + } + + public function getThread(): ?MessageThread + { + return $this->thread; + } + + public function setThread(?MessageThread $thread): static + { + $this->thread = $thread; + + return $this; + } +} diff --git a/src/Entity/MessagePart.php b/src/Entity/MessagePart.php new file mode 100644 index 00000000..548eea24 --- /dev/null +++ b/src/Entity/MessagePart.php @@ -0,0 +1,143 @@ +id; + } + + public function getMessage(): ?Message + { + return $this->message; + } + + public function setMessage(?Message $message): static + { + $this->message = $message; + + return $this; + } + + public function getContentType(): ?string + { + return $this->contentType; + } + + public function setContentType(string $contentType): static + { + $this->contentType = $contentType; + + return $this; + } + + public function getFilename(): ?string + { + return $this->filename; + } + + public function setFilename(?string $filename): static + { + $this->filename = $filename; + + return $this; + } + + public function getContentId(): ?string + { + return $this->contentId; + } + + public function setContentId(?string $contentId): static + { + $this->contentId = $contentId; + + return $this; + } + + public function getDisposition(): ?string + { + return $this->disposition; + } + + public function setDisposition(string $disposition): static + { + $this->disposition = $disposition; + + return $this; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function setSize(?int $size): static + { + $this->size = $size; + + return $this; + } + + public function getStoragePath(): ?string + { + return $this->storagePath; + } + + public function setStoragePath(?string $storagePath): static + { + $this->storagePath = $storagePath; + + return $this; + } + + public function isInline(): ?bool + { + return $this->isInline; + } + + public function setIsInline(bool $isInline): static + { + $this->isInline = $isInline; + + return $this; + } +} diff --git a/src/Entity/MessageThread.php b/src/Entity/MessageThread.php new file mode 100644 index 00000000..1f4f0f6f --- /dev/null +++ b/src/Entity/MessageThread.php @@ -0,0 +1,250 @@ + + */ + #[ORM\OneToMany(targetEntity: Message::class, mappedBy: 'thread', orphanRemoval: true)] + private Collection $messages; + + /** + * @var Collection + */ + #[ORM\ManyToMany(targetEntity: Mailbox::class, inversedBy: 'messageThreads')] + #[ORM\JoinTable(name: 'message_thread_mailbox')] + private Collection $mailboxes; + + #[ORM\Column(enumType: ThreadingMethod::class)] + private ?ThreadingMethod $threadingMethod = null; + + #[ORM\Column(length: 255)] + private ?string $normalizedSubject = null; + + public function __construct() + { + $this->messages = new ArrayCollection(); + $this->mailboxes = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccount(): ?Account + { + return $this->account; + } + + public function setAccount(?Account $account): static + { + $this->account = $account; + + return $this; + } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): static + { + $this->subject = $subject; + + return $this; + } + + public function getLastMessageAt(): ?\DateTimeImmutable + { + return $this->lastMessageAt; + } + + public function setLastMessageAt(?\DateTimeImmutable $lastMessageAt): static + { + $this->lastMessageAt = $lastMessageAt; + + return $this; + } + + public function getMessageCount(): ?int + { + return $this->messageCount; + } + + public function setMessageCount(int $messageCount): static + { + $this->messageCount = $messageCount; + + return $this; + } + + public function getUnreadCount(): ?int + { + return $this->unreadCount; + } + + public function setUnreadCount(int $unreadCount): static + { + $this->unreadCount = $unreadCount; + + return $this; + } + + public function getTab(): ?MessageTab + { + return $this->tab; + } + + public function setTab(?MessageTab $tab): static + { + $this->tab = $tab; + + return $this; + } + + public function getStarredAt(): ?\DateTimeImmutable + { + return $this->starredAt; + } + + public function setStarredAt(?\DateTimeImmutable $starredAt): static + { + $this->starredAt = $starredAt; + + return $this; + } + + public function getAttachmentCount(): int + { + return $this->attachmentCount; + } + + public function setAttachmentCount(int $attachmentCount): static + { + $this->attachmentCount = $attachmentCount; + + return $this; + } + + /** + * @return Collection + */ + public function getMessages(): Collection + { + return $this->messages; + } + + public function addMessage(Message $message): static + { + if (!$this->messages->contains($message)) { + $this->messages->add($message); + $message->setThread($this); + } + + return $this; + } + + public function removeMessage(Message $message): static + { + if ($this->messages->removeElement($message)) { + // set the owning side to null (unless already changed) + if ($message->getThread() === $this) { + $message->setThread(null); + } + } + + return $this; + } + + /** + * @return Collection + */ + public function getMailboxes(): Collection + { + return $this->mailboxes; + } + + public function addMailbox(Mailbox $mailbox): static + { + if (!$this->mailboxes->contains($mailbox)) { + $this->mailboxes->add($mailbox); + } + + return $this; + } + + public function removeMailbox(Mailbox $mailbox): static + { + $this->mailboxes->removeElement($mailbox); + + return $this; + } + + public function getThreadingMethod(): ?ThreadingMethod + { + return $this->threadingMethod; + } + + public function setThreadingMethod(ThreadingMethod $threadingMethod): static + { + $this->threadingMethod = $threadingMethod; + + return $this; + } + + public function getNormalizedSubject(): ?string + { + return $this->normalizedSubject; + } + + public function setNormalizedSubject(string $normalizedSubject): static + { + $this->normalizedSubject = $normalizedSubject; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 00000000..daded238 --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,259 @@ + + */ + #[ORM\OneToMany(targetEntity: Account::class, mappedBy: 'usr', orphanRemoval: true)] + private Collection $accounts; + + public function __construct() + { + $this->setCreatedAt(new \DateTimeImmutable()); + $this->setUpdatedAt(new \DateTimeImmutable()); + $this->setDeletedAt(null); + $this->accounts = new ArrayCollection(); + } + + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): string + { + return $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + public function getAvatar(): ?string + { + return $this->avatar; + } + + public function setAvatar(string $avatar): self + { + $this->avatar = $avatar; + + return $this; + } + + public function getNameFirst(): ?string + { + return $this->nameFirst; + } + + public function setNameFirst(string $nameFirst): self + { + $this->nameFirst = $nameFirst; + + return $this; + } + + public function getNameLast(): ?string + { + return $this->nameLast; + } + + public function setNameLast(string $nameLast): self + { + $this->nameLast = $nameLast; + + return $this; + } + + public function getLastLogin(): ?\DateTimeInterface + { + return $this->lastLogin; + } + + public function setLastLogin(?\DateTimeInterface $lastLogin): self + { + $this->lastLogin = $lastLogin; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(\DateTimeImmutable $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } + + public function getDeletedAt(): ?\DateTimeImmutable + { + return $this->deletedAt; + } + + public function setDeletedAt(?\DateTimeImmutable $deletedAt): self + { + $this->deletedAt = $deletedAt; + + return $this; + } + + /** + * @return Collection + */ + public function getAccounts(): Collection + { + return $this->accounts; + } + + public function addAccount(Account $account): static + { + if (!$this->accounts->contains($account)) { + $this->accounts->add($account); + $account->setUsr($this); + } + + return $this; + } + + public function removeAccount(Account $account): static + { + if ($this->accounts->removeElement($account)) { + // set the owning side to null (unless already changed) + if ($account->getUsr() === $this) { + $account->setUsr(null); + } + } + + return $this; + } +} diff --git a/src/Enum/AuthType.php b/src/Enum/AuthType.php new file mode 100644 index 00000000..fd8b0499 --- /dev/null +++ b/src/Enum/AuthType.php @@ -0,0 +1,14 @@ +add('label', TextType::class, [ + 'attr' => [ + 'placeholder' => 'Work, Personal…', + 'class' => 'form-input', + ], + 'label' => 'Account label', + ]) + ->add('username', TextType::class, [ + 'attr' => [ + 'placeholder' => 'you@example.com', + 'class' => 'form-input', + 'autocomplete' => 'email', + ], + 'label' => 'Email address', + 'constraints' => [new NotBlank()], + ]) + ->add('password', PasswordType::class, [ + 'attr' => [ + 'placeholder' => '••••••••', + 'class' => 'form-input', + 'autocomplete' => 'new-password', + ], + 'label' => 'Password', + 'always_empty' => true, + ]) + ->add('imapHost', TextType::class, [ + 'attr' => [ + 'placeholder' => 'imap.example.com', + 'class' => 'form-input', + ], + 'label' => 'IMAP host', + 'constraints' => [new NotBlank()], + ]) + ->add('imapPort', IntegerType::class, [ + 'attr' => [ + 'placeholder' => '993', + 'class' => 'form-input', + ], + 'label' => 'IMAP port', + 'data' => 993, + 'constraints' => [new NotBlank(), new Range(min: 1, max: 65535)], + ]) + ->add('imapEncryption', ChoiceType::class, [ + 'choices' => [ + 'SSL / TLS' => 'ssl', + 'STARTTLS' => 'starttls', + 'None' => 'none', + ], + 'attr' => ['class' => 'form-select'], + 'label' => 'Encryption', + ]) + ->add('smtpHost', TextType::class, [ + 'required' => false, + 'attr' => [ + 'placeholder' => 'smtp.example.com', + 'class' => 'form-input', + ], + 'label' => 'SMTP host', + ]) + ->add('smtpPort', IntegerType::class, [ + 'required' => false, + 'attr' => [ + 'placeholder' => '587', + 'class' => 'form-input', + ], + 'label' => 'SMTP port', + 'data' => 587, + 'constraints' => [new Range(min: 1, max: 65535)], + ]) + ->add('smtpEncryption', ChoiceType::class, [ + 'required' => false, + 'choices' => [ + 'STARTTLS' => 'starttls', + 'SSL / TLS' => 'ssl', + 'None' => 'none', + ], + 'attr' => ['class' => 'form-select'], + 'label' => 'SMTP encryption', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Account::class, + ]); + } +} diff --git a/src/Kernel.php b/src/Kernel.php index a00950f2..aec827cb 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -2,6 +2,8 @@ namespace App; +use App\Doctrine\Type\EncryptedStringType; +use App\Encryption\Encryptor; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\HttpKernel\Kernel as BaseKernel; @@ -9,6 +11,7 @@ class Kernel extends BaseKernel { use MicroKernelTrait; + /** * @return list An array of allowed values for APP_ENV */ diff --git a/src/Message/SyncMailboxMessage.php b/src/Message/SyncMailboxMessage.php new file mode 100644 index 00000000..15658a77 --- /dev/null +++ b/src/Message/SyncMailboxMessage.php @@ -0,0 +1,10 @@ +mailboxRepository->find($message->mailboxId); + + if ($mailbox === null) { + $this->logger->info('Mailbox not found', ['mailboxId' => $message->mailboxId]); + return; + } + + if (false === $mailbox->isSyncEnabled()) { + $this->logger->info('Mailbox sync disabled', ['mailboxId' => $message->mailboxId]); + return; + } + + $account = $mailbox->getAccount(); + $client = ImapConnectionFactory::connect($account); + $this->messageSyncer->syncMailbox($mailbox, $client); + $client->disconnect(); + + $this->logger->info('Publishing Mercure update', ['mailboxId' => $message->mailboxId]); + + $userId = $account->getUsr()->getId(); + + $this->hub->publish(new Update( + topics: [ + sprintf('mail/user/%d', $userId), + sprintf('mail/mailbox/%d', $mailbox->getId()), + ], + data: json_encode([ + 'type' => 'mailbox.synced', + 'mailboxId' => $mailbox->getId(), + 'accountId' => $account->getId(), + 'specialUse'=> $mailbox->getSpecialUse(), + ]), + )); + + $this->logger->info('Mercure update published', ['mailboxId' => $message->mailboxId]); + } +} diff --git a/src/Repository/AccountRepository.php b/src/Repository/AccountRepository.php new file mode 100644 index 00000000..1d120b33 --- /dev/null +++ b/src/Repository/AccountRepository.php @@ -0,0 +1,43 @@ + + */ +class AccountRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Account::class); + } + + // /** + // * @return Account[] Returns an array of Account objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('a.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?Account + // { + // return $this->createQueryBuilder('a') + // ->andWhere('a.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/MailboxRepository.php b/src/Repository/MailboxRepository.php new file mode 100644 index 00000000..b2ef7ea6 --- /dev/null +++ b/src/Repository/MailboxRepository.php @@ -0,0 +1,65 @@ + + */ +class MailboxRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Mailbox::class); + } + + public function findIndexedByFullPath(Account $account): array + { + $mailboxes = $this->findBy(['account' => $account]); + $indexed = []; + + foreach ($mailboxes as $mailbox) { + $indexed[$mailbox->getFullPath()] = $mailbox; + } + + return $indexed; + } + + public function findIdleEnabledAndSyncEnabled(): array + { + $queryBuilder = $this->createQueryBuilder('mailbox'); + + $queryBuilder + ->innerJoin('mailbox.account', 'account') + ->addSelect('account') + ->andWhere('mailbox.isIdleEnabled = :isIdleEnabled') + ->andWhere('mailbox.isSyncEnabled = :isSyncEnabled') + ->andWhere('account.isActive = :isActive') + ->setParameter('isIdleEnabled', true) + ->setParameter('isSyncEnabled', true) + ->setParameter('isActive', true); + + return $queryBuilder->getQuery()->getResult(); + } + + public function getIdsOfActiveInboxMailboxesForUser(UserInterface $user): array + { + return $this->createQueryBuilder('mailbox') + ->select('mailbox.id') + ->leftJoin('mailbox.account', 'account') + ->where('account.isActive = :isActive') + ->andWhere('account.usr = :usr') + ->andWhere('mailbox.specialUse = :inbox') + ->setParameter('isActive', true) + ->setParameter('usr', $user) + ->setParameter('inbox', '\\Inbox') + ->getQuery() + ->getSingleColumnResult(); + } +} diff --git a/src/Repository/MessagePartRepository.php b/src/Repository/MessagePartRepository.php new file mode 100644 index 00000000..0604a22a --- /dev/null +++ b/src/Repository/MessagePartRepository.php @@ -0,0 +1,43 @@ + + */ +class MessagePartRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, MessagePart::class); + } + + // /** + // * @return MessagePart[] Returns an array of MessagePart objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('m') + // ->andWhere('m.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('m.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?MessagePart + // { + // return $this->createQueryBuilder('m') + // ->andWhere('m.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/src/Repository/MessageRepository.php b/src/Repository/MessageRepository.php new file mode 100644 index 00000000..ba95b457 --- /dev/null +++ b/src/Repository/MessageRepository.php @@ -0,0 +1,73 @@ +createQueryBuilder('m') + ->select('m.imapUid') + ->where('m.mailbox = :mailbox') + ->andWhere('m.imapUid IS NOT NULL') + ->setParameter('mailbox', $mailbox) + ->getQuery() + ->getSingleColumnResult(); + } + + public function findByMailboxOrderedByReceivedDate(Mailbox $mailbox): array + { + return $this->createQueryBuilder('m') + ->where('m.mailbox = :mailbox') + ->setParameter('mailbox', $mailbox) + ->orderBy('m.receivedAt', 'DESC') + ->getQuery() + ->getResult(); + } + + public function findOneByMessageIdsForAccount(array $messageIds, Account $account): ?Message + { + if (count($messageIds) === 0) { + return null; + } + + return $this->createQueryBuilder('message') + ->innerJoin('message.mailbox', 'mailbox') + ->where('mailbox.account = :account') + ->andWhere('message.messageId IN (:messageIds)') + ->setParameter('account', $account) + ->setParameter('messageIds', $messageIds) + ->orderBy('message.receivedAt', 'ASC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function existsWithFromAddressInThread(string $fromAddress, MessageThread $thread): bool + { + $result = $this->createQueryBuilder('m') + ->select('1') + ->where('m.thread = :thread') + ->andWhere('LOWER(m.fromAddress) = :fromAddress') + ->setParameter('thread', $thread) + ->setParameter('fromAddress', mb_strtolower($fromAddress)) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + + return $result !== null; + } +} diff --git a/src/Repository/MessageThreadRepository.php b/src/Repository/MessageThreadRepository.php new file mode 100644 index 00000000..167e2f9a --- /dev/null +++ b/src/Repository/MessageThreadRepository.php @@ -0,0 +1,72 @@ +createQueryBuilder('thread') + ->where('thread.account = :account') + ->andWhere('thread.normalizedSubject = :normalizedSubject') + ->setParameter('account', $account) + ->setParameter('normalizedSubject', $normalizedSubject) + ->orderBy('thread.lastMessageAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function findForUnifiedInbox(UserInterface $user, MessageTab $tab, int $page = 1, int $perPage = 50): array + { + $offset = ($page - 1) * $perPage; + + return $this->createQueryBuilder('t') + ->join('t.account', 'a') + ->join('t.mailboxes', 'm') + ->where('a.usr = :user') + ->andWhere('a.isActive = true') + ->andWhere('m.specialUse = :inbox') + ->andWhere('t.tab = :tab') + ->setParameter('user', $user) + ->setParameter('inbox', '\\Inbox') + ->setParameter('tab', $tab) + ->orderBy('t.lastMessageAt', 'DESC') + ->setFirstResult($offset) + ->setMaxResults($perPage) + ->distinct() + ->getQuery() + ->getResult(); + } + + public function countForUnifiedInbox(UserInterface $user, MessageTab $tab): int + { + return $this->createQueryBuilder('t') + ->select('COUNT(t.id)') + ->join('t.account', 'a') + ->join('t.mailboxes', 'm') + ->where('a.usr = :user') + ->andWhere('a.isActive = true') + ->andWhere('m.specialUse = :inbox') + ->andWhere('t.tab = :tab') + ->setParameter('user', $user) + ->setParameter('inbox', '\\Inbox') + ->setParameter('tab', $tab) + ->distinct() + ->getQuery() + ->getSingleScalarResult(); + } +} diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 00000000..878f5f11 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,115 @@ + + * + * @method User|null find($id, $lockMode = null, $lockVersion = null) + * @method User|null findOneBy(array $criteria, array $orderBy = null) + * @method User[] findAll() + * @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + public function add(User $entity, bool $flush = false): void + { + $this->getEntityManager()->persist($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(User $entity, bool $flush = false): void + { + $this->getEntityManager()->remove($entity); + + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function countUndeleted(): int + { + return $this->createQueryBuilder('user') + ->select('count(user.id)') + ->where('user.deletedAt IS NULL') + ->getQuery() + ->getSingleScalarResult(); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', \get_class($user))); + } + + $user->setPassword($newHashedPassword); + + $this->add($user, true); + } + + public function createUndeletedQueryBuilder(): QueryBuilder + { + $qb = $this->createQueryBuilder('user'); + $qb->expr()->isNull('user.deletedAt'); + + return $qb; + } + + /** + * @throws NonUniqueResultException + */ + public function findOneByEmailExcept(string $email, User $user): ?User + { + $qb = $this->createUndeletedQueryBuilder(); + $qb + ->andWhere('user.email = :email') + ->setParameter('email', $email); + + if (null !== $user->getId()) { + $qb + ->andWhere('user.id != :id') + ->setParameter('id', $user->getId()); + } + + $result = $qb->getQuery()->getOneOrNullResult(); + + return $result; + } + + public function createSearchQueryBuilder(?string $search): QueryBuilder + { + $qb = $this->createUndeletedQueryBuilder(); + + if (null !== $search && 2 < strlen($search)) { + $qb->expr()->orX( + $qb->expr()->like('user.email', ':search'), + $qb->expr()->like('user.nameFirst', ':search'), + $qb->expr()->like('user.nameLast', ':search'), + ); + + $qb->setParameter('search', sprintf('%%s%', $search)); + } + + return $qb; + } +} diff --git a/src/Security/LoginFormAuthenticator.php b/src/Security/LoginFormAuthenticator.php new file mode 100644 index 00000000..e9002562 --- /dev/null +++ b/src/Security/LoginFormAuthenticator.php @@ -0,0 +1,155 @@ +documentManager = $documentManager; + + $this->csrfTokenManager = $csrfTokenManager; + $this->urlGenerator = $urlGenerator; + $this->userPasswordHasher = $userPasswordHasher; + + $this->userRepository = $userRepository; + } + + public function authenticate(Request $request): Passport + { + $email = $request->request->get('email', ''); + + $request->getSession()->set(SecurityRequestAttributes::LAST_USERNAME, $email); + + return new Passport( + new UserBadge($email), + new PasswordCredentials($request->request->get('password', '')), + [ + new CsrfTokenBadge('authenticate', $request->request->get('_csrf_token')), + ] + ); + } + + public function checkCredentials($credentials, User $user): bool + { + return $this->userPasswordHasher->isPasswordValid($user, $credentials['password']); + } + + #[ArrayShape(['email' => "mixed", 'password' => "mixed", 'csrf_token' => "mixed"])] public function getCredentials(Request $request): array + { + $credentials = [ + 'email' => $request->request->get('email'), + 'password' => $request->request->get('password'), + 'csrf_token' => $request->request->get('_csrf_token'), + ]; + + $request->getSession()->set( + SecurityRequestAttributes::LAST_USERNAME, + $credentials['email'] + ); + + return $credentials; + } + + protected function getLoginUrl(Request $request): string + { + return $this->urlGenerator->generate(self::LOGIN_ROUTE); + } + + public function getPassword($credentials): ?string + { + return $credentials['password']; + } + + public function getUser(array $credentials): ?User + { + $token = new CsrfToken('authenticate', $credentials['csrf_token']); + if (false === $this->csrfTokenManager->isTokenValid($token)) { + throw new InvalidCsrfTokenException(); + } + + $user = $this->userRepository->findOneByEmail($credentials['email']); + + if (null === $user) { + // fail authentication with a custom error + throw new CustomUserMessageAuthenticationException('Email could not be found.'); + } + + return $user; + } + + /** + * @param Request $request + * @param TokenInterface $token + * @param string $firewallName + * + * @return RedirectResponse + * + */ + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): RedirectResponse + { + $user = $token->getUser(); + if ($user instanceof User) { + $user->setLastLogin(new DateTime()); + $this->documentManager->flush(); + } + + if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) { + return new RedirectResponse($targetPath); + } + + return new RedirectResponse($this->urlGenerator->generate('app_default_index')); + } + + public function supports(Request $request): bool + { + return (self::LOGIN_ROUTE === $request->attributes->get('_route') && $request->isMethod(Request::METHOD_POST)); + } + + +} diff --git a/src/Security/UserProvider.php b/src/Security/UserProvider.php new file mode 100644 index 00000000..a7afb777 --- /dev/null +++ b/src/Security/UserProvider.php @@ -0,0 +1,91 @@ +userRepository = $userRepository; + } + + /** + * Symfony calls this method if you use features like switch_user + * or remember_me. + * + * If you're not using these features, you do not need to implement + * this method. + * + * @throws Throwable + */ + public function loadUserByIdentifier($identifier): UserInterface + { + $user = $this->userRepository->findOneByEmail($identifier); + if (null === $user) { + throw new UserNotFoundException(); + } + + return $user; + } + + /** + * Refreshes the user after being reloaded from the session. + * + * When a user is logged in, at the beginning of each request, the + * User object is loaded from the session and then this method is + * called. Your job is to make sure the user's data is still fresh by, + * for example, re-querying for fresh User data. + * + * If your firewall is "stateless: true" (for a pure API), this + * method is not called. + * + * @param UserInterface $user + * + * @return UserInterface + * @throws Exception + */ + public function refreshUser(UserInterface $user): UserInterface + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Invalid user class "%s".', get_class($user))); + } + + $user = $this->userRepository->find($user->getId()); + if (null === $user) { + throw new UserNotFoundException(); + } + + return $user; + } + + /** + * Tells Symfony to use this provider for this User class. + */ + public function supportsClass($class): bool + { + return User::class === $class || is_subclass_of($class, User::class); + } + + /** + * Upgrades the hashed password of a user, typically for using a better hash algorithm. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if ($user instanceof User) { + $user->setPassword($newHashedPassword); + } + } +} diff --git a/src/Service/Imap/MailboxSyncer.php b/src/Service/Imap/MailboxSyncer.php new file mode 100644 index 00000000..d5f36edb --- /dev/null +++ b/src/Service/Imap/MailboxSyncer.php @@ -0,0 +1,113 @@ +getFolders(false); + + $existing = $this->mailboxRepository->findIndexedByFullPath($account); + + $result = [ + 'created' => 0, + 'updated' => 0, + 'deleted' => 0, + ]; + $seen = []; + + foreach ($serverFolders as $folder) { + $fullPath = $folder->path; + $seen[] = $fullPath; + + if (isset($existing[$fullPath])) { + $this->update($existing[$fullPath], $folder); + $result['updated']++; + } else { + $this->create($account, $folder); + $result['created']++; + } + } + + foreach ($existing as $fullPath => $mailbox) { + if (!in_array($fullPath, $seen, true)) { + $this->em->remove($mailbox); + $result['deleted']++; + } + } + + $this->em->flush(); + $client->disconnect(); + + return $result; + } + + private function create(Account $account, Folder $folder): void + { + $mailbox = new Mailbox(); + $mailbox->setAccount($account); + $mailbox->setIsSyncEnabled(true); + $mailbox->setIsIdleEnabled(in_array( + $this->detectSpecialUse($folder), + ['\\Inbox', '\\Junk'], + true, + )); + $this->hydrate($mailbox, $folder); + $this->em->persist($mailbox); + } + + private function update(Mailbox $mailbox, Folder $folder): void + { + $this->hydrate($mailbox, $folder); + } + + private function hydrate(Mailbox $mailbox, Folder $folder): void + { + $mailbox->setName($folder->name); + $mailbox->setFullPath($folder->path); + $mailbox->setDelimiter($folder->delimiter); + $mailbox->setSpecialUse($this->detectSpecialUse($folder)); + } + + private function detectSpecialUse(Folder $folder): ?string + { + $name = strtolower($folder->name); + + $nameMap = [ + 'inbox' => '\\Inbox', + 'sent' => '\\Sent', + 'sent messages' => '\\Sent', + 'drafts' => '\\Drafts', + 'draft' => '\\Drafts', + 'trash' => '\\Trash', + 'deleted' => '\\Trash', + 'deleted messages' => '\\Trash', + 'junk' => '\\Junk', + 'spam' => '\\Junk', + 'spambucket' => '\\Junk', + 'archive' => '\\Archive', + ]; + + if (array_key_exists($name, $nameMap)) { + return $nameMap[$name]; + } + + return null; + } +} diff --git a/src/Service/Imap/MessageSyncer.php b/src/Service/Imap/MessageSyncer.php new file mode 100644 index 00000000..b507d315 --- /dev/null +++ b/src/Service/Imap/MessageSyncer.php @@ -0,0 +1,196 @@ +getId(); + $accountId = $mailbox->getAccount()->getId(); + $lastSeenUid = $mailbox->getLastSeenUid() ?? 0; + $uidRange = ($lastSeenUid + 1) . ':*'; + + $this->logger->info('Syncing mailbox', [ + 'mailbox' => $mailbox->getFullPath(), + 'account' => $accountId, + ]); + + $folder = $client->getFolder($mailbox->getName()); + + if ($folder === null) { + $this->logger->error('Folder not found', ['mailbox' => $mailbox->getName()]); + return; + } + + $synced = 0; + + $folder->messages() + ->where('UID', $uidRange) + ->chunked(function ($batch) use ($mailboxId, $accountId, &$synced) { + $this->processBatch($batch, $mailboxId, $accountId); + $synced += count($batch); + $this->em->clear(); + $this->logger->info(sprintf('Synced %d messages so far', $synced)); + }, self::BATCH_SIZE); + + $mailbox = $this->mailboxRepository->find($mailboxId); + $mailbox->setSyncedAt(new \DateTimeImmutable()); + $this->em->flush(); + } + + private function processBatch($batch, int $mailboxId, int $accountId): void + { + $mailbox = $this->mailboxRepository->find($mailboxId); + $maxUid = 0; + + foreach ($batch as $imapMessage) { + try { + $this->persistMessage($imapMessage, $mailbox, $accountId); + + if ($imapMessage->getUid() > $maxUid) { + $maxUid = $imapMessage->getUid(); + } + } catch (\Throwable $e) { + $this->logger->error('Failed to sync message', [ + 'uid' => $imapMessage->getUid(), + 'error' => $e->getMessage(), + ]); + } + } + + // Update lastSeenUid so next sync only fetches newer messages + if ($maxUid > 0) { + $mailbox->setLastSeenUid($maxUid); + } + + $this->em->flush(); + } + + private function persistMessage(ImapMessage $imapMessage, Mailbox $mailbox, int $accountId): void + { + $message = new Message(); + $message->setMailbox($mailbox); + $message->setImapUid($imapMessage->getUid()); + $message->setMessageId((string) $imapMessage->getMessageId()); + $message->setSubject((string) $imapMessage->getSubject() ?: '(no subject)'); + + // From + $from = $imapMessage->getFrom()->first(); + if ($from !== null) { + $message->setFromAddress($from->mail ?? ''); + $message->setFromName($from->personal ?? ''); + } + + // Recipients + $message->setToAddresses($this->formatAddresses($imapMessage->getTo())); + $message->setCcAddresses($this->formatAddresses($imapMessage->getCc())); + $message->setBccAddresses($this->formatAddresses($imapMessage->getBcc())); + + // Dates + $date = $imapMessage->getDate()->toDate(); + $message->setSentAt(\DateTimeImmutable::createFromInterface($date)); + $message->setReceivedAt(\DateTimeImmutable::createFromInterface($date)); + + // Flags + $flags = $imapMessage->getFlags()->toArray(); + $flagNames = array_values($flags); + $message->setFlags($flagNames); + + if (in_array('Seen', $flagNames, true) || in_array('\\Seen', $flagNames, true)) { + $message->setSeenAt(new \DateTimeImmutable()); + } + + // Threading headers + $inReplyTo = $imapMessage->getInReplyTo(); + $references = $imapMessage->getReferences(); + + $message->setInReplyTo( + $inReplyTo->exist() ? explode(' ', (string) $inReplyTo) : [] + ); + $message->setReferences( + $references->exist() ? explode(' ', (string) $references) : [] + ); + + // Body + $message->setBodyText($imapMessage->getTextBody() ?? ''); + $message->setBodyHtml($imapMessage->getHTMLBody() ?? ''); + + // Attachments + $attachments = $imapMessage->getAttachments(); + $message->setHasAttachments($attachments->isNotEmpty()); + + $message->setSyncedAt(new \DateTimeImmutable()); + + $this->messageThreader->assignThread($message, $mailbox->getAccount(), $mailbox); + $this->em->persist($message); + + foreach ($attachments as $attachment) { + $this->persistAttachment($attachment, $message, $accountId); + } + } + + private function persistAttachment($attachment, Message $message, int $accountId): void + { + $filename = $attachment->getFilename() ?? ('attachment_' . uniqid()); + $content = $attachment->getContent(); + + $storagePath = $this->attachmentStorage->store( + $accountId, + $message->getMailbox()->getId(), + $message->getImapUid(), + $filename, + $content, + ); + + $part = new MessagePart(); + $part->setMessage($message); + $part->setContentType($attachment->getContentType() ?? 'application/octet-stream'); + $part->setFilename($filename); + $part->setDisposition($attachment->getDisposition() ?? 'attachment'); + $part->setSize(strlen($content)); + $part->setStoragePath($storagePath); + $part->setIsInline(($attachment->getDisposition() ?? '') === 'inline'); + + $this->em->persist($part); + } + + private function formatAddresses($attribute): array + { + if ($attribute === null) { + return []; + } + + $result = []; + + foreach ($attribute as $address) { + $result[] = [ + 'name' => $address->personal ?? '', + 'address' => $address->mail ?? '', + ]; + } + + return $result; + } +} diff --git a/src/Service/Imap/MessageThreader.php b/src/Service/Imap/MessageThreader.php new file mode 100644 index 00000000..90b3ce14 --- /dev/null +++ b/src/Service/Imap/MessageThreader.php @@ -0,0 +1,180 @@ +collectReferenceIds($message); + + if (count($referenceIds) > 0) { + $parentMessage = $this->messageRepository->findOneByMessageIdsForAccount($referenceIds, $account); + + if ($parentMessage !== null && $parentMessage->getThread() !== null) { + $thread = $parentMessage->getThread(); + $this->attachMessageToThread($message, $thread, $mailbox); + + return; + } + + // Headers are valid even though no parent has been synced yet — + // method is about what the message provides, not whether a match occurred. + $thread = $this->createThread($message, $account, ThreadingMethod::References); + $this->attachMessageToThread($message, $thread, $mailbox); + + return; + } + + // No usable references at all — fall back to subject + participant matching. + $normalizedSubject = $this->normalizeSubject($message->getSubject()); + + if ($normalizedSubject !== '') { + $candidateThread = $this->messageThreadRepository->findMatchingNormalizedSubjectThreadForAccount( + $normalizedSubject, + $account, + ); + + if ($candidateThread !== null && $this->participantsOverlap($message, $candidateThread)) { + $this->attachMessageToThread($message, $candidateThread, $mailbox); + + return; + } + } + + $thread = $this->createThread($message, $account, ThreadingMethod::SubjectFallback); + $this->attachMessageToThread($message, $thread, $mailbox); + } + + /** + * @return string[] message-ids this message references, most specific first + */ + private function collectReferenceIds(Message $message): array + { + $ids = []; + + $inReplyTo = $message->getInReplyTo(); + + if (is_array($inReplyTo)) { + foreach ($inReplyTo as $id) { + if (is_string($id) && $id !== '') { + $ids[] = $id; + } + } + } + + $references = $message->getReferences(); + + if (is_array($references)) { + foreach ($references as $id) { + if (is_string($id) && $id !== '') { + $ids[] = $id; + } + } + } + + return array_values(array_unique($ids)); + } + + private function normalizeSubject(?string $subject): string + { + if ($subject === null) { + return ''; + } + + $normalized = trim($subject); + + // Strip repeated Re:/Fwd:/Fw: prefixes, including localized/spaced variants + // like "RE :" or "Fwd:Fwd:". + $pattern = '/^\s*((re|fwd|fw)\s*:\s*)+/i'; + $normalized = preg_replace($pattern, '', $normalized); + + if ($normalized === null) { + $normalized = trim($subject); + } + + $normalized = mb_strtolower(trim($normalized)); + + return $normalized; + } + + private function participantsOverlap(Message $message, MessageThread $thread): bool + { + $fromAddress = $message->getFromAddress(); + + if ($fromAddress === null || $fromAddress === '') { + return false; + } + + return $this->messageRepository->existsWithFromAddressInThread($fromAddress, $thread); + } + + private function createThread(Message $message, Account $account, ThreadingMethod $threadingMethod): MessageThread + { + $thread = new MessageThread() + ->setAccount($account)->setSubject($message->getSubject())->setNormalizedSubject($this->normalizeSubject($message->getSubject())) + ->setThreadingMethod($threadingMethod) + ->setMessageCount(0) + ->setUnreadCount(0) + ->setTab(MessageTab::Primary) + ->setAttachmentCount(0); + + $this->entityManager->persist($thread); + + return $thread; + } + + private function attachMessageToThread(Message $message, MessageThread $thread, Mailbox $mailbox): void + { + $message->setThread($thread); + + if (!$thread->getMailboxes()->contains($mailbox)) { + $thread->addMailbox($mailbox); + } + + $thread->setMessageCount($thread->getMessageCount() + 1); + + if (null === $message->getSeenAt()) { + $thread->setUnreadCount($thread->getUnreadCount() + 1); + } + + if (true === $message->hasAttachments()) { + $thread->setAttachmentCount($thread->getAttachmentCount() + 1); + } + + $receivedAt = $message->getReceivedAt(); + + if (null !== $receivedAt ) { + $currentLastMessageAt = $thread->getLastMessageAt(); + + if (null !== $currentLastMessageAt || $receivedAt > $currentLastMessageAt) { + $thread->setLastMessageAt($receivedAt); + } + } + } +} diff --git a/src/Twig/AccountsGlobal.php b/src/Twig/AccountsGlobal.php new file mode 100644 index 00000000..86516c1f --- /dev/null +++ b/src/Twig/AccountsGlobal.php @@ -0,0 +1,28 @@ +security->getUser(); + + if ($user === null) { + return new ArrayIterator([]); + } + + return new ArrayIterator($this->accounts->findBy(['usr' => $user, 'isActive' => true])); + } +} diff --git a/src/Twig/AssetsTwigExtension.php b/src/Twig/AssetsTwigExtension.php new file mode 100644 index 00000000..994e1751 --- /dev/null +++ b/src/Twig/AssetsTwigExtension.php @@ -0,0 +1,50 @@ + +
+ + {# Sidebar — solid, opaque, its own card #} + {% include '_partials/_sidebar.html.twig' %} + + {# Right column: glass topbar + glass content, stacked #} +
+ + {% include '_partials/_topbar.html.twig' %} + +
+ {# List pane #} +
+ {% block inbox_tabs %}{% endblock %} + +
    + {% block message_list %}{% endblock %} +
+
+ + {# Reading pane #} +
+ {% block reading_pane %} + {% if selectedMessage is not null %} + {% include 'mail/_message_content.html.twig' with { message: selectedMessage } %} + {% endif %} + {% endblock %} +
+
+ +
+ +
+ +{% endblock %} diff --git a/templates/_layout/app.html.twig b/templates/_layout/app.html.twig new file mode 100644 index 00000000..78a5fc76 --- /dev/null +++ b/templates/_layout/app.html.twig @@ -0,0 +1,43 @@ + + + + + {% block title %}Welcome!{% endblock %} + + + {# Must be first thing in to avoid flash of wrong theme #} + + + {% block stylesheets %} + {% endblock %} + + {% block javascripts %} + {% block importmap %}{{ importmap('app') }}{% endblock %} + {% endblock %} + + {% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %} + {% if frankenphpHotReload %} + + + + {% endif %} + + +{% block body %}{% endblock %} +{% block javascripts_body %}{% endblock %} +{% include '_partials/_modal.html.twig' %} +
+ + diff --git a/templates/_partials/_account_list.html.twig b/templates/_partials/_account_list.html.twig new file mode 100644 index 00000000..6953272a --- /dev/null +++ b/templates/_partials/_account_list.html.twig @@ -0,0 +1,42 @@ +{% for account in accounts %} + {% set currentAccount = app.request.attributes.get('account') %} + {% set isActive = currentAccount is not null and currentAccount == account.id %} +
+ + + + + {% if isActive %} +
Loading…
+ {% endif %} +
+
+{% else %} +

No accounts yet

+{% endfor %} diff --git a/templates/_partials/_message_row.html.twig b/templates/_partials/_message_row.html.twig new file mode 100644 index 00000000..b78834c7 --- /dev/null +++ b/templates/_partials/_message_row.html.twig @@ -0,0 +1,98 @@ +{# _partials/_message_row.html.twig #} +
  • +
    + {# Checkbox #} + + + {# Star toggle #} + + + {# Clickable row content #} + + {# Sender #} + + {{ message.fromName ?: message.fromAddress }} + + + {# Subject + snippet, single line, ellipsis #} + + + {{ message.subject ?: '(no subject)' }} + + + — {{ message.bodyText|striptags|slice(0, 100) }} + + + + + {# Date — hidden on hover, replaced by quick actions #} + + {% if message.receivedAt %} + {% if message.receivedAt|date('Y-m-d') == 'now'|date('Y-m-d') %} + {{ message.receivedAt|date('g:i a') }} + {% else %} + {{ message.receivedAt|date('M j') }} + {% endif %} + {% endif %} + + + {# Hover quick actions — visual only for now #} + +
    +
  • diff --git a/templates/_partials/_modal.html.twig b/templates/_partials/_modal.html.twig new file mode 100644 index 00000000..21a262f4 --- /dev/null +++ b/templates/_partials/_modal.html.twig @@ -0,0 +1,102 @@ +{# +templates/_partials/_modal.html.twig + +Global modal shell. Include once in app.html.twig (inside , before ). + +The is the load target. Any controller action that +wants to open a form just sets frame.src to the form URL; Turbo fetches the +page and renders the matching from the response. + +The modal is hidden by default (via the `hidden` attribute). The Stimulus +modal controller removes/adds `hidden` on open/close. +#} + +{# ── Backdrop + dialog wrapper ─────────────────────────────────────────────── #} +{# /backdrop #} diff --git a/templates/_partials/_modal_form.html.twig b/templates/_partials/_modal_form.html.twig new file mode 100644 index 00000000..a0cf725d --- /dev/null +++ b/templates/_partials/_modal_form.html.twig @@ -0,0 +1,82 @@ +{# +templates/_partials/_modal_form.html.twig + +Wrap any form rendered inside the modal turbo-frame with this partial. +Turbo extracts only the fragment; the rest of +the page layout is ignored, so extending app.html.twig is optional. + +Parameters: + title (string) – modal heading + form (FormView) – Symfony form view + submit_label (string) – submit button text, defaults to "Save" + cancel_label (string) – cancel button text, defaults to "Cancel" + danger (bool) – red submit button for destructive actions +#} +{% set submit_label = submit_label ?? 'Save' %} +{% set cancel_label = cancel_label ?? 'Cancel' %} +{% set danger = danger ?? false %} + + + + + {{ form_start(form, { + attr: { + 'data-turbo-frame': 'modal', + class: 'flex flex-col min-h-0', + } + }) }} + + {# ── Body ── #} +
    + + {# Global form errors #} + {% if form.vars.errors|length %} + + {% endif %} + + {{ form_widget(form) }} + +
    + + {# ── Footer ── #} +
    + + + +
    + + {{ form_end(form) }} +
    diff --git a/templates/_partials/_sidebar.html.twig b/templates/_partials/_sidebar.html.twig new file mode 100644 index 00000000..7e01f690 --- /dev/null +++ b/templates/_partials/_sidebar.html.twig @@ -0,0 +1,83 @@ + diff --git a/templates/_partials/_thread_row.html.twig b/templates/_partials/_thread_row.html.twig new file mode 100644 index 00000000..50f39b38 --- /dev/null +++ b/templates/_partials/_thread_row.html.twig @@ -0,0 +1,176 @@ +{# _partials/_message_row.html.twig #} + +{% set item = thread ?? message %} +{% set isThread = item.messages is defined %} + +{% if isThread %} + {% set unread = item.unreadCount > 0 %} + {% set subject = item.subject ?: '(no subject)' %} + {% set starred = item.starredAt is not null %} + {% set date = item.lastMessageAt %} + {% set count = item.messageCount %} + + {# Ideally these would eventually be denormalized onto MessageThread #} + {% set latest = item.messages|last %} + {% set sender = latest ? (latest.fromName ?: latest.fromAddress) : '' %} + {% set snippet = latest ? latest.bodyText|striptags|slice(0, 100) : '' %} + +{# {% set href = path('app_mail_thread', { id: item.id }) %}#} + {% set rowId = item.id %} +{% else %} + {% set unread = item.seenAt is null %} + {% set subject = item.subject ?: '(no subject)' %} + {% set starred = item.starredAt is not null %} + {% set date = item.receivedAt %} + {% set count = 1 %} + {% set sender = item.fromName ?: item.fromAddress %} + {% set snippet = item.bodyText|striptags|slice(0, 100) %} + + {% set href = path('app_mail_message', { id: item.id }) %} + {% set rowId = item.id %} +{% endif %} + +
  • +
    + {# Checkbox #} + + + {# Star toggle #} + + + {# Clickable row #} + + {# Sender #} + + {{ sender }} + + + {# Subject + snippet #} + + + {{ subject }} + + {% if count > 1 %} + + ({{ count }}) + + {% endif %} + + + + — {{ snippet }} + + + + + {# Date — hidden on hover #} + + {% if date %} + {% if date|date('Y-m-d') == 'now'|date('Y-m-d') %} + {{ date|date('g:i a') }} + {% else %} + {{ date|date('M j') }} + {% endif %} + {% endif %} + + {# Hover quick actions #} + +
    +
  • + diff --git a/templates/_partials/_toast.html.twig b/templates/_partials/_toast.html.twig new file mode 100644 index 00000000..960662f6 --- /dev/null +++ b/templates/_partials/_toast.html.twig @@ -0,0 +1,21 @@ +{# _toast.html.twig #} +{% set colors = { + success: 'bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900', + error: 'bg-red-600 text-white', + info: 'bg-blue-600 text-white', +} %} + +
    + {{ message }} + +
    diff --git a/templates/_partials/_topbar.html.twig b/templates/_partials/_topbar.html.twig new file mode 100644 index 00000000..2d76109a --- /dev/null +++ b/templates/_partials/_topbar.html.twig @@ -0,0 +1,70 @@ +
    + + {# Logo #} +
    + + + + + Mail +
    + + {# Search #} +
    +
    + + + + +
    +
    + + {# Right actions #} +
    + + {# Dark mode toggle #} + + + {# User menu #} +
    + + + +
    + +
    +
    diff --git a/templates/_partials/modal_form_theme.html.twig b/templates/_partials/modal_form_theme.html.twig new file mode 100644 index 00000000..c673d81c --- /dev/null +++ b/templates/_partials/modal_form_theme.html.twig @@ -0,0 +1,183 @@ +{# +templates/_partials/modal_form_theme.html.twig + +Tailwind form theme for fields rendered inside the modal. +Register in config/packages/twig.yaml: + + twig: + form_themes: + - '_partials/modal_form_theme.html.twig' + +Or apply per-form: + {{ form_start(form, { attr: {...} }) }} + ... + (use the global config approach — per-form override requires PHP changes) +#} + +{# ── Row wrapper ─────────────────────────────────────────────────────────── #} +{% block form_row %} +
    + {{ form_label(form) }} + {{ form_widget(form) }} + {{ form_errors(form) }} + {{ form_help(form) }} +
    +{% endblock %} + +{# ── Label ───────────────────────────────────────────────────────────────── #} +{% block form_label %} + {% if label is not same as(false) %} + {% set label = label ?? (name|humanize) %} + + {% endif %} +{% endblock %} + +{# ── Text / email / password / number / url / search inputs ─────────────── #} +{% block text_widget %} + {% set attr = attr|merge({ + class: 'block w-full h-9 rounded-lg border border-zinc-300 dark:border-zinc-600/80 + bg-white dark:bg-zinc-800/60 + px-3 text-sm text-zinc-900 dark:text-zinc-100 + placeholder:text-zinc-400 dark:placeholder:text-zinc-500 + shadow-sm shadow-zinc-950/5 + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent + transition-colors duration-100 + disabled:opacity-50 disabled:cursor-not-allowed + ' ~ (attr.class ?? '') + }) %} + {{ block('form_widget_simple') }} +{% endblock %} + +{% block email_widget %}{% set type = 'email' %}{{ block('text_widget') }}{% endblock %} +{% block password_widget %}{% set type = 'password' %}{{ block('text_widget') }}{% endblock %} +{% block url_widget %}{% set type = 'url' %}{{ block('text_widget') }}{% endblock %} +{% block search_widget %}{% set type = 'search' %}{{ block('text_widget') }}{% endblock %} +{% block tel_widget %}{% set type = 'tel' %}{{ block('text_widget') }}{% endblock %} + +{# ── Number — explicit block so Tailwind v4 preflight doesn't strip the box ─ #} +{% block number_widget %} + {% set attr = attr|merge({ + class: 'block w-full h-9 rounded-lg border border-zinc-300 dark:border-zinc-600/80 + bg-white dark:bg-zinc-800/60 + px-3 text-sm text-zinc-900 dark:text-zinc-100 + placeholder:text-zinc-400 dark:placeholder:text-zinc-500 + shadow-sm shadow-zinc-950/5 + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent + transition-colors duration-100 + disabled:opacity-50 disabled:cursor-not-allowed + [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none + ' ~ (attr.class ?? '') + }) %} + +{% endblock %} + +{# ── Textarea ────────────────────────────────────────────────────────────── #} +{% block textarea_widget %} + {% set attr = attr|merge({ + class: 'block w-full rounded-lg border border-zinc-300 dark:border-zinc-600/80 + bg-white dark:bg-zinc-800/60 + px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 + placeholder:text-zinc-400 dark:placeholder:text-zinc-500 + shadow-sm shadow-zinc-950/5 + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent + resize-y transition-colors duration-100 + disabled:opacity-50 disabled:cursor-not-allowed + ' ~ (attr.class ?? ''), + rows: attr.rows ?? 3, + }) %} + +{% endblock %} + +{# ── Select ──────────────────────────────────────────────────────────────── #} +{% block choice_widget_collapsed %} + {% set attr = attr|merge({ + class: 'block w-full h-9 rounded-lg border border-zinc-300 dark:border-zinc-600/80 + bg-white dark:bg-zinc-800/60 + pl-3 pr-8 text-sm text-zinc-900 dark:text-zinc-100 + shadow-sm shadow-zinc-950/5 appearance-none + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent + transition-colors duration-100 + disabled:opacity-50 disabled:cursor-not-allowed + ' ~ (attr.class ?? '') + }) %} + {# Wrap in a relative div to add the chevron icon #} +
    + +
    + +
    +
    +{% endblock %} + +{# ── Checkbox ────────────────────────────────────────────────────────────── #} +{% block checkbox_widget %} + {% set attr = attr|merge({ + class: 'size-4 rounded border-zinc-300 dark:border-zinc-600 + text-blue-600 dark:text-blue-500 + bg-white dark:bg-zinc-800 + focus:ring-2 focus:ring-blue-500 focus:ring-offset-0 + cursor-pointer + ' ~ (attr.class ?? '') + }) %} + {{ block('form_widget_simple') }} +{% endblock %} + +{% block checkbox_row %} +
    + {{ form_widget(form) }} + +
    + {{ form_errors(form) }} + {{ form_help(form) }} +{% endblock %} + +{# ── Field-level errors ──────────────────────────────────────────────────── #} +{% block form_errors %} + {% if errors|length > 0 %} +
      + {% for error in errors %} +
    • + + {{ error.message }} +
    • + {% endfor %} +
    + {% endif %} +{% endblock %} + +{# ── Help text ───────────────────────────────────────────────────────────── #} +{% block form_help %} + {% if help is not empty %} +

    + {{ help|trans({}, translation_domain) }} +

    + {% endif %} +{% endblock %} diff --git a/templates/account/_form_modal.html.twig b/templates/account/_form_modal.html.twig new file mode 100644 index 00000000..4a5d1a01 --- /dev/null +++ b/templates/account/_form_modal.html.twig @@ -0,0 +1,165 @@ +{# +templates/mail/account_new.html.twig + +Rendered inside the modal turbo-frame. +Tabs (IMAP/SMTP · OAuth) switch via Stimulus — no page reload needed. + +Controller sets up: + $form = $this->createForm(AccountType::class); + return $this->render('mail/account_new.html.twig', ['form' => $form]); +#} +{% extends '_layout/app.html.twig' %} + +{% block body %} + + + + {{ form_start(form, { + attr: { + 'data-turbo': 'true', + 'data-controller': 'account-form', + class: 'flex flex-col min-h-0', + } + }) }} + + {# ── Tab bar ─────────────────────────────────────────────────────────── #} +
    + + +
    + + {# ── Scrollable body ──────────────────────────────────────────────────── #} +
    + + {# Global errors #} + {% if form.vars.errors|length %} + + {% endif %} + + {# ── IMAP / SMTP panel ──────────────────────────────────────────────── #} +
    + + {# Account basics #} +
    +
    + {{ form_row(form.label, { label: 'Account label', attr: { placeholder: 'Work, Personal…' } }) }} + {{ form_row(form.username, { label: 'Email address', attr: { placeholder: 'you@example.com' } }) }} +
    + {{ form_row(form.password, { label: 'Password', attr: { placeholder: '••••••••' } }) }} +
    + + {# IMAP #} +
    +

    Incoming — IMAP

    +
    +
    + {{ form_row(form.imapHost, { label: 'Host', attr: { placeholder: 'imap.example.com' } }) }} + {{ form_row(form.imapPort, { label: 'Port', attr: { placeholder: '993' } }) }} +
    + {{ form_row(form.imapEncryption, { label: 'Encryption' }) }} +
    +
    + + {# SMTP #} +
    +

    Outgoing — SMTP

    +
    +
    + {{ form_row(form.smtpHost, { label: 'Host', attr: { placeholder: 'smtp.example.com' } }) }} + {{ form_row(form.smtpPort, { label: 'Port', attr: { placeholder: '587' } }) }} +
    + {{ form_row(form.smtpEncryption, { label: 'Encryption' }) }} +
    +
    + +
    {# /imap panel #} + + {# ── OAuth panel ────────────────────────────────────────────────────── #} + {# /oauth panel #} + +
    {# /scrollable body #} + + {# ── Footer ───────────────────────────────────────────────────────────── #} +
    + + + +
    + {{ form_end(form) }} +
    +{% endblock %} diff --git a/templates/account/_form_success.stream.html.twig b/templates/account/_form_success.stream.html.twig new file mode 100644 index 00000000..3a4da4c7 --- /dev/null +++ b/templates/account/_form_success.stream.html.twig @@ -0,0 +1,21 @@ +{# Close the modal by emptying the frame #} + + + + +{# Inject a toast #} + + + + +{# Refresh the account list #} + + + diff --git a/templates/base.html.twig b/templates/base.html.twig deleted file mode 100644 index a9cdfb3e..00000000 --- a/templates/base.html.twig +++ /dev/null @@ -1,24 +0,0 @@ - - - - - {% block title %}Welcome!{% endblock %} - - {% block stylesheets %} - {% endblock %} - - {% block javascripts %} - {% block importmap %}{{ importmap('app') }}{% endblock %} - {% endblock %} - - {% set frankenphpHotReload = app.request.server.get('FRANKENPHP_HOT_RELOAD') %} - {% if frankenphpHotReload %} - - - - {% endif %} - - - {% block body %}{% endblock %} - - diff --git a/templates/broadcast/Account.stream.html.twig b/templates/broadcast/Account.stream.html.twig new file mode 100644 index 00000000..57f5cff3 --- /dev/null +++ b/templates/broadcast/Account.stream.html.twig @@ -0,0 +1,22 @@ +{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #} +{% block create %} + + + +{% endblock %} + +{% block update %} + + + +{% endblock %} + +{% block remove %} + +{% endblock %} diff --git a/templates/broadcast/Mailbox.stream.html.twig b/templates/broadcast/Mailbox.stream.html.twig new file mode 100644 index 00000000..d0d896f6 --- /dev/null +++ b/templates/broadcast/Mailbox.stream.html.twig @@ -0,0 +1,22 @@ +{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #} +{% block create %} + + + +{% endblock %} + +{% block update %} + + + +{% endblock %} + +{% block remove %} + +{% endblock %} diff --git a/templates/broadcast/Message.stream.html.twig b/templates/broadcast/Message.stream.html.twig new file mode 100644 index 00000000..5a9365f2 --- /dev/null +++ b/templates/broadcast/Message.stream.html.twig @@ -0,0 +1,22 @@ +{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #} +{% block create %} + + + +{% endblock %} + +{% block update %} + + + +{% endblock %} + +{% block remove %} + +{% endblock %} diff --git a/templates/broadcast/MessagePart.stream.html.twig b/templates/broadcast/MessagePart.stream.html.twig new file mode 100644 index 00000000..dd8d5bd8 --- /dev/null +++ b/templates/broadcast/MessagePart.stream.html.twig @@ -0,0 +1,22 @@ +{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #} +{% block create %} + + + +{% endblock %} + +{% block update %} + + + +{% endblock %} + +{% block remove %} + +{% endblock %} diff --git a/templates/broadcast/MessageThread.stream.html.twig b/templates/broadcast/MessageThread.stream.html.twig new file mode 100644 index 00000000..e29970e7 --- /dev/null +++ b/templates/broadcast/MessageThread.stream.html.twig @@ -0,0 +1,22 @@ +{# Learn how to use Turbo Streams: https://github.com/symfony/ux-turbo#broadcast-doctrine-entities-update #} +{% block create %} + + + +{% endblock %} + +{% block update %} + + + +{% endblock %} + +{% block remove %} + +{% endblock %} diff --git a/templates/default/index.html.twig b/templates/default/index.html.twig new file mode 100644 index 00000000..0aa61df2 --- /dev/null +++ b/templates/default/index.html.twig @@ -0,0 +1,303 @@ +{% extends '_layout/app.html.twig' %} + +{% block title %}Inbox — Mail{% endblock %} + +{% block body %} +
    + + {# ===== TOP BAR ===== #} +
    + + {# Logo #} +
    + + + + + Mail +
    + + {# Search bar #} +
    +
    + + + + +
    +
    + + {# Right side actions #} +
    + + {# Dark mode toggle #} + + + {# User avatar #} +
    + + + {# Dropdown #} + +
    + +
    +
    + + {# ===== MAIN AREA (sidebar + list + reading pane) ===== #} +
    + + {# SIDEBAR #} + + + {# ===== MESSAGE LIST ===== #} +
    + + {# Tabs #} +
    + +
    + + {# Search #} +
    +
    + + + + +
    +
    + + {# Message rows #} +
      + + {# Unread message #} +
    • +
      JD
      +
      +
      + Jane Doe + 10:42 +
      +

      Project kickoff tomorrow

      +

      Just confirming we're still on for 9am…

      +
      +
    • + + {# Unread message #} +
    • +
      TK
      +
      +
      + Tom K. + 09:15 +
      +

      Invoice #1042 attached

      +

      Please find the invoice for last month…

      +
      +
    • + + {# Read message #} +
    • +
      AL
      +
      +
      + Anna L. + Yesterday +
      +

      Re: Design feedback

      +

      Thanks for the notes, I'll update the mockups…

      +
      +
    • + + {# Read message #} +
    • +
      GH
      +
      +
      + GitHub + Mon +
      +

      [mail-client] PR #3 opened

      +

      someone opened a pull request in your repo…

      +
      +
    • + +
    +
    + + {# ===== READING PANE ===== #} +
    + + {# Reading pane toolbar #} +
    + + + +
    + + {# Message content #} +
    +

    Project kickoff tomorrow

    + +
    +
    +
    JD
    +
    +

    Jane Doe <jane@example.com>

    +

    To: me  ·  Today at 10:42

    +
    +
    +
    + + +
    +
    + +
    +

    Hi,

    +

    Just confirming we're still on for the project kickoff tomorrow at 9am. I've booked the main meeting room and sent calendar invites to everyone on the team.

    +

    Agenda for the call:

    +
      +
    • Introductions and roles
    • +
    • Project scope and timeline
    • +
    • Tooling and workflow decisions
    • +
    • Q&A
    • +
    +

    Let me know if anything needs to change.

    +

    Best,
    Jane

    +
    + + {# Reply box #} +
    +

    Reply to Jane Doe

    + +
    + +
    +
    +
    +
    + +
    +
    +{% endblock %} + + diff --git a/templates/mail/_account_folders.html.twig b/templates/mail/_account_folders.html.twig new file mode 100644 index 00000000..5a627c5e --- /dev/null +++ b/templates/mail/_account_folders.html.twig @@ -0,0 +1,15 @@ + + + diff --git a/templates/mail/_message_content.html.twig b/templates/mail/_message_content.html.twig new file mode 100644 index 00000000..c1db53ed --- /dev/null +++ b/templates/mail/_message_content.html.twig @@ -0,0 +1,54 @@ +{# mail/_message_content.html.twig #} +
    + + {{ message.mailbox.name }} +
    + +
    +
    + +

    + {{ message.subject ?: '(no subject)' }} +

    + +
    +
    +

    + {{ message.fromName ?: message.fromAddress }} +

    +

    + {{ message.fromAddress }} +

    +
    +

    + {{ message.receivedAt ? message.receivedAt|date('M j, Y, g:i a') : '' }} +

    +
    + + {% if message.bodyHtml %} +
    + {{ message.bodyHtml|raw }} +
    + {% else %} +
    + {{ message.bodyText }} +
    + {% endif %} + + {% if message.hasAttachments %} +
    +

    Attachments

    + {# attachment list goes here once MessagePart rendering is wired up #} +
    + {% endif %} + +
    +
    diff --git a/templates/mail/_thread_content.html.twig b/templates/mail/_thread_content.html.twig new file mode 100644 index 00000000..38582b58 --- /dev/null +++ b/templates/mail/_thread_content.html.twig @@ -0,0 +1,129 @@ +{# mail/_thread_content.html.twig #} + +
    + + {{ thread.subject ?: '(no subject)' }} +
    + +
    +
    +

    + {{ thread.subject ?: '(no subject)' }} +

    + +
    + {% for message in thread.messages|reverse %} + {% set isLatest = loop.first %} + {% set isRead = message.seenAt is not null %} + {% set isExpanded = isLatest or not isRead %} + +
    + + {# Thread connection line #} + {% if not loop.last %} +
    + {% endif %} + +
    + + {# Avatar #} +
    + {{ message.fromName|slice(0,1) ?: message.fromAddress|slice(0,1)|upper }} +
    + + {# Bubble #} +
    + {# Header row #} +
    +
    + + {{ message.fromName ?: message.fromAddress }} + + + {{ message.receivedAt ? message.receivedAt|date('M j, g:i a') : '' }} + +
    + + {% if not isLatest %} + + {% endif %} +
    + + {# Snippet — collapsed #} +
    + {{ message.bodyText|striptags|slice(0, 150) }}… +
    + + {# Body — expanded #} +
    +
    + {% if message.bodyHtml %} + + {% else %} +
    {{ message.bodyText }}
    + {% endif %} +
    + + {% if message.hasAttachments %} +
    +

    Attachments

    +
    + {# TODO: iterate message.parts filtered by disposition = attachment #} +
    +
    + {% endif %} + +
    + + +
    +
    + +
    {# /bubble #} +
    {# /flex items-start #} +
    {# /relative #} + {% endfor %} +
    +
    +
    diff --git a/templates/mail/drafts.html.twig b/templates/mail/drafts.html.twig new file mode 100644 index 00000000..e1b8a033 --- /dev/null +++ b/templates/mail/drafts.html.twig @@ -0,0 +1,10 @@ +{% extends '_layout/_mailbox.html.twig' %} + +{% block message_list %} + +{% endblock %} + +{% block inbox_tabs %} + +{% endblock %} + diff --git a/templates/mail/inbox.html.twig b/templates/mail/inbox.html.twig new file mode 100644 index 00000000..aa4cc40e --- /dev/null +++ b/templates/mail/inbox.html.twig @@ -0,0 +1,29 @@ +{# mail/inbox.html.twig #} +{% extends '_layout/_mailbox.html.twig' %} + +{% block inbox_tabs %} +
    + +
    + + {% if threads is empty %} +
  • + No messages in this mailbox +
  • + {% else %} + {% for thread in threads %} + {% include '_partials/_thread_row.html.twig' with { + thread: thread, + } %} + {% endfor %} + {% endif %} +{% endblock %} + +{% block message_list %} + {# loop over messages here #} +{% endblock %} diff --git a/templates/mail/mailbox.html.twig b/templates/mail/mailbox.html.twig new file mode 100644 index 00000000..155588eb --- /dev/null +++ b/templates/mail/mailbox.html.twig @@ -0,0 +1,16 @@ +{% extends '_layout/_mailbox.html.twig' %} + +{% block message_list %} + {% if messages is empty %} +
  • + No messages in this mailbox +
  • + {% else %} + {% for message in messages %} + {% include '_partials/_message_row.html.twig' with { + message: message, + mailbox: mailbox.name + } %} + {% endfor %} + {% endif %} +{% endblock %} diff --git a/templates/mail/sent.html.twig b/templates/mail/sent.html.twig new file mode 100644 index 00000000..e1b8a033 --- /dev/null +++ b/templates/mail/sent.html.twig @@ -0,0 +1,10 @@ +{% extends '_layout/_mailbox.html.twig' %} + +{% block message_list %} + +{% endblock %} + +{% block inbox_tabs %} + +{% endblock %} + diff --git a/templates/mail/starred.html.twig b/templates/mail/starred.html.twig new file mode 100644 index 00000000..f245dd5c --- /dev/null +++ b/templates/mail/starred.html.twig @@ -0,0 +1,6 @@ +{# mail/starred.html.twig #} +{% extends '_layout/_mailbox.html.twig' %} + +{% block message_list %} + {# loop over starred messages here #} +{% endblock %} diff --git a/templates/mail/thread.html.twig b/templates/mail/thread.html.twig new file mode 100644 index 00000000..aef5ec95 --- /dev/null +++ b/templates/mail/thread.html.twig @@ -0,0 +1,12 @@ +{# mail/thread.html.twig #} +{% extends '_layout/_mailbox.html.twig' %} + +{% block message_list %}{% endblock %} +{% block inbox_tabs %}{% endblock %} +{% block pane_open %}true{% endblock %} +{% block list_hidden %}hidden{% endblock %} +{% block reading_hidden %}{% endblock %} + +{% block reading_pane %} + {% include 'mail/_thread_content.html.twig' with { thread: thread } %} +{% endblock %} diff --git a/templates/mail/trash.html.twig b/templates/mail/trash.html.twig new file mode 100644 index 00000000..e1b8a033 --- /dev/null +++ b/templates/mail/trash.html.twig @@ -0,0 +1,10 @@ +{% extends '_layout/_mailbox.html.twig' %} + +{% block message_list %} + +{% endblock %} + +{% block inbox_tabs %} + +{% endblock %} + diff --git a/templates/security/login.html.twig b/templates/security/login.html.twig new file mode 100644 index 00000000..50c7464e --- /dev/null +++ b/templates/security/login.html.twig @@ -0,0 +1,97 @@ +{# templates/security/login.html.twig #} +{% extends '_layout/app.html.twig' %} + +{% block title %}Sign in — Mail{% endblock %} + +{% block body %} +
    +
    + + {# Logo #} +
    +
    + + + + + Mail +
    +

    Sign in to your account

    +
    + + {# Error #} + {% if error %} +
    + {{ error.messageKey | trans(error.messageData, 'security') }} +
    + {% endif %} + + {# Card #} +
    + +
    + +
    +
    + + +
    + +
    +
    + + + Forgot password? + +
    + +
    +
    + + + + + +
    +
    + + {# Register link #} +

    + Don't have an account? + + Create one + + Registration coming soon. +

    + +
    +
    +{% endblock %} diff --git a/tests/Controller/MailControllerTest.php b/tests/Controller/MailControllerTest.php new file mode 100644 index 00000000..cbb0e965 --- /dev/null +++ b/tests/Controller/MailControllerTest.php @@ -0,0 +1,16 @@ +request('GET', '/mail'); + + self::assertResponseIsSuccessful(); + } +}