google oauth

This commit is contained in:
Paul Lützner committed 2026-07-16 17:46:51 +02:00
1 parent 57fd87aa08
commit 4dc569e439
23 files changed
+1490 -83

No files matched your search

+5
View File
@@ -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 ###
Generated
+9
View File
@@ -167,6 +167,15 @@
<path value="$PROJECT_DIR$/vendor/illuminate/support" />
<path value="$PROJECT_DIR$/vendor/voku/portable-ascii" />
<path value="$PROJECT_DIR$/vendor/symfony/ux-autocomplete" />
<path value="$PROJECT_DIR$/vendor/ralouphie/getallheaders" />
<path value="$PROJECT_DIR$/vendor/psr/http-client" />
<path value="$PROJECT_DIR$/vendor/psr/http-factory" />
<path value="$PROJECT_DIR$/vendor/psr/http-message" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/promises" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/psr7" />
<path value="$PROJECT_DIR$/vendor/guzzlehttp/guzzle" />
<path value="$PROJECT_DIR$/vendor/league/oauth2-client" />
<path value="$PROJECT_DIR$/vendor/league/oauth2-google" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4">
+9
View File
@@ -153,6 +153,15 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/voku/portable-ascii" />
<excludeFolder url="file://$MODULE_DIR$/vendor/webklex/php-imap" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/ux-autocomplete" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/guzzle" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/promises" />
<excludeFolder url="file://$MODULE_DIR$/vendor/guzzlehttp/psr7" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/oauth2-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/league/oauth2-google" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-client" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-factory" />
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/http-message" />
<excludeFolder url="file://$MODULE_DIR$/vendor/ralouphie/getallheaders" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
+2
View File
@@ -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.*",
Generated
+656 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "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",
+14
View File
@@ -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
+2 -1
View File
@@ -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) {
+5 -2
View File
@@ -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());
+2 -1
View File
@@ -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;
+169
View File
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Domain\Enum\MailProvider;
use App\Entity\Account;
use App\Entity\User;
use App\Enum\AuthType;
use App\Repository\AccountRepository;
use App\Service\OAuth\OAuthProviderFactory;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessTokenInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_USER')]
#[Route('/oauth', name: 'app_oauth_')]
class OAuthController extends AbstractController
{
private const SESSION_STATE_KEY = 'oauth2_state';
public function __construct(
private readonly OAuthProviderFactory $providerFactory,
private readonly AccountRepository $accountRepository,
private readonly EntityManagerInterface $em,
) {
}
#[Route('/{provider}/connect', name: 'connect', methods: ['GET'])]
public function connect(string $provider, Request $request): RedirectResponse
{
$mailProvider = $this->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<string, mixed> $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;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Domain\Enum;
/**
* A cloud mail provider we connect to over OAuth2.
*
* Holds only the *non-flow* provider differences (scopes, mail hosts,
* authorization-url options). The OAuth flow itself is provider-agnostic and
* lives in the OAuth service layer.
*/
enum MailProvider: string
{
case Google = 'google';
case Microsoft = 'microsoft';
/**
* OAuth scopes requested at consent time.
*
* @return string[]
*/
public function scopes(): array
{
return match ($this) {
MailProvider::Google => [
'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<string, string>
*/
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';
}
}
+34 -14
View File
@@ -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;
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Domain\Interface;
use App\Entity\Account;
use Symfony\Component\Mime\Email;
interface MailSenderInterface
{
/**
* Whether this sender handles the given account.
*/
public function supports(Account $account): bool;
/**
* Send a fully built MIME email. Returns false on a handled failure.
*/
public function send(Email $email, Account $account): bool;
/**
* Whether this sender files the Sent copy itself (true for provider APIs),
* so the caller should skip the manual IMAP append.
*/
public function filesSentCopy(): bool;
}
+2 -1
View File
@@ -24,6 +24,7 @@ final class ApplyImapFlagsHandler
private readonly MessageRepository $messageRepository,
private readonly MailboxRepository $mailboxRepository,
private readonly LoggerInterface $logger,
private readonly ImapConnectionFactory $imapConnectionFactory,
) {}
public function __invoke(ApplyImapFlagsMessage $message): void
@@ -53,7 +54,7 @@ final class ApplyImapFlagsHandler
try {
$client = ImapConnectionFactory::connect($account);
$client = $this->imapConnectionFactory->connect($account);
$this->processAccount($client, $account, $byMailbox, $message->action);
$client->disconnect();
} catch (Throwable $e) {
@@ -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();
+16 -14
View File
@@ -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)) {
+75 -47
View File
@@ -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);
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Enum\MailProvider;
use App\Domain\Interface\MailSenderInterface;
use App\Entity\Account;
use App\Enum\AuthType;
use App\Service\OAuth\OAuthTokenManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
/**
* Future-proof send path for Google accounts: POST the RFC822 message to the
* Gmail API. Covered by the https://mail.google.com/ scope we already hold, and
* Gmail files the Sent copy itself — no SMTP, no manual IMAP append.
*/
class GmailApiSender implements MailSenderInterface
{
private const SEND_ENDPOINT = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send';
public function __construct(
private readonly OAuthTokenManager $tokenManager,
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
) {
}
public function supports(Account $account): bool
{
if (AuthType::OAuth2->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, '=');
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Interface\MailSenderInterface;
use App\Entity\Account;
/**
* Picks the sender for an account. Senders are injected highest-priority first
* (API senders before SMTP), so the first that supports the account wins.
*/
class MailSenderRegistry
{
/**
* @param iterable<MailSenderInterface> $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(),
));
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Interface\MailSenderInterface;
use App\Entity\Account;
use App\Enum\AuthType;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
/**
* SMTP sending for generic password accounts. OAuth accounts are handled by a
* provider-API sender instead, so this deliberately declines them.
*/
class SmtpMailSender implements MailSenderInterface
{
public function supports(Account $account): bool
{
if (AuthType::OAuth2->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;
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Service\OAuth;
use App\Domain\Enum\MailProvider;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\Google;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
/**
* Builds a configured league OAuth2 provider for a given MailProvider.
*
* This is the only place that knows about concrete league provider classes.
* Adding Microsoft later = one more branch here + its provider package.
*/
class OAuthProviderFactory
{
public function __construct(
private readonly UrlGeneratorInterface $urlGenerator,
private readonly string $googleClientId,
private readonly string $googleClientSecret,
) {
}
public function create(MailProvider $provider): AbstractProvider
{
$redirectUri = $this->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,
));
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace App\Service\OAuth;
use App\Domain\Enum\MailProvider;
use App\Entity\Account;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
/**
* Single source of a valid access token for an OAuth account.
*
* Both the IMAP factory (reading) and the Gmail API sender (sending) call
* getValidAccessToken() and never touch the refresh flow themselves.
*/
class OAuthTokenManager
{
/**
* Refresh this many seconds before the token actually expires, so a token
* that is valid at check-time is still valid when the request lands.
*/
private const EXPIRY_BUFFER_SECONDS = 120;
public function __construct(
private readonly OAuthProviderFactory $providerFactory,
private readonly EntityManagerInterface $em,
) {
}
public function getValidAccessToken(Account $account): string
{
if (true === $this->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;
}
}
+2 -1
View File
@@ -106,7 +106,8 @@
<div class="space-y-2.5">
<a
href="#"
href="{{ path('app_oauth_connect', { provider: 'google' }) }}"
data-turbo="false"
class="flex items-center gap-3 mb-3 w-full px-4 py-2.5 rounded-xl
border border-white/15 dark:border-white/10
bg-white/10 dark:bg-white/5