From 4dc569e4396d979cf58993e005ec3d7f98c26e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Thu, 16 Jul 2026 17:46:51 +0200 Subject: [PATCH] google oauth --- .env | 5 + .idea/php.xml | 9 + .idea/pl_mail.iml | 9 + composer.json | 2 + composer.lock | 657 +++++++++++++++++- config/services.yaml | 14 + src/Command/ImapIdleCommand.php | 3 +- src/Command/ImapTestCommand.php | 7 +- src/Command/SyncMessagesCommand.php | 3 +- src/Controller/OAuthController.php | 169 +++++ src/Domain/Enum/MailProvider.php | 76 ++ src/Domain/Helper/ImapConnectionFactory.php | 48 +- src/Domain/Interface/MailSenderInterface.php | 27 + src/MessageHandler/ApplyImapFlagsHandler.php | 3 +- .../SyncMailboxMessageHandler.php | 3 +- src/Service/Imap/MailboxSyncer.php | 30 +- src/Service/Imap/MessageSendService.php | 122 ++-- src/Service/Mail/GmailApiSender.php | 95 +++ src/Service/Mail/MailSenderRegistry.php | 37 + src/Service/Mail/SmtpMailSender.php | 75 ++ src/Service/OAuth/OAuthProviderFactory.php | 49 ++ src/Service/OAuth/OAuthTokenManager.php | 127 ++++ templates/account/_form_modal.html.twig | 3 +- 23 files changed, 1490 insertions(+), 83 deletions(-) create mode 100644 src/Controller/OAuthController.php create mode 100644 src/Domain/Enum/MailProvider.php create mode 100644 src/Domain/Interface/MailSenderInterface.php create mode 100644 src/Service/Mail/GmailApiSender.php create mode 100644 src/Service/Mail/MailSenderRegistry.php create mode 100644 src/Service/Mail/SmtpMailSender.php create mode 100644 src/Service/OAuth/OAuthProviderFactory.php create mode 100644 src/Service/OAuth/OAuthTokenManager.php diff --git a/.env b/.env index d352eabd..07537119 100644 --- a/.env +++ b/.env @@ -66,3 +66,8 @@ MERCURE_URL=http://mercure/.well-known/mercure MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" ###< symfony/mercure-bundle ### + +###> google oauth ### +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= +###< google oauth ### diff --git a/.idea/php.xml b/.idea/php.xml index 6c7fd446..bf3daa99 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -167,6 +167,15 @@ + + + + + + + + + diff --git a/.idea/pl_mail.iml b/.idea/pl_mail.iml index 17777333..0d7ff110 100644 --- a/.idea/pl_mail.iml +++ b/.idea/pl_mail.iml @@ -153,6 +153,15 @@ + + + + + + + + + diff --git a/composer.json b/composer.json index 766fc112..679feadd 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,8 @@ "doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/orm": "^3.6", "knplabs/knp-paginator-bundle": "*", + "league/oauth2-client": "^2.9", + "league/oauth2-google": "^5.0", "phpdocumentor/reflection-docblock": "^6.0", "phpstan/phpdoc-parser": "^2.3", "symfony/asset": "8.1.*", diff --git a/composer.lock b/composer.lock index a41ba2b0..926f9dec 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "58d90beaac008d72c805d86380f53b7d", + "content-hash": "25b734262146938a3da5806db6d75dd5", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -1334,6 +1334,337 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.14.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "fa88c57803501ad0770f5cddb1e60525d49da9a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/fa88c57803501ad0770f5cddb1e60525d49da9a1", + "reference": "fa88c57803501ad0770f5cddb1e60525d49da9a1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.1", + "guzzlehttp/psr7": "^2.12.5", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.6", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.14.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-07-14T18:15:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29", + "reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-07-08T15:48:39+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.12.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "9365d578a9fd1552ad6ca9c3cb530708526feb09" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/9365d578a9fd1552ad6ca9c3cb530708526feb09", + "reference": "9365d578a9fd1552ad6ca9c3cb530708526feb09", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.12.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-07-13T01:27:20+00:00" + }, { "name": "illuminate/collections", "version": "v13.18.0", @@ -1951,6 +2282,126 @@ ], "time": "2025-10-17T11:30:53+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.9.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "reference": "26e8c5da4f3d78cede7021e09b1330a0fc093d5e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.6.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.9.0" + }, + "time": "2025-11-25T22:17:17+00:00" + }, + { + "name": "league/oauth2-google", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-google.git", + "reference": "23ba9a808fd88d7c9a9fdec5548c4861b3a4b295" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-google/zipball/23ba9a808fd88d7c9a9fdec5548c4861b3a4b295", + "reference": "23ba9a808fd88d7c9a9fdec5548c4861b3a4b295", + "shasum": "" + }, + "require": { + "league/oauth2-client": "^2.0 || ^3.0", + "php": "^8.0" + }, + "require-dev": { + "eloquent/phony-phpunit": "^6.0 || ^7.1", + "phpunit/phpunit": "^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Woody Gilk", + "email": "hello@shadowhand.com", + "homepage": "https://shadowhand.com" + } + ], + "description": "Google OAuth 2.0 Client Provider for The PHP League OAuth2-Client", + "keywords": [ + "Authentication", + "authorization", + "client", + "google", + "oauth", + "oauth2" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-google/issues", + "source": "https://github.com/thephpleague/oauth2-google/tree/5.0.0" + }, + "time": "2026-03-23T14:25:21+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -2582,6 +3033,166 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "psr/link", "version": "2.0.1", @@ -2739,6 +3350,50 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "symfony/asset", "version": "v8.1.0", diff --git a/config/services.yaml b/config/services.yaml index 261b7d6f..fe21d698 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -22,3 +22,17 @@ services: App\Domain\Helper\AttachmentStorageHelper: arguments: $projectDir: '%kernel.project_dir%' + App\Service\OAuth\OAuthProviderFactory: + arguments: + $googleClientId: '%env(GOOGLE_OAUTH_CLIENT_ID)%' + $googleClientSecret: '%env(GOOGLE_OAUTH_CLIENT_SECRET)%' + + App\Service\Mail\GmailApiSender: + tags: [{ name: app.mail_sender, priority: 10 }] + + App\Service\Mail\SmtpMailSender: + tags: [{ name: app.mail_sender, priority: 0 }] + + App\Service\Mail\MailSenderRegistry: + arguments: + $senders: !tagged_iterator app.mail_sender diff --git a/src/Command/ImapIdleCommand.php b/src/Command/ImapIdleCommand.php index 968f0a23..78b9e3ec 100644 --- a/src/Command/ImapIdleCommand.php +++ b/src/Command/ImapIdleCommand.php @@ -29,6 +29,7 @@ class ImapIdleCommand extends Command public function __construct( private readonly MailboxRepository $mailboxRepository, private readonly MessageBusInterface $bus, + private readonly ImapConnectionFactory $imapConnectionFactory, ) { parent::__construct(); } @@ -120,7 +121,7 @@ class ImapIdleCommand extends Command { $mailbox = $this->mailboxRepository->find($mailboxId); $account = $mailbox->getAccount(); - $client = ImapConnectionFactory::connect($account); + $client = $this->imapConnectionFactory->connect($account); $folder = $client->getFolder($mailbox->getName()); if ($folder === null) { diff --git a/src/Command/ImapTestCommand.php b/src/Command/ImapTestCommand.php index c38174aa..5514d9a0 100644 --- a/src/Command/ImapTestCommand.php +++ b/src/Command/ImapTestCommand.php @@ -15,7 +15,10 @@ use Symfony\Component\Console\Style\SymfonyStyle; #[AsCommand(name: 'app:imap:test', description: 'Test IMAP connection and folder listing')] class ImapTestCommand extends Command { - public function __construct(private readonly AccountRepository $accounts) + public function __construct( + private readonly AccountRepository $accounts, + private readonly ImapConnectionFactory $imapConnectionFactory, + ) { parent::__construct(); } @@ -44,7 +47,7 @@ class ImapTestCommand extends Command // 1. Connect $io->section('1. Connecting…'); try { - $client = ImapConnectionFactory::connect($account); + $client = $this->imapConnectionFactory->connect($account); $io->success('Connected.'); } catch (\Throwable $e) { $io->error('Connection failed: ' . $e->getMessage()); diff --git a/src/Command/SyncMessagesCommand.php b/src/Command/SyncMessagesCommand.php index 0b927e18..693f4888 100644 --- a/src/Command/SyncMessagesCommand.php +++ b/src/Command/SyncMessagesCommand.php @@ -23,6 +23,7 @@ class SyncMessagesCommand extends Command private readonly AccountRepository $accountRepository, private readonly MailboxRepository $mailboxRepository, private readonly MessageSyncer $messageSyncer, + private readonly ImapConnectionFactory $imapConnectionFactory, ) { parent::__construct(); } @@ -47,7 +48,7 @@ class SyncMessagesCommand extends Command $io->section('Syncing messages for: ' . $account->getEmail()); try { - $connection = ImapConnectionFactory::connect($account); + $connection = $this->imapConnectionFactory->connect($account); } catch (\Throwable $e) { $io->error('Failed to connect: ' . $e->getMessage()); continue; diff --git a/src/Controller/OAuthController.php b/src/Controller/OAuthController.php new file mode 100644 index 00000000..d3e39fd9 --- /dev/null +++ b/src/Controller/OAuthController.php @@ -0,0 +1,169 @@ +resolveProvider($provider); + $client = $this->providerFactory->create($mailProvider); + + $options = array_merge( + ['scope' => $mailProvider->scopes()], + $mailProvider->authorizationUrlOptions(), + ); + + $authUrl = $client->getAuthorizationUrl($options); + + $request->getSession()->set(self::SESSION_STATE_KEY, $client->getState()); + + return new RedirectResponse($authUrl); + } + + #[Route('/{provider}/callback', name: 'callback', methods: ['GET'])] + public function callback(string $provider, Request $request): Response + { + $mailProvider = $this->resolveProvider($provider); + + $state = $request->query->get('state'); + $expectedState = $request->getSession()->get(self::SESSION_STATE_KEY); + $request->getSession()->remove(self::SESSION_STATE_KEY); + + if (null === $state || $state !== $expectedState) { + throw $this->createAccessDeniedException('Invalid OAuth state.'); + } + + $code = $request->query->get('code'); + + if (null === $code) { + throw $this->createAccessDeniedException('Missing authorization code.'); + } + + $client = $this->providerFactory->create($mailProvider); + + try { + $token = $client->getAccessToken('authorization_code', ['code' => $code]); + } catch (IdentityProviderException $e) { + throw $this->createAccessDeniedException('OAuth token exchange failed: ' . $e->getMessage()); + } + + $ownerData = $client->getResourceOwner($token)->toArray(); + $email = $this->extractEmail($ownerData); + + if (null === $email) { + throw $this->createAccessDeniedException('Could not determine the account email from the provider.'); + } + + $this->upsertAccount($mailProvider, $email, $token); + + return $this->redirectToRoute('app_default_index'); + } + + private function upsertAccount(MailProvider $provider, string $email, AccessTokenInterface $token): void + { + /** @var User $user */ + $user = $this->getUser(); + + $account = $this->accountRepository->findOneBy([ + 'usr' => $user, + 'email' => $email, + ]); + + if (null === $account) { + $account = new Account(); + $account->setUsr($user); + $account->setEmail($email); + $account->setName($email); + $account->setIsActive(true); + } + + $account->setUsername($email); + $account->setAuthType(AuthType::OAuth2->value); + $account->setOauthProvider($provider->value); + $account->setImapHost($provider->imapHost()); + $account->setImapPort($provider->imapPort()); + $account->setImapEncryption($provider->imapEncryption()); + + // OAuth accounts never carry a password; sending goes over the provider API. + $account->setPassword(null); + + $account->setOauthAccessToken($token->getToken()); + + $refreshToken = $token->getRefreshToken(); + if (null !== $refreshToken) { + $account->setOauthRefreshToken($refreshToken); + } + + $expires = $token->getExpires(); + if (null !== $expires) { + $account->setOauthTokenExpiry(new DateTimeImmutable()->setTimestamp($expires)); + } + + $account->setUpdatedAt(new DateTimeImmutable()); + + $this->em->persist($account); + $this->em->flush(); + } + + /** + * Provider-agnostic email extraction. Google exposes "email"; Microsoft + * exposes "mail" or falls back to "userPrincipalName". + * + * @param array $ownerData + */ + private function extractEmail(array $ownerData): ?string + { + $candidateKeys = ['email', 'mail', 'userPrincipalName']; + + foreach ($candidateKeys as $key) { + if (array_key_exists($key, $ownerData) && is_string($ownerData[$key]) && '' !== $ownerData[$key]) { + return $ownerData[$key]; + } + } + + return null; + } + + private function resolveProvider(string $provider): MailProvider + { + $mailProvider = MailProvider::tryFrom($provider); + + if (null === $mailProvider) { + throw $this->createNotFoundException(sprintf('Unknown OAuth provider "%s".', $provider)); + } + + return $mailProvider; + } +} diff --git a/src/Domain/Enum/MailProvider.php b/src/Domain/Enum/MailProvider.php new file mode 100644 index 00000000..87e1401e --- /dev/null +++ b/src/Domain/Enum/MailProvider.php @@ -0,0 +1,76 @@ + [ + 'https://mail.google.com/', + 'openid', + 'email', + ], + MailProvider::Microsoft => [ + 'offline_access', + 'https://outlook.office.com/IMAP.AccessAsUser.All', + 'https://outlook.office.com/SMTP.Send', + 'openid', + 'email', + ], + }; + } + + /** + * Extra parameters appended to the authorization URL. + * + * Google needs access_type=offline + prompt=consent to reliably return a + * refresh token. Microsoft signals the same intent via the offline_access + * scope, so it needs nothing here. + * + * @return array + */ + public function authorizationUrlOptions(): array + { + return match ($this) { + MailProvider::Google => ['prompt' => 'consent'], + MailProvider::Microsoft => [], + }; + } + + public function imapHost(): string + { + return match ($this) { + MailProvider::Google => 'imap.gmail.com', + MailProvider::Microsoft => 'outlook.office365.com', + }; + } + + public function imapPort(): int + { + return 993; + } + + public function imapEncryption(): string + { + return 'ssl'; + } +} diff --git a/src/Domain/Helper/ImapConnectionFactory.php b/src/Domain/Helper/ImapConnectionFactory.php index 971f64ea..0bea8c82 100644 --- a/src/Domain/Helper/ImapConnectionFactory.php +++ b/src/Domain/Helper/ImapConnectionFactory.php @@ -3,14 +3,26 @@ namespace App\Domain\Helper; use App\Entity\Account; +use App\Enum\AuthType; +use App\Service\OAuth\OAuthTokenManager; use Webklex\PHPIMAP\Client; use Webklex\PHPIMAP\Config; -use Webklex\PHPIMAP\Support\Masks\AttachmentMask; -use Webklex\PHPIMAP\Support\Masks\MessageMask; +/** + * Builds and opens a Webklex IMAP client for an account. + * + * Now a service (was static) because OAuth accounts need a freshly refreshed + * access token, which requires the token manager. Password accounts behave + * exactly as before. + */ class ImapConnectionFactory { - public static function connect(Account $account): Client + public function __construct( + private readonly OAuthTokenManager $tokenManager, + ) { + } + + public function connect(Account $account): Client { $encryption = match ($account->getImapEncryption()) { 'ssl' => 'ssl', @@ -19,22 +31,30 @@ class ImapConnectionFactory default => false, }; + $accountConfig = [ + 'host' => $account->getImapHost(), + 'port' => $account->getImapPort(), + 'encryption' => $encryption, + 'validate_cert' => true, + 'username' => $account->getUsername(), + 'protocol' => 'imap', + ]; + + if (AuthType::OAuth2->value === $account->getAuthType()) { + $accountConfig['password'] = $this->tokenManager->getValidAccessToken($account); + $accountConfig['authentication'] = 'oauth'; + } else { + $accountConfig['password'] = $account->getPassword(); + $accountConfig['authentication'] = null; + } + $client = new Client(Config::make([ - 'default' => 'default', + 'default' => 'default', 'accounts' => [ - 'default' => [ - 'host' => $account->getImapHost(), - 'port' => $account->getImapPort(), - 'encryption' => $encryption, - 'validate_cert' => true, - 'username' => $account->getUsername(), - 'password' => $account->getPassword(), - 'protocol' => 'imap', - ], + 'default' => $accountConfig, ], ])); - $client->connect(); return $client; diff --git a/src/Domain/Interface/MailSenderInterface.php b/src/Domain/Interface/MailSenderInterface.php new file mode 100644 index 00000000..7ce2f390 --- /dev/null +++ b/src/Domain/Interface/MailSenderInterface.php @@ -0,0 +1,27 @@ +imapConnectionFactory->connect($account); $this->processAccount($client, $account, $byMailbox, $message->action); $client->disconnect(); } catch (Throwable $e) { diff --git a/src/MessageHandler/SyncMailboxMessageHandler.php b/src/MessageHandler/SyncMailboxMessageHandler.php index 05eb6beb..09c837b3 100644 --- a/src/MessageHandler/SyncMailboxMessageHandler.php +++ b/src/MessageHandler/SyncMailboxMessageHandler.php @@ -22,6 +22,7 @@ readonly class SyncMailboxMessageHandler private HubInterface $hub, private LoggerInterface $logger, private MessageBusInterface $bus, + private ImapConnectionFactory $imapConnectionFactory, ) {} public function __invoke(SyncMailboxMessage $message): void @@ -39,7 +40,7 @@ readonly class SyncMailboxMessageHandler } $account = $mailbox->getAccount(); - $client = ImapConnectionFactory::connect($account); + $client = $this->imapConnectionFactory->connect($account); $this->messageSyncer->syncMailbox($mailbox, $client); $client->disconnect(); diff --git a/src/Service/Imap/MailboxSyncer.php b/src/Service/Imap/MailboxSyncer.php index d5f36edb..08669087 100644 --- a/src/Service/Imap/MailboxSyncer.php +++ b/src/Service/Imap/MailboxSyncer.php @@ -2,6 +2,7 @@ namespace App\Service\Imap; +use App\Domain\Enum\MailboxSpecialUse; use App\Domain\Helper\ImapConnectionFactory; use App\Entity\Account; use App\Entity\Mailbox; @@ -15,11 +16,12 @@ readonly class MailboxSyncer public function __construct( private MailboxRepository $mailboxRepository, private EntityManagerInterface $em, + private ImapConnectionFactory $imapConnectionFactory, ) {} public function syncForAccount(Account $account): array { - $client = ImapConnectionFactory::connect($account); + $client = $this->imapConnectionFactory->connect($account); $serverFolders = $client->getFolders(false); @@ -85,23 +87,23 @@ readonly class MailboxSyncer $mailbox->setSpecialUse($this->detectSpecialUse($folder)); } - private function detectSpecialUse(Folder $folder): ?string + private function detectSpecialUse(Folder $folder): ?MailboxSpecialUse { $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', + 'inbox' => MailboxSpecialUse::INBOX, + 'sent' => MailboxSpecialUse::SENT, + 'sent messages' => MailboxSpecialUse::SENT, + 'drafts' => MailboxSpecialUse::DRAFTS, + 'draft' => MailboxSpecialUse::DRAFTS, + 'trash' => MailboxSpecialUse::TRASH, + 'deleted' => MailboxSpecialUse::TRASH, + 'deleted messages' => MailboxSpecialUse::TRASH, + 'junk' => MailboxSpecialUse::JUNK, + 'spam' => MailboxSpecialUse::JUNK, + 'spambucket' => MailboxSpecialUse::JUNK, + 'archive' => MailboxSpecialUse::ARCHIVE, ]; if (array_key_exists($name, $nameMap)) { diff --git a/src/Service/Imap/MessageSendService.php b/src/Service/Imap/MessageSendService.php index fcad1f8b..e99fcd9c 100644 --- a/src/Service/Imap/MessageSendService.php +++ b/src/Service/Imap/MessageSendService.php @@ -8,39 +8,46 @@ use App\Domain\Helper\ImapConnectionFactory; use App\Entity\Account; use App\Entity\Message; use App\Repository\MailboxRepository; -use Carbon\Carbon; +use App\Service\Mail\MailSenderRegistry; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; -use Symfony\Component\Mailer\Exception\TransportExceptionInterface; -use Symfony\Component\Mailer\Mailer; -use Symfony\Component\Mailer\Transport; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; +/** + * Builds the outgoing MIME once, then dispatches it to the sender that handles + * the account (Gmail API for Google OAuth, SMTP for password accounts). + */ class MessageSendService { public function __construct( - private readonly MailboxRepository $mailboxRepository, - private readonly EntityManagerInterface $em, + private readonly MailboxRepository $mailboxRepository, + private readonly EntityManagerInterface $em, private readonly AttachmentStorageHelper $attachmentStorage, - ) - { + private readonly MailSenderRegistry $senderRegistry, + private readonly ImapConnectionFactory $imapConnectionFactory, + ) { } public function send(Message $message): bool { $account = $message->getMailbox()->getAccount(); + $email = $this->buildEmail($message, $account); - $email = $this->buildEmail($message, $account); + $sender = $this->senderRegistry->resolve($account); + $sendSuccess = $sender->send($email, $account); - $sendSuccess = $this->sendViaSmtp($email, $account); - $this->appendToSentFolder($email, $account); - - $sentMailbox = $this->mailboxRepository->findSentMailboxForAccount($account); - if(false === $sendSuccess){ + if (false === $sendSuccess) { return false; } + // API senders file their own Sent copy; only append manually for SMTP. + if (false === $sender->filesSentCopy()) { + $this->appendToSentFolder($email, $account); + } + + $sentMailbox = $this->mailboxRepository->findSentMailboxForAccount($account); + if (null !== $sentMailbox) { $message ->setMailbox($sentMailbox) @@ -52,38 +59,18 @@ class MessageSendService return true; } - private function sendViaSmtp(Email $email, Account $account): bool - { - $enc = strtolower($account->getSmtpEncryption() ?? 'tls'); - $dsn = sprintf( - '%s://%s:%s@%s:%d', - $enc === 'ssl' ? 'smtps' : 'smtp', - urlencode($account->getUsername()), - urlencode($account->getPassword()), - $account->getSmtpHost(), - $account->getSmtpPort() ?? 587, - ); - - try { - new Mailer(Transport::fromDsn($dsn))->send($email); - } catch (TransportExceptionInterface $e) { - return false; - } - - return true; - } - private function appendToSentFolder(Email $email, Account $account): void { $sentMailbox = $this->mailboxRepository->findSentMailboxForAccount($account); + if (null === $sentMailbox) { return; } - $client = ImapConnectionFactory::connect($account); + $client = $this->imapConnectionFactory->connect($account); $folder = $client->getFolder($sentMailbox->getName()); - $appendResult = $folder->appendMessage( + $folder->appendMessage( $email->toString(), [MessageFlag::SEEN->value], ); @@ -93,32 +80,59 @@ class MessageSendService private function buildEmail(Message $message, Account $account): Email { - $email = new Email() - ->from(new Address($account->getEmail(), $account->getName() ?? '')) - ->subject($message->getSubject() ?? ''); + $fromName = $account->getName(); + if (null === $fromName) { + $fromName = ''; + } - foreach ($message->getToAddresses() ?? [] as $addr) { - $email->addTo(new Address($addr['address'], $addr['name'] ?? '')); + $subject = $message->getSubject(); + if (null === $subject) { + $subject = ''; } - foreach ($message->getCcAddresses() ?? [] as $addr) { - $email->addCc(new Address($addr['address'], $addr['name'] ?? '')); + + $email = (new Email()) + ->from(new Address($account->getEmail(), $fromName)) + ->subject($subject); + + $toAddresses = $message->getToAddresses(); + if (null !== $toAddresses) { + foreach ($toAddresses as $addr) { + $email->addTo($this->toAddress($addr)); + } } - foreach ($message->getBccAddresses() ?? [] as $addr) { - $email->addBcc(new Address($addr['address'], $addr['name'] ?? '')); + + $ccAddresses = $message->getCcAddresses(); + if (null !== $ccAddresses) { + foreach ($ccAddresses as $addr) { + $email->addCc($this->toAddress($addr)); + } + } + + $bccAddresses = $message->getBccAddresses(); + if (null !== $bccAddresses) { + foreach ($bccAddresses as $addr) { + $email->addBcc($this->toAddress($addr)); + } } if ($message->getBodyHtml()) { $email->html($message->getBodyHtml()); } + if ($message->getBodyText()) { $email->text($message->getBodyText()); } foreach ($message->getMessageParts() as $part) { if (true === $part->isInline()) { + $contentId = $part->getContentId(); + if (null === $contentId) { + $contentId = $part->getFilename(); + } + $email->embedFromPath( $this->attachmentStorage->getAbsolutePath($part->getStoragePath()), - $part->getContentId() ?? $part->getFilename(), + $contentId, $part->getContentType(), ); } else { @@ -132,4 +146,18 @@ class MessageSendService return $email; } + + /** + * @param array{name?: string|null, address: string} $addr + */ + private function toAddress(array $addr): Address + { + $name = ''; + + if (array_key_exists('name', $addr) && null !== $addr['name']) { + $name = $addr['name']; + } + + return new Address($addr['address'], $name); + } } diff --git a/src/Service/Mail/GmailApiSender.php b/src/Service/Mail/GmailApiSender.php new file mode 100644 index 00000000..5e3815c5 --- /dev/null +++ b/src/Service/Mail/GmailApiSender.php @@ -0,0 +1,95 @@ +value !== $account->getAuthType()) { + return false; + } + + if (MailProvider::Google->value !== $account->getOauthProvider()) { + return false; + } + + return true; + } + + public function filesSentCopy(): bool + { + return true; + } + + public function send(Email $email, Account $account): bool + { + $accessToken = $this->tokenManager->getValidAccessToken($account); + $raw = $this->toBase64Url($email->toString()); + + try { + $response = $this->httpClient->request('POST', self::SEND_ENDPOINT, [ + 'auth_bearer' => $accessToken, + 'json' => ['raw' => $raw], + ]); + + $statusCode = $response->getStatusCode(); + + if ($statusCode < 200 || $statusCode >= 300) { + $this->logger->error('GmailApiSender: send failed', [ + 'status' => $statusCode, + 'account' => $account->getId(), + 'body' => $response->getContent(false), + ]); + + return false; + } + } catch (HttpExceptionInterface $e) { + $this->logger->error('GmailApiSender: request error', [ + 'account' => $account->getId(), + 'error' => $e->getMessage(), + ]); + + return false; + } + + return true; + } + + /** + * Gmail requires URL-safe base64 (RFC 4648 §5) without padding. + */ + private function toBase64Url(string $mime): string + { + $base64 = base64_encode($mime); + $urlSafe = strtr($base64, '+/', '-_'); + + return rtrim($urlSafe, '='); + } +} diff --git a/src/Service/Mail/MailSenderRegistry.php b/src/Service/Mail/MailSenderRegistry.php new file mode 100644 index 00000000..84455681 --- /dev/null +++ b/src/Service/Mail/MailSenderRegistry.php @@ -0,0 +1,37 @@ + $senders + */ + public function __construct( + private readonly iterable $senders, + ) { + } + + public function resolve(Account $account): MailSenderInterface + { + foreach ($this->senders as $sender) { + if (true === $sender->supports($account)) { + return $sender; + } + } + + throw new \RuntimeException(sprintf( + 'No mail sender supports account %d.', + (int) $account->getId(), + )); + } +} diff --git a/src/Service/Mail/SmtpMailSender.php b/src/Service/Mail/SmtpMailSender.php new file mode 100644 index 00000000..557ea82b --- /dev/null +++ b/src/Service/Mail/SmtpMailSender.php @@ -0,0 +1,75 @@ +value === $account->getAuthType()) { + return false; + } + + return true; + } + + public function filesSentCopy(): bool + { + return false; + } + + public function send(Email $email, Account $account): bool + { + $rawEncryption = $account->getSmtpEncryption(); + + if (null === $rawEncryption) { + $encryption = 'tls'; + } else { + $encryption = strtolower($rawEncryption); + } + + if ('ssl' === $encryption) { + $scheme = 'smtps'; + } else { + $scheme = 'smtp'; + } + + $port = $account->getSmtpPort(); + + if (null === $port) { + $port = 587; + } + + $dsn = sprintf( + '%s://%s:%s@%s:%d', + $scheme, + urlencode($account->getUsername()), + urlencode($account->getPassword()), + $account->getSmtpHost(), + $port, + ); + + try { + $mailer = new Mailer(Transport::fromDsn($dsn)); + $mailer->send($email); + } catch (TransportExceptionInterface $e) { + return false; + } + + return true; + } +} diff --git a/src/Service/OAuth/OAuthProviderFactory.php b/src/Service/OAuth/OAuthProviderFactory.php new file mode 100644 index 00000000..98f93f85 --- /dev/null +++ b/src/Service/OAuth/OAuthProviderFactory.php @@ -0,0 +1,49 @@ +urlGenerator->generate( + 'app_oauth_callback', + ['provider' => $provider->value], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + if (MailProvider::Google === $provider) { + return new Google([ + 'clientId' => $this->googleClientId, + 'clientSecret' => $this->googleClientSecret, + 'redirectUri' => $redirectUri, + 'accessType' => 'offline', + ]); + } + + throw new \RuntimeException(sprintf( + 'OAuth provider "%s" is not yet implemented.', + $provider->value, + )); + } +} diff --git a/src/Service/OAuth/OAuthTokenManager.php b/src/Service/OAuth/OAuthTokenManager.php new file mode 100644 index 00000000..86ee55df --- /dev/null +++ b/src/Service/OAuth/OAuthTokenManager.php @@ -0,0 +1,127 @@ +isExpiring($account)) { + return $this->refresh($account); + } + + $token = $account->getOauthAccessToken(); + + if (null === $token) { + return $this->refresh($account); + } + + return $token; + } + + private function isExpiring(Account $account): bool + { + $expiry = $account->getOauthTokenExpiry(); + + if (null === $expiry) { + return true; + } + + $threshold = new DateTimeImmutable(sprintf('+%d seconds', self::EXPIRY_BUFFER_SECONDS)); + + if ($expiry <= $threshold) { + return true; + } + + return false; + } + + private function refresh(Account $account): string + { + $refreshToken = $account->getOauthRefreshToken(); + + if (null === $refreshToken) { + throw new \RuntimeException(sprintf( + 'Account %d has no refresh token; the account must be reconnected.', + (int) $account->getId(), + )); + } + + $provider = $this->providerFactory->create($this->providerFor($account)); + + $newToken = $provider->getAccessToken('refresh_token', [ + 'refresh_token' => $refreshToken, + ]); + + $account->setOauthAccessToken($newToken->getToken()); + + $expires = $newToken->getExpires(); + if (null !== $expires) { + $account->setOauthTokenExpiry( + (new DateTimeImmutable())->setTimestamp($expires), + ); + } + + // Providers usually omit a fresh refresh token on refresh — keep the + // existing one unless a new one is explicitly returned. + $returnedRefresh = $newToken->getRefreshToken(); + if (null !== $returnedRefresh) { + $account->setOauthRefreshToken($returnedRefresh); + } + + $account->setUpdatedAt(new DateTimeImmutable()); + + $this->em->flush(); + + return $newToken->getToken(); + } + + private function providerFor(Account $account): MailProvider + { + $providerValue = $account->getOauthProvider(); + + if (null === $providerValue) { + throw new \RuntimeException(sprintf( + 'Account %d has no OAuth provider set.', + (int) $account->getId(), + )); + } + + $provider = MailProvider::tryFrom($providerValue); + + if (null === $provider) { + throw new \RuntimeException(sprintf( + 'Account %d has unknown OAuth provider "%s".', + (int) $account->getId(), + $providerValue, + )); + } + + return $provider; + } +} diff --git a/templates/account/_form_modal.html.twig b/templates/account/_form_modal.html.twig index 52194bac..4cbddbb9 100644 --- a/templates/account/_form_modal.html.twig +++ b/templates/account/_form_modal.html.twig @@ -106,7 +106,8 @@