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 "Send Test Mail" 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,
New language relevant PR in upstream repo: joomla/joomla-cms#48290 Here are the upstream changes:
Click to expand the diff!