diff --git a/addons/crm/models/res_config_settings.py b/addons/crm/models/res_config_settings.py
index 04267d7d634..cd32bc052da 100644
--- a/addons/crm/models/res_config_settings.py
+++ b/addons/crm/models/res_config_settings.py
@@ -18,6 +18,7 @@ class ResConfigSettings(models.TransientModel):
module_crm_iap_lead = fields.Boolean("Generate new leads based on their country, industries, size, etc.")
module_crm_iap_lead_website = fields.Boolean("Create Leads/Opportunities from your website's traffic")
module_crm_iap_lead_enrich = fields.Boolean("Enrich your leads automatically with company data based on their email address.")
+ module_mail_client_extension = fields.Boolean("See and manage users, companies, and leads from our mail client extensions.")
lead_enrich_auto = fields.Selection([
('manual', 'Enrich leads on demand only'),
('auto', 'Enrich all leads automatically'),
diff --git a/addons/crm/views/res_config_settings_views.xml b/addons/crm/views/res_config_settings_views.xml
index 982e3e6e987..ac1bec2faea 100644
--- a/addons/crm/views/res_config_settings_views.xml
+++ b/addons/crm/views/res_config_settings_views.xml
@@ -135,6 +135,19 @@
+
+
+
+
+
+
+
+
+
+ Turn emails received in your Outlook mailbox into leads and log their content as internal notes.
+
+
+
diff --git a/addons/mail_client_extension/__init__.py b/addons/mail_client_extension/__init__.py
new file mode 100644
index 00000000000..38718f0843b
--- /dev/null
+++ b/addons/mail_client_extension/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/addons/mail_client_extension/__manifest__.py b/addons/mail_client_extension/__manifest__.py
new file mode 100644
index 00000000000..9c2f8417118
--- /dev/null
+++ b/addons/mail_client_extension/__manifest__.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Mail Client Extension',
+ 'version': '1.0',
+ 'category': 'Sales/CRM',
+ 'sequence': 5,
+ 'summary': 'Turn emails received in your Outlook mailbox into leads and log their content as internal notes.',
+ 'description': "Turn emails received in your Outlook mailbox into leads and log their content as internal notes.",
+ 'website': 'https://www.odoo.com/page/crm',
+ 'depends': [
+ 'web',
+ 'crm',
+ 'crm_iap_lead_enrich'
+ ],
+ 'data': [
+ 'views/mail_client_extension_login.xml',
+ 'views/mail_client_extension_lead.xml',
+ ],
+ 'installable': True,
+ 'application': False,
+ 'auto_install': False
+}
diff --git a/addons/mail_client_extension/controllers/__init__.py b/addons/mail_client_extension/controllers/__init__.py
new file mode 100644
index 00000000000..f7273e63342
--- /dev/null
+++ b/addons/mail_client_extension/controllers/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*
+from . import main
\ No newline at end of file
diff --git a/addons/mail_client_extension/controllers/main.py b/addons/mail_client_extension/controllers/main.py
new file mode 100644
index 00000000000..e04c4a26972
--- /dev/null
+++ b/addons/mail_client_extension/controllers/main.py
@@ -0,0 +1,275 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import base64
+import datetime
+import hmac
+import json
+import logging
+import odoo
+import werkzeug
+
+import odoo.addons.iap.tools.iap_tools
+from odoo import http, tools
+from odoo.http import request
+from odoo.tools.misc import formatLang
+
+_logger = logging.getLogger(__name__)
+
+# The top 100 email providers as I'm writing this comment.
+# We don't want to attempt matching companies in the database based on those domains, or we will end up with multiple
+# and/or the wrong company. This solution won't work all the time, the goal is to cover most cases.
+_DOMAIN_BLACKLIST = {'gmail.com', 'yahoo.com', 'hotmail.com', 'aol.com', 'hotmail.co.uk', 'hotmail.fr', 'msn.com',
+ 'yahoo.fr', 'wanadoo.fr', 'orange.fr', 'comcast.net', 'yahoo.co.uk', 'yahoo.com.br', 'yahoo.co.in',
+ 'live.com', 'rediffmail.com', 'free.fr', 'gmx.de', 'web.de', 'yandex.ru', 'ymail.com', 'libero.it',
+ 'outlook.com', 'uol.com.br', 'bol.com.br', 'mail.ru', 'cox.net', 'hotmail.it', 'sbcglobal.net',
+ 'sfr.fr', 'live.fr', 'verizon.net', 'live.co.uk', 'googlemail.com', 'yahoo.es', 'ig.com.br',
+ 'live.nl', 'bigpond.com', 'terra.com.br', 'yahoo.it', 'neuf.fr', 'yahoo.de', 'alice.it',
+ 'rocketmail.com', 'att.net', 'laposte.net', 'facebook.com', 'bellsouth.net', 'yahoo.in',
+ 'hotmail.es', 'charter.net', 'yahoo.ca', 'yahoo.com.au', 'rambler.ru', 'hotmail.de', 'tiscali.it',
+ 'shaw.ca', 'yahoo.co.jp', 'sky.com', 'earthlink.net', 'optonline.net', 'freenet.de', 't-online.de',
+ 'aliceadsl.fr', 'virgilio.it', 'home.nl', 'qq.com', 'telenet.be', 'me.com', 'yahoo.com.ar',
+ 'tiscali.co.uk', 'yahoo.com.mx', 'voila.fr', 'gmx.net', 'mail.com', 'planet.nl', 'tin.it',
+ 'live.it', 'ntlworld.com', 'arcor.de', 'yahoo.co.id', 'frontiernet.net', 'hetnet.nl',
+ 'live.com.au', 'yahoo.com.sg', 'zonnet.nl', 'club-internet.fr', 'juno.com', 'optusnet.com.au',
+ 'blueyonder.co.uk', 'bluewin.ch', 'skynet.be', 'sympatico.ca', 'windstream.net', 'mac.com',
+ 'centurytel.net', 'chello.nl', 'live.ca', 'aim.com', 'bigpond.net.au'}
+
+
+class MailClientExtensionController(http.Controller):
+
+ @http.route('/mail_client_extension/auth', type='http', auth="user", methods=['GET'], website=True)
+ def auth(self, **values):
+ """
+ Once authenticated this route renders the view that shows an app wants to access Odoo.
+ The user is invited to allow or deny the app. The form posts to `/mail_client_extension/auth/confirm`.
+ """
+ return request.render('mail_client_extension.app_auth', values)
+
+ @http.route('/mail_client_extension/auth/confirm', type='http', auth="user", methods=['POST'])
+ def auth_confirm(self, scope, friendlyname, redirect, info=None, do=None, **kw):
+ """
+ Called by the `app_auth` template. If the user decided to allow the app to access Odoo, a temporary auth code
+ is generated and he is redirected to `redirect` with this code in the URL. It should redirect to the app, and
+ the app should then exchange this auth code for an access token by calling
+ `/mail_client_extension/auth/access_token`.
+ """
+ parsed_redirect = werkzeug.urls.url_parse(redirect)
+ params = parsed_redirect.decode_query()
+ if do:
+ name = friendlyname if not info else f'{friendlyname}: {info}'
+ auth_code = self._generate_auth_code(scope, name)
+ # params is a MultiDict which does not support .update() with kwargs
+ params.update({'success': 1, 'auth_code': auth_code})
+ else:
+ params['success'] = 0
+ updated_redirect = parsed_redirect.replace(query=werkzeug.urls.url_encode(params))
+ return werkzeug.utils.redirect(updated_redirect.to_url())
+
+ # In this case, an exception will be thrown in case of preflight request if only POST is allowed.
+ @http.route('/mail_client_extension/auth/access_token', type='json', auth="none", cors="*", methods=['POST', 'OPTIONS'])
+ def auth_access_token(self, auth_code, **kw):
+ """
+ Called by the external app to exchange an auth code, which is temporary and was passed in a URL, for an
+ access token, which is permanent, and can be used in the `Authorization` header to authorize subsequent requests
+ """
+ auth_message = self._get_auth_code_data(auth_code)
+ if not auth_message:
+ return {"error": "Invalid code"}
+ request.uid = auth_message['uid']
+ scope = 'odoo.plugin.' + auth_message.get('scope', '')
+ api_key = request.env['res.users.apikeys']._generate(scope, auth_message['name'])
+ return {'access_token': api_key }
+
+ def _get_auth_code_data(self, auth_code):
+ data, auth_code_signature = auth_code.split('.')
+ data = base64.b64decode(data)
+ auth_code_signature = base64.b64decode(auth_code_signature)
+ signature = odoo.tools.misc.hmac(request.env(su=True), 'mail_client_extension', data).encode()
+ if not hmac.compare_digest(auth_code_signature, signature):
+ return None
+
+ auth_message = json.loads(data)
+ # Check the expiration
+ if datetime.datetime.utcnow() - datetime.datetime.fromtimestamp(auth_message['timestamp']) > datetime.timedelta(minutes=3):
+ return None
+
+ return auth_message
+
+ # Using UTC explicitly in case of a distributed system where the generation and the signature verification do not
+ # necessarily happen on the same server
+ def _generate_auth_code(self, scope, name):
+ auth_dict = {
+ 'scope': scope,
+ 'name': name,
+ 'timestamp': int(datetime.datetime.utcnow().timestamp()), # <- elapsed time should be < 3 mins when verifying
+ 'uid': request.uid,
+ }
+ auth_message = json.dumps(auth_dict, sort_keys=True).encode()
+ signature = odoo.tools.misc.hmac(request.env(su=True), 'mail_client_extension', auth_message).encode()
+ auth_code = "%s.%s" % (base64.b64encode(auth_message).decode(), base64.b64encode(signature).decode())
+ _logger.info('Auth code created - user %s, scope %s', request.env.user, scope)
+ return auth_code
+
+ def _iap_enrich(self, domain):
+ enriched_data = {}
+ try:
+ response = request.env['iap.enrich.api']._request_enrich({domain: domain}) # The key doesn't matter
+ #except odoo.addons.iap.models.iap.InsufficientCreditError as ice:
+ except odoo.addons.iap.tools.iap_tools.InsufficientCreditError:
+ enriched_data['enrichment_info'] = {'type': 'insufficient_credit', 'info': request.env['iap.account'].get_credits_url('reveal')}
+ except Exception as e:
+ enriched_data["enrichment_info"] = {'type': 'other', 'info': 'Unknown reason'}
+ else:
+ enriched_data = response.get(domain)
+ if not enriched_data:
+ enriched_data = {'enrichment_info': {'type': 'no_data', 'info': 'The enrichment API found no data for the email provided.'}}
+ return enriched_data
+
+ @http.route('/mail_client_extension/modules/get', type="json", auth="outlook", csrf=False, cors="*")
+ def modules_get(self, **kwargs):
+ return {'modules': ['contacts', 'crm']}
+
+ # Find an existing company based on the email.
+ def _find_existing_company(self, domain):
+ if domain in _DOMAIN_BLACKLIST:
+ return
+ return request.env['res.partner'].search([('is_company', '=', True), ('email', '=ilike', '%' + domain)], limit=1)
+
+ def _get_company_dict(self, company):
+ if not company:
+ return {'id': -1}
+
+ return {
+ 'id': company.id,
+ 'name': company.name,
+ 'phone': company.phone,
+ 'mobile': company.mobile,
+ 'email': company.email,
+ 'address': {
+ 'street': company.street,
+ 'city': company.city,
+ 'zip': company.zip,
+ 'country': company.country_id.name if company.country_id else ''
+ },
+ 'website': company.website,
+ 'additionalInfo': json.loads(company.iap_enrich_info) if company.iap_enrich_info else {}
+ }
+
+ def _create_company_from_iap(self, domain):
+ iap_data = self._iap_enrich(domain)
+ if 'enrichment_info' in iap_data:
+ return None, iap_data['enrichment_info']
+
+ new_company_info = {
+ 'is_company': True,
+ 'name': iap_data.get("name"),
+ 'street': iap_data.get("street_name"),
+ 'city': iap_data.get("city"),
+ 'zip': iap_data.get("postal_code"),
+ 'phone': iap_data.get('phone_numbers', [''])[0],
+ 'website': iap_data.get("domain"),
+ 'email': iap_data.get('email', [''])[0]
+ }
+ if iap_data.get('country_code'):
+ country = request.env['res.country'].search([('code', '=', iap_data['country_code'].upper())])
+ if country:
+ new_company_info['country_id'] = country.id
+ if iap_data.get('state_code'):
+ state = request.env['res.country.state'].search([
+ ('code', '=', iap_data['state_code']),
+ ('country_id', '=', country.id)
+ ])
+ if state:
+ new_company_info['state_id'] = state.id
+
+ new_company_info['iap_enrich_info'] = json.dumps(iap_data)
+ new_company = request.env['res.partner'].create(new_company_info)
+
+ return new_company, {'type': 'company_created'}
+
+ @http.route('/mail_client_extension/partner/get', type="json", auth="outlook", cors="*")
+ def res_partner_get_by_email(self, email, name, **kwargs):
+ response = {}
+
+ #compute the sender's domain
+ normalized_email = tools.email_normalize(email)
+ if not normalized_email:
+ response['error'] = 'Bad email.'
+ return response
+ sender_domain = normalized_email.split('@')[1]
+
+ # Search for the partner based on the email.
+ # If multiple are found, take the first one.
+ partner = request.env['res.partner'].search([('email', 'in', [normalized_email, email])], limit=1)
+ if partner:
+ response['partner'] = {
+ 'id': partner.id,
+ 'name': partner.name,
+ 'title': partner.function,
+ 'email': partner.email,
+ 'image': partner.image_128,
+ 'phone': partner.phone,
+ 'mobile': partner.mobile,
+ 'enrichment_info': None
+ }
+ # if there is already a company for this partner, just take it without enrichment.
+ if partner.parent_id:
+ response['partner']['company'] = self._get_company_dict(partner.parent_id)
+ else:
+ company = self._find_existing_company(sender_domain)
+ if not company: # create and enrich company
+ company, enrichment_info = self._create_company_from_iap(sender_domain)
+ response['enrichment_info'] = enrichment_info
+ response['partner']['company'] = self._get_company_dict(company)
+ else: #no partner found
+ response['partner'] = {
+ 'id': -1,
+ 'name': name,
+ 'email': email,
+ 'enrichment_info': None
+ }
+ company = self._find_existing_company(sender_domain)
+ if not company: # create and enrich company
+ company, enrichment_info = self._create_company_from_iap(sender_domain)
+ response['enrichment_info'] = enrichment_info
+ response['partner']['company'] = self._get_company_dict(company)
+
+ return response
+
+ @http.route('/mail_client_extension/partner/create', type="json", auth="outlook", cors="*")
+ def res_partner_create(self, email, name, company, **kwargs):
+ # TODO search the company again instead of relying on the one provided here?
+ # Create the partner if needed.
+ partner_info = {
+ 'name': name,
+ 'email': email,
+ }
+ if company > -1:
+ partner_info['parent_id'] = company
+ partner = request.env['res.partner'].create(partner_info)
+
+ response = {'id': partner.id}
+ return response
+
+ @http.route('/mail_client_extension/log_single_mail_content', type="json", auth="outlook", cors="*")
+ def log_single_mail_content(self, lead, message, **kw):
+ crm_lead = request.env['crm.lead'].browse(lead)
+ crm_lead.message_post(body=message)
+
+ @http.route('/mail_client_extension/lead/get_by_partner_id', type="json", auth="outlook", cors="*")
+ def crm_lead_get_by_partner_id(self, partner, limit, offset, **kwargs):
+ partner_leads = request.env['crm.lead'].search([('partner_id', '=', partner)], offset=offset, limit=limit)
+ leads = []
+ for lead in partner_leads:
+ leads.append({
+ 'id': lead.id,
+ 'name': lead.name,
+ 'expected_revenue': formatLang(request.env, lead.expected_revenue, monetary=True, currency_obj=lead.company_currency),
+ })
+
+ return {'leads': leads}
+
+ @http.route('/mail_client_extension/lead/create_from_partner', type='http', auth='user', methods=['GET'])
+ def crm_lead_redirect_form_view(self, partner_id):
+ server_action = http.request.env.ref("mail_client_extension.lead_creation_prefilled_action")
+ return werkzeug.utils.redirect('/web#action=%s&model=crm.lead&partner_id=%s' % (server_action.id, int(partner_id)))
diff --git a/addons/mail_client_extension/models/__init__.py b/addons/mail_client_extension/models/__init__.py
new file mode 100644
index 00000000000..47f7bea7264
--- /dev/null
+++ b/addons/mail_client_extension/models/__init__.py
@@ -0,0 +1,3 @@
+from . import crm_lead
+from . import res_partner
+from . import ir_http
\ No newline at end of file
diff --git a/addons/mail_client_extension/models/crm_lead.py b/addons/mail_client_extension/models/crm_lead.py
new file mode 100644
index 00000000000..1bc5c8f0f3d
--- /dev/null
+++ b/addons/mail_client_extension/models/crm_lead.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+class Lead(models.Model):
+ _inherit = 'crm.lead'
+
+ @api.model
+ def _form_view_auto_fill(self):
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'crm.lead',
+ 'context': {
+ 'default_partner_id': self.env.context.get('params', {}).get('partner_id'),
+ }
+ }
diff --git a/addons/mail_client_extension/models/ir_http.py b/addons/mail_client_extension/models/ir_http.py
new file mode 100644
index 00000000000..e69a12324c3
--- /dev/null
+++ b/addons/mail_client_extension/models/ir_http.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from werkzeug.exceptions import BadRequest
+
+from odoo import models
+from odoo.http import request
+
+class IrHttp(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ @classmethod
+ def _auth_method_outlook(cls):
+ access_token = request.httprequest.headers.get('Authorization')
+ if not access_token:
+ raise BadRequest('Access token missing')
+
+ if access_token.startswith('Bearer '):
+ access_token = access_token[7:]
+
+ user_id = request.env["res.users.apikeys"]._check_credentials(scope='odoo.plugin.outlook', key=access_token)
+ if not user_id:
+ raise BadRequest('Access token invalid')
+
+ # take the identity of the API key user
+ request.uid = user_id
\ No newline at end of file
diff --git a/addons/mail_client_extension/models/res_partner.py b/addons/mail_client_extension/models/res_partner.py
new file mode 100644
index 00000000000..466e72028c1
--- /dev/null
+++ b/addons/mail_client_extension/models/res_partner.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from odoo import fields, models
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ iap_enrich_info = fields.Text('IAP Enrich Info', help='Stores additional info retrieved from IAP in JSON')
diff --git a/addons/mail_client_extension/views/mail_client_extension_lead.xml b/addons/mail_client_extension/views/mail_client_extension_lead.xml
new file mode 100644
index 00000000000..f13a110ec1a
--- /dev/null
+++ b/addons/mail_client_extension/views/mail_client_extension_lead.xml
@@ -0,0 +1,10 @@
+
+
+
+ Redirection to the lead creation form with prefilled info
+ ir.actions.server
+
+ code
+ action = model._form_view_auto_fill()
+
+
diff --git a/addons/mail_client_extension/views/mail_client_extension_login.xml b/addons/mail_client_extension/views/mail_client_extension_login.xml
new file mode 100644
index 00000000000..8e588152c47
--- /dev/null
+++ b/addons/mail_client_extension/views/mail_client_extension_login.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/odoo/http.py b/odoo/http.py
index 3ffdd0c7b64..7006d6313b9 100644
--- a/odoo/http.py
+++ b/odoo/http.py
@@ -761,7 +761,7 @@ class HttpRequest(WebRequest):
if self._is_cors_preflight(request.endpoint):
headers = {
'Access-Control-Max-Age': 60 * 60 * 24,
- 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept'
+ 'Access-Control-Allow-Headers': 'Origin, X-Requested-With, Content-Type, Accept, Authorization'
}
return Response(status=200, headers=headers)