diff --git a/mail_tracking_mailgun/README.rst b/mail_tracking_mailgun/README.rst new file mode 100644 index 00000000..880d07e2 --- /dev/null +++ b/mail_tracking_mailgun/README.rst @@ -0,0 +1,86 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +========================= +Mail tracking for Mailgun +========================= + +This module integrates mail_tracking events with Mailgun webhooks. + +Mailgun (https://www.mailgun.com/) is a service that provides an e-mail +sending infrastructure through an SMTP server or via API. You can also +query that API for seeing statistics of your sent e-mails, or provide +hooks that processes the status changes in real time, which is the +function used here. + +Configuration +============= + +You must configure Mailgun webhooks in order to receive mail events: + +1. Got a Mailgun account and validate your sending domain. +2. Go to Webhook tab and configure the below URL for each event: + +.. code:: html + + https:///mail/tracking/all/ + +Replace '' with your Odoo install domain name +and '' with your database name. + +In order to validate Mailgun webhooks you have to save Mailgun api_key in +a system parameter named 'mailgun.apikey'. You can find Mailgun api_key in your +validated sending domain. + +Usage +===== + +In your mail tracking status screens (explained on module *mail_tracking*), you will +see a more accurate information, like the 'Received' or 'Bounced' status, which are +not usually detected by normal SMTP servers. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/205/8.0 + +Known issues / Roadmap +====================== + +* There's no support for more than one Mailgun mail server. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Mailgun logo: `SVG Icon `_. + +Contributors +------------ + +* Antonio Espinosa + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/mail_tracking_mailgun/__init__.py b/mail_tracking_mailgun/__init__.py new file mode 100644 index 00000000..5935294f --- /dev/null +++ b/mail_tracking_mailgun/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Antonio Espinosa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import models diff --git a/mail_tracking_mailgun/__openerp__.py b/mail_tracking_mailgun/__openerp__.py new file mode 100644 index 00000000..e0b0d480 --- /dev/null +++ b/mail_tracking_mailgun/__openerp__.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Antonio Espinosa +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Mail tracking for Mailgun", + "summary": "Mail tracking and Mailgun webhooks integration", + "version": "8.0.1.0.0", + "category": "Social Network", + "website": "https://odoo-community.org/", + "author": "Tecnativa, " + "Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "mail_tracking", + ], +} diff --git a/mail_tracking_mailgun/models/__init__.py b/mail_tracking_mailgun/models/__init__.py new file mode 100644 index 00000000..64dc6c20 --- /dev/null +++ b/mail_tracking_mailgun/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Antonio Espinosa - +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import ir_mail_server +from . import mail_tracking_email diff --git a/mail_tracking_mailgun/models/ir_mail_server.py b/mail_tracking_mailgun/models/ir_mail_server.py new file mode 100644 index 00000000..2ebb4ce6 --- /dev/null +++ b/mail_tracking_mailgun/models/ir_mail_server.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Antonio Espinosa - +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import json +from openerp import models + + +class IrMailServer(models.Model): + _inherit = "ir.mail_server" + + def _tracking_headers_add(self, tracking_email_id, headers): + headers = super(IrMailServer, self)._tracking_headers_add( + tracking_email_id, headers) + headers = headers or {} + metadata = { + # NOTE: We can not use 'self.env.cr.dbname' because self is + # ir.mail_server object in old API (osv.osv) + 'odoo_db': self.pool.db_name, + 'tracking_email_id': tracking_email_id, + } + headers['X-Mailgun-Variables'] = json.dumps(metadata) + return headers diff --git a/mail_tracking_mailgun/models/mail_tracking_email.py b/mail_tracking_mailgun/models/mail_tracking_email.py new file mode 100644 index 00000000..ebd7d658 --- /dev/null +++ b/mail_tracking_mailgun/models/mail_tracking_email.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Antonio Espinosa - +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import hashlib +import hmac +from datetime import datetime +from openerp import models, api, fields + +import logging +_logger = logging.getLogger(__name__) + + +class MailTrackingEmail(models.Model): + _inherit = "mail.tracking.email" + + def _country_search(self, country_code): + country = False + if country_code: + country = self.env['res.country'].search([ + ('code', '=', country_code.upper()), + ]) + if country: + return country.id + return False + + @property + def _mailgun_mandatory_fields(self): + return ('event', 'timestamp', 'token', 'signature', + 'tracking_email_id', 'odoo_db') + + @property + def _mailgun_event_type_mapping(self): + return { + # Mailgun event type: tracking event type + 'delivered': 'delivered', + 'opened': 'open', + 'clicked': 'click', + 'unsubscribed': 'unsub', + 'complained': 'spam', + 'bounced': 'hard_bounce', + 'dropped': 'reject', + } + + @property + def _mailgun_supported_event_types(self): + return self._mailgun_event_type_mapping.keys() + + def _mailgun_event_type_verify(self, event): + event = event or {} + mailgun_event_type = event.get('event') + if mailgun_event_type not in self._mailgun_supported_event_types: + _logger.info("Mailgun: event type '%s' not supported", + mailgun_event_type) + return False + # OK, event type is valid + return True + + def _mailgun_signature(self, api_key, timestamp, token): + return hmac.new( + key=str(api_key), + msg='{}{}'.format(str(timestamp), str(token)), + digestmod=hashlib.sha256).hexdigest() + + def _mailgun_signature_verify(self, event): + event = event or {} + api_key = self.env['ir.config_parameter'].get_param('mailgun.apikey') + if not api_key: + _logger.info("No Mailgun api key configured. " + "Please add 'mailgun.apikey' to System parameters " + "to enable Mailgun authentication webhoook requests. " + "More info at: " + "https://documentation.mailgun.com/user_manual.html" + "#webhooks") + else: + timestamp = event.get('timestamp') + token = event.get('token') + signature = event.get('signature') + event_digest = self._mailgun_signature(api_key, timestamp, token) + if signature != event_digest: + _logger.error("Mailgun: Invalid signature '%s' != '%s'", + signature, event_digest) + return False + # OK, signature is valid + return True + + def _db_verify(self, event): + event = event or {} + odoo_db = event.get('odoo_db') + current_db = self.env.cr.dbname + if odoo_db != current_db: + _logger.info("Mailgun: Database '%s' is not the current database", + odoo_db) + return False + # OK, DB is current + return True + + def _mailgun_metadata(self, mailgun_event_type, event, metadata): + # Get Mailgun timestamp when found + ts = event.get('timestamp', False) + try: + ts = float(ts) + except: + ts = False + if ts: + dt = datetime.utcfromtimestamp(ts) + metadata.update({ + 'timestamp': ts, + 'time': fields.Datetime.to_string(dt), + 'date': fields.Date.to_string(dt), + }) + # Common field mapping + mapping = { + 'recipient': 'recipient', + 'ip': 'ip', + 'user_agent': 'user-agent', + 'os_family': 'client-os', + 'ua_family': 'client-name', + 'ua_type': 'client-type', + 'url': 'url', + } + for k, v in mapping.iteritems(): + if event.get(v, False): + metadata[k] = event[v] + # Special field mapping + metadata.update({ + 'mobile': event.get('device-type') in ('mobile', 'tablet'), + 'user_country_id': self._country_search( + event.get('country', False)), + }) + # Mapping for special events + if mailgun_event_type == 'bounced': + metadata.update({ + 'error_type': event.get('code', False), + 'error_description': event.get('error', False), + 'error_details': event.get('notification', False), + }) + elif mailgun_event_type == 'dropped': + metadata.update({ + 'error_type': event.get('reason', False), + 'error_description': event.get('code', False), + 'error_details': event.get('description', False), + }) + elif mailgun_event_type == 'complained': + metadata.update({ + 'error_type': 'spam', + 'error_description': + "Recipient '%s' mark this email as spam" % + event.get('recipient', False), + }) + return metadata + + def _mailgun_tracking_get(self, event): + tracking = False + tracking_email_id = event.get('tracking_email_id', False) + if tracking_email_id and tracking_email_id.isdigit(): + tracking = self.search([('id', '=', tracking_email_id)], limit=1) + return tracking + + def _event_is_from_mailgun(self, event): + event = event or {} + return all([k in event for k in self._mailgun_mandatory_fields]) + + @api.model + def event_process(self, request, post, metadata, event_type=None): + res = super(MailTrackingEmail, self).event_process( + request, post, metadata, event_type=event_type) + if res == 'NONE' and self._event_is_from_mailgun(post): + if not self._mailgun_signature_verify(post): + res = 'ERROR: Signature' + elif not self._mailgun_event_type_verify(post): + res = 'ERROR: Event type not supported' + elif not self._db_verify(post): + res = 'ERROR: Invalid DB' + else: + res = 'OK' + if res == 'OK': + mailgun_event_type = post.get('event') + mapped_event_type = self._mailgun_event_type_mapping.get( + mailgun_event_type) or event_type + if not mapped_event_type: # pragma: no cover + res = 'ERROR: Bad event' + tracking = self._mailgun_tracking_get(post) + if not tracking: + res = 'ERROR: Tracking not found' + if res == 'OK': + # Complete metadata with mailgun event info + metadata = self._mailgun_metadata( + mailgun_event_type, post, metadata) + # Create event + tracking.event_create(mapped_event_type, metadata) + if res != 'NONE': + if event_type: + _logger.info( + "Mailgun: event '%s' process '%s'", event_type, res) + else: + _logger.info("Mailgun: event process '%s'", res) + return res diff --git a/mail_tracking_mailgun/static/description/icon.png b/mail_tracking_mailgun/static/description/icon.png new file mode 100644 index 00000000..22dc3f87 Binary files /dev/null and b/mail_tracking_mailgun/static/description/icon.png differ diff --git a/mail_tracking_mailgun/static/description/icon.svg b/mail_tracking_mailgun/static/description/icon.svg new file mode 100644 index 00000000..30665467 --- /dev/null +++ b/mail_tracking_mailgun/static/description/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/mail_tracking_mailgun/tests/__init__.py b/mail_tracking_mailgun/tests/__init__.py new file mode 100644 index 00000000..d7169b5d --- /dev/null +++ b/mail_tracking_mailgun/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Antonio Espinosa - +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_mailgun diff --git a/mail_tracking_mailgun/tests/test_mailgun.py b/mail_tracking_mailgun/tests/test_mailgun.py new file mode 100644 index 00000000..f4ecf16c --- /dev/null +++ b/mail_tracking_mailgun/tests/test_mailgun.py @@ -0,0 +1,281 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Antonio Espinosa - +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from openerp.tests.common import TransactionCase + + +class TestMailgun(TransactionCase): + def mail_send(self): + mail = self.env['mail.mail'].create({ + 'subject': 'Test subject', + 'email_from': 'from@example.com', + 'email_to': self.recipient, + '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 setUp(self): + super(TestMailgun, self).setUp() + self.recipient = u'to@example.com' + self.mail, self.tracking_email = self.mail_send() + self.api_key = u'key-12345678901234567890123456789012' + self.token = u'f1349299097a51b9a7d886fcb5c2735b426ba200ada6e9e149' + self.timestamp = u'1471021089' + self.signature = ('4fb6d4dbbe10ce5d620265dcd7a3c0b8' + 'ca0dede1433103891bc1ae4086e9d5b2') + self.env['ir.config_parameter'].set_param( + 'mailgun.apikey', self.api_key) + self.event = { + 'Message-Id': u'', + 'X-Mailgun-Sid': u'WyIwNjgxZSIsICJ0b0BleGFtcGxlLmNvbSIsICI3MG' + 'I0MWYiXQ==', + 'token': self.token, + 'timestamp': self.timestamp, + 'signature': self.signature, + 'domain': u'example.com', + 'message-headers': u'[]', + 'recipient': self.recipient, + 'odoo_db': self.env.cr.dbname, + 'tracking_email_id': u'%s' % self.tracking_email.id + } + self.metadata = { + 'ip': '127.0.0.1', + 'user_agent': False, + 'os_family': False, + 'ua_family': False, + } + + def event_search(self, event_type): + event = self.env['mail.tracking.event'].search([ + ('tracking_email_id', '=', self.tracking_email.id), + ('event_type', '=', event_type), + ]) + self.assertTrue(event) + return event + + def test_no_api_key(self): + self.env['ir.config_parameter'].set_param('mailgun.apikey', '') + self.test_event_delivered() + + def test_bad_signature(self): + self.event.update({ + 'event': u'delivered', + 'signature': u'bad_signature', + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('ERROR: Signature', response) + + def test_bad_event_type(self): + self.event.update({ + 'event': u'bad_event', + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('ERROR: Event type not supported', response) + + def test_bad_db(self): + self.event.update({ + 'event': u'delivered', + 'odoo_db': u'bad_db', + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('ERROR: Invalid DB', response) + + def test_bad_ts(self): + timestamp = u'7a' # Now time will be used instead + signature = ('06cc05680f6e8110e59b41152b2d1c0f' + '1045d755ef2880ff922344325c89a6d4') + self.event.update({ + 'event': u'delivered', + 'timestamp': timestamp, + 'signature': signature, + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('OK', response) + + def test_tracking_not_found(self): + self.event.update({ + 'event': u'delivered', + 'tracking_email_id': u'bad_id', + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('ERROR: Tracking not found', response) + + # https://documentation.mailgun.com/user_manual.html#tracking-deliveries + def test_event_delivered(self): + self.event.update({ + 'event': u'delivered', + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('OK', response) + event = self.event_search('delivered') + self.assertEqual(event.timestamp, float(self.timestamp)) + self.assertEqual(event.recipient, self.recipient) + + # https://documentation.mailgun.com/user_manual.html#tracking-opens + def test_event_opened(self): + ip = u'127.0.0.1' + user_agent = u'Odoo Test/8.0 Gecko Firefox/11.0' + os_family = u'Linux' + ua_family = u'Firefox' + ua_type = u'browser' + self.event.update({ + 'event': u'opened', + 'city': u'Mountain View', + 'country': u'US', + 'region': u'CA', + 'client-name': ua_family, + 'client-os': os_family, + 'client-type': ua_type, + 'device-type': u'desktop', + 'ip': ip, + 'user-agent': user_agent, + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('OK', response) + event = self.event_search('open') + self.assertEqual(event.timestamp, float(self.timestamp)) + self.assertEqual(event.recipient, self.recipient) + self.assertEqual(event.ip, ip) + self.assertEqual(event.user_agent, user_agent) + self.assertEqual(event.os_family, os_family) + self.assertEqual(event.ua_family, ua_family) + self.assertEqual(event.ua_type, ua_type) + self.assertEqual(event.mobile, False) + self.assertEqual(event.user_country_id.code, 'US') + + # https://documentation.mailgun.com/user_manual.html#tracking-clicks + def test_event_clicked(self): + ip = u'127.0.0.1' + user_agent = u'Odoo Test/8.0 Gecko Firefox/11.0' + os_family = u'Linux' + ua_family = u'Firefox' + ua_type = u'browser' + url = u'https://odoo-community.org' + self.event.update({ + 'event': u'clicked', + 'city': u'Mountain View', + 'country': u'US', + 'region': u'CA', + 'client-name': ua_family, + 'client-os': os_family, + 'client-type': ua_type, + 'device-type': u'tablet', + 'ip': ip, + 'user-agent': user_agent, + 'url': url, + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata, event_type='click') + self.assertEqual('OK', response) + event = self.event_search('click') + self.assertEqual(event.timestamp, float(self.timestamp)) + self.assertEqual(event.recipient, self.recipient) + self.assertEqual(event.ip, ip) + self.assertEqual(event.user_agent, user_agent) + self.assertEqual(event.os_family, os_family) + self.assertEqual(event.ua_family, ua_family) + self.assertEqual(event.ua_type, ua_type) + self.assertEqual(event.mobile, True) + self.assertEqual(event.url, url) + + # https://documentation.mailgun.com/user_manual.html#tracking-unsubscribes + def test_event_unsubscribed(self): + ip = u'127.0.0.1' + user_agent = u'Odoo Test/8.0 Gecko Firefox/11.0' + os_family = u'Linux' + ua_family = u'Firefox' + ua_type = u'browser' + self.event.update({ + 'event': u'unsubscribed', + 'city': u'Mountain View', + 'country': u'US', + 'region': u'CA', + 'client-name': ua_family, + 'client-os': os_family, + 'client-type': ua_type, + 'device-type': u'mobile', + 'ip': ip, + 'user-agent': user_agent, + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('OK', response) + event = self.event_search('unsub') + self.assertEqual(event.timestamp, float(self.timestamp)) + self.assertEqual(event.recipient, self.recipient) + self.assertEqual(event.ip, ip) + self.assertEqual(event.user_agent, user_agent) + self.assertEqual(event.os_family, os_family) + self.assertEqual(event.ua_family, ua_family) + self.assertEqual(event.ua_type, ua_type) + self.assertEqual(event.mobile, True) + + # https://documentation.mailgun.com/ + # user_manual.html#tracking-spam-complaints + def test_event_complained(self): + self.event.update({ + 'event': u'complained', + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('OK', response) + event = self.event_search('spam') + self.assertEqual(event.timestamp, float(self.timestamp)) + self.assertEqual(event.recipient, self.recipient) + self.assertEqual(event.error_type, 'spam') + + # https://documentation.mailgun.com/user_manual.html#tracking-bounces + def test_event_bounced(self): + code = u'550' + error = (u"5.1.1 The email account does not exist.\n" + "5.1.1 double-checking the recipient's email address") + notification = u"Please, check recipient's email address" + self.event.update({ + 'event': u'bounced', + 'code': code, + 'error': error, + 'notification': notification, + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('OK', response) + event = self.event_search('hard_bounce') + self.assertEqual(event.timestamp, float(self.timestamp)) + self.assertEqual(event.recipient, self.recipient) + self.assertEqual(event.error_type, code) + self.assertEqual(event.error_description, error) + self.assertEqual(event.error_details, notification) + + # https://documentation.mailgun.com/user_manual.html#tracking-failures + def test_event_dropped(self): + reason = u'hardfail' + code = u'605' + description = u'Not delivering to previously bounced address' + self.event.update({ + 'event': u'dropped', + 'reason': reason, + 'code': code, + 'description': description, + }) + response = self.env['mail.tracking.email'].event_process( + None, self.event, self.metadata) + self.assertEqual('OK', response) + event = self.event_search('reject') + self.assertEqual(event.timestamp, float(self.timestamp)) + self.assertEqual(event.recipient, self.recipient) + self.assertEqual(event.error_type, reason) + self.assertEqual(event.error_description, code) + self.assertEqual(event.error_details, description)