mail_plugin.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import base64
  4. import json
  5. import logging
  6. import requests
  7. from werkzeug.exceptions import Forbidden
  8. from odoo import http, tools, _
  9. from odoo.addons.iap.tools import iap_tools
  10. from odoo.exceptions import AccessError
  11. from odoo.http import request
  12. _logger = logging.getLogger(__name__)
  13. class MailPluginController(http.Controller):
  14. @http.route('/mail_client_extension/modules/get', type="json", auth="outlook", csrf=False, cors="*")
  15. def modules_get(self, **kwargs):
  16. """
  17. deprecated as of saas-14.3, not needed for newer versions of the mail plugin but necessary
  18. for supporting older versions
  19. """
  20. return {'modules': ['contacts', 'crm']}
  21. @http.route('/mail_plugin/partner/enrich_and_create_company',
  22. type="json", auth="outlook", cors="*")
  23. def res_partner_enrich_and_create_company(self, partner_id):
  24. """
  25. Route used when the user clicks on the create and enrich partner button
  26. it will try to find a company using IAP, if a company is found
  27. the enriched company will then be created in the database
  28. """
  29. partner = request.env['res.partner'].browse(partner_id).exists()
  30. if not partner:
  31. return {'error': _("This partner does not exist")}
  32. if partner.parent_id:
  33. return {'error': _("The partner already has a company related to him")}
  34. normalized_email = partner.email_normalized
  35. if not normalized_email:
  36. return {'error': _('The email of this contact is not valid and we can not enrich it')}
  37. company, enrichment_info = self._create_company_from_iap(normalized_email)
  38. if company:
  39. partner.write({'parent_id': company})
  40. return {
  41. 'enrichment_info': enrichment_info,
  42. 'company': self._get_company_data(company),
  43. }
  44. @http.route('/mail_plugin/partner/enrich_and_update_company', type='json', auth='outlook', cors='*')
  45. def res_partner_enrich_and_update_company(self, partner_id):
  46. """
  47. Enriches an existing company using IAP
  48. """
  49. partner = request.env['res.partner'].browse(partner_id).exists()
  50. if not partner:
  51. return {'error': _("This partner does not exist")}
  52. if not partner.is_company:
  53. return {'error': 'Contact must be a company'}
  54. normalized_email = partner.email_normalized
  55. if not normalized_email:
  56. return {'error': 'The email of this contact is not valid and we can not enrich it'}
  57. domain = tools.email_domain_extract(normalized_email)
  58. iap_data = self._iap_enrich(domain)
  59. if 'enrichment_info' in iap_data: # means that an issue happened with the enrichment request
  60. return {
  61. 'enrichment_info': iap_data['enrichment_info'],
  62. 'company': self._get_company_data(partner),
  63. }
  64. phone_numbers = iap_data.get('phone_numbers')
  65. partner_values = {}
  66. if not partner.phone and phone_numbers:
  67. partner_values.update({'phone': phone_numbers[0]})
  68. if not partner.iap_enrich_info:
  69. partner_values.update({'iap_enrich_info': json.dumps(iap_data)})
  70. if not partner.image_128:
  71. logo_url = iap_data.get('logo')
  72. if logo_url:
  73. try:
  74. response = requests.get(logo_url, timeout=2)
  75. if response.ok:
  76. partner_values.update({'image_1920': base64.b64encode(response.content)})
  77. except Exception:
  78. pass
  79. model_fields_to_iap_mapping = {
  80. 'street': 'street_name',
  81. 'city': 'city',
  82. 'zip': 'postal_code',
  83. 'website': 'domain',
  84. }
  85. # only update keys for which we dont have values yet
  86. partner_values.update({
  87. model_field: iap_data.get(iap_key)
  88. for model_field, iap_key in model_fields_to_iap_mapping.items() if not partner[model_field]
  89. })
  90. partner.write(partner_values)
  91. partner.message_post_with_view(
  92. 'iap_mail.enrich_company',
  93. values=iap_data,
  94. subtype_id=request.env.ref('mail.mt_note').id,
  95. )
  96. return {
  97. 'enrichment_info': {'type': 'company_updated'},
  98. 'company': self._get_company_data(partner),
  99. }
  100. @http.route(['/mail_client_extension/partner/get', '/mail_plugin/partner/get']
  101. , type="json", auth="outlook", cors="*")
  102. def res_partner_get(self, email=None, name=None, partner_id=None, **kwargs):
  103. """
  104. returns a partner given it's id or an email and a name.
  105. In case the partner does not exist, we return partner having an id -1, we also look if an existing company
  106. matching the contact exists in the database, if none is found a new company is enriched and created automatically
  107. old route name "/mail_client_extension/partner/get is deprecated as of saas-14.3, it is not needed for newer
  108. versions of the mail plugin but necessary for supporting older versions, only the route name is deprecated not
  109. the entire method.
  110. """
  111. if not (partner_id or (name and email)):
  112. return {'error': _('You need to specify at least the partner_id or the name and the email')}
  113. if partner_id:
  114. partner = request.env['res.partner'].browse(partner_id).exists()
  115. return self._get_contact_data(partner)
  116. normalized_email = tools.email_normalize(email)
  117. if not normalized_email:
  118. return {'error': _('Bad Email.')}
  119. # Search for the partner based on the email.
  120. # If multiple are found, take the first one.
  121. partner = request.env['res.partner'].search(['|', ('email', 'in', [normalized_email, email]),
  122. ('email_normalized', '=', normalized_email)], limit=1)
  123. response = self._get_contact_data(partner)
  124. # if no partner is found in the database, we should also return an empty one having id = -1, otherwise older versions of
  125. # plugin won't work
  126. if not response['partner']:
  127. response['partner'] = {
  128. 'id': -1,
  129. 'email': email,
  130. 'name': name,
  131. 'enrichment_info': None,
  132. }
  133. company = self._find_existing_company(normalized_email)
  134. can_create_partner = request.env['res.partner'].check_access_rights('create', raise_exception=False)
  135. if not company and can_create_partner: # create and enrich company
  136. company, enrichment_info = self._create_company_from_iap(normalized_email)
  137. response['partner']['enrichment_info'] = enrichment_info
  138. response['partner']['company'] = self._get_company_data(company)
  139. return response
  140. @http.route('/mail_plugin/partner/search', type="json", auth="outlook", cors="*")
  141. def res_partners_search(self, search_term, limit=30, **kwargs):
  142. """
  143. Used for the plugin search contact functionality where the user types a string query in order to search for
  144. matching contacts, the string query can either be the name of the contact, it's reference or it's email.
  145. We choose these fields because these are probably the most interesting fields that the user can perform a
  146. search on.
  147. The method returns an array containing the dicts of the matched contacts.
  148. """
  149. normalized_email = tools.email_normalize(search_term)
  150. if normalized_email:
  151. filter_domain = [('email_normalized', 'ilike', search_term)]
  152. else:
  153. filter_domain = ['|', '|', ('display_name', 'ilike', search_term), ('ref', '=', search_term),
  154. ('email', 'ilike', search_term)]
  155. # Search for the partner based on the email.
  156. # If multiple are found, take the first one.
  157. partners = request.env['res.partner'].search(filter_domain, limit=limit)
  158. partners = [
  159. self._get_partner_data(partner)
  160. for partner in partners
  161. ]
  162. return {"partners": partners}
  163. @http.route(['/mail_client_extension/partner/create', '/mail_plugin/partner/create'],
  164. type="json", auth="outlook", cors="*")
  165. def res_partner_create(self, email, name, company):
  166. """
  167. params email: email of the new partner
  168. params name: name of the new partner
  169. params company: parent company id of the new partner
  170. """
  171. # old route name "/mail_client_extension/partner/create is deprecated as of saas-14.3,it is not needed for newer
  172. # versions of the mail plugin but necessary for supporting older versions
  173. # TODO search the company again instead of relying on the one provided here?
  174. # Create the partner if needed.
  175. partner_info = {
  176. 'name': name,
  177. 'email': email,
  178. }
  179. #see if the partner has a parent company
  180. if company and company > -1:
  181. partner_info['parent_id'] = company
  182. partner = request.env['res.partner'].create(partner_info)
  183. response = {'id': partner.id}
  184. return response
  185. @http.route('/mail_plugin/log_mail_content', type="json", auth="outlook", cors="*")
  186. def log_mail_content(self, model, res_id, message, attachments=None):
  187. """Log the email on the given record.
  188. :param model: Model of the record on which we want to log the email
  189. :param res_id: ID of the record
  190. :param message: Body of the email
  191. :param attachments: List of attachments of the email.
  192. List of tuple: (filename, base 64 encoded content)
  193. """
  194. if model not in self._mail_content_logging_models_whitelist():
  195. raise Forbidden()
  196. if attachments:
  197. attachments = [
  198. (name, base64.b64decode(content))
  199. for name, content in attachments
  200. ]
  201. request.env[model].browse(res_id).message_post(body=message, attachments=attachments)
  202. return True
  203. @http.route('/mail_plugin/get_translations', type="json", auth="outlook", cors="*")
  204. def get_translations(self):
  205. return self._prepare_translations()
  206. def _iap_enrich(self, domain):
  207. """
  208. Returns enrichment data for a given domain, in case an error happens the response will
  209. contain an enrichment_info key explaining what went wrong
  210. """
  211. if domain in iap_tools._MAIL_DOMAIN_BLACKLIST:
  212. # Can not enrich the provider domain names (gmail.com; outlook.com, etc)
  213. return {'enrichment_info': {'type': 'missing_data'}}
  214. enriched_data = {}
  215. try:
  216. response = request.env['iap.enrich.api']._request_enrich({domain: domain}) # The key doesn't matter
  217. except iap_tools.InsufficientCreditError:
  218. enriched_data['enrichment_info'] = {'type': 'insufficient_credit', 'info': request.env['iap.account'].get_credits_url('reveal')}
  219. except Exception:
  220. enriched_data["enrichment_info"] = {'type': 'other', 'info': 'Unknown reason'}
  221. else:
  222. enriched_data = response.get(domain)
  223. if not enriched_data:
  224. enriched_data = {'enrichment_info': {'type': 'no_data', 'info': 'The enrichment API found no data for the email provided.'}}
  225. return enriched_data
  226. def _find_existing_company(self, email):
  227. """Find the company corresponding to the given domain and its IAP cache.
  228. :param email: Email of the company we search
  229. :return: The partner corresponding to the company
  230. """
  231. search = self._get_iap_search_term(email)
  232. partner_iap = request.env["res.partner.iap"].sudo().search([("iap_search_domain", "=", search)], limit=1)
  233. if partner_iap:
  234. return partner_iap.partner_id.sudo(False)
  235. return request.env["res.partner"].search([("is_company", "=", True), ("email_normalized", "=ilike", "%" + search)], limit=1)
  236. def _get_company_data(self, company):
  237. if not company:
  238. return {'id': -1}
  239. try:
  240. company.check_access_rights('read')
  241. company.check_access_rule('read')
  242. except AccessError:
  243. return {'id': company.id, 'name': _('No Access')}
  244. fields_list = ['id', 'name', 'phone', 'mobile', 'email', 'website']
  245. company_values = dict((fname, company[fname]) for fname in fields_list)
  246. company_values['address'] = {'street': company.street,
  247. 'city': company.city,
  248. 'zip': company.zip,
  249. 'country': company.country_id.name if company.country_id else ''}
  250. company_values['additionalInfo'] = json.loads(company.iap_enrich_info) if company.iap_enrich_info else {}
  251. company_values['image'] = company.image_1920
  252. return company_values
  253. def _create_company_from_iap(self, email):
  254. domain = tools.email_domain_extract(email)
  255. iap_data = self._iap_enrich(domain)
  256. if 'enrichment_info' in iap_data:
  257. return None, iap_data['enrichment_info']
  258. phone_numbers = iap_data.get('phone_numbers')
  259. emails = iap_data.get('email')
  260. new_company_info = {
  261. 'is_company': True,
  262. 'name': iap_data.get("name") or domain,
  263. 'street': iap_data.get("street_name"),
  264. 'city': iap_data.get("city"),
  265. 'zip': iap_data.get("postal_code"),
  266. 'phone': phone_numbers[0] if phone_numbers else None,
  267. 'website': iap_data.get("domain"),
  268. 'email': emails[0] if emails else None
  269. }
  270. logo_url = iap_data.get('logo')
  271. if logo_url:
  272. try:
  273. response = requests.get(logo_url, timeout=2)
  274. if response.ok:
  275. new_company_info['image_1920'] = base64.b64encode(response.content)
  276. except Exception as e:
  277. _logger.warning('Download of image for new company %s failed, error %s', new_company_info.name, e)
  278. if iap_data.get('country_code'):
  279. country = request.env['res.country'].search([('code', '=', iap_data['country_code'].upper())])
  280. if country:
  281. new_company_info['country_id'] = country.id
  282. if iap_data.get('state_code'):
  283. state = request.env['res.country.state'].search([
  284. ('code', '=', iap_data['state_code']),
  285. ('country_id', '=', country.id)
  286. ])
  287. if state:
  288. new_company_info['state_id'] = state.id
  289. new_company_info.update({
  290. 'iap_search_domain': self._get_iap_search_term(email),
  291. 'iap_enrich_info': json.dumps(iap_data),
  292. })
  293. new_company = request.env['res.partner'].create(new_company_info)
  294. new_company.message_post_with_view(
  295. 'iap_mail.enrich_company',
  296. values=iap_data,
  297. subtype_id=request.env.ref('mail.mt_note').id,
  298. )
  299. return new_company, {'type': 'company_created'}
  300. def _get_partner_data(self, partner):
  301. fields_list = ['id', 'name', 'email', 'phone', 'mobile', 'is_company']
  302. partner_values = dict((fname, partner[fname]) for fname in fields_list)
  303. partner_values['image'] = partner.image_128
  304. partner_values['title'] = partner.function
  305. partner_values['enrichment_info'] = None
  306. try:
  307. partner.check_access_rights('write')
  308. partner.check_access_rule('write')
  309. partner_values['can_write_on_partner'] = True
  310. except AccessError:
  311. partner_values['can_write_on_partner'] = False
  312. if not partner_values['name']:
  313. # Always ensure that the partner has a name
  314. name, email = request.env['res.partner']._parse_partner_name(
  315. partner_values['email'])
  316. partner_values['name'] = name or email
  317. return partner_values
  318. def _get_contact_data(self, partner):
  319. """
  320. method used to return partner related values, it can be overridden by other modules if extra information have to
  321. be returned with the partner (e.g., leads, ...)
  322. """
  323. if partner:
  324. partner_response = self._get_partner_data(partner)
  325. if partner.company_type == 'company':
  326. partner_response['company'] = self._get_company_data(partner)
  327. elif partner.parent_id:
  328. partner_response['company'] = self._get_company_data(partner.parent_id)
  329. else:
  330. partner_response['company'] = self._get_company_data(None)
  331. else: # no partner found
  332. partner_response = {}
  333. return {
  334. 'partner': partner_response,
  335. 'user_companies': request.env.user.company_ids.ids,
  336. 'can_create_partner': request.env['res.partner'].check_access_rights(
  337. 'create', raise_exception=False),
  338. }
  339. def _mail_content_logging_models_whitelist(self):
  340. """
  341. Returns all models that emails can be logged to and that can be used by the "log_mail_content" method,
  342. it can be overridden by sub modules in order to whitelist more models
  343. """
  344. return ['res.partner']
  345. def _get_iap_search_term(self, email):
  346. """Return the domain or the email depending if the domain is blacklisted or not.
  347. So if the domain is blacklisted, we search based on the entire email address
  348. (e.g. asbl@gmail.com). But if the domain is not blacklisted, we search based on
  349. the domain (e.g. bob@sncb.be -> sncb.be)
  350. """
  351. domain = tools.email_domain_extract(email)
  352. return ("@" + domain) if domain not in iap_tools._MAIL_DOMAIN_BLACKLIST else email
  353. def _translation_modules_whitelist(self):
  354. """
  355. Returns the list of modules to be translated
  356. Other mail plugin modules have to override this method to include their module names
  357. """
  358. return ['mail_plugin']
  359. def _prepare_translations(self):
  360. lang = request.env['res.users'].browse(request.uid).lang
  361. translations_per_module = request.env["ir.http"].get_translations_for_webclient(
  362. self._translation_modules_whitelist(), lang)[0]
  363. translations_dict = {}
  364. for module in self._translation_modules_whitelist():
  365. translations = translations_per_module.get(module, {})
  366. messages = translations.get('messages', {})
  367. for message in messages:
  368. translations_dict.update({message['id']: message['string']})
  369. return translations_dict