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.

257 lines
10 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 requests
  8. from datetime import datetime
  9. from odoo import _, api, fields, models
  10. from odoo.exceptions import UserError, ValidationError
  11. from odoo.tools import email_split
  12. import logging
  13. _logger = logging.getLogger(__name__)
  14. class MailTrackingEmail(models.Model):
  15. _inherit = "mail.tracking.email"
  16. def _country_search(self, country_code):
  17. country = False
  18. if country_code:
  19. country = self.env['res.country'].search([
  20. ('code', '=', country_code.upper()),
  21. ])
  22. if country:
  23. return country.id
  24. return False
  25. @property
  26. def _mailgun_mandatory_fields(self):
  27. return ('event', 'timestamp', 'token', 'signature',
  28. 'tracking_email_id', 'odoo_db')
  29. @property
  30. def _mailgun_event_type_mapping(self):
  31. return {
  32. # Mailgun event type: tracking event type
  33. 'delivered': 'delivered',
  34. 'opened': 'open',
  35. 'clicked': 'click',
  36. 'unsubscribed': 'unsub',
  37. 'complained': 'spam',
  38. 'bounced': 'hard_bounce',
  39. 'dropped': 'reject',
  40. 'accepted': 'sent',
  41. }
  42. def _mailgun_event_type_verify(self, event):
  43. event = event or {}
  44. mailgun_event_type = event.get('event')
  45. if mailgun_event_type not in self._mailgun_event_type_mapping:
  46. _logger.error("Mailgun: event type '%s' not supported",
  47. mailgun_event_type)
  48. return False
  49. # OK, event type is valid
  50. return True
  51. def _mailgun_signature(self, api_key, timestamp, token):
  52. return hmac.new(
  53. key=bytes(api_key, 'utf-8'),
  54. msg=bytes('{}{}'.format(str(timestamp), str(token)), 'utf-8'),
  55. digestmod=hashlib.sha256).hexdigest()
  56. def _mailgun_values(self):
  57. icp = self.env['ir.config_parameter'].sudo()
  58. api_key = icp.get_param('mailgun.apikey')
  59. if not api_key:
  60. raise ValidationError(_('There is no Mailgun API key!'))
  61. api_url = icp.get_param(
  62. 'mailgun.api_url', 'https://api.mailgun.net/v3')
  63. catchall_domain = icp.get_param('mail.catchall.domain')
  64. domain = icp.get_param('mailgun.domain', 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. icp = self.env['ir.config_parameter'].sudo()
  72. api_key = icp.get_param('mailgun.apikey')
  73. if not api_key:
  74. _logger.warning("No Mailgun api key configured. "
  75. "Please add 'mailgun.apikey' to System parameters "
  76. "to enable Mailgun authentication webhoook "
  77. "requests. More info at: "
  78. "https://documentation.mailgun.com/"
  79. "user_manual.html#webhooks")
  80. else:
  81. timestamp = event.get('timestamp')
  82. token = event.get('token')
  83. signature = event.get('signature')
  84. event_digest = self._mailgun_signature(api_key, timestamp, token)
  85. if signature != event_digest:
  86. _logger.error("Mailgun: Invalid signature '%s' != '%s'",
  87. signature, event_digest)
  88. return False
  89. # OK, signature is valid
  90. return True
  91. def _db_verify(self, event):
  92. event = event or {}
  93. odoo_db = event.get('odoo_db')
  94. current_db = self.env.cr.dbname
  95. if odoo_db != current_db:
  96. _logger.error("Mailgun: Database '%s' is not the current database",
  97. odoo_db)
  98. return False
  99. # OK, DB is current
  100. return True
  101. def _mailgun_metadata(self, mailgun_event_type, event, metadata):
  102. # Get Mailgun timestamp when found
  103. ts = event.get('timestamp', False)
  104. try:
  105. ts = float(ts)
  106. except Exception:
  107. ts = False
  108. if ts:
  109. dt = datetime.utcfromtimestamp(ts)
  110. metadata.update({
  111. 'timestamp': ts,
  112. 'time': fields.Datetime.to_string(dt),
  113. 'date': fields.Date.to_string(dt),
  114. 'mailgun_id': event.get('id', False)
  115. })
  116. # Common field mapping
  117. mapping = {
  118. 'recipient': 'recipient',
  119. 'ip': 'ip',
  120. 'user_agent': 'user-agent',
  121. 'os_family': 'client-os',
  122. 'ua_family': 'client-name',
  123. 'ua_type': 'client-type',
  124. 'url': 'url',
  125. }
  126. for k, v in mapping.items():
  127. if event.get(v, False):
  128. metadata[k] = event[v]
  129. # Special field mapping
  130. metadata.update({
  131. 'mobile': event.get('device-type') in {'mobile', 'tablet'},
  132. 'user_country_id': self._country_search(
  133. event.get('country', False)),
  134. })
  135. # Mapping for special events
  136. if mailgun_event_type == 'bounced':
  137. metadata.update({
  138. 'error_type': event.get('code', False),
  139. 'error_description': event.get('error', False),
  140. 'error_details': event.get('notification', False),
  141. })
  142. elif mailgun_event_type == 'dropped':
  143. metadata.update({
  144. 'error_type': event.get('reason', False),
  145. 'error_description': event.get('code', False),
  146. 'error_details': event.get('description', False),
  147. })
  148. elif mailgun_event_type == 'complained':
  149. metadata.update({
  150. 'error_type': 'spam',
  151. 'error_description':
  152. "Recipient '%s' mark this email as spam" %
  153. event.get('recipient', False),
  154. })
  155. return metadata
  156. def _mailgun_tracking_get(self, event):
  157. tracking = False
  158. tracking_email_id = event.get('tracking_email_id', False)
  159. if tracking_email_id and tracking_email_id.isdigit():
  160. tracking = self.search([('id', '=', tracking_email_id)], limit=1)
  161. return tracking
  162. def _event_is_from_mailgun(self, event):
  163. event = event or {}
  164. return all([k in event for k in self._mailgun_mandatory_fields])
  165. @api.model
  166. def event_process(self, request, post, metadata, event_type=None):
  167. res = super(MailTrackingEmail, self).event_process(
  168. request, post, metadata, event_type=event_type)
  169. if res == 'NONE' and self._event_is_from_mailgun(post):
  170. if not self._mailgun_signature_verify(post):
  171. res = 'ERROR: Signature'
  172. elif not self._mailgun_event_type_verify(post):
  173. res = 'ERROR: Event type not supported'
  174. elif not self._db_verify(post):
  175. res = 'ERROR: Invalid DB'
  176. else:
  177. res = 'OK'
  178. if res == 'OK':
  179. mailgun_event_type = post.get('event')
  180. mapped_event_type = self._mailgun_event_type_mapping.get(
  181. mailgun_event_type) or event_type
  182. if not mapped_event_type: # pragma: no cover
  183. res = 'ERROR: Bad event'
  184. tracking = self._mailgun_tracking_get(post)
  185. if not tracking:
  186. res = 'ERROR: Tracking not found'
  187. if res == 'OK':
  188. # Complete metadata with mailgun event info
  189. metadata = self._mailgun_metadata(
  190. mailgun_event_type, post, metadata)
  191. # Create event
  192. tracking.event_create(mapped_event_type, metadata)
  193. if res != 'NONE':
  194. if event_type:
  195. _logger.info(
  196. "Mailgun: event '%s' process '%s'", event_type, res)
  197. else:
  198. _logger.info("Mailgun: event process '%s'", res)
  199. return res
  200. @api.multi
  201. def action_manual_check_mailgun(self):
  202. """
  203. Manual check against Mailgun API
  204. API Documentation:
  205. https://documentation.mailgun.com/en/latest/api-events.html
  206. """
  207. api_key, api_url, domain, validation_key = self._mailgun_values()
  208. for tracking in self:
  209. if not tracking.mail_message_id:
  210. raise UserError(_('There is no tracked message!'))
  211. message_id = tracking.mail_message_id.message_id.replace(
  212. "<", "").replace(">", "")
  213. res = requests.get(
  214. '%s/%s/events' % (api_url, domain),
  215. auth=("api", api_key),
  216. params={
  217. "begin": tracking.timestamp,
  218. "ascending": "yes",
  219. "message-id": message_id,
  220. }
  221. )
  222. if not res or res.status_code != 200:
  223. raise ValidationError(_(
  224. "Couldn't retrieve Mailgun information"))
  225. content = res.json()
  226. if "items" not in content:
  227. raise ValidationError(_("Event information not longer stored"))
  228. for item in content["items"]:
  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. if not self.env['mail.tracking.event'].search(
  233. [('mailgun_id', '=', item["id"])]) and (
  234. item.get("recipient", "") ==
  235. email_split(tracking.recipient)[0]):
  236. mapped_event_type = self._mailgun_event_type_mapping.get(
  237. item["event"], item["event"])
  238. metadata = self._mailgun_metadata(
  239. mapped_event_type, item, {})
  240. tracking.event_create(mapped_event_type, metadata)