diff --git a/addons/crm_iap_lead_enrich/models/__init__.py b/addons/crm_iap_lead_enrich/models/__init__.py
index dcb5aa3d8a4..7ac0406ee23 100644
--- a/addons/crm_iap_lead_enrich/models/__init__.py
+++ b/addons/crm_iap_lead_enrich/models/__init__.py
@@ -2,5 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import crm_lead
-from . import iap_enrich_api
from . import res_config_settings
diff --git a/addons/crm_iap_lead_enrich/tests/__init__.py b/addons/crm_iap_lead_enrich/tests/__init__.py
index 19c6636d158..33ab21b0da9 100644
--- a/addons/crm_iap_lead_enrich/tests/__init__.py
+++ b/addons/crm_iap_lead_enrich/tests/__init__.py
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from . import common
from . import test_lead_enrich
diff --git a/addons/crm_iap_lead_enrich/tests/test_lead_enrich.py b/addons/crm_iap_lead_enrich/tests/test_lead_enrich.py
index 9d90189322c..9b7efb0f895 100644
--- a/addons/crm_iap_lead_enrich/tests/test_lead_enrich.py
+++ b/addons/crm_iap_lead_enrich/tests/test_lead_enrich.py
@@ -2,7 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.crm.tests.common import TestCrmCommon
-from odoo.addons.crm_iap_lead_enrich.tests.common import MockIAPEnrich
+from odoo.addons.iap.tests.common import MockIAPEnrich
from odoo.tests.common import users
diff --git a/addons/crm_mail_plugin/__init__.py b/addons/crm_mail_plugin/__init__.py
new file mode 100644
index 00000000000..8c67f893b37
--- /dev/null
+++ b/addons/crm_mail_plugin/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
+from . import controllers
diff --git a/addons/crm_mail_plugin/__manifest__.py b/addons/crm_mail_plugin/__manifest__.py
new file mode 100644
index 00000000000..aec93db3e10
--- /dev/null
+++ b/addons/crm_mail_plugin/__manifest__.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'CRM Mail Plugin',
+ 'version': '1.0',
+ 'category': 'Sales/CRM',
+ 'sequence': 5,
+ 'summary': 'Turn emails received in your mailbox into leads and log their content as internal notes.',
+ 'description': "Turn emails received in your mailbox into leads and log their content as internal notes.",
+ 'website': 'https://www.odoo.com/page/crm',
+ 'depends': [
+ 'crm',
+ 'mail_plugin',
+ ],
+ 'data': [
+ 'views/crm_mail_plugin_lead.xml',
+ 'views/crm_lead_views.xml'
+ ],
+ 'installable': True,
+ 'application': False,
+ 'auto_install': True
+}
diff --git a/addons/crm_mail_plugin/controllers/__init__.py b/addons/crm_mail_plugin/controllers/__init__.py
new file mode 100644
index 00000000000..e7d2c7f26ec
--- /dev/null
+++ b/addons/crm_mail_plugin/controllers/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import crm_client
+from . import mail_plugin
diff --git a/addons/crm_mail_plugin/controllers/crm_client.py b/addons/crm_mail_plugin/controllers/crm_client.py
new file mode 100644
index 00000000000..fccee1dee7d
--- /dev/null
+++ b/addons/crm_mail_plugin/controllers/crm_client.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import werkzeug
+
+from odoo import http
+from odoo.http import request
+from odoo.tools import html2plaintext
+
+
+class CrmClient(http.Controller):
+
+ @http.route(route='/mail_client_extension/log_single_mail_content',
+ type="json", auth="outlook", cors="*")
+ def log_single_mail_content(self, lead, message, **kw):
+ """
+ deprecated as of saas-14.3, not needed for newer versions of the mail plugin but necessary
+ for supporting older versions
+ """
+ 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=5, offset=0, **kwargs):
+ """
+ deprecated as of saas-14.3, not needed for newer versions of the mail plugin but necessary
+ for supporting older versions
+ """
+ partner_instance = request.env['res.partner'].browse(partner)
+ return {'leads': self._fetch_partner_leads(partner_instance, limit, offset)}
+
+ @http.route('/mail_client_extension/lead/create_from_partner', type='http', auth='user', methods=['GET'])
+ def crm_lead_redirect_create_form_view(self, partner_id):
+ """
+ deprecated as of saas-14.3, not needed for newer versions of the mail plugin but necessary
+ for supporting older versions
+ """
+ server_action = http.request.env.ref("crm_mail_plugin.lead_creation_prefilled_action")
+ return werkzeug.utils.redirect(
+ '/web#action=%s&model=crm.lead&partner_id=%s' % (server_action.id, int(partner_id)))
+
+ @http.route('/mail_plugin/lead/create', type='json', auth='outlook', cors="*")
+ def crm_lead_create(self, partner_id, email_body, email_subject):
+ partner = request.env['res.partner'].browse(partner_id).exists()
+ if not partner:
+ return {'error': 'partner_not_found'}
+
+ # In a multi-company environment, we will not be able to create a lead if the user is connected to a different
+ # company then the one linked to the contact, this is not supported, and users are encouraged to check if they
+ # are connected to the right company before performing such operation.
+ record = request.env['crm.lead'].create({
+ 'name': html2plaintext(email_subject),
+ 'partner_id': partner_id,
+ 'description': html2plaintext(email_body),
+ })
+
+ return {'lead_id': record.id}
+
+ @http.route('/mail_client_extension/lead/open', type='http', auth='user')
+ def crm_lead_open(self, lead_id):
+ """
+ deprecated as of saas-14.3, not needed for newer versions of the mail plugin but necessary
+ for supporting older versions
+ """
+ action = http.request.env.ref("crm.crm_lead_view_form")
+ url = '/web#id=%s&action=%s&model=crm.lead&edit=1&model=crm.lead' % (lead_id, action.id)
+ return werkzeug.utils.redirect(url)
diff --git a/addons/crm_mail_plugin/controllers/mail_plugin.py b/addons/crm_mail_plugin/controllers/mail_plugin.py
new file mode 100644
index 00000000000..ebde2a99e51
--- /dev/null
+++ b/addons/crm_mail_plugin/controllers/mail_plugin.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo.http import request
+from odoo.tools.misc import formatLang
+
+from odoo.addons.mail_plugin.controllers import mail_plugin
+
+_logger = logging.getLogger(__name__)
+
+
+class MailPluginController(mail_plugin.MailPluginController):
+
+ def _fetch_partner_leads(self, partner, limit=5, offset=0):
+ """
+ Returns an array containing partner leads, each lead will have the following structure :
+ {
+ id: the lead's id,
+ name: the lead's name,
+ expected_revenue: the expected revenue field value
+ probability: the value of the probability field,
+ recurring_revenue: the value of the recurring_revenue field if the lead has a recurring revenue
+ recurring_plan: the value of the recurring plan field if the lead has a recurring revenue
+ }
+ """
+
+ partner_leads = request.env['crm.lead'].search(
+ [('partner_id', '=', partner.id)], offset=offset, limit=limit)
+ recurring_revenues = request.env.user.has_group('crm.group_use_recurring_revenues')
+
+ leads = []
+ for lead in partner_leads:
+ lead_values = {
+ 'lead_id': lead.id,
+ 'name': lead.name,
+ 'expected_revenue': formatLang(request.env, lead.expected_revenue, monetary=True,
+ currency_obj=lead.company_currency),
+ 'probability': lead.probability,
+ }
+
+ if recurring_revenues:
+ lead_values.update({
+ 'recurring_revenue': formatLang(request.env, lead.recurring_revenue, monetary=True,
+ currency_obj=lead.company_currency),
+ 'recurring_plan': lead.recurring_plan.name,
+ })
+
+ leads.append(lead_values)
+
+ return leads
+
+ def _prepare_contact_values(self, partner):
+ contact_values = super(MailPluginController, self)._prepare_contact_values(partner)
+ if not partner:
+ contact_values['leads'] = []
+ else:
+ contact_values['leads'] = self._fetch_partner_leads(partner)
+ return contact_values
+
+ def _mail_content_logging_models_whitelist(self):
+ return super(MailPluginController, self)._mail_content_logging_models_whitelist() + ['crm.lead']
diff --git a/addons/crm_mail_plugin/models/__init__.py b/addons/crm_mail_plugin/models/__init__.py
new file mode 100644
index 00000000000..00140f99c9f
--- /dev/null
+++ b/addons/crm_mail_plugin/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import crm_lead
diff --git a/addons/mail_client_extension/models/crm_lead.py b/addons/crm_mail_plugin/models/crm_lead.py
similarity index 74%
rename from addons/mail_client_extension/models/crm_lead.py
rename to addons/crm_mail_plugin/models/crm_lead.py
index 1bc5c8f0f3d..588504bf9ab 100644
--- a/addons/mail_client_extension/models/crm_lead.py
+++ b/addons/crm_mail_plugin/models/crm_lead.py
@@ -3,11 +3,16 @@
from odoo import api, models
+
class Lead(models.Model):
_inherit = 'crm.lead'
@api.model
def _form_view_auto_fill(self):
+ """
+ deprecated as of saas-14.3, not needed for newer versions of the mail plugin but necessary
+ for supporting older versions
+ """
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
diff --git a/addons/crm_mail_plugin/views/crm_lead_views.xml b/addons/crm_mail_plugin/views/crm_lead_views.xml
new file mode 100644
index 00000000000..3eb3bb2dca5
--- /dev/null
+++ b/addons/crm_mail_plugin/views/crm_lead_views.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ Lead: redirect form in edit mode
+ ir.actions.act_window
+ crm.lead
+ form
+
+
+ {
+ 'form_view_initial_mode': 'edit',
+ }
+
+
+
diff --git a/addons/mail_client_extension/views/mail_client_extension_lead.xml b/addons/crm_mail_plugin/views/crm_mail_plugin_lead.xml
similarity index 92%
rename from addons/mail_client_extension/views/mail_client_extension_lead.xml
rename to addons/crm_mail_plugin/views/crm_mail_plugin_lead.xml
index f13a110ec1a..c087782f7cd 100644
--- a/addons/mail_client_extension/views/mail_client_extension_lead.xml
+++ b/addons/crm_mail_plugin/views/crm_mail_plugin_lead.xml
@@ -1,5 +1,6 @@
+
Redirection to the lead creation form with prefilled info
ir.actions.server
diff --git a/addons/iap/models/__init__.py b/addons/iap/models/__init__.py
index d148377834f..2e95fd52ee5 100644
--- a/addons/iap/models/__init__.py
+++ b/addons/iap/models/__init__.py
@@ -3,3 +3,4 @@
from . import iap_account
from . import res_config_settings
+from . import iap_enrich_api
diff --git a/addons/crm_iap_lead_enrich/models/iap_enrich_api.py b/addons/iap/models/iap_enrich_api.py
similarity index 100%
rename from addons/crm_iap_lead_enrich/models/iap_enrich_api.py
rename to addons/iap/models/iap_enrich_api.py
diff --git a/addons/iap/tests/__init__.py b/addons/iap/tests/__init__.py
index 3c032a13d8b..4a6cacf0f89 100644
--- a/addons/iap/tests/__init__.py
+++ b/addons/iap/tests/__init__.py
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_iap
+from . import common
diff --git a/addons/crm_iap_lead_enrich/tests/common.py b/addons/iap/tests/common.py
similarity index 96%
rename from addons/crm_iap_lead_enrich/tests/common.py
rename to addons/iap/tests/common.py
index fb4b40e4269..1abed724698 100644
--- a/addons/crm_iap_lead_enrich/tests/common.py
+++ b/addons/iap/tests/common.py
@@ -6,7 +6,7 @@ from unittest.mock import patch
from odoo import exceptions
from odoo.addons.iap.tools import iap_tools
-from odoo.addons.crm_iap_lead_enrich.models.iap_enrich_api import IapEnrichAPI
+from odoo.addons.iap.models.iap_enrich_api import IapEnrichAPI
from odoo.tests import common
diff --git a/addons/mail_client_extension/__init__.py b/addons/mail_client_extension/__init__.py
deleted file mode 100644
index 38718f0843b..00000000000
--- a/addons/mail_client_extension/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-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
deleted file mode 100644
index 9c2f8417118..00000000000
--- a/addons/mail_client_extension/__manifest__.py
+++ /dev/null
@@ -1,24 +0,0 @@
-# -*- 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
deleted file mode 100644
index f7273e63342..00000000000
--- a/addons/mail_client_extension/controllers/__init__.py
+++ /dev/null
@@ -1,2 +0,0 @@
-# -*- 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
deleted file mode 100644
index 42f2687b1f1..00000000000
--- a/addons/mail_client_extension/controllers/main.py
+++ /dev/null
@@ -1,275 +0,0 @@
-# -*- 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 requests
-import werkzeug
-
-from odoo import http, tools
-from odoo.addons.iap.tools import iap_tools
-from odoo.http import request
-from odoo.tools.misc import formatLang
-
-_logger = logging.getLogger(__name__)
-
-
-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 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 iap_tools._MAIL_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']
-
- phone_numbers = iap_data.get('phone_numbers')
- emails = iap_data.get('email')
- 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': phone_numbers[0] if phone_numbers else None,
- 'website': iap_data.get("domain"),
- 'email': emails[0] if emails else None
- }
-
- logo_url = iap_data.get('logo')
- if logo_url:
- try:
- response = requests.get(logo_url, timeout=2)
- if response.ok:
- new_company_info['image_1920'] = base64.b64encode(response.content)
- except Exception as e:
- _logger.warning('Download of image for new company %r failed, error %r' % (new_company_info.name, e))
-
- 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)
- new_company.message_post_with_view(
- 'iap_mail.enrich_company',
- values=iap_data,
- subtype_id=request.env.ref('mail.mt_note').id,
- )
-
- 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
- partner.write({'parent_id': company})
- 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
deleted file mode 100644
index 47f7bea7264..00000000000
--- a/addons/mail_client_extension/models/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from . import crm_lead
-from . import res_partner
-from . import ir_http
\ No newline at end of file
diff --git a/addons/mail_plugin/__init__.py b/addons/mail_plugin/__init__.py
new file mode 100644
index 00000000000..8c67f893b37
--- /dev/null
+++ b/addons/mail_plugin/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
+from . import controllers
diff --git a/addons/mail_plugin/__manifest__.py b/addons/mail_plugin/__manifest__.py
new file mode 100644
index 00000000000..62fbdfe95ea
--- /dev/null
+++ b/addons/mail_plugin/__manifest__.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': 'Mail Plugin',
+ 'version': '1.0',
+ 'category': 'Sales/CRM',
+ 'sequence': 5,
+ 'summary': 'Allows integration with mail plugins.',
+ 'description': "Integrate Odoo with your mailbox, get information about contacts directly inside your mailbox, log content of emails as internal notes",
+ 'depends': [
+ 'web',
+ 'contacts',
+ 'iap'
+ ],
+ 'data': [
+ 'views/mail_plugin_login.xml',
+ ],
+ 'installable': True,
+ 'application': False,
+ 'auto_install': False
+}
diff --git a/addons/mail_plugin/controllers/__init__.py b/addons/mail_plugin/controllers/__init__.py
new file mode 100644
index 00000000000..2de4f0eb2d5
--- /dev/null
+++ b/addons/mail_plugin/controllers/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import authenticate
+from . import mail_plugin
diff --git a/addons/mail_plugin/controllers/authenticate.py b/addons/mail_plugin/controllers/authenticate.py
new file mode 100644
index 00000000000..72523d78592
--- /dev/null
+++ b/addons/mail_plugin/controllers/authenticate.py
@@ -0,0 +1,104 @@
+# -*- 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
+
+from odoo import http
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class Authenticate(http.Controller):
+
+ @http.route(['/mail_client_extension/auth', '/mail_plugin/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`.
+
+ old route name "/mail_client_extension/auth is deprecated as of saas-14.3,it is not needed for newer
+ versions of the mail plugin but necessary for supporting older versions
+ """
+ return request.render('mail_plugin.app_auth', values)
+
+ @http.route(['/mail_client_extension/auth/confirm', '/mail_plugin/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/auth/access_token`.
+
+ old route name "/mail_client_extension/auth/confirm is deprecated as of saas-14.3,it is not needed for newer
+ versions of the mail plugin but necessary for supporting older versions
+ """
+ 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
+ # the state attribute is needed for the gmail connector
+ params.update({'success': 1, 'auth_code': auth_code, 'state': kw.get('state', '')})
+ else:
+ params.update({'success': 0, 'state': kw.get('state', '')})
+ 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', '/mail_plugin/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
+
+ old route name "/mail_client_extension/auth/access_token is deprecated as of saas-14.3,it is not needed for newer
+ versions of the mail plugin but necessary for supporting older versions
+ """
+ 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_plugin', 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_plugin', 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
diff --git a/addons/mail_plugin/controllers/mail_plugin.py b/addons/mail_plugin/controllers/mail_plugin.py
new file mode 100644
index 00000000000..d31f5394c4e
--- /dev/null
+++ b/addons/mail_plugin/controllers/mail_plugin.py
@@ -0,0 +1,293 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import base64
+import json
+import logging
+import requests
+from werkzeug.exceptions import Forbidden
+
+from odoo import http, tools, _
+from odoo.addons.iap.tools import iap_tools
+from odoo.http import request
+
+_logger = logging.getLogger(__name__)
+
+
+class MailPluginController(http.Controller):
+
+ @http.route('/mail_client_extension/modules/get', type="json", auth="outlook", csrf=False, cors="*")
+ def modules_get(self, **kwargs):
+ """
+ deprecated as of saas-14.3, not needed for newer versions of the mail plugin but necessary
+ for supporting older versions
+ """
+ return {'modules': ['contacts', 'crm']}
+
+ @http.route('/mail_plugin/partner/enrich_and_create_company',
+ type="json", auth="outlook", cors="*")
+ def res_partner_enrich_and_create_company(self, partner_id):
+ """
+ Route used when the user clicks on the create and enrich partner button
+ it will try to find a company using IAP, if a company is found
+ the enriched company will then be created in the database
+ """
+ response = {}
+
+ partner = request.env['res.partner'].browse(partner_id)
+
+ if partner.parent_id:
+ return {'error': _("The partner already has a company related to him")}
+
+ normalized_email = partner.email_normalized
+ if not normalized_email:
+ response = {'error': _('Contact has no valid email')}
+ return response
+
+ company_domain = tools.email_domain_extract(normalized_email)
+
+ company, enrichment_info = self._create_company_from_iap(company_domain)
+
+ response['enrichment_info'] = enrichment_info
+ response['company'] = self._prepare_company_values(company)
+ if company:
+ partner.write({'parent_id': company})
+
+ return response
+
+ @http.route(['/mail_client_extension/partner/get', '/mail_plugin/partner/get']
+ , type="json", auth="outlook", cors="*")
+ def res_partner_get(self, email=None, name=None, partner_id=None, **kwargs):
+ """
+ returns a partner given it's id or an email and a name.
+ In case the partner does not exist, we return partner having an id -1, we also look if an existing company
+ matching the contact exists in the database, if none is found a new company is enriched and created automatically
+
+ old route name "/mail_client_extension/partner/get is deprecated as of saas-14.3, it is not needed for newer
+ versions of the mail plugin but necessary for supporting older versions, only the route name is deprecated not
+ the entire method.
+ """
+
+ if not (partner_id or (name and email)):
+ return {'error': _('You need to specify at least the partner_id or the name and the email')}
+
+ if partner_id:
+ partner = request.env['res.partner'].browse(partner_id)
+ return self._prepare_contact_values(partner)
+
+ normalized_email = tools.email_normalize(email)
+ if not normalized_email:
+ return {'error': _('Bad Email.')}
+
+ # 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]),
+ ('email_normalized', '=', normalized_email)], limit=1)
+
+ response = self._prepare_contact_values(partner)
+
+ # if no partner is found in the database, we should also return an empty one having id = -1, otherwise older versions of
+ # plugin won't work
+ if not response['partner']:
+ sender_domain = tools.email_domain_extract(email)
+ response['partner'] = {
+ 'id': -1,
+ 'email': email,
+ 'name': name,
+ 'enrichment_info': None,
+ }
+ company = self._find_existing_company(normalized_email)
+ if not company: # create and enrich company
+ company, enrichment_info = self._create_company_from_iap(sender_domain)
+ response['partner']['enrichment_info'] = enrichment_info
+ response['partner']['company'] = self._prepare_company_values(company)
+
+ return response
+
+ @http.route('/mail_plugin/partner/search', type="json", auth="outlook", cors="*")
+ def res_partners_search(self, search_term, limit=30, **kwargs):
+ """
+ Used for the plugin search contact functionality where the user types a string query in order to search for
+ matching contacts, the string query can either be the name of the contact, it's reference or it's email.
+ We choose these fields because these are probably the most interesting fields that the user can perform a
+ search on.
+ The method returns an array containing the dicts of the matched contacts.
+ """
+
+ #In a multi-company environment, the method may return contacts not belonging to the company that the user
+ #is connected to, this may result in the user not being able to view the contact in Odoo, while this may happen
+ #it is not supported for now and users are encouraged to check if they are connected to the correct company before
+ # clicking on a contact.
+
+ normalized_email = tools.email_normalize(search_term)
+
+ if normalized_email:
+ filter_domain = [('email_normalized', '=', search_term)]
+ else:
+ filter_domain = ['|', '|', ('display_name', 'ilike', search_term), ('ref', '=', search_term),
+ ('email', 'ilike', search_term)]
+
+ # Search for the partner based on the email.
+ # If multiple are found, take the first one.
+ partners = request.env['res.partner'].search(filter_domain, limit=limit)
+
+ partners = [
+ self._prepare_partner_values(partner)
+ for partner in partners
+ ]
+ return {"partners": partners}
+
+ @http.route(['/mail_client_extension/partner/create', '/mail_plugin/partner/create'],
+ type="json", auth="outlook", cors="*")
+ def res_partner_create(self, email, name, company):
+ """
+ params email: email of the new partner
+ params name: name of the new partner
+ params company: parent company id of the new partner
+ """
+ # old route name "/mail_client_extension/partner/create is deprecated as of saas-14.3,it is not needed for newer
+ # versions of the mail plugin but necessary for supporting older versions
+ # TODO search the company again instead of relying on the one provided here?
+ # Create the partner if needed.
+ partner_info = {
+ 'name': name,
+ 'email': email,
+ }
+
+ #see if the partner has a parent company
+ if company and company > -1:
+ partner_info['parent_id'] = company
+ partner = request.env['res.partner'].create(partner_info)
+
+ response = {'id': partner.id}
+ return response
+
+ @http.route('/mail_plugin/log_mail_content', type="json", auth="outlook", cors="*")
+ def log_mail_content(self, model, res_id, message):
+ if model not in self._mail_content_logging_models_whitelist():
+ raise Forbidden()
+ request.env[model].browse(res_id).message_post(body=message)
+
+ def _iap_enrich(self, domain):
+ enriched_data = {}
+ try:
+ response = request.env['iap.enrich.api']._request_enrich({domain: domain}) # The key doesn't matter
+ except iap_tools.InsufficientCreditError:
+ enriched_data['enrichment_info'] = {'type': 'insufficient_credit', 'info': request.env['iap.account'].get_credits_url('reveal')}
+ except Exception:
+ 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
+
+ def _find_existing_company(self, normalized_email):
+ sender_domain = tools.email_domain_extract(normalized_email)
+ search = sender_domain if sender_domain not in iap_tools._MAIL_DOMAIN_BLACKLIST else normalized_email
+ return request.env['res.partner'].search([('is_company', '=', True), ('email_normalized', '=ilike', '%' + search)],
+ limit=1)
+
+ def _prepare_company_values(self, company):
+
+ if not company:
+ return {'id': -1}
+
+ fields_list = ['id', 'name', 'phone', 'mobile', 'email', 'website']
+
+ company_values = dict((fname, company[fname]) for fname in fields_list)
+ company_values['address'] = {'street': company.street,
+ 'city': company.city,
+ 'zip': company.zip,
+ 'country': company.country_id.name if company.country_id else ''}
+ company_values['additionalInfo'] = json.loads(company.iap_enrich_info) if company.iap_enrich_info else {}
+ company_values['image'] = company.image_1920
+
+ return company_values
+
+
+ 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']
+
+ phone_numbers = iap_data.get('phone_numbers')
+ emails = iap_data.get('email')
+ new_company_info = {
+ 'is_company': True,
+ 'name': iap_data.get("name") or domain,
+ 'street': iap_data.get("street_name"),
+ 'city': iap_data.get("city"),
+ 'zip': iap_data.get("postal_code"),
+ 'phone': phone_numbers[0] if phone_numbers else None,
+ 'website': iap_data.get("domain"),
+ 'email': emails[0] if emails else None
+ }
+
+ logo_url = iap_data.get('logo')
+ if logo_url:
+ try:
+ response = requests.get(logo_url, timeout=2)
+ if response.ok:
+ new_company_info['image_1920'] = base64.b64encode(response.content)
+ except Exception as e:
+ _logger.warning('Download of image for new company %s failed, error %s', new_company_info.name, e)
+
+ 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)
+ new_company.message_post_with_view(
+ 'iap_mail.enrich_company',
+ values=iap_data,
+ subtype_id=request.env.ref('mail.mt_note').id,
+ )
+
+ return new_company, {'type': 'company_created'}
+
+ def _prepare_partner_values(self, partner):
+
+ fields_list = ['id', 'name', 'email', 'phone', 'mobile']
+
+ partner_values = dict((fname, partner[fname]) for fname in fields_list)
+ partner_values['image'] = partner.image_128
+ partner_values['title'] = partner.function
+ partner_values['enrichment_info'] = None
+
+ return partner_values
+
+
+ def _prepare_contact_values(self, partner):
+ """
+ method used to return partner related values, it can be overridden by other modules if extra information have to
+ be returned with the partner (e.g., leads, ...)
+ """
+ if partner:
+ partner_response = self._prepare_partner_values(partner)
+ if partner.company_type == 'company':
+ partner_response['company'] = self._prepare_company_values(partner)
+ elif partner.parent_id:
+ partner_response['company'] = self._prepare_company_values(partner.parent_id)
+ else:
+ partner_response['company'] = self._prepare_company_values(None)
+ else: # no partner found
+ partner_response = {}
+
+ return {'partner': partner_response}
+
+ def _mail_content_logging_models_whitelist(self):
+ """
+ Returns all models that emails can be logged to and that can be used by the "log_mail_content" method,
+ it can be overridden by sub modules in order to whitelist more models
+ """
+ return ['res.partner']
diff --git a/addons/mail_client_extension/i18n/mail_client_extension.pot b/addons/mail_plugin/i18n/mail_client_extension.pot
similarity index 100%
rename from addons/mail_client_extension/i18n/mail_client_extension.pot
rename to addons/mail_plugin/i18n/mail_client_extension.pot
diff --git a/addons/mail_plugin/models/__init__.py b/addons/mail_plugin/models/__init__.py
new file mode 100644
index 00000000000..4d45ab6ee21
--- /dev/null
+++ b/addons/mail_plugin/models/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import ir_http
+from . import res_partner
diff --git a/addons/mail_client_extension/models/ir_http.py b/addons/mail_plugin/models/ir_http.py
similarity index 96%
rename from addons/mail_client_extension/models/ir_http.py
rename to addons/mail_plugin/models/ir_http.py
index e69a12324c3..f7bc5628076 100644
--- a/addons/mail_client_extension/models/ir_http.py
+++ b/addons/mail_plugin/models/ir_http.py
@@ -23,4 +23,4 @@ class IrHttp(models.AbstractModel):
raise BadRequest('Access token invalid')
# take the identity of the API key user
- request.uid = user_id
\ No newline at end of file
+ request.uid = user_id
diff --git a/addons/mail_client_extension/models/res_partner.py b/addons/mail_plugin/models/res_partner.py
similarity index 100%
rename from addons/mail_client_extension/models/res_partner.py
rename to addons/mail_plugin/models/res_partner.py
diff --git a/addons/mail_client_extension/views/mail_client_extension_login.xml b/addons/mail_plugin/views/mail_plugin_login.xml
similarity index 84%
rename from addons/mail_client_extension/views/mail_client_extension_login.xml
rename to addons/mail_plugin/views/mail_plugin_login.xml
index 8d6918b20fe..b322b73096b 100644
--- a/addons/mail_client_extension/views/mail_client_extension_login.xml
+++ b/addons/mail_plugin/views/mail_plugin_login.xml
@@ -1,12 +1,13 @@
-
+
-
-
\ No newline at end of file
+