You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

312 lines
13 KiB

  1. # -*- coding: utf-8 -*-
  2. # Copyright 2016 Tecnativa - Antonio Espinosa
  3. # Copyright 2017 Tecnativa - David Vidal
  4. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
  5. import hashlib
  6. import hmac
  7. import json
  8. import requests
  9. from datetime import datetime
  10. from odoo import _, api, fields, models
  11. from odoo.exceptions import UserError, ValidationError
  12. from odoo.tools import email_split
  13. import logging
  14. _logger = logging.getLogger(__name__)
  15. class MailTrackingEmail(models.Model):
  16. _inherit = "mail.tracking.email"
  17. def _country_search(self, country_code):
  18. country = False
  19. if country_code:
  20. country = self.env['res.country'].search([
  21. ('code', '=', country_code.upper()),
  22. ])
  23. if country:
  24. return country.id
  25. return False
  26. @property
  27. def _mailgun_mandatory_fields(self):
  28. return ('event', 'timestamp', 'token', 'signature',
  29. 'tracking_email_id', 'odoo_db')
  30. @property
  31. def _mailgun_event_type_mapping(self):
  32. return {
  33. # Mailgun event type: tracking event type
  34. 'delivered': 'delivered',
  35. 'opened': 'open',
  36. 'clicked': 'click',
  37. 'unsubscribed': 'unsub',
  38. 'complained': 'spam',
  39. 'bounced': 'hard_bounce',
  40. 'dropped': 'reject',
  41. 'accepted': 'sent',
  42. }
  43. def _mailgun_event_type_verify(self, event):
  44. event = event or {}
  45. mailgun_event_type = event.get('event')
  46. if mailgun_event_type not in self._mailgun_event_type_mapping:
  47. _logger.error("Mailgun: event type '%s' not supported",
  48. mailgun_event_type)
  49. return False
  50. # OK, event type is valid
  51. return True
  52. def _mailgun_signature(self, api_key, timestamp, token):
  53. return hmac.new(
  54. key=str(api_key),
  55. msg='{}{}'.format(str(timestamp), str(token)),
  56. digestmod=hashlib.sha256).hexdigest()
  57. def _mailgun_values(self):
  58. icp = self.env['ir.config_parameter']
  59. api_key = icp.get_param('mailgun.apikey')
  60. if not api_key:
  61. raise ValidationError(_('There is no Mailgun API key!'))
  62. api_url = icp.get_param(
  63. 'mailgun.api_url', 'https://api.mailgun.net/v3')
  64. domain = icp.get_param('mail.catchall.domain')
  65. if not domain:
  66. raise ValidationError(_('A Mailgun domain value is needed!'))
  67. validation_key = icp.get_param('mailgun.validation_key')
  68. return api_key, api_url, domain, validation_key
  69. def _mailgun_signature_verify(self, event):
  70. event = event or {}
  71. api_key = self.env['ir.config_parameter'].get_param('mailgun.apikey')
  72. if not api_key:
  73. _logger.warning("No Mailgun api key configured. "
  74. "Please add 'mailgun.apikey' to System parameters "
  75. "to enable Mailgun authentication webhoook "
  76. "requests. More info at: "
  77. "https://documentation.mailgun.com/"
  78. "user_manual.html#webhooks")
  79. else:
  80. timestamp = event.get('timestamp')
  81. token = event.get('token')
  82. signature = event.get('signature')
  83. event_digest = self._mailgun_signature(api_key, timestamp, token)
  84. if signature != event_digest:
  85. _logger.error("Mailgun: Invalid signature '%s' != '%s'",
  86. signature, event_digest)
  87. return False
  88. # OK, signature is valid
  89. return True
  90. def _db_verify(self, event):
  91. event = event or {}
  92. odoo_db = event.get('odoo_db')
  93. current_db = self.env.cr.dbname
  94. if odoo_db != current_db:
  95. _logger.error("Mailgun: Database '%s' is not the current database",
  96. odoo_db)
  97. return False
  98. # OK, DB is current
  99. return True
  100. def _mailgun_metadata(self, mailgun_event_type, event, metadata):
  101. # Get Mailgun timestamp when found
  102. ts = event.get('timestamp', False)
  103. try:
  104. ts = float(ts)
  105. except:
  106. ts = False
  107. if ts:
  108. dt = datetime.utcfromtimestamp(ts)
  109. metadata.update({
  110. 'timestamp': ts,
  111. 'time': fields.Datetime.to_string(dt),
  112. 'date': fields.Date.to_string(dt),
  113. 'mailgun_id': event.get('id', False)
  114. })
  115. # Common field mapping
  116. mapping = {
  117. 'recipient': 'recipient',
  118. 'ip': 'ip',
  119. 'user_agent': 'user-agent',
  120. 'os_family': 'client-os',
  121. 'ua_family': 'client-name',
  122. 'ua_type': 'client-type',
  123. 'url': 'url',
  124. }
  125. for k, v in mapping.iteritems():
  126. if event.get(v, False):
  127. metadata[k] = event[v]
  128. # Special field mapping
  129. metadata.update({
  130. 'mobile': event.get('device-type') in {'mobile', 'tablet'},
  131. 'user_country_id': self._country_search(
  132. event.get('country', False)),
  133. })
  134. # Mapping for special events
  135. if mailgun_event_type == 'bounced':
  136. metadata.update({
  137. 'error_type': event.get('code', False),
  138. 'error_description': event.get('error', False),
  139. 'error_details': event.get('notification', False),
  140. })
  141. elif mailgun_event_type == 'dropped':
  142. metadata.update({
  143. 'error_type': event.get('reason', False),
  144. 'error_description': event.get('code', False),
  145. 'error_details': event.get('description', False),
  146. })
  147. elif mailgun_event_type == 'complained':
  148. metadata.update({
  149. 'error_type': 'spam',
  150. 'error_description':
  151. "Recipient '%s' mark this email as spam" %
  152. event.get('recipient', False),
  153. })
  154. return metadata
  155. def _mailgun_tracking_get(self, event):
  156. tracking = False
  157. tracking_email_id = event.get('tracking_email_id', False)
  158. if tracking_email_id and tracking_email_id.isdigit():
  159. tracking = self.search([('id', '=', tracking_email_id)], limit=1)
  160. return tracking
  161. def _event_is_from_mailgun(self, event):
  162. event = event or {}
  163. return all([k in event for k in self._mailgun_mandatory_fields])
  164. @api.model
  165. def event_process(self, request, post, metadata, event_type=None):
  166. res = super(MailTrackingEmail, self).event_process(
  167. request, post, metadata, event_type=event_type)
  168. if res == 'NONE' and self._event_is_from_mailgun(post):
  169. if not self._mailgun_signature_verify(post):
  170. res = 'ERROR: Signature'
  171. elif not self._mailgun_event_type_verify(post):
  172. res = 'ERROR: Event type not supported'
  173. elif not self._db_verify(post):
  174. res = 'ERROR: Invalid DB'
  175. else:
  176. res = 'OK'
  177. if res == 'OK':
  178. mailgun_event_type = post.get('event')
  179. mapped_event_type = self._mailgun_event_type_mapping.get(
  180. mailgun_event_type) or event_type
  181. if not mapped_event_type: # pragma: no cover
  182. res = 'ERROR: Bad event'
  183. tracking = self._mailgun_tracking_get(post)
  184. if not tracking:
  185. res = 'ERROR: Tracking not found'
  186. if res == 'OK':
  187. # Complete metadata with mailgun event info
  188. metadata = self._mailgun_metadata(
  189. mailgun_event_type, post, metadata)
  190. # Create event
  191. tracking.event_create(mapped_event_type, metadata)
  192. if res != 'NONE':
  193. if event_type:
  194. _logger.info(
  195. "Mailgun: event '%s' process '%s'", event_type, res)
  196. else:
  197. _logger.info("Mailgun: event process '%s'", res)
  198. return res
  199. @api.multi
  200. def action_manual_check_mailgun(self):
  201. """
  202. Manual check against Mailgun API
  203. API Documentation:
  204. https://documentation.mailgun.com/en/latest/api-events.html
  205. """
  206. api_key, api_url, domain, validation_key = self._mailgun_values()
  207. for tracking in self:
  208. if not tracking.mail_message_id:
  209. raise UserError(_('There is no tracked message!'))
  210. message_id = tracking.mail_message_id.message_id.replace(
  211. "<", "").replace(">", "")
  212. res = requests.get(
  213. '%s/%s/events' % (api_url, domain),
  214. auth=("api", api_key),
  215. params={
  216. "begin": tracking.timestamp,
  217. "ascending": "yes",
  218. "message-id": message_id,
  219. }
  220. )
  221. if not res or res.status_code != 200:
  222. raise ValidationError(_(
  223. "Couldn't retrieve Mailgun information"))
  224. content = json.loads(res.content, res.apparent_encoding)
  225. if "items" not in content:
  226. raise ValidationError(_("Event information not longer stored"))
  227. for item in content["items"]:
  228. if not self.env['mail.tracking.event'].search(
  229. # mailgun event hasn't been synced and recipient is the same as
  230. # in the evaluated tracking. We use email_split since tracking
  231. # recipient could come in format: "example" <to@dest.com>
  232. [('mailgun_id', '=', item["id"])]) and (
  233. item.get("recipient", "") ==
  234. email_split(tracking.recipient)[0]):
  235. mapped_event_type = self._mailgun_event_type_mapping.get(
  236. item["event"], item["event"])
  237. metadata = self._mailgun_metadata(
  238. mapped_event_type, item, {})
  239. tracking.event_create(mapped_event_type, metadata)
  240. @api.multi
  241. def check_email_list_validity(self, email_list):
  242. """
  243. Checks email list validity with Mailgun's API
  244. API documentation:
  245. https://documentation.mailgun.com/en/latest/api-email-validation.html
  246. """
  247. api_key, api_url, domain, validation_key = self.env[
  248. 'mail.tracking.email']._mailgun_values()
  249. if not validation_key:
  250. raise UserError(_('You need to configure mailgun.validation_key'
  251. ' in order to be able to check mails validity'))
  252. result = {}
  253. for email in email_list:
  254. res = requests.get(
  255. "%s/address/validate" % api_url,
  256. auth=("api", validation_key), params={
  257. "address": email,
  258. "mailbox_verification": True,
  259. })
  260. if not res or res.status_code != 200:
  261. result[email] = {'result': (_(
  262. 'Error %s trying to '
  263. 'check mail' % res.status_code or 'of connection'))}
  264. continue
  265. content = json.loads(res.content, res.apparent_encoding)
  266. if 'mailbox_verification' not in content:
  267. result[email] = {'result': (
  268. _("Mailgun Error. Mailbox verification value wasn't"
  269. " returned"))}
  270. continue
  271. # Not a valid address: API sets 'is_valid' as False
  272. # and 'mailbox_verification' as None
  273. if not content['is_valid']:
  274. result[email] = {'result': (
  275. _('%s is not a valid email address. Please check it '
  276. 'in order to avoid sending issues') % (email))}
  277. continue
  278. # If the mailbox is not valid API returns 'mailbox_verification'
  279. # as a string with value 'false'
  280. if content['mailbox_verification'] == 'false':
  281. result[email] = {'result': (
  282. _('%s failed the mailbox verification. Please check it '
  283. 'in order to avoid sending issues') % (email))}
  284. continue
  285. # If Mailgun can't complete the validation request the API returns
  286. # 'mailbox_verification' as a string set to 'unknown'
  287. if content['mailbox_verification'] == 'unknown':
  288. result[email] = {'result': (
  289. _("%s couldn't be verified. Either the request couln't be "
  290. "completed or the mailbox provider doesn't support "
  291. "email verification") % (email))}
  292. continue
  293. result[email] = {'result': _("The mailbox is correct")}
  294. return result