diff --git a/mail_tracking/__init__.py b/mail_tracking/__init__.py index 745a1fac..1c66d89e 100644 --- a/mail_tracking/__init__.py +++ b/mail_tracking/__init__.py @@ -5,3 +5,4 @@ from . import models from . import controllers +from .hooks import post_init_hook diff --git a/mail_tracking/__openerp__.py b/mail_tracking/__openerp__.py index 19821bc4..10cb3dcf 100644 --- a/mail_tracking/__openerp__.py +++ b/mail_tracking/__openerp__.py @@ -5,7 +5,7 @@ { "name": "Email tracking", "summary": "Email tracking system for all mails sent", - "version": "8.0.1.0.0", + "version": "8.0.2.0.0", "category": "Social Network", "website": "http://www.tecnativa.com", "author": "Tecnativa, " @@ -23,8 +23,10 @@ "views/assets.xml", "views/mail_tracking_email_view.xml", "views/mail_tracking_event_view.xml", + "views/res_partner_view.xml", ], "qweb": [ "static/src/xml/mail_tracking.xml", - ] + ], + "post_init_hook": "post_init_hook", } diff --git a/mail_tracking/controllers/main.py b/mail_tracking/controllers/main.py index 20bffbc4..657dbb49 100644 --- a/mail_tracking/controllers/main.py +++ b/mail_tracking/controllers/main.py @@ -11,6 +11,19 @@ _logger = logging.getLogger(__name__) BLANK = 'R0lGODlhAQABAIAAANvf7wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==' +def _env_get(db): + reg = False + try: + reg = registry(db) + except OperationalError: + _logger.warning("Selected BD '%s' not found", db) + except: + _logger.warning("Selected BD '%s' connection error", db) + if reg: + return api.Environment(reg.cursor(), SUPERUSER_ID, {}) + return False + + class MailTrackingController(http.Controller): def _request_metadata(self): @@ -22,29 +35,49 @@ class MailTrackingController(http.Controller): 'ua_family': request.user_agent.browser, } + @http.route('/mail/tracking/all/', + type='http', auth='none') + def mail_tracking_all(self, db, **kw): + env = _env_get(db) + if not env: + return 'NOT FOUND' + metadata = self._request_metadata() + response = env['mail.tracking.email'].event_process( + http.request, kw, metadata) + env.cr.commit() + env.cr.close() + return response + + @http.route('/mail/tracking/event//', + type='http', auth='none') + def mail_tracking_event(self, db, event_type, **kw): + env = _env_get(db) + if not env: + return 'NOT FOUND' + metadata = self._request_metadata() + response = env['mail.tracking.email'].event_process( + http.request, kw, metadata, event_type=event_type) + env.cr.commit() + env.cr.close() + return response + @http.route('/mail/tracking/open/' '//blank.gif', type='http', auth='none') def mail_tracking_open(self, db, tracking_email_id, **kw): - reg = False - try: - reg = registry(db) - except OperationalError: - _logger.warning("Selected BD '%s' not found", db) - except: - _logger.warning("Selected BD '%s' connection error", db) - if reg: - with reg.cursor() as cr: - env = api.Environment(cr, SUPERUSER_ID, {}) - tracking_email = env['mail.tracking.email'].search([ - ('id', '=', tracking_email_id), - ]) - if tracking_email: - metadata = self._request_metadata() - tracking_email.event_process('open', metadata) - else: - _logger.warning( - "MailTracking email '%s' not found", tracking_email_id) + env = _env_get(db) + if env: + tracking_email = env['mail.tracking.email'].search([ + ('id', '=', tracking_email_id), + ]) + if tracking_email: + metadata = self._request_metadata() + tracking_email.event_create('open', metadata) + else: + _logger.warning( + "MailTracking email '%s' not found", tracking_email_id) + env.cr.commit() + env.cr.close() # Always return GIF blank image response = werkzeug.wrappers.Response() diff --git a/mail_tracking/hooks.py b/mail_tracking/hooks.py new file mode 100644 index 00000000..2b6b20b1 --- /dev/null +++ b/mail_tracking/hooks.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# © 2016 Antonio Espinosa - +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging +from openerp import api, SUPERUSER_ID + +_logger = logging.getLogger(__name__) + + +def post_init_hook(cr, registry): + with api.Environment.manage(): + env = api.Environment(cr, SUPERUSER_ID, {}) + # Recalculate all partner tracking_email_ids + partners = env['res.partner'].search([ + ('email', '!=', False), + ]) + emails = partners.mapped('email') + _logger.info( + "Recalculating 'tracking_email_ids' in 'res.partner' " + "model for %d email addresses", len(emails)) + for email in emails: + env['mail.tracking.email'].tracking_ids_recalculate( + 'res.partner', 'email', 'tracking_email_ids', email) diff --git a/mail_tracking/models/__init__.py b/mail_tracking/models/__init__.py index 2e9afdfe..42a28f51 100644 --- a/mail_tracking/models/__init__.py +++ b/mail_tracking/models/__init__.py @@ -8,3 +8,4 @@ from . import mail_mail from . import mail_message from . import mail_tracking_email from . import mail_tracking_event +from . import res_partner diff --git a/mail_tracking/models/ir_mail_server.py b/mail_tracking/models/ir_mail_server.py index 92537c54..9cdb2ef4 100644 --- a/mail_tracking/models/ir_mail_server.py +++ b/mail_tracking/models/ir_mail_server.py @@ -24,7 +24,7 @@ class IrMailServer(models.Model): if match: try: tracking_email_id = int(match.group(1)) - except: + except: # pragma: no cover pass return tracking_email_id @@ -61,7 +61,7 @@ class IrMailServer(models.Model): mail_server = mail_server_ids[0] if mail_server_ids else None if mail_server: smtp_server_used = mail_server.smtp_host - else: + else: # pragma: no cover smtp_server_used = smtp_server or tools.config.get('smtp_server') return smtp_server_used diff --git a/mail_tracking/models/mail_tracking_email.py b/mail_tracking/models/mail_tracking_email.py index 4e130024..d0452add 100644 --- a/mail_tracking/models/mail_tracking_email.py +++ b/mail_tracking/models/mail_tracking_email.py @@ -5,6 +5,7 @@ import logging import urlparse import time +import re from datetime import datetime from openerp import models, api, fields, tools @@ -16,10 +17,13 @@ _logger = logging.getLogger(__name__) class MailTrackingEmail(models.Model): _name = "mail.tracking.email" _order = 'time desc' - _rec_name = 'name' + _rec_name = 'display_name' _description = 'MailTracking email' name = fields.Char(string="Subject", readonly=True, index=True) + display_name = fields.Char( + string="Display name", readonly=True, store=True, + compute="_compute_display_name") timestamp = fields.Float( string='UTC timestamp', readonly=True, digits=dp.get_precision('MailTracking Timestamp')) @@ -33,6 +37,9 @@ class MailTrackingEmail(models.Model): partner_id = fields.Many2one( string="Partner", comodel_name='res.partner', readonly=True) recipient = fields.Char(string='Recipient email', readonly=True) + recipient_address = fields.Char( + string='Recipient email address', readonly=True, store=True, + compute='_compute_recipient_address') sender = fields.Char(string='Sender email', readonly=True) state = fields.Selection([ ('error', 'Error'), @@ -77,6 +84,84 @@ class MailTrackingEmail(models.Model): string="Tracking events", comodel_name='mail.tracking.event', inverse_name='tracking_email_id', readonly=True) + @api.model + def tracking_ids_recalculate(self, model, email_field, tracking_field, + email, new_tracking=None): + objects = self.env[model].search([ + (email_field, '=ilike', email), + ]) + for obj in objects: + trackings = obj[tracking_field] + if new_tracking: + trackings |= new_tracking + trackings = trackings._email_score_tracking_filter() + if set(obj[tracking_field].ids) != set(trackings.ids): + if trackings: + obj.write({ + tracking_field: [(6, False, trackings.ids)] + }) + else: + obj.write({ + tracking_field: [(5, False, False)] + }) + return True + + @api.model + def _tracking_ids_to_write(self, email): + trackings = self.env['mail.tracking.email'].search([ + ('recipient_address', '=ilike', email) + ]) + trackings = trackings._email_score_tracking_filter() + if trackings: + return [(6, False, trackings.ids)] + else: + return [(5, False, False)] + + @api.multi + def _email_score_tracking_filter(self): + """Default email score filter for tracking emails""" + # Consider only last 10 tracking emails + return self.sorted(key=lambda r: r.time, reverse=True)[:10] + + @api.multi + def email_score(self): + """Default email score algorimth""" + score = 50.0 + trackings = self._email_score_tracking_filter() + for tracking in trackings: + if tracking.state in ('error',): + score -= 50.0 + elif tracking.state in ('rejected', 'spam', 'bounced'): + score -= 25.0 + elif tracking.state in ('soft-bounced', 'unsub'): + score -= 10.0 + elif tracking.state in ('delivered',): + score += 5.0 + elif tracking.state in ('opened',): + score += 10.0 + if score > 100.0: + score = 100.0 + return score + + @api.multi + @api.depends('recipient') + def _compute_recipient_address(self): + for email in self: + matches = re.search(r'<(.*@.*)>', email.recipient) + if matches: + email.recipient_address = matches.group(1) + else: + email.recipient_address = email.recipient + + @api.multi + @api.depends('name', 'recipient') + def _compute_display_name(self): + for email in self: + parts = [email.name] + if email.recipient: + parts.append(email.recipient) + email.display_name = ' - '.join(parts) + @api.multi @api.depends('time') def _compute_date(self): @@ -84,6 +169,14 @@ class MailTrackingEmail(models.Model): email.date = fields.Date.to_string( fields.Date.from_string(email.time)) + @api.model + def create(self, vals): + tracking = super(MailTrackingEmail, self).create(vals) + self.tracking_ids_recalculate( + 'res.partner', 'email', 'tracking_email_ids', + tracking.recipient_address, new_tracking=tracking) + return tracking + def _get_mail_tracking_img(self): base_url = self.env['ir.config_parameter'].get_param('web.base.url') path_url = ( @@ -159,15 +252,23 @@ class MailTrackingEmail(models.Model): method = getattr(m_event, 'process_' + event_type, None) if method and hasattr(method, '__call__'): return method(self, metadata) - else: + else: # pragma: no cover _logger.info('Unknown event type: %s' % event_type) return False @api.multi - def event_process(self, event_type, metadata): + def event_create(self, event_type, metadata): event_ids = self.env['mail.tracking.event'] for tracking_email in self: vals = tracking_email._event_prepare(event_type, metadata) if vals: event_ids += event_ids.sudo().create(vals) return event_ids + + @api.model + def event_process(self, request, post, metadata, event_type=None): + # Generic event process hook, inherit it and + # - return 'OK' if processed + # - return 'NONE' if this request is not for you + # - return 'ERROR' if any error + return 'NONE' # pragma: no cover diff --git a/mail_tracking/models/res_partner.py b/mail_tracking/models/res_partner.py new file mode 100644 index 00000000..d4ae19de --- /dev/null +++ b/mail_tracking/models/res_partner.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# © 2016 Antonio Espinosa - +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp import models, api, fields + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + tracking_email_ids = fields.Many2many( + string="Tracking emails", comodel_name="mail.tracking.email", + readonly=True) + tracking_emails_count = fields.Integer( + string="Tracking emails count", store=True, readonly=True, + compute="_compute_tracking_emails_count") + email_score = fields.Float( + string="Email score", + compute="_compute_email_score", store=True, readonly=True) + + @api.one + @api.depends('tracking_email_ids.state') + def _compute_email_score(self): + self.email_score = self.tracking_email_ids.email_score() + + @api.one + @api.depends('tracking_email_ids') + def _compute_tracking_emails_count(self): + self.tracking_emails_count = self.env['mail.tracking.email'].\ + search_count([ + ('recipient_address', '=ilike', self.email) + ]) + + @api.multi + def write(self, vals): + email = vals.get('email') + if email is not None: + vals['tracking_email_ids'] = \ + self.env['mail.tracking.email']._tracking_ids_to_write(email) + return super(ResPartner, self).write(vals) diff --git a/mail_tracking/tests/test_mail_tracking.py b/mail_tracking/tests/test_mail_tracking.py index 8089e111..816a6bef 100644 --- a/mail_tracking/tests/test_mail_tracking.py +++ b/mail_tracking/tests/test_mail_tracking.py @@ -2,7 +2,24 @@ # © 2016 Antonio Espinosa - # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import mock +import base64 from openerp.tests.common import TransactionCase +from openerp.addons.mail_tracking.controllers.main import \ + MailTrackingController, BLANK + +mock_request = 'openerp.http.request' +mock_send_email = ('openerp.addons.base.ir.ir_mail_server.' + 'ir_mail_server.send_email') + + +class FakeUserAgent(object): + browser = 'Test browser' + platform = 'Test platform' + + def __str__(self): + """Return name""" + return 'Test suite' # One test case per method @@ -20,6 +37,12 @@ class TestMailTracking(TransactionCase): 'email': 'recipient@example.com', 'notify_email': 'always', }) + self.request = { + 'httprequest': type('obj', (object,), { + 'remote_addr': '123.123.123.123', + 'user_agent': FakeUserAgent(), + }), + } def test_message_post(self): # This message will generate a notification for recipient @@ -62,5 +85,51 @@ class TestMailTracking(TransactionCase): 'os_family': 'linux', 'ua_family': 'odoo', } - tracking_email.event_process('open', metadata) + tracking_email.event_create('open', metadata) self.assertEqual(tracking_email.state, 'opened') + + def mail_send(self): + mail = self.env['mail.mail'].create({ + 'subject': 'Test subject', + 'email_from': 'from@domain.com', + 'email_to': 'to@domain.com', + 'body_html': '

This is a test message

', + }) + mail.send() + # Search tracking created + tracking_email = self.env['mail.tracking.email'].search([ + ('mail_id', '=', mail.id), + ]) + return mail, tracking_email + + def test_mail_send(self): + controller = MailTrackingController() + db = self.env.cr.dbname + image = base64.decodestring(BLANK) + mail, tracking = self.mail_send() + self.assertEqual(mail.email_to, tracking.recipient) + self.assertEqual(mail.email_from, tracking.sender) + with mock.patch(mock_request) as mock_func: + mock_func.return_value = type('obj', (object,), self.request) + res = controller.mail_tracking_open(db, tracking.id) + self.assertEqual(image, res.response[0]) + + def test_smtp_error(self): + with mock.patch(mock_send_email) as mock_func: + mock_func.side_effect = Warning('Test error') + mail, tracking = self.mail_send() + self.assertEqual('error', tracking.state) + self.assertEqual('Warning', tracking.error_type) + self.assertEqual('Test error', tracking.error_description) + + def test_db(self): + db = self.env.cr.dbname + controller = MailTrackingController() + with mock.patch(mock_request) as mock_func: + mock_func.return_value = type('obj', (object,), self.request) + not_found = controller.mail_tracking_all('not_found_db') + self.assertEqual('NOT FOUND', not_found.response[0]) + none = controller.mail_tracking_all(db) + self.assertEqual('NONE', none.response[0]) + none = controller.mail_tracking_event(db, 'open') + self.assertEqual('NONE', none.response[0]) diff --git a/mail_tracking/views/mail_tracking_email_view.xml b/mail_tracking/views/mail_tracking_email_view.xml index 39d038d2..f2b12af0 100644 --- a/mail_tracking/views/mail_tracking_email_view.xml +++ b/mail_tracking/views/mail_tracking_email_view.xml @@ -8,7 +8,7 @@ mail.tracking.email.form mail.tracking.email -
+
@@ -63,13 +63,14 @@ mail.tracking.email.tree mail.tracking.email - - + + @@ -79,8 +80,10 @@ mail.tracking.email - + + diff --git a/mail_tracking/views/mail_tracking_event_view.xml b/mail_tracking/views/mail_tracking_event_view.xml index 15e31624..7df9d4ce 100644 --- a/mail_tracking/views/mail_tracking_event_view.xml +++ b/mail_tracking/views/mail_tracking_event_view.xml @@ -8,7 +8,7 @@ mail.tracking.event.form mail.tracking.event - + @@ -50,7 +50,8 @@ mail.tracking.event.tree mail.tracking.event - + diff --git a/mail_tracking/views/res_partner_view.xml b/mail_tracking/views/res_partner_view.xml new file mode 100644 index 00000000..4f227608 --- /dev/null +++ b/mail_tracking/views/res_partner_view.xml @@ -0,0 +1,33 @@ + + + + + + + Partner Form with tracking emails + res.partner + + +
+ +
+ + + + +
+ +
+