Browse Source

[ADD] mail_tracking_mailgun

pull/351/head
Antonio Espinosa 8 years ago
committed by Jairo Llopis
parent
commit
cc2f9dcdcd
  1. 86
      mail_tracking_mailgun/README.rst
  2. 5
      mail_tracking_mailgun/__init__.py
  3. 18
      mail_tracking_mailgun/__openerp__.py
  4. 6
      mail_tracking_mailgun/models/__init__.py
  5. 23
      mail_tracking_mailgun/models/ir_mail_server.py
  6. 198
      mail_tracking_mailgun/models/mail_tracking_email.py
  7. BIN
      mail_tracking_mailgun/static/description/icon.png
  8. 6
      mail_tracking_mailgun/static/description/icon.svg
  9. 5
      mail_tracking_mailgun/tests/__init__.py
  10. 281
      mail_tracking_mailgun/tests/test_mailgun.py

86
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://<your_domain>/mail/tracking/all/<your_database>
Replace '<your_domain>' with your Odoo install domain name
and '<your_database>' 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
<https://github.com/OCA/social/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 <http://seeklogo.com/mailgun-logo-273630.html>`_.
Contributors
------------
* Antonio Espinosa <antonio.espinosa@tecnativa.com>
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.

5
mail_tracking_mailgun/__init__.py

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import models

18
mail_tracking_mailgun/__openerp__.py

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Antonio Espinosa <antonio.espinosa@tecnativa.com>
# 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",
],
}

6
mail_tracking_mailgun/models/__init__.py

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Antonio Espinosa - <antonio.espinosa@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import ir_mail_server
from . import mail_tracking_email

23
mail_tracking_mailgun/models/ir_mail_server.py

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Antonio Espinosa - <antonio.espinosa@tecnativa.com>
# 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

198
mail_tracking_mailgun/models/mail_tracking_email.py

@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Antonio Espinosa - <antonio.espinosa@tecnativa.com>
# 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

BIN
mail_tracking_mailgun/static/description/icon.png

After

Width: 128  |  Height: 128  |  Size: 6.3 KiB

6
mail_tracking_mailgun/static/description/icon.svg

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="256px" height="256px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
<g>
<path d="M127.997895,105.847663 C140.236194,105.847663 150.154742,115.765009 150.154742,127.999399 C150.154742,140.236194 140.236194,150.154742 127.997895,150.154742 C115.763806,150.154742 105.845258,140.233488 105.845258,127.999399 C105.845258,115.765009 115.763806,105.847663 127.997895,105.847663 L127.997895,105.847663 Z M52.5129516,127.999399 C52.5129516,86.3085405 86.3085405,52.5141543 127.997895,52.5141543 C169.692662,52.5141543 203.485846,86.3085405 203.485846,127.999399 C203.485846,130.759044 203.338512,133.481406 203.05046,136.159267 C202.477063,143.517821 207.877285,149.134533 215.188933,149.134533 C227.598018,149.134533 228.920411,133.134721 228.920411,127.999399 C228.920411,72.2595437 183.736547,27.0768826 127.997895,27.0768826 C72.259243,27.0768826 27.0768826,72.2595437 27.0768826,127.999399 C27.0768826,183.735345 72.259243,228.919209 127.997895,228.919209 C157.599292,228.919209 184.224251,216.18178 202.685735,195.88374 L223.433292,213.305771 C199.997651,239.503464 165.92303,256.003909 127.997895,256.003909 C57.3064005,256.003909 0,198.692096 0,127.999399 C0,57.3064005 57.3064005,0 127.997895,0 C198.693599,0 256,57.3064005 256,127.999399 C256,156.415212 242.469076,179.489736 215.29357,179.489736 C203.333401,179.489736 196.064148,174.009833 192.093662,167.893991 C178.763893,189.262454 155.045612,203.489755 127.997895,203.489755 C86.3085405,203.489755 52.5129516,169.688753 52.5129516,127.999399 L52.5129516,127.999399 Z M127.997895,79.5898342 C101.264089,79.5898342 79.5886315,101.261684 79.5886315,127.999399 C79.5886315,154.735911 101.264089,176.411369 127.997895,176.411369 C154.734407,176.411369 176.411369,154.735911 176.411369,127.999399 C176.411369,101.261684 154.734407,79.5898342 127.997895,79.5898342 L127.997895,79.5898342 Z" fill="#AF252A"></path>
</g>
</svg>

5
mail_tracking_mailgun/tests/__init__.py

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Antonio Espinosa - <antonio.espinosa@tecnativa.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_mailgun

281
mail_tracking_mailgun/tests/test_mailgun.py

@ -0,0 +1,281 @@
# -*- coding: utf-8 -*-
# Copyright 2016 Antonio Espinosa - <antonio.espinosa@tecnativa.com>
# 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': '<p>This is a test message</p>',
})
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'<xxx.xxx.xxx-openerp-xxx-res.partner@test_db>',
'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)
Loading…
Cancel
Save