diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py index f1fd84be745..083d846d245 100644 --- a/addons/base_setup/models/res_config_settings.py +++ b/addons/base_setup/models/res_config_settings.py @@ -22,6 +22,9 @@ class ResConfigSettings(models.TransientModel): string='Allow the users to synchronize their calendar with Google Calendar') module_microsoft_calendar = fields.Boolean( string='Allow the users to synchronize their calendar with Outlook Calendar') + module_mail_plugin = fields.Boolean( + string='Allow integration with the mail plugins' + ) module_google_drive = fields.Boolean("Attach Google documents to any record") module_google_spreadsheet = fields.Boolean("Google Spreadsheet") module_auth_oauth = fields.Boolean("Use external authentication providers (OAuth)") diff --git a/addons/base_setup/views/res_config_settings_views.xml b/addons/base_setup/views/res_config_settings_views.xml index 1063f056842..b27a1d51a52 100644 --- a/addons/base_setup/views/res_config_settings_views.xml +++ b/addons/base_setup/views/res_config_settings_views.xml @@ -210,17 +210,15 @@

Integrations

-
+
- +
-
@@ -239,6 +237,20 @@
+
+
+ +
+
+
+
diff --git a/addons/crm/models/res_config_settings.py b/addons/crm/models/res_config_settings.py index f9165f22188..70e3ca98420 100644 --- a/addons/crm/models/res_config_settings.py +++ b/addons/crm/models/res_config_settings.py @@ -39,7 +39,6 @@ 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 5bd0de10945..e3623f7a5c4 100644 --- a/addons/crm/views/res_config_settings_views.xml +++ b/addons/crm/views/res_config_settings_views.xml @@ -119,19 +119,6 @@

Lead Generation

-
-
- -
- -
-
-
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 @@ -