From 6f2d706dcdeb7335a9b636e21777bfc5bdc77628 Mon Sep 17 00:00:00 2001 From: skraetzig Date: Sun, 10 May 2026 10:45:04 +0200 Subject: [PATCH 1/3] #411: Add OAuth Support --- README.md | 5 ++ src/PhpImap/Mailbox.php | 120 ++++++++++++++++++++++++++++- tests/unit/Fixtures/Mailbox.php | 15 ++++ tests/unit/MailboxOAuthTest.php | 129 ++++++++++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 tests/unit/MailboxOAuthTest.php diff --git a/README.md b/README.md index ad978bab..678da026 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,11 @@ $mailbox->setConnectionArgs( | OP_SECURE // don't do non-secure authentication ); +// Some providers require OAuth instead of a password. +// Obtain and refresh the access token outside of this library, then enable OAuth explicitly. +// Your ext-imap build must expose OP_XOAUTH2 for this to work. +$mailbox->enableOAuth($accessToken); + try { // Get all emails (messages) // PHP.net imap_search criteria: http://php.net/manual/en/function.imap-search.php diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index 8e1803b9..9ceaf59b 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -89,6 +89,10 @@ class Mailbox public const PART_TYPE_TWO = 2; + public const AUTHENTICATION_TYPE_PASSWORD = 'password'; + + public const AUTHENTICATION_TYPE_OAUTH = 'oauth'; + public const IMAP_OPTIONS_SUPPORTED_VALUES = OP_READONLY // 2 | OP_ANONYMOUS // 4 @@ -113,6 +117,12 @@ class Mailbox /** @var string */ protected $imapPassword; + /** @var string */ + protected $authenticationType = self::AUTHENTICATION_TYPE_PASSWORD; + + /** @var string|null */ + protected $imapOAuthToken = null; + /** @var int */ protected $imapSearchOption = SE_UID; @@ -371,6 +381,39 @@ public function getLogin(): string return $this->imapLogin; } + /** + * Enables OAuth-based authentication for the IMAP connection. + * + * The provided access token will be passed to imap_open() instead of the password. + * + * @throws InvalidParameterException + */ + public function enableOAuth(string $accessToken): void + { + if ('' === \trim($accessToken)) { + throw new InvalidParameterException('enableOAuth() expects a non-empty OAuth access token.'); + } + + $this->imapOAuthToken = $accessToken; + $this->authenticationType = self::AUTHENTICATION_TYPE_OAUTH; + } + + /** + * Disables OAuth-based authentication and switches back to password-based authentication. + */ + public function disableOAuth(): void + { + $this->authenticationType = self::AUTHENTICATION_TYPE_PASSWORD; + } + + /** + * Returns whether OAuth-based authentication is enabled for the IMAP connection. + */ + public function isOAuthEnabled(): bool + { + return self::AUTHENTICATION_TYPE_OAUTH === $this->authenticationType; + } + /** * Set custom connection arguments of imap_open method. See http://php.net/imap_open. * @@ -383,7 +426,7 @@ public function getLogin(): string public function setConnectionArgs(int $options = 0, int $retriesNum = 0, array $params = null): void { if (0 !== $options) { - if (($options & self::IMAP_OPTIONS_SUPPORTED_VALUES) !== $options) { + if (($options & $this->getSupportedImapOptions()) !== $options) { throw new InvalidParameterException('Please check your option for setConnectionArgs()! Unsupported option "'.$options.'". Available options: https://www.php.net/manual/de/function.imap-open.php'); } $this->imapOptions = $options; @@ -1709,8 +1752,8 @@ protected function initImapStream() $imapStream = Imap::open( $this->imapPath, $this->imapLogin, - $this->imapPassword, - $this->imapOptions, + $this->getImapOpenSecret(), + $this->getImapOpenOptions(), $this->imapRetriesNum, $this->imapParams ); @@ -1718,6 +1761,77 @@ protected function initImapStream() return $imapStream; } + /** + * Returns the supported imap_open() option bitmask for the current runtime. + */ + protected function getSupportedImapOptions(): int + { + $supportedOptions = self::IMAP_OPTIONS_SUPPORTED_VALUES; + + if (\defined('OP_XOAUTH2')) { + $oauthOption = \constant('OP_XOAUTH2'); + if (\is_int($oauthOption)) { + $supportedOptions |= $oauthOption; + } + } + + return $supportedOptions; + } + + /** + * Returns the credential that should be passed to imap_open(). + * + * @throws ConnectionException + */ + protected function getImapOpenSecret(): string + { + if (!$this->isOAuthEnabled()) { + return $this->imapPassword; + } + + if (!\is_string($this->imapOAuthToken) || '' === \trim($this->imapOAuthToken)) { + throw new ConnectionException(['OAuth authentication requires a non-empty access token.']); + } + + return $this->imapOAuthToken; + } + + /** + * Returns the option bitmask that should be passed to imap_open(). + * + * @throws ConnectionException + */ + protected function getImapOpenOptions(): int + { + $options = $this->imapOptions; + + if ($this->isOAuthEnabled()) { + $options |= $this->getOAuthImapOption(); + } + + return $options; + } + + /** + * Returns the runtime-specific OP_XOAUTH2 flag. + * + * @throws ConnectionException + */ + protected function getOAuthImapOption(): int + { + if (!\defined('OP_XOAUTH2')) { + throw new ConnectionException(['OAuth authentication requires an ext-imap build with OP_XOAUTH2 support.']); + } + + $oauthOption = \constant('OP_XOAUTH2'); + + if (!\is_int($oauthOption)) { + throw new ConnectionException(['OAuth authentication requires a valid OP_XOAUTH2 ext-imap constant.']); + } + + return $oauthOption; + } + /** * @param string|0 $partNum * diff --git a/tests/unit/Fixtures/Mailbox.php b/tests/unit/Fixtures/Mailbox.php index 0e9fa456..cf55aa45 100644 --- a/tests/unit/Fixtures/Mailbox.php +++ b/tests/unit/Fixtures/Mailbox.php @@ -13,8 +13,23 @@ public function getImapPassword(): string return $this->imapPassword; } + public function getImapOAuthToken(): ?string + { + return $this->imapOAuthToken; + } + public function getImapOptions(): int { return $this->imapOptions; } + + public function getImapOpenSecretForTests(): string + { + return $this->getImapOpenSecret(); + } + + public function getImapOpenOptionsForTests(): int + { + return $this->getImapOpenOptions(); + } } diff --git a/tests/unit/MailboxOAuthTest.php b/tests/unit/MailboxOAuthTest.php new file mode 100644 index 00000000..acc5e529 --- /dev/null +++ b/tests/unit/MailboxOAuthTest.php @@ -0,0 +1,129 @@ +getMailbox(); + + $mailbox->enableOAuth('oauth-access-token'); + + $this->assertTrue($mailbox->isOAuthEnabled()); + $this->assertSame('oauth-access-token', $mailbox->getImapOAuthToken()); + $this->assertSame('oauth-access-token', $mailbox->getImapOpenSecretForTests()); + $this->assertSame($this->password, $mailbox->getImapPassword()); + } + + public function testDisableOAuthFallsBackToPasswordForImapConnection(): void + { + $mailbox = $this->getMailbox(); + + $mailbox->enableOAuth('oauth-access-token'); + $mailbox->disableOAuth(); + + $this->assertFalse($mailbox->isOAuthEnabled()); + $this->assertSame($this->password, $mailbox->getImapOpenSecretForTests()); + } + + public function testEnableOAuthRejectsEmptyAccessToken(): void + { + $mailbox = $this->getMailbox(); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('enableOAuth() expects a non-empty OAuth access token.'); + + $mailbox->enableOAuth(' '); + } + + public function testGetImapOpenOptionsFailsClearlyWhenOAuthIsUnsupported(): void + { + if (\defined('OP_XOAUTH2')) { + $this->markTestSkipped('OP_XOAUTH2 is available in this runtime.'); + } + + $mailbox = $this->getMailbox(); + $mailbox->enableOAuth('oauth-access-token'); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('OAuth authentication requires an ext-imap build with OP_XOAUTH2 support.'); + + $mailbox->getImapOpenOptionsForTests(); + } + + public function testEnableOAuthAddsXoauth2FlagWhenRuntimeSupportsIt(): void + { + if (!\defined('OP_XOAUTH2')) { + $this->markTestSkipped('OP_XOAUTH2 is not available in this runtime.'); + } + + /** @var int $readonlyOption */ + $readonlyOption = \constant('OP_READONLY'); + /** @var int $oauthOption */ + $oauthOption = \constant('OP_XOAUTH2'); + + $mailbox = $this->getMailbox(); + $mailbox->setConnectionArgs($readonlyOption); + $mailbox->enableOAuth('oauth-access-token'); + + $this->assertSame($readonlyOption | $oauthOption, $mailbox->getImapOpenOptionsForTests()); + } + + public function testSetConnectionArgsAcceptsXoauth2WhenRuntimeSupportsIt(): void + { + if (!\defined('OP_XOAUTH2')) { + $this->markTestSkipped('OP_XOAUTH2 is not available in this runtime.'); + } + + /** @var int $oauthOption */ + $oauthOption = \constant('OP_XOAUTH2'); + + $mailbox = $this->getMailbox(); + $mailbox->setConnectionArgs($oauthOption); + + $this->assertSame($oauthOption, $mailbox->getImapOptions()); + } + + protected function getMailbox(): Fixtures\Mailbox + { + return new Fixtures\Mailbox( + $this->imapPath, + $this->login, + $this->password, + $this->attachmentsDir, + $this->serverEncoding + ); + } +} From 6d8b6ffd5a8f4c5da45837a20bfbbde5afc4f557 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 11:40:11 +0200 Subject: [PATCH 2/3] Update GitHub workflow dependencies - GitHub retired ubuntu-20.04 in April 2025 - GitHub retired actions/cache@v2 in March 2025 --- .github/workflows/php_code_coverage.yml | 8 ++++---- .github/workflows/php_static_analysis.yml | 8 ++++---- .github/workflows/php_unit_tests.yml | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/php_code_coverage.yml b/.github/workflows/php_code_coverage.yml index 9b216264..23a273d6 100644 --- a/.github/workflows/php_code_coverage.yml +++ b/.github/workflows/php_code_coverage.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.operating-system }} strategy: matrix: - operating-system: ['ubuntu-20.04'] + operating-system: ['ubuntu-24.04'] php-versions: ['8.1'] steps: @@ -22,7 +22,7 @@ jobs: php-version: ${{ matrix.php-versions }} coverage: xdebug - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Validate composer.json and composer.lock run: composer validate @@ -30,10 +30,10 @@ jobs: - name: Get Composer Cache Directory id: composer-cache run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" + echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" - name: Cache Files - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: | ${{ steps.composer-cache.outputs.dir }} diff --git a/.github/workflows/php_static_analysis.yml b/.github/workflows/php_static_analysis.yml index eb68eca5..d6a5d751 100644 --- a/.github/workflows/php_static_analysis.yml +++ b/.github/workflows/php_static_analysis.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.operating-system }} strategy: matrix: - operating-system: ['ubuntu-20.04'] + operating-system: ['ubuntu-24.04'] php-versions: ['7.4', '8.0', '8.1'] steps: @@ -22,7 +22,7 @@ jobs: php-version: ${{ matrix.php-versions }} coverage: none - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Validate composer.json and composer.lock run: composer validate @@ -30,10 +30,10 @@ jobs: - name: Get Composer Cache Directory id: composer-cache run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" + echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" - name: Cache Files - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: | ${{ steps.composer-cache.outputs.dir }} diff --git a/.github/workflows/php_unit_tests.yml b/.github/workflows/php_unit_tests.yml index fe1023f4..02767c6e 100644 --- a/.github/workflows/php_unit_tests.yml +++ b/.github/workflows/php_unit_tests.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.operating-system }} strategy: matrix: - operating-system: ['ubuntu-20.04'] + operating-system: ['ubuntu-24.04'] php-versions: ['7.4', '8.0', '8.1'] steps: @@ -22,7 +22,7 @@ jobs: php-version: ${{ matrix.php-versions }} coverage: none - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Validate composer.json and composer.lock run: composer validate @@ -30,10 +30,10 @@ jobs: - name: Get Composer Cache Directory id: composer-cache run: | - echo "::set-output name=dir::$(composer config cache-files-dir)" + echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" - name: Cache Files - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: | ${{ steps.composer-cache.outputs.dir }} From 7c581429f014885a5fb37eb4f100f0668dfbe1f6 Mon Sep 17 00:00:00 2001 From: Sebbo94BY Date: Sun, 10 May 2026 12:19:23 +0200 Subject: [PATCH 3/3] Update PHP support --- .github/workflows/php_code_coverage.yml | 2 +- .github/workflows/php_static_analysis.yml | 2 +- .github/workflows/php_unit_tests.yml | 2 +- README.md | 12 +++++++++--- composer.json | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/php_code_coverage.yml b/.github/workflows/php_code_coverage.yml index 23a273d6..5eca8851 100644 --- a/.github/workflows/php_code_coverage.yml +++ b/.github/workflows/php_code_coverage.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: operating-system: ['ubuntu-24.04'] - php-versions: ['8.1'] + php-versions: ['8.5'] steps: - name: Setup PHP diff --git a/.github/workflows/php_static_analysis.yml b/.github/workflows/php_static_analysis.yml index d6a5d751..3d5004f3 100644 --- a/.github/workflows/php_static_analysis.yml +++ b/.github/workflows/php_static_analysis.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: operating-system: ['ubuntu-24.04'] - php-versions: ['7.4', '8.0', '8.1'] + php-versions: ['8.2', '8.3', '8.4', '8.5'] steps: - name: Setup PHP diff --git a/.github/workflows/php_unit_tests.yml b/.github/workflows/php_unit_tests.yml index 02767c6e..52207b06 100644 --- a/.github/workflows/php_unit_tests.yml +++ b/.github/workflows/php_unit_tests.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: operating-system: ['ubuntu-24.04'] - php-versions: ['7.4', '8.0', '8.1'] + php-versions: ['8.2', '8.3', '8.4', '8.5'] steps: - name: Setup PHP diff --git a/README.md b/README.md index 678da026..5be0da1c 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,15 @@ Initially released in December 2012, the PHP IMAP Mailbox is a powerful and open | 7.1 | 3.x | End of life | | 7.2 | 3.x, 4.x | End of life | | 7.3 | 3.x, 4.x | End of life | -| 7.4 | >3.0.33, 4.x, 5.x | Active support | -| 8.0 | >3.0.33, 4.x, 5.x | Active support | -| 8.1 | >4.3.0, 5.x | Active support | +| 7.4 | >3.0.33, 4.x, 5.x | End of life | +| 8.0 | >3.0.33, 4.x, 5.x | End of life | +| 8.1 | >4.3.0, 5.x | End of life | +| 8.2 | 6.x | Active support | +| 8.3 | 6.x | Active support | +| 8.4 | 6.x | Active support | +| 8.5 | 6.x | Active support | + +The next major release raises the minimum supported PHP version to PHP 8.2 and is tested on PHP 8.2 through PHP 8.5. * PHP `fileinfo` extension must be present; so make sure this line is active in your php.ini: `extension=php_fileinfo.dll` * PHP `iconv` extension must be present; so make sure this line is active in your php.ini: `extension=php_iconv.dll` diff --git a/composer.json b/composer.json index 9b44d8e2..96c1dc99 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "sort-packages": true }, "require": { - "php": "^7.4 || ^8.0", + "php": "^8.2", "ext-fileinfo": "*", "ext-iconv": "*", "ext-imap": "*",