diff --git a/application/Espo/Core/HttpClient/Client.php b/application/Espo/Core/HttpClient/Client.php new file mode 100644 index 0000000000..af3e640bc8 --- /dev/null +++ b/application/Espo/Core/HttpClient/Client.php @@ -0,0 +1,143 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient; + +use Espo\Core\HttpClient\Exceptions\ConnectException; +use Espo\Core\HttpClient\Exceptions\NotAllowedInternalHost; +use Espo\Core\HttpClient\Exceptions\TooManyRedirectsException; +use Espo\Core\Utils\Security\UrlCheck; +use GuzzleHttp; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use RuntimeException; + +use const CURLE_OPERATION_TIMEDOUT; + +class Client +{ + private const int MAX_REDIRECT_NUMBER = 5; + + /** + * To be instantiated with the ClientFactory. + * + * @internal + */ + public function __construct( + private Options $options, + private UrlCheck $urlCheck, + ) {} + + /** + * Send a request. Does not throw exceptions on error responses. + * + * @throws TooManyRedirectsException + * @throws ConnectException + */ + public function send(RequestInterface $request): ResponseInterface + { + $options = [ + 'protocols' => array_map( + fn (Protocol $protocol) => $protocol->value, + $this->options->redirect->protocols + ), + 'allow_redirects' => false, + 'http_errors' => false, + ]; + + if ($this->options->redirect->allow) { + $options['allow_redirects'] = [ + 'max' => $this->options->redirect->maxNumber ?? self::MAX_REDIRECT_NUMBER, + 'strict' => $this->options->redirect->strict, + 'protocols' => array_map( + fn (Protocol $protocol) => $protocol->value, + $this->options->redirect->protocols + ), + ]; + } + + if ($this->options->timeout !== null) { + $options['timeout'] = $this->options->timeout; + } + + if ($this->options->connectTimeout !== null) { + $options['connect_timeout'] = $this->options->connectTimeout; + } + + if ($this->options->internalHostRestriction->restrict) { + $stack = GuzzleHttp\HandlerStack::create(); + + $stack->push( + GuzzleHttp\Middleware::mapRequest(function (RequestInterface $request) { + $url = (string) $request->getUri(); + + $this->checkUrl($url, $this->options->internalHostRestriction->allowed); + + return $request; + }) + ); + + $options['handler'] = $stack; + } + + $client = new GuzzleHttp\Client($options); + + try { + return $client->send($request); + } catch (GuzzleHttp\Exception\ConnectException $e) { + $context = $e->getHandlerContext(); + + $reason = null; + + if (($context['errno'] ?? 0) === CURLE_OPERATION_TIMEDOUT) { + $reason = ConnectErrorReason::Timeout; + } + + throw ConnectException::create(previous: $e, reason: $reason); + } catch (GuzzleHttp\Exception\TooManyRedirectsException $e) { + throw new TooManyRedirectsException(previous: $e); + } catch (GuzzleHttp\Exception\GuzzleException $e) { + throw new RuntimeException(previous: $e); + } + } + + /** + * @param string[] $allowed + * @throws NotAllowedInternalHost + */ + private function checkUrl(string $url, array $allowed): void + { + if ( + !Util::matchUrlToAddressList($url, $allowed) && + !$this->urlCheck->isNotInternalUrl($url) + ) { + throw new NotAllowedInternalHost("Not allowed internal host in '$url'."); + } + } +} diff --git a/application/Espo/Core/HttpClient/ClientFactory.php b/application/Espo/Core/HttpClient/ClientFactory.php new file mode 100644 index 0000000000..f3298197a4 --- /dev/null +++ b/application/Espo/Core/HttpClient/ClientFactory.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient; + +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\InjectableFactory; + +/** + * An HTTP client factory. + * + * @since 9.4.0 + */ +class ClientFactory +{ + public function __construct( + private InjectableFactory $injectableFactory, + ) {} + + public function create(Options $options): Client + { + $binding = BindingContainerBuilder::create() + ->bindInstance(Options::class, $options) + ->build(); + + return $this->injectableFactory->createWithBinding(Client::class, $binding); + } +} diff --git a/application/Espo/Core/HttpClient/ConnectErrorReason.php b/application/Espo/Core/HttpClient/ConnectErrorReason.php new file mode 100644 index 0000000000..a3c1b846da --- /dev/null +++ b/application/Espo/Core/HttpClient/ConnectErrorReason.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient; + +enum ConnectErrorReason +{ + case Timeout; +} diff --git a/application/Espo/Core/HttpClient/Exceptions/ConnectException.php b/application/Espo/Core/HttpClient/Exceptions/ConnectException.php new file mode 100644 index 0000000000..cfc67c9e15 --- /dev/null +++ b/application/Espo/Core/HttpClient/Exceptions/ConnectException.php @@ -0,0 +1,54 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient\Exceptions; + +use Espo\Core\HttpClient\ConnectErrorReason; +use Exception; + +final class ConnectException extends SendException +{ + private ?ConnectErrorReason $reason = null; + + /** + * @internal + */ + public static function create(?Exception $previous, ?ConnectErrorReason $reason): ConnectException + { + $exception = new ConnectException(previous: $previous); + $exception->reason = $reason; + + return $exception; + } + + public function getReason(): ?ConnectErrorReason + { + return $this->reason; + } +} diff --git a/application/Espo/Core/HttpClient/Exceptions/NotAllowedInternalHost.php b/application/Espo/Core/HttpClient/Exceptions/NotAllowedInternalHost.php new file mode 100644 index 0000000000..ec40e7d2cf --- /dev/null +++ b/application/Espo/Core/HttpClient/Exceptions/NotAllowedInternalHost.php @@ -0,0 +1,34 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient\Exceptions; + +class NotAllowedInternalHost extends SendException +{ +} diff --git a/application/Espo/Core/HttpClient/Exceptions/SendException.php b/application/Espo/Core/HttpClient/Exceptions/SendException.php new file mode 100644 index 0000000000..db1d2271ed --- /dev/null +++ b/application/Espo/Core/HttpClient/Exceptions/SendException.php @@ -0,0 +1,35 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient\Exceptions; + +use Exception; + +abstract class SendException extends Exception +{} diff --git a/application/Espo/Core/HttpClient/Exceptions/TooManyRedirectsException.php b/application/Espo/Core/HttpClient/Exceptions/TooManyRedirectsException.php new file mode 100644 index 0000000000..96177ebebb --- /dev/null +++ b/application/Espo/Core/HttpClient/Exceptions/TooManyRedirectsException.php @@ -0,0 +1,33 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient\Exceptions; + +class TooManyRedirectsException extends SendException +{} diff --git a/application/Espo/Core/HttpClient/Options.php b/application/Espo/Core/HttpClient/Options.php new file mode 100644 index 0000000000..2d31ac0fef --- /dev/null +++ b/application/Espo/Core/HttpClient/Options.php @@ -0,0 +1,50 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient; + +use Espo\Core\HttpClient\Options\InternalHostRestriction; +use Espo\Core\HttpClient\Options\Redirect; + +readonly class Options +{ + /** + * @todo SSL options. + * Use named parameters when calling. + * + * @param Protocol[] $protocols + */ + public function __construct( + public array $protocols = [Protocol::https, Protocol::http], + public Redirect $redirect = new Redirect(), + public ?int $timeout = null, + public ?int $connectTimeout = null, + public InternalHostRestriction $internalHostRestriction = new InternalHostRestriction(), + ) {} +} diff --git a/application/Espo/Core/HttpClient/Options/InternalHostRestriction.php b/application/Espo/Core/HttpClient/Options/InternalHostRestriction.php new file mode 100644 index 0000000000..26dfc2089b --- /dev/null +++ b/application/Espo/Core/HttpClient/Options/InternalHostRestriction.php @@ -0,0 +1,41 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient\Options; + +class InternalHostRestriction +{ + /** + * @param string[] $allowed A list of `host:port`. + */ + public function __construct( + public bool $restrict = false, + public array $allowed = [], + ) {} +} diff --git a/application/Espo/Core/HttpClient/Options/Redirect.php b/application/Espo/Core/HttpClient/Options/Redirect.php new file mode 100644 index 0000000000..fc5a78ff5d --- /dev/null +++ b/application/Espo/Core/HttpClient/Options/Redirect.php @@ -0,0 +1,47 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient\Options; + +use Espo\Core\HttpClient\Protocol; + +readonly class Redirect +{ + /** + * Use named parameters when calling. + * + * @param Protocol[] $protocols + */ + public function __construct( + public bool $allow = false, + public array $protocols = [Protocol::https], + public ?int $maxNumber = null, + public bool $strict = false, + ) {} +} diff --git a/application/Espo/Core/HttpClient/Protocol.php b/application/Espo/Core/HttpClient/Protocol.php new file mode 100644 index 0000000000..e2da6fb71f --- /dev/null +++ b/application/Espo/Core/HttpClient/Protocol.php @@ -0,0 +1,36 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient; + +enum Protocol: string +{ + case http = 'http'; + case https = 'https'; +} diff --git a/application/Espo/Core/HttpClient/Util.php b/application/Espo/Core/HttpClient/Util.php new file mode 100644 index 0000000000..5571f0a598 --- /dev/null +++ b/application/Espo/Core/HttpClient/Util.php @@ -0,0 +1,80 @@ +. + * + * The interactive user interfaces in modified source and object code versions + * of this program must display Appropriate Legal Notices, as required under + * Section 5 of the GNU Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Core\HttpClient; + +use GuzzleHttp\Psr7\Utils; +use Psr\Http\Message\StreamInterface; + +class Util +{ + /** + * @param resource|string|int|float|bool|StreamInterface $resource + * @since 9.4.0 + */ + public static function streamFor($resource): StreamInterface + { + return Utils::streamFor($resource); + } + + /** + * @internal + * @param string[] $addressList + */ + public static function matchUrlToAddressList(string $url, array $addressList): bool + { + if (!$addressList) { + return false; + } + + $host = parse_url($url, PHP_URL_HOST); + $port = parse_url($url, PHP_URL_PORT); + $scheme = parse_url($url, PHP_URL_SCHEME); + + if (!is_string($host)) { + return false; + } + + if (!is_int($port)) { + if ($scheme === 'https') { + $port = 443; + } else if ($scheme === 'http') { + $port = 80; + } + } + + if (!is_int($port)) { + return false; + } + + $address = $host . ':' . $port; + + return in_array($address, $addressList); + } +} diff --git a/application/Espo/Core/Webhook/AddressUtil.php b/application/Espo/Core/Webhook/AddressUtil.php index 8ccff583ee..d7a6f992fc 100644 --- a/application/Espo/Core/Webhook/AddressUtil.php +++ b/application/Espo/Core/Webhook/AddressUtil.php @@ -29,6 +29,7 @@ namespace Espo\Core\Webhook; +use Espo\Core\HttpClient\Util; use Espo\Core\Utils\Config; /** @@ -48,32 +49,6 @@ class AddressUtil /** @var string[] $allowedAddressList */ $allowedAddressList = $this->config->get('webhookAllowedAddressList') ?? []; - if (!$allowedAddressList) { - return false; - } - - $host = parse_url($url, PHP_URL_HOST); - $port = parse_url($url, PHP_URL_PORT); - $scheme = parse_url($url, PHP_URL_SCHEME); - - if (!is_string($host)) { - return false; - } - - if (!is_int($port)) { - if ($scheme === 'https') { - $port = 443; - } else if ($scheme === 'http') { - $port = 80; - } - } - - if (!is_int($port)) { - return false; - } - - $address = $host . ':' . $port; - - return in_array($address, $allowedAddressList); + return Util::matchUrlToAddressList($url, $allowedAddressList); } } diff --git a/application/Espo/Core/Webhook/Queue.php b/application/Espo/Core/Webhook/Queue.php index 37290fd515..f0668b4043 100644 --- a/application/Espo/Core/Webhook/Queue.php +++ b/application/Espo/Core/Webhook/Queue.php @@ -283,7 +283,11 @@ class Queue } catch (Exception $e) { $this->failQueueItemList($itemList, true); - $this->log->error("Webhook Queue: Webhook '{$webhook->getId()}' sending failed. Error: {$e->getMessage()}"); + $this->log->error("Webhook Queue: Webhook '{id}' sending failed; {message}", [ + 'exception' => $e, + 'id' => $webhook->getId(), + 'message' => $e->getMessage(), + ]); return; } @@ -305,7 +309,10 @@ class Queue protected function logSending(Webhook $webhook, int $code): void { - $this->log->debug("Webhook Queue: Webhook '{$webhook->getId()}' sent, response code: $code."); + $this->log->debug("Webhook Queue: Webhook '{id}' sent, response code: {code}.", [ + 'id' => $webhook->getId(), + 'code' => $code, + ]); } /** diff --git a/application/Espo/Core/Webhook/Sender.php b/application/Espo/Core/Webhook/Sender.php index 23c3b1cac3..086dd0fde7 100644 --- a/application/Espo/Core/Webhook/Sender.php +++ b/application/Espo/Core/Webhook/Sender.php @@ -34,19 +34,23 @@ use Espo\Core\Utils\Config; use Espo\Core\Utils\Json; use Espo\Core\Utils\Security\UrlCheck; use Espo\Entities\Webhook; +use Espo\Core\HttpClient; +use GuzzleHttp\Psr7\Request; +use LogicException; +use Psr\Http\Message\RequestInterface; /** * Sends a portion. */ class Sender { - private const CONNECT_TIMEOUT = 5; - private const TIMEOUT = 10; + private const int CONNECT_TIMEOUT = 5; + private const int TIMEOUT = 10; public function __construct( private Config $config, private UrlCheck $urlCheck, - private AddressUtil $addressUtil, + private HttpClient\ClientFactory $clientFactory, ) {} /** @@ -57,91 +61,44 @@ class Sender { $payload = Json::encode($dataList); - $signature = null; - $legacySignature = null; + [$signature, $legacySignature] = $this->prepareSignatures($webhook, $payload); - $secretKey = $webhook->getSecretKey(); + $options = new HttpClient\Options( + protocols: [HttpClient\Protocol::https, HttpClient\Protocol::http], + redirect: new HttpClient\Options\Redirect( + allow: true, + protocols: [HttpClient\Protocol::https], + ), + timeout: $this->getTimeout(), + connectTimeout: $this->getConnectTimeout(), + internalHostRestriction: new HttpClient\Options\InternalHostRestriction( + restrict: true, + allowed: $this->getAllowedAddressList(), + ), + ); - if ($secretKey) { - $signature = $this->buildSignature($webhook, $payload, $secretKey); - $legacySignature = $this->buildSignatureLegacy($webhook, $payload, $secretKey); + $request = $this->prepareRequest( + url: $this->getUrl($webhook), + payload: $payload, + signature: $signature, + legacySignature: $legacySignature, + ); + + $client = $this->clientFactory->create($options); + + try { + $response = $client->send($request); + } catch (HttpClient\Exceptions\ConnectException $e) { + if ($e->getReason() === HttpClient\ConnectErrorReason::Timeout) { + return 408; + } + + throw new Error("Connect error.", previous: $e); + } catch (HttpClient\Exceptions\TooManyRedirectsException $e) { + throw new Error("Too many redirects.", previous: $e); } - $connectTimeout = $this->config->get('webhookConnectTimeout', self::CONNECT_TIMEOUT); - $timeout = $this->config->get('webhookTimeout', self::TIMEOUT); - - $headerList = []; - - $headerList[] = 'Content-Type: application/json'; - $headerList[] = 'Content-Length: ' . strlen($payload); - - if ($signature) { - $headerList[] = 'Signature: ' . $signature; - } - - if ($legacySignature) { - $headerList[] = 'X-Signature: ' . $legacySignature; - } - - $url = $webhook->getUrl(); - - if (!$url) { - throw new Error("Webhook does not have URL."); - } - - if (!$this->urlCheck->isUrl($url)) { - throw new Error("'$url' is not valid URL."); - } - - if ( - !$this->addressUtil->isAllowedUrl($url) && - !$this->urlCheck->isNotInternalUrl($url) - ) { - throw new Error("URL '$url' points to an internal host, not allowed."); - } - - $handler = curl_init($url); - - if ($handler === false) { - throw new Error("Could not init CURL for URL {$url}."); - } - - curl_setopt($handler, \CURLOPT_RETURNTRANSFER, true); - curl_setopt($handler, \CURLOPT_FOLLOWLOCATION, true); - curl_setopt($handler, \CURLOPT_SSL_VERIFYPEER, true); - curl_setopt($handler, \CURLOPT_HEADER, true); - curl_setopt($handler, \CURLOPT_CUSTOMREQUEST, 'POST'); - curl_setopt($handler, \CURLOPT_CONNECTTIMEOUT, $connectTimeout); - curl_setopt($handler, \CURLOPT_TIMEOUT, $timeout); - curl_setopt($handler, \CURLOPT_PROTOCOLS, \CURLPROTO_HTTPS | \CURLPROTO_HTTP); - curl_setopt($handler, \CURLOPT_REDIR_PROTOCOLS, \CURLPROTO_HTTPS); - curl_setopt($handler, \CURLOPT_HTTPHEADER, $headerList); - curl_setopt($handler, \CURLOPT_POSTFIELDS, $payload); - - curl_exec($handler); - - $code = curl_getinfo($handler, \CURLINFO_HTTP_CODE); - - if (!is_numeric($code)) { - $code = 0; - } - - if (!is_int($code)) { - $code = intval($code); - } - - $errorNumber = curl_errno($handler); - - if ( - $errorNumber && - in_array($errorNumber, [\CURLE_OPERATION_TIMEDOUT, \CURLE_OPERATION_TIMEOUTED]) - ) { - $code = 408; - } - - curl_close($handler); - - return $code; + return $response->getStatusCode(); } private function buildSignature(Webhook $webhook, string $payload, string $secretKey): string @@ -159,4 +116,86 @@ class Sender { return base64_encode($webhook->getId() . ':' . hash_hmac('sha256', $payload, $secretKey, true)); } + + /** + * @return string + * @throws Error + */ + private function getUrl(Webhook $webhook): string + { + $url = $webhook->getUrl() ?? throw new Error("Webhook does not have URL."); + + if (!$this->urlCheck->isUrl($url)) { + throw new Error("'$url' is not valid URL."); + } + + return $url; + } + + /** + * @return string[] + */ + private function getAllowedAddressList(): array + { + /** @var string[] $allowedAddressList */ + $allowedAddressList = $this->config->get('webhookAllowedAddressList') ?? []; + + return $allowedAddressList; + } + + private function prepareRequest( + string $url, + string $payload, + ?string $signature, + ?string $legacySignature, + ): RequestInterface { + + $request = (new Request('POST', $url)) + ->withHeader('Content-Type', 'application/json') + ->withHeader('Content-Length', (string) strlen($payload)); + + if ($signature) { + $request = $request->withHeader('Signature', $signature); + } + + if ($legacySignature) { + $request = $request->withHeader('X-Signature', $legacySignature); + } + + $request = $request->withBody(HttpClient\Util::streamFor($payload)); + + if (!$request instanceof RequestInterface) { + throw new LogicException(); + } + + return $request; + } + + /** + * @return array{0: ?string, 1: ?string} + */ + private function prepareSignatures(Webhook $webhook, string $payload): array + { + $signature = null; + $legacySignature = null; + + $secretKey = $webhook->getSecretKey(); + + if ($secretKey) { + $signature = $this->buildSignature($webhook, $payload, $secretKey); + $legacySignature = $this->buildSignatureLegacy($webhook, $payload, $secretKey); + } + + return [$signature, $legacySignature]; + } + + private function getConnectTimeout(): ?int + { + return $this->config->get('webhookConnectTimeout', self::CONNECT_TIMEOUT); + } + + private function getTimeout(): ?int + { + return $this->config->get('webhookTimeout', self::TIMEOUT); + } } diff --git a/composer.json b/composer.json index 58ace4ab4e..c4f51eba7b 100644 --- a/composer.json +++ b/composer.json @@ -55,7 +55,8 @@ "react/child-process": "^0.6.6", "lasserafn/php-initial-avatar-generator": "dev-update-image-lib#a46ab8f1427f93c5b37957e739205da7fcca0290", "directorytree/imapengine": "^1.19", - "zbateson/mail-mime-parser": "^3.0" + "zbateson/mail-mime-parser": "^3.0", + "guzzlehttp/guzzle": "^7.10" }, "require-dev": { "phpunit/phpunit": "^11.5", diff --git a/composer.lock b/composer.lock index 08097335bd..ddff65323b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c2f50edd82d711d5f56b2a91686689a5", + "content-hash": "3e508d38a30eb43d18cd36d7412dd02d", "packages": [ { "name": "async-aws/core",