Skip to content

[6.2] Add generic XOAuth2 implementation #3820

Description

@jgerman-bot

New language relevant PR in upstream repo: joomla/joomla-cms#48290 Here are the upstream changes:

Click to expand the diff!
diff --git a/administrator/components/com_config/forms/application.xml b/administrator/components/com_config/forms/application.xml
index 3e9bb13234a0e..dc24060f14b05 100644
--- a/administrator/components/com_config/forms/application.xml
+++ b/administrator/components/com_config/forms/application.xml
@@ -586,22 +586,32 @@
 
 		<field
 			name="smtpauth"
-			type="radio"
-			layout="joomla.form.field.radio.switcher"
+			type="list"
 			label="COM_CONFIG_FIELD_MAIL_SMTP_AUTH_LABEL"
 			default="0"
 			showon="mailonline:1[AND]mailer:smtp"
-			filter="boolean"
+			filter="options"
 			>
-			<option value="0">JNO</option>
-			<option value="1">JYES</option>
+			<option value="0">JDISABLED</option>
+			<option value="1">COM_CONFIG_FIELD_SMTP_AUTH_VALUE_USERNAME_PASSWORD</option>
+			<option value="2">COM_CONFIG_FIELD_SMTP_AUTH_VALUE_OAUTH2</option>
 		</field>
 
+		<field
+			name="smtp_oauth2_info"
+			type="note"
+			label="COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_INFO_LABEL"
+			description="COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_INFO_DESCRIPTION"
+			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:2"
+			filter="string"
+			autocomplete="off"
+		/>
+
 		<field
 			name="smtpuser"
 			type="text"
 			label="COM_CONFIG_FIELD_MAIL_SMTP_USERNAME_LABEL"
-			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:1"
+			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:1,2"
 			filter="string"
 			autocomplete="off"
 		/>
@@ -616,6 +626,67 @@
 			lock="true"
 		/>
 
+		<field
+			name="smtp_oauth2_client_id"
+			type="text"
+			label="COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_CLIENT_ID_LABEL"
+			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:2"
+			filter="string"
+			autocomplete="off"
+		/>
+
+		<field
+			name="smtp_oauth2_client_secret"
+			type="password"
+			label="COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_CLIENT_SECRET_LABEL"
+			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:2"
+			filter="raw"
+			autocomplete="off"
+			lock="true"
+		/>
+
+		<field
+			name="smtp_oauth2_scope"
+			type="text"
+			label="COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_SCOPE_LABEL"
+			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:2"
+			filter="string"
+		/>
+
+		<field
+			name="smtp_oauth2_authorize_url"
+			type="url"
+			label="COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_AUTHORIZE_URL_LABEL"
+			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:2"
+			filter="string"
+		/>
+
+		<field
+			name="smtp_oauth2_token_url"
+			type="url"
+			label="COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_TOKEN_URL_LABEL"
+			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:2"
+			filter="string"
+		/>
+
+		<field
+			name="smtp_oauth2_auth_wizard"
+			type="Oauth2Token"
+			label="COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_GET_TOKEN_LABEL"
+			showon="mailonline:1[AND]mailer:smtp[AND]smtpauth:2"
+		/>
+
+		<field
+			name="smtp_oauth2_refresh_token"
+			type="hidden"
+			readonly="true"
+		/>
+
+		<field
+			name="smtp_oauth2_token_issued_at"
+			type="hidden"
+			readonly="true"
+		/>
 	</fieldset>
 
 	<fieldset
diff --git a/administrator/components/com_config/src/Controller/ApplicationController.php b/administrator/components/com_config/src/Controller/ApplicationController.php
index eb33421783efb..bb7dd859f87fb 100644
--- a/administrator/components/com_config/src/Controller/ApplicationController.php
+++ b/administrator/components/com_config/src/Controller/ApplicationController.php
@@ -243,6 +243,7 @@ public function removeroot()
      * @return  void
      *
      * @since   3.5
+     * @deprecated  6.2  Use the MailController instead.
      */
     public function sendtestmail()
     {
diff --git a/administrator/components/com_config/src/Controller/MailController.php b/administrator/components/com_config/src/Controller/MailController.php
new file mode 100644
index 0000000000000..7a3985964b4f0
--- /dev/null
+++ b/administrator/components/com_config/src/Controller/MailController.php
@@ -0,0 +1,404 @@
+<?php
+
+/**
+ * @package         Joomla.Administrator
+ * @subpackage      com_config
+ *
+ * @copyright      (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license         GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Config\Administrator\Controller;
+
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\MVC\Controller\BaseController;
+use Joomla\CMS\Response\JsonResponse;
+use Joomla\CMS\Router\Route;
+use Joomla\CMS\Session\Session;
+use Joomla\CMS\Uri\Uri;
+use Joomla\CMS\User\UserHelper;
+use Joomla\OAuth2\Client;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Mail Controller
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class MailController extends BaseController
+{
+    /**
+     * Redirects an authorized administrator to the provider authorization endpoint.
+     *
+     * @return  void
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    public function oauth2auth(): void
+    {
+        if (!$this->app->getIdentity()->authorise('core.admin')) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::_('JERROR_ALERTNOAUTHOR'),
+                'error',
+            );
+
+            return;
+        }
+
+        $providerConfig = $this->resolveProviderConfig();
+
+        if (empty($providerConfig['authorize_url']) || empty($providerConfig['client_id'])) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::_('COM_CONFIG_MAIL_OAUTH2_AUTHORIZE_CONFIG_MISSING'),
+                'warning',
+            );
+
+            return;
+        }
+
+        $state = UserHelper::genRandomPassword(32);
+
+        $this->app->getSession()->set('com_config.oauth2_state', $state);
+
+        try {
+            $oauth2 = $this->createOAuth2Client($providerConfig, $state);
+
+            $this->setRedirect($oauth2->createUrl());
+        } catch (\Throwable $e) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                $e->getMessage(),
+                'error',
+            );
+        }
+    }
+
+
+    /**
+     * Processes the OAuth2 callback and stores the received refresh token.
+     *
+     * @return  void
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    public function oauth2callback(): void
+    {
+        if (!$this->app->getIdentity()->authorise('core.admin')) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::_('JERROR_ALERTNOAUTHOR'),
+                'error',
+            );
+
+            return;
+        }
+
+        $session           = $this->app->getSession();
+        $stateFromProvider = $this->input->getString('state');
+        $stateFromSession  = $session->get('com_config.oauth2_state');
+
+        if (
+            !$stateFromProvider
+            || !$stateFromSession
+            || !hash_equals($stateFromSession, $stateFromProvider)
+        ) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::_('JINVALID_TOKEN'),
+                'error',
+            );
+
+            return;
+        }
+
+        $session->set('com_config.oauth2_state', null);
+
+        $error = $this->input->getString('error', '');
+
+        if ($error !== '') {
+            $description = $this->input->getString('error_description');
+
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                $description ?: $error,
+                'error'
+            );
+
+            return;
+        }
+
+        if (!$this->input->getString('code')) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::_('JERROR_AN_ERROR_HAS_OCCURRED'),
+                'error',
+            );
+
+            return;
+        }
+
+        $providerConfig = $this->resolveProviderConfig();
+
+        if (
+            empty($providerConfig['token_url'])
+            || empty($providerConfig['client_id'])
+            || empty($providerConfig['client_secret'])
+        ) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::_('COM_CONFIG_MAIL_OAUTH2_TOKEN_CONFIG_MISSING'),
+                'warning',
+            );
+
+            return;
+        }
+
+        try {
+            $oauth2 = $this->createOAuth2Client($providerConfig);
+
+            $token = $oauth2->authenticate();
+
+            if (
+                !\is_array($token)
+                || empty($token['refresh_token'])
+            ) {
+                throw new \RuntimeException(
+                    Text::_('COM_CONFIG_MAIL_OAUTH2_TOKEN_CREATE_FAILED'),
+                );
+            }
+
+            $refreshToken = (string)$token['refresh_token'];
+        } catch (\Throwable $e) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                $e->getMessage(),
+                'error',
+            );
+
+            return;
+        }
+
+        $issuedAt = gmdate('Y-m-d H:i:s') . ' UTC';
+
+        $formData = (array)$this->app->getUserState(
+            'com_config.config.global.data',
+            [],
+        );
+
+        $formData['smtp_oauth2_refresh_token']   = $refreshToken;
+        $formData['smtp_oauth2_token_issued_at'] = $issuedAt;
+
+        $this->app->setUserState(
+            'com_config.config.global.data',
+            $formData,
+        );
+
+        try {
+            $model    = $this->getModel('Application', 'Administrator');
+            $saveData = $model->getData();
+
+            $saveData['smtp_oauth2_refresh_token']   = $refreshToken;
+            $saveData['smtp_oauth2_token_issued_at'] = $issuedAt;
+
+            if (!$model->save($saveData)) {
+                throw new \RuntimeException(
+                    Text::_('COM_CONFIG_ERROR_WRITE_FAILED'),
+                );
+            }
+        } catch (\Throwable $e) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::sprintf(
+                    'COM_CONFIG_MAIL_OAUTH2_TOKEN_SAVE_FAILED',
+                    $e->getMessage(),
+                ),
+                'warning',
+            );
+
+            return;
+        }
+
+        $this->setRedirect(
+            Route::_('index.php?option=com_config&view=application', false),
+            Text::_('COM_CONFIG_MAIL_OAUTH2_TOKEN_SAVED'),
+            'message',
+        );
+    }
+
+    /**
+     * Checks whether the configured OAuth2 refresh token can be exchanged successfully.
+     *
+     * @return  void
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    public function oauth2checktoken(): void
+    {
+        if (!$this->app->getIdentity()->authorise('core.admin')) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::_('JERROR_ALERTNOAUTHOR'),
+                'error',
+            );
+
+            return;
+        }
+
+        $providerConfig = $this->resolveProviderConfig();
+
+        if (
+            empty($providerConfig['token_url'])
+            || empty($providerConfig['client_id'])
+            || empty($providerConfig['client_secret'])
+            || empty($providerConfig['refresh_token'])
+        ) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::_('COM_CONFIG_MAIL_OAUTH2_TOKEN_CHECK_MISSING'),
+                'warning',
+            );
+
+            return;
+        }
+
+        try {
+            $oauth2 = $this->createOAuth2Client($providerConfig);
+
+            $token = $oauth2->refreshToken(
+                $providerConfig['refresh_token'],
+            );
+
+            if (
+                !\is_array($token)
+                || empty($token['access_token'])
+            ) {
+                throw new \RuntimeException(
+                    Text::_('COM_CONFIG_MAIL_OAUTH2_TOKEN_CHECK_INVALID'),
+                );
+            }
+
+            $expiresIn = isset($token['expires_in'])
+                ? (int)$token['expires_in']
+                : 0;
+
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::sprintf(
+                    'COM_CONFIG_MAIL_OAUTH2_TOKEN_CHECK_VALID',
+                    $expiresIn,
+                ),
+                'message',
+            );
+        } catch (\Throwable $e) {
+            $this->setRedirect(
+                Route::_('index.php?option=com_config&view=application', false),
+                Text::sprintf(
+                    'COM_CONFIG_MAIL_OAUTH2_TOKEN_CHECK_FAILED',
+                    $e->getMessage(),
+                ),
+                'error',
+            );
+        }
+    }
+
+    /**
+     * Creates the OAuth2 client.
+     *
+     * @param array<string, string>  $providerConfig OAuth2 provider configuration.
+     * @param string                 $state          OAuth2 state value.
+     *
+     * @return  Client
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    private function createOAuth2Client(array  $providerConfig, string $state = ''): Client
+    {
+        $requestParams = [
+            'response_mode' => 'query',
+            'prompt'        => 'consent',
+            'access_type'   => 'offline',
+        ];
+
+        $options = [
+            'authurl'       => $providerConfig['authorize_url'],
+            'tokenurl'      => $providerConfig['token_url'],
+            'clientid'      => $providerConfig['client_id'],
+            'clientsecret'  => $providerConfig['client_secret'],
+            'redirecturi'   => Uri::base() . 'index.php?option=com_config&task=mail.oauth2callback&format=raw',
+            'scope'         => $providerConfig['scope'],
+            'state'         => $state,
+            'requestparams' => $requestParams,
+            'userefresh'    => true,
+            'sendheaders'   => false,
+        ];
+
+        return new Client(
+            $options,
+            null,
+            $this->input,
+        );
+    }
+
+    /**
+     * Build OAuth2 configuration from global configuration.
+     *
+     * @return  array<string, string>
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    private function resolveProviderConfig(): array
+    {
+        $params = $this->app->getConfig();
+
+        return [
+            'client_id'     => $params->get('smtp_oauth2_client_id', ''),
+            'client_secret' => $params->get('smtp_oauth2_client_secret', ''),
+            'refresh_token' => $params->get('smtp_oauth2_refresh_token', ''),
+            'scope'         => $params->get('smtp_oauth2_scope', ''),
+            'authorize_url' => $params->get('smtp_oauth2_authorize_url', ''),
+            'token_url'     => $params->get('smtp_oauth2_token_url', ''),
+        ];
+    }
+
+    /**
+     * Method to send the test mail.
+     *
+     * @return  void
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    public function sendtestmail()
+    {
+        // Send json mime type.
+        $this->app->mimeType = 'application/json';
+        $this->app->setHeader('Content-Type', $this->app->mimeType . '; charset=' . $this->app->charSet);
+        $this->app->sendHeaders();
+
+        // Check if user token is valid.
+        if (!Session::checkToken()) {
+            $this->app->enqueueMessage(Text::_('JINVALID_TOKEN'), 'error');
+            echo new JsonResponse();
+            $this->app->close();
+        }
+
+        // Check if the user is authorized to do this.
+        if (!$this->app->getIdentity()->authorise('core.admin')) {
+            $this->app->enqueueMessage(Text::_('JERROR_ALERTNOAUTHOR'), 'error');
+            echo new JsonResponse();
+            $this->app->close();
+        }
+
+        /** @var \Joomla\Component\Config\Administrator\Model\ApplicationModel $model */
+        $model = $this->getModel('Application', 'Administrator');
+
+        echo new JsonResponse($model->sendTestMail());
+
+        $this->app->close();
+    }
+
+}
diff --git a/administrator/components/com_config/src/Field/Oauth2TokenField.php b/administrator/components/com_config/src/Field/Oauth2TokenField.php
new file mode 100644
index 0000000000000..94b1ec186e965
--- /dev/null
+++ b/administrator/components/com_config/src/Field/Oauth2TokenField.php
@@ -0,0 +1,69 @@
+<?php
+
+/**
+ * @package     Joomla.Administrator
+ * @subpackage  com_config
+ *
+ * @copyright   (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Config\Administrator\Field;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+use Joomla\CMS\Form\FormField;
+use Joomla\CMS\Uri\Uri;
+
+/**
+ * Renders OAuth2 token actions for Global Configuration mail settings.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class Oauth2TokenField extends FormField
+{
+    /**
+     * The form field type.
+     *
+     * @var    string
+     * @since  __DEPLOY_VERSION__
+     */
+    protected $type = 'Oauth2Token';
+
+    /**
+     * The layout to render.
+     *
+     * @var    string
+     * @since  __DEPLOY_VERSION__
+     */
+    protected $layout = 'joomla.form.field.oauth2token';
+
+    /**
+     * Build the OAuth2 token action UI.
+     *
+     * @return  string
+     *
+     * @since   __DEPLOY_VERSION__
+     */
+    protected function getInput()
+    {
+
+        $formData      = $this->form->getData();
+        $clientId      = (string) $formData->get('smtp_oauth2_client_id');
+        $refreshToken  = (string) $formData->get('smtp_oauth2_refresh_token');
+        $tokenIssuedAt = (string) $formData->get('smtp_oauth2_token_issued_at');
+
+        $data = $this->getLayoutData();
+
+        $data['clientId']      = $clientId;
+        $data['refreshToken']  = !empty($refreshToken);
+        $data['tokenIssuedAt'] = $tokenIssuedAt;
+        $data['callbackUrl']   = Uri::base() . 'index.php?option=com_config&task=mail.oauth2callback&format=raw';
+        $data['issueUrl']      = 'index.php?option=com_config&task=mail.oauth2auth';
+        $data['checkUrl']      = 'index.php?option=com_config&task=mail.oauth2checktoken';
+
+        return $this->getRenderer($this->layout)->render($data);
+    }
+}
diff --git a/administrator/components/com_config/src/Model/ApplicationModel.php b/administrator/components/com_config/src/Model/ApplicationModel.php
index e5223f60beb56..2a66c4c80212a 100644
--- a/administrator/components/com_config/src/Model/ApplicationModel.php
+++ b/administrator/components/com_config/src/Model/ApplicationModel.php
@@ -62,7 +62,7 @@ class ApplicationModel extends FormModel implements MailerFactoryAwareInterface,
      * @var    array
      * @since  3.9.23
      */
-    private $protectedConfigurationFields = ['password', 'secret', 'smtppass', 'redis_server_auth', 'session_redis_server_auth'];
+    private $protectedConfigurationFields = ['password', 'secret', 'smtppass', 'smtp_oauth2_client_secret', 'smtp_oauth2_refresh_token', 'redis_server_auth', 'session_redis_server_auth'];
 
     /**
      * Method to get a form object.
@@ -1207,6 +1207,14 @@ public function sendTestMail()
         $config->set('mailer', $input->get('mailer'));
         $config->set('mailonline', $input->get('mailonline'));
 
+        // We do not load the current oauth2 information since this information needs to be already saved after authorization
+        $config->set('smtp_oauth2_client_id', $app->get('smtp_oauth2_client_id', ''));
+        $config->set('smtp_oauth2_client_secret', $app->get('smtp_oauth2_client_secret', ''));
+        $config->set('smtp_oauth2_scope', $app->get('smtp_oauth2_scope', ''));
+        $config->set('smtp_oauth2_authorize_url', $app->get('smtp_oauth2_authorize_url', ''));
+        $config->set('smtp_oauth2_token_url', $app->get('smtp_oauth2_token_url', ''));
+        $config->set('smtp_oauth2_refresh_token', $app->get('smtp_oauth2_refresh_token', ''));
+
         // Use smtppass only if it was submitted
         if ($smtppass !== null) {
             $config->set('smtppass', $smtppass);
diff --git a/administrator/components/com_config/tmpl/application/default_mail.php b/administrator/components/com_config/tmpl/application/default_mail.php
index 955b7750b6ea2..6a82956cd055a 100644
--- a/administrator/components/com_config/tmpl/application/default_mail.php
+++ b/administrator/components/com_config/tmpl/application/default_mail.php
@@ -36,7 +36,7 @@
 Text::script('JLIB_JS_AJAX_ERROR_TIMEOUT');
 
 // Ajax request data.
-$ajaxUri = Route::_('index.php?option=com_config&task=application.sendtestmail&format=json');
+$ajaxUri = Route::_('index.php?option=com_config&task=mail.sendtestmail&format=json');
 
 $this->name = Text::_('COM_CONFIG_MAIL_SETTINGS');
 $this->description = '';
diff --git a/administrator/language/en-GB/com_config.ini b/administrator/language/en-GB/com_config.ini
index a512f603f88d9..985b101bb1b40 100644
--- a/administrator/language/en-GB/com_config.ini
+++ b/administrator/language/en-GB/com_config.ini
@@ -130,7 +130,17 @@ COM_CONFIG_FIELD_MAIL_REPLY_TO_EMAIL_LABEL="Reply To Email"
 COM_CONFIG_FIELD_MAIL_REPLY_TO_NAME_LABEL="Reply To Name"
 COM_CONFIG_FIELD_MAIL_SENDMAIL_PATH_LABEL="Sendmail Path"
 COM_CONFIG_FIELD_MAIL_SMTP_AUTH_LABEL="SMTP Authentication"
+COM_CONFIG_FIELD_SMTP_AUTH_VALUE_OAUTH2="OAuth 2.0"
+COM_CONFIG_FIELD_SMTP_AUTH_VALUE_USERNAME_PASSWORD="Username and Password"
 COM_CONFIG_FIELD_MAIL_SMTP_HOST_LABEL="SMTP Host"
+COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_AUTHORIZE_URL_LABEL="OAuth2 Authorize URL"
+COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_CLIENT_ID_LABEL="OAuth2 Client ID"
+COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_CLIENT_SECRET_LABEL="OAuth2 Client Secret"
+COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_GET_TOKEN_LABEL="OAuth2 Token"
+COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_INFO_DESCRIPTION="For configuration help, please refer to the <a href=\"https://guide.joomla.org/user-manual/configuration/sending-email-using-oauth-2-0\" target=\"_blank\" rel=\"noopener noreferrer\">Joomla Documentation</a>.<br>The call back URL for your OAuth2 application is: <br><code>https://[domain]/administrator/index.php?option=com_config&task=mail.oauth2callback&format=raw</code><br>In most cases the domain should satisfy the requirements."
+COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_INFO_LABEL="OAuth2 Information"
+COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_SCOPE_LABEL="OAuth2 Scope"
+COM_CONFIG_FIELD_MAIL_SMTP_OAUTH2_TOKEN_URL_LABEL="OAuth2 Token URL"
 COM_CONFIG_FIELD_MAIL_SMTP_PASSWORD_LABEL="SMTP Password"
 COM_CONFIG_FIELD_MAIL_SMTP_PORT_LABEL="SMTP Port"
 COM_CONFIG_FIELD_MAIL_SMTP_SECURE_LABEL="SMTP Security"
@@ -234,6 +244,26 @@ COM_CONFIG_LOCATION_SETTINGS="Location"
 COM_CONFIG_LOGGING="Logging"
 COM_CONFIG_LOGGING_CUSTOM_SETTINGS="Custom logging"
 COM_CONFIG_LOGGING_SETTINGS="Logging"
+; todo check start
+COM_CONFIG_MAIL_OAUTH2_AUTHORIZE_CONFIG_MISSING="OAuth2 authorize URL or client ID is missing."
+COM_CONFIG_MAIL_OAUTH2_BUTTON_CHECK="Validate Token"
+COM_CONFIG_MAIL_OAUTH2_BUTTON_ISSUE="Get OAuth2 Token"
+COM_CONFIG_MAIL_OAUTH2_BUTTON_REISSUE="Reissue OAuth2 Token"
+COM_CONFIG_MAIL_OAUTH2_BUTTON_TITLE="Get OAuth2 Token"
+COM_CONFIG_MAIL_OAUTH2_BUTTON_TITLE_REISSUE="Reissue OAuth2 Token"
+COM_CONFIG_MAIL_OAUTH2_BUTTON_TITLE_VALIDATE="Validate Token"
+COM_CONFIG_MAIL_OAUTH2_CALLBACK_HINT="Make sure callback URL %s is set in provider setup"
+COM_CONFIG_MAIL_OAUTH2_CLIENT_ID_REQUIRED="OAuth2 Client ID required to request token"
+COM_CONFIG_MAIL_OAUTH2_TOKEN_CHECK_FAILED="Token check failed: %s"
+COM_CONFIG_MAIL_OAUTH2_TOKEN_CHECK_INVALID="Token is invalid or refresh failed."
+COM_CONFIG_MAIL_OAUTH2_TOKEN_CHECK_MISSING="Token validation requires client ID, client secret and refresh token."
+COM_CONFIG_MAIL_OAUTH2_TOKEN_CHECK_VALID="Token is valid. Access token expires in %d seconds."
+COM_CONFIG_MAIL_OAUTH2_TOKEN_CONFIG_MISSING="OAuth2 token URL, client ID or client secret is missing."
+COM_CONFIG_MAIL_OAUTH2_TOKEN_CREATE_FAILED="Failed to acquire OAuth2 refresh token."
+COM_CONFIG_MAIL_OAUTH2_TOKEN_ISSUED_AT="Token issued at: %s"
+COM_CONFIG_MAIL_OAUTH2_TOKEN_SAVED="OAuth2 token received and saved."
+COM_CONFIG_MAIL_OAUTH2_TOKEN_SAVE_FAILED="OAuth2 token received but could not be saved automatically: %s"
+; todo check end
 COM_CONFIG_MAIL_SETTINGS="Mail"
 COM_CONFIG_MAIL_TEST_MAIL_DESC="Sent when you click the &quot;Send Test Mail&quot; button in the Global Configuration. It is sent to the sending mail address that is set in the mail settings."
 COM_CONFIG_MAIL_TEST_MAIL_TITLE="Global Configuration: Test Mail"
diff --git a/layouts/joomla/form/field/oauth2token.php b/layouts/joomla/form/field/oauth2token.php
new file mode 100644
index 0000000000000..e574c88fd3b4b
--- /dev/null
+++ b/layouts/joomla/form/field/oauth2token.php
@@ -0,0 +1,57 @@
+<?php
+
+/**
+ * @package     Joomla.Administrator
+ * @subpackage  com_config
+ *
+ * @copyright   (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\Router\Route;
+
+/** @var array $displayData */
+
+$clientId      = (string) ($displayData['clientId'] ?? '');
+$refreshToken  = $displayData['refreshToken'] ?? false;
+$tokenIssuedAt = (string) ($displayData['tokenIssuedAt'] ?? '');
+$callbackUrl   = (string) ($displayData['callbackUrl'] ?? '');
+$issueUrl      = (string) ($displayData['issueUrl'] ?? '');
+$checkUrl      = (string) ($displayData['checkUrl'] ?? '');
+
+if ($clientId === '') {
+    echo '<div class="alert alert-warning">' . Text::_('COM_CONFIG_MAIL_OAUTH2_CLIENT_ID_REQUIRED') . '</div>';
+
+    return;
+}
+
+?>
+<div class="d-flex flex-column gap-2">
+    <div class="btn-group" role="group">
+        <a class="btn btn-primary" href="<?php echo Joomla\CMS\Router\Route::_($issueUrl); ?>">
+            <?php echo Text::_('COM_CONFIG_MAIL_OAUTH2_BUTTON_' . ($refreshToken ? 'REISSUE' : 'ISSUE')); ?>
+        </a>
+        <a
+            class="btn btn-outline-secondary<?php echo !$refreshToken ? ' disabled' : ''; ?>"
+            href="<?php echo !$refreshToken ? '#' : Route::_($checkUrl); ?>"
+            <?php echo !$refreshToken ? 'aria-disabled="true"' : ''; ?>
+        >
+            <?php echo Text::_('COM_CONFIG_MAIL_OAUTH2_BUTTON_CHECK'); ?>
+        </a>
+    </div>
+
+    <?php if ($tokenIssuedAt !== '') : ?>
+        <div class="small text-muted">
+            <?php echo Text::sprintf('COM_CONFIG_MAIL_OAUTH2_TOKEN_ISSUED_AT', $tokenIssuedAt); ?>
+        </div>
+    <?php endif; ?>
+
+    <div class="small text-muted">
+        <?php echo Text::sprintf('COM_CONFIG_MAIL_OAUTH2_CALLBACK_HINT', $callbackUrl); ?>
+    </div>
+</div>
diff --git a/libraries/src/Mail/Mail.php b/libraries/src/Mail/Mail.php
index 873bec62d7d02..a0604e674bae0 100644
--- a/libraries/src/Mail/Mail.php
+++ b/libraries/src/Mail/Mail.php
@@ -541,20 +541,21 @@ public function useSendmail($sendmail = null)
     /**
      * Use SMTP for sending the email
      *
-     * @param   string   $auth    SMTP Authentication [optional]
-     * @param   string   $host    SMTP Host [optional]
-     * @param   string   $user    SMTP Username [optional]
-     * @param   string   $pass    SMTP Password [optional]
-     * @param   string   $secure  Use secure methods
-     * @param   integer  $port    The SMTP port
+     * @param integer  $auth               SMTP Authentication [optional]
+     * @param string   $host               SMTP Host [optional]
+     * @param string   $user               SMTP Username [optional]
+     * @param string   $pass               SMTP Password [optional]
+     * @param string   $secure             Use secure methods
+     * @param integer  $port               The SMTP port
+     * @param object   $oauthTokenProvider The OAuth token provider
      *
-     * @return  boolean  True on success
+     * @return boolean  True on success
      *
      * @since   1.7.0
      */
-    public function useSmtp($auth = null, $host = null, $user = null, $pass = null, $secure = null, $port = 25)
+    public function useSmtp($auth = null, $host = null, $user = null, $pass = null, $secure = null, $port = 25, $oauthTokenProvider = null)
     {
-        $this->SMTPAuth = $auth;
+        $this->SMTPAuth = !empty($auth);
         $this->Host     = $host;
         $this->Username = $user;
         $this->Password = $pass;
@@ -564,9 +565,15 @@ public function useSmtp($auth = null, $host = null, $user = null, $pass = null,
             $this->SMTPSecure = $secure;
         }
 
+        if ($oauthTokenProvider) {
+            $this->AuthType = 'XOAUTH2';
+            $this->setOAuth($oauthTokenProvider);
+        }
+
         if (
-            ($this->SMTPAuth !== null && $this->Host !== null && $this->Username !== null && $this->Password !== null)
-            || ($this->SMTPAuth === null && $this->Host !== null)
+            ($this->SMTPAuth && $this->Host !== null && $this->Username !== null && $this->Password !== null)
+            || ($this->SMTPAuth && $this->Host !== null && $this->Username !== null && $this->AuthType === 'XOAUTH2')
+            || (!$this->SMTPAuth && $this->Host !== null)
         ) {
             $this->isSMTP();
 
diff --git a/libraries/src/Mail/MailOAuth2TokenProvider.php b/libraries/src/Mail/MailOAuth2TokenProvider.php
new file mode 100644
index 0000000000000..bc982080f9fe4
--- /dev/null
+++ b/libraries/src/Mail/MailOAuth2TokenProvider.php
@@ -0,0 +1,87 @@
+<?php
+
+/**
+ * Joomla! Content Management System
+ *
+ * @copyright  (C) 2026 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license    GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\CMS\Mail;
+
+use Joomla\OAuth2\Client;
+use PHPMailer\PHPMailer\OAuthTokenProvider;
+
+// phpcs:disable PSR1.Files.SideEffects
+\defined('_JEXEC') or die;
+// phpcs:enable PSR1.Files.SideEffects
+
+/**
+ * Generic OAuth token provider for SMTP XOAUTH2 authentication.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+final class MailOAuth2TokenProvider implements OAuthTokenProvider
+{
+    /**
+     * @var string|null Access token
+     */
+    private ?string $accessToken = null;
+
+    /** @var int Expiration time */
+    private int $expiresAt = 0;
+
+    public function __construct(
+        private string $tokenUrl,
+        private string $clientId,
+        private string $clientSecret,
+        private string $refreshToken,
+        private string $userName
+    ) {
+    }
+
+    /**
+     * Generate a base64-encoded OAuth token ensuring that the access token has not expired.
+     * The string to be base 64 encoded should be in the form:
+     * "user=<user_email_address>\001auth=Bearer <access_token>\001\001"
+     *
+     * @return string
+     */
+    public function getOauth64(): string
+    {
+        if (!$this->accessToken || time() >= $this->expiresAt) {
+            $this->requestAccessToken();
+        }
+
+        return base64_encode('user=' . $this->userName . "\001auth=Bearer " . $this->accessToken . "\001\001");
+    }
+
+    /**
+     * Request a new access token using the refresh token.
+     *
+     * @return void
+     */
+    private function requestAccessToken(): void
+    {
+        $oauth2 = new Client([
+            'tokenurl'     => $this->tokenUrl,
+            'clientid'     => $this->clientId,
+            'clientsecret' => $this->clientSecret,
+            'userefresh'   => true,
+        ]);
+
+        $token = $oauth2->refreshToken($this->refreshToken);
+
+        if (empty($token['access_token'])) {
+            throw new \RuntimeException(
+                'Failed to acquire SMTP OAuth2 access token.'
+            );
+        }
+
+        $this->accessToken = (string) $token['access_token'];
+
+        $expiresIn = isset($token['expires_in']) ? (int) $token['expires_in'] : 3600;
+
+        $this->expiresAt = $token['created'] + max(60, $expiresIn - 60);
+    }
+}
diff --git a/libraries/src/Mail/MailerFactory.php b/libraries/src/Mail/MailerFactory.php
index 4efae62a2d5ab..887931d1b7b3e 100644
--- a/libraries/src/Mail/MailerFactory.php
+++ b/libraries/src/Mail/MailerFactory.php
@@ -31,6 +31,11 @@ class MailerFactory implements MailerFactoryInterface
      */
     private $defaultConfiguration;
 
+    /**
+     * @var array Cached TokenProvider
+     */
+    private array $oauth2TokenProvider = [];
+
     /**
      * The MailerFactory constructor.
      *
@@ -61,7 +66,7 @@ public function createMailer(?Registry $settings = null): MailerInterface
 
         $mailer = new Mail((bool) $configuration->get('throw_exceptions', true));
 
-        $smtpauth   = $configuration->get('smtpauth') == 0 ? null : 1;
+        $smtpauth   = (int) $configuration->get('smtpauth');
         $smtpuser   = $configuration->get('smtpuser');
         $smtppass   = $configuration->get('smtppass');
         $smtphost   = $configuration->get('smtphost');
@@ -90,7 +95,40 @@ public function createMailer(?Registry $settings = null): MailerInterface
         // Default mailer is to use PHP's mail function
         switch ($mailType) {
             case 'smtp':
-                $mailer->useSmtp($smtpauth, $smtphost, $smtpuser, $smtppass, $smtpsecure, $smtpport);
+                $oauth2TokenProvider = null;
+
+                if ($smtpauth === 2) {
+                    $oauth2ClientId     = $configuration->get('smtp_oauth2_client_id');
+                    $oauth2ClientSecret = $configuration->get('smtp_oauth2_client_secret');
+                    $oauth2RefreshToken = $configuration->get('smtp_oauth2_refresh_token');
+                    $oauth2TokenUrl     = $configuration->get('smtp_oauth2_token_url');
+
+                    if (!$smtpuser || !$oauth2ClientId || !$oauth2ClientSecret || !$oauth2RefreshToken || !$oauth2TokenUrl) {
+                        throw new \RuntimeException('OAuth2 SMTP configuration is incomplete.');
+                    }
+
+                    $tokenProviderHash = md5(
+                        $oauth2TokenUrl . ':' .
+                        $oauth2ClientId . ':' .
+                        $oauth2ClientSecret . ':' .
+                        $oauth2RefreshToken . ':' .
+                        $smtpuser
+                    );
+
+                    if (empty($this->oauth2TokenProvider[$tokenProviderHash])) {
+                        $this->oauth2TokenProvider[$tokenProviderHash] = new MailOAuth2TokenProvider(
+                            $oauth2TokenUrl,
+                            $oauth2ClientId,
+                            $oauth2ClientSecret,
+                            $oauth2RefreshToken,
+                            $smtpuser
+                        );
+                    }
+
+                    $oauth2TokenProvider = $this->oauth2TokenProvider[$tokenProviderHash];
+                }
+
+                $mailer->useSmtp($smtpauth, $smtphost, $smtpuser, $smtppass, $smtpsecure, $smtpport, $oauth2TokenProvider);
                 break;
 
             case 'sendmail':
diff --git a/libraries/src/Mail/TransportConfigurableMailerInterface.php b/libraries/src/Mail/TransportConfigurableMailerInterface.php
index afcbe612dbc5e..2ee84585cd9d4 100644
--- a/libraries/src/Mail/TransportConfigurableMailerInterface.php
+++ b/libraries/src/Mail/TransportConfigurableMailerInterface.php
@@ -23,18 +23,19 @@ interface TransportConfigurableMailerInterface
     /**
      * Use SMTP for sending the email.
      *
-     * @param   string   $auth    SMTP Authentication [optional]
-     * @param   string   $host    SMTP Host [optional]
-     * @param   string   $user    SMTP Username [optional]
-     * @param   string   $pass    SMTP Password [optional]
-     * @param   string   $secure  Use secure methods
-     * @param   integer  $port    The SMTP port
+     * @param integer  $auth               SMTP Authentication [optional]
+     * @param string   $host               SMTP Host [optional]
+     * @param string   $user               SMTP Username [optional]
+     * @param string   $pass               SMTP Password [optional]
+     * @param string   $secure             Use secure methods
+     * @param integer  $port               The SMTP port
+     * @param object   $oauthTokenProvider The OAuth token provider
      *
      * @return  boolean  True on success
      *
      * @since   6.2.0
      */
-    public function useSmtp($auth = null, $host = null, $user = null, $pass = null, $secure = null, $port = 25);
+    public function useSmtp($auth = null, $host = null, $user = null, $pass = null, $secure = null, $port = 25, $oauthTokenProvider = null);
 
     /**
      * Use sendmail for sending the email.
diff --git a/media_source/system/js/fields/joomla-field-send-test-mail.w-c.es6.js b/media_source/system/js/fields/joomla-field-send-test-mail.w-c.es6.js
index ed3c7021f5edf..daa074e594640 100644
--- a/media_source/system/js/fields/joomla-field-send-test-mail.w-c.es6.js
+++ b/media_source/system/js/fields/joomla-field-send-test-mail.w-c.es6.js
@@ -24,7 +24,7 @@
 
     sendTestMail() {
       const emailData = {
-        smtpauth: document.getElementById('jform_smtpauth1').checked ? 1 : 0,
+        smtpauth: this.querySelector('[name="jform[smtpauth]"]').value,
         smtpuser: this.querySelector('[name="jform[smtpuser]"]').value,
         smtphost: this.querySelector('[name="jform[smtphost]"]').value,
         smtpsecure: this.querySelector('[name="jform[smtpsecure]"]').value,

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions