From b163df6bf77701727874b46d24c8bea2ca71c598 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 21 Dec 2025 10:11:02 -0500 Subject: [PATCH] Initial commit --- .gitignore | 29 + composer.json | 27 + composer.lock | 1066 +++++++++++++++++++++++++++++++++++ lib/Module.php | 73 +++ lib/Providers/Provider.php | 236 ++++++++ lib/Providers/Service.php | 653 +++++++++++++++++++++ lib/Stores/ServiceStore.php | 362 ++++++++++++ 7 files changed, 2446 insertions(+) create mode 100644 .gitignore create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 lib/Module.php create mode 100644 lib/Providers/Provider.php create mode 100644 lib/Providers/Service.php create mode 100644 lib/Stores/ServiceStore.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..812e4cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Frontend development +node_modules/ +*.local +.env.local +.env.*.local +.cache/ +.vite/ +.temp/ +.tmp/ + +# Frontend build +/static/ + +# Backend development +/lib/vendor/ +coverage/ +phpunit.xml.cache +.phpunit.result.cache +.php-cs-fixer.cache +.phpstan.cache +.phpactor/ + +# Editors +.DS_Store +.vscode/ +.idea/ + +# Logs +*.log diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8e5a7b4 --- /dev/null +++ b/composer.json @@ -0,0 +1,27 @@ +{ + "name": "ktxm/provider-mail-system", + "type": "project", + "authors": [ + { + "name": "Sebastian Krupinski", + "email": "krupinski01@gmail.com" + } + ], + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.2" + }, + "autoloader-suffix": "ProviderMailSystem", + "vendor-dir": "lib/vendor" + }, + "require": { + "php": ">=8.2 <=8.5", + "symfony/mailer": "^7.0" + }, + "autoload": { + "psr-4": { + "KTXM\\ProviderMailSystem\\": "lib/" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..83d0658 --- /dev/null +++ b/composer.lock @@ -0,0 +1,1066 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "bb709c908478727cb01c6c5cf4af2552", + "packages": [ + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "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/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-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.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.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": "2025-10-28T09:38:46+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586", + "reference": "59eb412e93815df44f05f342958efa9f46b1e586", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "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": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "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": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.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": "2025-11-21T15:26:00+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", + "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": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "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": "^3.0|^4.0|^5.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.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": "2025-11-16T10:14:42+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "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.33.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": "2024-09-10T14:38:51+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "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.33.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": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", + "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "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.33.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": "2024-12-23T08:48:59+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "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": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.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": "2025-07-15T11:30:57+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2 <=8.5" + }, + "platform-dev": [], + "platform-overrides": { + "php": "8.2" + }, + "plugin-api-version": "2.3.0" +} diff --git a/lib/Module.php b/lib/Module.php new file mode 100644 index 0000000..987048e --- /dev/null +++ b/lib/Module.php @@ -0,0 +1,73 @@ + [ + 'label' => 'Access System Mail Provider', + 'description' => 'View and access the System mail provider module', + 'group' => 'Mail Providers' + ], + ]; + } + + public function boot(): void + { + $this->providerManager->register(ProviderInterface::TYPE_MAIL, 'system', Provider::class); + } + + public function registerBI(): array { + return [ + 'handle' => $this->handle(), + 'namespace' => 'ProviderMailSystem', + 'version' => $this->version(), + 'label' => $this->label(), + 'author' => $this->author(), + 'description' => $this->description() + ]; + } +} diff --git a/lib/Providers/Provider.php b/lib/Providers/Provider.php new file mode 100644 index 0000000..73a117e --- /dev/null +++ b/lib/Providers/Provider.php @@ -0,0 +1,236 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderMailSystem\Providers; + +use KTXF\Mail\Provider\ProviderBaseInterface; +use KTXF\Mail\Selector\ServiceSelector; +use KTXF\Mail\Service\IServiceBase; +use KTXF\Mail\Service\ServiceScope; +use KTXM\ProviderMailSystem\Stores\ServiceStore; +use Psr\Log\LoggerInterface; + +/** + * SMTP Mail Provider + * + * Provider for SMTP-based mail services. Supports multiple configured + * services per tenant with system and user scopes. + * + * @since 2025.05.01 + */ +class Provider implements ProviderBaseInterface { + + private const PROVIDER_ID = 'system'; + private const PROVIDER_LABEL = 'System Mail Provider'; + private const PROVIDER_DESCRIPTION = 'Outbound-only System mail provider for system notifications'; + private const PROVIDER_ICON = 'fa-envelope'; + + private array $capabilities = [ + self::CAPABILITY_SERVICE_LIST => true, + self::CAPABILITY_SERVICE_FETCH => true, + self::CAPABILITY_SERVICE_EXTANT => true, + ]; + + public function __construct( + private LoggerInterface $logger, + private ServiceStore $serviceStore, + ) {} + + /** + * @inheritDoc + */ + public function capable(string $value): bool { + return $this->capabilities[$value] ?? false; + } + + /** + * @inheritDoc + */ + public function capabilities(): array { + return $this->capabilities; + } + + /** + * @inheritDoc + */ + public function id(): string { + return self::PROVIDER_ID; + } + + /** + * @inheritDoc + */ + public function label(): string { + return self::PROVIDER_LABEL; + } + + /** + * @inheritDoc + */ + public function type(): string { + return self::TYPE_MAIL; + } + + /** + * @inheritDoc + */ + public function identifier(): string { + return self::PROVIDER_ID; + } + + /** + * @inheritDoc + */ + public function description(): string { + return self::PROVIDER_DESCRIPTION; + } + + /** + * @inheritDoc + */ + public function icon(): string { + return self::PROVIDER_ICON; + } + + /** + * @inheritDoc + */ + public function serviceList(string $tenantId, string $userId, ?ServiceSelector $selector = null): array { + + } + + /** + * @inheritDoc + */ + public function serviceExtant(string $tenantId, ?string $userId, string|int ...$identifiers): array { + $result = []; + + foreach ($identifiers as $id) { + $result[$id] = $this->serviceFetch($tenantId, $userId, $id) !== null; + } + + return $result; + } + + /** + * @inheritDoc + */ + public function serviceFetch(string $tenantId, ?string $userId, string|int $identifier): ?IServiceBase { + $identifier = (string)$identifier; + if ($identifier === '') { + return null; + } + + // Only handle @system addresses for this provider + if (!str_ends_with(strtolower($identifier), '@system')) { + return null; + } + + // Fetch by primary address (which is the sid) + $service = $this->serviceStore->getService($tenantId, $identifier); + if ($service === null) { + return null; + } + + // Enforce scope visibility + if ($service->getScope() === ServiceScope::System) { + return $service; + } + + if ($service->getScope() === ServiceScope::User) { + if ($userId !== null && $service->getOwner() === $userId) { + return $service; + } + return null; + } + + return null; + } + + /** + * @inheritDoc + */ + public function serviceFindByAddress(string $tenantId, ?string $userId, string $address): ?IServiceBase { + $address = strtolower(trim($address)); + if ($address === '') { + return null; + } + + // Only handle @system addresses for this provider + if (!str_ends_with($address, '@system')) { + return null; + } + + // Use store's findServiceByAddress which checks primary, secondary, and catch-all patterns + $service = $this->serviceStore->findServiceByAddress($tenantId, $address); + if ($service === null) { + return null; + } + + // Enforce scope visibility + if ($service->getScope() === ServiceScope::System) { + return $service; + } + + if ($service->getScope() === ServiceScope::User) { + if ($userId !== null && $service->getOwner() === $userId) { + return $service; + } + return null; + } + + return null; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_ID => $this->id(), + self::JSON_PROPERTY_LABEL => $this->label(), + self::JSON_PROPERTY_CAPABILITIES => $this->capabilities(), + ]; + } + + /** + * Apply selector filters to services + */ + private function applySelector(array $services, ServiceSelector $selector): array { + return array_filter($services, function(IServiceBase $service) use ($selector) { + if ($selector->getScope() !== null && $service->getScope() !== $selector->getScope()) { + return false; + } + + if ($selector->getOwner() !== null && $service->getOwner() !== $selector->getOwner()) { + return false; + } + + if ($selector->getAddress() !== null && !$service->handlesAddress($selector->getAddress())) { + return false; + } + + if ($selector->getCapabilities() !== null) { + foreach ($selector->getCapabilities() as $cap) { + if (!$service->capable($cap)) { + return false; + } + } + } + + if ($selector->getEnabled() !== null && $service->getEnabled() !== $selector->getEnabled()) { + return false; + } + + return true; + }); + } + +} diff --git a/lib/Providers/Service.php b/lib/Providers/Service.php new file mode 100644 index 0000000..60ff00f --- /dev/null +++ b/lib/Providers/Service.php @@ -0,0 +1,653 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderMailSystem\Providers; + +use KTXF\Mail\Entity\Address; +use KTXF\Mail\Entity\IAddress; +use KTXF\Mail\Entity\IMessageMutable; +use KTXF\Mail\Entity\Message; +use KTXF\Mail\Exception\SendException; +use KTXF\Mail\Service\IServiceIdentity; +use KTXF\Mail\Service\IServiceLocation; +use KTXF\Mail\Service\IServiceSend; +use KTXF\Mail\Service\ServiceScope; +use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mailer\Transport; +use Symfony\Component\Mime\Email; + +/** + * SMTP Mail Service + * + * Outbound-only mail service using Symfony Mailer for SMTP delivery. + * + * @since 2025.05.01 + */ +class Service implements IServiceSend { + + private array $capabilities = [ + self::CAPABILITY_SEND => true, + ]; + + /** @var array */ + private array $secondaryAddresses = []; + + private ?Mailer $mailer = null; + + /** + * @param string $providerId Provider identifier + * @param string|int $id Service identifier + * @param string $label Human-friendly label + * @param ServiceScope $scope Service scope + * @param string|null $owner Owner user ID (for User scope) + * @param bool $enabled Whether service is enabled + * @param IAddress|null $primaryAddress Primary sending address + * @param IServiceLocation|null $location Connection location + * @param IServiceIdentity|null $identity Authentication credentials + */ + public function __construct( + private string $providerId, + private string|int $id, + private string $label, + private ServiceScope $scope = ServiceScope::System, + private ?string $owner = null, + private bool $enabled = true, + private ?IAddress $primaryAddress = null, + private ?IServiceLocation $location = null, + private ?IServiceIdentity $identity = null, + ) {} + + /** + * @inheritDoc + */ + public function capable(string $value): bool { + return $this->capabilities[$value] ?? false; + } + + /** + * @inheritDoc + */ + public function capabilities(): array { + return $this->capabilities; + } + + /** + * @inheritDoc + */ + public function in(): string { + return $this->providerId; + } + + /** + * @inheritDoc + */ + public function id(): string|int { + return $this->id; + } + + /** + * @inheritDoc + */ + public function getLabel(): string { + return $this->label; + } + + /** + * Sets the service label + * + * @param string $label + * + * @return self + */ + public function setLabel(string $label): self { + $this->label = $label; + return $this; + } + + /** + * @inheritDoc + */ + public function getScope(): ServiceScope { + return $this->scope; + } + + /** + * Sets the service scope + * + * @param ServiceScope $scope + * + * @return self + */ + public function setScope(ServiceScope $scope): self { + $this->scope = $scope; + return $this; + } + + /** + * @inheritDoc + */ + public function getOwner(): ?string { + return $this->owner; + } + + /** + * Sets the service owner + * + * @param string|null $owner + * + * @return self + */ + public function setOwner(?string $owner): self { + $this->owner = $owner; + return $this; + } + + /** + * @inheritDoc + */ + public function getEnabled(): bool { + return $this->enabled; + } + + /** + * Sets the enabled status + * + * @param bool $enabled + * + * @return self + */ + public function setEnabled(bool $enabled): self { + $this->enabled = $enabled; + return $this; + } + + /** + * @inheritDoc + */ + public function getPrimaryAddress(): IAddress { + return $this->primaryAddress ?? new Address(''); + } + + /** + * Sets the primary address + * + * @param IAddress $address + * + * @return self + */ + public function setPrimaryAddress(IAddress $address): self { + $this->primaryAddress = $address; + return $this; + } + + /** + * @inheritDoc + */ + public function getSecondaryAddresses(): array { + return $this->secondaryAddresses; + } + + /** + * Sets the secondary addresses + * + * @param array $addresses + * + * @return self + */ + public function setSecondaryAddresses(array $addresses): self { + $this->secondaryAddresses = $addresses; + return $this; + } + + /** + * Adds a secondary address + * + * @param IAddress $address + * + * @return self + */ + public function addSecondaryAddress(IAddress $address): self { + $this->secondaryAddresses[] = $address; + return $this; + } + + /** + * Gets the service location + * + * @return IServiceLocation|null + */ + public function getLocation(): ?IServiceLocation { + return $this->location; + } + + /** + * Sets the service location + * + * @param IServiceLocation $location + * + * @return self + */ + public function setLocation(IServiceLocation $location): self { + $this->location = $location; + $this->mailer = null; // Reset mailer when location changes + return $this; + } + + /** + * Gets the service identity + * + * @return IServiceIdentity|null + */ + public function getIdentity(): ?IServiceIdentity { + return $this->identity; + } + + /** + * Sets the service identity + * + * @param IServiceIdentity $identity + * + * @return self + */ + public function setIdentity(IServiceIdentity $identity): self { + $this->identity = $identity; + $this->mailer = null; // Reset mailer when identity changes + return $this; + } + + /** + * @inheritDoc + */ + public function handlesAddress(string $address): bool { + $address = strtolower(trim($address)); + + // Check primary address (exact match or catch-all) + if ($this->primaryAddress !== null) { + if ($this->matchesAddress($this->primaryAddress->getAddress(), $address)) { + return true; + } + } + + // Check secondary addresses (exact match or catch-all) + foreach ($this->secondaryAddresses as $secondary) { + if ($this->matchesAddress($secondary->getAddress(), $address)) { + return true; + } + } + + return false; + } + + /** + * Check if a pattern matches an address (supports catch-all with *) + * + * @param string $pattern Pattern like "noreply@system" or "*@system" + * @param string $address Address to check + * + * @return bool + */ + private function matchesAddress(string $pattern, string $address): bool { + $pattern = strtolower(trim($pattern)); + $address = strtolower(trim($address)); + + // Exact match + if ($pattern === $address) { + return true; + } + + // Catch-all pattern (e.g., *@system) + if (str_starts_with($pattern, '*@')) { + $domain = substr($pattern, 2); + return str_ends_with($address, '@' . $domain); + } + + return false; + } + + /** + * @inheritDoc + */ + public function messageFresh(): IMessageMutable { + $message = new Message(); + + // Pre-populate from address if available + if ($this->primaryAddress !== null) { + $message->setFrom($this->primaryAddress); + } + + return $message; + } + + /** + * @inheritDoc + */ + public function messageSend(IMessageMutable $message): string { + if (!$this->enabled) { + throw SendException::permanent('Service is disabled'); + } + + if ($this->location === null) { + throw SendException::permanent('Service location not configured'); + } + + if (!$message->hasRecipients()) { + throw SendException::permanent('Message has no recipients'); + } + + if (!$message->hasBody()) { + throw SendException::permanent('Message has no body content'); + } + + // Build Symfony Email + $email = $this->buildSymfonyEmail($message); + + // Get or create mailer + $mailer = $this->getMailer(); + + try { + $mailer->send($email); + + // Return message ID (generate one if not available) + return $email->getHeaders()->get('Message-ID')?->getBodyAsString() + ?? $this->generateMessageId(); + + } catch (TransportExceptionInterface $e) { + // Determine if this is a permanent or temporary failure + $message = $e->getMessage(); + $isPermanent = $this->isPermanentFailure($e); + + throw new SendException( + "SMTP delivery failed: $message", + $e->getCode(), + $e, + null, + $isPermanent + ); + } + } + + // Collection operations - SMTP is outbound-only, these are not supported + public function collectionList(?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null): array { + return []; + } + + public function collectionListFilter(): \KTXF\Resource\Filter\IFilter { + throw new \BadMethodCallException('SMTP service does not support collection operations'); + } + + public function collectionListSort(): \KTXF\Resource\Sort\ISort { + throw new \BadMethodCallException('SMTP service does not support collection operations'); + } + + public function collectionExtant(string|int ...$identifiers): array { + return []; + } + + public function collectionFetch(string|int $identifier): ?\KTXF\Mail\Collection\ICollectionBase { + return null; + } + + // Message operations - SMTP is outbound-only, these are not supported + public function messageList(string|int $collection, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null, ?\KTXF\Resource\Range\IRange $range = null, ?array $properties = null): array { + return []; + } + + public function messageListFilter(): \KTXF\Resource\Filter\IFilter { + throw new \BadMethodCallException('SMTP service does not support message listing'); + } + + public function messageListSort(): \KTXF\Resource\Sort\ISort { + throw new \BadMethodCallException('SMTP service does not support message listing'); + } + + public function messageListRange(\KTXF\Resource\Range\RangeType $type): \KTXF\Resource\Range\IRange { + throw new \BadMethodCallException('SMTP service does not support message listing'); + } + + public function messageDelta(string|int $collection, string $signature, string $detail = 'ids'): array { + return ['signature' => $signature, 'added' => [], 'modified' => [], 'removed' => []]; + } + + public function messageExtant(string|int $collection, string|int ...$identifiers): array { + return []; + } + + public function messageFetch(string|int $collection, string|int ...$identifiers): array { + return []; + } + + public function messageSearch(string $query, ?array $collections = null, ?\KTXF\Resource\Filter\IFilter $filter = null, ?\KTXF\Resource\Sort\ISort $sort = null, ?\KTXF\Resource\Range\IRange $range = null): array { + return []; + } + + /** + * @inheritDoc + */ + public function jsonSerialize(): array { + return [ + self::JSON_PROPERTY_TYPE => self::JSON_TYPE, + self::JSON_PROPERTY_PROVIDER => $this->providerId, + self::JSON_PROPERTY_ID => $this->id, + self::JSON_PROPERTY_LABEL => $this->label, + self::JSON_PROPERTY_SCOPE => $this->scope->value, + self::JSON_PROPERTY_OWNER => $this->owner, + self::JSON_PROPERTY_ENABLED => $this->enabled, + self::JSON_PROPERTY_CAPABILITIES => $this->capabilities, + self::JSON_PROPERTY_PRIMARY_ADDRESS => $this->primaryAddress, + self::JSON_PROPERTY_SECONDARY_ADDRESSES => $this->secondaryAddresses, + ]; + } + + /** + * Build a Symfony Email from IMessageMutable + */ + private function buildSymfonyEmail(IMessageMutable $message): Email { + $email = new Email(); + + // From + $from = $message->getFrom() ?? $this->primaryAddress; + if ($from !== null) { + $email->from(new \Symfony\Component\Mime\Address( + $from->getAddress(), + $from->getName() ?? '' + )); + } + + // Reply-To + $replyTo = $message->getReplyTo(); + if ($replyTo !== null) { + $email->replyTo(new \Symfony\Component\Mime\Address( + $replyTo->getAddress(), + $replyTo->getName() ?? '' + )); + } + + // To + foreach ($message->getTo() as $to) { + $email->addTo(new \Symfony\Component\Mime\Address( + $to->getAddress(), + $to->getName() ?? '' + )); + } + + // CC + foreach ($message->getCc() as $cc) { + $email->addCc(new \Symfony\Component\Mime\Address( + $cc->getAddress(), + $cc->getName() ?? '' + )); + } + + // BCC + foreach ($message->getBcc() as $bcc) { + $email->addBcc(new \Symfony\Component\Mime\Address( + $bcc->getAddress(), + $bcc->getName() ?? '' + )); + } + + // Subject + $email->subject($message->getSubject()); + + // Body + if ($message->getBodyText() !== null) { + $email->text($message->getBodyText()); + } + if ($message->getBodyHtml() !== null) { + $email->html($message->getBodyHtml()); + } + + // Attachments + foreach ($message->getAttachments() as $attachment) { + if ($attachment->isInline()) { + $email->embed( + $attachment->getContent(), + $attachment->getName(), + $attachment->getMimeType() + ); + } else { + $email->attach( + $attachment->getContent(), + $attachment->getName(), + $attachment->getMimeType() + ); + } + } + + // Custom headers + foreach ($message->getHeaders() as $name => $value) { + $email->getHeaders()->addTextHeader($name, $value); + } + + return $email; + } + + /** + * Get or create the Symfony Mailer instance + */ + private function getMailer(): Mailer { + if ($this->mailer === null) { + $dsn = $this->buildDsn(); + $transport = Transport::fromDsn($dsn); + $this->mailer = new Mailer($transport); + } + + return $this->mailer; + } + + /** + * Build the DSN string for Symfony Mailer + */ + private function buildDsn(): string { + if ($this->location === null) { + throw new \RuntimeException('Service location not configured'); + } + + $host = $this->location->getOutboundHost() ?? $this->location->getInboundHost(); + $port = $this->location->getOutboundPort() ?? $this->location->getInboundPort(); + $security = $this->location->getOutboundSecurity() ?? $this->location->getInboundSecurity(); + + if ($host === null) { + throw new \RuntimeException('SMTP host not configured'); + } + + // Determine scheme based on security setting + $scheme = match($security) { + IServiceLocation::SECURITY_SSL => 'smtps', + IServiceLocation::SECURITY_TLS, IServiceLocation::SECURITY_STARTTLS => 'smtp', + default => 'smtp', + }; + + // Build DSN + $dsn = $scheme . '://'; + + // Add credentials if available + if ($this->identity !== null) { + $username = $this->identity->getUsername ?? null; + $password = $this->identity->getPassword ?? null; + + if (method_exists($this->identity, 'getUsername')) { + $username = $this->identity->getUsername(); + } + if (method_exists($this->identity, 'getPassword')) { + $password = $this->identity->getPassword(); + } + + if ($username !== null) { + $dsn .= urlencode($username); + if ($password !== null) { + $dsn .= ':' . urlencode($password); + } + $dsn .= '@'; + } + } + + $dsn .= $host; + + if ($port !== null) { + $dsn .= ':' . $port; + } + + // Disable certificate verification + $dsn .= '?verify_peer=0'; + + return $dsn; + } + + /** + * Generate a unique message ID + */ + private function generateMessageId(): string { + $domain = 'ktrix.local'; + if ($this->primaryAddress !== null) { + $parts = explode('@', $this->primaryAddress->getAddress()); + if (count($parts) === 2) { + $domain = $parts[1]; + } + } + + return sprintf('<%s.%s@%s>', + bin2hex(random_bytes(8)), + time(), + $domain + ); + } + + /** + * Determine if an exception represents a permanent failure + */ + private function isPermanentFailure(TransportExceptionInterface $e): bool { + $message = strtolower($e->getMessage()); + + // Common permanent failure indicators + $permanentPatterns = [ + 'user unknown', + 'mailbox not found', + 'invalid recipient', + 'relay access denied', + 'authentication failed', + 'bad recipient', + '550 ', + '551 ', + '552 ', + '553 ', + '554 ', + ]; + + foreach ($permanentPatterns as $pattern) { + if (str_contains($message, $pattern)) { + return true; + } + } + + return false; + } + +} diff --git a/lib/Stores/ServiceStore.php b/lib/Stores/ServiceStore.php new file mode 100644 index 0000000..6df0aaf --- /dev/null +++ b/lib/Stores/ServiceStore.php @@ -0,0 +1,362 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderMailSystem\Stores; + +use KTXF\Mail\Entity\Address; +use KTXF\Mail\Service\IServiceBase; +use KTXF\Mail\Service\ServiceIdentityBasic; +use KTXF\Mail\Service\ServiceLocation; +use KTXF\Mail\Service\ServiceScope; +use KTXM\ProviderMailSystem\Providers\Service; +use KTXC\Db\DataStore; +use Psr\Log\LoggerInterface; + +/** + * Service Store + * + * @since 2025.05.01 + */ +class ServiceStore { + + public function __construct( + private DataStore $store, + private LoggerInterface $logger, + ) {} + + private string $serviceCollection = 'mail_provider_smtp_service'; + + /** + * List all services for a tenant + * + * @param string $tenantId + * + * @return array + */ + public function listServices(string $tenantId): array { + try { + $cursor = $this->store->selectCollection($this->serviceCollection)->find([ + 'tid' => $tenantId, + ]); + + $services = []; + foreach ($cursor as $entry) { + $id = (string)($entry['sid'] ?? ''); + if ($id === '') { + continue; + } + + $service = $this->hydrateService($id, is_array($entry) ? $entry : []); + if ($service !== null) { + $services[$id] = $service; + } + } + + return $services; + } catch (\Throwable $e) { + $this->logger->warning('Failed to list services', [ + 'tenantId' => $tenantId, + 'error' => $e->getMessage(), + ]); + return []; + } + } + + /** + * Find a service by address (checks primary, secondary, and catch-all patterns) + * + * @param string $tenantId + * @param string $address Address to search for + * + * @return Service|null + */ + public function findServiceByAddress(string $tenantId, string $address): ?Service { + $address = strtolower(trim($address)); + if ($address === '') { + return null; + } + + try { + // Get all services for tenant + $services = $this->listServices($tenantId); + + foreach ($services as $service) { + if ($service->handlesAddress($address)) { + return $service; + } + } + + return null; + } catch (\Throwable $e) { + $this->logger->warning('Failed to find service by address', [ + 'tenantId' => $tenantId, + 'address' => $address, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get a specific service + * + * @param string $tenantId + * @param string|int $serviceId + * + * @return Service|null + */ + public function getService(string $tenantId, string|int $serviceId): ?Service { + $serviceId = (string)$serviceId; + if ($serviceId === '') { + return null; + } + + try { + $entry = $this->store->selectCollection($this->serviceCollection)->findOne([ + 'tid' => $tenantId, + 'sid' => $serviceId, + ]); + + if ($entry === null) { + return null; + } + + return $this->hydrateService($serviceId, is_array($entry) ? $entry : []); + } catch (\Throwable $e) { + $this->logger->warning('Failed to fetch service', [ + 'tenantId' => $tenantId, + 'serviceId' => $serviceId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Create a new service + * + * @param string $tenantId + * @param IServiceBase $service + * + * @return string|int Service ID + */ + public function createService(string $tenantId, IServiceBase $service): string|int { + $id = (string)$service->id(); + if ($id === '') { + $id = $this->generateServiceId($tenantId); + } + + $now = date('c'); + $data = $this->dehydrateService($service); + + try { + $document = array_merge($data, [ + 'tid' => $tenantId, + 'sid' => $id, + 'createdOn' => $now, + 'modifiedOn' => $now, + ]); + + $this->store->selectCollection($this->serviceCollection)->insertOne($document); + return $id; + } catch (\Throwable $e) { + $this->logger->warning('Failed to create service', [ + 'tenantId' => $tenantId, + 'serviceId' => $id, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Update an existing service + * + * @param string $tenantId + * @param IServiceBase $service + * + * @return string|int Service ID + */ + public function updateService(string $tenantId, IServiceBase $service): string|int { + $id = (string)$service->id(); + if ($id === '') { + $id = $this->generateServiceId($tenantId); + } + + $now = date('c'); + $data = $this->dehydrateService($service); + unset($data['tid'], $data['sid'], $data['createdOn'], $data['modifiedOn']); + + try { + $this->store->selectCollection($this->serviceCollection)->updateOne( + ['tid' => $tenantId, 'sid' => $id], + [ + '$set' => array_merge($data, ['modifiedOn' => $now]), + '$setOnInsert' => ['tid' => $tenantId, 'sid' => $id, 'createdOn' => $now], + ], + ['upsert' => true] + ); + + return $id; + } catch (\Throwable $e) { + $this->logger->warning('Failed to update service', [ + 'tenantId' => $tenantId, + 'serviceId' => $id, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Delete a service + * + * @param string $tenantId + * @param string|int $serviceId + * + * @return bool + */ + public function deleteService(string $tenantId, string|int $serviceId): bool { + $serviceId = (string)$serviceId; + if ($serviceId === '') { + return false; + } + + try { + $result = $this->store->selectCollection($this->serviceCollection)->deleteOne([ + 'tid' => $tenantId, + 'sid' => $serviceId, + ]); + + return $result->getDeletedCount() === 1; + } catch (\Throwable $e) { + $this->logger->warning('Failed to delete service', [ + 'tenantId' => $tenantId, + 'serviceId' => $serviceId, + 'error' => $e->getMessage(), + ]); + return false; + } + } + + /** + * Generate a unique service ID + */ + private function generateServiceId(string $tenantId): string { + // Try a few times to avoid collisions if a unique index is ever added. + for ($attempt = 0; $attempt < 5; $attempt++) { + $id = sprintf('%08x-%04x', time(), mt_rand(0, 0xffff)); + try { + $existing = $this->store->selectCollection($this->serviceCollection)->findOne([ + 'tid' => $tenantId, + 'sid' => $id, + ], [ + 'projection' => ['sid' => 1, '_id' => 0], + ]); + + if ($existing === null) { + return $id; + } + } catch (\Throwable) { + // If the store is unavailable, fall back to generated id. + return $id; + } + } + + return sprintf('%08x-%04x', time(), mt_rand(0, 0xffff)); + } + + /** + * Hydrate a Service from stored data + */ + private function hydrateService(string|int $id, array $data): ?Service { + try { + $service = new Service( + providerId: 'smtp', + id: $id, + label: $data['label'] ?? '', + scope: ServiceScope::tryFrom($data['scope'] ?? 'system') ?? ServiceScope::System, + owner: $data['owner'] ?? null, + enabled: $data['enabled'] ?? true, + ); + + // Primary address + if (isset($data['primaryAddress'])) { + $service->setPrimaryAddress(Address::fromArray($data['primaryAddress'])); + } + + // Secondary addresses + if (isset($data['secondaryAddresses']) && is_array($data['secondaryAddresses'])) { + foreach ($data['secondaryAddresses'] as $addrData) { + $service->addSecondaryAddress(Address::fromArray($addrData)); + } + } + + // Location + if (isset($data['location'])) { + $service->setLocation(ServiceLocation::fromArray($data['location'])); + } + + // Identity + if (isset($data['identity'])) { + $identityType = $data['identity']['type'] ?? 'basic'; + if ($identityType === 'basic') { + $service->setIdentity(ServiceIdentityBasic::fromArray($data['identity'])); + } + } + + return $service; + + } catch (\Throwable $e) { + $this->logger->warning('Failed to hydrate service', [ + 'id' => $id, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Dehydrate a Service to storable data + */ + private function dehydrateService(IServiceBase $service): array { + $data = [ + 'label' => $service->getLabel(), + 'scope' => $service->getScope()->value, + 'owner' => $service->getOwner(), + 'enabled' => $service->getEnabled(), + 'primaryAddress' => $service->getPrimaryAddress()->jsonSerialize(), + 'secondaryAddresses' => array_map( + fn($a) => $a->jsonSerialize(), + $service->getSecondaryAddresses() + ), + ]; + + // Store location if it's a Service instance + if ($service instanceof Service) { + $location = $service->getLocation(); + if ($location !== null) { + $data['location'] = $location->jsonSerialize(); + } + + $identity = $service->getIdentity(); + if ($identity !== null) { + $identityData = $identity->jsonSerialize(); + // Include password for storage (it's excluded from default serialization) + if ($identity instanceof ServiceIdentityBasic) { + $identityData['password'] = $identity->getPassword(); + } + $data['identity'] = $identityData; + } + } + + return $data; + } + +}