generated from Nodarx/template
Compare commits
33 Commits
8093c031d9
...
898626491e
| Author | SHA1 | Date | |
|---|---|---|---|
| 898626491e | |||
| 67a0dbbee7 | |||
| ad6b7c5eba | |||
| e031831719 | |||
| d94b1ea211 | |||
| 3dd9c2f983 | |||
| c8f0f8328b | |||
| db45636bd4 | |||
| 5b05f76928 | |||
| 624f7a9690 | |||
| fc47b6abd0 | |||
| b87265adac | |||
| f022af4b1d | |||
| 7cbfb3d8c2 | |||
| a0347225cb | |||
| a3d9bb0aed | |||
| 695bdfc1b8 | |||
| fbc9dab0b0 | |||
| 15d27fd72f | |||
| cfc8c92101 | |||
| 7df98223f8 | |||
| 37d88d992d | |||
| 0b1a566e8a | |||
| 3163e7f857 | |||
| 7813a5a8b2 | |||
| 60cfefcfee | |||
| 2647a55964 | |||
| 186263c62a | |||
| 9cdebd82b8 | |||
| 640e3aa811 | |||
| 55dcd8a3f1 | |||
| b9e811e9d5 | |||
| 25723fd246 |
+4
-2
@@ -20,10 +20,12 @@
|
|||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-ctype": "*",
|
"ext-ctype": "*",
|
||||||
"psr/log": "^1.0|^2.0|^3.0",
|
"psr/log": "^1.0|^2.0|^3.0",
|
||||||
"doctrine/lexer": "^3.0"
|
"doctrine/lexer": "^3.0",
|
||||||
|
"symfony/mime": "^7.0",
|
||||||
|
"egulias/email-validator": "^4.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.0"
|
"phpunit/phpunit": "^13.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
Generated
+488
-4
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "46ea36f63cc6f77b99348c76fc0f72b2",
|
"content-hash": "63573b43a17b583b8766d74720f367a3",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "doctrine/lexer",
|
"name": "doctrine/lexer",
|
||||||
@@ -83,6 +83,73 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-05T11:56:58+00:00"
|
"time": "2024-02-05T11:56:58+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "egulias/email-validator",
|
||||||
|
"version": "4.0.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/egulias/EmailValidator.git",
|
||||||
|
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
|
||||||
|
"reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"doctrine/lexer": "^2.0 || ^3.0",
|
||||||
|
"php": ">=8.1",
|
||||||
|
"symfony/polyfill-intl-idn": "^1.26"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^10.2",
|
||||||
|
"vimeo/psalm": "^5.12"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "4.0.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Egulias\\EmailValidator\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Eduardo Gulias Davis"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A library for validating emails against several RFCs",
|
||||||
|
"homepage": "https://github.com/egulias/EmailValidator",
|
||||||
|
"keywords": [
|
||||||
|
"email",
|
||||||
|
"emailvalidation",
|
||||||
|
"emailvalidator",
|
||||||
|
"validation",
|
||||||
|
"validator"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/egulias/EmailValidator/issues",
|
||||||
|
"source": "https://github.com/egulias/EmailValidator/tree/4.0.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/egulias",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-03-06T22:45:56+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "psr/log",
|
"name": "psr/log",
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
@@ -132,6 +199,423 @@
|
|||||||
"source": "https://github.com/php-fig/log/tree/3.0.2"
|
"source": "https://github.com/php-fig/log/tree/3.0.2"
|
||||||
},
|
},
|
||||||
"time": "2024-09-11T13:17:53+00:00"
|
"time": "2024-09-11T13:17:53+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/deprecation-contracts",
|
||||||
|
"version": "v3.7.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/deprecation-contracts.git",
|
||||||
|
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
|
||||||
|
"reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/contracts",
|
||||||
|
"name": "symfony/contracts"
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.7-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"function.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "A generic function and convention to trigger deprecation notices",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-13T15:52:40+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/mime",
|
||||||
|
"version": "v7.4.13",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/mime.git",
|
||||||
|
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770",
|
||||||
|
"reference": "a845722765c4f6b2ce88beaf4f4479975b186770",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"symfony/deprecation-contracts": "^2.5|^3",
|
||||||
|
"symfony/polyfill-intl-idn": "^1.10",
|
||||||
|
"symfony/polyfill-mbstring": "^1.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"egulias/email-validator": "~3.0.0",
|
||||||
|
"phpdocumentor/reflection-docblock": "<5.2|>=7",
|
||||||
|
"phpdocumentor/type-resolver": "<1.5.1",
|
||||||
|
"symfony/mailer": "<6.4",
|
||||||
|
"symfony/serializer": "<6.4.3|>7.0,<7.0.3"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"egulias/email-validator": "^2.1.10|^3.1|^4",
|
||||||
|
"league/html-to-markdown": "^5.0",
|
||||||
|
"phpdocumentor/reflection-docblock": "^5.2|^6.0",
|
||||||
|
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
|
||||||
|
"symfony/process": "^6.4|^7.0|^8.0",
|
||||||
|
"symfony/property-access": "^6.4|^7.0|^8.0",
|
||||||
|
"symfony/property-info": "^6.4|^7.0|^8.0",
|
||||||
|
"symfony/serializer": "^6.4.3|^7.0.3|^8.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Component\\Mime\\": ""
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Fabien Potencier",
|
||||||
|
"email": "fabien@symfony.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Allows manipulating MIME messages",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"mime",
|
||||||
|
"mime-type"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/mime/tree/v7.4.13"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-23T16:22:37+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-intl-idn",
|
||||||
|
"version": "v1.38.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-intl-idn.git",
|
||||||
|
"reference": "dc21118016c039a66235cf93d96b435ffb282412"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
|
||||||
|
"reference": "dc21118016c039a66235cf93d96b435ffb282412",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2",
|
||||||
|
"symfony/polyfill-intl-normalizer": "^1.10"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-intl": "For best performance"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Polyfill\\Intl\\Idn\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Laurent Bassin",
|
||||||
|
"email": "laurent@bassin.info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Trevor Rowbotham",
|
||||||
|
"email": "trevor.rowbotham@pm.me"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"idn",
|
||||||
|
"intl",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-25T15:22:23+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-intl-normalizer",
|
||||||
|
"version": "v1.38.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
|
||||||
|
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
|
||||||
|
"reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-intl": "For best performance"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Polyfill\\Intl\\Normalizer\\": ""
|
||||||
|
},
|
||||||
|
"classmap": [
|
||||||
|
"Resources/stubs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill for intl's Normalizer class and related functions",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"intl",
|
||||||
|
"normalizer",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-25T13:48:31+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-mbstring",
|
||||||
|
"version": "v1.38.2",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-mbstring.git",
|
||||||
|
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||||
|
"reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"php": ">=7.2"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"ext-mbstring": "*"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-mbstring": "For best performance"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Polyfill\\Mbstring\\": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill for the Mbstring extension",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"mbstring",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-27T06:59:30+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
@@ -1919,7 +2403,7 @@
|
|||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": [],
|
"stability-flags": {},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
@@ -1927,9 +2411,9 @@
|
|||||||
"ext-iconv": "*",
|
"ext-iconv": "*",
|
||||||
"ext-ctype": "*"
|
"ext-ctype": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"platform-overrides": {
|
"platform-overrides": {
|
||||||
"php": "8.2"
|
"php": "8.2"
|
||||||
},
|
},
|
||||||
"plugin-api-version": "2.3.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -6,8 +6,8 @@ namespace KTXM\ProviderImap\Client;
|
|||||||
|
|
||||||
use KTXM\ProviderImap\Client\Command\CapabilityCommand;
|
use KTXM\ProviderImap\Client\Command\CapabilityCommand;
|
||||||
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||||
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\Command\LoginCommand;
|
use KTXM\ProviderImap\Client\Command\LoginCommand;
|
||||||
use KTXM\ProviderImap\Client\Command\StatusCommand;
|
|
||||||
use KTXM\ProviderImap\Client\Command\StartTlsCommand;
|
use KTXM\ProviderImap\Client\Command\StartTlsCommand;
|
||||||
use KTXM\ProviderImap\Client\Protocol\CommandExecutor;
|
use KTXM\ProviderImap\Client\Protocol\CommandExecutor;
|
||||||
use KTXM\ProviderImap\Client\Protocol\ProtocolReader;
|
use KTXM\ProviderImap\Client\Protocol\ProtocolReader;
|
||||||
@@ -87,6 +87,15 @@ final class Client implements ClientInterface
|
|||||||
return $this->executor->perform($command, $this->session);
|
return $this->executor->perform($command, $this->session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function download(FetchTarget $target, string $section, int $chunkSize = 8192): \Generator
|
||||||
|
{
|
||||||
|
if ($this->session === null || $this->executor === null) {
|
||||||
|
throw new ImapException('IMAP client is not connected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->executor->download($target, $section, $chunkSize, $this->session);
|
||||||
|
}
|
||||||
|
|
||||||
public function session(): SessionContext
|
public function session(): SessionContext
|
||||||
{
|
{
|
||||||
if ($this->session === null) {
|
if ($this->session === null) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace KTXM\ProviderImap\Client;
|
namespace KTXM\ProviderImap\Client;
|
||||||
|
|
||||||
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||||
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
|
|
||||||
interface ClientInterface
|
interface ClientInterface
|
||||||
{
|
{
|
||||||
@@ -21,4 +22,11 @@ interface ClientInterface
|
|||||||
* @return TResult
|
* @return TResult
|
||||||
*/
|
*/
|
||||||
public function perform(CommandInterface $command): mixed;
|
public function perform(CommandInterface $command): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the raw bytes of a single IMAP BODY section without buffering.
|
||||||
|
*
|
||||||
|
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
|
||||||
|
*/
|
||||||
|
public function download(FetchTarget $target, string $section, int $chunkSize = 8192): \Generator;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Client\Command;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\ResponseStream;
|
||||||
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APPEND a raw RFC822 message to a mailbox (RFC 3501 §6.3.11).
|
||||||
|
*
|
||||||
|
* Uses a synchronizing literal: the command line ends with `{<len>}`, the
|
||||||
|
* server replies with a continuation request ("+"), and only then is the
|
||||||
|
* message streamed back via {@see ResponseStream::respond()}. Returns the
|
||||||
|
* assigned UID when the server reports APPENDUID (RFC 4315 / UIDPLUS),
|
||||||
|
* otherwise null.
|
||||||
|
*
|
||||||
|
* @implements CommandInterface<int|null>
|
||||||
|
*/
|
||||||
|
final class AppendCommand implements CommandInterface
|
||||||
|
{
|
||||||
|
private readonly string $literal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $flags optional initial flags, e.g. ['\\Seen']
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $mailbox,
|
||||||
|
string $message,
|
||||||
|
private readonly array $flags = [],
|
||||||
|
) {
|
||||||
|
// IMAP literals are octet-counted; normalise to CRLF line endings.
|
||||||
|
$this->literal = (string) preg_replace('/\r\n|\r|\n/', "\r\n", $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'APPEND';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowedStates(): array
|
||||||
|
{
|
||||||
|
return [SessionState::Authenticated, SessionState::Selected];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function encode(string $tag, SessionContext $context): RequestFrame
|
||||||
|
{
|
||||||
|
unset($tag, $context);
|
||||||
|
|
||||||
|
$flagSegment = $this->flags === [] ? '' : '(' . implode(' ', $this->flags) . ') ';
|
||||||
|
|
||||||
|
return new RequestFrame(sprintf(
|
||||||
|
'APPEND %s %s{%d}',
|
||||||
|
$this->quote($this->mailbox),
|
||||||
|
$flagSegment,
|
||||||
|
strlen($this->literal),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(ResponseStream $responses, SessionContext $context): ?int
|
||||||
|
{
|
||||||
|
unset($context);
|
||||||
|
|
||||||
|
foreach ($responses as $response) {
|
||||||
|
if ($response instanceof ContinuationResponse) {
|
||||||
|
// Continuation granted: stream the literal, then CRLF to end the command.
|
||||||
|
$responses->respond($this->literal . "\r\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response instanceof TaggedResponse) {
|
||||||
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('APPEND failed: ' . $response->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseAppendUid($response->text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ImapException('APPEND did not receive a tagged completion response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function quote(string $mailbox): string
|
||||||
|
{
|
||||||
|
return '"' . addcslashes($mailbox, "\\\"") . '"';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseAppendUid(string $text): ?int
|
||||||
|
{
|
||||||
|
if (preg_match('/\[APPENDUID\s+\d+\s+(\d+)\]/i', $text, $matches) === 1) {
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,11 @@ final class FetchOptions
|
|||||||
return $this->with('BODY[TEXT]');
|
return $this->with('BODY[TEXT]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function withBody(): self
|
||||||
|
{
|
||||||
|
return $this->with('BODY[]');
|
||||||
|
}
|
||||||
|
|
||||||
public function withBodySection(string $section): self
|
public function withBodySection(string $section): self
|
||||||
{
|
{
|
||||||
$section = strtoupper(trim($section));
|
$section = strtoupper(trim($section));
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ final class Message
|
|||||||
return $this->bodySections;
|
return $this->bodySections;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function bodyRaw(): ?string
|
||||||
|
{
|
||||||
|
return $this->bodySections[''] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $bodySections
|
* @param array<string, string> $bodySections
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -524,9 +524,6 @@ final class MessageParser
|
|||||||
}
|
}
|
||||||
|
|
||||||
$section = strtoupper(trim($matches[1]));
|
$section = strtoupper(trim($matches[1]));
|
||||||
if ($section === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/^(\d+(?:\.\d+)*)\.TEXT$/', $section, $partMatches) === 1) {
|
if (preg_match('/^(\d+(?:\.\d+)*)\.TEXT$/', $section, $partMatches) === 1) {
|
||||||
$section = $partMatches[1];
|
$section = $partMatches[1];
|
||||||
|
|||||||
@@ -167,8 +167,9 @@ final class MessagePart
|
|||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
'partId' => $this->partId,
|
'partId' => $this->partId,
|
||||||
|
'blobId' => $this->partId,
|
||||||
|
'cid' => $this->contentId,
|
||||||
'type' => $this->mimeType,
|
'type' => $this->mimeType,
|
||||||
'blobId' => $this->contentId,
|
|
||||||
'charset' => $this->parameters['charset'] ?? null,
|
'charset' => $this->parameters['charset'] ?? null,
|
||||||
'name' => $this->parameters['name'] ?? $this->dispositionParameters['filename'] ?? null,
|
'name' => $this->parameters['name'] ?? $this->dispositionParameters['filename'] ?? null,
|
||||||
'encoding' => $this->encoding,
|
'encoding' => $this->encoding,
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ namespace KTXM\ProviderImap\Client\Protocol;
|
|||||||
|
|
||||||
use Generator;
|
use Generator;
|
||||||
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
use KTXM\ProviderImap\Client\Command\CommandInterface;
|
||||||
|
use KTXM\ProviderImap\Client\FetchTarget;
|
||||||
use KTXM\ProviderImap\Client\ImapException;
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\TaggedResponse;
|
||||||
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
use KTXM\ProviderImap\Client\Protocol\Response\UntaggedResponse;
|
||||||
|
use KTXM\ProviderImap\Client\Protocol\RequestFrame;
|
||||||
use KTXM\ProviderImap\Client\SessionContext;
|
use KTXM\ProviderImap\Client\SessionContext;
|
||||||
use KTXM\ProviderImap\Client\SessionState;
|
use KTXM\ProviderImap\Client\SessionState;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -40,9 +42,46 @@ final class CommandExecutor
|
|||||||
$frame = $command->encode($tag, $context);
|
$frame = $command->encode($tag, $context);
|
||||||
$this->writer->write($tag, $frame);
|
$this->writer->write($tag, $frame);
|
||||||
|
|
||||||
return $command->handle(new ResponseStream(function () use ($tag, $context): Generator {
|
return $command->handle(new ResponseStream(
|
||||||
yield from $this->responsesUntilCompletion($tag, $context);
|
function () use ($tag, $context): Generator {
|
||||||
}), $context);
|
yield from $this->processPerform($tag, $context);
|
||||||
|
},
|
||||||
|
function (string $payload): void {
|
||||||
|
$this->writer->writeRaw($payload);
|
||||||
|
},
|
||||||
|
), $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the raw bytes of a single IMAP BODY section without buffering.
|
||||||
|
*
|
||||||
|
* Sends a UID FETCH for the given section and yields the literal bytes
|
||||||
|
* directly from the socket in chunks, never assembling a full string.
|
||||||
|
* The caller MUST fully exhaust the returned Generator before issuing
|
||||||
|
* any further IMAP commands.
|
||||||
|
*
|
||||||
|
* @return \Generator<string> raw (transfer-encoded) bytes from the socket
|
||||||
|
*/
|
||||||
|
public function download(FetchTarget $target, string $section, int $chunkSize, SessionContext $context): \Generator
|
||||||
|
{
|
||||||
|
$this->assertState([SessionState::Selected], $context->state(), 'FETCH (download)');
|
||||||
|
|
||||||
|
$tag = $this->tags->next();
|
||||||
|
$this->writer->write($tag, new RequestFrame(sprintf(
|
||||||
|
'UID FETCH %s (UID BODY[%s])',
|
||||||
|
$target->sequenceSet()->toCommand(),
|
||||||
|
$section,
|
||||||
|
)));
|
||||||
|
|
||||||
|
$result = $this->reader->readUntilFetchLiteral($tag);
|
||||||
|
|
||||||
|
if ($result === null) {
|
||||||
|
return; // UID not found or empty FETCH result
|
||||||
|
}
|
||||||
|
|
||||||
|
yield from $this->reader->streamLiteral($result['literalLength'], $chunkSize);
|
||||||
|
|
||||||
|
$this->reader->readToEnd($tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,7 +102,7 @@ final class CommandExecutor
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function responsesUntilCompletion(string $tag, SessionContext $context): Generator
|
private function processPerform(string $tag, SessionContext $context): Generator
|
||||||
{
|
{
|
||||||
while (true) {
|
while (true) {
|
||||||
$response = $this->reader->readResponse();
|
$response = $this->reader->readResponse();
|
||||||
|
|||||||
@@ -42,7 +42,14 @@ final class ProtocolReader
|
|||||||
|
|
||||||
public function readResponse(): ResponseInterface
|
public function readResponse(): ResponseInterface
|
||||||
{
|
{
|
||||||
$raw = $this->readRawResponse();
|
$raw = $this->connection->readLine();
|
||||||
|
|
||||||
|
while (($literalLength = $this->trailingLiteralLength($raw)) !== null) {
|
||||||
|
$raw .= $this->connection->readBytes($literalLength);
|
||||||
|
$raw .= $this->connection->readLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = $this->trimTrailingLineEnding($raw);
|
||||||
|
|
||||||
if ($raw === '') {
|
if ($raw === '') {
|
||||||
throw new ImapException('Received empty IMAP response line.');
|
throw new ImapException('Received empty IMAP response line.');
|
||||||
@@ -85,16 +92,72 @@ final class ProtocolReader
|
|||||||
return new TaggedResponse($parts[0], $status, $parts[2] ?? '', $raw);
|
return new TaggedResponse($parts[0], $status, $parts[2] ?? '', $raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function readRawResponse(): string
|
/**
|
||||||
|
* Read responses until an untagged FETCH response containing a literal marker is found,
|
||||||
|
* returning the literal byte count WITHOUT consuming the literal bytes from the socket.
|
||||||
|
* Returns null if the tagged OK/NO/BAD for $tag arrives before any literal is detected.
|
||||||
|
*
|
||||||
|
* ⚠️ After a non-null return the literal bytes MUST be consumed (via streamLiteral())
|
||||||
|
* before any further reads are made on this reader.
|
||||||
|
*
|
||||||
|
* @return array{literalLength: int, prefixLine: string}|null
|
||||||
|
*/
|
||||||
|
public function readUntilFetchLiteral(string $tag): ?array
|
||||||
{
|
{
|
||||||
$raw = $this->connection->readLine();
|
while (true) {
|
||||||
|
$line = $this->connection->readLine();
|
||||||
|
$trimmed = $this->trimTrailingLineEnding($line);
|
||||||
|
|
||||||
while (($literalLength = $this->trailingLiteralLength($raw)) !== null) {
|
// Literal marker at the end of the line — stop before consuming the bytes
|
||||||
$raw .= $this->connection->readBytes($literalLength);
|
$literalLength = $this->trailingLiteralLength($line);
|
||||||
$raw .= $this->connection->readLine();
|
if ($literalLength !== null) {
|
||||||
|
return ['literalLength' => $literalLength, 'prefixLine' => $trimmed];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagged completion for our command
|
||||||
|
if (str_starts_with($trimmed, $tag . ' ')) {
|
||||||
|
$parts = preg_split('/\s+/', $trimmed, 3) ?: [];
|
||||||
|
$status = strtoupper($parts[1] ?? '');
|
||||||
|
if ($status === 'NO' || $status === 'BAD') {
|
||||||
|
throw new ImapException(sprintf('FETCH command failed: %s', $trimmed));
|
||||||
|
}
|
||||||
|
return null; // Tagged OK without ever finding a literal → UID not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other untagged response — discard and continue
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $this->trimTrailingLineEnding($raw);
|
public function readToEnd(string $tag): TaggedResponse
|
||||||
|
{
|
||||||
|
while (true) {
|
||||||
|
$response = $this->readResponse();
|
||||||
|
|
||||||
|
if ($response instanceof TaggedResponse && $response->tag() === $tag) {
|
||||||
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException(sprintf('FETCH failed: %s', $response->text()));
|
||||||
|
}
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yield the literal bytes already waiting in the socket as chunks.
|
||||||
|
* After the generator is fully exhausted this method reads the one trailing
|
||||||
|
* line that closes the FETCH parenthesised response (e.g. ")\r\n").
|
||||||
|
*
|
||||||
|
* Contract: the caller MUST exhaust this generator before issuing any further
|
||||||
|
* reads on this reader.
|
||||||
|
*
|
||||||
|
* @return \Generator<string>
|
||||||
|
*/
|
||||||
|
public function streamLiteral(int $length, int $chunkSize = 8192): \Generator
|
||||||
|
{
|
||||||
|
yield from $this->connection->readBytesChunked($length, $chunkSize);
|
||||||
|
|
||||||
|
// Consume the closing portion of the FETCH parenthesised list (e.g. ")\r\n")
|
||||||
|
$this->connection->readLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function trailingLiteralLength(string $raw): ?int
|
private function trailingLiteralLength(string $raw): ?int
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ final class ProtocolWriter
|
|||||||
$this->connection->write($wire);
|
$this->connection->write($wire);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write raw bytes to the connection without tagging or logging the payload.
|
||||||
|
*
|
||||||
|
* Used to send literal data (e.g. an APPEND message body) after the server
|
||||||
|
* has issued a command continuation request.
|
||||||
|
*/
|
||||||
|
public function writeRaw(string $payload): void
|
||||||
|
{
|
||||||
|
$this->logger?->debug('IMAP literal sent (bytes={bytes})', [
|
||||||
|
'bytes' => strlen($payload),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->connection->write($payload);
|
||||||
|
}
|
||||||
|
|
||||||
private function sanitizeWire(string $wire): string
|
private function sanitizeWire(string $wire): string
|
||||||
{
|
{
|
||||||
$trimmed = rtrim($wire, "\r\n");
|
$trimmed = rtrim($wire, "\r\n");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace KTXM\ProviderImap\Client\Protocol;
|
|||||||
|
|
||||||
use Generator;
|
use Generator;
|
||||||
use IteratorAggregate;
|
use IteratorAggregate;
|
||||||
|
use KTXM\ProviderImap\Client\ImapException;
|
||||||
use Traversable;
|
use Traversable;
|
||||||
|
|
||||||
final class ResponseStream implements IteratorAggregate
|
final class ResponseStream implements IteratorAggregate
|
||||||
@@ -13,16 +14,39 @@ final class ResponseStream implements IteratorAggregate
|
|||||||
/** @var \Closure():Generator */
|
/** @var \Closure():Generator */
|
||||||
private readonly \Closure $generatorFactory;
|
private readonly \Closure $generatorFactory;
|
||||||
|
|
||||||
|
/** @var (\Closure(string):void)|null */
|
||||||
|
private readonly ?\Closure $continuationWriter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param \Closure():Generator $generatorFactory
|
* @param \Closure():Generator $generatorFactory
|
||||||
|
* @param (\Closure(string):void)|null $continuationWriter writes raw bytes
|
||||||
|
* back to the server in response to a command continuation request
|
||||||
*/
|
*/
|
||||||
public function __construct(\Closure $generatorFactory)
|
public function __construct(\Closure $generatorFactory, ?\Closure $continuationWriter = null)
|
||||||
{
|
{
|
||||||
$this->generatorFactory = $generatorFactory;
|
$this->generatorFactory = $generatorFactory;
|
||||||
|
$this->continuationWriter = $continuationWriter;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getIterator(): Traversable
|
public function getIterator(): Traversable
|
||||||
{
|
{
|
||||||
return ($this->generatorFactory)();
|
return ($this->generatorFactory)();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send raw bytes to the server after a command continuation request (a "+"
|
||||||
|
* response) — e.g. the literal payload of an APPEND.
|
||||||
|
*
|
||||||
|
* Must only be called while iterating this stream, in response to a
|
||||||
|
* {@see \KTXM\ProviderImap\Client\Protocol\Response\ContinuationResponse};
|
||||||
|
* the next pulled response then reflects the server's reaction to the data.
|
||||||
|
*/
|
||||||
|
public function respond(string $payload): void
|
||||||
|
{
|
||||||
|
if ($this->continuationWriter === null) {
|
||||||
|
throw new ImapException('This response stream does not support continuation replies.');
|
||||||
|
}
|
||||||
|
|
||||||
|
($this->continuationWriter)($payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,5 +20,13 @@ interface ConnectionInterface
|
|||||||
|
|
||||||
public function readBytes(int $length): string;
|
public function readBytes(int $length): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yield the literal payload in chunks without buffering the full content.
|
||||||
|
* Reads exactly $length bytes from the socket, never crossing the literal boundary.
|
||||||
|
*
|
||||||
|
* @return \Generator<string>
|
||||||
|
*/
|
||||||
|
public function readBytesChunked(int $length, int $chunkSize = 8192): \Generator;
|
||||||
|
|
||||||
public function upgradeToTls(): void;
|
public function upgradeToTls(): void;
|
||||||
}
|
}
|
||||||
@@ -135,6 +135,26 @@ final class SocketConnection implements ConnectionInterface
|
|||||||
return $buffer;
|
return $buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function readBytesChunked(int $length, int $chunkSize = 8192): \Generator
|
||||||
|
{
|
||||||
|
if ($length < 0) {
|
||||||
|
throw new ImapException('IMAP socket cannot read a negative number of bytes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining = $length;
|
||||||
|
|
||||||
|
while ($remaining > 0) {
|
||||||
|
$chunk = fread($this->stream(), min($chunkSize, $remaining));
|
||||||
|
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
throw new ImapException('Failed to read literal payload from IMAP socket.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$remaining -= strlen($chunk);
|
||||||
|
yield $chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function upgradeToTls(): void
|
public function upgradeToTls(): void
|
||||||
{
|
{
|
||||||
$stream = $this->stream();
|
$stream = $this->stream();
|
||||||
|
|||||||
@@ -150,12 +150,11 @@ class ConnectCommand extends Command
|
|||||||
// ── Build service object ────────────────────────────────────────────
|
// ── Build service object ────────────────────────────────────────────
|
||||||
|
|
||||||
$location = new ServiceLocation(
|
$location = new ServiceLocation(
|
||||||
host: $host,
|
inboundHost: $host,
|
||||||
port: $port > 0 ? $port : 993,
|
inboundPort: $port > 0 ? $port : 993,
|
||||||
encryption: $encryption,
|
inboundEncryption: $encryption,
|
||||||
verifyPeer: !$noVerify,
|
inboundVerifyPeer: !$noVerify,
|
||||||
verifyPeerName: !$noVerify,
|
inboundVerifyHost: !$noVerify,
|
||||||
allowSelfSigned: $noVerify,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$identity = (new ServiceIdentityBasic())->jsonDeserialize([
|
$identity = (new ServiceIdentityBasic())->jsonDeserialize([
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class DisconnectCommand extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$label = $service->getLabel() ?? $serviceId;
|
$label = $service->getLabel() ?? $serviceId;
|
||||||
$host = $service->getLocation()?->getHost() ?? 'unknown';
|
$host = $service->getLocation()?->getInboundHost() ?? 'unknown';
|
||||||
|
|
||||||
$io->title('Disconnect IMAP Service');
|
$io->title('Disconnect IMAP Service');
|
||||||
$io->definitionList(
|
$io->definitionList(
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use KTXM\ProviderImap\Providers\Service;
|
|||||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
||||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
use KTXM\ProviderImap\Providers\ServiceLocation;
|
||||||
use KTXM\ProviderImap\Service\Discovery;
|
use KTXM\ProviderImap\Service\Discovery;
|
||||||
use KTXM\ProviderImap\Service\Remote\RemoteService;
|
|
||||||
use KTXC\SessionTenant;
|
use KTXC\SessionTenant;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
@@ -151,7 +150,7 @@ class DiscoverCommand extends Command
|
|||||||
/** @var array<string, ServiceLocation> $choiceMap */
|
/** @var array<string, ServiceLocation> $choiceMap */
|
||||||
$choiceMap = [];
|
$choiceMap = [];
|
||||||
foreach ($candidates as $c) {
|
foreach ($candidates as $c) {
|
||||||
$label = sprintf('%s : %d [%s]', $c->getHost(), $c->getPort(), $encLabel($c->getEncryption()));
|
$label = sprintf('%s : %d [%s]', $c->getInboundHost(), $c->getInboundPort(), $encLabel($c->getInboundEncryption()));
|
||||||
$choiceMap[$label] = $c;
|
$choiceMap[$label] = $c;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,9 +169,9 @@ class DiscoverCommand extends Command
|
|||||||
|
|
||||||
$io->success('Server selected:');
|
$io->success('Server selected:');
|
||||||
$io->definitionList(
|
$io->definitionList(
|
||||||
['Host' => $location->getHost()],
|
['Host' => $location->getInboundHost()],
|
||||||
['Port' => (string) $location->getPort()],
|
['Port' => (string) $location->getInboundPort()],
|
||||||
['Encryption' => $encLabel($location->getEncryption())],
|
['Encryption' => $encLabel($location->getInboundEncryption())],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$save) {
|
if (!$save) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use KTXM\ProviderImap\Client\Message;
|
|||||||
use KTXM\ProviderImap\Client\SequenceSet;
|
use KTXM\ProviderImap\Client\SequenceSet;
|
||||||
use KTXM\ProviderImap\Providers\Provider;
|
use KTXM\ProviderImap\Providers\Provider;
|
||||||
use KTXM\ProviderImap\Providers\Service;
|
use KTXM\ProviderImap\Providers\Service;
|
||||||
use KTXM\ProviderImap\Service\Remote\RemoteService;
|
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||||
use Symfony\Component\Console\Attribute\AsCommand;
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
@@ -119,8 +119,7 @@ class TestCommand extends Command
|
|||||||
$startedAt = microtime(true);
|
$startedAt = microtime(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$client = RemoteService::freshClient($service);
|
$mailService = new RemoteMailService($service);
|
||||||
$mailService = RemoteService::mailService($service, $client);
|
|
||||||
$mailboxes = $mailService->collectionList();
|
$mailboxes = $mailService->collectionList();
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$io->error('IMAP diagnostic failed: ' . $e->getMessage());
|
$io->error('IMAP diagnostic failed: ' . $e->getMessage());
|
||||||
@@ -184,9 +183,8 @@ class TestCommand extends Command
|
|||||||
$io->section(sprintf('Recent Messages: %s', $mailboxName));
|
$io->section(sprintf('Recent Messages: %s', $mailboxName));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$mailboxClient = RemoteService::freshClient($service);
|
$mailboxService = new RemoteMailService($service);
|
||||||
$mailboxService = RemoteService::mailService($service, $mailboxClient);
|
$selectedMailbox = $mailboxService->imapClient()->perform(new SelectCommand($mailboxName, true));
|
||||||
$selectedMailbox = $mailboxClient->perform(new SelectCommand($mailboxName, true));
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$io->error('Mailbox inspection failed: ' . $e->getMessage());
|
$io->error('Mailbox inspection failed: ' . $e->getMessage());
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
@@ -308,7 +306,7 @@ class TestCommand extends Command
|
|||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
return sprintf('%s://%s:%d', $location->getEncryption(), $location->getHost(), $location->getPort());
|
return sprintf('%s://%s:%d', $location->getInboundEncryption(), $location->getInboundHost(), $location->getInboundPort());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatSender(Message $message): string
|
private function formatSender(Message $message): string
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Mime;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use KTXF\Mail\Object\AddressInterface;
|
||||||
|
use KTXF\Mail\Object\MessagePartInterface;
|
||||||
|
use KTXF\Mail\Object\MessagePropertiesBaseInterface;
|
||||||
|
use Symfony\Component\Mime\Address as MimeAddress;
|
||||||
|
use Symfony\Component\Mime\Email;
|
||||||
|
use Symfony\Component\Mime\Part\DataPart;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME Message Builder
|
||||||
|
*
|
||||||
|
* Converts a framework {@see MessagePropertiesBaseInterface} into raw RFC822
|
||||||
|
* bytes. The output is transport-neutral and used by both of this module's
|
||||||
|
* outbound paths: the SMTP DATA payload (submission) and the IMAP APPEND
|
||||||
|
* literal (saving a copy to the Sent collection).
|
||||||
|
*
|
||||||
|
* The MIME engine (symfony/mime) is intentionally hidden behind this API so it
|
||||||
|
* can be swapped without touching consumers.
|
||||||
|
*
|
||||||
|
* @since 2026.06.22
|
||||||
|
*/
|
||||||
|
final class MessageBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Headers that are derived from structured properties and therefore must
|
||||||
|
* not be copied verbatim from {@see MessagePropertiesBaseInterface::getHeaders()}.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const RESERVED_HEADERS = [
|
||||||
|
'from', 'sender', 'reply-to', 'to', 'cc', 'bcc', 'subject', 'date',
|
||||||
|
'message-id', 'mime-version', 'content-type', 'content-transfer-encoding',
|
||||||
|
'in-reply-to', 'references', 'bcc',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the complete RFC822 message bytes for the given properties.
|
||||||
|
*/
|
||||||
|
public function build(MessagePropertiesBaseInterface $message): string
|
||||||
|
{
|
||||||
|
return $this->toEmail($message)->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect the envelope recipients (To + Cc + Bcc) as bare e-mail addresses.
|
||||||
|
*
|
||||||
|
* Useful for the SMTP RCPT TO phase, which needs every recipient including
|
||||||
|
* Bcc, even though Bcc never appears in the rendered headers.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function recipients(MessagePropertiesBaseInterface $message): array
|
||||||
|
{
|
||||||
|
$recipients = [];
|
||||||
|
|
||||||
|
foreach ([...$message->getTo(), ...$message->getCc(), ...$message->getBcc()] as $address) {
|
||||||
|
$email = trim($address->getAddress());
|
||||||
|
if ($email !== '' && !in_array($email, $recipients, true)) {
|
||||||
|
$recipients[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toEmail(MessagePropertiesBaseInterface $message): Email
|
||||||
|
{
|
||||||
|
$email = new Email();
|
||||||
|
|
||||||
|
if (($from = $message->getFrom()) !== null) {
|
||||||
|
$email->from($this->address($from));
|
||||||
|
}
|
||||||
|
if (($sender = $message->getSender()) !== null) {
|
||||||
|
$email->sender($this->address($sender));
|
||||||
|
}
|
||||||
|
|
||||||
|
$replyTo = $this->addresses($message->getReplyTo());
|
||||||
|
if ($replyTo !== []) {
|
||||||
|
$email->replyTo(...$replyTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
$to = $this->addresses($message->getTo());
|
||||||
|
if ($to !== []) {
|
||||||
|
$email->to(...$to);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cc = $this->addresses($message->getCc());
|
||||||
|
if ($cc !== []) {
|
||||||
|
$email->cc(...$cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bcc = $this->addresses($message->getBcc());
|
||||||
|
if ($bcc !== []) {
|
||||||
|
$email->bcc(...$bcc);
|
||||||
|
}
|
||||||
|
|
||||||
|
$email->subject($message->getSubject());
|
||||||
|
|
||||||
|
if (($sent = $message->getSent()) !== null) {
|
||||||
|
$email->date($sent instanceof DateTimeImmutable ? $sent : DateTimeImmutable::createFromInterface($sent));
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = $message->getBodyTextPlain();
|
||||||
|
$html = $message->getBodyTextHtml();
|
||||||
|
// Inline attachments may carry Content-IDs without an "@"; those must be
|
||||||
|
// normalised (symfony requires an "@") and the matching cid: references
|
||||||
|
// in the HTML body rewritten so the parts still resolve.
|
||||||
|
$cidRewrites = $this->inlineCidRewrites($message);
|
||||||
|
if ($text !== null && $text !== '') {
|
||||||
|
$email->text($text);
|
||||||
|
}
|
||||||
|
if ($html !== null && $html !== '') {
|
||||||
|
$email->html($this->rewriteCids($html, $cidRewrites));
|
||||||
|
}
|
||||||
|
// Guarantee at least an (empty) text part so the message is well-formed.
|
||||||
|
if (($text === null || $text === '') && ($html === null || $html === '')) {
|
||||||
|
$email->text('');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($message->getAttachments() as $attachment) {
|
||||||
|
$part = $this->attachmentPart($attachment);
|
||||||
|
if ($part !== null) {
|
||||||
|
$email->addPart($part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->applyThreadHeaders($email, $message);
|
||||||
|
$this->applyCustomHeaders($email, $message);
|
||||||
|
|
||||||
|
return $email;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attachmentPart(MessagePartInterface $attachment): ?DataPart
|
||||||
|
{
|
||||||
|
$content = $attachment->getContent();
|
||||||
|
if ($content === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$part = new DataPart(
|
||||||
|
$content,
|
||||||
|
$attachment->getName(),
|
||||||
|
$attachment->getType() ?? 'application/octet-stream',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($attachment->getDisposition() === 'inline') {
|
||||||
|
$part->asInline();
|
||||||
|
$cid = $attachment->getContentId();
|
||||||
|
if ($cid !== null && $cid !== '') {
|
||||||
|
$part->setContentId($this->normalizeContentId(trim($cid, '<>')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $part;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a map of original => normalised Content-IDs for inline parts whose
|
||||||
|
* id required normalisation (i.e. lacked an "@").
|
||||||
|
*
|
||||||
|
* @return array<string,string>
|
||||||
|
*/
|
||||||
|
private function inlineCidRewrites(MessagePropertiesBaseInterface $message): array
|
||||||
|
{
|
||||||
|
$rewrites = [];
|
||||||
|
|
||||||
|
foreach ($message->getAttachments() as $attachment) {
|
||||||
|
if ($attachment->getDisposition() !== 'inline') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cid = $attachment->getContentId();
|
||||||
|
if ($cid === null || $cid === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$original = trim($cid, '<>');
|
||||||
|
$normalized = $this->normalizeContentId($original);
|
||||||
|
if ($original !== $normalized) {
|
||||||
|
$rewrites[$original] = $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rewrites;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $rewrites
|
||||||
|
*/
|
||||||
|
private function rewriteCids(string $html, array $rewrites): string
|
||||||
|
{
|
||||||
|
foreach ($rewrites as $original => $normalized) {
|
||||||
|
$html = str_replace('cid:' . $original, 'cid:' . $normalized, $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $html;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeContentId(string $cid): string
|
||||||
|
{
|
||||||
|
return str_contains($cid, '@') ? $cid : $cid . '@ktrix';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyThreadHeaders(Email $email, MessagePropertiesBaseInterface $message): void
|
||||||
|
{
|
||||||
|
$headers = $email->getHeaders();
|
||||||
|
|
||||||
|
$inReplyTo = $message->getInReplyTo();
|
||||||
|
if ($inReplyTo !== null && $inReplyTo !== '') {
|
||||||
|
$headers->addIdHeader('In-Reply-To', $this->stripBrackets($inReplyTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
$references = array_values(array_filter(array_map(
|
||||||
|
fn (string $id): string => $this->stripBrackets($id),
|
||||||
|
$message->getReferences(),
|
||||||
|
), static fn (string $id): bool => $id !== ''));
|
||||||
|
|
||||||
|
if ($references !== []) {
|
||||||
|
$headers->addIdHeader('References', $references);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyCustomHeaders(Email $email, MessagePropertiesBaseInterface $message): void
|
||||||
|
{
|
||||||
|
$headers = $email->getHeaders();
|
||||||
|
|
||||||
|
foreach ($message->getHeaders() as $name => $value) {
|
||||||
|
if (!is_string($name) || $value === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in_array(strtolower($name), self::RESERVED_HEADERS, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$headers->addTextHeader($name, is_array($value) ? implode(', ', $value) : (string) $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,AddressInterface> $addresses
|
||||||
|
* @return list<MimeAddress>
|
||||||
|
*/
|
||||||
|
private function addresses(array $addresses): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($addresses as $address) {
|
||||||
|
if ($address instanceof AddressInterface && trim($address->getAddress()) !== '') {
|
||||||
|
$result[] = $this->address($address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function address(AddressInterface $address): MimeAddress
|
||||||
|
{
|
||||||
|
return new MimeAddress($address->getAddress(), $address->getLabel() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stripBrackets(string $id): string
|
||||||
|
{
|
||||||
|
return trim($id, " \t<>");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,14 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXM\ProviderImap\Providers;
|
namespace KTXM\ProviderImap\Providers;
|
||||||
|
|
||||||
|
use KTXF\Mail\Object\MessagePartInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mail Attachment Object
|
* Mail Attachment Object
|
||||||
*
|
*
|
||||||
* @since 1.0.0
|
* @since 1.0.0
|
||||||
*/
|
*/
|
||||||
class MessageAttachment implements \KTXF\Mail\Object\MessagePartInterface {
|
class MessageAttachment implements MessagePartInterface {
|
||||||
|
|
||||||
protected MessagePart $_meta;
|
protected MessagePart $_meta;
|
||||||
protected ?string $_contents = null;
|
protected ?string $_contents = null;
|
||||||
@@ -119,10 +121,20 @@ class MessageAttachment implements \KTXF\Mail\Object\MessagePartInterface {
|
|||||||
|
|
||||||
public function getBlobId(): ?string { return $this->_meta->getBlobId(); }
|
public function getBlobId(): ?string { return $this->_meta->getBlobId(); }
|
||||||
public function getId(): ?string { return $this->_meta->getId(); }
|
public function getId(): ?string { return $this->_meta->getId(); }
|
||||||
|
public function getSize(): ?int { return $this->_meta->getSize(); }
|
||||||
public function getDisposition(): ?string { return $this->_meta->getDisposition(); }
|
public function getDisposition(): ?string { return $this->_meta->getDisposition(); }
|
||||||
|
public function getContentId(): ?string { return $this->_meta->getContentId(); }
|
||||||
public function getCharset(): ?string { return $this->_meta->getCharset(); }
|
public function getCharset(): ?string { return $this->_meta->getCharset(); }
|
||||||
public function getLanguage(): ?string { return $this->_meta->getLanguage(); }
|
public function getLanguage(): ?string { return $this->_meta->getLanguage(); }
|
||||||
public function getLocation(): ?string { return $this->_meta->getLocation(); }
|
public function getLocation(): ?string { return $this->_meta->getLocation(); }
|
||||||
|
public function getContent(): ?string { return $this->_contents; }
|
||||||
public function getParts(): array { return $this->_meta->getParts(); }
|
public function getParts(): array { return $this->_meta->getParts(); }
|
||||||
public function jsonSerialize(): array { return $this->_meta->jsonSerialize(); }
|
public function jsonSerialize(): array {
|
||||||
|
$data = $this->_meta->jsonSerialize();
|
||||||
|
if ($this->_contents !== null) {
|
||||||
|
$data['content'] = $this->_contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-157
@@ -9,177 +9,44 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace KTXM\ProviderImap\Providers;
|
namespace KTXM\ProviderImap\Providers;
|
||||||
|
|
||||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart;
|
|
||||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part;
|
|
||||||
use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart;
|
|
||||||
use KTXF\Mail\Object\MessagePartMutableAbstract;
|
use KTXF\Mail\Object\MessagePartMutableAbstract;
|
||||||
|
use KTXM\ProviderImap\Client\MessagePart as ImapMessagePart;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mail Message Part Implementation
|
* Mail Message Part Implementation
|
||||||
*/
|
*/
|
||||||
class MessagePart extends MessagePartMutableAbstract {
|
class MessagePart extends MessagePartMutableAbstract {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $data
|
||||||
|
*/
|
||||||
|
private function hydrateArray(array $data): static {
|
||||||
|
$this->data = $data;
|
||||||
|
|
||||||
|
if (isset($this->data['subParts']) && is_array($this->data['subParts'])) {
|
||||||
|
foreach ($this->data['subParts'] as $entry) {
|
||||||
|
if (is_array($entry)) {
|
||||||
|
$this->parts[] = (new self())->hydrateArray($entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($this->data['subParts']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert gricob BodyStructure part to message part object
|
* Convert gricob BodyStructure part to message part object
|
||||||
*
|
*
|
||||||
* @param Part $part gricob BodyStructure Part (SinglePart or MultiPart)
|
* @param Part $part gricob BodyStructure Part (SinglePart or MultiPart)
|
||||||
* @param string $partId numeric part identifier (e.g. "1", "1.1", "2")
|
* @param string $partId numeric part identifier (e.g. "1", "1.1", "2")
|
||||||
*/
|
*/
|
||||||
public function fromImap(Part $part, string $partId = '1'): static {
|
public function fromImap(ImapMessagePart $part, string $partId = '1'): static {
|
||||||
|
$data = $part->toArray();
|
||||||
|
$data['partId'] = $partId;
|
||||||
|
$data['blobId'] = $data['blobId'] ?? $partId;
|
||||||
|
|
||||||
$this->data['partId'] = $partId;
|
return $this->hydrateArray($data);
|
||||||
|
|
||||||
if ($part instanceof SinglePart) {
|
|
||||||
$mimeType = strtolower($part->type) . '/' . strtolower($part->subtype);
|
|
||||||
$this->data['type'] = $mimeType;
|
|
||||||
|
|
||||||
if ($part->id !== null) {
|
|
||||||
$this->data['blobId'] = trim($part->id, '<>');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content-Type parameters (name, charset, etc.)
|
|
||||||
if (!empty($part->attributes)) {
|
|
||||||
foreach ($part->attributes as $key => $value) {
|
|
||||||
$keyLower = strtolower($key);
|
|
||||||
if ($keyLower === 'name') {
|
|
||||||
$this->data['name'] = $value;
|
|
||||||
} elseif ($keyLower === 'charset') {
|
|
||||||
$this->data['charset'] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($part->encoding !== null) {
|
|
||||||
$this->data['encoding'] = strtolower($part->encoding);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($part->size !== null) {
|
|
||||||
$this->data['size'] = $part->size;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($part->disposition !== null) {
|
|
||||||
$this->data['disposition'] = strtolower($part->disposition->type);
|
|
||||||
// disposition filename attribute
|
|
||||||
if (!empty($part->disposition->attributes)) {
|
|
||||||
foreach ($part->disposition->attributes as $key => $value) {
|
|
||||||
if (strtolower($key) === 'filename') {
|
|
||||||
$this->data['name'] = $this->data['name'] ?? $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($part->language)) {
|
|
||||||
$this->data['language'] = implode(',', $part->language);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($part->location !== null) {
|
|
||||||
$this->data['location'] = $part->location;
|
|
||||||
}
|
|
||||||
|
|
||||||
} elseif ($part instanceof MultiPart) {
|
|
||||||
$this->data['type'] = 'multipart/' . strtolower($part->subtype);
|
|
||||||
|
|
||||||
if ($part->disposition !== null) {
|
|
||||||
$this->data['disposition'] = strtolower($part->disposition->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($part->language)) {
|
|
||||||
$this->data['language'] = implode(',', $part->language);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($part->location !== null) {
|
|
||||||
$this->data['location'] = $part->location;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively process sub-parts
|
|
||||||
// When this part has no section ID (root multipart) children are
|
|
||||||
// numbered "1", "2", … to match IMAP section numbering.
|
|
||||||
foreach ($part->parts as $index => $subPart) {
|
|
||||||
$subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1);
|
|
||||||
$this->parts[] = (new MessagePart())->fromImap($subPart, $subPartId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert message part to store array
|
|
||||||
*/
|
|
||||||
public function toStore(): array {
|
|
||||||
$data = $this->data;
|
|
||||||
if (count($this->parts) > 0) {
|
|
||||||
$data['subParts'] = [];
|
|
||||||
foreach ($this->parts as $subPart) {
|
|
||||||
if ($subPart instanceof MessagePart) {
|
|
||||||
$data['subParts'][] = $subPart->toStore();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$data['subParts'] = null;
|
|
||||||
}
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrate message part from store array
|
|
||||||
*/
|
|
||||||
public function fromStore(array $data): static {
|
|
||||||
if (isset($data['subParts']) && is_array($data['subParts'])) {
|
|
||||||
foreach ($data['subParts'] as $subPart) {
|
|
||||||
$this->parts[] = (new MessagePart())->fromStore($subPart);
|
|
||||||
}
|
|
||||||
unset($data['subParts']);
|
|
||||||
}
|
|
||||||
$this->data = $data;
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject decoded body content from a map of IMAP section-ID → raw encoded text.
|
|
||||||
*
|
|
||||||
* Walks the MessagePart tree recursively. For each text/* leaf part whose
|
|
||||||
* partId is present in $sectionMap the raw text is decoded according to the
|
|
||||||
* part's Content-Transfer-Encoding and converted to UTF-8 before being
|
|
||||||
* stored in 'content'. Binary parts (images, PDFs, …) are skipped.
|
|
||||||
*
|
|
||||||
* @param array<string,string> $sectionMap Keys: IMAP section IDs (e.g. "1", "1.2");
|
|
||||||
* Values: raw (transfer-encoded) body text
|
|
||||||
*/
|
|
||||||
public function injectSections(array $sectionMap): void
|
|
||||||
{
|
|
||||||
// MultiPart: recurse into children
|
|
||||||
if (!empty($this->parts)) {
|
|
||||||
foreach ($this->parts as $childPart) {
|
|
||||||
if ($childPart instanceof MessagePart) {
|
|
||||||
$childPart->injectSections($sectionMap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// SinglePart: only inject decoded content for text/* MIME types
|
|
||||||
$type = strtolower($this->data['type'] ?? '');
|
|
||||||
if (!str_starts_with($type, 'text/')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$partId = $this->data['partId'] ?? null;
|
|
||||||
if ($partId === null || !array_key_exists($partId, $sectionMap)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = $sectionMap[$partId];
|
|
||||||
$encoding = strtolower($this->data['encoding'] ?? '7bit');
|
|
||||||
$decoded = match ($encoding) {
|
|
||||||
'quoted-printable' => quoted_printable_decode($raw),
|
|
||||||
'base64' => base64_decode($raw, strict: false),
|
|
||||||
default => $raw, // 7bit, 8bit, binary
|
|
||||||
};
|
|
||||||
|
|
||||||
$charset = $this->data['charset'] ?? 'us-ascii';
|
|
||||||
$this->data['content'] = MessageProperties::toUtf8($decoded, $charset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,36 +82,16 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($message->bodyStructure() !== null) {
|
if ($message->bodyStructure() !== null) {
|
||||||
$this->data[static::PROPERTY_BODY] = $message->bodyStructure()->toArray();
|
$body = $message->bodyStructure()->withInjectedSections($message->bodySections() ?? [])->toArray();
|
||||||
|
$this->data[static::PROPERTY_BODY] = $body;
|
||||||
|
|
||||||
$attachments = [];
|
$attachments = [];
|
||||||
$this->collectAttachments($message->bodyStructure(), $attachments);
|
$this->collectAttachments($body, $attachments);
|
||||||
if ($attachments !== []) {
|
if ($attachments !== []) {
|
||||||
$this->data[static::PROPERTY_ATTACHMENTS] = $attachments;
|
$this->data[static::PROPERTY_ATTACHMENTS] = $attachments;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($message->bodyStructure() !== null) {
|
|
||||||
$this->data[static::PROPERTY_BODY] = $message->bodyStructure()->toArray();
|
|
||||||
// Recursively add content from bodyValues to matching parts
|
|
||||||
if (is_array($message->bodySections())) {
|
|
||||||
$addContentToParts = function(&$structure, $bodyValues) use (&$addContentToParts) {
|
|
||||||
// If this part has a partId and matching bodyValue, add content
|
|
||||||
if (isset($structure['partId']) && isset($bodyValues[$structure['partId']])) {
|
|
||||||
$structure['content'] = $bodyValues[$structure['partId']] ?? null;
|
|
||||||
}
|
|
||||||
// Recursively process subParts
|
|
||||||
if (isset($structure['subParts']) && is_array($structure['subParts'])) {
|
|
||||||
foreach ($structure['subParts'] as &$subPart) {
|
|
||||||
$addContentToParts($subPart, $bodyValues);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$addContentToParts($this->data[static::PROPERTY_BODY], $message->bodySections());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->data[static::PROPERTY_FLAGS] = [];
|
$this->data[static::PROPERTY_FLAGS] = [];
|
||||||
foreach ($message->flags() as $flag) {
|
foreach ($message->flags() as $flag) {
|
||||||
$flag = ltrim($flag, '\\');
|
$flag = ltrim($flag, '\\');
|
||||||
@@ -132,9 +112,9 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
|||||||
/**
|
/**
|
||||||
* Recursively collect attachment parts from body structure
|
* Recursively collect attachment parts from body structure
|
||||||
*/
|
*/
|
||||||
private function collectAttachments(ClientMessagePart $part, array &$attachments): void
|
private function collectAttachments(array $part, array &$attachments): void
|
||||||
{
|
{
|
||||||
$children = $part->parts();
|
$children = $part['subParts'] ?? [];
|
||||||
if ($children !== []) {
|
if ($children !== []) {
|
||||||
foreach ($children as $childPart) {
|
foreach ($children as $childPart) {
|
||||||
$this->collectAttachments($childPart, $attachments);
|
$this->collectAttachments($childPart, $attachments);
|
||||||
@@ -142,9 +122,9 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$mimeType = strtolower($part->mimeType());
|
$mimeType = strtolower((string)($part['type'] ?? ''));
|
||||||
$disposition = strtolower($part->disposition() ?? '');
|
$disposition = strtolower((string)($part['disposition'] ?? ''));
|
||||||
$name = $part->parameters()['name'] ?? $part->dispositionParameters()['filename'] ?? null;
|
$name = $part['name'] ?? null;
|
||||||
$isInlineText = str_starts_with($mimeType, 'text/')
|
$isInlineText = str_starts_with($mimeType, 'text/')
|
||||||
&& in_array($mimeType, ['text/plain', 'text/html'], true)
|
&& in_array($mimeType, ['text/plain', 'text/html'], true)
|
||||||
&& $disposition !== 'attachment';
|
&& $disposition !== 'attachment';
|
||||||
@@ -153,7 +133,7 @@ class MessageProperties extends MessagePropertiesMutableAbstract {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attachments[] = $part->toArray();
|
$attachments[] = $part;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use KTXF\Mail\Service\ServiceMutableInterface;
|
|||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||||
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
|
use KTXF\Resource\Provider\ResourceServiceMutateInterface;
|
||||||
use KTXM\ProviderImap\Service\Discovery;
|
use KTXM\ProviderImap\Service\Discovery;
|
||||||
use KTXM\ProviderImap\Service\Remote\RemoteService;
|
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||||
use KTXM\ProviderImap\Stores\ServiceStore;
|
use KTXM\ProviderImap\Stores\ServiceStore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,10 +49,10 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
|
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||||
self::JSON_PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
|
self::PROPERTY_IDENTIFIER => self::PROVIDER_IDENTIFIER,
|
||||||
self::JSON_PROPERTY_LABEL => self::PROVIDER_LABEL,
|
self::PROPERTY_LABEL => self::PROVIDER_LABEL,
|
||||||
self::JSON_PROPERTY_CAPABILITIES => $this->providerAbilities,
|
self::PROPERTY_CAPABILITIES => $this->providerAbilities,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +192,8 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
$service->fromStore(['sid' => 'test']);
|
$service->fromStore(['sid' => 'test']);
|
||||||
|
|
||||||
// Attempt to authenticate and list mailboxes as a connectivity check
|
// Attempt to authenticate and list mailboxes as a connectivity check
|
||||||
$client = RemoteService::freshClient($service);
|
$remote = new RemoteMailService($service);
|
||||||
$service = RemoteService::mailService($service, $client);
|
$mailboxes = iterator_to_array($remote->collectionList());
|
||||||
$mailboxes = $service->collectionList();
|
|
||||||
|
|
||||||
$latency = (int) round((microtime(true) - $startTime) * 1000);
|
$latency = (int) round((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
@@ -205,36 +204,9 @@ class Provider implements ProviderBaseInterface, ProviderServiceMutateInterface,
|
|||||||
. ' (Latency: ' . $latency . ' ms)',
|
. ' (Latency: ' . $latency . ' ms)',
|
||||||
];
|
];
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$latency = (int) round((microtime(true) - $startTime) * 1000);
|
|
||||||
|
|
||||||
$location = ($service instanceof Service) ? $service->getLocation() : null;
|
|
||||||
$target = $location
|
|
||||||
? $location->getEncryption() . '://' . $location->getHost() . ':' . $location->getPort()
|
|
||||||
: 'unknown host';
|
|
||||||
|
|
||||||
// stream_socket_client errors are suppressed with @ in gricob — recover them
|
|
||||||
$phpError = error_get_last();
|
|
||||||
$detail = $e->getMessage() !== '' ? $e->getMessage() : ($phpError['message'] ?? '');
|
|
||||||
|
|
||||||
if ($detail === '' && $location !== null) {
|
|
||||||
$host = $location->getHost();
|
|
||||||
if ($host !== '' && gethostbyname($host) === $host) {
|
|
||||||
$detail = "hostname '{$host}' could not be resolved";
|
|
||||||
} else {
|
|
||||||
$detail = 'connection refused or timed out — check port and encryption settings';
|
|
||||||
}
|
|
||||||
} elseif ($detail === '') {
|
|
||||||
$detail = 'no details — check host, port, and encryption settings';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => sprintf(
|
'message' => 'Test failed: ' . $e->getMessage(),
|
||||||
'Connection to %s failed (%s): %s',
|
|
||||||
$target,
|
|
||||||
(new \ReflectionClass($e))->getShortName(),
|
|
||||||
$detail,
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+242
-141
@@ -17,8 +17,11 @@ use KTXF\Mail\Object\AddressInterface;
|
|||||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||||
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
||||||
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceEntitySubmitInterface;
|
||||||
use KTXF\Mail\Service\ServiceConfigurableInterface;
|
use KTXF\Mail\Service\ServiceConfigurableInterface;
|
||||||
use KTXF\Mail\Service\ServiceMutableInterface;
|
use KTXF\Mail\Service\ServiceMutableInterface;
|
||||||
|
use KTXF\Mail\Submission\EntitySubmitResult;
|
||||||
|
use KTXF\Resource\BinaryResource;
|
||||||
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||||
use KTXF\Resource\Delta\Delta;
|
use KTXF\Resource\Delta\Delta;
|
||||||
@@ -34,18 +37,16 @@ use KTXF\Resource\Sort\ISort;
|
|||||||
use KTXF\Resource\Sort\Sort;
|
use KTXF\Resource\Sort\Sort;
|
||||||
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
use KTXM\ProviderImap\Providers\ServiceIdentityBasic;
|
||||||
use KTXM\ProviderImap\Providers\ServiceLocation;
|
use KTXM\ProviderImap\Providers\ServiceLocation;
|
||||||
|
use KTXM\ProviderImap\Mime\MessageBuilder;
|
||||||
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
use KTXM\ProviderImap\Service\Remote\RemoteMailService;
|
||||||
use KTXM\ProviderImap\Service\Remote\RemoteService;
|
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
use KTXM\ProviderImap\Providers\CollectionResource;
|
||||||
use KTXF\Mail\Collection\CollectionRoles;
|
use KTXF\Mail\Collection\CollectionRoles;
|
||||||
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
use KTXF\Mail\Object\MessagePropertiesMutableInterface;
|
||||||
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
use KTXM\ProviderImap\Providers\EntityResource;
|
||||||
|
use KTXM\ProviderImap\Client\Mailbox;
|
||||||
|
|
||||||
/**
|
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface, ServiceEntitySubmitInterface
|
||||||
* IMAP Mail Service
|
|
||||||
*/
|
|
||||||
class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceConfigurableInterface, ServiceCollectionMutableInterface, ServiceEntityMutableInterface
|
|
||||||
{
|
{
|
||||||
private const PROVIDER_IDENTIFIER = 'imap';
|
private const PROVIDER_IDENTIFIER = 'imap';
|
||||||
|
|
||||||
@@ -54,27 +55,30 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
private ?string $serviceIdentifier = null;
|
private ?string $serviceIdentifier = null;
|
||||||
private ?string $serviceLabel = null;
|
private ?string $serviceLabel = null;
|
||||||
private bool $serviceEnabled = false;
|
private bool $serviceEnabled = false;
|
||||||
private string $primaryAddress = '';
|
private array $primaryAddress = [];
|
||||||
private array $secondaryAddresses = [];
|
private array $secondaryAddresses = [];
|
||||||
private ?ServiceLocation $location = null;
|
private ?ServiceLocation $location = null;
|
||||||
private ?ServiceIdentityBasic $identity = null;
|
private ?ServiceIdentityBasic $identity = null;
|
||||||
private array $auxiliary = [];
|
private array $auxiliary = [];
|
||||||
|
|
||||||
private array $serviceAbilities = [
|
private array $serviceAbilities = [
|
||||||
self::CAPABILITY_COLLECTION_LIST => true,
|
self::CAPABILITY_COLLECTION_LIST => true,
|
||||||
self::CAPABILITY_COLLECTION_LIST_FILTER => [
|
self::CAPABILITY_COLLECTION_LIST_FILTER => [
|
||||||
self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:128:256:256',
|
self::CAPABILITY_COLLECTION_FILTER_LABEL => 's:128:256:256',
|
||||||
self::CAPABILITY_COLLECTION_FILTER_ROLE => 's:32:1:1',
|
self::CAPABILITY_COLLECTION_FILTER_ROLE => 's:32:1:1',
|
||||||
self::CAPABILITY_COLLECTION_FILTER_SUBSCRIBED => 'b:0:1:1',
|
self::CAPABILITY_COLLECTION_FILTER_SUBSCRIBED => 'b:0:1:1',
|
||||||
],
|
],
|
||||||
self::CAPABILITY_COLLECTION_LIST_SORT => [],
|
self::CAPABILITY_COLLECTION_LIST_SORT => [
|
||||||
self::CAPABILITY_COLLECTION_EXTANT => true,
|
self::CAPABILITY_COLLECTION_SORT_LABEL,
|
||||||
|
self::CAPABILITY_COLLECTION_SORT_RANK,
|
||||||
|
],
|
||||||
self::CAPABILITY_COLLECTION_FETCH => true,
|
self::CAPABILITY_COLLECTION_FETCH => true,
|
||||||
|
self::CAPABILITY_COLLECTION_EXTANT => true,
|
||||||
self::CAPABILITY_COLLECTION_CREATE => true,
|
self::CAPABILITY_COLLECTION_CREATE => true,
|
||||||
self::CAPABILITY_COLLECTION_UPDATE => true,
|
self::CAPABILITY_COLLECTION_UPDATE => true,
|
||||||
self::CAPABILITY_COLLECTION_DELETE => true,
|
self::CAPABILITY_COLLECTION_DELETE => true,
|
||||||
self::CAPABILITY_COLLECTION_MOVE => true,
|
self::CAPABILITY_COLLECTION_MOVE => true,
|
||||||
self::CAPABILITY_ENTITY_LIST => true,
|
self::CAPABILITY_ENTITY_LIST => true,
|
||||||
self::CAPABILITY_ENTITY_LIST_FILTER => [
|
self::CAPABILITY_ENTITY_LIST_FILTER => [
|
||||||
self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256',
|
self::CAPABILITY_ENTITY_FILTER_FROM => 's:100:256:256',
|
||||||
self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256',
|
self::CAPABILITY_ENTITY_FILTER_TO => 's:100:256:256',
|
||||||
@@ -85,56 +89,62 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16',
|
self::CAPABILITY_ENTITY_FILTER_SIZE_MIN => 'i:0:16:16',
|
||||||
self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32',
|
self::CAPABILITY_ENTITY_FILTER_SIZE_MAX => 'i:0:32:32',
|
||||||
],
|
],
|
||||||
self::CAPABILITY_ENTITY_LIST_SORT => [],
|
self::CAPABILITY_ENTITY_LIST_SORT => [
|
||||||
self::CAPABILITY_ENTITY_LIST_RANGE => ['tally' => ['absolute', 'relative']],
|
self::CAPABILITY_ENTITY_SORT_FROM,
|
||||||
self::CAPABILITY_ENTITY_EXTANT => true,
|
self::CAPABILITY_ENTITY_SORT_TO,
|
||||||
self::CAPABILITY_ENTITY_FETCH => true,
|
self::CAPABILITY_ENTITY_SORT_SUBJECT,
|
||||||
self::CAPABILITY_ENTITY_CREATE => false,
|
self::CAPABILITY_ENTITY_SORT_DATE_RECEIVED,
|
||||||
self::CAPABILITY_ENTITY_MODIFY => false,
|
self::CAPABILITY_ENTITY_SORT_DATE_SENT,
|
||||||
self::CAPABILITY_ENTITY_DELETE => true,
|
self::CAPABILITY_ENTITY_SORT_SIZE,
|
||||||
self::CAPABILITY_ENTITY_MOVE => true,
|
],
|
||||||
self::CAPABILITY_ENTITY_COPY => false,
|
self::CAPABILITY_ENTITY_LIST_RANGE => [
|
||||||
|
'tally' => ['absolute', 'relative']
|
||||||
|
],
|
||||||
|
self::CAPABILITY_ENTITY_FETCH => true,
|
||||||
|
self::CAPABILITY_ENTITY_EXTANT => true,
|
||||||
|
self::CAPABILITY_ENTITY_CREATE => false,
|
||||||
|
self::CAPABILITY_ENTITY_MODIFY => false,
|
||||||
|
self::CAPABILITY_ENTITY_PATCH => true,
|
||||||
|
self::CAPABILITY_ENTITY_DELETE => true,
|
||||||
|
self::CAPABILITY_ENTITY_MOVE => true,
|
||||||
|
self::CAPABILITY_ENTITY_COPY => false,
|
||||||
|
'EntityTransmit' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
private RemoteMailService $mailService;
|
private RemoteMailService $remoteService;
|
||||||
|
|
||||||
public function __construct() {}
|
public function __construct() {}
|
||||||
|
|
||||||
// ── Lazy initialisation ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private function initialize(): void
|
private function initialize(): void
|
||||||
{
|
{
|
||||||
if (!isset($this->mailService)) {
|
if (!isset($this->remoteService)) {
|
||||||
$wrapper = RemoteService::freshClient($this);
|
$this->remoteService = new RemoteMailService($this);
|
||||||
$this->mailService = RemoteService::mailService($this, $wrapper);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Store (MongoDB persistence) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
public function toStore(): array
|
public function toStore(): array
|
||||||
{
|
{
|
||||||
return array_filter([
|
return array_filter([
|
||||||
'tid' => $this->serviceTenantId,
|
'tid' => $this->serviceTenantId,
|
||||||
'uid' => $this->serviceUserId,
|
'uid' => $this->serviceUserId,
|
||||||
'sid' => $this->serviceIdentifier,
|
'sid' => $this->serviceIdentifier,
|
||||||
'label' => $this->serviceLabel,
|
'enabled' => $this->serviceEnabled,
|
||||||
'enabled' => $this->serviceEnabled,
|
'label' => $this->serviceLabel,
|
||||||
'primaryAddress' => $this->primaryAddress,
|
'primaryAddress' => $this->primaryAddress,
|
||||||
'secondaryAddresses'=> $this->secondaryAddresses,
|
'secondaryAddresses'=> $this->secondaryAddresses,
|
||||||
'location' => $this->location?->toStore(),
|
'location' => $this->location?->toStore(),
|
||||||
'identity' => $this->identity?->toStore(),
|
'identity' => $this->identity?->toStore(),
|
||||||
'auxiliary' => $this->auxiliary,
|
'auxiliary' => $this->auxiliary,
|
||||||
], fn($v) => $v !== null);
|
], fn($v) => $v !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fromStore(array $data): static
|
public function fromStore(array $data): static
|
||||||
{
|
{
|
||||||
$this->serviceTenantId = $data['tid'] ?? null;
|
$this->serviceTenantId = $data['tid'] ?? null;
|
||||||
$this->serviceUserId = $data['uid'] ?? null;
|
$this->serviceUserId = $data['uid'] ?? null;
|
||||||
$this->serviceIdentifier = $data['sid'] ?? null;
|
$this->serviceIdentifier = $data['sid'] ?? null;
|
||||||
$this->serviceLabel = $data['label'] ?? '';
|
$this->serviceLabel = $data['label'] ?? '';
|
||||||
$this->serviceEnabled = $data['enabled'] ?? false;
|
$this->serviceEnabled = $data['enabled'] ?? false;
|
||||||
|
|
||||||
if (isset($data['primaryAddress'])) {
|
if (isset($data['primaryAddress'])) {
|
||||||
$this->primaryAddress = $data['primaryAddress'];
|
$this->primaryAddress = $data['primaryAddress'];
|
||||||
@@ -155,22 +165,20 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── JSON ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
{
|
{
|
||||||
return array_filter([
|
return array_filter([
|
||||||
self::JSON_PROPERTY_TYPE => self::JSON_TYPE,
|
self::PROPERTY_TYPE => self::JSON_TYPE,
|
||||||
self::JSON_PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
|
self::PROPERTY_PROVIDER => self::PROVIDER_IDENTIFIER,
|
||||||
self::JSON_PROPERTY_IDENTIFIER => $this->serviceIdentifier,
|
self::PROPERTY_IDENTIFIER => $this->serviceIdentifier,
|
||||||
self::JSON_PROPERTY_LABEL => $this->serviceLabel,
|
self::PROPERTY_LABEL => $this->serviceLabel,
|
||||||
self::JSON_PROPERTY_ENABLED => $this->serviceEnabled,
|
self::PROPERTY_ENABLED => $this->serviceEnabled,
|
||||||
self::JSON_PROPERTY_CAPABILITIES => $this->serviceAbilities,
|
self::PROPERTY_CAPABILITIES => $this->serviceAbilities,
|
||||||
self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
|
self::PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress,
|
||||||
self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
|
self::PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses,
|
||||||
self::JSON_PROPERTY_LOCATION => $this->location?->jsonSerialize(),
|
self::PROPERTY_LOCATION => $this->location?->jsonSerialize(),
|
||||||
self::JSON_PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
|
self::PROPERTY_IDENTITY => $this->identity?->jsonSerialize(),
|
||||||
self::JSON_PROPERTY_AUXILIARY => $this->auxiliary,
|
self::PROPERTY_AUXILIARY => $this->auxiliary,
|
||||||
], fn($v) => $v !== null);
|
], fn($v) => $v !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,36 +188,34 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
$data = json_decode($data, true, 512, JSON_THROW_ON_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($data[self::JSON_PROPERTY_LABEL])) {
|
if (isset($data[self::PROPERTY_ENABLED])) {
|
||||||
$this->setLabel($data[self::JSON_PROPERTY_LABEL]);
|
$this->setEnabled($data[self::PROPERTY_ENABLED]);
|
||||||
}
|
}
|
||||||
if (isset($data[self::JSON_PROPERTY_ENABLED])) {
|
if (isset($data[self::PROPERTY_LABEL])) {
|
||||||
$this->setEnabled($data[self::JSON_PROPERTY_ENABLED]);
|
$this->setLabel($data[self::PROPERTY_LABEL]);
|
||||||
}
|
}
|
||||||
if (isset($data[self::JSON_PROPERTY_LOCATION])) {
|
if (isset($data[self::PROPERTY_LOCATION])) {
|
||||||
$this->setLocation($this->freshLocation(null, $data[self::JSON_PROPERTY_LOCATION]));
|
$this->setLocation($this->freshLocation(null, $data[self::PROPERTY_LOCATION]));
|
||||||
}
|
}
|
||||||
if (isset($data[self::JSON_PROPERTY_IDENTITY])) {
|
if (isset($data[self::PROPERTY_IDENTITY])) {
|
||||||
$this->setIdentity($this->freshIdentity(null, $data[self::JSON_PROPERTY_IDENTITY]));
|
$this->setIdentity($this->freshIdentity(null, $data[self::PROPERTY_IDENTITY]));
|
||||||
}
|
}
|
||||||
if (isset($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::JSON_PROPERTY_PRIMARY_ADDRESS])) {
|
if (isset($data[self::PROPERTY_PRIMARY_ADDRESS]) && is_string($data[self::PROPERTY_PRIMARY_ADDRESS])) {
|
||||||
$this->setPrimaryAddress(new Address($data[self::JSON_PROPERTY_PRIMARY_ADDRESS]));
|
$this->setPrimaryAddress(new Address($data[self::PROPERTY_PRIMARY_ADDRESS]));
|
||||||
}
|
}
|
||||||
if (isset($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::JSON_PROPERTY_SECONDARY_ADDRESSES])) {
|
if (isset($data[self::PROPERTY_SECONDARY_ADDRESSES]) && is_array($data[self::PROPERTY_SECONDARY_ADDRESSES])) {
|
||||||
$this->setSecondaryAddresses(array_map(
|
$this->setSecondaryAddresses(array_map(
|
||||||
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
|
fn($addr) => new Address(is_array($addr) ? ($addr['address'] ?? $addr) : $addr),
|
||||||
$data[self::JSON_PROPERTY_SECONDARY_ADDRESSES]
|
$data[self::PROPERTY_SECONDARY_ADDRESSES]
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if (isset($data[self::JSON_PROPERTY_AUXILIARY]) && is_array($data[self::JSON_PROPERTY_AUXILIARY])) {
|
if (isset($data[self::PROPERTY_AUXILIARY]) && is_array($data[self::PROPERTY_AUXILIARY])) {
|
||||||
$this->setAuxiliary($data[self::JSON_PROPERTY_AUXILIARY]);
|
$this->setAuxiliary($data[self::PROPERTY_AUXILIARY]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ServiceBaseInterface ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function capable(string $value): bool
|
public function capable(string $value): bool
|
||||||
{
|
{
|
||||||
return isset($this->serviceAbilities[$value]);
|
return isset($this->serviceAbilities[$value]);
|
||||||
@@ -230,8 +236,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
return $this->serviceIdentifier;
|
return $this->serviceIdentifier;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ServiceMutableInterface ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function getLabel(): ?string
|
public function getLabel(): ?string
|
||||||
{
|
{
|
||||||
return $this->serviceLabel;
|
return $this->serviceLabel;
|
||||||
@@ -256,23 +260,28 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
|
|
||||||
public function getPrimaryAddress(): AddressInterface
|
public function getPrimaryAddress(): AddressInterface
|
||||||
{
|
{
|
||||||
return new Address($this->primaryAddress);
|
return Address::fromArray($this->primaryAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setPrimaryAddress(AddressInterface $value): static
|
public function setPrimaryAddress(AddressInterface $value): static
|
||||||
{
|
{
|
||||||
$this->primaryAddress = $value->getAddress();
|
$this->primaryAddress = $value->toArray();
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getSecondaryAddresses(): array
|
public function getSecondaryAddresses(): array
|
||||||
{
|
{
|
||||||
return $this->secondaryAddresses;
|
return array_map(
|
||||||
|
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr])
|
||||||
|
, $this->secondaryAddresses);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setSecondaryAddresses(array $addresses): static
|
public function setSecondaryAddresses(array $addresses): static
|
||||||
{
|
{
|
||||||
$this->secondaryAddresses = $addresses;
|
$this->secondaryAddresses = array_map(
|
||||||
|
fn($addr) => $addr instanceof AddressInterface ? $addr : Address::fromArray(is_array($addr) ? $addr : ['address' => (string) $addr]),
|
||||||
|
$addresses
|
||||||
|
);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +289,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
{
|
{
|
||||||
$address = strtolower(trim($address));
|
$address = strtolower(trim($address));
|
||||||
|
|
||||||
if ($this->primaryAddress && strtolower($this->primaryAddress) === $address) {
|
if ($this->primaryAddress && strtolower($this->primaryAddress['address'] ?? '') === $address) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
foreach ($this->secondaryAddresses as $secondary) {
|
foreach ($this->secondaryAddresses as $secondary) {
|
||||||
@@ -294,8 +303,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ServiceConfigurableInterface ──────────────────────────────────────────
|
|
||||||
|
|
||||||
public function getLocation(): ServiceLocation
|
public function getLocation(): ServiceLocation
|
||||||
{
|
{
|
||||||
return $this->location;
|
return $this->location;
|
||||||
@@ -354,15 +361,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Collection operations ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
|
public function collectionList(string|int|null $location, ?IFilter $filter = null, ?ISort $sort = null): array
|
||||||
{
|
{
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
|
|
||||||
$list = [];
|
$list = [];
|
||||||
|
|
||||||
foreach ($this->mailService->collectionList($location, $filter, $sort) as $mailbox) {
|
foreach ($this->remoteService->collectionList($location, $filter, $sort) as $mailbox) {
|
||||||
$resource = $this->collectionFresh();
|
$resource = $this->collectionFresh();
|
||||||
$resource->fromImap($mailbox);
|
$resource->fromImap($mailbox);
|
||||||
$list[$mailbox->name()] = $resource;
|
$list[$mailbox->name()] = $resource;
|
||||||
@@ -389,7 +394,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
|
|
||||||
foreach ($identifiers as $identifier) {
|
foreach ($identifiers as $identifier) {
|
||||||
$key = (string) $identifier;
|
$key = (string) $identifier;
|
||||||
$result = $this->mailService->collectionFetch($key);
|
$result = $this->remoteService->collectionFetch($key);
|
||||||
|
|
||||||
$list[$key] = $result !== false;
|
$list[$key] = $result !== false;
|
||||||
}
|
}
|
||||||
@@ -397,11 +402,11 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function collectionFetch(string|int $identifier): ?CollectionBaseInterface
|
public function collectionFetch(string|int $identifier): ?CollectionResource
|
||||||
{
|
{
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
|
|
||||||
$mailbox = $this->mailService->collectionFetch((string) $identifier);
|
$mailbox = $this->remoteService->collectionFetch((string) $identifier);
|
||||||
if ($mailbox === null) {
|
if ($mailbox === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -430,13 +435,13 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
if ($target !== null) {
|
if ($target !== null) {
|
||||||
$path = $target->collection();
|
$path = $target->collection();
|
||||||
// Determine the hierarchy delimiter from an existing mailbox, default to '/'
|
// Determine the hierarchy delimiter from an existing mailbox, default to '/'
|
||||||
$mailboxes = iterator_to_array($this->mailService->collectionList(null, null, null, ''));
|
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, null, null, ''));
|
||||||
$rootMailbox = $mailboxes === [] ? null : reset($mailboxes);
|
$rootMailbox = $mailboxes === [] ? null : reset($mailboxes);
|
||||||
$delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/');
|
$delimiter = $rootMailbox === false ? '/' : ($rootMailbox?->delimiter() ?? '/');
|
||||||
$label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter);
|
$label = rtrim((string) $path, $delimiter) . $delimiter . ltrim($label, $delimiter);
|
||||||
}
|
}
|
||||||
|
|
||||||
$mailbox = $this->mailService->collectionCreate($label);
|
$mailbox = $this->remoteService->collectionCreate($label);
|
||||||
|
|
||||||
$collection = $this->collectionFresh();
|
$collection = $this->collectionFresh();
|
||||||
$collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]);
|
$collection->fromImap($mailbox, ['delimiter' => $delimiter ?? null]);
|
||||||
@@ -456,7 +461,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
// In IMAP, "update" = rename to the new label
|
// In IMAP, "update" = rename to the new label
|
||||||
$oldPath = (string) $target->collection();
|
$oldPath = (string) $target->collection();
|
||||||
$newName = $properties->getLabel();
|
$newName = $properties->getLabel();
|
||||||
$mailbox = $this->mailService->collectionRename($oldPath, $newName);
|
$mailbox = $this->remoteService->collectionRename($oldPath, $newName);
|
||||||
|
|
||||||
$collection = $this->collectionFresh();
|
$collection = $this->collectionFresh();
|
||||||
$collection->fromImap($mailbox);
|
$collection->fromImap($mailbox);
|
||||||
@@ -476,14 +481,14 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
|
|
||||||
// Move to target collection (e.g. Trash) instead of deleting
|
// Move to target collection (e.g. Trash) instead of deleting
|
||||||
if ($deleteMode === 'soft' && $deleteTarget !== null) {
|
if ($deleteMode === 'soft' && $deleteTarget !== null) {
|
||||||
return $this->collectionMove($target, new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget));
|
return $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($deleteMode === 'soft' && $deleteTarget === null) {
|
if ($deleteMode === 'soft' && $deleteTarget === null) {
|
||||||
$filter = $this->collectionListFilter();
|
$filter = $this->collectionListFilter();
|
||||||
$filter->condition('role', CollectionRoles::Trash->value);
|
$filter->condition('role', CollectionRoles::Trash->value);
|
||||||
|
|
||||||
$mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null));
|
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null));
|
||||||
if (empty($mailboxes)) {
|
if (empty($mailboxes)) {
|
||||||
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
||||||
}
|
}
|
||||||
@@ -499,7 +504,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
|
|
||||||
$result = match ($deleteMode) {
|
$result = match ($deleteMode) {
|
||||||
'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target),
|
'soft' => $this->collectionMove(new CollectionIdentifier($target->provider(), $target->service(), $deleteTarget), $target),
|
||||||
'hard' => $this->mailService->collectionDestroy((string) $target->collection()),
|
'hard' => $this->remoteService->collectionDestroy((string) $target->collection()),
|
||||||
};
|
};
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -508,8 +513,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
{
|
{
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
|
|
||||||
$sourceMailbox = $this->mailService->collectionFetch((string) $source->collection());
|
$sourceMailbox = $this->remoteService->collectionFetch((string) $source->collection());
|
||||||
$targetMailbox = $this->mailService->collectionFetch((string) $target->collection());
|
$targetMailbox = $this->remoteService->collectionFetch((string) $target->collection());
|
||||||
if ($sourceMailbox === null) {
|
if ($sourceMailbox === null) {
|
||||||
throw new \RuntimeException('Source collection not found for move operation');
|
throw new \RuntimeException('Source collection not found for move operation');
|
||||||
}
|
}
|
||||||
@@ -521,16 +526,17 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
$targetDelimiter = $targetMailbox->delimiter() ?? '/';
|
$targetDelimiter = $targetMailbox->delimiter() ?? '/';
|
||||||
|
|
||||||
$extantPath = $sourceMailbox->name();
|
$extantPath = $sourceMailbox->name();
|
||||||
$freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end(explode($sourceDelimiter, $extantPath));
|
$extantPathLeafs = explode($sourceDelimiter, rtrim($extantPath, $sourceDelimiter));
|
||||||
$mutatedMailbox = $this->mailService->collectionRename($extantPath, $freshPath);
|
|
||||||
|
$freshPath = rtrim($targetMailbox->name(), $targetDelimiter) . $targetDelimiter . end($extantPathLeafs);
|
||||||
|
|
||||||
|
$mutatedMailbox = $this->remoteService->collectionRename($extantPath, $freshPath);
|
||||||
|
|
||||||
$collection = $this->collectionFresh();
|
$collection = $this->collectionFresh();
|
||||||
$collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]);
|
$collection->fromImap($mutatedMailbox, ['delimiter' => $targetDelimiter]);
|
||||||
return $collection;
|
return $collection;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Entity operations ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
|
public function entityListBulk(string|int $collection, ?IFilter $filter = null, ?ISort $sort = null, ?IRange $range = null, ?array $properties = null): array
|
||||||
{
|
{
|
||||||
return iterator_to_array($this->entityListStream((string) $collection, $filter, $sort, $range), true);
|
return iterator_to_array($this->entityListStream((string) $collection, $filter, $sort, $range), true);
|
||||||
@@ -540,7 +546,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
{
|
{
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
|
|
||||||
foreach ($this->mailService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) {
|
foreach ($this->remoteService->entityList((string) $collection, $filter, $sort, $range) as $identifier => $message) {
|
||||||
$resource = $this->entityFresh();
|
$resource = $this->entityFresh();
|
||||||
$resource->fromImap($message, $collection);
|
$resource->fromImap($message, $collection);
|
||||||
yield $resource->urn() => $resource;
|
yield $resource->urn() => $resource;
|
||||||
@@ -578,7 +584,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
|
|
||||||
foreach ($identifiers as $collection => $entities) {
|
foreach ($identifiers as $collection => $entities) {
|
||||||
$uids = array_keys($entities);
|
$uids = array_keys($entities);
|
||||||
foreach ($this->mailService->entityFetch((string) $collection, ...$uids) as $uid => $message) {
|
foreach ($this->remoteService->entityFetch((string) $collection, null, ...$uids) as $uid => $message) {
|
||||||
$resource = $this->entityFresh();
|
$resource = $this->entityFresh();
|
||||||
$resource->fromImap($message, $collection);
|
$resource->fromImap($message, $collection);
|
||||||
yield $resource->urn() => $resource;
|
yield $resource->urn() => $resource;
|
||||||
@@ -586,6 +592,16 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function entityDownload(EntityIdentifierInterface $target, array|null $part): BinaryResource {
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$collection = $target->collection();
|
||||||
|
$uid = (int) $target->entity();
|
||||||
|
$partId = isset($part['partId']) ? (string) $part['partId'] : null;
|
||||||
|
|
||||||
|
return $this->remoteService->entityDownload($collection, $uid, $partId);
|
||||||
|
}
|
||||||
|
|
||||||
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
|
public function entityDelta(string|int $collection, string $signature, string $detail = 'ids'): Delta
|
||||||
{
|
{
|
||||||
return new Delta(signature: $signature);
|
return new Delta(signature: $signature);
|
||||||
@@ -595,7 +611,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
{
|
{
|
||||||
$this->initialize();
|
$this->initialize();
|
||||||
|
|
||||||
$allUids = $this->mailService->entityList((string) $collection);
|
$allUids = $this->remoteService->entityList((string) $collection);
|
||||||
$uidSet = array_flip($allUids); // int[] → [uid => index]
|
$uidSet = array_flip($allUids); // int[] → [uid => index]
|
||||||
$extant = [];
|
$extant = [];
|
||||||
foreach ($identifiers as $id) {
|
foreach ($identifiers as $id) {
|
||||||
@@ -609,6 +625,79 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
return new EntityResource($this->provider(), $this->identifier());
|
return new EntityResource($this->provider(), $this->identifier());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function entitySubmit(AddressInterface $sender, EntityIdentifierInterface|null $source = null, MessagePropertiesMutableInterface|null $message = null): EntitySubmitResult
|
||||||
|
{
|
||||||
|
if ($message === null) {
|
||||||
|
return new EntitySubmitResult(
|
||||||
|
EntitySubmitResult::DISPOSITION_ERROR,
|
||||||
|
errorCode: 'invalid_message',
|
||||||
|
errorMessage: 'No message properties were provided for submission.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outbound submission via SMTP — independent of the IMAP connection.
|
||||||
|
try {
|
||||||
|
$raw = (new MessageBuilder())->build($message);
|
||||||
|
$recipients = MessageBuilder::recipients($message);
|
||||||
|
if ($recipients === []) {
|
||||||
|
throw new \RuntimeException('Message has no recipients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->initialize();
|
||||||
|
$smtp = $this->remoteService->smtpClient();
|
||||||
|
try {
|
||||||
|
$queueId = $smtp->send(trim($sender->getAddress()), $recipients, $raw);
|
||||||
|
} finally {
|
||||||
|
$smtp->quit();
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return new EntitySubmitResult(
|
||||||
|
EntitySubmitResult::DISPOSITION_ERROR,
|
||||||
|
errorCode: 'submission_failed',
|
||||||
|
errorMessage: $e->getMessage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: store a copy in the Sent collection. A failure here must
|
||||||
|
// not turn a successful delivery into an error.
|
||||||
|
$sentEntity = null;
|
||||||
|
try {
|
||||||
|
$this->initialize();
|
||||||
|
$sentCollection = $this->resolveSentCollection();
|
||||||
|
if ($sentCollection !== null) {
|
||||||
|
$uid = $this->remoteService->entityCreate($sentCollection, $raw, ['\\Seen']);
|
||||||
|
if ($uid !== null && $uid > 0) {
|
||||||
|
$sentEntity = new EntityIdentifier($this->provider(), $this->identifier(), $sentCollection, (string) $uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// ignore — the message was already delivered
|
||||||
|
}
|
||||||
|
|
||||||
|
return new EntitySubmitResult(
|
||||||
|
disposition: EntitySubmitResult::DISPOSITION_SENT,
|
||||||
|
transportId: $queueId !== '' ? $queueId : null,
|
||||||
|
sentEntity: $sentEntity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the native name of the collection flagged with the Sent role.
|
||||||
|
*/
|
||||||
|
private function resolveSentCollection(): ?string
|
||||||
|
{
|
||||||
|
$filter = $this->collectionListFilter();
|
||||||
|
$filter->condition('role', CollectionRoles::Sent->value);
|
||||||
|
|
||||||
|
/** @var Mailbox[] $mailboxes */
|
||||||
|
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null));
|
||||||
|
if ($mailboxes === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$mailbox = reset($mailboxes);
|
||||||
|
return $mailbox === false ? null : $mailbox->name();
|
||||||
|
}
|
||||||
|
|
||||||
public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource
|
public function entityCreate(CollectionIdentifier $target, MessagePropertiesMutableInterface $properties, array $options = []): EntityResource
|
||||||
{
|
{
|
||||||
throw new \RuntimeException('Entity creation is not supported in this service');
|
throw new \RuntimeException('Entity creation is not supported in this service');
|
||||||
@@ -631,8 +720,18 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
|
|
||||||
foreach ($targets as $targetCollection => $targetIdentifiers) {
|
foreach ($targets as $targetCollection => $targetIdentifiers) {
|
||||||
$uids = array_keys($targetIdentifiers);
|
$uids = array_keys($targetIdentifiers);
|
||||||
|
$flagsAdd = [];
|
||||||
|
$flagsRemove = [];
|
||||||
|
|
||||||
$mutations = $this->mailService->entityPatch($targetCollection, $properties, ...$uids);
|
foreach ($properties->getFlags() as $flag => $value) {
|
||||||
|
if ($value === true) {
|
||||||
|
$flagsAdd[] = $flag;
|
||||||
|
} elseif ($value === false) {
|
||||||
|
$flagsRemove[] = $flag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mutations = $this->remoteService->entityPatch($targetCollection, $flagsAdd, $flagsRemove, ...$uids);
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
foreach ($uids as $uid) {
|
||||||
$list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched'];
|
$list[(string)$targetIdentifiers[$uid]] = ['disposition' => 'patched'];
|
||||||
@@ -663,17 +762,18 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
$filter = $this->collectionListFilter();
|
$filter = $this->collectionListFilter();
|
||||||
$filter->condition('role', CollectionRoles::Trash->value);
|
$filter->condition('role', CollectionRoles::Trash->value);
|
||||||
|
|
||||||
$mailboxes = iterator_to_array($this->mailService->collectionList(null, $filter, null));
|
/** @var Mailbox[] $mailboxes */
|
||||||
|
$mailboxes = iterator_to_array($this->remoteService->collectionList(null, $filter, null));
|
||||||
if (empty($mailboxes)) {
|
if (empty($mailboxes)) {
|
||||||
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
||||||
}
|
}
|
||||||
|
|
||||||
$rootMailbox = reset($mailboxes);
|
$targetMailbox = reset($mailboxes);
|
||||||
if ($rootMailbox === false) {
|
if ($targetMailbox === false) {
|
||||||
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
throw new \RuntimeException('No Trash collection configured or found for deletion');
|
||||||
}
|
}
|
||||||
|
|
||||||
$deleteTargetNative = $rootMailbox->name();
|
$deleteTargetNative = $targetMailbox->name();
|
||||||
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
|
$deleteTargetIdentifier = new CollectionIdentifier($this->provider(), (string) $this->identifier(), $deleteTargetNative);
|
||||||
} else {
|
} else {
|
||||||
$deleteTargetNative = $deleteTarget;
|
$deleteTargetNative = $deleteTarget;
|
||||||
@@ -695,8 +795,8 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
$uids = array_keys($sourceEntities);
|
$uids = array_keys($sourceEntities);
|
||||||
|
|
||||||
$mutations = match ($deleteMode) {
|
$mutations = match ($deleteMode) {
|
||||||
'soft' => $this->mailService->entityMove($deleteTargetNative, $sourceCollection, ...$uids),
|
'soft' => $this->remoteService->entityMove($deleteTargetNative, $sourceCollection, ...$uids),
|
||||||
'hard' => $this->mailService->entityDestroy($sourceCollection, ...$uids),
|
'hard' => $this->remoteService->entityDestroy($sourceCollection, ...$uids),
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
foreach ($uids as $uid) {
|
||||||
@@ -712,39 +812,6 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
return $list;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
|
||||||
{
|
|
||||||
// validate target belongs to this service
|
|
||||||
if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) {
|
|
||||||
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target);
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate identifiers and group by collection
|
|
||||||
$sources = $this->groupEntitiesByCollection(...$sources);
|
|
||||||
|
|
||||||
// copy entities on remote store and construct result map
|
|
||||||
$this->initialize();
|
|
||||||
|
|
||||||
$list = [];
|
|
||||||
|
|
||||||
foreach ($sources as $sourceCollection => $sourceEntities) {
|
|
||||||
$uids = array_keys($sourceEntities);
|
|
||||||
|
|
||||||
$mutations = $this->mailService->entityCopy($target->collection(), $sourceCollection, ...$uids);
|
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
|
||||||
$mutatedUid = $mutations[$uid] ?? null;
|
|
||||||
$list[(string)$sourceEntities[$uid]] = [
|
|
||||||
'disposition' => $mutatedUid !== null ? 'copied' : 'error',
|
|
||||||
'destination' => $target,
|
|
||||||
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $list;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
public function entityMove(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
||||||
{
|
{
|
||||||
// validate target belongs to this service
|
// validate target belongs to this service
|
||||||
@@ -763,7 +830,7 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
foreach ($sources as $sourceCollection => $sourceEntities) {
|
foreach ($sources as $sourceCollection => $sourceEntities) {
|
||||||
$uids = array_keys($sourceEntities);
|
$uids = array_keys($sourceEntities);
|
||||||
|
|
||||||
$mutations = $this->mailService->entityMove($target->collection(), $sourceCollection, ...$uids);
|
$mutations = $this->remoteService->entityMove($target->collection(), $sourceCollection, ...$uids);
|
||||||
|
|
||||||
foreach ($uids as $uid) {
|
foreach ($uids as $uid) {
|
||||||
$mutatedUid = $mutations[$uid] ?? null;
|
$mutatedUid = $mutations[$uid] ?? null;
|
||||||
@@ -772,6 +839,40 @@ class Service implements ServiceBaseInterface, ServiceMutableInterface, ServiceC
|
|||||||
'destination' => $target,
|
'destination' => $target,
|
||||||
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
|
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
|
||||||
];
|
];
|
||||||
|
unset($sourceEntities[$uid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function entityCopy(CollectionIdentifier $target, EntityIdentifier ...$sources): array
|
||||||
|
{
|
||||||
|
// validate target belongs to this service
|
||||||
|
if ($target->provider() !== $this->provider() || $target->service() !== $this->identifier()) {
|
||||||
|
throw new \InvalidArgumentException('Target collection does not belong to this service: ' . $target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate identifiers and group by collection
|
||||||
|
$sources = $this->groupEntitiesByCollection(...$sources);
|
||||||
|
|
||||||
|
// copy entities on remote store and construct result map
|
||||||
|
$this->initialize();
|
||||||
|
|
||||||
|
$list = [];
|
||||||
|
|
||||||
|
foreach ($sources as $sourceCollection => $sourceEntities) {
|
||||||
|
$uids = array_keys($sourceEntities);
|
||||||
|
|
||||||
|
$mutations = $this->remoteService->entityCopy($target->collection(), $sourceCollection, ...$uids);
|
||||||
|
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$mutatedUid = $mutations[$uid] ?? null;
|
||||||
|
$list[(string)$sourceEntities[$uid]] = [
|
||||||
|
'disposition' => $mutatedUid !== null ? 'copied' : 'error',
|
||||||
|
'destination' => $target,
|
||||||
|
'mutation' => $mutatedUid !== null ? new EntityIdentifier($this->provider(), $this->identifier(), $target->collection(), $mutatedUid) : null,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,22 +11,29 @@ namespace KTXM\ProviderImap\Providers;
|
|||||||
|
|
||||||
use KTXM\ProviderImap\Client\ConnectionConfig;
|
use KTXM\ProviderImap\Client\ConnectionConfig;
|
||||||
use KTXM\ProviderImap\Client\ConnectionSecurity;
|
use KTXM\ProviderImap\Client\ConnectionSecurity;
|
||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
use KTXM\ProviderImap\Smtp\ConnectionConfig as SmtpConnectionConfig;
|
||||||
|
use KTXM\ProviderImap\Smtp\ConnectionSecurity as SmtpConnectionSecurity;
|
||||||
|
use KTXF\Resource\Provider\ResourceServiceLocationSocketSplit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMAP Service Location
|
* IMAP/SMTP Service Location
|
||||||
*
|
*
|
||||||
* Connection details for an IMAP server (host / port / encryption).
|
* Split socket location: the inbound side describes the IMAP server, the
|
||||||
|
* outbound side describes the SMTP submission server.
|
||||||
*/
|
*/
|
||||||
class ServiceLocation implements ResourceServiceLocationInterface
|
class ServiceLocation implements ResourceServiceLocationSocketSplit
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private string $host = '',
|
private string $inboundHost = '',
|
||||||
private int $port = 993,
|
private int $inboundPort = 993,
|
||||||
private string $encryption = 'ssl', // ssl | tls | starttls | none
|
private string $inboundEncryption = 'ssl', // none | ssl | tls | starttls
|
||||||
private bool $verifyPeer = true,
|
private bool $inboundVerifyPeer = true,
|
||||||
private bool $verifyPeerName = true,
|
private bool $inboundVerifyHost = true,
|
||||||
private bool $allowSelfSigned = false,
|
private string $outboundHost = '',
|
||||||
|
private int $outboundPort = 587,
|
||||||
|
private string $outboundEncryption = 'starttls', // none | ssl | tls | starttls
|
||||||
|
private bool $outboundVerifyPeer = true,
|
||||||
|
private bool $outboundVerifyHost = true,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── Serialisation ────────────────────────────────────────────────────────
|
// ── Serialisation ────────────────────────────────────────────────────────
|
||||||
@@ -43,15 +50,19 @@ class ServiceLocation implements ResourceServiceLocationInterface
|
|||||||
|
|
||||||
public function jsonSerialize(): array
|
public function jsonSerialize(): array
|
||||||
{
|
{
|
||||||
return array_filter([
|
return [
|
||||||
'type' => self::TYPE_URI,
|
'type' => self::TYPE_SOCKET_SPLIT,
|
||||||
'host' => $this->host,
|
'inboundHost' => $this->inboundHost,
|
||||||
'port' => $this->port,
|
'inboundPort' => $this->inboundPort,
|
||||||
'encryption' => $this->encryption,
|
'inboundEncryption' => $this->inboundEncryption,
|
||||||
'verifyPeer' => $this->verifyPeer,
|
'inboundVerifyPeer' => $this->inboundVerifyPeer,
|
||||||
'verifyPeerName' => $this->verifyPeerName,
|
'inboundVerifyHost' => $this->inboundVerifyHost,
|
||||||
'allowSelfSigned' => $this->allowSelfSigned,
|
'outboundHost' => $this->outboundHost,
|
||||||
], fn($v) => $v !== null && $v !== '');
|
'outboundPort' => $this->outboundPort,
|
||||||
|
'outboundEncryption' => $this->outboundEncryption,
|
||||||
|
'outboundVerifyPeer' => $this->outboundVerifyPeer,
|
||||||
|
'outboundVerifyHost' => $this->outboundVerifyHost,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function jsonDeserialize(array|string $data): static
|
public function jsonDeserialize(array|string $data): static
|
||||||
@@ -60,70 +71,119 @@ class ServiceLocation implements ResourceServiceLocationInterface
|
|||||||
$data = json_decode($data, true);
|
$data = json_decode($data, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->host = $data['host'] ?? '';
|
$this->inboundHost = $data['inboundHost'] ?? '';
|
||||||
$this->port = (int)($data['port'] ?? 993);
|
$this->inboundPort = (int) ($data['inboundPort'] ?? 993);
|
||||||
$this->encryption = $data['encryption'] ?? 'ssl';
|
$this->inboundEncryption = $data['inboundEncryption'] ?? 'ssl';
|
||||||
$this->verifyPeer = $data['verifyPeer'] ?? true;
|
$this->inboundVerifyPeer = $data['inboundVerifyPeer'] ?? true;
|
||||||
$this->verifyPeerName = $data['verifyPeerName'] ?? true;
|
$this->inboundVerifyHost = $data['inboundVerifyHost'] ?? true;
|
||||||
$this->allowSelfSigned = $data['allowSelfSigned'] ?? false;
|
|
||||||
|
$this->outboundHost = $data['outboundHost'] ?? '';
|
||||||
|
$this->outboundPort = (int) ($data['outboundPort'] ?? 587);
|
||||||
|
$this->outboundEncryption = $data['outboundEncryption'] ?? 'starttls';
|
||||||
|
$this->outboundVerifyPeer = $data['outboundVerifyPeer'] ?? true;
|
||||||
|
$this->outboundVerifyHost = $data['outboundVerifyHost'] ?? true;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ResourceServiceLocationInterface ─────────────────────────────────────
|
// ── ResourceServiceLocationSocketSplit ───────────────────────────────────
|
||||||
|
|
||||||
public function type(): string
|
public function type(): string
|
||||||
{
|
{
|
||||||
return self::TYPE_URI;
|
return self::TYPE_SOCKET_SPLIT;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function location(): string
|
public function locationInbound(): string
|
||||||
{
|
{
|
||||||
return $this->encryption . '://' . $this->host . ':' . $this->port;
|
return $this->inboundEncryption . '://' . $this->inboundHost . ':' . $this->inboundPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Accessors ────────────────────────────────────────────────────────────
|
public function locationOutbound(): string
|
||||||
|
{
|
||||||
|
return $this->outboundEncryption . '://' . $this->outboundHost . ':' . $this->outboundPort;
|
||||||
|
}
|
||||||
|
|
||||||
public function getHost(): string { return $this->host; }
|
public function getInboundHost(): string { return $this->inboundHost; }
|
||||||
public function setHost(string $v): void { $this->host = $v; }
|
public function setInboundHost(string $value): void { $this->inboundHost = $value; }
|
||||||
|
|
||||||
public function getPort(): int { return $this->port; }
|
public function getOutboundHost(): string { return $this->outboundHost; }
|
||||||
public function setPort(int $v): void { $this->port = $v; }
|
public function setOutboundHost(string $value): void { $this->outboundHost = $value; }
|
||||||
|
|
||||||
public function getEncryption(): string { return $this->encryption; }
|
public function getInboundPort(): int { return $this->inboundPort; }
|
||||||
public function setEncryption(string $v): void { $this->encryption = $v; }
|
public function setInboundPort(int $value): void { $this->inboundPort = $value; }
|
||||||
|
|
||||||
public function getVerifyPeer(): bool { return $this->verifyPeer; }
|
public function getOutboundPort(): int { return $this->outboundPort; }
|
||||||
public function setVerifyPeer(bool $v): void { $this->verifyPeer = $v; }
|
public function setOutboundPort(int $value): void { $this->outboundPort = $value; }
|
||||||
|
|
||||||
public function getVerifyPeerName(): bool { return $this->verifyPeerName; }
|
public function getInboundEncryption(): string { return $this->inboundEncryption; }
|
||||||
public function setVerifyPeerName(bool $v): void { $this->verifyPeerName = $v; }
|
public function setInboundEncryption(string $value): void { $this->inboundEncryption = $value; }
|
||||||
|
|
||||||
public function getAllowSelfSigned(): bool { return $this->allowSelfSigned; }
|
public function getOutboundEncryption(): string { return $this->outboundEncryption; }
|
||||||
public function setAllowSelfSigned(bool $v): void { $this->allowSelfSigned = $v; }
|
public function setOutboundEncryption(string $value): void { $this->outboundEncryption = $value; }
|
||||||
|
|
||||||
|
public function getInboundVerifyPeer(): bool { return $this->inboundVerifyPeer; }
|
||||||
|
public function setInboundVerifyPeer(bool $value): void { $this->inboundVerifyPeer = $value; }
|
||||||
|
|
||||||
|
public function getInboundVerifyHost(): bool { return $this->inboundVerifyHost; }
|
||||||
|
public function setInboundVerifyHost(bool $value): void { $this->inboundVerifyHost = $value; }
|
||||||
|
|
||||||
|
public function getOutboundVerifyPeer(): bool { return $this->outboundVerifyPeer; }
|
||||||
|
public function setOutboundVerifyPeer(bool $value): void { $this->outboundVerifyPeer = $value; }
|
||||||
|
|
||||||
|
public function getOutboundVerifyHost(): bool { return $this->outboundVerifyHost; }
|
||||||
|
public function setOutboundVerifyHost(bool $value): void { $this->outboundVerifyHost = $value; }
|
||||||
|
|
||||||
// ── Client helpers ───────────────────────────────────────────────────────
|
// ── Client helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a standalone IMAP client ConnectionConfig from this location.
|
* Build an IMAP ConnectionConfig from the inbound side.
|
||||||
*/
|
*/
|
||||||
public function toConnectionConfig(?string $username = null, ?string $password = null): ConnectionConfig
|
public function toConnectionConfig(?string $username = null, ?string $password = null): ConnectionConfig
|
||||||
{
|
{
|
||||||
$security = match ($this->encryption) {
|
|
||||||
'ssl', 'tls' => ConnectionSecurity::Tls,
|
|
||||||
'starttls' => ConnectionSecurity::StartTls,
|
|
||||||
default => ConnectionSecurity::Plain,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new ConnectionConfig(
|
return new ConnectionConfig(
|
||||||
host: $this->host,
|
host: $this->inboundHost,
|
||||||
port: $this->port,
|
port: $this->inboundPort,
|
||||||
security: $security,
|
security: $this->imapSecurity($this->inboundEncryption),
|
||||||
username: $username,
|
username: $username,
|
||||||
password: $password,
|
password: $password,
|
||||||
verifyPeer: $this->verifyPeer,
|
verifyPeer: $this->inboundVerifyPeer,
|
||||||
verifyPeerName: $this->verifyPeerName,
|
verifyPeerName: $this->inboundVerifyHost,
|
||||||
allowSelfSigned: $this->allowSelfSigned,
|
allowSelfSigned: !$this->inboundVerifyPeer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an SMTP submission ConnectionConfig from the outbound side.
|
||||||
|
*/
|
||||||
|
public function toSmtpConnectionConfig(?string $username = null, ?string $password = null): SmtpConnectionConfig
|
||||||
|
{
|
||||||
|
return new SmtpConnectionConfig(
|
||||||
|
host: $this->outboundHost,
|
||||||
|
port: $this->outboundPort,
|
||||||
|
security: $this->smtpSecurity($this->outboundEncryption),
|
||||||
|
username: $username,
|
||||||
|
password: $password,
|
||||||
|
verifyPeer: $this->outboundVerifyPeer,
|
||||||
|
verifyPeerName: $this->outboundVerifyHost,
|
||||||
|
allowSelfSigned: !$this->outboundVerifyPeer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function imapSecurity(string $encryption): ConnectionSecurity
|
||||||
|
{
|
||||||
|
return match ($encryption) {
|
||||||
|
'ssl', 'tls' => ConnectionSecurity::Tls,
|
||||||
|
'starttls' => ConnectionSecurity::StartTls,
|
||||||
|
default => ConnectionSecurity::Plain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function smtpSecurity(string $encryption): SmtpConnectionSecurity
|
||||||
|
{
|
||||||
|
return match ($encryption) {
|
||||||
|
'ssl', 'tls' => SmtpConnectionSecurity::Tls,
|
||||||
|
'starttls' => SmtpConnectionSecurity::StartTls,
|
||||||
|
default => SmtpConnectionSecurity::Plain,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ class Discovery
|
|||||||
];
|
];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
foreach ($results as $r) {
|
foreach ($results as $r) {
|
||||||
$seen[$r->getHost()] = true;
|
$seen[$r->getInboundHost()] = true;
|
||||||
}
|
}
|
||||||
foreach ($candidates as $host) {
|
foreach ($candidates as $host) {
|
||||||
if (isset($seen[$host])) {
|
if (isset($seen[$host])) {
|
||||||
@@ -203,10 +203,11 @@ class Discovery
|
|||||||
|
|
||||||
$loc = new ServiceLocation();
|
$loc = new ServiceLocation();
|
||||||
$loc->jsonDeserialize([
|
$loc->jsonDeserialize([
|
||||||
'host' => $host,
|
'inboundHost' => $host,
|
||||||
'port' => $port,
|
'inboundPort' => $port,
|
||||||
'encryption' => $encryption,
|
'inboundEncryption' => $encryption,
|
||||||
'verifyPeer' => $verifySSL,
|
'inboundVerifyPeer' => $verifySSL,
|
||||||
|
'inboundVerifyHost' => $verifySSL,
|
||||||
]);
|
]);
|
||||||
return $loc;
|
return $loc;
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ namespace KTXM\ProviderImap\Service\Remote;
|
|||||||
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Generator;
|
use Generator;
|
||||||
use KTXM\ProviderImap\Client\Client;
|
use KTXC\Server;
|
||||||
|
use KTXC\Logger\PlainFileLogger;
|
||||||
|
use KTXM\ProviderImap\Client\Client as ImapClient;
|
||||||
|
use KTXM\ProviderImap\Client\Command\AppendCommand;
|
||||||
use KTXM\ProviderImap\Client\Command\FetchManyCommand;
|
use KTXM\ProviderImap\Client\Command\FetchManyCommand;
|
||||||
|
use KTXM\ProviderImap\Client\Command\FetchOneCommand;
|
||||||
use KTXM\ProviderImap\Client\Command\ExpungeCommand;
|
use KTXM\ProviderImap\Client\Command\ExpungeCommand;
|
||||||
use KTXM\ProviderImap\Client\Command\ListCommand;
|
use KTXM\ProviderImap\Client\Command\ListCommand;
|
||||||
use KTXM\ProviderImap\Client\Command\SearchCommand;
|
use KTXM\ProviderImap\Client\Command\SearchCommand;
|
||||||
@@ -45,8 +49,10 @@ use KTXF\Resource\Range\IRangeTally;
|
|||||||
use KTXF\Resource\Range\RangeAnchorType;
|
use KTXF\Resource\Range\RangeAnchorType;
|
||||||
use KTXF\Resource\Range\RangeTally;
|
use KTXF\Resource\Range\RangeTally;
|
||||||
use KTXF\Resource\Sort\ISort;
|
use KTXF\Resource\Sort\ISort;
|
||||||
use KTXM\ProviderImap\Providers\CollectionResource;
|
use KTXF\Resource\BinaryResource;
|
||||||
use KTXM\ProviderImap\Providers\EntityResource;
|
use KTXM\ProviderImap\Providers\Service;
|
||||||
|
use KTXM\ProviderImap\Smtp\Client as SmtpClient;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMAP Remote Mail Service
|
* IMAP Remote Mail Service
|
||||||
@@ -57,10 +63,80 @@ class RemoteMailService
|
|||||||
private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription'];
|
private const COLLECTION_FILTER_OPTIONS = ['name', 'role', 'subscription'];
|
||||||
private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY'];
|
private const DEFAULT_MAILBOX_STATUS_ITEMS = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY'];
|
||||||
|
|
||||||
|
private ?ImapClient $imapClient = null;
|
||||||
|
private ?SmtpClient $smtpClient = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Client $client,
|
private readonly Service $service,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connected IMAP client, established on first use.
|
||||||
|
*/
|
||||||
|
public function imapClient(): ImapClient
|
||||||
|
{
|
||||||
|
if ($this->imapClient instanceof ImapClient) {
|
||||||
|
return $this->imapClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
$location = $this->service->getLocation();
|
||||||
|
if ($location === null || $location->getInboundHost() === '') {
|
||||||
|
throw new RuntimeException('No IMAP (inbound) host is configured for this service.');
|
||||||
|
}
|
||||||
|
$identity = $this->service->getIdentity();
|
||||||
|
|
||||||
|
$config = $location->toConnectionConfig(
|
||||||
|
$identity?->getIdentity(),
|
||||||
|
$identity?->getSecret(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client = new ImapClient(logger: $this->logger('imap'));
|
||||||
|
$client->connect($config);
|
||||||
|
|
||||||
|
return $this->imapClient = $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connected SMTP client, established on first use.
|
||||||
|
*/
|
||||||
|
public function smtpClient(): SmtpClient
|
||||||
|
{
|
||||||
|
if ($this->smtpClient instanceof SmtpClient) {
|
||||||
|
return $this->smtpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
$location = $this->service->getLocation();
|
||||||
|
if ($location === null || $location->getOutboundHost() === '') {
|
||||||
|
throw new RuntimeException('No SMTP submission (outbound) host is configured for this service.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$identity = $this->service->getIdentity();
|
||||||
|
|
||||||
|
$config = $location->toSmtpConnectionConfig(
|
||||||
|
$identity?->getIdentity(),
|
||||||
|
$identity?->getSecret(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client = new SmtpClient(logger: $this->logger('smtp'));
|
||||||
|
$client->connect($config);
|
||||||
|
|
||||||
|
return $this->smtpClient = $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a per-protocol file logger when the service has debug enabled.
|
||||||
|
*/
|
||||||
|
private function logger(string $channel): ?PlainFileLogger
|
||||||
|
{
|
||||||
|
if (!$this->service->getDebug()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log';
|
||||||
|
|
||||||
|
return new PlainFileLogger($logDir . '/' . $channel, $this->service->identifier());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* list of collections in remote storage
|
* list of collections in remote storage
|
||||||
*
|
*
|
||||||
@@ -78,7 +154,7 @@ class RemoteMailService
|
|||||||
$location = '';
|
$location = '';
|
||||||
}
|
}
|
||||||
// construct the most efficient LIST command based on server capabilities
|
// construct the most efficient LIST command based on server capabilities
|
||||||
if ($this->client->hasCapability('LIST-STATUS') && !empty($depth)) {
|
if ($this->imapClient()->hasCapability('LIST-STATUS') && !empty($depth)) {
|
||||||
$command = new ListCommand($location, $depth, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
$command = new ListCommand($location, $depth, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||||
$rfc5258 = true;
|
$rfc5258 = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -87,7 +163,7 @@ class RemoteMailService
|
|||||||
}
|
}
|
||||||
// retrieve list of mailboxes from remote
|
// retrieve list of mailboxes from remote
|
||||||
$mailboxes = [];
|
$mailboxes = [];
|
||||||
foreach ($this->client->perform($command) as $mailbox) {
|
foreach ($this->imapClient()->perform($command) as $mailbox) {
|
||||||
// apply filter
|
// apply filter
|
||||||
if ($filter === null || $this->mailboxFilter($mailbox, $filter)) {
|
if ($filter === null || $this->mailboxFilter($mailbox, $filter)) {
|
||||||
if ($rfc5258) {
|
if ($rfc5258) {
|
||||||
@@ -105,7 +181,7 @@ class RemoteMailService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
$status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||||
$mailbox = $mailbox->fromStatus($status);
|
$mailbox = $mailbox->fromStatus($status);
|
||||||
} catch (ImapException) {
|
} catch (ImapException) {
|
||||||
// do nothing
|
// do nothing
|
||||||
@@ -125,13 +201,13 @@ class RemoteMailService
|
|||||||
public function collectionFetch(string $identifier): ?Mailbox
|
public function collectionFetch(string $identifier): ?Mailbox
|
||||||
{
|
{
|
||||||
// retrieve mailbox from remote
|
// retrieve mailbox from remote
|
||||||
$mailbox = iterator_to_array($this->client->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS))));
|
$mailbox = iterator_to_array($this->imapClient()->perform(new ListCommand('', $identifier, null, ListReturnOptions::status(...self::DEFAULT_MAILBOX_STATUS_ITEMS))));
|
||||||
if (empty($mailbox)) {
|
if (empty($mailbox)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
$mailbox = reset($mailbox);
|
$mailbox = reset($mailbox);
|
||||||
// enrich with STATUS
|
// enrich with STATUS
|
||||||
$status = $this->client->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
$status = $this->imapClient()->perform(new StatusCommand($mailbox->name(), self::DEFAULT_MAILBOX_STATUS_ITEMS));
|
||||||
$mailbox = $mailbox->fromStatus($status);
|
$mailbox = $mailbox->fromStatus($status);
|
||||||
|
|
||||||
return $mailbox;
|
return $mailbox;
|
||||||
@@ -145,7 +221,7 @@ class RemoteMailService
|
|||||||
*/
|
*/
|
||||||
public function collectionCreate(string $name): Mailbox
|
public function collectionCreate(string $name): Mailbox
|
||||||
{
|
{
|
||||||
$result = $this->client->perform(new CreateCommand($name));
|
$result = $this->imapClient()->perform(new CreateCommand($name));
|
||||||
|
|
||||||
if (!$result->isOk()) {
|
if (!$result->isOk()) {
|
||||||
throw new ImapException('Failed to create mailbox: ' . $name);
|
throw new ImapException('Failed to create mailbox: ' . $name);
|
||||||
@@ -165,7 +241,7 @@ class RemoteMailService
|
|||||||
*/
|
*/
|
||||||
public function collectionRename(string $oldName, string $newName): Mailbox
|
public function collectionRename(string $oldName, string $newName): Mailbox
|
||||||
{
|
{
|
||||||
$result = $this->client->perform(new RenameCommand($oldName, $newName));
|
$result = $this->imapClient()->perform(new RenameCommand($oldName, $newName));
|
||||||
|
|
||||||
if (!$result->isOk()) {
|
if (!$result->isOk()) {
|
||||||
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
|
throw new ImapException('Failed to rename mailbox: ' . $oldName . ' to ' . $newName);
|
||||||
@@ -185,7 +261,7 @@ class RemoteMailService
|
|||||||
*/
|
*/
|
||||||
public function collectionDestroy(string $name): bool
|
public function collectionDestroy(string $name): bool
|
||||||
{
|
{
|
||||||
$result = $this->client->perform(new DeleteCommand($name));
|
$result = $this->imapClient()->perform(new DeleteCommand($name));
|
||||||
|
|
||||||
if (!$result->isOk()) {
|
if (!$result->isOk()) {
|
||||||
throw new ImapException('Failed to delete mailbox: ' . $name);
|
throw new ImapException('Failed to delete mailbox: ' . $name);
|
||||||
@@ -206,18 +282,18 @@ class RemoteMailService
|
|||||||
$nativeFilter = $this->buildEntitySearchCriteria($filter);
|
$nativeFilter = $this->buildEntitySearchCriteria($filter);
|
||||||
$nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : [];
|
$nativeSort = $sort !== null ? $this->entitySortCriteria($sort) : [];
|
||||||
|
|
||||||
$this->client->perform(new SelectCommand($collection, true));
|
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||||
$rfc5258 = $this->client->hasCapability('SORT');
|
$rfc5258 = $this->imapClient()->hasCapability('SORT');
|
||||||
|
|
||||||
$uids = [];
|
$uids = [];
|
||||||
if ($nativeSort !== [] && $rfc5258) {
|
if ($nativeSort !== [] && $rfc5258) {
|
||||||
$uids = $this->client->perform(new SortCommand(
|
$uids = $this->imapClient()->perform(new SortCommand(
|
||||||
$nativeSort,
|
$nativeSort,
|
||||||
$nativeFilter,
|
$nativeFilter,
|
||||||
IdentifierMode::Uid,
|
IdentifierMode::Uid,
|
||||||
))->matches();
|
))->matches();
|
||||||
} else {
|
} else {
|
||||||
$uids = $this->client->perform(new SearchCommand(
|
$uids = $this->imapClient()->perform(new SearchCommand(
|
||||||
$nativeFilter,
|
$nativeFilter,
|
||||||
IdentifierMode::Uid,
|
IdentifierMode::Uid,
|
||||||
))->matches();
|
))->matches();
|
||||||
@@ -245,13 +321,13 @@ class RemoteMailService
|
|||||||
|
|
||||||
// fast path: fetch all messages without filtering, sorting or pagination
|
// fast path: fetch all messages without filtering, sorting or pagination
|
||||||
if ($filter === null && $sort === null && $range === null) {
|
if ($filter === null && $sort === null && $range === null) {
|
||||||
$mailbox = $this->client->perform(new SelectCommand($collection, true));
|
$mailbox = $this->imapClient()->perform(new SelectCommand($collection, true));
|
||||||
|
|
||||||
if ($mailbox === null) {
|
if ($mailbox === null) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
yield from $this->client->perform(new FetchManyCommand(
|
yield from $this->imapClient()->perform(new FetchManyCommand(
|
||||||
FetchTarget::all(),
|
FetchTarget::all(),
|
||||||
$options,
|
$options,
|
||||||
));
|
));
|
||||||
@@ -282,27 +358,137 @@ class RemoteMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$options ??= FetchOptions::message()->withBodyText();
|
$options ??= FetchOptions::message()->withBodyText();
|
||||||
$this->client->perform(new SelectCommand($collection, true));
|
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||||
|
|
||||||
$request = new FetchManyCommand(
|
$request = new FetchManyCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$options,
|
$options,
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach ($this->client->perform($request) as $message) {
|
foreach ($this->imapClient()->perform($request) as $message) {
|
||||||
$uid = $message->uid() ?: $message->sequence();
|
$uid = $message->uid() ?: $message->sequence();
|
||||||
yield $uid => $message;
|
yield $uid => $message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream the raw bytes of a message or a specific MIME part without buffering.
|
||||||
|
*
|
||||||
|
* When $partId is given, first fetches BODYSTRUCTURE to determine the
|
||||||
|
* correct filename and MIME type, then starts the streaming body fetch.
|
||||||
|
*
|
||||||
|
* @param string $collection Mailbox name
|
||||||
|
* @param int $uid Message UID
|
||||||
|
* @param string|null $partId MIME section (e.g. '1', '1.2'); null = full RFC 822
|
||||||
|
*/
|
||||||
|
public function entityDownload(string $collection, int $uid, ?string $partId = null): BinaryResource
|
||||||
|
{
|
||||||
|
$this->imapClient()->perform(new SelectCommand($collection, true));
|
||||||
|
|
||||||
|
$encoding = null;
|
||||||
|
|
||||||
|
if ($partId === null) {
|
||||||
|
$filename = 'message.eml';
|
||||||
|
$mimeType = 'message/rfc822';
|
||||||
|
} else {
|
||||||
|
// Fetch BODYSTRUCTURE first to determine metadata (no body bytes transferred)
|
||||||
|
$message = $this->imapClient()->perform(new FetchOneCommand(
|
||||||
|
FetchTarget::uid(SequenceSet::items($uid)),
|
||||||
|
FetchOptions::of('BODYSTRUCTURE'),
|
||||||
|
));
|
||||||
|
$bodyStructure = $message->bodyStructure();
|
||||||
|
$part = $bodyStructure !== null ? $this->findBodyPart($bodyStructure, $partId) : null;
|
||||||
|
$mimeType = $part?->mimeType() ?? 'application/octet-stream';
|
||||||
|
$partData = $part?->toArray() ?? [];
|
||||||
|
$filename = isset($partData['name']) && $partData['name'] !== ''
|
||||||
|
? $partData['name']
|
||||||
|
: "attachment-{$partId}";
|
||||||
|
$encoding = $part?->encoding();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start download stream
|
||||||
|
$stream = $this->decodeStream(
|
||||||
|
$this->imapClient()->download(FetchTarget::uid(SequenceSet::items($uid)), $partId ?? ''),
|
||||||
|
$encoding
|
||||||
|
);
|
||||||
|
|
||||||
|
return new BinaryResource($filename, $mimeType, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findBodyPart(MessagePart $root, string $partId): ?MessagePart
|
||||||
|
{
|
||||||
|
if ($root->partId() === $partId) {
|
||||||
|
return $root;
|
||||||
|
}
|
||||||
|
foreach ($root->parts() as $child) {
|
||||||
|
$found = $this->findBodyPart($child, $partId);
|
||||||
|
if ($found !== null) {
|
||||||
|
return $found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a raw IMAP body stream with a transfer-encoding decoder.
|
||||||
|
*
|
||||||
|
* IMAP BODY[n] literals are delivered in the transfer encoding declared
|
||||||
|
* by BODYSTRUCTURE (typically base64 or quoted-printable for attachments).
|
||||||
|
* 7bit / 8bit / binary sections pass through unchanged.
|
||||||
|
*/
|
||||||
|
private function decodeStream(\Generator $stream, ?string $encoding): \Generator
|
||||||
|
{
|
||||||
|
return match (strtolower($encoding ?? '')) {
|
||||||
|
'base64' => $this->decodeBase64Stream($stream),
|
||||||
|
'quoted-printable' => $this->decodeQpStream($stream),
|
||||||
|
default => $stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeBase64Stream(\Generator $source): \Generator
|
||||||
|
{
|
||||||
|
$buffer = '';
|
||||||
|
foreach ($source as $chunk) {
|
||||||
|
// IMAP folds base64 at 76 chars with CRLF — strip all whitespace
|
||||||
|
$buffer .= preg_replace('/\s+/', '', $chunk);
|
||||||
|
// Decode complete 4-character groups; keep any partial tail
|
||||||
|
$remainder = strlen($buffer) % 4;
|
||||||
|
$complete = strlen($buffer) - $remainder;
|
||||||
|
if ($complete > 0) {
|
||||||
|
yield base64_decode(substr($buffer, 0, $complete), true);
|
||||||
|
$buffer = substr($buffer, $complete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Flush remainder (handles padded or stripped trailing '=')
|
||||||
|
if ($buffer !== '') {
|
||||||
|
yield base64_decode($buffer, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeQpStream(\Generator $source): \Generator
|
||||||
|
{
|
||||||
|
// Buffer until we have complete lines so soft-line-breaks are intact
|
||||||
|
$buffer = '';
|
||||||
|
foreach ($source as $chunk) {
|
||||||
|
$buffer .= $chunk;
|
||||||
|
while (($pos = strpos($buffer, "\n")) !== false) {
|
||||||
|
yield quoted_printable_decode(substr($buffer, 0, $pos + 1));
|
||||||
|
$buffer = substr($buffer, $pos + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($buffer !== '') {
|
||||||
|
yield quoted_printable_decode($buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a raw RFC 822 message to a mailbox and return the assigned UID.
|
* Append a raw RFC 822 message to a mailbox and return the assigned UID.
|
||||||
*
|
*
|
||||||
* @param string[] $flags optional initial flags, e.g. ['\\Seen']
|
* @param string[] $flags optional initial flags, e.g. ['\\Seen']
|
||||||
*/
|
*/
|
||||||
public function entityCreate(string $collection, string $rawMessage, array $flags = []): int
|
public function entityCreate(string $collection, string $rawMessage, array $flags = []): ?int
|
||||||
{
|
{
|
||||||
return $this->client->append($rawMessage, $collection, !empty($flags) ? $flags : null);
|
return $this->imapClient()->perform(new AppendCommand($collection, $rawMessage, $flags));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -318,8 +504,8 @@ class RemoteMailService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->client->perform(new SelectCommand($collection, false));
|
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||||
$this->client->perform(new StoreCommand(
|
$this->imapClient()->perform(new StoreCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$flags,
|
$flags,
|
||||||
$action,
|
$action,
|
||||||
@@ -337,9 +523,9 @@ class RemoteMailService
|
|||||||
|
|
||||||
$target = FetchTarget::uid(SequenceSet::items(...array_values($uids)));
|
$target = FetchTarget::uid(SequenceSet::items(...array_values($uids)));
|
||||||
|
|
||||||
$this->client->perform(new SelectCommand($collection, false));
|
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||||
$this->client->perform(new StoreCommand($target, ['\\Deleted'], '+'));
|
$this->imapClient()->perform(new StoreCommand($target, ['\\Deleted'], '+'));
|
||||||
$this->client->perform(new ExpungeCommand($target));
|
$this->imapClient()->perform(new ExpungeCommand($target));
|
||||||
|
|
||||||
// TODO: find a way to determine which actual UID's were deleted
|
// TODO: find a way to determine which actual UID's were deleted
|
||||||
return array_fill_keys($uids, true);
|
return array_fill_keys($uids, true);
|
||||||
@@ -351,10 +537,13 @@ class RemoteMailService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->client->perform(new SelectCommand($collection, false));
|
$this->imapClient()->perform(new SelectCommand($collection, false));
|
||||||
|
|
||||||
|
$flagsToAdd = $this->normalizeFlags($flagsToAdd);
|
||||||
|
$flagsToRemove = $this->normalizeFlags($flagsToRemove);
|
||||||
|
|
||||||
if (!empty($flagsToAdd)) {
|
if (!empty($flagsToAdd)) {
|
||||||
$this->client->perform(new StoreCommand(
|
$this->imapClient()->perform(new StoreCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$flagsToAdd,
|
$flagsToAdd,
|
||||||
'+',
|
'+',
|
||||||
@@ -362,7 +551,7 @@ class RemoteMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($flagsToRemove)) {
|
if (!empty($flagsToRemove)) {
|
||||||
$this->client->perform(new StoreCommand(
|
$this->imapClient()->perform(new StoreCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$flagsToRemove,
|
$flagsToRemove,
|
||||||
'-',
|
'-',
|
||||||
@@ -376,28 +565,28 @@ class RemoteMailService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$rfc6851 = $this->client->hasCapability('MOVE');
|
$rfc6851 = $this->imapClient()->hasCapability('MOVE');
|
||||||
|
|
||||||
// if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE
|
// if MOVE is supported, use it; otherwise, fall back to COPY + EXPUNGE
|
||||||
if ($rfc6851) {
|
if ($rfc6851) {
|
||||||
$this->client->perform(new SelectCommand($sourceCollection, false));
|
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||||
$response = $this->client->perform(new MoveCommand(
|
$response = $this->imapClient()->perform(new MoveCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$targetCollection,
|
$targetCollection,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
$this->client->perform(new SelectCommand($sourceCollection, false));
|
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||||
$response = $this->client->perform(new CopyCommand(
|
$response = $this->imapClient()->perform(new CopyCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$targetCollection,
|
$targetCollection,
|
||||||
));
|
));
|
||||||
if ($response->isOk()) {
|
if ($response->isOk()) {
|
||||||
$this->client->perform(new StoreCommand(
|
$this->imapClient()->perform(new StoreCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
['\\Deleted'],
|
['\\Deleted'],
|
||||||
'+',
|
'+',
|
||||||
));
|
));
|
||||||
$this->client->perform(new ExpungeCommand(
|
$this->imapClient()->perform(new ExpungeCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -422,6 +611,36 @@ class RemoteMailService
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function entityCopy(string $targetCollection, string $sourceCollection, int ...$uids): array
|
||||||
|
{
|
||||||
|
if (empty($uids)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->imapClient()->perform(new SelectCommand($sourceCollection, false));
|
||||||
|
$response = $this->imapClient()->perform(new CopyCommand(
|
||||||
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
|
$targetCollection,
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!$response->isOk()) {
|
||||||
|
throw new ImapException('Failed to copy messages: ' . implode(', ', $response->responseCodes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// construct operation result as a map of source UID to boolean or destination UID, depending on server support
|
||||||
|
$map = $response->copyUidMap();
|
||||||
|
if ($map === []) {
|
||||||
|
$result = array_fill_keys(array_map('strval', $uids), true);
|
||||||
|
} else {
|
||||||
|
$result = array_fill_keys(array_map('strval', $uids), false);
|
||||||
|
foreach ($uids as $uid) {
|
||||||
|
$result[$uid] = $map[$uid] ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
private function buildEntitySearchCriteria(?IFilter $filter): SearchCriteriaBuilder
|
private function buildEntitySearchCriteria(?IFilter $filter): SearchCriteriaBuilder
|
||||||
{
|
{
|
||||||
if ($filter === null || $filter->conditions() === []) {
|
if ($filter === null || $filter->conditions() === []) {
|
||||||
@@ -624,7 +843,7 @@ class RemoteMailService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$messages = iterator_to_array($this->client->perform(new FetchManyCommand(
|
$messages = iterator_to_array($this->imapClient()->perform(new FetchManyCommand(
|
||||||
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
FetchTarget::uid(SequenceSet::items(...array_values($uids))),
|
||||||
$options,
|
$options,
|
||||||
)));
|
)));
|
||||||
@@ -869,4 +1088,23 @@ class RemoteMailService
|
|||||||
|
|
||||||
return CollectionRoles::None->value;
|
return CollectionRoles::None->value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalizeFlags(array $flags): array
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'read' => '\\Seen',
|
||||||
|
'answered' => '\\Answered',
|
||||||
|
'flagged' => '\\Flagged',
|
||||||
|
'deleted' => '\\Deleted',
|
||||||
|
'draft' => '\\Draft',
|
||||||
|
];
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($flags as $flag) {
|
||||||
|
$flag = strtolower(trim($flag));
|
||||||
|
if (isset($map[$flag])) {
|
||||||
|
$normalized[] = $map[$flag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace KTXM\ProviderImap\Service\Remote;
|
|
||||||
|
|
||||||
use KTXC\Server;
|
|
||||||
use KTXC\Logger\PlainFileLogger;
|
|
||||||
use KTXM\ProviderImap\Client\Client;
|
|
||||||
use KTXM\ProviderImap\Providers\Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Static factory for IMAP remote service objects.
|
|
||||||
*
|
|
||||||
* - freshClient() → builds a gricob Client from service config
|
|
||||||
* - mailService() → constructs a RemoteMailService from the client
|
|
||||||
*/
|
|
||||||
class RemoteService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Build and bootstrap a fully-configured IMAP client from a Service.
|
|
||||||
*/
|
|
||||||
public static function freshClient(Service $service): Client
|
|
||||||
{
|
|
||||||
$location = $service->getLocation();
|
|
||||||
$identity = $service->getIdentity();
|
|
||||||
|
|
||||||
// Build a file logger when debug mode is enabled, otherwise pass null
|
|
||||||
$logger = null;
|
|
||||||
if ($service->getDebug()) {
|
|
||||||
$logDir = Server::getInstance()?->logDir() ?? __DIR__ . '/../../../../../var/log';
|
|
||||||
$logger = new PlainFileLogger($logDir . '/imap', $service->identifier());
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = $location->toConnectionConfig(
|
|
||||||
$identity?->getIdentity(),
|
|
||||||
$identity?->getSecret(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$client = new Client(logger: $logger);
|
|
||||||
$client->connect($config);
|
|
||||||
|
|
||||||
return $client;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a RemoteMailService from a Service and a pre-authenticated client.
|
|
||||||
*
|
|
||||||
* The provider identifier and service ID are taken directly from the Service
|
|
||||||
* object so the caller does not have to repeat them.
|
|
||||||
*/
|
|
||||||
public static function mailService(Service $service, Client $client): RemoteMailService
|
|
||||||
{
|
|
||||||
return new RemoteMailService($client, $service->provider(), $service->identifier());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp\Auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported SASL authentication mechanisms, in preference order.
|
||||||
|
*/
|
||||||
|
enum AuthMechanism: string
|
||||||
|
{
|
||||||
|
case Plain = 'PLAIN';
|
||||||
|
case Login = 'LOGIN';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the most preferred mechanism the server advertises.
|
||||||
|
*
|
||||||
|
* @param list<string> $advertised mechanisms offered by the server (uppercased)
|
||||||
|
*/
|
||||||
|
public static function select(array $advertised): ?self
|
||||||
|
{
|
||||||
|
foreach ([self::Plain, self::Login] as $mechanism) {
|
||||||
|
if (in_array($mechanism->value, $advertised, true)) {
|
||||||
|
return $mechanism;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The initial AUTH response for the PLAIN mechanism:
|
||||||
|
* base64( authzid NUL authcid NUL passwd ).
|
||||||
|
*/
|
||||||
|
public static function plainToken(string $username, string $password): string
|
||||||
|
{
|
||||||
|
return base64_encode("\0" . $username . "\0" . $password);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Smtp\Auth\AuthMechanism;
|
||||||
|
use KTXM\ProviderImap\Smtp\Protocol\ProtocolWriter;
|
||||||
|
use KTXM\ProviderImap\Smtp\Protocol\Response\Response;
|
||||||
|
use KTXM\ProviderImap\Smtp\Protocol\ProtocolReader;
|
||||||
|
use KTXM\ProviderImap\Smtp\Transport\ConnectionFactoryInterface;
|
||||||
|
use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface;
|
||||||
|
use KTXM\ProviderImap\Smtp\Transport\SocketConnectionFactory;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal SMTP submission client.
|
||||||
|
*
|
||||||
|
* Implements the ESMTP submission handshake (RFC 5321 / RFC 4954):
|
||||||
|
* greeting → EHLO → [STARTTLS → EHLO] → [AUTH] → MAIL FROM → RCPT TO → DATA.
|
||||||
|
*
|
||||||
|
* The client carries opaque RFC822 bytes; message construction lives in the
|
||||||
|
* {@see \KTXM\ProviderImap\Mime\MessageBuilder}.
|
||||||
|
*/
|
||||||
|
final class Client
|
||||||
|
{
|
||||||
|
private ?ConnectionInterface $connection = null;
|
||||||
|
private ?ProtocolReader $reader = null;
|
||||||
|
private ?ProtocolWriter $writer = null;
|
||||||
|
private ?ConnectionConfig $config = null;
|
||||||
|
private ?SmtpCapabilities $capabilities = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ConnectionFactoryInterface $connectionFactory = new SocketConnectionFactory(),
|
||||||
|
private readonly ?LoggerInterface $logger = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function connect(ConnectionConfig $config): void
|
||||||
|
{
|
||||||
|
$connection = $this->connectionFactory->create($this->logger);
|
||||||
|
$connection->connect($config);
|
||||||
|
|
||||||
|
$this->connection = $connection;
|
||||||
|
$this->config = $config;
|
||||||
|
$this->reader = new ProtocolReader($connection, $this->logger);
|
||||||
|
$this->writer = new ProtocolWriter($connection, $this->logger);
|
||||||
|
|
||||||
|
// Server greeting.
|
||||||
|
$greeting = $this->reader()->read();
|
||||||
|
if (!$greeting->isPositiveCompletion()) {
|
||||||
|
throw new SmtpException('SMTP server rejected the connection: ' . $greeting->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->capabilities = $this->ehlo();
|
||||||
|
|
||||||
|
if ($config->security() === ConnectionSecurity::StartTls) {
|
||||||
|
$this->startTls();
|
||||||
|
$this->capabilities = $this->ehlo();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($config->hasCredentials()) {
|
||||||
|
$this->authenticate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capabilities(): SmtpCapabilities
|
||||||
|
{
|
||||||
|
return $this->caps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConnected(): bool
|
||||||
|
{
|
||||||
|
return $this->connection !== null && $this->connection->isConnected();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a message envelope and its raw RFC822 body.
|
||||||
|
*
|
||||||
|
* @param string $from envelope sender (bare address)
|
||||||
|
* @param list<string> $recipients envelope recipients (bare addresses, incl. Bcc)
|
||||||
|
* @param string $rawMessage complete RFC822 message bytes
|
||||||
|
*
|
||||||
|
* @return string the queue/transport id reported by the server, when available
|
||||||
|
*/
|
||||||
|
public function send(string $from, array $recipients, string $rawMessage): string
|
||||||
|
{
|
||||||
|
if ($recipients === []) {
|
||||||
|
throw new SmtpException('Cannot send a message without recipients.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailFrom = sprintf('MAIL FROM:<%s>', $from);
|
||||||
|
if (($max = $this->caps()->maxSize()) !== null) {
|
||||||
|
$size = strlen($rawMessage);
|
||||||
|
if ($size > $max) {
|
||||||
|
throw new SmtpException(sprintf('Message size %d exceeds server limit %d.', $size, $max));
|
||||||
|
}
|
||||||
|
$mailFrom .= ' SIZE=' . $size;
|
||||||
|
}
|
||||||
|
$this->command($mailFrom, static fn (Response $r): bool => $r->isPositiveCompletion(), 'MAIL FROM');
|
||||||
|
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
$this->command(
|
||||||
|
sprintf('RCPT TO:<%s>', $recipient),
|
||||||
|
static fn (Response $r): bool => $r->isPositiveCompletion(),
|
||||||
|
'RCPT TO',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command('DATA', static fn (Response $r): bool => $r->isPositiveIntermediate(), 'DATA');
|
||||||
|
|
||||||
|
$this->writer()->writeRaw($this->dotStuff($rawMessage) . "\r\n.\r\n");
|
||||||
|
$reply = $this->reader()->read();
|
||||||
|
if (!$reply->isPositiveCompletion()) {
|
||||||
|
throw new SmtpException('SMTP DATA delivery failed: ' . $reply->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reply->text();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quit(): void
|
||||||
|
{
|
||||||
|
if ($this->connection === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->writer?->writeLine('QUIT');
|
||||||
|
$this->reader?->read();
|
||||||
|
} catch (SmtpException) {
|
||||||
|
// Best-effort: the peer may already have closed the stream.
|
||||||
|
} finally {
|
||||||
|
$this->connection->disconnect();
|
||||||
|
$this->connection = null;
|
||||||
|
$this->reader = null;
|
||||||
|
$this->writer = null;
|
||||||
|
$this->capabilities = null;
|
||||||
|
$this->config = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ehlo(): SmtpCapabilities
|
||||||
|
{
|
||||||
|
$this->writer()->writeLine('EHLO ' . $this->config()->ehloDomain());
|
||||||
|
$reply = $this->reader()->read();
|
||||||
|
|
||||||
|
if (!$reply->isPositiveCompletion()) {
|
||||||
|
throw new SmtpException('EHLO was rejected: ' . $reply->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
return SmtpCapabilities::fromEhlo($reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function startTls(): void
|
||||||
|
{
|
||||||
|
if (!$this->caps()->supportsStartTls()) {
|
||||||
|
throw new SmtpException('SMTP server does not advertise STARTTLS.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command('STARTTLS', static fn (Response $r): bool => $r->isPositiveCompletion(), 'STARTTLS');
|
||||||
|
$this->connection()->upgradeToTls();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authenticate(): void
|
||||||
|
{
|
||||||
|
$username = $this->config()->username() ?? '';
|
||||||
|
$password = $this->config()->password() ?? '';
|
||||||
|
|
||||||
|
$mechanism = AuthMechanism::select($this->caps()->authMechanisms());
|
||||||
|
if ($mechanism === null) {
|
||||||
|
throw new SmtpException('No supported SMTP AUTH mechanism is available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($mechanism) {
|
||||||
|
AuthMechanism::Plain => $this->authPlain($username, $password),
|
||||||
|
AuthMechanism::Login => $this->authLogin($username, $password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authPlain(string $username, string $password): void
|
||||||
|
{
|
||||||
|
$this->command(
|
||||||
|
'AUTH PLAIN ' . AuthMechanism::plainToken($username, $password),
|
||||||
|
static fn (Response $r): bool => $r->isPositiveCompletion(),
|
||||||
|
'AUTH PLAIN',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authLogin(string $username, string $password): void
|
||||||
|
{
|
||||||
|
$this->command('AUTH LOGIN', static fn (Response $r): bool => $r->isPositiveIntermediate(), 'AUTH LOGIN');
|
||||||
|
|
||||||
|
$this->writer()->writeLine(base64_encode($username), true);
|
||||||
|
$userResponse = $this->reader()->read();
|
||||||
|
if (!$userResponse->isPositiveIntermediate()) {
|
||||||
|
throw new SmtpException('SMTP AUTH LOGIN username rejected: ' . $userResponse->text());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->writer()->writeLine(base64_encode($password), true);
|
||||||
|
$passResponse = $this->reader()->read();
|
||||||
|
if (!$passResponse->isPositiveCompletion()) {
|
||||||
|
throw new SmtpException('SMTP authentication failed: ' . $passResponse->text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue a command line and assert the reply satisfies $accept.
|
||||||
|
*
|
||||||
|
* @param callable(Response):bool $accept
|
||||||
|
*/
|
||||||
|
private function command(string $line, callable $accept, string $label, bool $sensitive = false): Response
|
||||||
|
{
|
||||||
|
$this->writer()->writeLine($line, $sensitive);
|
||||||
|
$reply = $this->reader()->read();
|
||||||
|
|
||||||
|
if (!$accept($reply)) {
|
||||||
|
throw new SmtpException(sprintf('SMTP %s failed (%d): %s', $label, $reply->code(), $reply->text()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $reply;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dot-stuffing per RFC 5321 §4.5.2: any line beginning with a period gets
|
||||||
|
* an extra leading period so it is not mistaken for the end-of-data marker.
|
||||||
|
* Line endings are normalised to CRLF.
|
||||||
|
*/
|
||||||
|
private function dotStuff(string $message): string
|
||||||
|
{
|
||||||
|
$normalized = preg_replace('/\r\n|\r|\n/', "\r\n", $message) ?? $message;
|
||||||
|
|
||||||
|
return preg_replace('/^\./m', '..', $normalized) ?? $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connection(): ConnectionInterface
|
||||||
|
{
|
||||||
|
return $this->connection ?? throw new SmtpException('SMTP client is not connected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reader(): ProtocolReader
|
||||||
|
{
|
||||||
|
return $this->reader ?? throw new SmtpException('SMTP client is not connected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writer(): ProtocolWriter
|
||||||
|
{
|
||||||
|
return $this->writer ?? throw new SmtpException('SMTP client is not connected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function config(): ConnectionConfig
|
||||||
|
{
|
||||||
|
return $this->config ?? throw new SmtpException('SMTP client is not connected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function caps(): SmtpCapabilities
|
||||||
|
{
|
||||||
|
return $this->capabilities ?? throw new SmtpException('SMTP client is not connected.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp;
|
||||||
|
|
||||||
|
final class ConnectionConfig
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $host,
|
||||||
|
private readonly int $port = 587,
|
||||||
|
private readonly ConnectionSecurity $security = ConnectionSecurity::StartTls,
|
||||||
|
private readonly ?string $username = null,
|
||||||
|
private readonly ?string $password = null,
|
||||||
|
private readonly float $timeout = 30.0,
|
||||||
|
private readonly bool $verifyPeer = true,
|
||||||
|
private readonly bool $verifyPeerName = true,
|
||||||
|
private readonly bool $allowSelfSigned = false,
|
||||||
|
private readonly ?string $ehloDomain = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function host(): string
|
||||||
|
{
|
||||||
|
return $this->host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function port(): int
|
||||||
|
{
|
||||||
|
return $this->port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function security(): ConnectionSecurity
|
||||||
|
{
|
||||||
|
return $this->security;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function username(): ?string
|
||||||
|
{
|
||||||
|
return $this->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function password(): ?string
|
||||||
|
{
|
||||||
|
return $this->password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCredentials(): bool
|
||||||
|
{
|
||||||
|
return $this->username !== null && $this->username !== ''
|
||||||
|
&& $this->password !== null && $this->password !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function timeout(): float
|
||||||
|
{
|
||||||
|
return $this->timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyPeer(): bool
|
||||||
|
{
|
||||||
|
return $this->verifyPeer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyPeerName(): bool
|
||||||
|
{
|
||||||
|
return $this->verifyPeerName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowSelfSigned(): bool
|
||||||
|
{
|
||||||
|
return $this->allowSelfSigned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The domain announced in the EHLO command. Falls back to the local
|
||||||
|
* hostname, then to a literal so a value is always sent.
|
||||||
|
*/
|
||||||
|
public function ehloDomain(): string
|
||||||
|
{
|
||||||
|
if ($this->ehloDomain !== null && $this->ehloDomain !== '') {
|
||||||
|
return $this->ehloDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hostname = gethostname();
|
||||||
|
|
||||||
|
return $hostname !== false && $hostname !== '' ? $hostname : 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function endpoint(): string
|
||||||
|
{
|
||||||
|
return sprintf('%s://%s:%d', $this->security->transport(), $this->host, $this->port);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
public function streamContextOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ssl' => [
|
||||||
|
'verify_peer' => $this->verifyPeer,
|
||||||
|
'verify_peer_name' => $this->verifyPeerName,
|
||||||
|
'allow_self_signed' => $this->allowSelfSigned,
|
||||||
|
'SNI_enabled' => true,
|
||||||
|
'peer_name' => $this->host,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp;
|
||||||
|
|
||||||
|
enum ConnectionSecurity: string
|
||||||
|
{
|
||||||
|
case Plain = 'plain';
|
||||||
|
case Tls = 'tls'; // implicit TLS (SMTPS, port 465)
|
||||||
|
case StartTls = 'starttls';
|
||||||
|
|
||||||
|
public function transport(): string
|
||||||
|
{
|
||||||
|
return $this === self::Tls ? 'ssl' : 'tcp';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp\Protocol;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Smtp\Protocol\Response\Response;
|
||||||
|
use KTXM\ProviderImap\Smtp\SmtpException;
|
||||||
|
use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads SMTP replies, assembling multi-line responses.
|
||||||
|
*
|
||||||
|
* A multi-line reply repeats the status code on every line, using a hyphen
|
||||||
|
* ("250-text") to indicate continuation and a space ("250 text") on the final
|
||||||
|
* line. See RFC 5321 §4.2.
|
||||||
|
*/
|
||||||
|
final class ProtocolReader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ConnectionInterface $connection,
|
||||||
|
private readonly ?LoggerInterface $logger = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function read(): Response
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
$raw = '';
|
||||||
|
$code = null;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
$rawLine = $this->connection->readLine();
|
||||||
|
$raw .= $rawLine;
|
||||||
|
$trimmed = rtrim($rawLine, "\r\n");
|
||||||
|
|
||||||
|
if (!preg_match('/^(\d{3})([ -]?)(.*)$/', $trimmed, $matches)) {
|
||||||
|
throw new SmtpException(sprintf('Malformed SMTP reply line: %s', $trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
$lineCode = (int) $matches[1];
|
||||||
|
$separator = $matches[2];
|
||||||
|
$lines[] = $matches[3];
|
||||||
|
|
||||||
|
if ($code === null) {
|
||||||
|
$code = $lineCode;
|
||||||
|
} elseif ($lineCode !== $code) {
|
||||||
|
throw new SmtpException(sprintf('Inconsistent SMTP reply code: %s', $trimmed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A space separator (or no separator) marks the final line.
|
||||||
|
if ($separator !== '-') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger?->debug('SMTP reply received: {raw}', [
|
||||||
|
'code' => $code,
|
||||||
|
'raw' => rtrim($raw, "\r\n"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new Response($code ?? 0, $lines, rtrim($raw, "\r\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp\Protocol;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Smtp\Transport\ConnectionInterface;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
final class ProtocolWriter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ConnectionInterface $connection,
|
||||||
|
private readonly ?LoggerInterface $logger = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a single command line, appending the CRLF terminator.
|
||||||
|
*
|
||||||
|
* @param bool $sensitive when true the payload is redacted from logs
|
||||||
|
* (used for AUTH credential exchanges)
|
||||||
|
*/
|
||||||
|
public function writeLine(string $line, bool $sensitive = false): void
|
||||||
|
{
|
||||||
|
$this->logger?->debug('SMTP command sent: {command}', [
|
||||||
|
'command' => $sensitive ? $this->redact($line) : $line,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->connection->write($line . "\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write raw bytes verbatim (e.g. the DATA payload). Never logged.
|
||||||
|
*/
|
||||||
|
public function writeRaw(string $payload): void
|
||||||
|
{
|
||||||
|
$this->connection->write($payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function redact(string $line): string
|
||||||
|
{
|
||||||
|
if (preg_match('/^(AUTH\s+\S+)(\s+.*)?$/i', $line, $matches) === 1) {
|
||||||
|
return $matches[1] . (isset($matches[2]) ? ' [REDACTED]' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '[REDACTED]';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp\Protocol\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parsed SMTP reply: a 3-digit status code plus one or more text lines.
|
||||||
|
*/
|
||||||
|
final class Response
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $code 3-digit SMTP status code
|
||||||
|
* @param list<string> $lines text portion of each reply line (code/separator stripped)
|
||||||
|
* @param string $raw the raw wire text (for diagnostics)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly int $code,
|
||||||
|
private readonly array $lines,
|
||||||
|
private readonly string $raw,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function code(): int
|
||||||
|
{
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function lines(): array
|
||||||
|
{
|
||||||
|
return $this->lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function text(): string
|
||||||
|
{
|
||||||
|
return implode(' ', $this->lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function raw(): string
|
||||||
|
{
|
||||||
|
return $this->raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2xx — requested action completed. */
|
||||||
|
public function isPositiveCompletion(): bool
|
||||||
|
{
|
||||||
|
return $this->code >= 200 && $this->code < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 3xx — more input required (e.g. DATA, AUTH challenge). */
|
||||||
|
public function isPositiveIntermediate(): bool
|
||||||
|
{
|
||||||
|
return $this->code >= 300 && $this->code < 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 4xx — transient negative completion (try again later). */
|
||||||
|
public function isTransientError(): bool
|
||||||
|
{
|
||||||
|
return $this->code >= 400 && $this->code < 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 5xx — permanent negative completion. */
|
||||||
|
public function isPermanentError(): bool
|
||||||
|
{
|
||||||
|
return $this->code >= 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Smtp\Protocol\Response\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed EHLO capabilities.
|
||||||
|
*
|
||||||
|
* The first line of an EHLO reply is the server greeting/domain; every
|
||||||
|
* subsequent line is a capability keyword optionally followed by parameters
|
||||||
|
* (e.g. "AUTH PLAIN LOGIN", "SIZE 35882577").
|
||||||
|
*/
|
||||||
|
final class SmtpCapabilities
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string,list<string>> $keywords capability keyword => parameters
|
||||||
|
*/
|
||||||
|
private function __construct(
|
||||||
|
private readonly array $keywords,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromEhlo(Response $reply): self
|
||||||
|
{
|
||||||
|
$keywords = [];
|
||||||
|
$lines = $reply->lines();
|
||||||
|
|
||||||
|
// Skip the first line (greeting/domain); the rest are capabilities.
|
||||||
|
foreach (array_slice($lines, 1) as $line) {
|
||||||
|
$parts = preg_split('/\s+/', trim($line)) ?: [];
|
||||||
|
$keyword = strtoupper((string) array_shift($parts));
|
||||||
|
if ($keyword === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$keywords[$keyword] = array_map('strtoupper', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new self($keywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(string $keyword): bool
|
||||||
|
{
|
||||||
|
return isset($this->keywords[strtoupper($keyword)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsStartTls(): bool
|
||||||
|
{
|
||||||
|
return $this->has('STARTTLS');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Advertised AUTH mechanisms (uppercased).
|
||||||
|
*/
|
||||||
|
public function authMechanisms(): array
|
||||||
|
{
|
||||||
|
return $this->keywords['AUTH'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maxSize(): ?int
|
||||||
|
{
|
||||||
|
$size = $this->keywords['SIZE'][0] ?? null;
|
||||||
|
|
||||||
|
return $size !== null && ctype_digit($size) ? (int) $size : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP client exception.
|
||||||
|
*/
|
||||||
|
final class SmtpException extends RuntimeException
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp\Transport;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
interface ConnectionFactoryInterface
|
||||||
|
{
|
||||||
|
public function create(?LoggerInterface $logger = null): ConnectionInterface;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp\Transport;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Smtp\ConnectionConfig;
|
||||||
|
|
||||||
|
interface ConnectionInterface
|
||||||
|
{
|
||||||
|
public function connect(ConnectionConfig $config): void;
|
||||||
|
|
||||||
|
public function disconnect(): void;
|
||||||
|
|
||||||
|
public function isConnected(): bool;
|
||||||
|
|
||||||
|
public function write(string $payload): void;
|
||||||
|
|
||||||
|
public function readLine(): string;
|
||||||
|
|
||||||
|
public function upgradeToTls(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp\Transport;
|
||||||
|
|
||||||
|
use KTXM\ProviderImap\Smtp\ConnectionConfig;
|
||||||
|
use KTXM\ProviderImap\Smtp\SmtpException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
final class SocketConnection implements ConnectionInterface
|
||||||
|
{
|
||||||
|
/** @var resource|null */
|
||||||
|
private $stream = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ?LoggerInterface $logger = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function connect(ConnectionConfig $config): void
|
||||||
|
{
|
||||||
|
if ($this->isConnected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = stream_context_create($config->streamContextOptions());
|
||||||
|
$errorCode = 0;
|
||||||
|
$errorMessage = '';
|
||||||
|
|
||||||
|
$stream = @stream_socket_client(
|
||||||
|
$config->endpoint(),
|
||||||
|
$errorCode,
|
||||||
|
$errorMessage,
|
||||||
|
$config->timeout(),
|
||||||
|
STREAM_CLIENT_CONNECT,
|
||||||
|
$context,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!is_resource($stream)) {
|
||||||
|
$this->logger?->error('SMTP socket connection failed', [
|
||||||
|
'endpoint' => $config->endpoint(),
|
||||||
|
'code' => $errorCode,
|
||||||
|
'message' => $errorMessage ?: 'unknown error',
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new SmtpException(sprintf(
|
||||||
|
'Unable to connect to %s (%d: %s)',
|
||||||
|
$config->endpoint(),
|
||||||
|
$errorCode,
|
||||||
|
$errorMessage ?: 'unknown error',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_set_timeout($stream, (int) $config->timeout());
|
||||||
|
$this->stream = $stream;
|
||||||
|
|
||||||
|
$this->logger?->info('SMTP socket connected to {endpoint} (timeout={timeout})', [
|
||||||
|
'endpoint' => $config->endpoint(),
|
||||||
|
'timeout' => $config->timeout(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disconnect(): void
|
||||||
|
{
|
||||||
|
if (!is_resource($this->stream)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($this->stream);
|
||||||
|
$this->stream = null;
|
||||||
|
|
||||||
|
$this->logger?->info('SMTP socket disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isConnected(): bool
|
||||||
|
{
|
||||||
|
return is_resource($this->stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function write(string $payload): void
|
||||||
|
{
|
||||||
|
$stream = $this->stream();
|
||||||
|
$written = fwrite($stream, $payload);
|
||||||
|
|
||||||
|
if ($written === false || $written !== strlen($payload)) {
|
||||||
|
$this->logger?->error('SMTP socket write failed (bytes={bytes})', [
|
||||||
|
'bytes' => strlen($payload),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new SmtpException('Failed to write complete payload to SMTP socket.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function readLine(): string
|
||||||
|
{
|
||||||
|
$stream = $this->stream();
|
||||||
|
$line = fgets($stream);
|
||||||
|
|
||||||
|
if ($line === false) {
|
||||||
|
$this->logger?->error('SMTP socket read failed');
|
||||||
|
throw new SmtpException('Failed to read line from SMTP socket.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $line;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upgradeToTls(): void
|
||||||
|
{
|
||||||
|
$stream = $this->stream();
|
||||||
|
$result = stream_socket_enable_crypto($stream, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
||||||
|
|
||||||
|
if ($result !== true) {
|
||||||
|
$this->logger?->error('SMTP TLS upgrade failed');
|
||||||
|
throw new SmtpException('Failed to enable TLS on SMTP socket.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger?->info('SMTP socket upgraded to TLS');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return resource
|
||||||
|
*/
|
||||||
|
private function stream()
|
||||||
|
{
|
||||||
|
if (!is_resource($this->stream)) {
|
||||||
|
throw new SmtpException('SMTP socket is not connected.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ProviderImap\Smtp\Transport;
|
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
final class SocketConnectionFactory implements ConnectionFactoryInterface
|
||||||
|
{
|
||||||
|
public function create(?LoggerInterface $logger = null): ConnectionInterface
|
||||||
|
{
|
||||||
|
return new SocketConnection($logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+188
-216
@@ -86,30 +86,30 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -119,13 +119,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -135,7 +135,6 @@
|
|||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -147,7 +146,6 @@
|
|||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -158,7 +156,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -214,7 +211,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||||
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -230,23 +226,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.130.0",
|
"version": "0.133.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -257,13 +252,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -274,13 +268,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -291,13 +284,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -308,13 +300,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -325,13 +316,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -342,13 +332,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -359,13 +348,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -376,13 +364,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -393,13 +380,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -410,13 +396,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -427,13 +412,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -444,13 +428,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -463,13 +446,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -480,13 +462,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -500,14 +481,13 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -594,53 +574,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
|
||||||
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
|
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.3",
|
"@babel/parser": "^7.29.7",
|
||||||
"@vue/shared": "3.5.34",
|
"@vue/shared": "3.5.38",
|
||||||
"entities": "^7.0.1",
|
"entities": "^7.0.1",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
|
||||||
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
|
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.34",
|
"@vue/compiler-core": "3.5.38",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.38"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
|
||||||
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
|
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.3",
|
"@babel/parser": "^7.29.7",
|
||||||
"@vue/compiler-core": "3.5.34",
|
"@vue/compiler-core": "3.5.38",
|
||||||
"@vue/compiler-dom": "3.5.34",
|
"@vue/compiler-dom": "3.5.38",
|
||||||
"@vue/compiler-ssr": "3.5.34",
|
"@vue/compiler-ssr": "3.5.38",
|
||||||
"@vue/shared": "3.5.34",
|
"@vue/shared": "3.5.38",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.15",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
|
||||||
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
|
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.34",
|
"@vue/compiler-dom": "3.5.38",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.38"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
@@ -701,9 +681,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/language-core": {
|
"node_modules/@vue/language-core": {
|
||||||
"version": "3.2.9",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.5.tgz",
|
||||||
"integrity": "sha512-ie0ojt/0fU/GfIogh+zgHbaYRPlt9S+cLOxcWwF7nTSFh897BVgnFKL2byT4kpp1mlqYWZ2psGwSniyE2xsxYw==",
|
"integrity": "sha512-UkKu5nhX89fg4VhlG/FOeI10G3cj/7radKT/cy9BT4Q9qJmJlSTAc/dP63Xqs29aypN4f39xUV6PsLNk/dcD6g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -717,53 +697,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
|
||||||
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
|
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.38"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
|
||||||
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
|
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.34",
|
"@vue/reactivity": "3.5.38",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.38"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
|
||||||
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
|
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.34",
|
"@vue/reactivity": "3.5.38",
|
||||||
"@vue/runtime-core": "3.5.34",
|
"@vue/runtime-core": "3.5.38",
|
||||||
"@vue/shared": "3.5.34",
|
"@vue/shared": "3.5.38",
|
||||||
"csstype": "^3.2.3"
|
"csstype": "^3.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
|
||||||
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
|
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.34",
|
"@vue/compiler-ssr": "3.5.38",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.38"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "3.5.34"
|
"vue": "3.5.38"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
|
||||||
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
|
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/tsconfig": {
|
"node_modules/@vue/tsconfig": {
|
||||||
@@ -821,13 +801,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ast-walker-scope": {
|
"node_modules/ast-walker-scope": {
|
||||||
"version": "0.8.3",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz",
|
||||||
"integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==",
|
"integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.4",
|
"@babel/parser": "^7.29.2",
|
||||||
"ast-kit": "^2.1.3"
|
"@babel/types": "^7.29.0",
|
||||||
|
"ast-kit": "^2.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
@@ -891,7 +872,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -942,7 +923,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -999,7 +979,7 @@
|
|||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
@@ -1032,7 +1012,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1053,7 +1032,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1074,7 +1052,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1095,7 +1072,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1116,7 +1092,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1137,7 +1112,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1158,7 +1132,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1179,7 +1152,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1200,7 +1172,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1221,7 +1192,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1242,7 +1212,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1339,9 +1308,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1435,9 +1404,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.14",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1454,7 +1423,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.12",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -1498,13 +1467,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.130.0",
|
"@oxc-project/types": "=0.133.0",
|
||||||
"@rolldown/pluginutils": "^1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -1514,21 +1483,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.1",
|
"@rolldown/binding-android-arm64": "1.0.3",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.1",
|
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.1",
|
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.1",
|
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.1",
|
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.1",
|
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.1",
|
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.1",
|
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.1",
|
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/scule": {
|
"node_modules/scule": {
|
||||||
@@ -1568,9 +1537,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -1587,7 +1556,6 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
@@ -1642,17 +1610,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.13",
|
"version": "8.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.15",
|
||||||
"rolldown": "1.0.1",
|
"rolldown": "1.0.3",
|
||||||
"tinyglobby": "^0.2.16"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vite": "bin/vite.js"
|
"vite": "bin/vite.js"
|
||||||
@@ -1727,16 +1695,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.34",
|
"version": "3.5.38",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
|
||||||
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
|
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.34",
|
"@vue/compiler-dom": "3.5.38",
|
||||||
"@vue/compiler-sfc": "3.5.34",
|
"@vue/compiler-sfc": "3.5.38",
|
||||||
"@vue/runtime-dom": "3.5.34",
|
"@vue/runtime-dom": "3.5.38",
|
||||||
"@vue/server-renderer": "3.5.34",
|
"@vue/server-renderer": "3.5.38",
|
||||||
"@vue/shared": "3.5.34"
|
"@vue/shared": "3.5.38"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
@@ -1748,28 +1716,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "5.0.7",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz",
|
||||||
"integrity": "sha512-dqfk8kvRbCutmCOCj/XLDqDEYxc1wBdAOGLuVy5M93ifYMsBd5fIjfaPN4tQAbxr5IprdBDIox1gr4wYyOx/SA==",
|
"integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/generator": "^8.0.0-rc.4",
|
"@babel/generator": "^8.0.0-rc.4",
|
||||||
"@vue-macros/common": "^3.1.1",
|
"@vue-macros/common": "^3.1.1",
|
||||||
"@vue/devtools-api": "^8.1.1",
|
"@vue/devtools-api": "^8.1.2",
|
||||||
"ast-walker-scope": "^0.8.3",
|
"ast-walker-scope": "^0.9.0",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"local-pkg": "^1.1.2",
|
"local-pkg": "^1.1.2",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"mlly": "^1.8.0",
|
"mlly": "^1.8.2",
|
||||||
"muggle-string": "^0.4.1",
|
"muggle-string": "^0.4.1",
|
||||||
"pathe": "^2.0.3",
|
"pathe": "^2.0.3",
|
||||||
"picomatch": "^4.0.3",
|
"picomatch": "^4.0.3",
|
||||||
"scule": "^1.3.0",
|
"scule": "^1.3.0",
|
||||||
"tinyglobby": "^0.2.15",
|
"tinyglobby": "^0.2.16",
|
||||||
"unplugin": "^3.0.0",
|
"unplugin": "^3.0.0",
|
||||||
"unplugin-utils": "^0.3.1",
|
"unplugin-utils": "^0.3.1",
|
||||||
"yaml": "^2.8.2"
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/posva"
|
"url": "https://github.com/sponsors/posva"
|
||||||
@@ -1778,6 +1746,7 @@
|
|||||||
"@pinia/colada": ">=0.21.2",
|
"@pinia/colada": ">=0.21.2",
|
||||||
"@vue/compiler-sfc": "^3.5.34",
|
"@vue/compiler-sfc": "^3.5.34",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"vite": "^7.0.0 || ^8.0.0",
|
||||||
"vue": "^3.5.34"
|
"vue": "^3.5.34"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -1789,18 +1758,21 @@
|
|||||||
},
|
},
|
||||||
"pinia": {
|
"pinia": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-tsc": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "3.2.9",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.5.tgz",
|
||||||
"integrity": "sha512-qm8/nbo+9eZc1SCndm9wT+gq23pM+wRIdHY0wjm83B3lIginHTwcdrLUyTrKjDWXbMVNjKegNrnymhpdqnCL3A==",
|
"integrity": "sha512-Rzh/G2MmNlMSAMTiQEjDrsb4dgB/jbtEM47rVN2NtidF1dfb/q4w4QvpQBtW5+y3y5H27Hjh7deVwk+YB02fNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@volar/typescript": "2.4.28",
|
"@volar/typescript": "2.4.28",
|
||||||
"@vue/language-core": "3.2.9"
|
"@vue/language-core": "3.3.5"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"vue-tsc": "bin/vue-tsc.js"
|
"vue-tsc": "bin/vue-tsc.js"
|
||||||
@@ -1810,9 +1782,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vuetify": {
|
"node_modules/vuetify": {
|
||||||
"version": "4.0.7",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-4.1.2.tgz",
|
||||||
"integrity": "sha512-SV+YJkBmudY3s9qfZO2ZGUsrD0TDQU8pMBH1ERga9AEyjGvioZuXXh93V9wHxXJphvqRC2NW10Nt2lW9IZQcPw==",
|
"integrity": "sha512-2SUzXt2Q71/cVmSZhU6kOmAo1ev3NZaI/ynj55TTcu6jXAc9kezoqZKY+Xm+0STnAOcCoTseF0qPSs01LjUaaA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||||
|
import { ServiceAddressObject } from '@KTXM/MailManager/models/address'
|
||||||
|
|
||||||
type AuxiliaryTab = 'addresses' | 'messages' | 'sync'
|
type AuxiliaryTab = 'addresses' | 'messages' | 'sync'
|
||||||
type DeleteMode = 'soft' | 'hard'
|
type DeleteMode = 'soft' | 'hard'
|
||||||
@@ -16,8 +17,8 @@ const emit = defineEmits<{
|
|||||||
const activeTab = ref<AuxiliaryTab>('addresses')
|
const activeTab = ref<AuxiliaryTab>('addresses')
|
||||||
const deleteMode = ref<DeleteMode>('soft')
|
const deleteMode = ref<DeleteMode>('soft')
|
||||||
const deleteDestination = ref('Trash')
|
const deleteDestination = ref('Trash')
|
||||||
const primaryAddress = ref('')
|
const primaryAddress = ref<ServiceAddressObject>(new ServiceAddressObject())
|
||||||
const secondaryAddresses = ref('')
|
const secondaryAddresses = ref<ServiceAddressObject[]>([])
|
||||||
|
|
||||||
const settingGroups = [
|
const settingGroups = [
|
||||||
{
|
{
|
||||||
@@ -61,10 +62,6 @@ const destinationHint = computed(() => {
|
|||||||
return 'Mailbox identifier or well-known role target, for example Trash.'
|
return 'Mailbox identifier or well-known role target, for example Trash.'
|
||||||
})
|
})
|
||||||
|
|
||||||
const secondaryAddressesHint = computed(() => {
|
|
||||||
return 'Use one address per line. Commas are also accepted.'
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.service,
|
() => props.service,
|
||||||
service => {
|
service => {
|
||||||
@@ -86,17 +83,17 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sameAuxiliary(nextService.auxiliary ?? {}, nextAuxiliary)) {
|
if (sameAuxiliary(nextService.auxiliary ?? {}, nextAuxiliary)) {
|
||||||
if (sameAddresses(nextService, primaryAddress.value, secondaryAddresses.value)) {
|
if (sameAddresses(nextService)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nextService.primaryAddress = normalizePrimaryAddress(primaryAddress.value)
|
nextService.primaryAddress = primaryAddress.value.empty ? null : primaryAddress.value
|
||||||
nextService.secondaryAddresses = normalizeSecondaryAddresses(secondaryAddresses.value)
|
nextService.secondaryAddresses = dedupeAddresses(secondaryAddresses.value)
|
||||||
nextService.auxiliary = nextAuxiliary
|
nextService.auxiliary = nextAuxiliary
|
||||||
emit('update:service', nextService)
|
emit('update:service', nextService)
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ deep: true, immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
function syncFromService(service?: ServiceObject) {
|
function syncFromService(service?: ServiceObject) {
|
||||||
@@ -105,8 +102,8 @@ function syncFromService(service?: ServiceObject) {
|
|||||||
deleteDestination.value = typeof auxiliary.deleteDestination === 'string' && auxiliary.deleteDestination.length > 0
|
deleteDestination.value = typeof auxiliary.deleteDestination === 'string' && auxiliary.deleteDestination.length > 0
|
||||||
? auxiliary.deleteDestination
|
? auxiliary.deleteDestination
|
||||||
: 'Trash'
|
: 'Trash'
|
||||||
primaryAddress.value = service?.primaryAddress ?? ''
|
primaryAddress.value = service?.primaryAddress ?? new ServiceAddressObject()
|
||||||
secondaryAddresses.value = (service?.secondaryAddresses ?? []).join('\n')
|
secondaryAddresses.value = service?.secondaryAddresses ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDeleteDestination(value: string): string {
|
function normalizeDeleteDestination(value: string): string {
|
||||||
@@ -114,16 +111,23 @@ function normalizeDeleteDestination(value: string): string {
|
|||||||
return trimmedValue.length > 0 ? trimmedValue : 'Trash'
|
return trimmedValue.length > 0 ? trimmedValue : 'Trash'
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePrimaryAddress(value: string): string | null {
|
function dedupeAddresses(entries: ServiceAddressObject[]): ServiceAddressObject[] {
|
||||||
const trimmedValue = value.trim()
|
const populated = entries.filter(entry => !entry.empty)
|
||||||
return trimmedValue.length > 0 ? trimmedValue : null
|
return populated.filter((entry, index) =>
|
||||||
|
populated.findIndex(candidate => candidate.matches(entry.address)) === index)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSecondaryAddresses(value: string): string[] {
|
function addSecondaryAddress() {
|
||||||
return value
|
secondaryAddresses.value.push(new ServiceAddressObject())
|
||||||
.split(/\r?\n|,/)
|
}
|
||||||
.map(entry => entry.trim())
|
|
||||||
.filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index)
|
function removeSecondaryAddress(index: number) {
|
||||||
|
secondaryAddresses.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function validAddress(value: string): boolean | string {
|
||||||
|
const trimmedValue = value.trim()
|
||||||
|
return trimmedValue.length === 0 || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedValue) || 'Invalid email address'
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameAuxiliary(current: Record<string, any>, next: Record<string, any>): boolean {
|
function sameAuxiliary(current: Record<string, any>, next: Record<string, any>): boolean {
|
||||||
@@ -131,9 +135,19 @@ function sameAuxiliary(current: Record<string, any>, next: Record<string, any>):
|
|||||||
&& (current.deleteDestination ?? undefined) === (next.deleteDestination ?? undefined)
|
&& (current.deleteDestination ?? undefined) === (next.deleteDestination ?? undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameAddresses(service: ServiceObject, nextPrimaryAddress: string, nextSecondaryAddresses: string): boolean {
|
function sameAddresses(service: ServiceObject): boolean {
|
||||||
return (service.primaryAddress ?? null) === normalizePrimaryAddress(nextPrimaryAddress)
|
const nextPrimary = primaryAddress.value.empty ? null : primaryAddress.value
|
||||||
&& JSON.stringify(service.secondaryAddresses) === JSON.stringify(normalizeSecondaryAddresses(nextSecondaryAddresses))
|
const nextSecondary = dedupeAddresses(secondaryAddresses.value)
|
||||||
|
return sameAddress(service.primaryAddress, nextPrimary)
|
||||||
|
&& service.secondaryAddresses.length === nextSecondary.length
|
||||||
|
&& service.secondaryAddresses.every((entry, index) => sameAddress(entry, nextSecondary[index]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameAddress(current: ServiceAddressObject | null, next: ServiceAddressObject | null): boolean {
|
||||||
|
if (current === null || next === null) {
|
||||||
|
return current === next
|
||||||
|
}
|
||||||
|
return current.equals(next)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -165,23 +179,66 @@ function sameAddresses(service: ServiceObject, nextPrimaryAddress: string, nextS
|
|||||||
Configure the primary mailbox identity and any additional sender aliases exposed by this service.
|
Configure the primary mailbox identity and any additional sender aliases exposed by this service.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<v-text-field
|
<div class="text-subtitle-2 mb-2">Primary Address</div>
|
||||||
v-model="primaryAddress"
|
<div class="d-flex ga-2 mb-6">
|
||||||
label="Primary Address"
|
<v-text-field
|
||||||
variant="outlined"
|
v-model="primaryAddress.label"
|
||||||
prepend-inner-icon="mdi-email-outline"
|
label="Display Name"
|
||||||
class="mb-4"
|
variant="outlined"
|
||||||
/>
|
density="compact"
|
||||||
|
hide-details="auto"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="primaryAddress.address"
|
||||||
|
label="Email Address"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
prepend-inner-icon="mdi-email-outline"
|
||||||
|
:rules="[validAddress]"
|
||||||
|
hide-details="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-textarea
|
<div class="text-subtitle-2 mb-2">Secondary Addresses</div>
|
||||||
v-model="secondaryAddresses"
|
<div
|
||||||
label="Secondary Addresses"
|
v-for="(entry, index) in secondaryAddresses"
|
||||||
variant="outlined"
|
:key="index"
|
||||||
prepend-inner-icon="mdi-email-multiple-outline"
|
class="d-flex ga-2 mb-2"
|
||||||
rows="4"
|
>
|
||||||
:hint="secondaryAddressesHint"
|
<v-text-field
|
||||||
persistent-hint
|
v-model="entry.label"
|
||||||
/>
|
label="Display Name"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details="auto"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="entry.address"
|
||||||
|
label="Email Address"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
prepend-inner-icon="mdi-email-multiple-outline"
|
||||||
|
:rules="[validAddress]"
|
||||||
|
hide-details="auto"
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="removeSecondaryAddress(index)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-delete-outline</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Remove Alias</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
variant="tonal"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
@click="addSecondaryAddress"
|
||||||
|
>
|
||||||
|
Add Alias
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { LocationSocketSole } from '@KTXM/MailManager/models/location'
|
import { LocationSocketSplit } from '@KTXM/MailManager/models/location'
|
||||||
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
import { ServiceObject } from '@KTXM/MailManager/models/service'
|
||||||
import type {
|
import type {
|
||||||
ServiceLocation,
|
ServiceLocation,
|
||||||
ServiceLocationSocketSole,
|
ServiceLocationSocketSplit,
|
||||||
} from '@KTXM/MailManager/types/service'
|
} from '@KTXM/MailManager/types/service'
|
||||||
import type {
|
import type {
|
||||||
ProviderProtocolPanelProps,
|
ProviderProtocolPanelProps,
|
||||||
ProviderProtocolPanelEmits,
|
ProviderProtocolPanelEmits,
|
||||||
} from '@KTXM/MailManager/types/integration'
|
} from '@KTXM/MailManager/types/integration'
|
||||||
|
|
||||||
type ImapEncryption = 'none' | 'ssl' | 'tls' | 'starttls'
|
type Encryption = 'none' | 'ssl' | 'tls' | 'starttls'
|
||||||
|
|
||||||
const props = defineProps<ProviderProtocolPanelProps>()
|
const props = defineProps<ProviderProtocolPanelProps>()
|
||||||
const emit = defineEmits<ProviderProtocolPanelEmits>()
|
const emit = defineEmits<ProviderProtocolPanelEmits>()
|
||||||
|
|
||||||
const host = ref('')
|
// ── Inbound (IMAP) ──────────────────────────────────────────────────────────
|
||||||
const encryption = ref<ImapEncryption>('ssl')
|
const inboundHost = ref('')
|
||||||
const port = ref('993')
|
const inboundEncryption = ref<Encryption>('ssl')
|
||||||
const verifyPeer = ref(true)
|
const inboundPort = ref('993')
|
||||||
const verifyHost = ref(true)
|
const inboundVerifyPeer = ref(true)
|
||||||
|
const inboundVerifyHost = ref(true)
|
||||||
|
|
||||||
|
// ── Outbound (SMTP) ─────────────────────────────────────────────────────────
|
||||||
|
const outboundHost = ref('')
|
||||||
|
const outboundEncryption = ref<Encryption>('starttls')
|
||||||
|
const outboundPort = ref('587')
|
||||||
|
const outboundVerifyPeer = ref(true)
|
||||||
|
const outboundVerifyHost = ref(true)
|
||||||
|
|
||||||
const encryptionOptions = [
|
const encryptionOptions = [
|
||||||
{ title: 'Implicit TLS (SSL)', value: 'ssl' },
|
{ title: 'Implicit TLS (SSL)', value: 'ssl' },
|
||||||
@@ -36,23 +44,30 @@ const rules = {
|
|||||||
return Number.isInteger(numericValue) && numericValue >= 1 && numericValue <= 65535
|
return Number.isInteger(numericValue) && numericValue >= 1 && numericValue <= 65535
|
||||||
? true
|
? true
|
||||||
: 'Port must be between 1 and 65535'
|
: 'Port must be between 1 and 65535'
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = computed(() => !!host.value && rules.port(port.value) === true)
|
const inboundValid = computed(() => !!inboundHost.value && rules.port(inboundPort.value) === true)
|
||||||
|
const outboundValid = computed(() => !!outboundHost.value && rules.port(outboundPort.value) === true)
|
||||||
|
const isValid = computed(() => inboundValid.value && outboundValid.value)
|
||||||
|
|
||||||
const currentLocation = computed((): ServiceLocationSocketSole | null => {
|
const currentLocation = computed((): ServiceLocationSocketSplit | null => {
|
||||||
if (!isValid.value) {
|
if (!isValid.value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'SOCKET_SOLE',
|
type: 'SOCKET_SPLIT',
|
||||||
host: host.value,
|
inboundHost: inboundHost.value,
|
||||||
port: Number(port.value),
|
inboundPort: Number(inboundPort.value),
|
||||||
encryption: encryption.value,
|
inboundEncryption: inboundEncryption.value,
|
||||||
verifyPeer: verifyPeer.value,
|
inboundVerifyPeer: inboundVerifyPeer.value,
|
||||||
verifyHost: verifyHost.value,
|
inboundVerifyHost: inboundVerifyHost.value,
|
||||||
|
outboundHost: outboundHost.value,
|
||||||
|
outboundPort: Number(outboundPort.value),
|
||||||
|
outboundEncryption: outboundEncryption.value,
|
||||||
|
outboundVerifyPeer: outboundVerifyPeer.value,
|
||||||
|
outboundVerifyHost: outboundVerifyHost.value,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,14 +95,20 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextService.location instanceof LocationSocketSole) {
|
if (nextService.location instanceof LocationSocketSplit) {
|
||||||
nextService.location.host = location.host
|
const target = nextService.location
|
||||||
nextService.location.port = location.port
|
target.inboundHost = location.inboundHost
|
||||||
nextService.location.encryption = location.encryption
|
target.inboundPort = location.inboundPort
|
||||||
nextService.location.verifyPeer = location.verifyPeer ?? true
|
target.inboundEncryption = location.inboundEncryption
|
||||||
nextService.location.verifyHost = location.verifyHost ?? true
|
target.inboundVerifyPeer = location.inboundVerifyPeer ?? true
|
||||||
|
target.inboundVerifyHost = location.inboundVerifyHost ?? true
|
||||||
|
target.outboundHost = location.outboundHost
|
||||||
|
target.outboundPort = location.outboundPort
|
||||||
|
target.outboundEncryption = location.outboundEncryption
|
||||||
|
target.outboundVerifyPeer = location.outboundVerifyPeer ?? true
|
||||||
|
target.outboundVerifyHost = location.outboundVerifyHost ?? true
|
||||||
} else {
|
} else {
|
||||||
nextService.location = LocationSocketSole.fromJson(location)
|
nextService.location = LocationSocketSplit.fromJson(location)
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('update:service', nextService)
|
emit('update:service', nextService)
|
||||||
@@ -95,25 +116,34 @@ watch(
|
|||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(encryption, (next, previous) => {
|
watch(inboundEncryption, (next, previous) => {
|
||||||
const previousDefault = defaultPortFor(previous)
|
const previousDefault = defaultInboundPort(previous)
|
||||||
if (!port.value || Number(port.value) === previousDefault) {
|
if (!inboundPort.value || Number(inboundPort.value) === previousDefault) {
|
||||||
port.value = String(defaultPortFor(next))
|
inboundPort.value = String(defaultInboundPort(next))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(outboundEncryption, (next, previous) => {
|
||||||
|
const previousDefault = defaultOutboundPort(previous)
|
||||||
|
if (!outboundPort.value || Number(outboundPort.value) === previousDefault) {
|
||||||
|
outboundPort.value = String(defaultOutboundPort(next))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function syncFromLocation(location: ServiceLocation | null) {
|
function syncFromLocation(location: ServiceLocation | null) {
|
||||||
const socketLocation = getSocketLocation(location)
|
const split = location?.type === 'SOCKET_SPLIT' ? location : null
|
||||||
|
|
||||||
host.value = socketLocation?.host ?? ''
|
inboundHost.value = split?.inboundHost ?? ''
|
||||||
encryption.value = socketLocation?.encryption ?? 'ssl'
|
inboundEncryption.value = split?.inboundEncryption ?? 'ssl'
|
||||||
port.value = String(socketLocation?.port ?? defaultPortFor(encryption.value))
|
inboundPort.value = String(split?.inboundPort ?? defaultInboundPort(inboundEncryption.value))
|
||||||
verifyPeer.value = socketLocation?.verifyPeer ?? true
|
inboundVerifyPeer.value = split?.inboundVerifyPeer ?? true
|
||||||
verifyHost.value = socketLocation?.verifyHost ?? true
|
inboundVerifyHost.value = split?.inboundVerifyHost ?? true
|
||||||
}
|
|
||||||
|
|
||||||
function getSocketLocation(location?: ServiceLocation | null): ServiceLocationSocketSole | null {
|
outboundHost.value = split?.outboundHost ?? ''
|
||||||
return location?.type === 'SOCKET_SOLE' ? location : null
|
outboundEncryption.value = split?.outboundEncryption ?? 'starttls'
|
||||||
|
outboundPort.value = String(split?.outboundPort ?? defaultOutboundPort(outboundEncryption.value))
|
||||||
|
outboundVerifyPeer.value = split?.outboundVerifyPeer ?? true
|
||||||
|
outboundVerifyHost.value = split?.outboundVerifyHost ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameLocation(a: ServiceLocation | null, b: ServiceLocation | null): boolean {
|
function sameLocation(a: ServiceLocation | null, b: ServiceLocation | null): boolean {
|
||||||
@@ -121,30 +151,51 @@ function sameLocation(a: ServiceLocation | null, b: ServiceLocation | null): boo
|
|||||||
return a === b
|
return a === b
|
||||||
}
|
}
|
||||||
|
|
||||||
if (a.type !== 'SOCKET_SOLE' || b.type !== 'SOCKET_SOLE') {
|
if (a.type !== 'SOCKET_SPLIT' || b.type !== 'SOCKET_SPLIT') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.host === b.host
|
return a.inboundHost === b.inboundHost
|
||||||
&& a.port === b.port
|
&& a.inboundPort === b.inboundPort
|
||||||
&& a.encryption === b.encryption
|
&& a.inboundEncryption === b.inboundEncryption
|
||||||
&& (a.verifyPeer ?? true) === (b.verifyPeer ?? true)
|
&& (a.inboundVerifyPeer ?? true) === (b.inboundVerifyPeer ?? true)
|
||||||
&& (a.verifyHost ?? true) === (b.verifyHost ?? true)
|
&& (a.inboundVerifyHost ?? true) === (b.inboundVerifyHost ?? true)
|
||||||
|
&& a.outboundHost === b.outboundHost
|
||||||
|
&& a.outboundPort === b.outboundPort
|
||||||
|
&& a.outboundEncryption === b.outboundEncryption
|
||||||
|
&& (a.outboundVerifyPeer ?? true) === (b.outboundVerifyPeer ?? true)
|
||||||
|
&& (a.outboundVerifyHost ?? true) === (b.outboundVerifyHost ?? true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultPortFor(nextEncryption: ImapEncryption): number {
|
function defaultInboundPort(encryption: Encryption): number {
|
||||||
return nextEncryption === 'ssl' || nextEncryption === 'tls' ? 993 : 143
|
return encryption === 'ssl' || encryption === 'tls' ? 993 : 143
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultOutboundPort(encryption: Encryption): number {
|
||||||
|
if (encryption === 'ssl') {
|
||||||
|
return 465
|
||||||
|
}
|
||||||
|
if (encryption === 'none') {
|
||||||
|
return 25
|
||||||
|
}
|
||||||
|
return 587
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="imap-protocol-panel">
|
<div class="imap-protocol-panel">
|
||||||
<h3 class="text-h6 mb-4">IMAP Connection Settings</h3>
|
<h3 class="text-h6 mb-4">Connection Settings</h3>
|
||||||
<p class="text-body-2 mb-6">Configure the server address, transport security, and certificate verification for your IMAP mailbox.</p>
|
<p class="text-body-2 mb-6">Configure the incoming (IMAP) and outgoing (SMTP) servers, transport security, and certificate verification for this mailbox.</p>
|
||||||
|
|
||||||
|
<!-- ── Incoming (IMAP) ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<v-icon start color="primary">mdi-email-arrow-left</v-icon>
|
||||||
|
<span class="text-subtitle-1 font-weight-medium">Incoming mail (IMAP)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="host"
|
v-model="inboundHost"
|
||||||
label="Server Host"
|
label="IMAP Server Host"
|
||||||
hint="For example: imap.example.com"
|
hint="For example: imap.example.com"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -157,7 +208,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<v-select
|
<v-select
|
||||||
v-model="encryption"
|
v-model="inboundEncryption"
|
||||||
:items="encryptionOptions"
|
:items="encryptionOptions"
|
||||||
label="Security"
|
label="Security"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -166,7 +217,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="port"
|
v-model="inboundPort"
|
||||||
label="Port"
|
label="Port"
|
||||||
hint="Defaults to 993 for TLS/SSL and 143 for plain or STARTTLS"
|
hint="Defaults to 993 for TLS/SSL and 143 for plain or STARTTLS"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
@@ -179,7 +230,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
|||||||
:rules="[rules.required, rules.port]"
|
:rules="[rules.required, rules.port]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-expansion-panels class="mt-4">
|
<v-expansion-panels class="mb-6">
|
||||||
<v-expansion-panel>
|
<v-expansion-panel>
|
||||||
<v-expansion-panel-title>
|
<v-expansion-panel-title>
|
||||||
<v-icon start>mdi-cog</v-icon>
|
<v-icon start>mdi-cog</v-icon>
|
||||||
@@ -187,7 +238,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
|||||||
</v-expansion-panel-title>
|
</v-expansion-panel-title>
|
||||||
<v-expansion-panel-text>
|
<v-expansion-panel-text>
|
||||||
<v-switch
|
<v-switch
|
||||||
v-model="verifyPeer"
|
v-model="inboundVerifyPeer"
|
||||||
label="Verify TLS certificate"
|
label="Verify TLS certificate"
|
||||||
color="primary"
|
color="primary"
|
||||||
hint="Disable only for trusted internal or test environments"
|
hint="Disable only for trusted internal or test environments"
|
||||||
@@ -196,7 +247,7 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<v-switch
|
<v-switch
|
||||||
v-model="verifyHost"
|
v-model="inboundVerifyHost"
|
||||||
label="Verify certificate hostname"
|
label="Verify certificate hostname"
|
||||||
color="primary"
|
color="primary"
|
||||||
hint="Checks that the certificate matches the IMAP host"
|
hint="Checks that the certificate matches the IMAP host"
|
||||||
@@ -207,9 +258,78 @@ function defaultPortFor(nextEncryption: ImapEncryption): number {
|
|||||||
</v-expansion-panel>
|
</v-expansion-panel>
|
||||||
</v-expansion-panels>
|
</v-expansion-panels>
|
||||||
|
|
||||||
<v-alert type="info" variant="tonal">
|
<v-divider class="mb-6" />
|
||||||
STARTTLS is accepted for compatibility, but the current IMAP client transport does not perform STARTTLS negotiation. Prefer TLS on port 993 when available.
|
|
||||||
</v-alert>
|
<!-- ── Outgoing (SMTP) ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="d-flex align-center mb-4">
|
||||||
|
<v-icon start color="primary">mdi-email-arrow-right</v-icon>
|
||||||
|
<span class="text-subtitle-1 font-weight-medium">Outgoing mail (SMTP)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="outboundHost"
|
||||||
|
label="SMTP Server Host"
|
||||||
|
hint="For example: smtp.example.com"
|
||||||
|
persistent-hint
|
||||||
|
variant="outlined"
|
||||||
|
prepend-inner-icon="mdi-server-network"
|
||||||
|
class="mb-4"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="none"
|
||||||
|
:rules="[rules.required]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-model="outboundEncryption"
|
||||||
|
:items="encryptionOptions"
|
||||||
|
label="Security"
|
||||||
|
variant="outlined"
|
||||||
|
prepend-inner-icon="mdi-shield-lock"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="outboundPort"
|
||||||
|
label="Port"
|
||||||
|
hint="Defaults to 465 for implicit TLS, 587 for STARTTLS, and 25 for plain"
|
||||||
|
persistent-hint
|
||||||
|
variant="outlined"
|
||||||
|
prepend-inner-icon="mdi-numeric"
|
||||||
|
class="mb-4"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
:rules="[rules.required, rules.port]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-expansion-panels>
|
||||||
|
<v-expansion-panel>
|
||||||
|
<v-expansion-panel-title>
|
||||||
|
<v-icon start>mdi-cog</v-icon>
|
||||||
|
Security Options
|
||||||
|
</v-expansion-panel-title>
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
<v-switch
|
||||||
|
v-model="outboundVerifyPeer"
|
||||||
|
label="Verify TLS certificate"
|
||||||
|
color="primary"
|
||||||
|
hint="Disable only for trusted internal or test environments"
|
||||||
|
persistent-hint
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-switch
|
||||||
|
v-model="outboundVerifyHost"
|
||||||
|
label="Verify certificate hostname"
|
||||||
|
color="primary"
|
||||||
|
hint="Checks that the certificate matches the SMTP host"
|
||||||
|
persistent-hint
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user