19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:28 +01:00
parent 20ddc1b4a3
commit c0efcc53f5
1162 changed files with 125577 additions and 105287 deletions

View file

@ -6,3 +6,4 @@ from . import microsoft_outlook_mixin
from . import fetchmail_server
from . import ir_mail_server
from . import res_config_settings
from . import res_users

View file

@ -23,17 +23,11 @@ class FetchmailServer(models.Model):
'the permissions.')
super(FetchmailServer, self - outlook_servers)._compute_server_type_info()
@api.depends('server_type')
def _compute_is_microsoft_outlook_configured(self):
outlook_servers = self.filtered(lambda server: server.server_type == 'outlook')
(self - outlook_servers).is_microsoft_outlook_configured = False
super(FetchmailServer, outlook_servers)._compute_is_microsoft_outlook_configured()
@api.constrains('server_type', 'is_ssl')
def _check_use_microsoft_outlook_service(self):
for server in self:
if server.server_type == 'outlook' and not server.is_ssl:
raise UserError(_('SSL is required for the server %r.', server.name))
raise UserError(_('SSL is required for server “%s”.', server.name))
@api.onchange('server_type')
def onchange_server_type(self):
@ -46,9 +40,9 @@ class FetchmailServer(models.Model):
self.microsoft_outlook_refresh_token = False
self.microsoft_outlook_access_token = False
self.microsoft_outlook_access_token_expiration = False
super(FetchmailServer, self).onchange_server_type()
super().onchange_server_type()
def _imap_login(self, connection):
def _imap_login__(self, connection): # noqa: PLW3201
"""Authenticate the IMAP connection.
If the mail server is Outlook, we use the OAuth2 authentication protocol.
@ -59,7 +53,7 @@ class FetchmailServer(models.Model):
connection.authenticate('XOAUTH2', lambda x: auth_string)
connection.select('INBOX')
else:
super()._imap_login(connection)
super()._imap_login__(connection)
def _get_connection_type(self):
"""Return which connection must be used for this mail server (IMAP or POP).

View file

@ -7,7 +7,7 @@ from odoo import _, api, fields, models
from odoo.exceptions import UserError
class IrMailServer(models.Model):
class IrMail_Server(models.Model):
"""Add the Outlook OAuth authentication on the outgoing mail servers."""
_name = 'ir.mail_server'
@ -19,19 +19,13 @@ class IrMailServer(models.Model):
selection_add=[('outlook', 'Outlook OAuth Authentication')],
ondelete={'outlook': 'set default'})
@api.depends('smtp_authentication')
def _compute_is_microsoft_outlook_configured(self):
outlook_servers = self.filtered(lambda server: server.smtp_authentication == 'outlook')
(self - outlook_servers).is_microsoft_outlook_configured = False
super(IrMailServer, outlook_servers)._compute_is_microsoft_outlook_configured()
def _compute_smtp_authentication_info(self):
outlook_servers = self.filtered(lambda server: server.smtp_authentication == 'outlook')
outlook_servers.smtp_authentication_info = _(
'Connect your Outlook account with the OAuth Authentication process. \n'
'By default, only a user with a matching email address will be able to use this server. '
'To extend its use, you should set a "mail.default.from" system parameter.')
super(IrMailServer, self - outlook_servers)._compute_smtp_authentication_info()
super(IrMail_Server, self - outlook_servers)._compute_smtp_authentication_info()
@api.constrains('smtp_authentication', 'smtp_pass', 'smtp_encryption', 'smtp_user')
def _check_use_microsoft_outlook_service(self):
@ -39,12 +33,12 @@ class IrMailServer(models.Model):
for server in outlook_servers:
if server.smtp_pass:
raise UserError(_(
'Please leave the password field empty for Outlook mail server %r. '
'Please leave the password field empty for Outlook mail server %s. '
'The OAuth process does not require it', server.name))
if server.smtp_encryption != 'starttls':
raise UserError(_(
'Incorrect Connection Security for Outlook mail server %r. '
'Incorrect Connection Security for Outlook mail server %s. '
'Please set it to "TLS (STARTTLS)".', server.name))
if not server.smtp_user:
@ -77,11 +71,22 @@ class IrMailServer(models.Model):
if self.smtp_authentication == 'outlook':
self.from_filter = self.smtp_user
def _smtp_login(self, connection, smtp_user, smtp_password):
def _smtp_login__(self, connection, smtp_user, smtp_password): # noqa: PLW3201
if len(self) == 1 and self.smtp_authentication == 'outlook':
auth_string = self._generate_outlook_oauth2_string(smtp_user)
oauth_param = base64.b64encode(auth_string.encode()).decode()
connection.ehlo()
connection.docmd('AUTH', f'XOAUTH2 {oauth_param}')
else:
super()._smtp_login(connection, smtp_user, smtp_password)
super()._smtp_login__(connection, smtp_user, smtp_password)
def _get_personal_mail_servers_limit(self):
"""Return the number of email we can send in 1 minutes for this outgoing server.
0 fallbacks to 30 to avoid blocking servers.
"""
if self.smtp_authentication == 'outlook':
# Outlook flag way faster email as spam, so we set a lower limit
return int(self.env['ir.config_parameter'].sudo()
.get_param('mail.server.personal.limit.minutes_outlook')) or 10
return super()._get_personal_mail_servers_limit()

View file

@ -6,24 +6,30 @@ import logging
import time
import requests
from werkzeug.urls import url_encode, url_join
from werkzeug.urls import url_encode
from odoo import _, api, fields, models
from odoo import _, api, fields, models, release
from odoo.exceptions import AccessError, UserError
from odoo.tools.misc import hmac
from odoo.tools import hmac, email_normalize
from odoo.tools.urls import urljoin as url_join
from odoo.addons.google_gmail.tools import get_iap_error_message
_logger = logging.getLogger(__name__)
OUTLOOK_TOKEN_REQUEST_TIMEOUT = 5
OUTLOOK_TOKEN_VALIDITY_THRESHOLD = OUTLOOK_TOKEN_REQUEST_TIMEOUT + 5
class MicrosoftOutlookMixin(models.AbstractModel):
_name = 'microsoft.outlook.mixin'
_description = 'Microsoft Outlook Mixin'
_OUTLOOK_SCOPE = None
_DEFAULT_OUTLOOK_IAP_ENDPOINT = 'https://outlook.api.odoo.com'
active = fields.Boolean(default=True)
is_microsoft_outlook_configured = fields.Boolean('Is Outlook Credential Configured',
compute='_compute_is_microsoft_outlook_configured')
microsoft_outlook_refresh_token = fields.Char(string='Outlook Refresh Token',
groups='base.group_system', copy=False)
microsoft_outlook_access_token = fields.Char(string='Outlook Access Token',
@ -33,20 +39,15 @@ class MicrosoftOutlookMixin(models.AbstractModel):
microsoft_outlook_uri = fields.Char(compute='_compute_outlook_uri', string='Authentication URI',
help='The URL to generate the authorization code from Outlook', groups='base.group_system')
def _compute_is_microsoft_outlook_configured(self):
Config = self.env['ir.config_parameter'].sudo()
microsoft_outlook_client_id = Config.get_param('microsoft_outlook_client_id')
microsoft_outlook_client_secret = Config.get_param('microsoft_outlook_client_secret')
self.is_microsoft_outlook_configured = microsoft_outlook_client_id and microsoft_outlook_client_secret
@api.depends('is_microsoft_outlook_configured')
def _compute_outlook_uri(self):
Config = self.env['ir.config_parameter'].sudo()
base_url = self.get_base_url()
microsoft_outlook_client_id = Config.get_param('microsoft_outlook_client_id')
microsoft_outlook_client_secret = Config.get_param('microsoft_outlook_client_secret')
is_configured = microsoft_outlook_client_id and microsoft_outlook_client_secret
for record in self:
if not record.id or not record.is_microsoft_outlook_configured:
if not is_configured:
record.microsoft_outlook_uri = False
continue
@ -56,12 +57,12 @@ class MicrosoftOutlookMixin(models.AbstractModel):
'redirect_uri': url_join(base_url, '/microsoft_outlook/confirm'),
'response_mode': 'query',
# offline_access is needed to have the refresh_token
'scope': 'offline_access %s' % self._OUTLOOK_SCOPE,
'scope': f'openid email offline_access https://outlook.office.com/User.read {self._OUTLOOK_SCOPE}',
'state': json.dumps({
'model': record._name,
'id': record.id,
'csrf_token': record._get_outlook_csrf_token(),
})
}),
}))
def open_microsoft_outlook_uri(self):
@ -73,22 +74,71 @@ class MicrosoftOutlookMixin(models.AbstractModel):
"""
self.ensure_one()
if not self.env.user.has_group('base.group_system'):
if not self.env.is_admin():
raise AccessError(_('Only the administrator can link an Outlook mail server.'))
if not self.is_microsoft_outlook_configured:
email_normalized = email_normalize(self[self._email_field])
if not email_normalized:
raise UserError(_('Please enter a valid email address.'))
Config = self.env['ir.config_parameter'].sudo()
microsoft_outlook_client_id = Config.get_param('microsoft_outlook_client_id')
microsoft_outlook_client_secret = Config.get_param('microsoft_outlook_client_secret')
is_configured = microsoft_outlook_client_id and microsoft_outlook_client_secret
if not is_configured: # use IAP (see '/microsoft_outlook/iap_confirm')
if release.version_info[-1] != 'e':
raise UserError(_('Please configure your Outlook credentials.'))
outlook_iap_endpoint = self.env['ir.config_parameter'].sudo().get_param(
'mail.server.outlook.iap.endpoint',
self._DEFAULT_OUTLOOK_IAP_ENDPOINT,
)
db_uuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
# final callback URL that will receive the token from IAP
callback_params = url_encode({
'model': self._name,
'rec_id': self.id,
'csrf_token': self._get_outlook_csrf_token(),
})
callback_url = url_join(self.get_base_url(), f'/microsoft_outlook/iap_confirm?{callback_params}')
try:
response = requests.get(
url_join(outlook_iap_endpoint, '/api/mail_oauth/1/outlook'),
params={'db_uuid': db_uuid, 'callback_url': callback_url},
timeout=OUTLOOK_TOKEN_REQUEST_TIMEOUT)
response.raise_for_status()
except requests.exceptions.RequestException as e:
_logger.error('Can not contact IAP: %s.', e)
raise UserError(_('Oops, we could not authenticate you. Please try again later.'))
response = response.json()
if 'error' in response:
self._raise_iap_error(response['error'])
# URL on IAP that will redirect to Outlook login page
microsoft_outlook_uri = response['url']
else:
microsoft_outlook_uri = self.microsoft_outlook_uri
if not microsoft_outlook_uri:
raise UserError(_('Please configure your Outlook credentials.'))
return {
'type': 'ir.actions.act_url',
'url': self.microsoft_outlook_uri,
'url': microsoft_outlook_uri,
'target': 'self',
}
def _fetch_outlook_refresh_token(self, authorization_code):
"""Request the refresh token and the initial access token from the authorization code.
:return:
refresh_token, access_token, access_token_expiration
refresh_token, access_token, id_token, access_token_expiration
"""
response = self._fetch_outlook_token('authorization_code', code=authorization_code)
return (
@ -103,10 +153,17 @@ class MicrosoftOutlookMixin(models.AbstractModel):
:return:
access_token, access_token_expiration
"""
Config = self.env['ir.config_parameter'].sudo()
microsoft_outlook_client_id = Config.get_param('microsoft_outlook_client_id')
microsoft_outlook_client_secret = Config.get_param('microsoft_outlook_client_secret')
if not microsoft_outlook_client_id or not microsoft_outlook_client_secret:
return self._fetch_outlook_access_token_iap(refresh_token)
response = self._fetch_outlook_token('refresh_token', refresh_token=refresh_token)
return (
response['refresh_token'],
response['access_token'],
response['id_token'],
int(time.time()) + int(response['expires_in']),
)
@ -128,12 +185,12 @@ class MicrosoftOutlookMixin(models.AbstractModel):
data={
'client_id': microsoft_outlook_client_id,
'client_secret': microsoft_outlook_client_secret,
'scope': 'offline_access %s' % self._OUTLOOK_SCOPE,
'scope': f'openid email offline_access https://outlook.office.com/User.read {self._OUTLOOK_SCOPE}',
'redirect_uri': url_join(base_url, '/microsoft_outlook/confirm'),
'grant_type': grant_type,
**values,
},
timeout=10,
timeout=OUTLOOK_TOKEN_REQUEST_TIMEOUT,
)
if not response.ok:
@ -145,22 +202,57 @@ class MicrosoftOutlookMixin(models.AbstractModel):
return response.json()
def _fetch_outlook_access_token_iap(self, refresh_token):
"""Fetch the access token using IAP.
Make a HTTP request to IAP, that will make a HTTP request
to the Outlook API and give us the result.
:return:
access_token, access_token_expiration
"""
outlook_iap_endpoint = self.env['ir.config_parameter'].sudo().get_param(
'mail.server.outlook.iap.endpoint',
self.env['microsoft.outlook.mixin']._DEFAULT_OUTLOOK_IAP_ENDPOINT,
)
db_uuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
response = requests.get(
url_join(outlook_iap_endpoint, '/api/mail_oauth/1/outlook_access_token'),
params={'refresh_token': refresh_token, 'db_uuid': db_uuid},
timeout=OUTLOOK_TOKEN_REQUEST_TIMEOUT,
)
if not response.ok:
_logger.error('Can not contact IAP: %s.', response.text)
raise UserError(_('Oops, we could not authenticate you. Please try again later.'))
response = response.json()
if 'error' in response:
self._raise_iap_error(response['error'])
return response
def _raise_iap_error(self, error):
raise UserError(get_iap_error_message(self.env, error))
def _generate_outlook_oauth2_string(self, login):
"""Generate a OAuth2 string which can be used for authentication.
:param user: Email address of the Outlook account to authenticate
:param login: Email address of the Outlook account to authenticate
:return: The SASL argument for the OAuth2 mechanism.
"""
self.ensure_one()
now_timestamp = int(time.time())
if not self.microsoft_outlook_access_token \
or not self.microsoft_outlook_access_token_expiration \
or self.microsoft_outlook_access_token_expiration < now_timestamp:
or self.microsoft_outlook_access_token_expiration - OUTLOOK_TOKEN_VALIDITY_THRESHOLD < now_timestamp:
if not self.microsoft_outlook_refresh_token:
raise UserError(_('Please connect with your Outlook account before using it.'))
(
self.microsoft_outlook_refresh_token,
self.microsoft_outlook_access_token,
_id_token,
self.microsoft_outlook_access_token_expiration,
) = self._fetch_outlook_access_token(self.microsoft_outlook_refresh_token)
_logger.info(

View file

@ -0,0 +1,28 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResUsers(models.Model):
_inherit = "res.users"
outgoing_mail_server_type = fields.Selection(
selection_add=[("outlook", "Outlook")],
ondelete={"outlook": "set default"},
)
@api.model
def _get_mail_server_values(self, server_type):
values = super()._get_mail_server_values(server_type)
if server_type == "outlook":
values |= {
"smtp_host": "smtp-mail.outlook.com",
"smtp_authentication": "outlook",
}
return values
@api.model
def _get_mail_server_setup_end_action(self, smtp_server):
if smtp_server.smtp_authentication == 'outlook':
return smtp_server.sudo().open_microsoft_outlook_uri()
return super()._get_mail_server_setup_end_action(smtp_server)