Add new optional beta email parser thats based on ImapEngine instead of Webklex

This commit is contained in:
johnnyq
2026-02-26 16:11:49 -05:00
parent 1ba19cc249
commit 9cb1ff7330
682 changed files with 101834 additions and 8 deletions

View File

@@ -1,5 +1,6 @@
{
"require": {
"webklex/php-imap": "^6.2"
"webklex/php-imap": "^6.2",
"directorytree/imapengine": "^1.22"
}
}

1265
plugins/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ return array(
'DateRangeError' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/DateRangeError.php',
'Deprecated' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/Deprecated.php',
'NoDiscard' => $vendorDir . '/symfony/polyfill-php85/Resources/stubs/NoDiscard.php',
'Normalizer' => $vendorDir . '/symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php',
'Override' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/Override.php',
'ReflectionConstant' => $vendorDir . '/symfony/polyfill-php84/Resources/stubs/ReflectionConstant.php',
'SQLite3Exception' => $vendorDir . '/symfony/polyfill-php83/Resources/stubs/SQLite3Exception.php',

View File

@@ -11,10 +11,15 @@ return array(
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
'606a39d89246991a373564698c2d8383' => $vendorDir . '/symfony/polyfill-php85/bootstrap.php',
'2203a247e6fda86070a5e4e07aed533a' => $vendorDir . '/symfony/clock/Resources/now.php',
'9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
'a1105708a18b76903365ca1c4aa61b02' => $vendorDir . '/symfony/translation/Resources/functions.php',
'9d2b9fc6db0f153a0a149fefb182415e' => $vendorDir . '/symfony/polyfill-php84/bootstrap.php',
'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'23f09fe3194f8c2f70923f90d6702129' => $vendorDir . '/illuminate/collections/functions.php',
'60799491728b879e74601d83e38b2cad' => $vendorDir . '/illuminate/collections/helpers.php',
'f598d06aa772fa33d905e87be6398fb1' => $vendorDir . '/symfony/polyfill-intl-idn/bootstrap.php',
'f625ee536139dfb962a398b200bdb2bd' => $vendorDir . '/illuminate/support/functions.php',
'72579e7bd17821bb1321b87411366eae' => $vendorDir . '/illuminate/support/helpers.php',
'7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php',
'def43f6c87e4f8dfd0c9e1b1bab14fe8' => $vendorDir . '/symfony/polyfill-iconv/bootstrap.php',
'b33e3d135e5d9e47d845c576147bda89' => $vendorDir . '/php-di/php-di/src/functions.php',
);

View File

@@ -7,22 +7,38 @@ $baseDir = dirname($vendorDir);
return array(
'voku\\' => array($vendorDir . '/voku/portable-ascii/src/voku'),
'ZBateson\\StreamDecorators\\' => array($vendorDir . '/zbateson/stream-decorators/src'),
'ZBateson\\MbWrapper\\' => array($vendorDir . '/zbateson/mb-wrapper/src'),
'ZBateson\\MailMimeParser\\' => array($vendorDir . '/zbateson/mail-mime-parser/src'),
'Webklex\\PHPIMAP\\' => array($vendorDir . '/webklex/php-imap/src'),
'Symfony\\Polyfill\\Php85\\' => array($vendorDir . '/symfony/polyfill-php85'),
'Symfony\\Polyfill\\Php84\\' => array($vendorDir . '/symfony/polyfill-php84'),
'Symfony\\Polyfill\\Php83\\' => array($vendorDir . '/symfony/polyfill-php83'),
'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
'Symfony\\Polyfill\\Intl\\Normalizer\\' => array($vendorDir . '/symfony/polyfill-intl-normalizer'),
'Symfony\\Polyfill\\Intl\\Idn\\' => array($vendorDir . '/symfony/polyfill-intl-idn'),
'Symfony\\Polyfill\\Iconv\\' => array($vendorDir . '/symfony/polyfill-iconv'),
'Symfony\\Contracts\\Translation\\' => array($vendorDir . '/symfony/translation-contracts'),
'Symfony\\Component\\Translation\\' => array($vendorDir . '/symfony/translation'),
'Symfony\\Component\\Mime\\' => array($vendorDir . '/symfony/mime'),
'Symfony\\Component\\HttpFoundation\\' => array($vendorDir . '/symfony/http-foundation'),
'Symfony\\Component\\Clock\\' => array($vendorDir . '/symfony/clock'),
'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
'Psr\\Log\\' => array($vendorDir . '/psr/log/src'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
'Psr\\Container\\' => array($vendorDir . '/psr/container/src'),
'Psr\\Clock\\' => array($vendorDir . '/psr/clock/src'),
'Laravel\\SerializableClosure\\' => array($vendorDir . '/laravel/serializable-closure/src'),
'Invoker\\' => array($vendorDir . '/php-di/invoker/src'),
'Illuminate\\Support\\' => array($vendorDir . '/illuminate/collections', $vendorDir . '/illuminate/conditionable', $vendorDir . '/illuminate/macroable', $vendorDir . '/illuminate/support'),
'Illuminate\\Pagination\\' => array($vendorDir . '/illuminate/pagination'),
'Illuminate\\Contracts\\' => array($vendorDir . '/illuminate/contracts'),
'GuzzleHttp\\Psr7\\' => array($vendorDir . '/guzzlehttp/psr7/src'),
'Egulias\\EmailValidator\\' => array($vendorDir . '/egulias/email-validator/src'),
'Doctrine\\Inflector\\' => array($vendorDir . '/doctrine/inflector/src'),
'Doctrine\\Common\\Lexer\\' => array($vendorDir . '/doctrine/lexer/src'),
'DirectoryTree\\ImapEngine\\' => array($vendorDir . '/directorytree/imapengine/src'),
'DI\\' => array($vendorDir . '/php-di/php-di/src'),
'Carbon\\Doctrine\\' => array($vendorDir . '/carbonphp/carbon-doctrine-types/src/Carbon/Doctrine'),
'Carbon\\' => array($vendorDir . '/nesbot/carbon/src/Carbon'),
);

View File

@@ -12,12 +12,17 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
'6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
'606a39d89246991a373564698c2d8383' => __DIR__ . '/..' . '/symfony/polyfill-php85/bootstrap.php',
'2203a247e6fda86070a5e4e07aed533a' => __DIR__ . '/..' . '/symfony/clock/Resources/now.php',
'9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php',
'a1105708a18b76903365ca1c4aa61b02' => __DIR__ . '/..' . '/symfony/translation/Resources/functions.php',
'9d2b9fc6db0f153a0a149fefb182415e' => __DIR__ . '/..' . '/symfony/polyfill-php84/bootstrap.php',
'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'23f09fe3194f8c2f70923f90d6702129' => __DIR__ . '/..' . '/illuminate/collections/functions.php',
'60799491728b879e74601d83e38b2cad' => __DIR__ . '/..' . '/illuminate/collections/helpers.php',
'f598d06aa772fa33d905e87be6398fb1' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/bootstrap.php',
'f625ee536139dfb962a398b200bdb2bd' => __DIR__ . '/..' . '/illuminate/support/functions.php',
'72579e7bd17821bb1321b87411366eae' => __DIR__ . '/..' . '/illuminate/support/helpers.php',
'7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php',
'def43f6c87e4f8dfd0c9e1b1bab14fe8' => __DIR__ . '/..' . '/symfony/polyfill-iconv/bootstrap.php',
'b33e3d135e5d9e47d845c576147bda89' => __DIR__ . '/..' . '/php-di/php-di/src/functions.php',
);
public static $prefixLengthsPsr4 = array (
@@ -25,6 +30,12 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
'voku\\' => 5,
),
'Z' =>
array (
'ZBateson\\StreamDecorators\\' => 26,
'ZBateson\\MbWrapper\\' => 19,
'ZBateson\\MailMimeParser\\' => 24,
),
'W' =>
array (
'Webklex\\PHPIMAP\\' => 16,
@@ -35,26 +46,48 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
'Symfony\\Polyfill\\Php84\\' => 23,
'Symfony\\Polyfill\\Php83\\' => 23,
'Symfony\\Polyfill\\Mbstring\\' => 26,
'Symfony\\Polyfill\\Intl\\Normalizer\\' => 33,
'Symfony\\Polyfill\\Intl\\Idn\\' => 26,
'Symfony\\Polyfill\\Iconv\\' => 23,
'Symfony\\Contracts\\Translation\\' => 30,
'Symfony\\Component\\Translation\\' => 30,
'Symfony\\Component\\Mime\\' => 23,
'Symfony\\Component\\HttpFoundation\\' => 33,
'Symfony\\Component\\Clock\\' => 24,
),
'P' =>
array (
'Psr\\SimpleCache\\' => 16,
'Psr\\Log\\' => 8,
'Psr\\Http\\Message\\' => 17,
'Psr\\Container\\' => 14,
'Psr\\Clock\\' => 10,
),
'L' =>
array (
'Laravel\\SerializableClosure\\' => 28,
),
'I' =>
array (
'Invoker\\' => 8,
'Illuminate\\Support\\' => 19,
'Illuminate\\Pagination\\' => 22,
'Illuminate\\Contracts\\' => 21,
),
'G' =>
array (
'GuzzleHttp\\Psr7\\' => 16,
),
'E' =>
array (
'Egulias\\EmailValidator\\' => 23,
),
'D' =>
array (
'Doctrine\\Inflector\\' => 19,
'Doctrine\\Common\\Lexer\\' => 22,
'DirectoryTree\\ImapEngine\\' => 25,
'DI\\' => 3,
),
'C' =>
array (
@@ -68,6 +101,18 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/voku/portable-ascii/src/voku',
),
'ZBateson\\StreamDecorators\\' =>
array (
0 => __DIR__ . '/..' . '/zbateson/stream-decorators/src',
),
'ZBateson\\MbWrapper\\' =>
array (
0 => __DIR__ . '/..' . '/zbateson/mb-wrapper/src',
),
'ZBateson\\MailMimeParser\\' =>
array (
0 => __DIR__ . '/..' . '/zbateson/mail-mime-parser/src',
),
'Webklex\\PHPIMAP\\' =>
array (
0 => __DIR__ . '/..' . '/webklex/php-imap/src',
@@ -88,6 +133,18 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
),
'Symfony\\Polyfill\\Intl\\Normalizer\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer',
),
'Symfony\\Polyfill\\Intl\\Idn\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-intl-idn',
),
'Symfony\\Polyfill\\Iconv\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-iconv',
),
'Symfony\\Contracts\\Translation\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/translation-contracts',
@@ -96,6 +153,10 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/symfony/translation',
),
'Symfony\\Component\\Mime\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/mime',
),
'Symfony\\Component\\HttpFoundation\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/http-foundation',
@@ -108,6 +169,15 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/psr/simple-cache/src',
),
'Psr\\Log\\' =>
array (
0 => __DIR__ . '/..' . '/psr/log/src',
),
'Psr\\Http\\Message\\' =>
array (
0 => __DIR__ . '/..' . '/psr/http-factory/src',
1 => __DIR__ . '/..' . '/psr/http-message/src',
),
'Psr\\Container\\' =>
array (
0 => __DIR__ . '/..' . '/psr/container/src',
@@ -116,6 +186,14 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/psr/clock/src',
),
'Laravel\\SerializableClosure\\' =>
array (
0 => __DIR__ . '/..' . '/laravel/serializable-closure/src',
),
'Invoker\\' =>
array (
0 => __DIR__ . '/..' . '/php-di/invoker/src',
),
'Illuminate\\Support\\' =>
array (
0 => __DIR__ . '/..' . '/illuminate/collections',
@@ -131,10 +209,30 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
array (
0 => __DIR__ . '/..' . '/illuminate/contracts',
),
'GuzzleHttp\\Psr7\\' =>
array (
0 => __DIR__ . '/..' . '/guzzlehttp/psr7/src',
),
'Egulias\\EmailValidator\\' =>
array (
0 => __DIR__ . '/..' . '/egulias/email-validator/src',
),
'Doctrine\\Inflector\\' =>
array (
0 => __DIR__ . '/..' . '/doctrine/inflector/src',
),
'Doctrine\\Common\\Lexer\\' =>
array (
0 => __DIR__ . '/..' . '/doctrine/lexer/src',
),
'DirectoryTree\\ImapEngine\\' =>
array (
0 => __DIR__ . '/..' . '/directorytree/imapengine/src',
),
'DI\\' =>
array (
0 => __DIR__ . '/..' . '/php-di/php-di/src',
),
'Carbon\\Doctrine\\' =>
array (
0 => __DIR__ . '/..' . '/carbonphp/carbon-doctrine-types/src/Carbon/Doctrine',
@@ -158,6 +256,7 @@ class ComposerStaticInit9b9826e5b5cc7806cd328c4112cca75e
'DateRangeError' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/DateRangeError.php',
'Deprecated' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/Deprecated.php',
'NoDiscard' => __DIR__ . '/..' . '/symfony/polyfill-php85/Resources/stubs/NoDiscard.php',
'Normalizer' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/Resources/stubs/Normalizer.php',
'Override' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/Override.php',
'ReflectionConstant' => __DIR__ . '/..' . '/symfony/polyfill-php84/Resources/stubs/ReflectionConstant.php',
'SQLite3Exception' => __DIR__ . '/..' . '/symfony/polyfill-php83/Resources/stubs/SQLite3Exception.php',

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'reference' => '612041635d962d37f2f400ba1974bec5456ccd1e',
'reference' => '1ba19cc2492aa1397d8556f7442ad0c66513c2bf',
'name' => '__root__',
'dev' => true,
),
@@ -16,7 +16,7 @@
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'reference' => '612041635d962d37f2f400ba1974bec5456ccd1e',
'reference' => '1ba19cc2492aa1397d8556f7442ad0c66513c2bf',
'dev_requirement' => false,
),
'carbonphp/carbon-doctrine-types' => array(
@@ -28,6 +28,15 @@
'reference' => '18ba5ddfec8976260ead6e866180bd5d2f71aa1d',
'dev_requirement' => false,
),
'directorytree/imapengine' => array(
'pretty_version' => 'v1.22.4',
'version' => '1.22.4.0',
'type' => 'library',
'install_path' => __DIR__ . '/../directorytree/imapengine',
'aliases' => array(),
'reference' => 'e41dd11f94bc9077a905de1e0c17bea87632ee64',
'dev_requirement' => false,
),
'doctrine/inflector' => array(
'pretty_version' => '2.1.0',
'version' => '2.1.0.0',
@@ -37,6 +46,33 @@
'reference' => '6d6c96277ea252fc1304627204c3d5e6e15faa3b',
'dev_requirement' => false,
),
'doctrine/lexer' => array(
'pretty_version' => '3.0.1',
'version' => '3.0.1.0',
'type' => 'library',
'install_path' => __DIR__ . '/../doctrine/lexer',
'aliases' => array(),
'reference' => '31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd',
'dev_requirement' => false,
),
'egulias/email-validator' => array(
'pretty_version' => '4.0.4',
'version' => '4.0.4.0',
'type' => 'library',
'install_path' => __DIR__ . '/../egulias/email-validator',
'aliases' => array(),
'reference' => 'd42c8731f0624ad6bdc8d3e5e9a4524f68801cfa',
'dev_requirement' => false,
),
'guzzlehttp/psr7' => array(
'pretty_version' => '2.8.0',
'version' => '2.8.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../guzzlehttp/psr7',
'aliases' => array(),
'reference' => '21dc724a0583619cd1652f673303492272778051',
'dev_requirement' => false,
),
'illuminate/collections' => array(
'pretty_version' => 'v12.28.1',
'version' => '12.28.1.0',
@@ -91,6 +127,15 @@
'reference' => '487bbe527806615b818e87c364d93ba91f27db9b',
'dev_requirement' => false,
),
'laravel/serializable-closure' => array(
'pretty_version' => 'v2.0.10',
'version' => '2.0.10.0',
'type' => 'library',
'install_path' => __DIR__ . '/../laravel/serializable-closure',
'aliases' => array(),
'reference' => '870fc81d2f879903dfc5b60bf8a0f94a1609e669',
'dev_requirement' => false,
),
'nesbot/carbon' => array(
'pretty_version' => '3.10.3',
'version' => '3.10.3.0',
@@ -100,6 +145,24 @@
'reference' => '8e3643dcd149ae0fe1d2ff4f2c8e4bbfad7c165f',
'dev_requirement' => false,
),
'php-di/invoker' => array(
'pretty_version' => '2.3.7',
'version' => '2.3.7.0',
'type' => 'library',
'install_path' => __DIR__ . '/../php-di/invoker',
'aliases' => array(),
'reference' => '3c1ddfdef181431fbc4be83378f6d036d59e81e1',
'dev_requirement' => false,
),
'php-di/php-di' => array(
'pretty_version' => '7.1.1',
'version' => '7.1.1.0',
'type' => 'library',
'install_path' => __DIR__ . '/../php-di/php-di',
'aliases' => array(),
'reference' => 'f88054cc052e40dbe7b383c8817c19442d480352',
'dev_requirement' => false,
),
'psr/clock' => array(
'pretty_version' => '1.0.0',
'version' => '1.0.0.0',
@@ -124,6 +187,51 @@
'reference' => 'c71ecc56dfe541dbd90c5360474fbc405f8d5963',
'dev_requirement' => false,
),
'psr/container-implementation' => array(
'dev_requirement' => false,
'provided' => array(
0 => '^1.0',
),
),
'psr/http-factory' => array(
'pretty_version' => '1.1.0',
'version' => '1.1.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-factory',
'aliases' => array(),
'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
'dev_requirement' => false,
),
'psr/http-factory-implementation' => array(
'dev_requirement' => false,
'provided' => array(
0 => '1.0',
),
),
'psr/http-message' => array(
'pretty_version' => '2.0',
'version' => '2.0.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-message',
'aliases' => array(),
'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
'dev_requirement' => false,
),
'psr/http-message-implementation' => array(
'dev_requirement' => false,
'provided' => array(
0 => '1.0',
),
),
'psr/log' => array(
'pretty_version' => '3.0.2',
'version' => '3.0.2.0',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/log',
'aliases' => array(),
'reference' => 'f16e1d5863e37f8d8c2a01719f5b34baa2b714d3',
'dev_requirement' => false,
),
'psr/simple-cache' => array(
'pretty_version' => '3.0.0',
'version' => '3.0.0.0',
@@ -133,6 +241,15 @@
'reference' => '764e0b3939f5ca87cb904f570ef9be2d78a07865',
'dev_requirement' => false,
),
'ralouphie/getallheaders' => array(
'pretty_version' => '3.0.3',
'version' => '3.0.3.0',
'type' => 'library',
'install_path' => __DIR__ . '/../ralouphie/getallheaders',
'aliases' => array(),
'reference' => '120b605dfeb996808c31b6477290a714d356e822',
'dev_requirement' => false,
),
'spatie/once' => array(
'dev_requirement' => false,
'replaced' => array(
@@ -166,6 +283,42 @@
'reference' => 'db488a62f98f7a81d5746f05eea63a74e55bb7c4',
'dev_requirement' => false,
),
'symfony/mime' => array(
'pretty_version' => 'v8.0.6',
'version' => '8.0.6.0',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/mime',
'aliases' => array(),
'reference' => '632aef4f15ead4d48c16395e447f2da12543d201',
'dev_requirement' => false,
),
'symfony/polyfill-iconv' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-iconv',
'aliases' => array(),
'reference' => '5f3b930437ae03ae5dff61269024d8ea1b3774aa',
'dev_requirement' => false,
),
'symfony/polyfill-intl-idn' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-intl-idn',
'aliases' => array(),
'reference' => '9614ac4d8061dc257ecc64cba1b140873dce8ad3',
'dev_requirement' => false,
),
'symfony/polyfill-intl-normalizer' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-intl-normalizer',
'aliases' => array(),
'reference' => '3833d7255cc303546435cb650316bff708a1c75c',
'dev_requirement' => false,
),
'symfony/polyfill-mbstring' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
@@ -244,5 +397,32 @@
'reference' => '6b8ef85d621bbbaf52741b00cca8e9237e2b2e05',
'dev_requirement' => false,
),
'zbateson/mail-mime-parser' => array(
'pretty_version' => '3.0.5',
'version' => '3.0.5.0',
'type' => 'library',
'install_path' => __DIR__ . '/../zbateson/mail-mime-parser',
'aliases' => array(),
'reference' => 'ff054c8e05310c445c2028c6128a4319cc9f6aa8',
'dev_requirement' => false,
),
'zbateson/mb-wrapper' => array(
'pretty_version' => '2.0.1',
'version' => '2.0.1.0',
'type' => 'library',
'install_path' => __DIR__ . '/../zbateson/mb-wrapper',
'aliases' => array(),
'reference' => '50a14c0c9537f978a61cde9fdc192a0267cc9cff',
'dev_requirement' => false,
),
'zbateson/stream-decorators' => array(
'pretty_version' => '2.1.1',
'version' => '2.1.1.0',
'type' => 'library',
'install_path' => __DIR__ . '/../zbateson/stream-decorators',
'aliases' => array(),
'reference' => '32a2a62fb0f26313395c996ebd658d33c3f9c4e5',
'dev_requirement' => false,
),
),
);

View File

@@ -4,8 +4,8 @@
$issues = array();
if (!(PHP_VERSION_ID >= 80200)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.2.0". You are running ' . PHP_VERSION . '.';
if (!(PHP_VERSION_ID >= 80400)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.4.0". You are running ' . PHP_VERSION . '.';
}
if ($issues) {

View File

@@ -0,0 +1,46 @@
{
"name": "directorytree/imapengine",
"type": "library",
"description": "A fully-featured IMAP library -- without the PHP extension",
"keywords": [
"imap",
"mail",
"engine"
],
"homepage": "https://github.com/directorytree/imapengine",
"license": "MIT",
"authors": [
{
"name": "Steve Bauman",
"email": "steven_bauman@outlook.com",
"role": "Developer"
}
],
"require": {
"php": "^8.1",
"symfony/mime": ">=6.0",
"nesbot/carbon": ">=2.0",
"illuminate/collections": ">=9.0",
"zbateson/mail-mime-parser": "^3.0",
"egulias/email-validator": "^4.0"
},
"require-dev": {
"spatie/ray": "^1.0",
"pestphp/pest": "^2.0|^3.0|^4.0"
},
"autoload": {
"psr-4": {
"DirectoryTree\\ImapEngine\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests"
}
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class Address implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected string $email,
protected string $name,
) {
$this->name = Str::decodeMimeHeader($this->name);
}
/**
* Get the address's email.
*/
public function email(): string
{
return $this->email;
}
/**
* Get the address's name.
*/
public function name(): string
{
return $this->name;
}
/**
* Get the array representation of the address.
*/
public function toArray(): array
{
return [
'email' => $this->email,
'name' => $this->name,
];
}
/**
* Get the JSON representation of the address.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace DirectoryTree\ImapEngine;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
use Psr\Http\Message\StreamInterface;
use Symfony\Component\Mime\MimeTypes;
class Attachment implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected ?string $filename,
protected ?string $contentId,
protected string $contentType,
protected ?string $contentDisposition,
protected StreamInterface $contentStream,
) {}
/**
* Get the attachment's filename.
*/
public function filename(): ?string
{
return $this->filename;
}
/**
* Get the attachment's content ID.
*/
public function contentId(): ?string
{
return $this->contentId;
}
/**
* Get the attachment's content type.
*/
public function contentType(): string
{
return $this->contentType;
}
/**
* Get the attachment's content disposition.
*/
public function contentDisposition(): string
{
return $this->contentDisposition;
}
/**
* Get the attachment's contents.
*/
public function contents(): string
{
return $this->contentStream->getContents();
}
/**
* Get the attachment's content stream.
*/
public function contentStream(): StreamInterface
{
return $this->contentStream;
}
/**
* Save the attachment to a file.
*/
public function save(string $path): false|int
{
return file_put_contents($path, $this->contents());
}
/**
* Get the attachment's extension.
*/
public function extension(): ?string
{
if ($ext = pathinfo($this->filename ?? '', PATHINFO_EXTENSION)) {
return $ext;
}
if ($ext = (MimeTypes::getDefault()->getExtensions($this->contentType)[0] ?? null)) {
return $ext;
}
return null;
}
/**
* Get the array representation of the attachment.
*/
public function toArray(): array
{
return [
'filename' => $this->filename,
'content_type' => $this->contentType,
'contents' => $this->contents(),
];
}
/**
* Get the JSON representation of the attachment.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace DirectoryTree\ImapEngine;
use Countable;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use IteratorAggregate;
use JsonSerializable;
use Traversable;
/**
* @implements IteratorAggregate<int, BodyStructurePart|BodyStructureCollection>
*/
class BodyStructureCollection implements Arrayable, Countable, IteratorAggregate, JsonSerializable
{
/**
* Constructor.
*
* @param array<BodyStructurePart|BodyStructureCollection> $parts
*/
public function __construct(
protected string $subtype = 'mixed',
protected array $parameters = [],
protected array $parts = [],
) {}
/**
* Parse a multipart BODYSTRUCTURE ListData into a BodyStructureCollection.
*/
public static function fromListData(ListData $data, ?string $partNumber = null): static
{
$tokens = $data->tokens();
$parts = [];
$childIndex = 1;
$subtypeIndex = null;
foreach ($tokens as $index => $token) {
if ($token instanceof Token && ! $token instanceof Nil) {
$subtypeIndex = $index;
break;
}
if (! $token instanceof ListData) {
continue;
}
$childPartNumber = $partNumber ? "{$partNumber}.{$childIndex}" : (string) $childIndex;
$parts[] = static::isMultipart($token)
? static::fromListData($token, $childPartNumber)
: BodyStructurePart::fromListData($token, $childPartNumber);
$childIndex++;
}
$parameters = [];
if ($subtypeIndex) {
foreach (array_slice($tokens, $subtypeIndex + 1) as $token) {
if ($token instanceof ListData && ! static::isDispositionList($token)) {
$parameters = $token->toKeyValuePairs();
break;
}
}
}
return new static(
$subtypeIndex ? strtolower($tokens[$subtypeIndex]->value) : 'mixed',
$parameters,
$parts
);
}
/**
* Determine if a ListData represents a multipart structure.
*/
protected static function isMultipart(ListData $data): bool
{
return head($data->tokens()) instanceof ListData;
}
/**
* Determine if a ListData represents a disposition (INLINE or ATTACHMENT).
*/
protected static function isDispositionList(ListData $data): bool
{
$tokens = $data->tokens();
if (count($tokens) < 2 || ! isset($tokens[0]) || ! $tokens[0] instanceof Token) {
return false;
}
return in_array(strtoupper($tokens[0]->value), ['INLINE', 'ATTACHMENT']);
}
/**
* Get the multipart subtype (mixed, alternative, related, etc.).
*/
public function subtype(): string
{
return $this->subtype;
}
/**
* Get the content type.
*/
public function contentType(): string
{
return "multipart/{$this->subtype}";
}
/**
* Get the parameters (e.g., boundary).
*/
public function parameters(): array
{
return $this->parameters;
}
/**
* Get the boundary parameter.
*/
public function boundary(): ?string
{
return $this->parameters['boundary'] ?? null;
}
/**
* Get the direct child parts.
*
* @return array<BodyStructurePart|BodyStructureCollection>
*/
public function parts(): array
{
return $this->parts;
}
/**
* Get all parts flattened (including nested parts).
*
* @return BodyStructurePart[]
*/
public function flatten(): array
{
$flattened = [];
foreach ($this->parts as $part) {
if ($part instanceof self) {
$flattened = array_merge($flattened, $part->flatten());
} else {
$flattened[] = $part;
}
}
return $flattened;
}
/**
* Find a part by its part number.
*/
public function find(string $partNumber): BodyStructurePart|BodyStructureCollection|null
{
foreach ($this->parts as $part) {
if ($part instanceof self) {
if ($found = $part->find($partNumber)) {
return $found;
}
} elseif ($part->partNumber() === $partNumber) {
return $part;
}
}
return null;
}
/**
* Get the text/plain part if available.
*/
public function text(): ?BodyStructurePart
{
foreach ($this->flatten() as $part) {
if ($part->isText()) {
return $part;
}
}
return null;
}
/**
* Get the text/html part if available.
*/
public function html(): ?BodyStructurePart
{
foreach ($this->flatten() as $part) {
if ($part->isHtml()) {
return $part;
}
}
return null;
}
/**
* Get all attachment parts.
*
* @return BodyStructurePart[]
*/
public function attachments(): array
{
return array_values(array_filter(
$this->flatten(),
fn (BodyStructurePart $part) => $part->isAttachment()
));
}
/**
* Determine if the collection has attachments.
*/
public function hasAttachments(): bool
{
return count($this->attachments()) > 0;
}
/**
* Get the count of attachments.
*/
public function attachmentCount(): int
{
return count($this->attachments());
}
/**
* Get the count of parts.
*/
public function count(): int
{
return count($this->parts);
}
/**
* Get an iterator for the parts.
*/
public function getIterator(): Traversable
{
yield from $this->parts;
}
/**
* Get the array representation.
*/
public function toArray(): array
{
return [
'subtype' => $this->subtype,
'parameters' => $this->parameters,
'content_type' => $this->contentType(),
'parts' => array_map(fn (Arrayable $part) => $part->toArray(), $this->parts),
];
}
/**
* Get the JSON representation.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -0,0 +1,243 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class BodyStructurePart implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected string $partNumber,
protected string $type,
protected string $subtype,
protected array $parameters = [],
protected ?string $id = null,
protected ?string $description = null,
protected ?string $encoding = null,
protected ?int $size = null,
protected ?int $lines = null,
protected ?ContentDisposition $disposition = null,
) {}
/**
* Parse a single part BODYSTRUCTURE ListData into a BodyStructurePart.
*/
public static function fromListData(ListData $data, string $partNumber = '1'): static
{
return static::parse($data->tokens(), $partNumber);
}
/**
* Parse a single (non-multipart) part.
*
* @param array<Token|ListData> $tokens
*/
protected static function parse(array $tokens, string $partNumber): static
{
return new static(
partNumber: $partNumber,
type: isset($tokens[0]) ? strtolower($tokens[0]->value) : 'text',
subtype: isset($tokens[1]) ? strtolower($tokens[1]->value) : 'plain',
parameters: isset($tokens[2]) && $tokens[2] instanceof ListData ? $tokens[2]->toKeyValuePairs() : [],
id: isset($tokens[3]) && ! $tokens[3] instanceof Nil ? $tokens[3]->value : null,
description: isset($tokens[4]) && ! $tokens[4] instanceof Nil ? $tokens[4]->value : null,
encoding: isset($tokens[5]) && ! $tokens[5] instanceof Nil ? $tokens[5]->value : null,
size: isset($tokens[6]) && ! $tokens[6] instanceof Nil ? (int) $tokens[6]->value : null,
lines: isset($tokens[7]) && ! $tokens[7] instanceof Nil ? (int) $tokens[7]->value : null,
disposition: ContentDisposition::parse($tokens),
);
}
/**
* Get the part number (e.g., "1", "1.2", "2.1.3").
*/
public function partNumber(): string
{
return $this->partNumber;
}
/**
* Get the MIME type (e.g., "text", "image", "multipart").
*/
public function type(): string
{
return $this->type;
}
/**
* Get the MIME subtype (e.g., "plain", "html", "jpeg", "mixed").
*/
public function subtype(): string
{
return $this->subtype;
}
/**
* Get the full content type (e.g., "text/plain", "multipart/alternative").
*/
public function contentType(): string
{
return "{$this->type}/{$this->subtype}";
}
/**
* Get the parameters (e.g., charset, boundary).
*/
public function parameters(): array
{
return $this->parameters;
}
/**
* Get a specific parameter value.
*/
public function parameter(string $name): ?string
{
return $this->parameters[strtolower($name)] ?? null;
}
/**
* Get the content ID.
*/
public function id(): ?string
{
return $this->id;
}
/**
* Get the content description.
*/
public function description(): ?string
{
return $this->description;
}
/**
* Get the content transfer encoding.
*/
public function encoding(): ?string
{
return $this->encoding;
}
/**
* Get the size in bytes.
*/
public function size(): ?int
{
return $this->size;
}
/**
* Get the number of lines (for text parts).
*/
public function lines(): ?int
{
return $this->lines;
}
/**
* Get the content disposition.
*/
public function disposition(): ?ContentDisposition
{
return $this->disposition;
}
/**
* Get the filename from disposition parameters.
*/
public function filename(): ?string
{
return $this->disposition?->filename() ?? $this->parameters['name'] ?? null;
}
/**
* Get the charset from parameters.
*/
public function charset(): ?string
{
return $this->parameters['charset'] ?? null;
}
/**
* Determine if this is a text part.
*/
public function isText(): bool
{
return $this->type === 'text' && $this->subtype === 'plain';
}
/**
* Determine if this is an HTML part.
*/
public function isHtml(): bool
{
return $this->type === 'text' && $this->subtype === 'html';
}
/**
* Determine if this is an attachment.
*/
public function isAttachment(): bool
{
if ($this->disposition?->isAttachment()) {
return true;
}
// Inline parts are not attachments.
if ($this->disposition?->isInline()) {
return false;
}
// Consider non-text/html parts with filenames as attachments.
if ($this->filename() && ! $this->isText() && ! $this->isHtml()) {
return true;
}
return false;
}
/**
* Determine if this is an inline part.
*/
public function isInline(): bool
{
return $this->disposition?->isInline() ?? false;
}
/**
* Get the array representation.
*/
public function toArray(): array
{
return [
'id' => $this->id,
'type' => $this->type,
'size' => $this->size,
'lines' => $this->lines,
'subtype' => $this->subtype,
'encoding' => $this->encoding,
'parameters' => $this->parameters,
'part_number' => $this->partNumber,
'description' => $this->description,
'content_type' => $this->contentType(),
'disposition' => $this->disposition?->toArray(),
];
}
/**
* Get the JSON representation.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\FolderInterface;
use Illuminate\Support\Collection;
/**
* @template-extends Collection<array-key, FolderInterface>
*/
class FolderCollection extends Collection {}

View File

@@ -0,0 +1,32 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Message;
use DirectoryTree\ImapEngine\MessageInterface;
/**
* @template-extends PaginatedCollection<array-key, MessageInterface|Message>
*/
class MessageCollection extends PaginatedCollection
{
/**
* Find a message by its UID.
*/
public function find(int $uid): ?MessageInterface
{
return $this->first(
fn (MessageInterface $message) => $message->uid() === $uid
);
}
/**
* Find a message by its UID or throw an exception.
*/
public function findOrFail(int $uid): MessageInterface
{
return $this->firstOrFail(
fn (MessageInterface $message) => $message->uid() === $uid
);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/**
* @template TKey of array-key
* @template TValue
*
* @template-extends Collection<TKey, TValue>
*/
class PaginatedCollection extends Collection
{
/**
* The total number of items.
*/
protected int $total = 0;
/**
* Paginate the current collection.
*
* @return LengthAwarePaginator<TKey, TValue>
*/
public function paginate(int $perPage = 15, ?int $page = null, string $pageName = 'page', bool $prepaginated = false): LengthAwarePaginator
{
$total = $this->total ?: $this->count();
$results = ! $prepaginated && $total ? $this->forPage($page, $perPage) : $this;
return $this->paginator($results, $total, $perPage, $page, $pageName);
}
/**
* Create a new length-aware paginator instance.
*
* @return LengthAwarePaginator<TKey, TValue>
*/
protected function paginator(Collection $items, int $total, int $perPage, ?int $currentPage, string $pageName): LengthAwarePaginator
{
return new LengthAwarePaginator($items, $total, $perPage, $currentPage, $pageName);
}
/**
* Get or set the total amount.
*/
public function total(?int $total = null): ?int
{
if (is_null($total)) {
return $this->total;
}
return $this->total = $total;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace DirectoryTree\ImapEngine\Collections;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use Illuminate\Support\Collection;
/**
* @template TKey of array-key
*
* @template-covariant TValue
*
* @extends Collection<array-key, TValue>
*/
class ResponseCollection extends Collection
{
/**
* Filter the collection to only tagged responses.
*
* @return self<array-key, TaggedResponse>
*/
public function tagged(): self
{
return $this->whereInstanceOf(TaggedResponse::class);
}
/**
* Filter the collection to only untagged responses.
*
* @return self<array-key, UntaggedResponse>
*/
public function untagged(): self
{
return $this->whereInstanceOf(UntaggedResponse::class);
}
/**
* Filter the collection to only continuation responses.
*
* @return self<array-key, ContinuationResponse>
*/
public function continuation(): self
{
return $this->whereInstanceOf(ContinuationResponse::class);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace DirectoryTree\ImapEngine;
trait ComparesFolders
{
/**
* Determine if two folders are the same.
*/
protected function isSameFolder(FolderInterface $a, FolderInterface $b): bool
{
return $a->path() === $b->path()
&& $a->mailbox()->config('host') === $b->mailbox()->config('host')
&& $a->mailbox()->config('username') === $b->mailbox()->config('username');
}
}

View File

@@ -0,0 +1,344 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use Generator;
interface ConnectionInterface
{
/**
* Open a new connection.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-state-and-flow-diagram
*/
public function connect(string $host, ?int $port = null, array $options = []): void;
/**
* Close the current connection.
*/
public function disconnect(): void;
/**
* Determine if the current session is connected.
*/
public function connected(): bool;
/**
* Send a "LOGIN" command.
*
* Login to a new session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-login-command
*/
public function login(string $user, string $password): TaggedResponse;
/**
* Send a "LOGOUT" command.
*
* Logout of the current server session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-logout-command
*/
public function logout(): void;
/**
* Send an "AUTHENTICATE" command.
*
* Authenticate the current session.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-authenticate-command
*/
public function authenticate(string $user, string $token): TaggedResponse;
/**
* Send a "STARTTLS" command.
*
* Upgrade the current plaintext connection to a secure TLS-encrypted connection.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-starttls-command
*/
public function startTls(): void;
/**
* Send an "IDLE" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-idle-command
*/
public function idle(int $timeout): Generator;
/**
* Send a "DONE" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.3.13
*/
public function done(): void;
/**
* Send a "NOOP" command.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-noop-command
*/
public function noop(): TaggedResponse;
/**
* Send a "EXPUNGE" command.
*
* Apply session saved changes to the server.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-expunge-command
*/
public function expunge(): ResponseCollection;
/**
* Send a "CAPABILITY" command.
*
* Get the mailbox's available capabilities.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-capability-command
*/
public function capability(): UntaggedResponse;
/**
* Send a "SEARCH" command.
*
* Execute a search request.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-search-command
*/
public function search(array $params): UntaggedResponse;
/**
* Send a "SORT" command.
*
* Execute a sort request using RFC 5256.
*
* @see https://datatracker.ietf.org/doc/html/rfc5256
*/
public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse;
/**
* Send a "FETCH" command.
*
* Exchange identification information.
*
* @see https://datatracker.ietf.org/doc/html/rfc2971.
*/
public function id(?array $ids = null): UntaggedResponse;
/**
* Send a "FETCH UID" command.
*
* Fetch message UIDs using the given message numbers.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-uid-command
*/
public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection;
/**
* Send a "FETCH BODY[TEXT]" command.
*
* Fetch message text contents.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyText(int|array $ids, bool $peek = true): ResponseCollection;
/**
* Send a "FETCH BODY[HEADER]" command.
*
* Fetch message headers.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection;
/**
* Send a "FETCH BODYSTRUCTURE" command.
*
* Fetch message body structure.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyStructure(int|array $ids): ResponseCollection;
/**
* Send a "FETCH BODY[i]" command.
*
* Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.9
*/
public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection;
/**
* Send a "FETCH FLAGS" command.
*
* Fetch a message flags.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.17
*/
public function flags(int|array $ids): ResponseCollection;
/**
* Send a "FETCH" command.
*
* Fetch one or more items for one or more messages.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-fetch-command
*/
public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection;
/**
* Send a "RFC822.SIZE" command.
*
* Fetch message sizes for one or more messages.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.4.5-9.21
*/
public function size(int|array $ids): ResponseCollection;
/**
* Send an IMAP command.
*/
public function send(string $name, array $tokens = [], ?string &$tag = null): void;
/**
* Send a "SELECT" command.
*
* Select the specified folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-select-command
*/
public function select(string $folder): ResponseCollection;
/**
* Send a "EXAMINE" command.
*
* Examine a given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-examine-command
*/
public function examine(string $folder): ResponseCollection;
/**
* Send a "LIST" command.
*
* Get a list of available folders.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-list-command
*/
public function list(string $reference = '', string $folder = '*'): ResponseCollection;
/**
* Send a "STATUS" command.
*
* Get the status of a given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-status-command
*/
public function status(string $folder, array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse;
/**
* Send a "STORE" command.
*
* Set message flags.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-store-command
*/
public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection;
/**
* Send a "APPEND" command.
*
* Append a new message to given folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-append-command
*/
public function append(string $folder, string $message, ?array $flags = null): TaggedResponse;
/**
* Send a "UID COPY" command.
*
* Copy message set from current folder to other folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-copy-command
*/
public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse;
/**
* Send a "UID MOVE" command.
*
* Move a message set from current folder to another folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-move-command
*/
public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse;
/**
* Send a "CREATE" command.
*
* Create a new folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-create-command
*/
public function create(string $folder): ResponseCollection;
/**
* Send a "DELETE" command.
*
* Delete a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-delete-command
*/
public function delete(string $folder): TaggedResponse;
/**
* Send a "RENAME" command.
*
* Rename an existing folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-rename-command
*/
public function rename(string $oldPath, string $newPath): TaggedResponse;
/**
* Send a "SUBSCRIBE" command.
*
* Subscribe to a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-subscribe-command
*/
public function subscribe(string $folder): TaggedResponse;
/**
* Send a "UNSUBSCRIBE" command.
*
* Unsubscribe from a folder.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-unsubscribe-command
*/
public function unsubscribe(string $folder): TaggedResponse;
/**
* Send a "GETQUOTA" command.
*
* Retrieve quota information about a specific quota root.
*
* @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquota
*/
public function quota(string $root): UntaggedResponse;
/**
* Send a "GETQUOTAROOT" command.
*
* Retrieve quota root information about a mailbox.
*
* @see https://datatracker.ietf.org/doc/html/rfc9208#name-getquotaroot
*/
public function quotaRoot(string $mailbox): ResponseCollection;
}

View File

@@ -0,0 +1,105 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use Stringable;
class ImapCommand implements Stringable
{
/**
* The compiled command lines.
*
* @var string[]
*/
protected ?array $compiled = null;
/**
* Constructor.
*/
public function __construct(
protected string $tag,
protected string $command,
protected array $tokens = [],
) {}
/**
* Get the IMAP tag.
*/
public function tag(): string
{
return $this->tag;
}
/**
* Get the IMAP command.
*/
public function command(): string
{
return $this->command;
}
/**
* Get the IMAP tokens.
*/
public function tokens(): array
{
return $this->tokens;
}
/**
* Compile the command into lines for transmission.
*
* @return string[]
*/
public function compile(): array
{
if (is_array($this->compiled)) {
return $this->compiled;
}
$lines = [];
$line = trim("{$this->tag} {$this->command}");
foreach ($this->tokens as $token) {
if (is_array($token)) {
// For tokens provided as arrays, the first element is a placeholder
// (for example, "{20}") that signals a literal value will follow.
// The second element holds the actual literal content.
[$placeholder, $literal] = $token;
$lines[] = "{$line} {$placeholder}";
$line = $literal;
} else {
$line .= " {$token}";
}
}
$lines[] = $line;
return $this->compiled = $lines;
}
/**
* Get a redacted version of the command for safe exposure.
*/
public function redacted(): ImapCommand
{
return new static($this->tag, $this->command, array_map(
function (mixed $token) {
return is_array($token)
? array_map(fn () => '[redacted]', $token)
: '[redacted]';
}, $this->tokens)
);
}
/**
* Get the command as a string.
*/
public function __toString(): string
{
return implode("\r\n", $this->compile());
}
}

View File

@@ -0,0 +1,815 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Loggers\LoggerInterface;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Streams\FakeStream;
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException;
use DirectoryTree\ImapEngine\Exceptions\ImapResponseException;
use DirectoryTree\ImapEngine\Exceptions\ImapStreamException;
use DirectoryTree\ImapEngine\Support\Str;
use Exception;
use Generator;
use LogicException;
use Throwable;
class ImapConnection implements ConnectionInterface
{
/**
* Sequence number used to generate unique command tags.
*/
protected int $sequence = 0;
/**
* The result instance.
*/
protected ?Result $result = null;
/**
* The parser instance.
*/
protected ?ImapParser $parser = null;
/**
* Constructor.
*/
public function __construct(
protected StreamInterface $stream,
protected ?LoggerInterface $logger = null,
) {}
/**
* Create a new connection with a fake stream.
*/
public static function fake(array $responses = []): static
{
$stream = new FakeStream;
$stream->open();
$stream->feed($responses);
return new static($stream);
}
/**
* Tear down the connection.
*/
public function __destruct()
{
if (! $this->connected()) {
return;
}
try {
@$this->logout();
} catch (Exception $e) {
// Do nothing.
}
}
/**
* {@inheritDoc}
*/
public function connect(string $host, ?int $port = null, array $options = []): void
{
$transport = strtolower($options['encryption'] ?? '') ?: 'tcp';
if (in_array($transport, ['ssl', 'tls'])) {
$port ??= 993;
} else {
$port ??= 143;
}
$this->setParser(
$this->newParser($this->stream)
);
$this->stream->open(
$transport === 'starttls' ? 'tcp' : $transport,
$host,
$port,
$options['timeout'] ?? 30,
$this->getDefaultSocketOptions(
$transport,
$options['proxy'] ?? [],
$options['validate_cert'] ?? true
)
);
$this->assertNextResponse(
fn (Response $response) => $response instanceof UntaggedResponse,
fn (UntaggedResponse $response) => $response->type()->is('OK'),
fn () => new ImapConnectionFailedException("Connection to $host:$port failed")
);
if ($transport === 'starttls') {
$this->startTls();
}
}
/**
* Get the default socket options for the given transport.
*
* @param 'ssl'|'tls'|'starttls'|'tcp' $transport
*/
protected function getDefaultSocketOptions(string $transport, array $proxy = [], bool $validateCert = true): array
{
$options = [];
$key = match ($transport) {
'ssl', 'tls' => 'ssl',
'starttls', 'tcp' => 'tcp',
};
if (in_array($transport, ['ssl', 'tls'])) {
$options[$key] = [
'verify_peer' => $validateCert,
'verify_peer_name' => $validateCert,
];
}
if (! isset($proxy['socket'])) {
return $options;
}
$options[$key]['proxy'] = $proxy['socket'];
$options[$key]['request_fulluri'] = $proxy['request_fulluri'] ?? false;
if (isset($proxy['username'])) {
$auth = base64_encode($proxy['username'].':'.$proxy['password']);
$options[$key]['header'] = ["Proxy-Authorization: Basic $auth"];
}
return $options;
}
/**
* {@inheritDoc}
*/
public function disconnect(): void
{
$this->stream->close();
}
/**
* {@inheritDoc}
*/
public function connected(): bool
{
return $this->stream->opened();
}
/**
* {@inheritDoc}
*/
public function login(string $user, string $password): TaggedResponse
{
$this->send('LOGIN', Str::literal([$user, $password]), $tag);
return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command()->redacted(), $response)
));
}
/**
* {@inheritDoc}
*/
public function logout(): void
{
$this->send('LOGOUT', tag: $tag);
}
/**
* {@inheritDoc}
*/
public function authenticate(string $user, string $token): TaggedResponse
{
$this->send('AUTHENTICATE', ['XOAUTH2', Str::credentials($user, $token)], $tag);
return $this->assertTaggedResponse($tag, fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command()->redacted(), $response)
));
}
/**
* {@inheritDoc}
*/
public function startTls(): void
{
$this->send('STARTTLS', tag: $tag);
$this->assertTaggedResponse($tag);
$this->stream->setSocketSetCrypto(true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
}
/**
* {@inheritDoc}
*/
public function select(string $folder = 'INBOX'): ResponseCollection
{
return $this->examineOrSelect('SELECT', $folder);
}
/**
* {@inheritDoc}
*/
public function examine(string $folder = 'INBOX'): ResponseCollection
{
return $this->examineOrSelect('EXAMINE', $folder);
}
/**
* Examine and select have the same response.
*/
protected function examineOrSelect(string $command = 'EXAMINE', string $folder = 'INBOX'): ResponseCollection
{
$this->send($command, [Str::literal($folder)], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged();
}
/**
* {@inheritDoc}
*/
public function status(string $folder = 'INBOX', array $arguments = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']): UntaggedResponse
{
$this->send('STATUS', [
Str::literal($folder),
Str::list($arguments),
], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstWhere(
fn (UntaggedResponse $response) => $response->type()->is('STATUS')
);
}
/**
* {@inheritDoc}
*/
public function create(string $folder): ResponseCollection
{
$this->send('CREATE', [Str::literal($folder)], $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('LIST')
);
}
/**
* {@inheritDoc}
*/
public function delete(string $folder): TaggedResponse
{
$this->send('DELETE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function rename(string $oldPath, string $newPath): TaggedResponse
{
$this->send('RENAME', Str::literal([$oldPath, $newPath]), tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function subscribe(string $folder): TaggedResponse
{
$this->send('SUBSCRIBE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function unsubscribe(string $folder): TaggedResponse
{
$this->send('UNSUBSCRIBE', [Str::literal($folder)], tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function quota(string $root): UntaggedResponse
{
$this->send('GETQUOTA', [Str::literal($root)], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('QUOTA')
);
}
/**
* {@inheritDoc}
*/
public function quotaRoot(string $mailbox): ResponseCollection
{
$this->send('GETQUOTAROOT', [Str::literal($mailbox)], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('QUOTA')
);
}
/**
* {@inheritDoc}
*/
public function list(string $reference = '', string $folder = '*'): ResponseCollection
{
$this->send('LIST', Str::literal([$reference, $folder]), $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('LIST')
);
}
/**
* {@inheritDoc}
*/
public function append(string $folder, string $message, ?array $flags = null): TaggedResponse
{
$tokens = [];
$tokens[] = Str::literal($folder);
if ($flags) {
$tokens[] = Str::list($flags);
}
$tokens[] = Str::literal($message);
$this->send('APPEND', $tokens, tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function copy(string $folder, array|int $from, ?int $to = null): TaggedResponse
{
$this->send('UID COPY', [
Str::set($from, $to),
Str::literal($folder),
], $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function move(string $folder, array|int $from, ?int $to = null): TaggedResponse
{
$this->send('UID MOVE', [
Str::set($from, $to),
Str::literal($folder),
], $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function store(array|string $flags, array|int $from, ?int $to = null, ?string $mode = null, bool $silent = true, ?string $item = null): ResponseCollection
{
$set = Str::set($from, $to);
$flags = Str::list((array) $flags);
$item = ($mode == '-' ? '-' : '+').(is_null($item) ? 'FLAGS' : $item).($silent ? '.SILENT' : '');
$this->send('UID STORE', [$set, $item, $flags], tag: $tag);
$this->assertTaggedResponse($tag);
return $silent ? new ResponseCollection : $this->result->responses()->untagged()->filter(
fn (UntaggedResponse $response) => $response->type()->is('FETCH')
);
}
/**
* {@inheritDoc}
*/
public function uid(int|array $ids, ImapFetchIdentifier $identifier): ResponseCollection
{
return $this->fetch(['UID'], (array) $ids, null, $identifier);
}
/**
* {@inheritDoc}
*/
public function bodyText(int|array $ids, bool $peek = true): ResponseCollection
{
return $this->fetch([$peek ? 'BODY.PEEK[TEXT]' : 'BODY[TEXT]'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function bodyHeader(int|array $ids, bool $peek = true): ResponseCollection
{
return $this->fetch([$peek ? 'BODY.PEEK[HEADER]' : 'BODY[HEADER]'], (array) $ids);
}
/**
* Fetch the BODYSTRUCTURE for the given message(s).
*/
public function bodyStructure(int|array $ids): ResponseCollection
{
return $this->fetch(['BODYSTRUCTURE'], (array) $ids);
}
/**
* Fetch a specific part of the message BODY, such as BODY[1], BODY[1.2], etc.
*/
public function bodyPart(string $partIndex, int|array $ids, bool $peek = false): ResponseCollection
{
$part = $peek ? "BODY.PEEK[$partIndex]" : "BODY[$partIndex]";
return $this->fetch([$part], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function flags(int|array $ids): ResponseCollection
{
return $this->fetch(['FLAGS'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function size(int|array $ids): ResponseCollection
{
return $this->fetch(['RFC822.SIZE'], (array) $ids);
}
/**
* {@inheritDoc}
*/
public function search(array $params): UntaggedResponse
{
$this->send('UID SEARCH', $params, tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('SEARCH')
);
}
/**
* {@inheritDoc}
*/
public function sort(ImapSortKey $key, string $direction, array $params): UntaggedResponse
{
$sortCriteria = $direction === 'desc' ? "REVERSE {$key->value}" : $key->value;
$this->send('UID SORT', ["({$sortCriteria})", 'UTF-8', ...$params], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('SORT')
);
}
/**
* {@inheritDoc}
*/
public function capability(): UntaggedResponse
{
$this->send('CAPABILITY', tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('CAPABILITY')
);
}
/**
* {@inheritDoc}
*/
public function id(?array $ids = null): UntaggedResponse
{
$token = 'NIL';
if (is_array($ids) && ! empty($ids)) {
$token = '(';
foreach ($ids as $id) {
$token .= '"'.Str::escape($id).'" ';
}
$token = rtrim($token).')';
}
$this->send('ID', [$token], tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged()->firstOrFail(
fn (UntaggedResponse $response) => $response->type()->is('ID')
);
}
/**
* {@inheritDoc}
*/
public function expunge(): ResponseCollection
{
$this->send('EXPUNGE', tag: $tag);
$this->assertTaggedResponse($tag);
return $this->result->responses()->untagged();
}
/**
* {@inheritDoc}
*/
public function noop(): TaggedResponse
{
$this->send('NOOP', tag: $tag);
return $this->assertTaggedResponse($tag);
}
/**
* {@inheritDoc}
*/
public function idle(int $timeout): Generator
{
$this->stream->setTimeout($timeout);
$this->send('IDLE', tag: $tag);
$this->assertNextResponse(
fn (Response $response) => $response instanceof ContinuationResponse,
fn (ContinuationResponse $response) => true,
fn (ContinuationResponse $response) => ImapCommandException::make(new ImapCommand('', 'IDLE'), $response),
);
while ($response = $this->nextReply()) {
yield $response;
}
}
/**
* {@inheritDoc}
*/
public function done(): void
{
$this->write('DONE');
// After issuing a "DONE" command, the server must eventually respond with a
// tagged response to indicate that the IDLE command has been successfully
// terminated and the server is ready to accept further commands.
$this->assertNextResponse(
fn (Response $response) => $response instanceof TaggedResponse,
fn (TaggedResponse $response) => $response->successful(),
fn (TaggedResponse $response) => ImapCommandException::make(new ImapCommand('', 'DONE'), $response),
);
}
/**
* Send an IMAP command.
*
* @param-out string $tag
*/
public function send(string $name, array $tokens = [], ?string &$tag = null): void
{
if (! $tag) {
$this->sequence++;
$tag = 'TAG'.$this->sequence;
}
$command = new ImapCommand($tag, $name, $tokens);
// After every command, we'll overwrite any previous result
// with the new command and its responses, so that we can
// easily access the commands responses for assertion.
$this->setResult(new Result($command));
foreach ($command->compile() as $line) {
$this->write($line);
}
}
/**
* Write data to the connected stream.
*/
protected function write(string $data): void
{
if ($this->stream->fwrite($data."\r\n") === false) {
throw new ImapStreamException('Failed to write data to stream');
}
$this->logger?->sent($data);
}
/**
* Fetch one or more items for one or more messages.
*/
public function fetch(array|string $items, array|int $from, mixed $to = null, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection
{
$prefix = ($identifier === ImapFetchIdentifier::Uid) ? 'UID' : '';
$this->send(trim($prefix.' FETCH'), [
Str::set($from, $to),
Str::list((array) $items),
], $tag);
$this->assertTaggedResponse($tag);
// Some IMAP servers can send unsolicited untagged responses along with fetch
// requests. We'll need to filter these out so that we can return only the
// responses that are relevant to the fetch command. For example:
// >> TAG123 FETCH (UID 456 BODY[TEXT])
// << * 123 FETCH (UID 456 BODY[TEXT] {14}\nHello, World!)
// << * 123 FETCH (FLAGS (\Seen)) <-- Unsolicited response
return $this->result->responses()->untagged()->filter(function (UntaggedResponse $response) use ($items, $identifier) {
// Skip over any untagged responses that are not FETCH responses.
// The third token should always be the list of data items.
if (! ($data = $response->tokenAt(3)) instanceof ListData) {
return false;
}
return match ($identifier) {
// If we're fetching UIDs, we can check if a UID token is contained in the list.
ImapFetchIdentifier::Uid => $data->contains('UID'),
// If we're fetching message numbers, we can check if the requested items are all contained in the list.
ImapFetchIdentifier::MessageNumber => $data->contains($items),
};
});
}
/**
* Set the current result instance.
*/
protected function setResult(Result $result): void
{
$this->result = $result;
}
/**
* Set the current parser instance.
*/
protected function setParser(ImapParser $parser): void
{
$this->parser = $parser;
}
/**
* Create a new parser instance.
*/
protected function newParser(StreamInterface $stream): ImapParser
{
return new ImapParser($this->newTokenizer($stream));
}
/**
* Create a new tokenizer instance.
*/
protected function newTokenizer(StreamInterface $stream): ImapTokenizer
{
return new ImapTokenizer($stream);
}
/**
* Assert the next response is a successful tagged response.
*/
protected function assertTaggedResponse(string $tag, ?callable $exception = null): TaggedResponse
{
/** @var TaggedResponse $response */
$response = $this->assertNextResponse(
fn (Response $response) => (
$response instanceof TaggedResponse && $response->tag()->is($tag)
),
fn (TaggedResponse $response) => (
$response->successful()
),
$exception ?? fn (TaggedResponse $response) => (
ImapCommandException::make($this->result->command(), $response)
),
);
return $response;
}
/**
* Assert the next response matches the given filter and assertion.
*
* @template T of Response
*
* @param callable(Response): bool $filter
* @param callable(T): bool $assertion
* @param callable(T): Throwable $exception
* @return T
*
* @throws ImapResponseException
*/
protected function assertNextResponse(callable $filter, callable $assertion, callable $exception): Response
{
while ($response = $this->nextResponse($filter)) {
if ($assertion($response)) {
return $response;
}
throw $exception($response);
}
throw new ImapResponseException('No matching response found');
}
/**
* Returns the next response matching the given filter.
*
* @template T of Response
*
* @param callable(T): bool $filter
* @return T|null
*/
protected function nextResponse(callable $filter): ?Response
{
if (! $this->parser) {
throw new LogicException('No parser instance set');
}
while ($response = $this->nextReply()) {
if (! $response instanceof Response) {
continue;
}
$this->result?->addResponse($response);
if ($filter($response)) {
return $response;
}
}
return null;
}
/**
* Read the next reply from the stream.
*/
protected function nextReply(): Data|Token|Response|null
{
if (! $reply = $this->parser->next()) {
$meta = $this->stream->meta();
throw match (true) {
$meta['timed_out'] ?? false => new ImapConnectionTimedOutException('Stream timed out, no response'),
$meta['eof'] ?? false => new ImapConnectionClosedException('Server closed the connection (EOF)'),
default => new ImapConnectionFailedException('Unknown stream error. Metadata: '.json_encode($meta)),
};
}
$this->logger?->received($reply);
return $reply;
}
}

View File

@@ -0,0 +1,270 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Connection\Responses\ContinuationResponse;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
use DirectoryTree\ImapEngine\Connection\Responses\TaggedResponse;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
use DirectoryTree\ImapEngine\Connection\Tokens\ListClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ListOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Exceptions\ImapParserException;
class ImapParser
{
/**
* The current token being parsed.
*
* Expected to be an associative array with keys like "type" and "value".
*/
protected ?Token $currentToken = null;
/**
* Constructor.
*/
public function __construct(
protected ImapTokenizer $tokenizer
) {}
/**
* Get the next response from the tokenizer.
*/
public function next(): Data|Token|Response|null
{
// Attempt to load the first token.
if (! $this->currentToken) {
$this->advance();
}
// No token was found, return null.
if (! $this->currentToken) {
return null;
}
// If the token indicates the beginning of a list, parse it.
if ($this->currentToken instanceof ListOpen) {
return $this->parseList();
}
// If the token is an Atom or Number, check its value for special markers.
if ($this->currentToken instanceof Atom || $this->currentToken instanceof Number) {
// '*' marks an untagged response.
if ($this->currentToken->value === '*') {
return $this->parseUntaggedResponse();
}
// '+' marks a continuation response.
if ($this->currentToken->value === '+') {
return $this->parseContinuationResponse();
}
// If it's an ATOM and not '*' or '+', it's likely a tagged response.
return $this->parseTaggedResponse();
}
return $this->parseElement();
}
/**
* Parse an untagged response.
*
* An untagged response begins with the '*' token. It may contain
* multiple elements, including lists and response codes.
*/
protected function parseUntaggedResponse(): UntaggedResponse
{
// Capture the initial '*' token.
$elements[] = clone $this->currentToken;
$this->advance();
// Collect all tokens until the end-of-response marker.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$elements[] = $this->parseElement();
}
// If the end-of-response marker (CRLF) is present, consume it.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated untagged response');
}
return new UntaggedResponse($elements);
}
/**
* Parse a continuation response.
*
* A continuation response starts with a '+' token, indicating
* that the server expects additional data from the client.
*/
protected function parseContinuationResponse(): ContinuationResponse
{
// Capture the initial '+' token.
$elements[] = clone $this->currentToken;
$this->advance();
// Collect all tokens until the CRLF marker.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$elements[] = $this->parseElement();
}
// Consume the CRLF marker if present.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated continuation response');
}
return new ContinuationResponse($elements);
}
/**
* Parse a tagged response.
*
* A tagged response begins with a tag (which is not '*' or '+')
* and is followed by a status and optional data.
*/
protected function parseTaggedResponse(): TaggedResponse
{
// Capture the initial TAG token.
$tokens[] = clone $this->currentToken;
$this->advance();
// Collect tokens until the end-of-response marker is reached.
while ($this->currentToken && ! $this->currentToken instanceof Crlf) {
$tokens[] = $this->parseElement();
}
// Consume the CRLF marker if present.
if ($this->currentToken && $this->currentToken instanceof Crlf) {
$this->currentToken = null;
} else {
throw new ImapParserException('Unterminated tagged response');
}
return new TaggedResponse($tokens);
}
/**
* Parses a bracket group of elements delimited by '[' and ']'.
*
* Bracket groups are used to represent response codes.
*/
protected function parseBracketGroup(): ResponseCodeData
{
// Consume the opening '[' token.
$this->advance();
$elements = [];
while (
$this->currentToken
&& ! $this->currentToken instanceof ResponseCodeClose
) {
// Skip CRLF tokens that may appear inside bracket groups.
if ($this->currentToken instanceof Crlf) {
$this->advance();
continue;
}
$elements[] = $this->parseElement();
}
if ($this->currentToken === null) {
throw new ImapParserException('Unterminated bracket group in response');
}
// Consume the closing ']' token.
$this->advance();
return new ResponseCodeData($elements);
}
/**
* Parses a list of elements delimited by '(' and ')'.
*
* Lists are handled recursively, as a list may contain nested lists.
*/
protected function parseList(): ListData
{
// Consume the opening '(' token.
$this->advance();
$elements = [];
// Continue to parse elements until we find the corresponding ')'.
while (
$this->currentToken
&& ! $this->currentToken instanceof ListClose
) {
// Skip CRLF tokens that appear inside lists (after literals).
if ($this->currentToken instanceof Crlf) {
$this->advance();
continue;
}
$elements[] = $this->parseElement();
}
// If we reached the end without finding a closing ')', throw an exception.
if ($this->currentToken === null) {
throw new ImapParserException('Unterminated list in response');
}
// Consume the closing ')' token.
$this->advance();
return new ListData($elements);
}
/**
* Parses a single element, which might be a list or a simple token.
*/
protected function parseElement(): Data|Token|null
{
// If there is no current token, return null.
if ($this->currentToken === null) {
return null;
}
// If the token indicates the start of a list, parse it as a list.
if ($this->currentToken instanceof ListOpen) {
return $this->parseList();
}
// If the token indicates the start of a group, parse it as a group.
if ($this->currentToken instanceof ResponseCodeOpen) {
return $this->parseBracketGroup();
}
// Otherwise, capture the current token.
$token = clone $this->currentToken;
$this->advance();
return $token;
}
/**
* Advance to the next token from the tokenizer.
*/
protected function advance(): void
{
$this->currentToken = $this->tokenizer->nextToken();
}
}

View File

@@ -0,0 +1,510 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use BackedEnum;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DateTimeInterface;
use DirectoryTree\ImapEngine\Enums\ImapSearchKey;
use DirectoryTree\ImapEngine\Support\Str;
class ImapQueryBuilder
{
/**
* The where conditions for the query.
*/
protected array $wheres = [];
/**
* The date format to use for date based queries.
*/
protected string $dateFormat = 'd-M-Y';
/**
* Add a where "ALL" clause to the query.
*/
public function all(): static
{
return $this->where(ImapSearchKey::All);
}
/**
* Add a where "NEW" clause to the query.
*/
public function new(): static
{
return $this->where(ImapSearchKey::New);
}
/**
* Add a where "OLD" clause to the query.
*/
public function old(): static
{
return $this->where(ImapSearchKey::Old);
}
/**
* Add a where "SEEN" clause to the query.
*/
public function seen(): static
{
return $this->where(ImapSearchKey::Seen);
}
/**
* Add a where "DRAFT" clause to the query.
*/
public function draft(): static
{
return $this->where(ImapSearchKey::Draft);
}
/**
* Add a where "RECENT" clause to the query.
*/
public function recent(): static
{
return $this->where(ImapSearchKey::Recent);
}
/**
* Add a where "UNSEEN" clause to the query.
*/
public function unseen(): static
{
return $this->where(ImapSearchKey::Unseen);
}
/**
* Add a where "FLAGGED" clause to the query.
*/
public function flagged(): static
{
return $this->where(ImapSearchKey::Flagged);
}
/**
* Add a where "DELETED" clause to the query.
*/
public function deleted(): static
{
return $this->where(ImapSearchKey::Deleted);
}
/**
* Add a where "ANSWERED" clause to the query.
*/
public function answered(): static
{
return $this->where(ImapSearchKey::Answered);
}
/**
* Add a where "UNDELETED" clause to the query.
*/
public function undeleted(): static
{
return $this->where(ImapSearchKey::Undeleted);
}
/**
* Add a where "UNFLAGGED" clause to the query.
*/
public function unflagged(): static
{
return $this->where(ImapSearchKey::Unflagged);
}
/**
* Add a where "UNANSWERED" clause to the query.
*/
public function unanswered(): static
{
return $this->where(ImapSearchKey::Unanswered);
}
/**
* Add a where "FROM" clause to the query.
*/
public function from(string $email): static
{
return $this->where(ImapSearchKey::From, $email);
}
/**
* Add a where "TO" clause to the query.
*/
public function to(string $value): static
{
return $this->where(ImapSearchKey::To, $value);
}
/**
* Add a where "CC" clause to the query.
*/
public function cc(string $value): static
{
return $this->where(ImapSearchKey::Cc, $value);
}
/**
* Add a where "BCC" clause to the query.
*/
public function bcc(string $value): static
{
return $this->where(ImapSearchKey::Bcc, $value);
}
/**
* Add a where "BODY" clause to the query.
*/
public function body(string $value): static
{
return $this->where(ImapSearchKey::Body, $value);
}
/**
* Add a where "KEYWORD" clause to the query.
*/
public function keyword(string $value): static
{
return $this->where(ImapSearchKey::Keyword, $value);
}
/**
* Add a where "UNKEYWORD" clause to the query.
*/
public function unkeyword(string $value): static
{
return $this->where(ImapSearchKey::Unkeyword, $value);
}
/**
* Add a where "ON" clause to the query.
*/
public function on(mixed $date): static
{
return $this->where(ImapSearchKey::On, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SINCE" clause to the query.
*/
public function since(mixed $date): static
{
return $this->where(ImapSearchKey::Since, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "BEFORE" clause to the query.
*/
public function before(mixed $value): static
{
return $this->where(ImapSearchKey::Before, new RawQueryValue(
$this->parseDate($value)->format($this->dateFormat)
));
}
/**
* Add a where "SENTON" clause to the query.
*/
public function sentOn(mixed $date): static
{
return $this->where(ImapSearchKey::SentOn, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SENTSINCE" clause to the query.
*/
public function sentSince(mixed $date): static
{
return $this->where(ImapSearchKey::SentSince, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SENTBEFORE" clause to the query.
*/
public function sentBefore(mixed $date): static
{
return $this->where(ImapSearchKey::SentBefore, new RawQueryValue(
$this->parseDate($date)->format($this->dateFormat)
));
}
/**
* Add a where "SUBJECT" clause to the query.
*/
public function subject(string $value): static
{
return $this->where(ImapSearchKey::Subject, $value);
}
/**
* Add a where "TEXT" clause to the query.
*/
public function text(string $value): static
{
return $this->where(ImapSearchKey::Text, $value);
}
/**
* Add a where "HEADER" clause to the query.
*/
public function header(string $header, string $value): static
{
return $this->where(ImapSearchKey::Header->value." $header", $value);
}
/**
* Add a where "UID" clause to the query.
*/
public function uid(int|string|array $from, int|float|null $to = null): static
{
return $this->where(ImapSearchKey::Uid, new RawQueryValue(Str::set($from, $to)));
}
/**
* Add a where "LARGER" clause to the query.
*/
public function larger(int $bytes): static
{
return $this->where(ImapSearchKey::Larger, new RawQueryValue($bytes));
}
/**
* Add a where "SMALLER" clause to the query.
*/
public function smaller(int $bytes): static
{
return $this->where(ImapSearchKey::Smaller, new RawQueryValue($bytes));
}
/**
* Add a "where" condition.
*/
public function where(mixed $column, mixed $value = null): static
{
if (is_callable($column)) {
$this->addNestedCondition('AND', $column);
} else {
$this->addBasicCondition('AND', $column, $value);
}
return $this;
}
/**
* Add an "or where" condition.
*/
public function orWhere(mixed $column, mixed $value = null): static
{
if (is_callable($column)) {
$this->addNestedCondition('OR', $column);
} else {
$this->addBasicCondition('OR', $column, $value);
}
return $this;
}
/**
* Add a "where not" condition.
*/
public function whereNot(mixed $column, mixed $value = null): static
{
$this->addBasicCondition('AND', $column, $value, true);
return $this;
}
/**
* Determine if the query has any where conditions.
*/
public function isEmpty(): bool
{
return empty($this->wheres);
}
/**
* Transform the instance into an IMAP-compatible query string.
*/
public function toImap(): string
{
return $this->compileWheres($this->wheres);
}
/**
* Create a new query instance (like Eloquent's newQuery).
*/
protected function newQuery(): static
{
return new static;
}
/**
* Add a basic condition to the query.
*/
protected function addBasicCondition(string $boolean, mixed $column, mixed $value, bool $not = false): void
{
$value = $this->prepareWhereValue($value);
$column = Str::enum($column);
$this->wheres[] = [
'type' => 'basic',
'not' => $not,
'key' => $column,
'value' => $value,
'boolean' => $boolean,
];
}
/**
* Prepare the where value, escaping it as needed.
*/
protected function prepareWhereValue(mixed $value): RawQueryValue|string|null
{
if (is_null($value)) {
return null;
}
if ($value instanceof RawQueryValue) {
return $value;
}
if ($value instanceof BackedEnum) {
$value = $value->value;
}
if ($value instanceof DateTimeInterface) {
$value = Carbon::instance($value);
}
if ($value instanceof CarbonInterface) {
$value = $value->format($this->dateFormat);
}
return Str::escape($value);
}
/**
* Add a nested condition group to the query.
*/
protected function addNestedCondition(string $boolean, callable $callback): void
{
$nested = $this->newQuery();
$callback($nested);
$this->wheres[] = [
'type' => 'nested',
'query' => $nested,
'boolean' => $boolean,
];
}
/**
* Attempt to parse a date string into a Carbon instance.
*/
protected function parseDate(mixed $date): CarbonInterface
{
if ($date instanceof CarbonInterface) {
return $date;
}
return Carbon::parse($date);
}
/**
* Build a single expression node from a basic or nested where.
*
* @param array{type: 'basic'|'nested', boolean: 'AND'|'OR', query: ImapQueryBuilder} $where
*/
protected function makeExpressionNode(array $where): array
{
return match ($where['type']) {
'basic' => [
'expr' => $this->compileBasic($where),
'boolean' => $where['boolean'],
],
'nested' => [
'expr' => $where['query']->toImap(),
'boolean' => $where['boolean'],
]
};
}
/**
* Merge the existing expression with the next expression, respecting the boolean operator.
*
* @param 'AND'|'OR' $boolean
*/
protected function mergeExpressions(string $existing, string $next, string $boolean): string
{
return match ($boolean) {
// AND is implicit just append.
'AND' => $existing.' '.$next,
// IMAP's OR is binary; nest accordingly.
'OR' => 'OR ('.$existing.') ('.$next.')',
};
}
/**
* Recursively compile the wheres array into an IMAP-compatible string.
*/
protected function compileWheres(array $wheres): string
{
if (empty($wheres)) {
return '';
}
// Convert each "where" into a node for later merging.
$exprNodes = array_map(fn (array $where) => (
$this->makeExpressionNode($where)
), $wheres);
// Start with the first expression.
$combined = array_shift($exprNodes)['expr'];
// Merge the rest of the expressions.
foreach ($exprNodes as $node) {
$combined = $this->mergeExpressions(
$combined, $node['expr'], $node['boolean']
);
}
return trim($combined);
}
/**
* Compile a basic where condition into an IMAP-compatible string.
*/
protected function compileBasic(array $where): string
{
$part = strtoupper($where['key']);
if ($where['value'] instanceof RawQueryValue) {
$part .= ' '.$where['value']->value;
} elseif ($where['value']) {
$part .= ' "'.Str::toImapUtf7($where['value']).'"';
}
if ($where['not']) {
$part = 'NOT '.$part;
}
return $part;
}
}

View File

@@ -0,0 +1,511 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Connection\Streams\StreamInterface;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Crlf;
use DirectoryTree\ImapEngine\Connection\Tokens\EmailAddress;
use DirectoryTree\ImapEngine\Connection\Tokens\ListClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ListOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Literal;
use DirectoryTree\ImapEngine\Connection\Tokens\Nil;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\QuotedString;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeClose;
use DirectoryTree\ImapEngine\Connection\Tokens\ResponseCodeOpen;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Exceptions\ImapParserException;
use DirectoryTree\ImapEngine\Exceptions\ImapStreamException;
class ImapTokenizer
{
/**
* The current position in the buffer.
*/
protected int $position = 0;
/**
* The buffer of characters read from the stream.
*/
protected string $buffer = '';
/**
* Constructor.
*/
public function __construct(
protected StreamInterface $stream
) {}
/**
* Returns the next token from the stream.
*/
public function nextToken(): ?Token
{
$this->skipWhitespace();
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null || $char === '') {
return null;
}
// Check for line feed.
if ($char === "\n") {
// With a valid IMAP response, we should never reach this point,
// but in case we receive a malformed response, we will flush
// the buffer and return null to prevent an infinite loop.
$this->flushBuffer();
return null;
}
// Check for carriage return. (\r\n)
if ($char === "\r") {
$this->advance(); // Consume CR
$this->ensureBuffer(1);
if ($this->currentChar() !== "\n") {
throw new ImapParserException('Expected LF after CR');
}
$this->advance(); // Consume LF (\n)
return new Crlf("\r\n");
}
// Check for parameter list opening.
if ($char === '(') {
$this->advance();
return new ListOpen('(');
}
// Check for a parameter list closing.
if ($char === ')') {
$this->advance();
return new ListClose(')');
}
// Check for a response group open.
if ($char === '[') {
$this->advance();
return new ResponseCodeOpen('[');
}
// Check for response group close.
if ($char === ']') {
$this->advance();
return new ResponseCodeClose(']');
}
// Check for angle bracket open (email addresses).
if ($char === '<') {
$this->advance();
return $this->readEmailAddress();
}
// Check for quoted string.
if ($char === '"') {
return $this->readQuotedString();
}
// Check for literal block open.
if ($char === '{') {
return $this->readLiteral();
}
// Otherwise, parse a number or atom.
return $this->readNumberOrAtom();
}
/**
* Skips whitespace characters (spaces and tabs only, preserving CRLF).
*/
protected function skipWhitespace(): void
{
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
// Break on EOF.
if ($char === null || $char === '') {
break;
}
// Break on CRLF.
if ($char === "\r" || $char === "\n") {
break;
}
// Break on non-whitespace.
if ($char !== ' ' && $char !== "\t") {
break;
}
$this->advance();
}
}
/**
* Reads a quoted string token.
*
* Quoted strings are enclosed in double quotes and may contain escaped characters.
*/
protected function readQuotedString(): QuotedString
{
// Skip the opening quote.
$this->advance();
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException(sprintf(
'Unterminated quoted string at buffer offset %d. Buffer: "%s"',
$this->position,
substr($this->buffer, max(0, $this->position - 10), 20)
));
}
if ($char === '\\') {
$this->advance(); // Skip the backslash.
$this->ensureBuffer(1);
$escapedChar = $this->currentChar();
if ($escapedChar === null) {
throw new ImapParserException('Unterminated escape sequence in quoted string');
}
$value .= $escapedChar;
$this->advance();
continue;
}
if ($char === '"') {
$this->advance(); // Skip the closing quote.
break;
}
$value .= $char;
$this->advance();
}
return new QuotedString($value);
}
/**
* Reads a literal token.
*
* Literal blocks in IMAP have the form {<length>}\r\n<data>.
*/
protected function readLiteral(): Literal
{
// Skip the opening '{'.
$this->advance();
// This will contain the size of the literal block in a sequence of digits.
// {<size>}\r\n<data>
$numStr = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException('Unterminated literal specifier');
}
if ($char === '}') {
$this->advance(); // Skip the '}'.
break;
}
$numStr .= $char;
$this->advance();
}
// Expect carriage return after the literal specifier.
$this->ensureBuffer(2);
// Get the carriage return.
$crlf = substr($this->buffer, $this->position, 2);
if ($crlf !== "\r\n") {
throw new ImapParserException('Expected CRLF after literal specifier');
}
// Skip the CRLF.
$this->advance(2);
$length = (int) $numStr;
// Use any data that is already in our buffer.
$available = strlen($this->buffer) - $this->position;
if ($available >= $length) {
$literal = substr($this->buffer, $this->position, $length);
$this->advance($length);
} else {
// Consume whatever is available without flushing the whole buffer.
$literal = substr($this->buffer, $this->position);
$consumed = strlen($literal);
// Advance the pointer by the number of bytes we took.
$this->advance($consumed);
// Calculate how many bytes are still needed.
$remaining = $length - $consumed;
// Read the missing bytes from the stream.
$data = $this->stream->read($remaining);
if ($data === false || strlen($data) !== $remaining) {
throw new ImapStreamException('Unexpected end of stream while trying to fill the buffer');
}
$literal .= $data;
}
// Verify that the literal length matches the expected length.
if (strlen($literal) !== $length) {
throw new ImapParserException(sprintf(
'Literal length mismatch: expected %d, got %d',
$length,
strlen($literal)
));
}
return new Literal($literal);
}
/**
* Reads a number or atom token.
*/
protected function readNumberOrAtom(): Token
{
$position = $this->position;
// First char must be a digit to even consider a number.
if (! ctype_digit($this->buffer[$position] ?? '')) {
return $this->readAtom();
}
// Walk forward to find the end of the digit run.
while (ctype_digit($this->buffer[$position] ?? '')) {
$position++;
$this->ensureBuffer($position - $this->position + 1);
}
$next = $this->buffer[$position] ?? null;
// If next is EOF or a delimiter, it's a Number.
if ($next === null || $this->isDelimiter($next)) {
return $this->readNumber();
}
// Otherwise it's an Atom.
return $this->readAtom();
}
/**
* Reads a number token.
*
* A number consists of one or more digit characters and represents a numeric value.
*/
protected function readNumber(): Number
{
$start = $this->position;
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
break;
}
if (! ctype_digit($char)) {
break;
}
$this->advance();
}
return new Number(substr($this->buffer, $start, $this->position - $start));
}
/**
* Reads an atom token.
*
* ATOMs are sequences of printable ASCII characters that do not contain delimiters.
*/
protected function readAtom(): Atom
{
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
break;
}
if (! $this->isValidAtomCharacter($char)) {
break;
}
$value .= $char;
$this->advance();
}
if (strcasecmp($value, 'NIL') === 0) {
return new Nil($value);
}
return new Atom($value);
}
/**
* Reads an email address token enclosed in angle brackets.
*
* Email addresses are enclosed in angle brackets ("<" and ">").
*
* For example "<johndoe@email.com>"
*/
protected function readEmailAddress(): ?EmailAddress
{
$value = '';
while (true) {
$this->ensureBuffer(1);
$char = $this->currentChar();
if ($char === null) {
throw new ImapParserException('Unterminated email address, expected ">"');
}
if ($char === '>') {
$this->advance(); // Skip the closing '>'.
break;
}
$value .= $char;
$this->advance();
}
return new EmailAddress($value);
}
/**
* Ensures that at least the given length in characters are available in the buffer.
*/
protected function ensureBuffer(int $length): void
{
// If we have enough data in the buffer, return early.
while ((strlen($this->buffer) - $this->position) < $length) {
$data = $this->stream->fgets();
if ($data === false) {
return;
}
$this->buffer .= $data;
}
}
/**
* Returns the current character in the buffer.
*/
protected function currentChar(): ?string
{
return $this->buffer[$this->position] ?? null;
}
/**
* Advances the internal pointer by $n characters.
*/
protected function advance(int $n = 1): void
{
$this->position += $n;
// If we have consumed the entire buffer, reset it.
if ($this->position >= strlen($this->buffer)) {
$this->flushBuffer();
}
}
/**
* Flush the buffer and reset the position.
*/
protected function flushBuffer(): void
{
$this->buffer = '';
$this->position = 0;
}
/**
* Determine if the given character is a valid atom character.
*/
protected function isValidAtomCharacter(string $char): bool
{
// Get the ASCII code.
$code = ord($char);
// Allow only printable ASCII (32-126).
if ($code < 32 || $code > 126) {
return false;
}
// Delimiters are not allowed inside ATOMs.
if ($this->isDelimiter($char)) {
return false;
}
return true;
}
/**
* Determine if the given character is a delimiter for tokenizing responses.
*/
protected function isDelimiter(string $char): bool
{
// This delimiter list includes additional characters (such as square
// brackets, curly braces, and angle brackets) to ensure that tokens
// like the response code group brackets are split out. This is fine
// for tokenizing responses, even though its more restrictive
// than the IMAP atom definition in RFC 3501 (section 9).
return in_array($char, [' ', '(', ')', '[', ']', '{', '}', '<', '>'], true);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class EchoLogger extends Logger
{
/**
* {@inheritDoc}
*/
public function write(string $message): void
{
echo $message;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class FileLogger extends Logger
{
/**
* Constructor.
*/
public function __construct(
protected string $path
) {}
/**
* {@inheritDoc}
*/
public function write(string $message): void
{
file_put_contents($this->path, $message, FILE_APPEND);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
abstract class Logger implements LoggerInterface
{
/**
* Write a message to the log.
*/
abstract protected function write(string $message): void;
/**
* {@inheritDoc}
*/
public function sent(string $message): void
{
$this->write(sprintf('%s: >> %s', $this->date(), $message).PHP_EOL);
}
/**
* {@inheritDoc}
*/
public function received(string $message): void
{
$this->write(sprintf('%s: << %s', $this->date(), $message).PHP_EOL);
}
/**
* Get the current date and time.
*/
protected function date(): string
{
return date('Y-m-d H:i:s');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
interface LoggerInterface
{
/**
* Log when a message is sent.
*/
public function sent(string $message): void;
/**
* Log when a message is received.
*/
public function received(string $message): void;
}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Loggers;
class RayLogger extends Logger
{
/**
* {@inheritDoc}
*/
protected function write(string $message): void
{
ray($message);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use Stringable;
class RawQueryValue
{
/**
* Constructor.
*/
public function __construct(
public readonly Stringable|string $value
) {}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class ContinuationResponse extends Response
{
/**
* Get the data tokens.
*
* @return Token[]
*/
public function data(): array
{
return array_slice($this->tokens, 1);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
use DirectoryTree\ImapEngine\Connection\Responses\HasTokens;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Stringable;
abstract class Data implements Stringable
{
use HasTokens;
/**
* Constructor.
*/
public function __construct(
protected array $tokens
) {}
/**
* Get the tokens.
*
* @return Token[]|Data[]
*/
public function tokens(): array
{
return $this->tokens;
}
/**
* Get the first token.
*/
public function first(): Token|Data|null
{
return $this->tokens[0] ?? null;
}
/**
* Get the last token.
*/
public function last(): Token|Data|null
{
return $this->tokens[count($this->tokens) - 1] ?? null;
}
/**
* Determine if the data contains a specific value.
*/
public function contains(array|string $needles): bool
{
$haystack = $this->values();
foreach ((array) $needles as $needle) {
if (! in_array($needle, $haystack)) {
return false;
}
}
return true;
}
/**
* Get all the token's values.
*/
public function values(): array
{
return array_map(function (Token|Data $token) {
return $token instanceof Data
? $token->values()
: $token->value;
}, $this->tokens);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class ListData extends Data
{
/**
* Find the immediate successor token of the given field in the list.
*/
public function lookup(string $field): Data|Token|null
{
foreach ($this->tokens as $index => $token) {
if ((string) $token === $field) {
return $this->tokenAt(++$index);
}
}
return null;
}
/**
* Convert alternating key/value tokens to an associative array.
*/
public function toKeyValuePairs(): array
{
$pairs = [];
for ($i = 0; $i < count($this->tokens) - 1; $i += 2) {
$key = strtolower($this->tokens[$i]->value);
$pairs[$key] = $this->tokens[$i + 1]->value;
}
return $pairs;
}
/**
* Get the list as a string.
*/
public function __toString(): string
{
return sprintf('(%s)', implode(
' ', array_map('strval', $this->tokens)
));
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses\Data;
class ResponseCodeData extends Data
{
/**
* Get the group as a string.
*/
public function __toString(): string
{
return sprintf('[%s]', implode(
' ', array_map('strval', $this->tokens)
));
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
trait HasTokens
{
/**
* Get the response tokens.
*
* @return Token[]|Data[]
*/
abstract public function tokens(): array;
/**
* Get the response token at the given index.
*/
public function tokenAt(int $index): Token|Data|null
{
return $this->tokens()[$index] ?? null;
}
/**
* Get the response tokens after the given index.
*/
public function tokensAfter(int $index): array
{
return array_slice($this->tokens(), $index);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ResponseCodeData;
class MessageResponseParser
{
/**
* Get the UID from a tagged move or copy response.
*/
public static function getUidFromCopy(TaggedResponse $response): ?int
{
if (! $data = $response->tokenAt(2)) {
return null;
}
if (! $data instanceof ResponseCodeData) {
return null;
}
if (! $value = $data->tokenAt(3)?->value) {
return null;
}
return (int) $value;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Responses\Data\Data;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Illuminate\Contracts\Support\Arrayable;
use Stringable;
class Response implements Arrayable, Stringable
{
use HasTokens;
/**
* Constructor.
*/
public function __construct(
protected array $tokens
) {}
/**
* Get the response tokens.
*
* @return Token[]|Data[]
*/
public function tokens(): array
{
return $this->tokens;
}
/**
* Get the instance as an array.
*/
public function toArray(): array
{
return array_map(function (Token|Data $token) {
return $token instanceof Data
? $token->values()
: $token->value;
}, $this->tokens);
}
/**
* Get a JSON representation of the response tokens.
*/
public function __toString(): string
{
return implode(' ', $this->tokens);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
class TaggedResponse extends Response
{
/**
* Get the response tag.
*/
public function tag(): Atom|Number
{
return $this->tokens[0];
}
/**
* Get the response status token.
*/
public function status(): Atom
{
return $this->tokens[1];
}
/**
* Get the response data tokens.
*
* @return Token[]
*/
public function data(): array
{
return array_slice($this->tokens, 2);
}
/**
* Determine if the response was successful.
*/
public function successful(): bool
{
return strtoupper($this->status()->value) === 'OK';
}
/**
* Determine if the response failed.
*/
public function failed(): bool
{
return in_array(strtoupper($this->status()->value), ['NO', 'BAD']);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Responses;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Connection\Tokens\Number;
class UntaggedResponse extends Response
{
/**
* Get the response type token.
*/
public function type(): Atom|Number
{
return $this->tokens[1];
}
/**
* Get the data tokens.
*
* @return Atom[]
*/
public function data(): array
{
return array_slice($this->tokens, 2);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace DirectoryTree\ImapEngine\Connection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
class Result
{
/**
* Constructor.
*/
public function __construct(
protected ImapCommand $command,
protected array $responses = [],
) {}
/**
* Get the executed command.
*/
public function command(): ImapCommand
{
return $this->command;
}
/**
* Add a response to the result.
*/
public function addResponse(Response $response): void
{
$this->responses[] = $response;
}
/**
* Get the recently received responses.
*/
public function responses(): ResponseCollection
{
return new ResponseCollection($this->responses);
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Streams;
use PHPUnit\Framework\Assert;
use RuntimeException;
class FakeStream implements StreamInterface
{
/**
* Lines queued for testing; each call to fgets() pops the next line.
*
* @var string[]
*/
protected array $buffer = [];
/**
* Data that has been "written" to this fake stream (for assertion).
*
* @var string[]
*/
protected array $written = [];
/**
* The connection info.
*/
protected ?array $connection = null;
/**
* The mock meta info.
*/
protected array $meta = [
'crypto' => [
'protocol' => '',
'cipher_name' => '',
'cipher_bits' => 0,
'cipher_version' => '',
],
'mode' => 'c',
'eof' => false,
'blocked' => false,
'timed_out' => false,
'seekable' => false,
'unread_bytes' => 0,
'stream_type' => 'tcp_socket/unknown',
];
/**
* Feed a line to the stream buffer with a newline character.
*/
public function feed(array|string $lines): self
{
// We'll ensure that each line ends with a CRLF,
// as this is the expected behavior of every
// reply that comes from an IMAP server.
$lines = array_map(fn (string $line) => (
rtrim($line, "\r\n")."\r\n"
), (array) $lines);
array_push($this->buffer, ...$lines);
return $this;
}
/**
* Feed a raw line to the stream buffer.
*/
public function feedRaw(array|string $lines): self
{
array_push($this->buffer, ...(array) $lines);
return $this;
}
/**
* Set the timed out status.
*/
public function setMeta(string $attribute, mixed $value): self
{
if (! isset($this->meta[$attribute])) {
throw new RuntimeException(
"Unknown metadata attribute: {$attribute}"
);
}
if (gettype($this->meta[$attribute]) !== gettype($value)) {
throw new RuntimeException(
"Metadata attribute {$attribute} must be of type ".gettype($this->meta[$attribute])
);
}
$this->meta[$attribute] = $value;
return $this;
}
/**
* {@inheritDoc}
*/
public function open(?string $transport = null, ?string $host = null, ?int $port = null, ?int $timeout = null, array $options = []): bool
{
$this->connection = compact('transport', 'host', 'port', 'timeout', 'options');
return true;
}
/**
* {@inheritDoc}
*/
public function close(): void
{
$this->buffer = [];
$this->connection = null;
}
/**
* {@inheritDoc}
*/
public function read(int $length): string|false
{
if (! $this->opened()) {
return false;
}
if ($this->meta['eof'] && empty($this->buffer)) {
return false; // EOF and no data left. Indicate end of stream.
}
$data = implode('', $this->buffer);
$availableLength = strlen($data);
if ($availableLength === 0) {
// No data available right now (but not EOF).
// Simulate non-blocking behavior.
return '';
}
$bytesToRead = min($length, $availableLength);
$result = substr($data, 0, $bytesToRead);
$remainingData = substr($data, $bytesToRead);
$this->buffer = $remainingData !== '' ? [$remainingData] : [];
return $result;
}
/**
* {@inheritDoc}
*/
public function fgets(): string|false
{
if (! $this->opened()) {
return false;
}
// Simulate timeout/eof checks.
if ($this->meta['timed_out'] || $this->meta['eof']) {
return false;
}
return array_shift($this->buffer) ?? false;
}
/**
* {@inheritDoc}
*/
public function fwrite(string $data): int|false
{
if (! $this->opened()) {
return false;
}
$this->written[] = $data;
return strlen($data);
}
/**
* {@inheritDoc}
*/
public function meta(): array
{
return $this->meta;
}
/**
* {@inheritDoc}
*/
public function opened(): bool
{
return (bool) $this->connection;
}
/**
* {@inheritDoc}
*/
public function setTimeout(int $seconds): bool
{
return true;
}
/**
* {@inheritDoc}
*/
public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int
{
return true;
}
/**
* Assert that the given data was written to the stream.
*/
public function assertWritten(string $string): void
{
$found = false;
foreach ($this->written as $index => $written) {
if (str_contains($written, $string)) {
unset($this->written[$index]);
$found = true;
break;
}
}
Assert::assertTrue($found, "Failed asserting that the string '{$string}' was written to the stream.");
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Streams;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionFailedException;
class ImapStream implements StreamInterface
{
/**
* The underlying PHP stream resource.
*
* @var resource|null
*/
protected mixed $stream = null;
/**
* {@inheritDoc}
*/
public function open(string $transport, string $host, int $port, int $timeout, array $options = []): bool
{
$this->stream = @stream_socket_client(
$address = "{$transport}://{$host}:{$port}",
$errno,
$errstr,
$timeout,
STREAM_CLIENT_CONNECT,
stream_context_create($options)
);
if (! $this->stream) {
throw new ImapConnectionFailedException("Unable to connect to {$address} ({$errstr})", $errno);
}
return true;
}
/**
* {@inheritDoc}
*/
public function close(): void
{
if ($this->opened()) {
fclose($this->stream);
}
$this->stream = null;
}
/**
* {@inheritDoc}
*/
public function read(int $length): string|false
{
if (! $this->opened()) {
return false;
}
$data = '';
while (strlen($data) < $length && ! feof($this->stream)) {
$chunk = fread($this->stream, $length - strlen($data));
if ($chunk === false) {
return false;
}
$data .= $chunk;
}
return $data;
}
/**
* {@inheritDoc}
*/
public function fgets(): string|false
{
return $this->opened() ? fgets($this->stream) : false;
}
/**
* {@inheritDoc}
*/
public function fwrite(string $data): int|false
{
return $this->opened() ? fwrite($this->stream, $data) : false;
}
/**
* {@inheritDoc}
*/
public function meta(): array
{
return $this->opened() ? stream_get_meta_data($this->stream) : [];
}
/**
* {@inheritDoc}
*/
public function opened(): bool
{
return is_resource($this->stream);
}
/**
* {@inheritDoc}
*/
public function setTimeout(int $seconds): bool
{
return stream_set_timeout($this->stream, $seconds);
}
/**
* {@inheritDoc}
*/
public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int
{
return stream_socket_enable_crypto($this->stream, $enabled, $method);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Streams;
interface StreamInterface
{
/**
* Open the underlying stream.
*/
public function open(string $transport, string $host, int $port, int $timeout, array $options = []): bool;
/**
* Close the underlying stream.
*/
public function close(): void;
/**
* Read data from the stream.
*/
public function read(int $length): string|false;
/**
* Read a single line from the stream.
*/
public function fgets(): string|false;
/**
* Write data to the stream.
*/
public function fwrite(string $data): int|false;
/**
* Return meta info (like stream_get_meta_data).
*/
public function meta(): array;
/**
* Determine if the stream is open.
*/
public function opened(): bool;
/**
* Set the timeout on the stream.
*/
public function setTimeout(int $seconds): bool;
/**
* Set encryption state on an already connected socked.
*/
public function setSocketSetCrypto(bool $enabled, ?int $method): bool|int;
}

View File

@@ -0,0 +1,8 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
/**
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-atom
*/
class Atom extends Token {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class Crlf extends Token {}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class EmailAddress extends Token
{
/**
* Get the token's value.
*/
public function __toString(): string
{
return '<'.$this->value.'>';
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ListClose extends Token {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ListOpen extends Token {}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class Literal extends Token
{
/**
* Get the token's value.
*/
public function __toString(): string
{
return sprintf("{%d}\r\n%s", strlen($this->value), $this->value);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
/**
* @see https://datatracker.ietf.org/doc/html/rfc9051#name-nil
*/
class Nil extends Atom {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class Number extends Token {}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class QuotedString extends Token
{
/**
* Get the token's value.
*/
public function __toString(): string
{
return '"'.$this->value.'"';
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ResponseCodeClose extends Token {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
class ResponseCodeOpen extends Token {}

View File

@@ -0,0 +1,39 @@
<?php
namespace DirectoryTree\ImapEngine\Connection\Tokens;
use Stringable;
abstract class Token implements Stringable
{
/**
* Constructor.
*/
public function __construct(
public string $value,
) {}
/**
* Determine if the token is the given value.
*/
public function is(string $value): bool
{
return $this->value === $value;
}
/**
* Determine if the token is not the given value.
*/
public function isNot(string $value): bool
{
return ! $this->is($value);
}
/**
* Get the token's value.
*/
public function __toString(): string
{
return $this->value;
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Enums\ContentDispositionType;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
/**
* @see https://datatracker.ietf.org/doc/html/rfc2183
*/
class ContentDisposition implements Arrayable, JsonSerializable
{
/**
* Constructor.
*/
public function __construct(
protected ContentDispositionType $type,
protected array $parameters = [],
) {}
/**
* Parse the disposition from tokens.
*
* @param array<Token|ListData> $tokens
*/
public static function parse(array $tokens): ?static
{
for ($i = 8; $i < count($tokens); $i++) {
if (! $tokens[$i] instanceof ListData) {
continue;
}
$innerTokens = $tokens[$i]->tokens();
if (! isset($innerTokens[0]) || ! $innerTokens[0] instanceof Token) {
continue;
}
if (! $type = ContentDispositionType::tryFrom(strtolower($innerTokens[0]->value))) {
continue;
}
$parameters = isset($innerTokens[1]) && $innerTokens[1] instanceof ListData
? $innerTokens[1]->toKeyValuePairs()
: [];
return new self($type, $parameters);
}
return null;
}
/**
* Get the disposition type.
*/
public function type(): ContentDispositionType
{
return $this->type;
}
/**
* Get the disposition parameters.
*/
public function parameters(): array
{
return $this->parameters;
}
/**
* Get a specific parameter value.
*/
public function parameter(string $name): ?string
{
return $this->parameters[strtolower($name)] ?? null;
}
/**
* Get the filename parameter.
*/
public function filename(): ?string
{
return $this->parameters['filename'] ?? null;
}
/**
* Determine if this is an attachment disposition.
*/
public function isAttachment(): bool
{
return $this->type === ContentDispositionType::Attachment;
}
/**
* Determine if this is an inline disposition.
*/
public function isInline(): bool
{
return $this->type === ContentDispositionType::Inline;
}
/**
* Get the array representation.
*/
public function toArray(): array
{
return [
'type' => $this->type->value,
'parameters' => $this->parameters,
];
}
/**
* Get the JSON representation.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace DirectoryTree\ImapEngine;
use DateTimeInterface;
use Stringable;
use Symfony\Component\Mime\Email;
class DraftMessage implements Stringable
{
/**
* The underlying Symfony Email instance.
*/
protected Email $message;
/**
* Constructor.
*/
public function __construct(
protected ?string $from = null,
protected array|string $to = [],
protected array|string $cc = [],
protected array|string $bcc = [],
protected ?string $subject = null,
protected ?string $text = null,
protected ?string $html = null,
protected array $headers = [],
protected array $attachments = [],
protected ?DateTimeInterface $date = null,
) {
$this->message = new Email;
if ($this->from) {
$this->message->from($this->from);
}
if ($this->subject) {
$this->message->subject($this->subject);
}
if ($this->text) {
$this->message->text($this->text);
}
if ($this->html) {
$this->message->html($this->html);
}
if ($this->date) {
$this->message->date($this->date);
}
if (! empty($this->to)) {
$this->message->to(...(array) $this->to);
}
if (! empty($this->cc)) {
$this->message->cc(...(array) $this->cc);
}
if (! empty($this->bcc)) {
$this->message->bcc(...(array) $this->bcc);
}
foreach ($this->attachments as $attachment) {
match (true) {
$attachment instanceof Attachment => $this->message->attach(
$attachment->contents(),
$attachment->filename(),
$attachment->contentType()
),
is_resource($attachment) => $this->message->attach($attachment),
default => $this->message->attachFromPath($attachment),
};
}
foreach ($this->headers as $name => $value) {
$this->message->getHeaders()->addTextHeader($name, $value);
}
}
/**
* Get the underlying Symfony Email instance.
*/
public function email(): Email
{
return $this->message;
}
/**
* Get the email as a string.
*/
public function __toString(): string
{
return $this->message->toString();
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
/**
* @see https://datatracker.ietf.org/doc/html/rfc2183
*/
enum ContentDispositionType: string
{
case Inline = 'inline';
case Attachment = 'attachment';
}

View File

@@ -0,0 +1,9 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
enum ImapFetchIdentifier
{
case Uid;
case MessageNumber;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
enum ImapFlag: string
{
case Seen = '\Seen';
case Draft = '\Draft';
case Recent = '\Recent';
case Flagged = '\Flagged';
case Deleted = '\Deleted';
case Answered = '\Answered';
}

View File

@@ -0,0 +1,39 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
enum ImapSearchKey: string
{
case Cc = 'CC';
case On = 'ON';
case To = 'TO';
case All = 'ALL';
case New = 'NEW';
case Old = 'OLD';
case Bcc = 'BCC';
case Uid = 'UID';
case Seen = 'SEEN';
case Body = 'BODY';
case From = 'FROM';
case Text = 'TEXT';
case Draft = 'DRAFT';
case Since = 'SINCE';
case SentOn = 'SENTON';
case SentSince = 'SENTSINCE';
case SentBefore = 'SENTBEFORE';
case Recent = 'RECENT';
case Unseen = 'UNSEEN';
case Before = 'BEFORE';
case Header = 'HEADER';
case Larger = 'LARGER';
case Deleted = 'DELETED';
case Flagged = 'FLAGGED';
case Keyword = 'KEYWORD';
case Unkeyword = 'UNKEYWORD';
case Subject = 'SUBJECT';
case Smaller = 'SMALLER';
case Answered = 'ANSWERED';
case Undeleted = 'UNDELETED';
case Unflagged = 'UNFLAGGED';
case Unanswered = 'UNANSWERED';
}

View File

@@ -0,0 +1,14 @@
<?php
namespace DirectoryTree\ImapEngine\Enums;
enum ImapSortKey: string
{
case Cc = 'CC';
case To = 'TO';
case Date = 'DATE';
case From = 'FROM';
case Size = 'SIZE';
case Arrival = 'ARRIVAL';
case Subject = 'SUBJECT';
}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class Exception extends \Exception {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapCapabilityException extends Exception {}

View File

@@ -0,0 +1,48 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
use DirectoryTree\ImapEngine\Connection\ImapCommand;
use DirectoryTree\ImapEngine\Connection\Responses\Response;
class ImapCommandException extends Exception
{
/**
* The IMAP response.
*/
protected Response $response;
/**
* The failed IMAP command.
*/
protected ImapCommand $command;
/**
* Make a new instance from a failed command and response.
*/
public static function make(ImapCommand $command, Response $response): static
{
$exception = new static(sprintf('IMAP command "%s" failed. Response: "%s"', $command, $response));
$exception->command = $command;
$exception->response = $response;
return $exception;
}
/**
* Get the failed IMAP command.
*/
public function command(): ImapCommand
{
return $this->command;
}
/**
* Get the IMAP response.
*/
public function response(): Response
{
return $this->response;
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapConnectionClosedException extends ImapConnectionException {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
abstract class ImapConnectionException extends Exception {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapConnectionFailedException extends ImapConnectionException {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapConnectionTimedOutException extends ImapConnectionException {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapParserException extends Exception {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapResponseException extends Exception {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class ImapStreamException extends Exception {}

View File

@@ -0,0 +1,5 @@
<?php
namespace DirectoryTree\ImapEngine\Exceptions;
class RuntimeException extends \RuntimeException {}

View File

@@ -0,0 +1,99 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use BadMethodCallException;
class FileMessage implements MessageInterface
{
use HasFlags, HasParsedMessage;
/**
* Constructor.
*/
public function __construct(
protected string $contents
) {}
/**
* {@inheritDoc}
*/
public function uid(): int
{
throw new BadMethodCallException('FileMessage does not support a UID');
}
/**
* {@inheritDoc}
*/
public function size(): ?int
{
return strlen($this->contents);
}
/**
* {@inheritDoc}
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void
{
throw new BadMethodCallException('FileMessage does not support flagging');
}
/**
* Get the string representation of the message.
*/
public function __toString(): string
{
return $this->contents;
}
/**
* Determine if this message is equal to another.
*/
public function is(MessageInterface $message): bool
{
return $message instanceof self
&& $this->contents === $message->contents;
}
/**
* Get the message flags.
*/
public function flags(): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function bodyStructure(): ?BodyStructureCollection
{
return null;
}
/**
* {@inheritDoc}
*/
public function hasBodyStructure(): bool
{
return false;
}
/**
* {@inheritDoc}
*/
public function bodyPart(string $partNumber, bool $peek = true): ?string
{
throw new BadMethodCallException('FileMessage does not support fetching body parts');
}
/**
* Determine if the message is empty.
*/
public function isEmpty(): bool
{
return empty($this->contents);
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
interface FlaggableInterface
{
/**
* Mark the message as read. Alias for markSeen.
*/
public function markRead(): void;
/**
* Mark the message as unread. Alias for unmarkSeen.
*/
public function markUnread(): void;
/**
* Mark the message as seen.
*/
public function markSeen(): void;
/**
* Unmark the seen flag.
*/
public function unmarkSeen(): void;
/**
* Mark the message as answered.
*/
public function markAnswered(): void;
/**
* Unmark the answered flag.
*/
public function unmarkAnswered(): void;
/**
* Mark the message as flagged.
*/
public function markFlagged(): void;
/**
* Unmark the flagged flag.
*/
public function unmarkFlagged(): void;
/**
* Mark the message as deleted.
*/
public function markDeleted(bool $expunge = false): void;
/**
* Unmark the deleted flag.
*/
public function unmarkDeleted(): void;
/**
* Mark the message as a draft.
*/
public function markDraft(): void;
/**
* Unmark the draft flag.
*/
public function unmarkDraft(): void;
/**
* Mark the message as recent.
*/
public function markRecent(): void;
/**
* Unmark the recent flag.
*/
public function unmarkRecent(): void;
/**
* Determine if the message is marked as seen.
*/
public function isSeen(): bool;
/**
* Determine if the message is marked as answered.
*/
public function isAnswered(): bool;
/**
* Determine if the message is flagged.
*/
public function isFlagged(): bool;
/**
* Determine if the message is marked as deleted.
*/
public function isDeleted(): bool;
/**
* Determine if the message is marked as a draft.
*/
public function isDraft(): bool;
/**
* Determine if the message is marked as recent.
*/
public function isRecent(): bool;
/**
* Get the message's flags.
*
* @return string[]
*/
public function flags(): array;
/**
* Determine if the message has the given flag.
*/
public function hasFlag(BackedEnum|string $flag): bool;
/**
* Add or remove a flag from the message.
*
* @param '+'|'-' $operation
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void;
}

View File

@@ -0,0 +1,278 @@
<?php
namespace DirectoryTree\ImapEngine;
use Closure;
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\ItemNotFoundException;
use JsonSerializable;
class Folder implements Arrayable, FolderInterface, JsonSerializable
{
use ComparesFolders;
/**
* Constructor.
*/
public function __construct(
protected Mailbox $mailbox,
protected string $path,
protected array $flags = [],
protected string $delimiter = '/',
) {}
/**
* Get the folder's mailbox.
*/
public function mailbox(): Mailbox
{
return $this->mailbox;
}
/**
* Get the folder path.
*/
public function path(): string
{
return $this->path;
}
/**
* Get the folder flags.
*
* @return string[]
*/
public function flags(): array
{
return $this->flags;
}
/**
* {@inheritDoc}
*/
public function delimiter(): string
{
return $this->delimiter;
}
/**
* {@inheritDoc}
*/
public function name(): string
{
return Str::fromImapUtf7(
last(explode($this->delimiter, $this->path))
);
}
/**
* {@inheritDoc}
*/
public function is(FolderInterface $folder): bool
{
return $this->isSameFolder($this, $folder);
}
/**
* {@inheritDoc}
*/
public function messages(): MessageQuery
{
// Ensure the folder is selected.
$this->select(true);
return new MessageQuery($this, new ImapQueryBuilder);
}
/**
* {@inheritDoc}
*/
public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void
{
if (! in_array('IDLE', $this->mailbox->capabilities())) {
throw new ImapCapabilityException('Unable to IDLE. IMAP server does not support IDLE capability.');
}
// Normalize timeout into a closure.
if (is_callable($timeout) && ! $timeout instanceof Closure) {
$timeout = $timeout(...);
}
// The message query to use when fetching messages.
$query ??= fn (MessageQuery $query) => $query;
// Fetch the message by message number.
$fetch = fn (int $msgn) => (
$query($this->messages())->findOrFail($msgn, ImapFetchIdentifier::MessageNumber)
);
(new Idle(clone $this->mailbox, $this->path, $timeout))->await(
function (int $msgn) use ($callback, $fetch) {
if (! $this->mailbox->connected()) {
$this->mailbox->connect();
}
try {
$message = $fetch($msgn);
} catch (ItemNotFoundException) {
// The message wasn't found. We will skip
// it and continue awaiting new messages.
return;
} catch (Exception) {
// Something else happened. We will attempt
// reconnecting and re-fetching the message.
$this->mailbox->reconnect();
$message = $fetch($msgn);
}
$callback($message);
}
);
}
/**
* {@inheritDoc}
*/
public function poll(callable $callback, ?callable $query = null, callable|int $frequency = 60): void
{
(new Poll(clone $this->mailbox, $this->path, $frequency))->start(
function (MessageInterface $message) use ($callback) {
if (! $this->mailbox->connected()) {
$this->mailbox->connect();
}
try {
$callback($message);
} catch (Exception) {
// Something unexpected happened. We will attempt
// reconnecting and continue polling for messages.
$this->mailbox->reconnect();
}
},
$query ?? fn (MessageQuery $query) => $query
);
}
/**
* {@inheritDoc}
*/
public function move(string $newPath): void
{
$this->mailbox->connection()->rename($this->path, $newPath);
$this->path = $newPath;
}
/**
* {@inheritDoc}
*/
public function select(bool $force = false): void
{
$this->mailbox->select($this, $force);
}
/**
* {@inheritDoc}
*/
public function quota(): array
{
if (! in_array('QUOTA', $this->mailbox->capabilities())) {
throw new ImapCapabilityException(
'Unable to fetch mailbox quotas. IMAP server does not support QUOTA capability.'
);
}
$responses = $this->mailbox->connection()->quotaRoot($this->path);
$values = [];
foreach ($responses as $response) {
$resource = $response->tokenAt(2);
$tokens = $response->tokenAt(3)->tokens();
for ($i = 0; $i + 2 < count($tokens); $i += 3) {
$values[$resource->value][$tokens[$i]->value] = [
'usage' => (int) $tokens[$i + 1]->value,
'limit' => (int) $tokens[$i + 2]->value,
];
}
}
return $values;
}
/**
* {@inheritDoc}
*/
public function status(): array
{
$response = $this->mailbox->connection()->status($this->path);
$tokens = $response->tokenAt(3)->tokens();
$values = [];
// Tokens are expected to alternate between keys and values.
for ($i = 0; $i < count($tokens); $i += 2) {
$values[$tokens[$i]->value] = $tokens[$i + 1]->value;
}
return $values;
}
/**
* {@inheritDoc}
*/
public function examine(): array
{
return $this->mailbox->connection()->examine($this->path)->map(
fn (UntaggedResponse $response) => $response->toArray()
)->all();
}
/**
* {@inheritDoc}
*/
public function expunge(): array
{
return $this->mailbox->connection()->expunge()->map(
fn (UntaggedResponse $response) => $response->tokenAt(1)->value
)->all();
}
/**
* {@inheritDoc}
*/
public function delete(): void
{
$this->mailbox->connection()->delete($this->path);
}
/**
* Get the array representation of the folder.
*/
public function toArray(): array
{
return [
'path' => $this->path,
'flags' => $this->flags,
'delimiter' => $this->delimiter,
];
}
/**
* Get the JSON representation of the folder.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace DirectoryTree\ImapEngine;
interface FolderInterface
{
/**
* Get the folder's mailbox.
*/
public function mailbox(): MailboxInterface;
/**
* Get the folder path.
*/
public function path(): string;
/**
* Get the folder flags.
*
* @return string[]
*/
public function flags(): array;
/**
* Get the folder delimiter.
*/
public function delimiter(): string;
/**
* Get the folder name.
*/
public function name(): string;
/**
* Determine if the current folder is the same as the given.
*/
public function is(FolderInterface $folder): bool;
/**
* Begin querying for messages.
*/
public function messages(): MessageQueryInterface;
/**
* Begin idling on the current folder for the given timeout in seconds.
*/
public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void;
/**
* Begin polling for new messages at the given frequency in seconds.
*/
public function poll(callable $callback, ?callable $query = null, callable|int $frequency = 60): void;
/**
* Move or rename the current folder.
*/
public function move(string $newPath): void;
/**
* Select the current folder.
*/
public function select(bool $force = false): void;
/**
* Get the folder's quotas.
*/
public function quota(): array;
/**
* Get the folder's status.
*/
public function status(): array;
/**
* Examine the current folder and get detailed status information.
*/
public function examine(): array;
/**
* Expunge the mailbox and return the expunged message sequence numbers.
*/
public function expunge(): array;
/**
* Delete the current folder.
*/
public function delete(): void;
}

View File

@@ -0,0 +1,68 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Collections\FolderCollection;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Support\Str;
class FolderRepository implements FolderRepositoryInterface
{
/**
* Constructor.
*/
public function __construct(
protected Mailbox $mailbox
) {}
/**
* {@inheritDoc}
*/
public function find(string $path): ?FolderInterface
{
return $this->get($path)->first();
}
/**
* {@inheritDoc}
*/
public function findOrFail(string $path): FolderInterface
{
return $this->get($path)->firstOrFail();
}
/**
* {@inheritDoc}
*/
public function create(string $path): FolderInterface
{
$this->mailbox->connection()->create(
Str::toImapUtf7($path)
);
return $this->find($path);
}
/**
* {@inheritDoc}
*/
public function firstOrCreate(string $path): FolderInterface
{
return $this->find($path) ?? $this->create($path);
}
/**
* {@inheritDoc}
*/
public function get(?string $match = '*', ?string $reference = ''): FolderCollection
{
return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match))->map(
fn (UntaggedResponse $response) => new Folder(
mailbox: $this->mailbox,
path: $response->tokenAt(4)->value,
flags: $response->tokenAt(2)->values(),
delimiter: $response->tokenAt(3)->value,
)
)->pipeInto(FolderCollection::class);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Collections\FolderCollection;
interface FolderRepositoryInterface
{
/**
* Find a folder.
*/
public function find(string $path): ?FolderInterface;
/**
* Find a folder or throw an exception.
*/
public function findOrFail(string $path): FolderInterface;
/**
* Create a new folder.
*/
public function create(string $path): FolderInterface;
/**
* Find or create a folder.
*/
public function firstOrCreate(string $path): FolderInterface;
/**
* Get the mailboxes folders.
*/
public function get(?string $match = '*', ?string $reference = ''): FolderCollection;
}

View File

@@ -0,0 +1,188 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use DirectoryTree\ImapEngine\Enums\ImapFlag;
use DirectoryTree\ImapEngine\Support\Str;
trait HasFlags
{
/**
* {@inheritDoc}
*/
public function markRead(): void
{
$this->markSeen();
}
/**
* {@inheritDoc}
*/
public function markUnread(): void
{
$this->unmarkSeen();
}
/**
* {@inheritDoc}
*/
public function markSeen(): void
{
$this->flag(ImapFlag::Seen, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkSeen(): void
{
$this->flag(ImapFlag::Seen, '-');
}
/**
* {@inheritDoc}
*/
public function markAnswered(): void
{
$this->flag(ImapFlag::Answered, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkAnswered(): void
{
$this->flag(ImapFlag::Answered, '-');
}
/**
* {@inheritDoc}
*/
public function markFlagged(): void
{
$this->flag(ImapFlag::Flagged, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkFlagged(): void
{
$this->flag(ImapFlag::Flagged, '-');
}
/**
* {@inheritDoc}
*/
public function markDeleted(bool $expunge = false): void
{
$this->flag(ImapFlag::Deleted, '+', $expunge);
}
/**
* {@inheritDoc}
*/
public function unmarkDeleted(): void
{
$this->flag(ImapFlag::Deleted, '-');
}
/**
* {@inheritDoc}
*/
public function markDraft(): void
{
$this->flag(ImapFlag::Draft, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkDraft(): void
{
$this->flag(ImapFlag::Draft, '-');
}
/**
* {@inheritDoc}
*/
public function markRecent(): void
{
$this->flag(ImapFlag::Recent, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkRecent(): void
{
$this->flag(ImapFlag::Recent, '-');
}
/**
* {@inheritDoc}
*/
public function isSeen(): bool
{
return $this->hasFlag(ImapFlag::Seen);
}
/**
* {@inheritDoc}
*/
public function isAnswered(): bool
{
return $this->hasFlag(ImapFlag::Answered);
}
/**
* {@inheritDoc}
*/
public function isFlagged(): bool
{
return $this->hasFlag(ImapFlag::Flagged);
}
/**
* {@inheritDoc}
*/
public function isDeleted(): bool
{
return $this->hasFlag(ImapFlag::Deleted);
}
/**
* {@inheritDoc}
*/
public function isDraft(): bool
{
return $this->hasFlag(ImapFlag::Draft);
}
/**
* {@inheritDoc}
*/
public function isRecent(): bool
{
return $this->hasFlag(ImapFlag::Recent);
}
/**
* {@inheritDoc}
*/
public function hasFlag(BackedEnum|string $flag): bool
{
return in_array(Str::enum($flag), $this->flags());
}
/**
* {@inheritDoc}
*/
abstract public function flags(): array;
/**
* {@inheritDoc}
*/
abstract public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void;
}

View File

@@ -0,0 +1,258 @@
<?php
namespace DirectoryTree\ImapEngine;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
use GuzzleHttp\Psr7\Utils;
use ZBateson\MailMimeParser\Header\DateHeader;
use ZBateson\MailMimeParser\Header\HeaderConsts;
use ZBateson\MailMimeParser\Header\IHeader;
use ZBateson\MailMimeParser\Header\IHeaderPart;
use ZBateson\MailMimeParser\Header\Part\AddressPart;
use ZBateson\MailMimeParser\Header\Part\ContainerPart;
use ZBateson\MailMimeParser\Header\Part\NameValuePart;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\Message\IMessagePart;
trait HasParsedMessage
{
/**
* The parsed message.
*/
protected ?IMessage $parsed = null;
/**
* Get the message date and time.
*/
public function date(): ?CarbonInterface
{
if (! $header = $this->header(HeaderConsts::DATE)) {
return null;
}
if (! $header instanceof DateHeader) {
return null;
}
if (! $date = $header->getDateTime()) {
return null;
}
return Carbon::instance($date);
}
/**
* Get the message's message-id.
*/
public function messageId(): ?string
{
return $this->header(HeaderConsts::MESSAGE_ID)?->getValue();
}
/**
* Get the message's subject.
*/
public function subject(): ?string
{
return $this->header(HeaderConsts::SUBJECT)?->getValue();
}
/**
* Get the FROM address.
*/
public function from(): ?Address
{
return head($this->addresses(HeaderConsts::FROM)) ?: null;
}
/**
* Get the SENDER address.
*/
public function sender(): ?Address
{
return head($this->addresses(HeaderConsts::SENDER)) ?: null;
}
/**
* Get the REPLY-TO address.
*/
public function replyTo(): ?Address
{
return head($this->addresses(HeaderConsts::REPLY_TO)) ?: null;
}
/**
* Get the IN-REPLY-TO message identifier(s).
*
* @return string[]
*/
public function inReplyTo(): array
{
$parts = $this->header(HeaderConsts::IN_REPLY_TO)?->getParts() ?? [];
$values = array_map(function (IHeaderPart $part) {
return $part->getValue();
}, $parts);
return array_values(array_filter($values));
}
/**
* Get the TO addresses.
*
* @return Address[]
*/
public function to(): array
{
return $this->addresses(HeaderConsts::TO);
}
/**
* Get the CC addresses.
*
* @return Address[]
*/
public function cc(): array
{
return $this->addresses(HeaderConsts::CC);
}
/**
* Get the BCC addresses.
*
* @return Address[]
*/
public function bcc(): array
{
return $this->addresses(HeaderConsts::BCC);
}
/**
* Get the message's attachments.
*
* @return Attachment[]
*/
public function attachments(): array
{
$attachments = [];
foreach ($this->parse()->getAllAttachmentParts() as $part) {
if ($this->isForwardedMessage($part)) {
$message = new FileMessage($part->getContent());
$attachments = array_merge($attachments, $message->attachments());
} else {
$attachments[] = new Attachment(
$part->getFilename(),
$part->getContentId(),
$part->getContentType(),
$part->getContentDisposition(),
$part->getBinaryContentStream() ?? Utils::streamFor(''),
);
}
}
return $attachments;
}
/**
* Determine if the message has attachments.
*/
public function hasAttachments(): bool
{
return $this->attachmentCount() > 0;
}
/**
* Get the count of attachments.
*/
public function attachmentCount(): int
{
return $this->parse()->getAttachmentCount();
}
/**
* Determine if the attachment should be treated as an embedded forwarded message.
*/
protected function isForwardedMessage(IMessagePart $part): bool
{
return empty($part->getFilename())
&& strtolower((string) $part->getContentType()) === 'message/rfc822'
&& strtolower((string) $part->getContentDisposition()) !== 'attachment';
}
/**
* Get addresses from the given header.
*
* @return Address[]
*/
public function addresses(string $header): array
{
$parts = $this->header($header)?->getParts() ?? [];
$addresses = array_map(fn (IHeaderPart $part) => match (true) {
$part instanceof AddressPart => new Address($part->getEmail(), $part->getName()),
$part instanceof NameValuePart => new Address($part->getName(), $part->getValue()),
$part instanceof ContainerPart => new Address($part->getValue(), ''),
default => null,
}, $parts);
return array_filter($addresses);
}
/**
* Get the message's HTML content.
*/
public function html(): ?string
{
return $this->parse()->getHtmlContent();
}
/**
* Get the message's text content.
*/
public function text(): ?string
{
return $this->parse()->getTextContent();
}
/**
* Get all headers from the message.
*/
public function headers(): array
{
return $this->parse()->getAllHeaders();
}
/**
* Get a header from the message.
*/
public function header(string $name, int $offset = 0): ?IHeader
{
return $this->parse()->getHeader($name, $offset);
}
/**
* Parse the message into a MailMimeMessage instance.
*/
public function parse(): IMessage
{
if ($this->isEmpty()) {
throw new RuntimeException('Cannot parse an empty message');
}
return $this->parsed ??= MessageParser::parse((string) $this);
}
/**
* Determine if the message is empty.
*/
abstract public function isEmpty(): bool;
/**
* Get the string representation of the message.
*/
abstract public function __toString(): string;
}

View File

@@ -0,0 +1,173 @@
<?php
namespace DirectoryTree\ImapEngine;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Closure;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Atom;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionTimedOutException;
use Generator;
class Idle
{
/**
* Constructor.
*/
public function __construct(
protected Mailbox $mailbox,
protected string $folder,
protected Closure|int $timeout,
) {}
/**
* Destructor.
*/
public function __destruct()
{
$this->disconnect();
}
/**
* Await new messages on the connection.
*/
public function await(callable $callback): void
{
$this->connect();
while ($ttl = $this->getNextTimeout()) {
try {
$this->listen($callback, $ttl);
} catch (ImapConnectionTimedOutException) {
$this->restart();
} catch (ImapConnectionClosedException) {
$this->reconnect();
}
}
}
/**
* Start listening for new messages using the idle() generator.
*/
protected function listen(callable $callback, CarbonInterface $ttl): void
{
// Iterate over responses yielded by the idle generator.
foreach ($this->idle($ttl) as $response) {
if (! $response instanceof UntaggedResponse) {
continue;
}
if (! $token = $response->tokenAt(2)) {
continue;
}
if ($token instanceof Atom && $token->is('EXISTS')) {
$msgn = (int) $response->tokenAt(1)->value;
$callback($msgn);
$ttl = $this->getNextTimeout();
}
if ($ttl === false) {
break;
}
// If we've been idle too long, break out to restart the session.
if (Carbon::now()->greaterThanOrEqualTo($ttl)) {
$this->restart();
break;
}
}
}
/**
* Get the folder to idle.
*/
protected function folder(): FolderInterface
{
return $this->mailbox->folders()->findOrFail($this->folder);
}
/**
* Issue a done command and restart the idle session.
*/
protected function restart(): void
{
try {
// Send DONE to terminate the current IDLE session gracefully.
$this->done();
} catch (Exception) {
$this->reconnect();
}
}
/**
* Reconnect the client and restart the idle session.
*/
protected function reconnect(): void
{
$this->mailbox->disconnect();
$this->connect();
}
/**
* Connect the client and select the folder to idle.
*/
protected function connect(): void
{
$this->mailbox->connect();
$this->mailbox->select($this->folder(), true);
}
/**
* Disconnect the client.
*/
protected function disconnect(): void
{
try {
// Attempt to terminate IDLE gracefully.
$this->done();
} catch (Exception) {
// Do nothing.
}
$this->mailbox->disconnect();
}
/**
* End the current IDLE session.
*/
protected function done(): void
{
$this->mailbox->connection()->done();
}
/**
* Begin a new IDLE session as a generator.
*/
protected function idle(CarbonInterface $ttl): Generator
{
yield from $this->mailbox->connection()->idle(
(int) Carbon::now()->diffInSeconds($ttl, true)
);
}
/**
* Get the next timeout as a Carbon instance.
*/
protected function getNextTimeout(): CarbonInterface|false
{
if (is_numeric($seconds = value($this->timeout))) {
return Carbon::now()->addSeconds(abs($seconds));
}
return false;
}
}

View File

@@ -0,0 +1,232 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Connection\ImapConnection;
use DirectoryTree\ImapEngine\Connection\Loggers\EchoLogger;
use DirectoryTree\ImapEngine\Connection\Loggers\FileLogger;
use DirectoryTree\ImapEngine\Connection\Streams\ImapStream;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use Exception;
class Mailbox implements MailboxInterface
{
/**
* The mailbox configuration.
*/
protected array $config = [
'port' => 993,
'host' => '',
'timeout' => 30,
'debug' => false,
'username' => '',
'password' => '',
'encryption' => 'ssl',
'validate_cert' => true,
'authentication' => 'plain',
'proxy' => [
'socket' => null,
'username' => null,
'password' => null,
'request_fulluri' => false,
],
];
/**
* The cached mailbox capabilities.
*
* @see https://datatracker.ietf.org/doc/html/rfc9051#section-6.1.1
*/
protected ?array $capabilities = null;
/**
* The currently selected folder.
*/
protected ?FolderInterface $selected = null;
/**
* The mailbox connection.
*/
protected ?ConnectionInterface $connection = null;
/**
* Constructor.
*/
public function __construct(array $config = [])
{
$this->config = array_merge($this->config, $config);
}
/**
* Prepare the cloned instance.
*/
public function __clone(): void
{
$this->connection = null;
}
/**
* Make a new mailbox instance.
*/
public static function make(array $config = []): static
{
return new static($config);
}
/**
* {@inheritDoc}
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if (is_null($key)) {
return $this->config;
}
return data_get($this->config, $key, $default);
}
/**
* {@inheritDoc}
*/
public function connection(): ConnectionInterface
{
if (! $this->connection) {
$this->connect();
}
return $this->connection;
}
/**
* {@inheritDoc}
*/
public function connected(): bool
{
return (bool) $this->connection?->connected();
}
/**
* {@inheritDoc}
*/
public function reconnect(): void
{
$this->disconnect();
$this->connect();
}
/**
* {@inheritDoc}
*/
public function connect(?ConnectionInterface $connection = null): void
{
if ($this->connected()) {
return;
}
$debug = $this->config('debug');
$this->connection = $connection ?? new ImapConnection(new ImapStream, match (true) {
class_exists($debug) => new $debug,
is_string($debug) => new FileLogger($debug),
is_bool($debug) && $debug => new EchoLogger,
default => null,
});
$this->connection->connect($this->config('host'), $this->config('port'), [
'proxy' => $this->config('proxy'),
'debug' => $this->config('debug'),
'timeout' => $this->config('timeout'),
'encryption' => $this->config('encryption'),
'validate_cert' => $this->config('validate_cert'),
]);
$this->authenticate();
}
/**
* Authenticate the current session.
*/
protected function authenticate(): void
{
if ($this->config('authentication') === 'oauth') {
$this->connection->authenticate(
$this->config('username'),
$this->config('password')
);
} else {
$this->connection->login(
$this->config('username'),
$this->config('password'),
);
}
}
/**
* {@inheritDoc}
*/
public function disconnect(): void
{
try {
$this->connection?->logout();
$this->connection?->disconnect();
} catch (Exception) {
// Do nothing.
} finally {
$this->connection = null;
}
}
/**
* {@inheritDoc}
*/
public function inbox(): FolderInterface
{
// "INBOX" is a special name reserved for the user's primary mailbox.
// See: https://datatracker.ietf.org/doc/html/rfc9051#section-5.1
return $this->folders()->find('INBOX');
}
/**
* {@inheritDoc}
*/
public function folders(): FolderRepositoryInterface
{
// Ensure the connection is established.
$this->connection();
return new FolderRepository($this);
}
/**
* {@inheritDoc}
*/
public function capabilities(): array
{
return $this->capabilities ??= array_map(
fn (Token $token) => $token->value,
$this->connection()->capability()->tokensAfter(2)
);
}
/**
* {@inheritDoc}
*/
public function select(FolderInterface $folder, bool $force = false): void
{
if (! $this->selected($folder) || $force) {
$this->connection()->select($folder->path());
}
$this->selected = $folder;
}
/**
* {@inheritDoc}
*/
public function selected(FolderInterface $folder): bool
{
return $this->selected?->is($folder) ?? false;
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
interface MailboxInterface
{
/**
* Get mailbox configuration values.
*/
public function config(?string $key = null, mixed $default = null): mixed;
/**
* Get the mailbox connection.
*/
public function connection(): ConnectionInterface;
/**
* Determine if connection was established.
*/
public function connected(): bool;
/**
* Force a reconnection to the server.
*/
public function reconnect(): void;
/**
* Connect to the server.
*/
public function connect(?ConnectionInterface $connection = null): void;
/**
* Disconnect from server.
*/
public function disconnect(): void;
/**
* Get the mailbox's inbox folder.
*/
public function inbox(): FolderInterface;
/**
* Begin querying for mailbox folders.
*/
public function folders(): FolderRepositoryInterface;
/**
* Get the mailbox's capabilities.
*/
public function capabilities(): array;
/**
* Select the given folder.
*/
public function select(FolderInterface $folder, bool $force = false): void;
/**
* Determine if the given folder is selected.
*/
public function selected(FolderInterface $folder): bool;
}

View File

@@ -0,0 +1,50 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
use Generator;
class Mbox
{
/**
* Constructor.
*/
public function __construct(
protected string $filepath
) {}
/**
* Get the messages from the mbox file.
*/
public function messages(
string $delimiter = '/^From\s+\S+\s+' // From
.'(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+' // Day
.'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+' // Month
.'\d{1,2}\s+\d{2}:\d{2}:\d{2}' // Time (HH:MM:SS)
.'(?:\s+[+-]\d{4})?' // Optional Timezone ("+0000")
.'\s+\d{4}/' // Year
): Generator {
if (! $handle = fopen($this->filepath, 'r')) {
throw new RuntimeException('Failed to open mbox file: '.$this->filepath);
}
$buffer = '';
while (($line = fgets($handle)) !== false) {
if (preg_match($delimiter, $line) && $buffer !== '') {
yield new FileMessage($buffer);
$buffer = '';
}
$buffer .= $line;
}
if ($buffer !== '') {
yield new FileMessage($buffer);
}
fclose($handle);
}
}

View File

@@ -0,0 +1,299 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\MessageResponseParser;
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;
class Message implements Arrayable, JsonSerializable, MessageInterface
{
use HasFlags, HasParsedMessage;
/**
* The parsed body structure.
*/
protected ?BodyStructureCollection $bodyStructure = null;
/**
* Constructor.
*/
public function __construct(
protected FolderInterface $folder,
protected int $uid,
protected array $flags,
protected string $head,
protected string $body,
protected ?int $size = null,
protected ?ListData $bodyStructureData = null,
) {}
/**
* Get the names of properties that should be serialized.
*/
public function __sleep(): array
{
// We don't want to serialize the parsed message.
return ['folder', 'uid', 'flags', 'head', 'body', 'size'];
}
/**
* Get the message's folder.
*/
public function folder(): FolderInterface
{
return $this->folder;
}
/**
* Get the message's identifier.
*/
public function uid(): int
{
return $this->uid;
}
/**
* Get the message's size in bytes (RFC822.SIZE).
*/
public function size(): ?int
{
return $this->size;
}
/**
* Get the message's flags.
*/
public function flags(): array
{
return $this->flags;
}
/**
* Get the message's raw headers.
*/
public function head(): string
{
return $this->head;
}
/**
* Determine if the message has headers.
*/
public function hasHead(): bool
{
return ! empty($this->head);
}
/**
* Get the message's raw body.
*/
public function body(): string
{
return $this->body;
}
/**
* Determine if the message has contents.
*/
public function hasBody(): bool
{
return ! empty($this->body);
}
/**
* Get the message's body structure.
*/
public function bodyStructure(): ?BodyStructureCollection
{
if ($this->bodyStructure) {
return $this->bodyStructure;
}
if (! $tokens = $this->bodyStructureData?->tokens()) {
return null;
}
// If the first token is a list, it's a multipart message.
return $this->bodyStructure = head($tokens) instanceof ListData
? BodyStructureCollection::fromListData($this->bodyStructureData)
: new BodyStructureCollection(parts: [BodyStructurePart::fromListData($this->bodyStructureData)]);
}
/**
* Determine if the message has body structure data.
*/
public function hasBodyStructure(): bool
{
return (bool) $this->bodyStructureData;
}
/**
* {@inheritDoc}
*/
public function is(MessageInterface $message): bool
{
return $message instanceof self
&& $this->uid === $message->uid
&& $this->folder->is($message->folder);
}
/**
* Add or remove a flag from the message.
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void
{
$flag = Str::enum($flag);
$this->folder->mailbox()
->connection()
->store($flag, $this->uid, mode: $operation);
if ($expunge) {
$this->folder->expunge();
}
$this->flags = match ($operation) {
'+' => array_unique(array_merge($this->flags, [$flag])),
'-' => array_diff($this->flags, [$flag]),
};
}
/**
* Copy the message to the given folder.
*/
public function copy(string $folder): ?int
{
$mailbox = $this->folder->mailbox();
$capabilities = $mailbox->capabilities();
if (! in_array('UIDPLUS', $capabilities)) {
throw new ImapCapabilityException(
'Unable to copy message. IMAP server does not support UIDPLUS capability'
);
}
$response = $mailbox->connection()->copy($folder, $this->uid);
return MessageResponseParser::getUidFromCopy($response);
}
/**
* Move the message to the given folder.
*
* @throws ImapCapabilityException
*/
public function move(string $folder, bool $expunge = false): ?int
{
$mailbox = $this->folder->mailbox();
$capabilities = $mailbox->capabilities();
switch (true) {
case in_array('MOVE', $capabilities):
$response = $mailbox->connection()->move($folder, $this->uid);
if ($expunge) {
$this->folder->expunge();
}
return MessageResponseParser::getUidFromCopy($response);
case in_array('UIDPLUS', $capabilities):
$uid = $this->copy($folder);
$this->delete($expunge);
return $uid;
default:
throw new ImapCapabilityException(
'Unable to move message. IMAP server does not support MOVE or UIDPLUS capabilities'
);
}
}
/**
* Fetch a specific body part by part number.
*/
public function bodyPart(string $partNumber, bool $peek = true): ?string
{
$response = $this->folder->mailbox()
->connection()
->bodyPart($partNumber, $this->uid, $peek);
if ($response->isEmpty()) {
return null;
}
$data = $response->first()->tokenAt(3);
if (! $data instanceof ListData) {
return null;
}
return $data->lookup("[$partNumber]")?->value;
}
/**
* Delete the message.
*/
public function delete(bool $expunge = false): void
{
$this->markDeleted($expunge);
}
/**
* Restore the message.
*/
public function restore(): void
{
$this->unmarkDeleted();
}
/**
* Get the array representation of the message.
*/
public function toArray(): array
{
return [
'uid' => $this->uid,
'flags' => $this->flags,
'head' => $this->head,
'body' => $this->body,
'size' => $this->size,
];
}
/**
* Get the string representation of the message.
*/
public function __toString(): string
{
return implode("\r\n\r\n", array_filter([
rtrim($this->head),
ltrim($this->body),
]));
}
/**
* Get the JSON representation of the message.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
/**
* Determine if the message is empty.
*/
public function isEmpty(): bool
{
return ! $this->hasHead() && ! $this->hasBody();
}
}

View File

@@ -0,0 +1,154 @@
<?php
namespace DirectoryTree\ImapEngine;
use Carbon\CarbonInterface;
use Stringable;
use ZBateson\MailMimeParser\Header\IHeader;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\Message as MailMimeMessage;
interface MessageInterface extends FlaggableInterface, Stringable
{
/**
* Get the message's identifier.
*/
public function uid(): int;
/**
* Get the message's size in bytes (RFC822.SIZE).
*/
public function size(): ?int;
/**
* Get the message date and time.
*/
public function date(): ?CarbonInterface;
/**
* Get the message's subject.
*/
public function subject(): ?string;
/**
* Get the 'From' address.
*/
public function from(): ?Address;
/**
* Get the 'Sender' address.
*/
public function sender(): ?Address;
/**
* Get the message's 'Message-ID'.
*/
public function messageId(): ?string;
/**
* Get the 'Reply-To' address.
*/
public function replyTo(): ?Address;
/**
* Get the 'In-Reply-To' message identifier(s).
*
* @return string[]
*/
public function inReplyTo(): array;
/**
* Get the 'To' addresses.
*
* @return Address[]
*/
public function to(): array;
/**
* Get the 'CC' addresses.
*
* @return Address[]
*/
public function cc(): array;
/**
* Get the 'BCC' addresses.
*
* @return Address[]
*/
public function bcc(): array;
/**
* Get the message's attachments.
*
* @return Attachment[]
*/
public function attachments(): array;
/**
* Determine if the message has attachments.
*/
public function hasAttachments(): bool;
/**
* Get the count of attachments.
*/
public function attachmentCount(): int;
/**
* Get addresses from the given header.
*
* @return Address[]
*/
public function addresses(string $header): array;
/**
* Get the message's HTML content.
*/
public function html(): ?string;
/**
* Get the message's text content.
*/
public function text(): ?string;
/**
* Get all headers from the message.
*/
public function headers(): array;
/**
* Get a header from the message.
*/
public function header(string $name, int $offset = 0): ?IHeader;
/**
* Parse the message into a MailMimeMessage instance.
*/
public function parse(): IMessage;
/**
* Get the message's body structure.
*/
public function bodyStructure(): ?BodyStructureCollection;
/**
* Determine if the message has body structure data.
*/
public function hasBodyStructure(): bool;
/**
* Fetch a specific body part by part number.
*/
public function bodyPart(string $partNumber, bool $peek = true): ?string;
/**
* Determine if the message is the same as another message.
*/
public function is(MessageInterface $message): bool;
/**
* Get the string representation of the message.
*/
public function __toString(): string;
}

View File

@@ -0,0 +1,30 @@
<?php
namespace DirectoryTree\ImapEngine;
use ZBateson\MailMimeParser\IMessage;
use ZBateson\MailMimeParser\MailMimeParser;
class MessageParser
{
/**
* The mail mime parser instance.
*/
protected static ?MailMimeParser $parser = null;
/**
* Parse the given message contents.
*/
public static function parse(string $contents): IMessage
{
return static::parser()->parse($contents, true);
}
/**
* Get the mail mime parser instance.
*/
protected static function parser(): MailMimeParser
{
return static::$parser ??= new MailMimeParser;
}
}

View File

@@ -0,0 +1,521 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use DirectoryTree\ImapEngine\Collections\MessageCollection;
use DirectoryTree\ImapEngine\Collections\ResponseCollection;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData;
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
use DirectoryTree\ImapEngine\Connection\Tokens\Token;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapFlag;
use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
use DirectoryTree\ImapEngine\Exceptions\ImapCommandException;
use DirectoryTree\ImapEngine\Exceptions\RuntimeException;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Support\Collection;
use Illuminate\Support\ItemNotFoundException;
/**
* @mixin \DirectoryTree\ImapEngine\Connection\ImapQueryBuilder
*/
class MessageQuery implements MessageQueryInterface
{
use QueriesMessages;
/**
* Constructor.
*/
public function __construct(
protected FolderInterface $folder,
protected ImapQueryBuilder $query,
) {}
/**
* Count all available messages matching the current search criteria.
*/
public function count(): int
{
return $this->search()->count();
}
/**
* Get the first message in the resulting collection.
*/
public function first(): ?MessageInterface
{
try {
return $this->firstOrFail();
} catch (ItemNotFoundException) {
return null;
}
}
/**
* Get the first message in the resulting collection or throw an exception.
*/
public function firstOrFail(): MessageInterface
{
return $this->limit(1)->get()->firstOrFail();
}
/**
* Get the messages matching the current query.
*/
public function get(): MessageCollection
{
return $this->process($this->sortKey ? $this->sort() : $this->search());
}
/**
* Append a new message to the folder.
*/
public function append(string $message, mixed $flags = null): int
{
$response = $this->connection()->append(
$this->folder->path(), $message, (array) Str::enums($flags),
);
return (int) $response // TAG4 OK [APPENDUID <uidvalidity> <uid>] APPEND completed.
->tokenAt(2) // [APPENDUID <uidvalidity> <uid>]
->tokenAt(2) // <uid>
->value;
}
/**
* Execute a callback over each message via a chunked query.
*/
public function each(callable $callback, int $chunkSize = 10, int $startChunk = 1): void
{
$this->chunk(function (MessageCollection $messages) use ($callback) {
foreach ($messages as $key => $message) {
if ($callback($message, $key) === false) {
return false;
}
}
}, $chunkSize, $startChunk);
}
/**
* Execute a callback over each chunk of messages.
*/
public function chunk(callable $callback, int $chunkSize = 10, int $startChunk = 1): void
{
$startChunk = max($startChunk, 1);
$chunkSize = max($chunkSize, 1);
// Get all search result tokens once.
$messages = $this->search();
// Calculate how many chunks there are
$totalChunks = (int) ceil($messages->count() / $chunkSize);
// If startChunk is beyond our total chunks, return early.
if ($startChunk > $totalChunks) {
return;
}
// Save previous state to restore later.
$previousLimit = $this->limit;
$previousPage = $this->page;
$this->limit = $chunkSize;
// Iterate from the starting chunk to the last chunk.
for ($page = $startChunk; $page <= $totalChunks; $page++) {
$this->page = $page;
// populate() will use $this->page to slice the results.
$hydrated = $this->populate($messages);
// If no messages are returned, break out to prevent infinite loop.
if ($hydrated->isEmpty()) {
break;
}
// If the callback returns false, break out.
if ($callback($hydrated, $page) === false) {
break;
}
}
// Restore the original state.
$this->limit = $previousLimit;
$this->page = $previousPage;
}
/**
* Paginate the current query.
*/
public function paginate(int $perPage = 5, $page = null, string $pageName = 'page'): LengthAwarePaginator
{
if (is_null($page) && isset($_GET[$pageName]) && $_GET[$pageName] > 0) {
$this->page = intval($_GET[$pageName]);
} elseif ($page > 0) {
$this->page = (int) $page;
}
$this->limit = $perPage;
return $this->get()->paginate($perPage, $this->page, $pageName, true);
}
/**
* Find a message by the given identifier type or throw an exception.
*/
public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface
{
/** @var UntaggedResponse $response */
$response = $this->id($id, $identifier)->firstOrFail();
$uid = $response->tokenAt(3) // ListData
->tokenAt(1) // Atom
->value; // UID
return $this->process(new MessageCollection([$uid]))->firstOrFail();
}
/**
* Find a message by the given identifier type.
*/
public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface
{
$response = $this->id($id, $identifier)->first();
if (! $response instanceof UntaggedResponse) {
return null;
}
$uid = $response->tokenAt(3) // ListData
->tokenAt(1) // Atom
->value; // UID
return $this->process(new MessageCollection([$uid]))->first();
}
/**
* Destroy the given messages.
*/
public function destroy(array|int $uids, bool $expunge = false): void
{
$uids = (array) $uids;
$this->folder->mailbox()
->connection()
->store([ImapFlag::Deleted->value], $uids, mode: '+');
if ($expunge) {
$this->folder->expunge();
}
}
/**
* {@inheritDoc}
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): int
{
$uids = $this->search()->all();
if (empty($uids)) {
return 0;
}
$this->connection()->store(
(array) Str::enums($flag),
$uids,
mode: $operation
);
if ($expunge) {
$this->folder->expunge();
}
return count($uids);
}
/**
* {@inheritDoc}
*/
public function markRead(): int
{
return $this->flag(ImapFlag::Seen, '+');
}
/**
* {@inheritDoc}
*/
public function markUnread(): int
{
return $this->flag(ImapFlag::Seen, '-');
}
/**
* {@inheritDoc}
*/
public function markFlagged(): int
{
return $this->flag(ImapFlag::Flagged, '+');
}
/**
* {@inheritDoc}
*/
public function unmarkFlagged(): int
{
return $this->flag(ImapFlag::Flagged, '-');
}
/**
* {@inheritDoc}
*/
public function delete(bool $expunge = false): int
{
return $this->flag(ImapFlag::Deleted, '+', $expunge);
}
/**
* {@inheritDoc}
*/
public function move(string $folder, bool $expunge = false): int
{
$uids = $this->search()->all();
if (empty($uids)) {
return 0;
}
$this->connection()->move($folder, $uids);
if ($expunge) {
$this->folder->expunge();
}
return count($uids);
}
/**
* {@inheritDoc}
*/
public function copy(string $folder): int
{
$uids = $this->search()->all();
if (empty($uids)) {
return 0;
}
$this->connection()->copy($folder, $uids);
return count($uids);
}
/**
* Process the collection of messages.
*/
protected function process(Collection $messages): MessageCollection
{
if ($messages->isNotEmpty()) {
return $this->populate($messages);
}
return MessageCollection::make();
}
/**
* Populate a given id collection and receive a fully fetched message collection.
*/
protected function populate(Collection $uids): MessageCollection
{
$messages = MessageCollection::make();
$messages->total($uids->count());
foreach ($this->fetch($uids) as $uid => $response) {
$messages->push(
$this->newMessage(
$uid,
$response['flags'] ?? [],
$response['head'] ?? '',
$response['body'] ?? '',
$response['size'] ?? null,
$response['bodystructure'] ?? null,
)
);
}
return $messages;
}
/**
* Fetch a given id collection.
*/
protected function fetch(Collection $messages): array
{
// Only apply client-side sorting when not using server-side sorting.
// When sortKey is set, the IMAP SORT command already returns UIDs
// in the correct order, so we should preserve that order.
if (! $this->sortKey) {
$messages = match ($this->fetchOrder) {
'asc' => $messages->sort(SORT_NUMERIC),
'desc' => $messages->sortDesc(SORT_NUMERIC),
};
}
$uids = $messages->forPage($this->page, $this->limit)->values();
$fetch = [];
if ($this->fetchFlags) {
$fetch[] = 'FLAGS';
}
if ($this->fetchSize) {
$fetch[] = 'RFC822.SIZE';
}
if ($this->fetchHeaders) {
$fetch[] = $this->fetchAsUnread
? 'BODY.PEEK[HEADER]'
: 'BODY[HEADER]';
}
if ($this->fetchBody) {
$fetch[] = $this->fetchAsUnread
? 'BODY.PEEK[TEXT]'
: 'BODY[TEXT]';
}
if ($this->fetchBodyStructure) {
$fetch[] = 'BODYSTRUCTURE';
}
if (empty($fetch)) {
return $uids->mapWithKeys(fn (string|int $uid) => [
$uid => [
'size' => null,
'flags' => [],
'head' => '',
'body' => '',
'bodystructure' => null,
],
])->all();
}
return $this->connection()->fetch($fetch, $uids->all())->mapWithKeys(function (UntaggedResponse $response) {
$data = $response->tokenAt(3);
if (! $data instanceof ListData) {
throw new RuntimeException(sprintf(
'Expected instance of %s at index 3 in FETCH response, got %s',
ListData::class,
get_debug_type($data)
));
}
$uid = $data->lookup('UID')->value;
$size = $data->lookup('RFC822.SIZE')?->value;
return [
$uid => [
'size' => $size ? (int) $size : null,
'flags' => $data->lookup('FLAGS')?->values() ?? [],
'head' => $data->lookup('[HEADER]')->value ?? '',
'body' => $data->lookup('[TEXT]')->value ?? '',
'bodystructure' => $data->lookup('BODYSTRUCTURE'),
],
];
})->all();
}
/**
* Execute an IMAP search request.
*/
protected function search(): Collection
{
// If the query is empty, default to fetching all.
if ($this->query->isEmpty()) {
$this->query->all();
}
$response = $this->connection()->search([
$this->query->toImap(),
]);
return new Collection(array_map(
fn (Token $token) => $token->value,
$response->tokensAfter(2)
));
}
/**
* Execute an IMAP UID SORT request using RFC 5256.
*/
protected function sort(): Collection
{
if (! in_array('SORT', $this->folder->mailbox()->capabilities())) {
throw new ImapCapabilityException(
'Unable to sort messages. IMAP server does not support SORT capability.'
);
}
if ($this->query->isEmpty()) {
$this->query->all();
}
$response = $this->connection()->sort(
$this->sortKey,
$this->sortDirection,
[$this->query->toImap()]
);
return new Collection(array_map(
fn (Token $token) => $token->value,
$response->tokensAfter(2)
));
}
/**
* Get the UID for the given identifier.
*/
protected function id(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ResponseCollection
{
try {
return $this->connection()->uid([$id], $identifier);
} catch (ImapCommandException $e) {
// IMAP servers may return an error if the message number is not found.
// If the identifier being used is a message number, and the message
// number is in the command tokens, we can assume this has occurred
// and safely ignore the error and return an empty collection.
if (
$identifier === ImapFetchIdentifier::MessageNumber
&& in_array($id, $e->command()->tokens())
) {
return ResponseCollection::make();
}
// Otherwise, re-throw the exception.
throw $e;
}
}
/**
* Make a new message from given raw components.
*/
protected function newMessage(int $uid, array $flags, string $head, string $body, ?int $size = null, ?ListData $bodystructure = null): Message
{
return new Message($this->folder, $uid, $flags, $head, $body, $size, $bodystructure);
}
/**
* Get the connection instance.
*/
protected function connection(): ConnectionInterface
{
return $this->folder->mailbox()->connection();
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace DirectoryTree\ImapEngine;
use BackedEnum;
use DirectoryTree\ImapEngine\Collections\MessageCollection;
use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use DirectoryTree\ImapEngine\Pagination\LengthAwarePaginator;
/**
* @mixin \DirectoryTree\ImapEngine\Connection\ImapQueryBuilder
*/
interface MessageQueryInterface
{
/**
* Don't mark messages as read when fetching.
*/
public function leaveUnread(): MessageQueryInterface;
/**
* Mark all messages as read when fetching.
*/
public function markAsRead(): MessageQueryInterface;
/**
* Set the limit and page for the current query.
*/
public function limit(int $limit, int $page = 1): MessageQueryInterface;
/**
* Get the set fetch limit.
*/
public function getLimit(): ?int;
/**
* Set the fetch limit.
*/
public function setLimit(int $limit): MessageQueryInterface;
/**
* Get the set page.
*/
public function getPage(): int;
/**
* Set the page.
*/
public function setPage(int $page): MessageQueryInterface;
/**
* Determine if the body of messages is being fetched.
*/
public function isFetchingBody(): bool;
/**
* Determine if the flags of messages is being fetched.
*/
public function isFetchingFlags(): bool;
/**
* Determine if the headers of messages is being fetched.
*/
public function isFetchingHeaders(): bool;
/**
* Determine if the size of messages is being fetched.
*/
public function isFetchingSize(): bool;
/**
* Determine if the body structure of messages is being fetched.
*/
public function isFetchingBodyStructure(): bool;
/**
* Fetch the flags of messages.
*/
public function withFlags(): MessageQueryInterface;
/**
* Fetch the body of messages.
*/
public function withBody(): MessageQueryInterface;
/**
* Fetch the headers of messages.
*/
public function withHeaders(): MessageQueryInterface;
/**
* Fetch the size of messages.
*/
public function withSize(): MessageQueryInterface;
/**
* Fetch the body structure of messages.
*/
public function withBodyStructure(): MessageQueryInterface;
/**
* Don't fetch the body of messages.
*/
public function withoutBody(): MessageQueryInterface;
/**
* Don't fetch the headers of messages.
*/
public function withoutHeaders(): MessageQueryInterface;
/**
* Don't fetch the flags of messages.
*/
public function withoutFlags(): MessageQueryInterface;
/**
* Don't fetch the size of messages.
*/
public function withoutSize(): MessageQueryInterface;
/**
* Don't fetch the body structure of messages.
*/
public function withoutBodyStructure(): MessageQueryInterface;
/**
* Set the fetch order.
*/
public function setFetchOrder(string $fetchOrder): MessageQueryInterface;
/**
* Get the fetch order.
*/
public function getFetchOrder(): string;
/**
* Set the fetch order to 'ascending'.
*/
public function setFetchOrderAsc(): MessageQueryInterface;
/**
* Set the fetch order to 'descending'.
*/
public function setFetchOrderDesc(): MessageQueryInterface;
/**
* Set the fetch order to show oldest messages first (ascending).
*/
public function oldest(): MessageQueryInterface;
/**
* Set the fetch order to show newest messages first (descending).
*/
public function newest(): MessageQueryInterface;
/**
* Set the sort key for server-side sorting (RFC 5256).
*/
public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface;
/**
* Get the sort key for server-side sorting.
*/
public function getSortKey(): ?ImapSortKey;
/**
* Set the sort direction for server-side sorting.
*/
public function setSortDirection(string $direction): MessageQueryInterface;
/**
* Get the sort direction for server-side sorting.
*/
public function getSortDirection(): string;
/**
* Sort messages by a field using server-side sorting (RFC 5256).
*/
public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): MessageQueryInterface;
/**
* Sort messages by a field in descending order using server-side sorting.
*/
public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface;
/**
* Count all available messages matching the current search criteria.
*/
public function count(): int;
/**
* Get the first message in the resulting collection.
*/
public function first(): ?MessageInterface;
/**
* Get the first message in the resulting collection or throw an exception.
*/
public function firstOrFail(): MessageInterface;
/**
* Get the messages matching the current query.
*/
public function get(): MessageCollection;
/**
* Append a new message to the folder.
*/
public function append(string $message, mixed $flags = null): int;
/**
* Execute a callback over each message via a chunked query.
*/
public function each(callable $callback, int $chunkSize = 10, int $startChunk = 1): void;
/**
* Execute a callback over each chunk of messages.
*/
public function chunk(callable $callback, int $chunkSize = 10, int $startChunk = 1): void;
/**
* Paginate the current query.
*/
public function paginate(int $perPage = 5, $page = null, string $pageName = 'page'): LengthAwarePaginator;
/**
* Find a message by the given identifier type or throw an exception.
*/
public function findOrFail(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): MessageInterface;
/**
* Find a message by the given identifier type.
*/
public function find(int $id, ImapFetchIdentifier $identifier = ImapFetchIdentifier::Uid): ?MessageInterface;
/**
* Destroy the given messages.
*/
public function destroy(array|int $uids, bool $expunge = false): void;
/**
* Add or remove a flag from all messages matching the current query.
*
* @param string $operation '+'|'-'
* @return int The number of messages affected.
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): int;
/**
* Mark all messages matching the current query as read.
*
* @return int The number of messages affected.
*/
public function markRead(): int;
/**
* Mark all messages matching the current query as unread.
*
* @return int The number of messages affected.
*/
public function markUnread(): int;
/**
* Mark all messages matching the current query as flagged.
*
* @return int The number of messages affected.
*/
public function markFlagged(): int;
/**
* Unmark all messages matching the current query as flagged.
*
* @return int The number of messages affected.
*/
public function unmarkFlagged(): int;
/**
* Delete all messages matching the current query.
*
* @return int The number of messages affected.
*/
public function delete(bool $expunge = false): int;
/**
* Move all messages matching the current query to the given folder.
*
* @return int The number of messages affected.
*/
public function move(string $folder, bool $expunge = false): int;
/**
* Copy all messages matching the current query to the given folder.
*
* @return int The number of messages affected.
*/
public function copy(string $folder): int;
}

View File

@@ -0,0 +1,181 @@
<?php
namespace DirectoryTree\ImapEngine\Pagination;
use DirectoryTree\ImapEngine\Support\ForwardsCalls;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
use JsonSerializable;
/**
* @template TKey of array-key
* @template TValue
*
* @template-implements Arrayable<TKey, TValue>
*/
class LengthAwarePaginator implements Arrayable, JsonSerializable
{
use ForwardsCalls;
/**
* Constructor.
*/
public function __construct(
protected Collection $items,
protected int $total,
protected int $perPage,
protected int $currentPage = 1,
protected string $path = '',
protected array $query = [],
protected string $pageName = 'page',
) {
$this->currentPage = max($currentPage, 1);
$this->path = rtrim($path, '/');
}
/**
* Handle dynamic method calls on the paginator.
*/
public function __call(string $method, array $parameters): mixed
{
return $this->forwardCallTo($this->items, $method, $parameters);
}
/**
* Get the items being paginated.
*
* @return Collection<TKey, TValue>
*/
public function items(): Collection
{
return $this->items;
}
/**
* Get the total number of items.
*/
public function total(): int
{
return $this->total;
}
/**
* Get the number of items per page.
*/
public function perPage(): int
{
return $this->perPage;
}
/**
* Get the current page number.
*/
public function currentPage(): int
{
return $this->currentPage;
}
/**
* Get the last page (total pages).
*/
public function lastPage(): int
{
return (int) ceil($this->total / $this->perPage);
}
/**
* Determine if there are enough items to split into multiple pages.
*/
public function hasPages(): bool
{
return $this->total() > $this->perPage();
}
/**
* Determine if there is a next page.
*/
public function hasMorePages(): bool
{
return $this->currentPage() < $this->lastPage();
}
/**
* Generate the URL for a given page.
*/
public function url(int $page): string
{
$params = array_merge($this->query, [$this->pageName => $page]);
$queryString = http_build_query($params);
return $this->path.($queryString ? '?'.$queryString : '');
}
/**
* Get the URL for the next page, or null if none.
*/
public function nextPageUrl(): ?string
{
if ($this->hasMorePages()) {
return $this->url($this->currentPage() + 1);
}
return null;
}
/**
* Get the URL for the previous page, or null if none.
*/
public function previousPageUrl(): ?string
{
if ($this->currentPage() > 1) {
return $this->url($this->currentPage() - 1);
}
return null;
}
/**
* Get the array representation of the paginator.
*/
public function toArray(): array
{
return [
'path' => $this->path,
'total' => $this->total(),
'to' => $this->calculateTo(),
'per_page' => $this->perPage(),
'last_page' => $this->lastPage(),
'first_page_url' => $this->url(1),
'data' => $this->items()->toArray(),
'current_page' => $this->currentPage(),
'next_page_url' => $this->nextPageUrl(),
'prev_page_url' => $this->previousPageUrl(),
'last_page_url' => $this->url($this->lastPage()),
'from' => $this->total() ? ($this->currentPage() - 1) * $this->perPage() + 1 : null,
];
}
/**
* Calculate the "to" index for the current page.
*/
protected function calculateTo(): ?int
{
if (! $this->total()) {
return null;
}
$to = $this->currentPage() * $this->perPage();
return min($to, $this->total());
}
/**
* Get the JSON representation of the paginator.
*/
public function jsonSerialize(): array
{
return $this->toArray();
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace DirectoryTree\ImapEngine;
use Closure;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\Exceptions\ImapConnectionClosedException;
class Poll
{
/**
* The last seen message UID.
*/
protected ?int $lastSeenUid = null;
/**
* Constructor.
*/
public function __construct(
protected Mailbox $mailbox,
protected string $folder,
protected Closure|int $frequency,
) {}
/**
* Destructor.
*/
public function __destruct()
{
$this->disconnect();
}
/**
* Poll for new messages at a given frequency.
*/
public function start(callable $callback, callable $query): void
{
$this->connect();
while ($frequency = $this->getNextFrequency()) {
try {
$this->check($callback, $query);
} catch (ImapConnectionClosedException) {
$this->reconnect();
}
sleep($frequency);
}
}
/**
* Check for new messages since the last seen UID.
*/
protected function check(callable $callback, callable $query): void
{
$folder = $this->folder();
// If we don't have a last seen UID, we will fetch
// the last one in the folder as a starting point.
if (! $this->lastSeenUid) {
$this->lastSeenUid = $folder->messages()
->first()
?->uid() ?? 0;
return;
}
$query($folder->messages())
->uid($this->lastSeenUid + 1, INF)
->each(function (MessageInterface $message) use ($callback) {
// Avoid processing the same message twice on subsequent polls.
// Some IMAP servers will always return the last seen UID in
// the search results regardless of given UID search range.
if ($this->lastSeenUid === $message->uid()) {
return;
}
$callback($message);
$this->lastSeenUid = $message->uid();
});
}
/**
* Get the folder to poll.
*/
protected function folder(): FolderInterface
{
return $this->mailbox->folders()->findOrFail($this->folder);
}
/**
* Reconnect the client and restart the poll session.
*/
protected function reconnect(): void
{
$this->mailbox->disconnect();
$this->connect();
}
/**
* Connect the client and select the folder to poll.
*/
protected function connect(): void
{
$this->mailbox->connect();
$this->mailbox->select($this->folder(), true);
}
/**
* Disconnect the client.
*/
protected function disconnect(): void
{
try {
$this->mailbox->disconnect();
} catch (Exception) {
// Do nothing.
}
}
/**
* Get the next frequency in seconds.
*/
protected function getNextFrequency(): int|false
{
if (is_numeric($seconds = value($this->frequency))) {
return abs((int) $seconds);
}
return false;
}
}

View File

@@ -0,0 +1,448 @@
<?php
namespace DirectoryTree\ImapEngine;
use DirectoryTree\ImapEngine\Connection\ImapQueryBuilder;
use DirectoryTree\ImapEngine\Enums\ImapSortKey;
use DirectoryTree\ImapEngine\Support\ForwardsCalls;
use Illuminate\Support\Traits\Conditionable;
trait QueriesMessages
{
use Conditionable, ForwardsCalls;
/**
* The query builder instance.
*/
protected ImapQueryBuilder $query;
/**
* The current page.
*/
protected int $page = 1;
/**
* The fetch limit.
*/
protected ?int $limit = null;
/**
* Whether to fetch the message body.
*/
protected bool $fetchBody = false;
/**
* Whether to fetch the message flags.
*/
protected bool $fetchFlags = false;
/**
* Whether to fetch the message headers.
*/
protected bool $fetchHeaders = false;
/**
* Whether to fetch the message size.
*/
protected bool $fetchSize = false;
/**
* Whether to fetch the message body structure.
*/
protected bool $fetchBodyStructure = false;
/**
* The fetch order.
*
* @var 'asc'|'desc'
*/
protected string $fetchOrder = 'desc';
/**
* Whether to leave messages fetched as unread by default.
*/
protected bool $fetchAsUnread = true;
/**
* The methods that should be returned from query builder.
*/
protected array $passthru = ['toimap', 'isempty'];
/**
* The sort key for server-side sorting (RFC 5256).
*/
protected ?ImapSortKey $sortKey = null;
/**
* The sort direction for server-side sorting.
*
* @var 'asc'|'desc'
*/
protected string $sortDirection = 'asc';
/**
* Handle dynamic method calls into the query builder.
*/
public function __call(string $method, array $parameters): mixed
{
if (in_array(strtolower($method), $this->passthru)) {
return $this->query->{$method}(...$parameters);
}
$this->forwardCallTo($this->query, $method, $parameters);
return $this;
}
/**
* {@inheritDoc}
*/
public function leaveUnread(): MessageQueryInterface
{
$this->fetchAsUnread = true;
return $this;
}
/**
* {@inheritDoc}
*/
public function markAsRead(): MessageQueryInterface
{
$this->fetchAsUnread = false;
return $this;
}
/**
* {@inheritDoc}
*/
public function limit(int $limit, int $page = 1): MessageQueryInterface
{
if ($page >= 1) {
$this->page = $page;
}
$this->limit = $limit;
return $this;
}
/**
* {@inheritDoc}
*/
public function getLimit(): ?int
{
return $this->limit;
}
/**
* {@inheritDoc}
*/
public function setLimit(int $limit): MessageQueryInterface
{
$this->limit = max($limit, 1);
return $this;
}
/**
* {@inheritDoc}
*/
public function getPage(): int
{
return $this->page;
}
/**
* {@inheritDoc}
*/
public function setPage(int $page): MessageQueryInterface
{
$this->page = $page;
return $this;
}
/**
* {@inheritDoc}
*/
public function isFetchingBody(): bool
{
return $this->fetchBody;
}
/**
* {@inheritDoc}
*/
public function isFetchingFlags(): bool
{
return $this->fetchFlags;
}
/**
* {@inheritDoc}
*/
public function isFetchingHeaders(): bool
{
return $this->fetchHeaders;
}
/**
* {@inheritDoc}
*/
public function isFetchingSize(): bool
{
return $this->fetchSize;
}
/**
* {@inheritDoc}
*/
public function isFetchingBodyStructure(): bool
{
return $this->fetchBodyStructure;
}
/**
* {@inheritDoc}
*/
public function withFlags(): MessageQueryInterface
{
return $this->setFetchFlags(true);
}
/**
* {@inheritDoc}
*/
public function withBody(): MessageQueryInterface
{
return $this->setFetchBody(true);
}
/**
* {@inheritDoc}
*/
public function withHeaders(): MessageQueryInterface
{
return $this->setFetchHeaders(true);
}
/**
* {@inheritDoc}
*/
public function withSize(): MessageQueryInterface
{
return $this->setFetchSize(true);
}
/**
* {@inheritDoc}
*/
public function withBodyStructure(): MessageQueryInterface
{
return $this->setFetchBodyStructure(true);
}
/**
* {@inheritDoc}
*/
public function withoutBody(): MessageQueryInterface
{
return $this->setFetchBody(false);
}
/**
* {@inheritDoc}
*/
public function withoutHeaders(): MessageQueryInterface
{
return $this->setFetchHeaders(false);
}
/**
* {@inheritDoc}
*/
public function withoutFlags(): MessageQueryInterface
{
return $this->setFetchFlags(false);
}
/**
* {@inheritDoc}
*/
public function withoutSize(): MessageQueryInterface
{
return $this->setFetchSize(false);
}
/**
* {@inheritDoc}
*/
public function withoutBodyStructure(): MessageQueryInterface
{
return $this->setFetchBodyStructure(false);
}
/**
* Set whether to fetch the flags.
*/
protected function setFetchFlags(bool $fetchFlags): MessageQueryInterface
{
$this->fetchFlags = $fetchFlags;
return $this;
}
/**
* Set the fetch body flag.
*/
protected function setFetchBody(bool $fetchBody): MessageQueryInterface
{
$this->fetchBody = $fetchBody;
return $this;
}
/**
* Set whether to fetch the headers.
*/
protected function setFetchHeaders(bool $fetchHeaders): MessageQueryInterface
{
$this->fetchHeaders = $fetchHeaders;
return $this;
}
/**
* Set whether to fetch the size.
*/
protected function setFetchSize(bool $fetchSize): MessageQueryInterface
{
$this->fetchSize = $fetchSize;
return $this;
}
/**
* Set whether to fetch the body structure.
*/
protected function setFetchBodyStructure(bool $fetchBodyStructure): MessageQueryInterface
{
$this->fetchBodyStructure = $fetchBodyStructure;
return $this;
}
/** {@inheritDoc} */
public function setFetchOrder(string $fetchOrder): MessageQueryInterface
{
$fetchOrder = strtolower($fetchOrder);
if (in_array($fetchOrder, ['asc', 'desc'])) {
$this->fetchOrder = $fetchOrder;
}
return $this;
}
/**
* {@inheritDoc}
*/
public function getFetchOrder(): string
{
return $this->fetchOrder;
}
/**
* {@inheritDoc}
*/
public function setFetchOrderAsc(): MessageQueryInterface
{
return $this->setFetchOrder('asc');
}
/**
* {@inheritDoc}
*/
public function setFetchOrderDesc(): MessageQueryInterface
{
return $this->setFetchOrder('desc');
}
/**
* {@inheritDoc}
*/
public function oldest(): MessageQueryInterface
{
return $this->setFetchOrder('asc');
}
/**
* {@inheritDoc}
*/
public function newest(): MessageQueryInterface
{
return $this->setFetchOrder('desc');
}
/**
* {@inheritDoc}
*/
public function setSortKey(ImapSortKey|string|null $key): MessageQueryInterface
{
if (is_string($key)) {
$key = ImapSortKey::from(strtoupper($key));
}
$this->sortKey = $key;
return $this;
}
/**
* {@inheritDoc}
*/
public function getSortKey(): ?ImapSortKey
{
return $this->sortKey;
}
/**
* {@inheritDoc}
*/
public function setSortDirection(string $direction): MessageQueryInterface
{
$direction = strtolower($direction);
if (in_array($direction, ['asc', 'desc'])) {
$this->sortDirection = $direction;
}
return $this;
}
/**
* {@inheritDoc}
*/
public function getSortDirection(): string
{
return $this->sortDirection;
}
/**
* {@inheritDoc}
*/
public function sortBy(ImapSortKey|string $key, string $direction = 'asc'): MessageQueryInterface
{
return $this->setSortKey($key)->setSortDirection($direction);
}
/**
* {@inheritDoc}
*/
public function sortByDesc(ImapSortKey|string $key): MessageQueryInterface
{
return $this->sortBy($key, 'desc');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace DirectoryTree\ImapEngine\Support;
use BadMethodCallException;
use Error;
trait ForwardsCalls
{
/**
* Forward a method call to the given object.
*/
protected function forwardCallTo(object $object, string $method, array $parameters): mixed
{
try {
return $object->{$method}(...$parameters);
} catch (Error|BadMethodCallException $e) {
$pattern = '~^Call to undefined method (?P<class>[^:]+)::(?P<method>[^\(]+)\(\)$~';
if (! preg_match($pattern, $e->getMessage(), $matches)) {
throw $e;
}
if ($matches['class'] != get_class($object) ||
$matches['method'] != $method) {
throw $e;
}
static::throwBadMethodCallException($method);
}
}
/**
* Throw a bad method call exception for the given method.
*/
protected static function throwBadMethodCallException(string $method): never
{
throw new BadMethodCallException(sprintf(
'Call to undefined method %s::%s()', static::class, $method
));
}
}

View File

@@ -0,0 +1,313 @@
<?php
namespace DirectoryTree\ImapEngine\Support;
use BackedEnum;
class Str
{
/**
* Make a list with literals or nested lists.
*/
public static function list(array $list): string
{
$values = [];
foreach ($list as $value) {
if (is_array($value)) {
$values[] = static::list($value);
} else {
$values[] = $value;
}
}
return sprintf('(%s)', implode(' ', $values));
}
/**
* Make one or more literals.
*/
public static function literal(array|string $string): array|string
{
if (is_array($string)) {
$result = [];
foreach ($string as $value) {
$result[] = static::literal($value);
}
return $result;
}
if (str_contains($string, "\n")) {
return ['{'.strlen($string).'}', $string];
}
return '"'.static::escape($string).'"';
}
/**
* Resolve the value of the given enums.
*/
public static function enums(BackedEnum|array|string|null $enums = null): array|string|null
{
if (is_null($enums)) {
return null;
}
if (is_array($enums)) {
return array_map([static::class, 'enums'], $enums);
}
return Str::enum($enums);
}
/**
* Resolve the value of the given enum.
*/
public static function enum(BackedEnum|string $enum): string
{
if ($enum instanceof BackedEnum) {
return $enum->value;
}
return (string) $enum;
}
/**
* Make a range set for use in a search command.
*/
public static function set(int|string|array $from, int|float|string|null $to = null): string
{
// If $from is an array with multiple elements, return them as a comma-separated list.
if (is_array($from) && count($from) > 1) {
return implode(',', $from);
}
// If $from is an array with a single element, return that element.
if (is_array($from) && count($from) === 1) {
return (string) reset($from);
}
// At this point, $from is an integer. No upper bound provided, return $from as a string.
if (is_null($to)) {
return (string) $from;
}
// If the upper bound is infinite, use the '*' notation.
if ($to == INF) {
return $from.':*';
}
// Otherwise, return a typical range string.
return $from.':'.$to;
}
/**
* Make a credentials string for use in the AUTHENTICATE command.
*/
public static function credentials(string $user, string $token): string
{
return base64_encode("user=$user\1auth=Bearer $token\1\1");
}
/**
* Prefix a string with the given prefix if it does not already start with it.
*/
public static function prefix(string $value, string $prefix): string
{
return str_starts_with($value, $prefix) ? $value : $prefix.$value;
}
/**
* Escape a string for use in a list.
*/
public static function escape(string $string): string
{
// Remove newlines and control characters (ASCII 0-31 and 127).
$string = preg_replace('/[\r\n\x00-\x1F\x7F]/', '', $string);
// Escape backslashes first to avoid double-escaping and then escape double quotes.
return str_replace(['\\', '"'], ['\\\\', '\\"'], $string);
}
/**
* Decode a modified UTF-7 string (IMAP specific) to UTF-8.
*/
public static function fromImapUtf7(string $string): string
{
// If the string doesn't contain any '&' character, it's not UTF-7 encoded.
if (! str_contains($string, '&')) {
return $string;
}
// Handle the special case of '&-' which represents '&' in UTF-7.
if ($string === '&-') {
return '&';
}
// Direct implementation of IMAP's modified UTF-7 decoding.
return preg_replace_callback('/&([^-]*)-?/', function ($matches) {
/** @var array{0: string, 1: string, 2?: string} $matches */
// If it's just an ampersand.
if ($matches[1] === '') {
return '&';
}
// If it's the special case for ampersand.
if ($matches[1] === '-') {
return '&';
}
// Convert modified base64 to standard base64.
$base64 = strtr($matches[1], ',', '/');
// Add padding if necessary.
switch (strlen($base64) % 4) {
case 1: $base64 .= '===';
break;
case 2: $base64 .= '==';
break;
case 3: $base64 .= '=';
break;
}
// Decode base64 to binary.
$binary = base64_decode($base64, true);
if ($binary === false) {
// If decoding fails, return the original string.
return '&'.$matches[1].($matches[2] ?? '');
}
$result = '';
// Convert binary UTF-16BE to UTF-8.
for ($i = 0; $i < strlen($binary); $i += 2) {
if (isset($binary[$i + 1])) {
$char = (ord($binary[$i]) << 8) | ord($binary[$i + 1]);
if ($char < 0x80) {
$result .= chr($char);
} elseif ($char < 0x800) {
$result .= chr(0xC0 | ($char >> 6)).chr(0x80 | ($char & 0x3F));
} else {
$result .= chr(0xE0 | ($char >> 12)).chr(0x80 | (($char >> 6) & 0x3F)).chr(0x80 | ($char & 0x3F));
}
}
}
return $result;
}, $string);
}
/**
* Encode a UTF-8 string to modified UTF-7 (IMAP specific).
*/
public static function toImapUtf7(string $string): string
{
$result = '';
$buffer = '';
// Iterate over each character in the UTF-8 string.
for ($i = 0; $i < mb_strlen($string, 'UTF-8'); $i++) {
$char = mb_substr($string, $i, 1, 'UTF-8');
// Convert character to its UTF-16BE code unit (for deciding if ASCII).
$ord = unpack('n', mb_convert_encoding($char, 'UTF-16BE', 'UTF-8'))[1];
// Handle printable ASCII characters (0x20 - 0x7E) except '&'
if ($ord >= 0x20 && $ord <= 0x7E && $char !== '&') {
// If there is any buffered non-ASCII content, flush it as a base64 section.
if ($buffer !== '') {
// Encode the buffer to UTF-16BE, then to base64, swap '/' for ',', trim '=' padding, and wrap with '&' and '-'.
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
$buffer = '';
}
// Append the ASCII character as-is.
$result .= $char;
continue;
}
// Special handling for literal '&' which becomes '&-'
if ($char === '&') {
// Flush any buffered non-ASCII content first.
if ($buffer !== '') {
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
$buffer = '';
}
// '&' is encoded as '&-'
$result .= '&-';
continue;
}
// Buffer non-ASCII characters for later base64 encoding.
$buffer .= $char;
}
// After the loop, flush any remaining buffered non-ASCII content.
if ($buffer !== '') {
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
}
return $result;
}
/**
* Determine if a given string matches a given pattern.
*/
public static function is(array|string $pattern, string $value, bool $ignoreCase = false): bool
{
if (! is_iterable($pattern)) {
$pattern = [$pattern];
}
foreach ($pattern as $pattern) {
$pattern = (string) $pattern;
// If the given value is an exact match we can of course return true right
// from the beginning. Otherwise, we will translate asterisks and do an
// actual pattern match against the two strings to see if they match.
if ($pattern === '*' || $pattern === $value) {
return true;
}
if ($ignoreCase && mb_strtolower($pattern) === mb_strtolower($value)) {
return true;
}
$pattern = preg_quote($pattern, '#');
// Asterisks are translated into zero-or-more regular expression wildcards
// to make it convenient to check if the strings starts with the given
// pattern such as "library/*", making any string check convenient.
$pattern = str_replace('\*', '.*', $pattern);
if (preg_match('#^'.$pattern.'\z#'.($ignoreCase ? 'isu' : 'su'), $value) === 1) {
return true;
}
}
return false;
}
/**
* Decode MIME-encoded header values.
*/
public static function decodeMimeHeader(string $value): string
{
if (! str_contains($value, '=?')) {
return $value;
}
if ($decoded = iconv_mime_decode($value, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8')) {
return $decoded;
}
return $value;
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace DirectoryTree\ImapEngine\Testing;
use DirectoryTree\ImapEngine\ComparesFolders;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
use DirectoryTree\ImapEngine\MessageQueryInterface;
use DirectoryTree\ImapEngine\Support\Str;
class FakeFolder implements FolderInterface
{
use ComparesFolders;
/**
* Constructor.
*/
public function __construct(
protected string $path = '',
protected array $flags = [],
/** @var FakeMessage[] */
protected array $messages = [],
protected string $delimiter = '/',
protected ?MailboxInterface $mailbox = null,
) {}
/**
* {@inheritDoc}
*/
public function mailbox(): MailboxInterface
{
return $this->mailbox ?? throw new Exception('Folder has no mailbox.');
}
/**
* {@inheritDoc}
*/
public function path(): string
{
return $this->path;
}
/**
* {@inheritDoc}
*/
public function flags(): array
{
return $this->flags;
}
/**
* {@inheritDoc}
*/
public function delimiter(): string
{
return $this->delimiter;
}
/**
* {@inheritDoc}
*/
public function name(): string
{
return Str::fromImapUtf7(
last(explode($this->delimiter, $this->path))
);
}
/**
* {@inheritDoc}
*/
public function is(FolderInterface $folder): bool
{
return $this->isSameFolder($this, $folder);
}
/**
* {@inheritDoc}
*/
public function messages(): MessageQueryInterface
{
// Ensure the folder is selected.
$this->select(true);
return new FakeMessageQuery($this);
}
/**
* {@inheritDoc}
*/
public function idle(callable $callback, ?callable $query = null, callable|int $timeout = 300): void
{
foreach ($this->messages as $message) {
$callback($message);
}
}
/**
* {@inheritDoc}
*/
public function poll(callable $callback, ?callable $query = null, callable|int $frequency = 60): void
{
foreach ($this->messages as $message) {
$callback($message);
}
}
/**
* {@inheritDoc}
*/
public function move(string $newPath): void
{
// Do nothing.
}
/**
* {@inheritDoc}
*/
public function select(bool $force = false): void
{
$this->mailbox?->select($this, $force);
}
/**
* {@inheritDoc}
*/
public function status(): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function examine(): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function expunge(): array
{
return [];
}
/**
* {@inheritDoc}
*/
public function quota(): array
{
return [
$this->path => [
'STORAGE' => [
'usage' => 0,
'limit' => 0,
],
'MESSAGE' => [
'usage' => 0,
'limit' => 0,
],
],
];
}
/**
* {@inheritDoc}
*/
public function delete(): void
{
// Do nothing.
}
/**
* Set the folder's path.
*/
public function setPath(string $path): FakeFolder
{
$this->path = $path;
return $this;
}
/**
* Set the folder's flags.
*/
public function setFlags(array $flags): FakeFolder
{
$this->flags = $flags;
return $this;
}
/**
* Set the folder's mailbox.
*/
public function setMailbox(MailboxInterface $mailbox): FakeFolder
{
$this->mailbox = $mailbox;
return $this;
}
/**
* Set the folder's messages.
*
* @param FakeMessage[] $messages
*/
public function setMessages(array $messages): FakeFolder
{
$this->messages = $messages;
return $this;
}
/**
* Get the folder's messages.
*
* @return FakeMessage[]
*/
public function getMessages(): array
{
return $this->messages;
}
/**
* Add a message to the folder.
*/
public function addMessage(FakeMessage $message): void
{
$this->messages[] = $message;
}
/**
* Set the folder's delimiter.
*/
public function setDelimiter(string $delimiter = '/'): FakeFolder
{
$this->delimiter = $delimiter;
return $this;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace DirectoryTree\ImapEngine\Testing;
use DirectoryTree\ImapEngine\Collections\FolderCollection;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\FolderRepositoryInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
use DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Support\ItemNotFoundException;
class FakeFolderRepository implements FolderRepositoryInterface
{
/**
* Constructor.
*/
public function __construct(
protected MailboxInterface $mailbox,
/** @var FolderInterface[] */
protected array $folders = []
) {}
/**
* {@inheritDoc}
*/
public function find(string $path): ?FolderInterface
{
try {
return $this->findOrFail($path);
} catch (ItemNotFoundException) {
return null;
}
}
/**
* {@inheritDoc}
*/
public function findOrFail(string $path): FolderInterface
{
return $this->get()->firstOrFail(
fn (FolderInterface $folder) => strtolower($folder->path()) === strtolower($path)
);
}
/**
* {@inheritDoc}
*/
public function create(string $path): FolderInterface
{
return $this->folders[] = new FakeFolder($path, mailbox: $this->mailbox);
}
/**
* {@inheritDoc}
*/
public function firstOrCreate(string $path): FolderInterface
{
return $this->find($path) ?? $this->create($path);
}
/**
* {@inheritDoc}
*/
public function get(?string $match = '*', ?string $reference = ''): FolderCollection
{
$folders = FolderCollection::make($this->folders);
// If we're not matching all, filter the folders by the match pattern.
if (! in_array($match, ['*', null])) {
return $folders->filter(
fn (FolderInterface $folder) => Str::is($match, $folder->path())
);
}
return $folders;
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace DirectoryTree\ImapEngine\Testing;
use DirectoryTree\ImapEngine\Connection\ConnectionInterface;
use DirectoryTree\ImapEngine\Exceptions\Exception;
use DirectoryTree\ImapEngine\FolderInterface;
use DirectoryTree\ImapEngine\FolderRepositoryInterface;
use DirectoryTree\ImapEngine\MailboxInterface;
class FakeMailbox implements MailboxInterface
{
/**
* The currently selected folder.
*/
protected ?FolderInterface $selected = null;
/**
* Constructor.
*/
public function __construct(
protected array $config = [],
/** @var FakeFolder[] */
protected array $folders = [],
protected array $capabilities = [],
) {
foreach ($folders as $folder) {
$folder->setMailbox($this);
}
}
/**
* {@inheritDoc}
*/
public function config(?string $key = null, mixed $default = null): mixed
{
if (is_null($key)) {
return $this->config;
}
return data_get($this->config, $key, $default);
}
/**
* {@inheritDoc}
*/
public function connection(): ConnectionInterface
{
throw new Exception('Unsupported.');
}
/**
* {@inheritDoc}
*/
public function connected(): bool
{
return true;
}
/**
* {@inheritDoc}
*/
public function reconnect(): void
{
// Do nothing.
}
/**
* {@inheritDoc}
*/
public function connect(?ConnectionInterface $connection = null): void
{
// Do nothing.
}
/**
* {@inheritDoc}
*/
public function disconnect(): void
{
// Do nothing.
}
/**
* {@inheritDoc}
*/
public function inbox(): FolderInterface
{
return $this->folders()->findOrFail('inbox');
}
/**
* {@inheritDoc}
*/
public function folders(): FolderRepositoryInterface
{
return new FakeFolderRepository($this, $this->folders);
}
/**
* {@inheritDoc}
*/
public function capabilities(): array
{
return $this->capabilities;
}
/**
* {@inheritDoc}
*/
public function select(FolderInterface $folder, bool $force = false): void
{
$this->selected = $folder;
}
/**
* {@inheritDoc}
*/
public function selected(FolderInterface $folder): bool
{
return $this->selected?->is($folder) ?? false;
}
}

View File

@@ -0,0 +1,115 @@
<?php
namespace DirectoryTree\ImapEngine\Testing;
use BackedEnum;
use DirectoryTree\ImapEngine\BodyStructureCollection;
use DirectoryTree\ImapEngine\HasFlags;
use DirectoryTree\ImapEngine\HasParsedMessage;
use DirectoryTree\ImapEngine\MessageInterface;
use DirectoryTree\ImapEngine\Support\Str;
class FakeMessage implements MessageInterface
{
use HasFlags, HasParsedMessage;
/**
* Constructor.
*/
public function __construct(
protected int $uid,
protected array $flags = [],
protected string $contents = '',
protected ?int $size = null,
protected ?BodyStructureCollection $bodyStructure = null,
) {}
/**
* {@inheritDoc}
*/
public function uid(): int
{
return $this->uid;
}
/**
* {@inheritDoc}
*/
public function size(): int
{
return $this->size ?? strlen($this->contents);
}
/**
* {@inheritDoc}
*/
public function is(MessageInterface $message): bool
{
return $message instanceof self
&& $this->uid === $message->uid
&& $this->flags === $message->flags
&& $this->contents === $message->contents;
}
/**
* {@inheritDoc}
*/
public function flag(BackedEnum|string $flag, string $operation, bool $expunge = false): void
{
$flag = Str::enum($flag);
if ($operation === '+') {
$this->flags = array_unique([...$this->flags, $flag]);
} else {
$this->flags = array_filter($this->flags, fn (string $value) => $value !== $flag);
}
}
/**
* {@inheritDoc}
*/
public function flags(): array
{
return $this->flags;
}
/**
* {@inheritDoc}
*/
public function bodyStructure(): ?BodyStructureCollection
{
return $this->bodyStructure;
}
/**
* {@inheritDoc}
*/
public function hasBodyStructure(): bool
{
return (bool) $this->bodyStructure;
}
/**
* {@inheritDoc}
*/
public function bodyPart(string $partNumber, bool $peek = true): ?string
{
return null;
}
/**
* {@inheritDoc}
*/
public function isEmpty(): bool
{
return empty($this->contents);
}
/**
* {@inheritDoc}
*/
public function __toString(): string
{
return $this->contents;
}
}

Some files were not shown because too many files have changed in this diff Show More