diff --git a/Changelog_iseta.md b/Changelog_iseta.md
new file mode 100644
index 0000000000000..73ad19fbe6254
--- /dev/null
+++ b/Changelog_iseta.md
@@ -0,0 +1,36 @@
+# Changelog iseta — patchs spécifiques cœur Dolibarr
+
+Modifications spécifiques iseta appliquées au cœur Dolibarr sur la branche `22.0_iseta`
+(hors modules `htdocs/custom/`). Chaque changement est marqué en code par `BACKPORT PR #37329`
+(ou une référence équivalente) pour être retrouvé lors des montées de version.
+
+## 2026-07-16 — Backport OAuth Microsoft Exchange Online (PR Dolibarr #37329)
+
+Backport de la fonctionnalité « Microsoft Exchange Online [SMTP/IMAP] OAuth2 » depuis la
+branche 23.0 vers la 22.0.
+
+- **Source** : https://github.com/Dolibarr/dolibarr/pull/37329 (mergée en 23.0)
+- **Périmètre** : nouveau provider OAuth `MICROSOFT3` (scopes `outlook.office.com` :
+ `SMTP.Send` + `IMAP.AccessAsUser.All`, `offline_access`), pour l'envoi/lecture de mail
+ via OAuth2 sur Exchange Online.
+
+### Fichiers ajoutés (copie conforme de la 23.0)
+- `htdocs/includes/OAuth/OAuth2/Service/Microsoft3.php`
+- `htdocs/core/modules/oauth/microsoft3_oauthcallback.php`
+
+### Fichiers modifiés (hunks OAuth uniquement, jamais de copie complète)
+- `htdocs/core/lib/oauth.lib.php` — enregistrement du provider `MICROSOFT3`
+ (`getAllOauth2Array` + `getSupportedOauth2Array`)
+- `htdocs/admin/oauth.php` — affichage du champ Tenant pour `MICROSOFT3`
+- `htdocs/core/modules/oauth/microsoft_oauthcallback.php` — gestion du paramètre `state`
+- `htdocs/core/class/CMailFile.class.php` — `setAuthMode('XOAUTH2')` en tête du bloc XOAUTH2
+ + `dol_syslog` quand l'objet token est invalide
+- `htdocs/langs/en_US/oauth.lang`, `htdocs/langs/fr_FR/oauth.lang` — clé `OAuthErrorStateDiffers`
+
+### Volontairement EXCLUS de la PR (bugfixes sans rapport avec OAuth)
+- `htdocs/compta/facture/card.php` (guard `fetch_thirdparty`)
+- `htdocs/website/class/websitepage.class.php` (`$this->errors[]`)
+- suppressions cosmétiques de lignes vides dans `CMailFile.class.php`
+
+### Intégration
+- Branche : `NEW/oauth/PR37329/MicrosoftExchangeOAuth` → PR vers `22.0_iseta` (remote `atm`).
diff --git a/htdocs/admin/oauth.php b/htdocs/admin/oauth.php
index dea7e1e8d5360..eae48e0368676 100644
--- a/htdocs/admin/oauth.php
+++ b/htdocs/admin/oauth.php
@@ -442,7 +442,8 @@
print '';
// Tenant
- if ($keybeforeprovider == 'MICROSOFT' || $keybeforeprovider == 'MICROSOFT2') {
+ // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth (MICROSOFT3 tenant field)
+ if ($keybeforeprovider == 'MICROSOFT' || $keybeforeprovider == 'MICROSOFT2' || $keybeforeprovider == 'MICROSOFT3') {
print '
';
print '
';
print '
';
diff --git a/htdocs/core/class/CMailFile.class.php b/htdocs/core/class/CMailFile.class.php
index d8900befe76d6..59ba582238872 100644
--- a/htdocs/core/class/CMailFile.class.php
+++ b/htdocs/core/class/CMailFile.class.php
@@ -1300,6 +1300,8 @@ public function sendfile()
$this->transport->setPassword(getDolGlobalString($keyforsmtppw));
}
if (getDolGlobalString($keyforsmtpauthtype) === "XOAUTH2") {
+ // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth
+ $this->transport->setAuthMode('XOAUTH2');
require_once DOL_DOCUMENT_ROOT.'/core/lib/oauth.lib.php';
$supportedoauth2array = getSupportedOauth2Array();
@@ -1364,6 +1366,8 @@ public function sendfile()
$this->transport->setPassword($tokenobj->getAccessToken());
} else {
$this->errors[] = "Token not found";
+ // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth
+ dol_syslog("CMailFile::sendfile: OAuth2 token object is not valid", LOG_ERR);
}
} catch (Exception $e) {
// Return an error if token not found
diff --git a/htdocs/core/lib/oauth.lib.php b/htdocs/core/lib/oauth.lib.php
index b5ff496e774ad..fd4080f9853af 100644
--- a/htdocs/core/lib/oauth.lib.php
+++ b/htdocs/core/lib/oauth.lib.php
@@ -156,6 +156,13 @@ function getAllOauth2Array()
'OAUTH_MICROSOFT2_ID',
'OAUTH_MICROSOFT2_SECRET',
),
+ // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth
+ array(
+ 'OAUTH_MICROSOFT3_NAME',
+ 'OAUTH_MICROSOFT3_ID',
+ 'OAUTH_MICROSOFT3_SECRET',
+ 'OAUTH_MICROSOFT3_DESC',
+ ),
array(
'OAUTH_NEST_NAME',
'OAUTH_NEST_ID',
@@ -341,6 +348,20 @@ function getSupportedOauth2Array()
'availablescopes' => 'openid,offline_access,profile,email,User.Read,https://outlook.office.com/.default',
'returnurl' => '/core/modules/oauth/microsoft2_oauthcallback.php'
);
+ // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth
+ $supportedoauth2array['OAUTH_MICROSOFT3_NAME'] = array(
+ 'callbackfile' => 'microsoft3',
+ 'picto' => 'microsoft',
+ 'urlforapp' => 'OAUTH_MICROSOFT3_DESC',
+ 'name' => 'Microsoft Exchange Online [SMTP/IMAP]',
+ 'urlforcredentials' => 'https://portal.azure.com/',
+ // CRITICAL: Use ONLY outlook.office.com scopes here, do NOT mix with Graph scopes (openid/profile/email).
+ // Mixing two resource namespaces in one token request causes AADSTS28000 error.
+ // offline_access is a neutral scope (no resource prefix) and is allowed alongside any resource.
+ // Azure permissions required: Microsoft Graph > Delegated > SMTP.Send and IMAP.AccessAsUser.All
+ 'availablescopes' => 'offline_access,https://outlook.office.com/SMTP.Send,https://outlook.office.com/IMAP.AccessAsUser.All',
+ 'returnurl' => '/core/modules/oauth/microsoft3_oauthcallback.php'
+ );
// Add a generic Oauth token handler. Tested with Mastodon.
$supportedoauth2array['OAUTH_GENERIC_NAME'] = array(
diff --git a/htdocs/core/modules/oauth/microsoft3_oauthcallback.php b/htdocs/core/modules/oauth/microsoft3_oauthcallback.php
new file mode 100644
index 0000000000000..f24c1882eea2c
--- /dev/null
+++ b/htdocs/core/modules/oauth/microsoft3_oauthcallback.php
@@ -0,0 +1,210 @@
+
+ * Copyright (C) 2015-2024 Frédéric France
+ * Copyright (C) 2024 MDW
+ * Copyright (C) 2026 Vidal Nicolas
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+// BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth callback (new file copied from core 23.0)
+
+/**
+ * \file htdocs/core/modules/oauth/microsoft3_oauthcallback.php
+ * \ingroup oauth
+ * \brief Page to get oauth callback for Microsoft Exchange Online (SMTP/IMAP)
+ *
+ * Uses Exchange Online OAuth2 scopes for SMTP/IMAP:
+ * - offline_access
+ * - https://outlook.office.com/SMTP.Send
+ * - https://outlook.office.com/IMAP.AccessAsUser.All
+ */
+
+// Load Dolibarr environment
+require '../../../main.inc.php';
+require_once DOL_DOCUMENT_ROOT.'/includes/OAuth/bootstrap.php';
+/**
+ * @var Conf $conf
+ * @var DoliDB $db
+ * @var Translate $langs
+ * @var User $user
+ *
+ * @var string $dolibarr_main_url_root
+ */
+
+use OAuth\Common\Storage\DoliStorage;
+use OAuth\Common\Consumer\Credentials;
+
+// Define $urlwithroot
+$urlwithouturlroot = preg_replace('/'.preg_quote(DOL_URL_ROOT, '/').'$/i', '', trim($dolibarr_main_url_root));
+$urlwithroot = $urlwithouturlroot.DOL_URL_ROOT; // This is to use external domain name found into config file
+
+
+$action = GETPOST('action', 'aZ09');
+$backtourl = GETPOST('backtourl', 'alpha');
+$keyforprovider = GETPOST('keyforprovider', 'aZ09');
+if (empty($keyforprovider) && !empty($_SESSION["oauthkeyforproviderbeforeoauthjump"]) && (GETPOST('code') || $action == 'delete')) {
+ $keyforprovider = $_SESSION["oauthkeyforproviderbeforeoauthjump"];
+}
+$genericstring = 'MICROSOFT3';
+
+
+/**
+ * Create a new instance of the URI class with the current URI, stripping the query string
+ */
+$uriFactory = new \OAuth\Common\Http\Uri\UriFactory();
+
+
+$currentUri = $uriFactory->createFromAbsolute($urlwithroot.'/core/modules/oauth/microsoft3_oauthcallback.php');
+
+
+/**
+ * Load the credential for the service
+ */
+
+/** @var \OAuth\ServiceFactory $serviceFactory An OAuth service factory. */
+$serviceFactory = new \OAuth\ServiceFactory();
+$httpClient = new \OAuth\Common\Http\Client\CurlClient();
+
+$serviceFactory->setHttpClient($httpClient);
+
+// Setup the credentials for the requests
+$keyforparamid = 'OAUTH_'.$genericstring.($keyforprovider ? '-'.$keyforprovider : '').'_ID';
+$keyforparamsecret = 'OAUTH_'.$genericstring.($keyforprovider ? '-'.$keyforprovider : '').'_SECRET';
+$keyforparamtenant = 'OAUTH_'.$genericstring.($keyforprovider ? '-'.$keyforprovider : '').'_TENANT';
+
+// Dolibarr storage
+$storage = new DoliStorage($db, $conf, $keyforprovider, getDolGlobalString($keyforparamtenant));
+
+$credentials = new Credentials(
+ getDolGlobalString($keyforparamid),
+ getDolGlobalString($keyforparamsecret),
+ $currentUri->getAbsoluteUri()
+);
+
+$state = GETPOST('state');
+
+$requestedpermissionsarray = array();
+if ($state) {
+ $requestedpermissionsarray = explode(',', $state); // Example: 'user'. 'state' parameter is standard to retrieve some parameters back
+}
+if ($action != 'delete' && empty($requestedpermissionsarray)) {
+ print 'Error, parameter state is not defined';
+ exit;
+}
+
+try {
+ $nameofservice = ucfirst(strtolower($genericstring));
+ $apiService = $serviceFactory->createService($nameofservice, $credentials, $storage, $requestedpermissionsarray);
+ '@phan-var-force OAuth\OAuth2\Service\AbstractService|OAuth\OAuth1\Service\AbstractService $apiService'; // createService is only ServiceInterface
+} catch (Exception $e) {
+ print $e->getMessage();
+ exit;
+}
+
+if (empty($apiService)) {
+ print 'Error, failed to create serviceFactory';
+ exit;
+}
+
+$langs->load("oauth");
+
+if (!getDolGlobalString($keyforparamid)) {
+ accessforbidden('Setup of service is not complete. Customer ID is missing');
+}
+if (!getDolGlobalString($keyforparamsecret)) {
+ accessforbidden('Setup of service is not complete. Secret key is missing');
+}
+if (!getDolGlobalString($keyforparamtenant)) {
+ accessforbidden('Setup of service is not complete. Tenant/Annuary ID key is missing');
+}
+
+/*
+ * Actions
+ */
+
+if ($action == 'delete' && (!empty($user->admin) || $user->id == GETPOSTINT('userid'))) {
+ $storage->userid = GETPOSTINT('userid');
+ $storage->clearToken($genericstring);
+
+ setEventMessages($langs->trans('TokenDeleted'), null, 'mesgs');
+
+ if (empty($backtourl)) {
+ $backtourl = DOL_URL_ROOT.'/';
+ }
+
+ header('Location: '.$backtourl);
+ exit();
+}
+
+
+if (GETPOST('code') || GETPOST('error')) { // We are coming from oauth provider page
+ // We should have
+
+ dol_syslog(basename(__FILE__)." We are coming from the oauth provider page code=".dol_trunc(GETPOST('code'), 5)." error=".GETPOST('error'));
+
+ // We must validate that the $state is the same than the one into $_SESSION['oauthstateanticsrf'], return error if not.
+ if (isset($_SESSION['oauthstateanticsrf']) && $state != $_SESSION['oauthstateanticsrf']) {
+ print $langs->trans("OAuthErrorStateDiffers", dol_escape_htmltag($state));
+ unset($_SESSION['oauthstateanticsrf']);
+ exit;
+ }
+
+ // This was a callback request from service, get the token
+ try {
+ if (GETPOST('error')) {
+ setEventMessages(GETPOST('error').' '.GETPOST('error_description'), null, 'errors');
+ } else {
+ $token = $apiService->requestAccessToken(GETPOST('code'));
+ setEventMessages($langs->trans('NewTokenStored'), null, 'mesgs'); // Stored into object managed by class DoliStorage so into table oauth_token
+ }
+
+ $backtourl = $_SESSION["backtourlsavedbeforeoauthjump"];
+ unset($_SESSION["backtourlsavedbeforeoauthjump"]);
+
+ header('Location: '.$backtourl);
+ exit();
+ } catch (Exception $e) {
+ print $e->getMessage();
+ }
+} else {
+ // If we enter this page without 'code' parameter, we arrive here. This is the case when we want to get the redirect
+ // to the OAuth provider login page.
+ $_SESSION["backtourlsavedbeforeoauthjump"] = $backtourl;
+ $_SESSION["oauthkeyforproviderbeforeoauthjump"] = $keyforprovider;
+ $_SESSION['oauthstateanticsrf'] = $state;
+
+ // This may create record into oauth_state before the header redirect.
+ // Creation of record with state in this tables depend on the Provider used (see its constructor).
+ $params = array('prompt' => 'consent');
+ if ($state) {
+ $params['state'] = $state;
+ }
+ $url = $apiService->getAuthorizationUri($params);
+
+ // Show url to get authorization
+ dol_syslog("Redirect to url=".$url);
+
+ // we go on oauth provider authorization page
+ header('Location: '.$url);
+ exit();
+}
+
+/*
+ * View
+ */
+
+// No view at all, just actions
+
+$db->close();
diff --git a/htdocs/core/modules/oauth/microsoft_oauthcallback.php b/htdocs/core/modules/oauth/microsoft_oauthcallback.php
index 3cc0c5da50c4f..5dfdc95ee67d3 100644
--- a/htdocs/core/modules/oauth/microsoft_oauthcallback.php
+++ b/htdocs/core/modules/oauth/microsoft_oauthcallback.php
@@ -208,8 +208,13 @@
// This may create record into oauth_state before the header redirect.
// Creation of record with state in this tables depend on the Provider used (see its constructor).
+ // BACKPORT PR #37329 (Dolibarr 23.0 -> 22.0_iseta): Microsoft Exchange Online OAuth (state param handling)
+ $params = array();
if ($state) {
- $url = $apiService->getAuthorizationUri(array('state' => $state));
+ $params['state'] = $state;
+ }
+ if (!empty($params)) {
+ $url = $apiService->getAuthorizationUri($params);
} else {
$url = $apiService->getAuthorizationUri(); // Parameter state will be randomly generated
}
diff --git a/htdocs/includes/OAuth/OAuth2/Service/Microsoft3.php b/htdocs/includes/OAuth/OAuth2/Service/Microsoft3.php
new file mode 100644
index 0000000000000..099c71c2f9e17
--- /dev/null
+++ b/htdocs/includes/OAuth/OAuth2/Service/Microsoft3.php
@@ -0,0 +1,111 @@
+ 22.0_iseta): Microsoft Exchange Online OAuth service (new file copied from core 23.0)
+
+namespace OAuth\OAuth2\Service;
+
+use OAuth\OAuth2\Token\StdOAuth2Token;
+use OAuth\Common\Http\Exception\TokenResponseException;
+use OAuth\Common\Http\Uri\Uri;
+use OAuth\Common\Consumer\CredentialsInterface;
+use OAuth\Common\Http\Client\ClientInterface;
+use OAuth\Common\Storage\TokenStorageInterface;
+use OAuth\Common\Http\Uri\UriInterface;
+
+/**
+ * Microsoft Exchange Online OAuth2 service (SMTP/IMAP)
+ *
+ * Uses Exchange Online OAuth2 scopes for legacy protocols (SMTP/IMAP):
+ * - offline_access (required for refresh token)
+ * - https://outlook.office.com/SMTP.Send
+ * - https://outlook.office.com/IMAP.AccessAsUser.All
+ */
+class Microsoft3 extends AbstractService
+{
+ // offline_access is resource-neutral, allowed with any resource scope
+ const SCOPE_OFFLINE_ACCESS = 'offline_access';
+
+ // Exchange Online scopes for SMTP/IMAP XOAUTH2 protocol authentication.
+ // MUST NOT be mixed with Microsoft Graph scopes (openid/profile/email/User.Read)
+ // in the same token request — doing so causes error AADSTS28000.
+ // Azure app registration requires: Microsoft Graph > Delegated > SMTP.Send and IMAP.AccessAsUser.All
+ // See: https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth
+ const SCOPE_SMTP_SEND = 'https://outlook.office.com/SMTP.Send';
+ const SCOPE_IMAP_ACCESSASUSERALL = 'https://outlook.office.com/IMAP.AccessAsUser.All';
+
+ protected $storage;
+
+ public function __construct(
+ CredentialsInterface $credentials,
+ ClientInterface $httpClient,
+ TokenStorageInterface $storage,
+ $scopes = array(),
+ UriInterface $baseApiUri = null
+ ) {
+ parent::__construct($credentials, $httpClient, $storage, $scopes, $baseApiUri);
+
+ $this->storage = $storage;
+
+ if (null === $baseApiUri) {
+ // baseApiUri is not used for SMTP/IMAP auth, but keep a sensible default for potential API calls.
+ $this->baseApiUri = new Uri('https://graph.microsoft.com/v1.0/');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAuthorizationEndpoint()
+ {
+ $tenant = $this->storage->getTenant();
+
+ return new Uri('https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/authorize');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAccessTokenEndpoint()
+ {
+ $tenant = $this->storage->getTenant();
+
+ return new Uri('https://login.microsoftonline.com/' . $tenant . '/oauth2/v2.0/token');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAuthorizationMethod()
+ {
+ return static::AUTHORIZATION_METHOD_QUERY_STRING;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function parseAccessTokenResponse($responseBody)
+ {
+ $data = json_decode($responseBody, true);
+
+ if (null === $data || !is_array($data)) {
+ throw new TokenResponseException('Unable to parse response.');
+ } elseif (isset($data['error'])) {
+ throw new TokenResponseException('Error in retrieving token: "' . $data['error'] . '"');
+ }
+
+ $token = new StdOAuth2Token();
+ $token->setAccessToken($data['access_token']);
+ $token->setLifetime($data['expires_in']);
+
+ if (isset($data['refresh_token'])) {
+ $token->setRefreshToken($data['refresh_token']);
+ unset($data['refresh_token']);
+ }
+
+ unset($data['access_token']);
+ unset($data['expires_in']);
+
+ $token->setExtraParams($data);
+
+ return $token;
+ }
+}
diff --git a/htdocs/langs/en_US/oauth.lang b/htdocs/langs/en_US/oauth.lang
index e976e4675ca60..8365ff571da2b 100644
--- a/htdocs/langs/en_US/oauth.lang
+++ b/htdocs/langs/en_US/oauth.lang
@@ -50,3 +50,4 @@ RefreshToken=Refresh token
RefreshTokenHelp=Use the Refresh Token to get a new Access Token
OldTokenWasExpiredItHasBeenRefresh=Old token was expired, it has been refreshed
OldTokenWasNotExpiredButItHasBeenRefresh=Old token was not expired but it has been refreshed
+OAuthErrorStateDiffers=Value for state=%s differs from value in session. Code is refused.
diff --git a/htdocs/langs/fr_FR/oauth.lang b/htdocs/langs/fr_FR/oauth.lang
index 15ae26c8025e3..0e38d622453e1 100644
--- a/htdocs/langs/fr_FR/oauth.lang
+++ b/htdocs/langs/fr_FR/oauth.lang
@@ -50,3 +50,4 @@ RefreshToken=Rafraîchir Jeton
RefreshTokenHelp=Utilisez le jeton d'actualisation pour obtenir un nouveau jeton d'accès
OldTokenWasExpiredItHasBeenRefresh=L'ancien jeton avait expiré, il a été actualisé
OldTokenWasNotExpiredButItHasBeenRefresh=L'ancien jeton n'avait pas expiré mais il a été actualisé
+OAuthErrorStateDiffers=La valeur de state=%s diffère de la valeur en session. Le code est refusé.