mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
google oauth
This commit is contained in:
1 parent
57fd87aa08
commit
4dc569e439
23 files changed
+1490
-83
No files matched your search
@@ -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
@@ -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">
|
||||
|
||||
Generated
+9
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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, '=');
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user