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.

324 lines
13 KiB

  1. # Copyright 2016 Antonio Espinosa - <antonio.espinosa@tecnativa.com>
  2. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
  3. import logging
  4. import urllib.parse
  5. import time
  6. import re
  7. from datetime import datetime
  8. from odoo import models, api, fields, tools
  9. import odoo.addons.decimal_precision as dp
  10. _logger = logging.getLogger(__name__)
  11. EVENT_OPEN_DELTA = 10 # seconds
  12. EVENT_CLICK_DELTA = 5 # seconds
  13. class MailTrackingEmail(models.Model):
  14. _name = "mail.tracking.email"
  15. _order = 'time desc'
  16. _rec_name = 'display_name'
  17. _description = 'MailTracking email'
  18. # This table is going to grow fast and to infinite, so we index:
  19. # - name: Search in tree view
  20. # - time: default order fields
  21. # - recipient_address: Used for email_store calculation (non-store)
  22. # - state: Search and group_by in tree view
  23. name = fields.Char(string="Subject", readonly=True, index=True)
  24. display_name = fields.Char(
  25. string="Display name", readonly=True, store=True,
  26. compute="_compute_tracking_display_name")
  27. timestamp = fields.Float(
  28. string='UTC timestamp', readonly=True,
  29. digits=dp.get_precision('MailTracking Timestamp'))
  30. time = fields.Datetime(string="Time", readonly=True, index=True)
  31. date = fields.Date(
  32. string="Date", readonly=True, compute="_compute_date", store=True)
  33. mail_message_id = fields.Many2one(
  34. string="Message", comodel_name='mail.message', readonly=True,
  35. index=True)
  36. mail_id = fields.Many2one(
  37. string="Email", comodel_name='mail.mail', readonly=True)
  38. partner_id = fields.Many2one(
  39. string="Partner", comodel_name='res.partner', readonly=True)
  40. recipient = fields.Char(string='Recipient email', readonly=True)
  41. recipient_address = fields.Char(
  42. string='Recipient email address', readonly=True, store=True,
  43. compute='_compute_recipient_address', index=True)
  44. sender = fields.Char(string='Sender email', readonly=True)
  45. state = fields.Selection([
  46. ('error', 'Error'),
  47. ('deferred', 'Deferred'),
  48. ('sent', 'Sent'),
  49. ('delivered', 'Delivered'),
  50. ('opened', 'Opened'),
  51. ('rejected', 'Rejected'),
  52. ('spam', 'Spam'),
  53. ('unsub', 'Unsubscribed'),
  54. ('bounced', 'Bounced'),
  55. ('soft-bounced', 'Soft bounced'),
  56. ], string='State', index=True, readonly=True, default=False,
  57. help=" * The 'Error' status indicates that there was an error "
  58. "when trying to sent the email, for example, "
  59. "'No valid recipient'\n"
  60. " * The 'Sent' status indicates that message was succesfully "
  61. "sent via outgoing email server (SMTP).\n"
  62. " * The 'Delivered' status indicates that message was "
  63. "succesfully delivered to recipient Mail Exchange (MX) server.\n"
  64. " * The 'Opened' status indicates that message was opened or "
  65. "clicked by recipient.\n"
  66. " * The 'Rejected' status indicates that recipient email "
  67. "address is blacklisted by outgoing email server (SMTP). "
  68. "It is recomended to delete this email address.\n"
  69. " * The 'Spam' status indicates that outgoing email "
  70. "server (SMTP) consider this message as spam.\n"
  71. " * The 'Unsubscribed' status indicates that recipient has "
  72. "requested to be unsubscribed from this message.\n"
  73. " * The 'Bounced' status indicates that message was bounced "
  74. "by recipient Mail Exchange (MX) server.\n"
  75. " * The 'Soft bounced' status indicates that message was soft "
  76. "bounced by recipient Mail Exchange (MX) server.\n")
  77. error_smtp_server = fields.Char(string='Error SMTP server', readonly=True)
  78. error_type = fields.Char(string='Error type', readonly=True)
  79. error_description = fields.Char(
  80. string='Error description', readonly=True)
  81. bounce_type = fields.Char(string='Bounce type', readonly=True)
  82. bounce_description = fields.Char(
  83. string='Bounce description', readonly=True)
  84. tracking_event_ids = fields.One2many(
  85. string="Tracking events", comodel_name='mail.tracking.event',
  86. inverse_name='tracking_email_id', readonly=True)
  87. @api.model
  88. def email_is_bounced(self, email):
  89. if not email:
  90. return False
  91. res = self._email_last_tracking_state(email)
  92. return res and res[0].get('state', '') in ['rejected', 'error',
  93. 'spam', 'bounced']
  94. @api.model
  95. def _email_last_tracking_state(self, email):
  96. return self.search_read([('recipient_address', '=', email.lower())],
  97. ['state'], limit=1, order='time DESC')
  98. @api.model
  99. def email_score_from_email(self, email):
  100. if not email:
  101. return 0.
  102. data = self.read_group([('recipient_address', '=', email.lower())],
  103. ['recipient_address', 'state'], ['state'])
  104. mapped_data = {state['state']: state['state_count'] for state in data}
  105. return self.with_context(mt_states=mapped_data).email_score()
  106. @api.model
  107. def _email_score_weights(self):
  108. """Default email score weights. Ready to be inherited"""
  109. return {
  110. 'error': -50.0,
  111. 'rejected': -25.0,
  112. 'spam': -25.0,
  113. 'bounced': -25.0,
  114. 'soft-bounced': -10.0,
  115. 'unsub': -10.0,
  116. 'delivered': 1.0,
  117. 'opened': 5.0,
  118. }
  119. def email_score(self):
  120. """Default email score algorimth. Ready to be inherited
  121. It can receive a recordset or mapped states dictionary via context.
  122. Must return a value beetwen 0.0 and 100.0
  123. - Bad reputation: Value between 0 and 50.0
  124. - Unknown reputation: Value 50.0
  125. - Good reputation: Value between 50.0 and 100.0
  126. """
  127. weights = self._email_score_weights()
  128. score = 50.0
  129. states = self.env.context.get('mt_states', False)
  130. if states:
  131. for state in states.keys():
  132. score += weights.get(state, 0.0) * states[state]
  133. else:
  134. for tracking in self:
  135. score += weights.get(tracking.state, 0.0)
  136. if score > 100.0:
  137. score = 100.0
  138. elif score < 0.0:
  139. score = 0.0
  140. return score
  141. @api.depends('recipient')
  142. def _compute_recipient_address(self):
  143. for email in self:
  144. if email.recipient:
  145. matches = re.search(r'<(.*@.*)>', email.recipient)
  146. if matches:
  147. email.recipient_address = matches.group(1).lower()
  148. else:
  149. email.recipient_address = email.recipient.lower()
  150. else:
  151. email.recipient_address = False
  152. @api.depends('name', 'recipient')
  153. def _compute_tracking_display_name(self):
  154. for email in self:
  155. parts = [email.name or '']
  156. if email.recipient:
  157. parts.append(email.recipient)
  158. email.display_name = ' - '.join(parts)
  159. @api.depends('time')
  160. def _compute_date(self):
  161. for email in self:
  162. email.date = fields.Date.to_string(
  163. fields.Date.from_string(email.time))
  164. def _get_mail_tracking_img(self):
  165. m_config = self.env['ir.config_parameter']
  166. base_url = (m_config.get_param('mail_tracking.base.url') or
  167. m_config.get_param('web.base.url'))
  168. path_url = (
  169. 'mail/tracking/open/%(db)s/%(tracking_email_id)s/blank.gif' % {
  170. 'db': self.env.cr.dbname,
  171. 'tracking_email_id': self.id,
  172. })
  173. track_url = urllib.parse.urljoin(base_url, path_url)
  174. return (
  175. '<img src="%(url)s" alt="" '
  176. 'data-odoo-tracking-email="%(tracking_email_id)s"/>' % {
  177. 'url': track_url,
  178. 'tracking_email_id': self.id,
  179. })
  180. @api.multi
  181. def _partners_email_bounced_set(self, reason, event=None):
  182. recipients = []
  183. if event and event.recipient_address:
  184. recipients.append(event.recipient_address)
  185. else:
  186. recipients = [x for x in self.mapped('recipient_address') if x]
  187. for recipient in recipients:
  188. self.env['res.partner'].search([
  189. ('email', '=ilike', recipient)
  190. ]).email_bounced_set(self, reason, event=event)
  191. @api.multi
  192. def smtp_error(self, mail_server, smtp_server, exception):
  193. self.sudo().write({
  194. 'error_smtp_server': tools.ustr(smtp_server),
  195. 'error_type': exception.__class__.__name__,
  196. 'error_description': tools.ustr(exception),
  197. 'state': 'error',
  198. })
  199. self.sudo()._partners_email_bounced_set('error')
  200. return True
  201. @api.multi
  202. def tracking_img_add(self, email):
  203. self.ensure_one()
  204. tracking_url = self._get_mail_tracking_img()
  205. if tracking_url:
  206. content = email.get('body', '')
  207. content = re.sub(
  208. r'<img[^>]*data-odoo-tracking-email=["\'][0-9]*["\'][^>]*>',
  209. '', content)
  210. body = tools.append_content_to_html(
  211. content, tracking_url, plaintext=False,
  212. container_tag='div')
  213. email['body'] = body
  214. return email
  215. def _message_partners_check(self, message, message_id):
  216. if not self.mail_message_id.exists(): # pragma: no cover
  217. return True
  218. mail_message = self.mail_message_id
  219. partners = (
  220. mail_message.needaction_partner_ids | mail_message.partner_ids)
  221. if (self.partner_id and self.partner_id not in partners):
  222. # If mail_message haven't tracking partner, then
  223. # add it in order to see his tracking status in chatter
  224. if mail_message.subtype_id:
  225. mail_message.sudo().write({
  226. 'needaction_partner_ids': [(4, self.partner_id.id)],
  227. })
  228. else:
  229. mail_message.sudo().write({
  230. 'partner_ids': [(4, self.partner_id.id)],
  231. })
  232. return True
  233. @api.multi
  234. def _tracking_sent_prepare(self, mail_server, smtp_server, message,
  235. message_id):
  236. self.ensure_one()
  237. ts = time.time()
  238. dt = datetime.utcfromtimestamp(ts)
  239. self._message_partners_check(message, message_id)
  240. self.sudo().write({'state': 'sent'})
  241. return {
  242. 'recipient': message['To'],
  243. 'timestamp': '%.6f' % ts,
  244. 'time': fields.Datetime.to_string(dt),
  245. 'tracking_email_id': self.id,
  246. 'event_type': 'sent',
  247. 'smtp_server': smtp_server,
  248. }
  249. def _event_prepare(self, event_type, metadata):
  250. self.ensure_one()
  251. m_event = self.env['mail.tracking.event']
  252. method = getattr(m_event, 'process_' + event_type, None)
  253. if method and hasattr(method, '__call__'):
  254. return method(self, metadata)
  255. else: # pragma: no cover
  256. _logger.info('Unknown event type: %s' % event_type)
  257. return False
  258. def _concurrent_events(self, event_type, metadata):
  259. m_event = self.env['mail.tracking.event']
  260. self.ensure_one()
  261. concurrent_event_ids = False
  262. if event_type in {'open', 'click'}:
  263. ts = metadata.get('timestamp', time.time())
  264. delta = EVENT_OPEN_DELTA if event_type == 'open' \
  265. else EVENT_CLICK_DELTA
  266. domain = [
  267. ('timestamp', '>=', ts - delta),
  268. ('timestamp', '<=', ts + delta),
  269. ('tracking_email_id', '=', self.id),
  270. ('event_type', '=', event_type),
  271. ]
  272. if event_type == 'click':
  273. domain.append(('url', '=', metadata.get('url', False)))
  274. concurrent_event_ids = m_event.search(domain)
  275. return concurrent_event_ids
  276. @api.multi
  277. def event_create(self, event_type, metadata):
  278. event_ids = self.env['mail.tracking.event']
  279. for tracking_email in self:
  280. other_ids = tracking_email._concurrent_events(event_type, metadata)
  281. if not other_ids:
  282. vals = tracking_email._event_prepare(event_type, metadata)
  283. if vals:
  284. events = event_ids.sudo().create(vals)
  285. if event_type in {'hard_bounce', 'spam', 'reject'}:
  286. for event in events:
  287. self.sudo()._partners_email_bounced_set(
  288. event_type, event=event)
  289. event_ids += events
  290. else:
  291. _logger.debug("Concurrent event '%s' discarded", event_type)
  292. return event_ids
  293. @api.model
  294. def event_process(self, request, post, metadata, event_type=None):
  295. # Generic event process hook, inherit it and
  296. # - return 'OK' if processed
  297. # - return 'NONE' if this request is not for you
  298. # - return 'ERROR' if any error
  299. return 'NONE' # pragma: no cover