diff --git a/.travis.yml b/.travis.yml
index c8f9fedb..f4ace9c1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -28,6 +28,7 @@ virtualenv:
system_site_packages: true
install:
+ - pip install sendgrid
- git clone --depth=1 https://github.com/OCA/maintainer-quality-tools.git ${HOME}/maintainer-quality-tools
- export PATH=${HOME}/maintainer-quality-tools/travis:${PATH}
- travis_install_nightly
diff --git a/mail_sendgrid/README.rst b/mail_sendgrid/README.rst
new file mode 100644
index 00000000..9de78760
--- /dev/null
+++ b/mail_sendgrid/README.rst
@@ -0,0 +1,120 @@
+.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
+ :alt: License: AGPL-3
+
+==================================
+SendGrid Mail Sending and Tracking
+==================================
+
+This module integrates
+`SendGrid `_ with Odoo. It can send transactional emails
+through SendGrid, using templates defined on the
+`SendGrid web interface `_. It also supports
+substitution of placeholder variables in these templates. The list of available
+templates can be fetched automatically.
+E-mails sent through SendGrid will be tracked using Sendgrid Webhook Events.
+
+Installation
+============
+You need to install python-sendgrid v3 API in order to install the module.
+
+If you're using a multi-database installation (with or without dbfilter option)
+where /web/databse/selector returns a list of more than one database, then
+you need to add ``mail_sendgrid`` addon to wide load addons list
+(by default, only ``web`` addon), setting ``--load`` option.
+For example, ``--load=web,mail_tracking,mail_sendgrid``
+
+Configuration
+=============
+
+You can add the following system parameters to configure the usage of SendGrid:
+
+* ``mail_sendgrid.substitution_prefix`` Any symbol or character used as a
+ prefix for `SendGrid Substitution Tags `_.
+ ``{`` is used by default.
+* ``mail_sendgrid.substitution_suffix`` Any symbol or character used as a
+ suffix for SendGrid Substitution Tags.
+ ``}`` is used by default.
+* ``mail_sendgrid.send_method`` Use value 'sendgrid' to override the traditional SMTP server used to send e-mails with sendgrid.
+ By default, SendGrid will co-exist with traditional system
+ (two buttons for sending either normally or with SendGrid).
+
+In order to use this module, the following variables have to be defined in the
+server command-line options (or in a configuration file):
+
+- ``sendgrid_api_key`` A valid API key obtained from the
+ SendGrid web interface with
+ full access for the ``Mail Send`` permission and read access for the
+ ``Template Engine`` permission.
+
+Optionally, the following configuration variables can be set as well:
+
+- ``sendgrid_test_address`` Destination email address for testing purposes.
+ You can use ``odoo@sink.sendgrid.net``, which is an address that
+ will simply receive and discard all incoming email.
+
+For tracking events to work, make sure you configure your Sendgrid Account with the correct Event Notification Url.
+You can do it under 'Settings -> Mail Settings -> Event Notification '.
+Set the URL to ``https:///mail/tracking/sendgrid/``
+
+Replace '' with your Odoo install domain name
+and '' with your database name.
+
+Usage
+=====
+
+If you designed templates in Sendgrid that you wan't to use with Odoo:
+ * Go to 'Settings -> Email -> SendGrid Templates'
+ * Create a new Template
+ * Click the "Update" button : this will automatically import all your templates
+
+In e-mail templates 'Settings -> Email -> Templates', you can attach a SendGrid template for any language.
+You can substitute Sendgrid keywords with placeholders or static text like in the body of the e-mail.
+The preview wizard now renders your e-mail with the SendGrid template applied.
+
+From e-mails, use the "Send (SendGrid)" button to send the e-mail using Sendgrid.
+
+.. 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/10.0
+
+Known issues / Roadmap
+======================
+
+* Extend the features from SendGrid
+
+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
+------
+
+* Sengrid logo: `SVG Icon `_.
+
+Contributors
+------------
+
+* Emanuel Cino
+* Roman Zoller
+
+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 http://odoo-community.org.
diff --git a/mail_sendgrid/__init__.py b/mail_sendgrid/__init__.py
new file mode 100644
index 00000000..673b18f9
--- /dev/null
+++ b/mail_sendgrid/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import models
+from . import wizards
+from . import controllers
diff --git a/mail_sendgrid/__manifest__.py b/mail_sendgrid/__manifest__.py
new file mode 100644
index 00000000..f916e21b
--- /dev/null
+++ b/mail_sendgrid/__manifest__.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+{
+ 'name': 'SendGrid',
+ 'version': '10.0.1.0.0',
+ 'category': 'Social Network',
+ 'author': 'Compassion CH, Odoo Community Association (OCA)',
+ 'license': 'AGPL-3',
+ 'website': 'https://github.com/OCA/social',
+ 'depends': ['mail_tracking'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/sendgrid_email_view.xml',
+ 'views/sendgrid_template_view.xml',
+ 'views/mail_compose_message_view.xml',
+ 'views/email_template_view.xml',
+ ],
+ 'demo': [],
+ 'installable': True,
+ 'auto_install': False,
+ 'external_dependencies': {
+ 'python': ['sendgrid'],
+ },
+}
diff --git a/mail_sendgrid/controllers/__init__.py b/mail_sendgrid/controllers/__init__.py
new file mode 100644
index 00000000..5fcac7c2
--- /dev/null
+++ b/mail_sendgrid/controllers/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import json_request
+from . import sendgrid_event_webhook
diff --git a/mail_sendgrid/controllers/json_request.py b/mail_sendgrid/controllers/json_request.py
new file mode 100644
index 00000000..189159ba
--- /dev/null
+++ b/mail_sendgrid/controllers/json_request.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+import simplejson
+
+from odoo.http import JsonRequest, Root, Response
+
+# Monkeypatch type of request rooter to use RESTJsonRequest
+old_get_request = Root.get_request
+
+
+def get_request(self, httprequest):
+ if (httprequest.mimetype == "application/json" and
+ httprequest.environ['PATH_INFO'].startswith('/mail')):
+ return RESTJsonRequest(httprequest)
+ return old_get_request(self, httprequest)
+
+
+Root.get_request = get_request
+
+
+class RESTJsonRequest(JsonRequest):
+ """ Special RestJson Handler to enable receiving lists in JSON
+ body
+ """
+ def __init__(self, *args):
+ try:
+ super(RESTJsonRequest, self).__init__(*args)
+ except AttributeError:
+ # The JSON may contain a list
+ self.params = dict()
+ self.context = dict(self.session.context)
+
+ def _json_response(self, result=None, error=None):
+ response = {}
+ if error is not None:
+ response['error'] = error
+ if result is not None:
+ response['result'] = result
+
+ mime = 'application/json'
+ body = simplejson.dumps(response)
+
+ return Response(
+ body, headers=[('Content-Type', mime),
+ ('Content-Length', len(body))])
diff --git a/mail_sendgrid/controllers/sendgrid_event_webhook.py b/mail_sendgrid/controllers/sendgrid_event_webhook.py
new file mode 100644
index 00000000..db1777d9
--- /dev/null
+++ b/mail_sendgrid/controllers/sendgrid_event_webhook.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+import logging
+
+from odoo import http
+from odoo.addons.mail_tracking.controllers.main import \
+ MailTrackingController, _env_get
+
+_logger = logging.getLogger(__name__)
+
+
+class SendgridTrackingController(MailTrackingController):
+ """Sendgrid is posting JSON so we must define a new route for tracking."""
+ @http.route('/mail/tracking/sendgrid/',
+ type='json', auth='none', csrf=False)
+ def mail_tracking_sendgrid(self, db, **kw):
+ try:
+ _env_get(db, self._tracking_event, None, None, **kw)
+ return {'status': 200}
+ except Exception as e:
+ _logger.error(e.message, exc_info=True)
+ return {'status': 400}
diff --git a/mail_sendgrid/models/__init__.py b/mail_sendgrid/models/__init__.py
new file mode 100644
index 00000000..58da3aa4
--- /dev/null
+++ b/mail_sendgrid/models/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import mail_mail
+from . import substitution
+from . import sendgrid_template
+from . import email_template
+from . import email_lang_template
+from . import email_tracking
+from . import mail_tracking_event
diff --git a/mail_sendgrid/models/email_lang_template.py b/mail_sendgrid/models/email_lang_template.py
new file mode 100644
index 00000000..a48cc625
--- /dev/null
+++ b/mail_sendgrid/models/email_lang_template.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import models, fields
+
+
+class LanguageTemplate(models.Model):
+ """ This class is the relation between and email_template object
+ and a sendgrid_template. It allows to specify a different
+ sendgrid_template for any selected language.
+ """
+ _name = 'sendgrid.email.lang.template'
+
+ email_template_id = fields.Many2one('mail.template', 'E-mail Template')
+ lang = fields.Selection('_select_lang', 'Language', required=True)
+ sendgrid_template_id = fields.Many2one(
+ 'sendgrid.template', 'Sendgrid Template', required=True)
+
+ def _select_lang(self):
+ languages = self.env['res.lang'].search([])
+ return [(language.code, language.name) for language in languages]
diff --git a/mail_sendgrid/models/email_template.py b/mail_sendgrid/models/email_template.py
new file mode 100644
index 00000000..5da331e0
--- /dev/null
+++ b/mail_sendgrid/models/email_template.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import models, fields, api
+from collections import defaultdict
+
+
+class EmailTemplate(models.Model):
+ _inherit = 'mail.template'
+
+ substitution_ids = fields.One2many(
+ 'sendgrid.substitution', 'email_template_id', 'Substitutions')
+ sendgrid_template_ids = fields.One2many(
+ 'sendgrid.email.lang.template', 'email_template_id',
+ 'Sendgrid Templates')
+ sendgrid_localized_template = fields.Many2one(
+ 'sendgrid.template', compute='_compute_localized_template')
+
+ def _compute_localized_template(self):
+ lang = self.env.context.get('lang', 'en_US')
+ for template in self:
+ lang_template = template.sendgrid_template_ids.filtered(
+ lambda t: t.lang == lang)
+ if lang_template and len(lang_template) == 1:
+ template.sendgrid_localized_template = \
+ lang_template.sendgrid_template_id
+
+ @api.multi
+ def update_substitutions(self):
+ for template in self:
+ new_substitutions = []
+ for language_template in template.sendgrid_template_ids:
+ sendgrid_template = language_template.sendgrid_template_id
+ lang = language_template.lang
+ substitutions = template.substitution_ids.filtered(
+ lambda s: s.lang == lang)
+ keywords = sendgrid_template.get_keywords()
+ # Add new keywords from the sendgrid template
+ for key in keywords:
+ if key not in substitutions.mapped('key'):
+ substitution_vals = {
+ 'key': key,
+ 'lang': lang,
+ 'email_template_id': template.id
+ }
+ new_substitutions.append((0, 0, substitution_vals))
+
+ template.write({'substitution_ids': new_substitutions})
+
+ return True
+
+ @api.multi
+ def render_substitutions(self, res_ids):
+ """
+ :param res_ids: resource ids for rendering the template
+ Returns values for substitutions in a mail.message creation
+ :return:
+ Values for mail creation (for each resource id given)
+ {res_id: list of substitutions values [0, 0 {substitution_vals}]}
+ """
+ self.ensure_one()
+ if isinstance(res_ids, (int, long)):
+ res_ids = [res_ids]
+ substitutions = self.substitution_ids.filtered(
+ lambda s: s.lang == self.env.context.get('lang', 'en_US'))
+ substitution_vals = defaultdict(list)
+ for substitution in substitutions:
+ values = self.render_template(
+ substitution.value, self.model, res_ids)
+ for res_id in res_ids:
+ substitution_vals[res_id].append((0, 0, {
+ 'key': substitution.key,
+ 'value': values[res_id]
+ }))
+ return substitution_vals
diff --git a/mail_sendgrid/models/email_tracking.py b/mail_sendgrid/models/email_tracking.py
new file mode 100644
index 00000000..c7b97e51
--- /dev/null
+++ b/mail_sendgrid/models/email_tracking.py
@@ -0,0 +1,171 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+import logging
+from datetime import datetime
+
+from werkzeug.useragents import UserAgent
+
+from odoo import models, fields, api
+
+_logger = logging.getLogger(__name__)
+
+
+class MailTrackingEmail(models.Model):
+ """ Count the user clicks on links inside e-mails sent.
+ Add tracking methods to process Sendgrid Notifications
+ """
+ _inherit = 'mail.tracking.email'
+
+ click_count = fields.Integer(
+ compute='_compute_clicks', store=True, readonly=True)
+
+ @api.depends('tracking_event_ids')
+ def _compute_clicks(self):
+ for mail in self:
+ mail.click_count = self.env['mail.tracking.event'].search_count([
+ ('event_type', '=', 'click'),
+ ('tracking_email_id', '=', mail.id)
+ ])
+
+ @property
+ def _sendgrid_mandatory_fields(self):
+ return ('event', 'timestamp', 'odoo_id', 'odoo_db')
+
+ @property
+ def _sendgrid_event_type_mapping(self):
+ return {
+ # Sendgrid event type: tracking event type
+ 'bounce': 'hard_bounce',
+ 'click': 'click',
+ 'deferred': 'deferral',
+ 'delivered': 'delivered',
+ 'dropped': 'reject',
+ 'group_unsubscribe': 'unsub',
+ 'open': 'open',
+ 'processed': 'sent',
+ 'spamreport': 'spam',
+ 'unsubscribe': 'unsub',
+ }
+
+ def _sendgrid_event_type_verify(self, event):
+ event = event or {}
+ sendgrid_event_type = event.get('event')
+ if sendgrid_event_type not in self._sendgrid_event_type_mapping:
+ _logger.error("Sendgrid: event type '%s' not supported",
+ sendgrid_event_type)
+ return False
+ # OK, event type is valid
+ return True
+
+ def _sendgrid_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.error("Sendgrid: Database '%s' is not the current "
+ "database",
+ odoo_db)
+ return False
+ # OK, DB is current
+ return True
+
+ def _sendgrid_metadata(self, sendgrid_event_type, event, metadata):
+ # Get sendgrid timestamp when found
+ ts = event.get('timestamp')
+ try:
+ ts = float(ts)
+ except ValueError:
+ 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 (sendgrid_field: odoo_field)
+ mapping = {
+ 'email': 'recipient',
+ 'ip': 'ip',
+ 'url': 'url',
+ }
+ for k, v in mapping.iteritems():
+ if event.get(k, False):
+ metadata[v] = event[k]
+ # Special field mapping
+ if event.get('useragent'):
+ user_agent = UserAgent(event['useragent'])
+ metadata.update({
+ 'user_agent': user_agent.string,
+ 'os_family': user_agent.platform,
+ 'ua_family': user_agent.browser,
+ 'mobile': user_agent.platform in [
+ 'android', 'iphone', 'ipad']
+ })
+ # Mapping for special events
+ if sendgrid_event_type == 'bounce':
+ metadata.update({
+ 'error_type': event.get('type', False),
+ 'bounce_type': event.get('type', False),
+ 'error_description': event.get('reason', False),
+ 'bounce_description': event.get('reason', False),
+ 'error_details': event.get('status', False),
+ })
+ elif sendgrid_event_type == 'dropped':
+ metadata.update({
+ 'error_type': event.get('reason', False),
+ })
+ return metadata
+
+ def _sendgrid_tracking_get(self, event):
+ tracking = False
+ message_id = event.get('odoo_id', False)
+ if message_id:
+ tracking = self.search([
+ ('mail_id.message_id', '=', message_id),
+ ('recipient', '=ilike', event.get('email'))], limit=1)
+ return tracking
+
+ def _event_is_from_sendgrid(self, event):
+ event = event or {}
+ return all([k in event for k in self._sendgrid_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)
+ is_json = hasattr(request, 'jsonrequest') and isinstance(
+ request.jsonrequest, list)
+ if res == 'NONE' and is_json:
+ for event in request.jsonrequest:
+ if self._event_is_from_sendgrid(event):
+ if not self._sendgrid_event_type_verify(event):
+ res = 'ERROR: Event type not supported'
+ elif not self._sendgrid_db_verify(event):
+ res = 'ERROR: Invalid DB'
+ else:
+ res = 'OK'
+ if res == 'OK':
+ sendgrid_event_type = event.get('event')
+ mapped_event_type = self._sendgrid_event_type_mapping.get(
+ sendgrid_event_type) or event_type
+ if not mapped_event_type:
+ res = 'ERROR: Bad event'
+ tracking = self._sendgrid_tracking_get(event)
+ if tracking:
+ # Complete metadata with sendgrid event info
+ metadata = self._sendgrid_metadata(
+ sendgrid_event_type, event, metadata)
+ # Create event
+ tracking.event_create(mapped_event_type, metadata)
+ else:
+ res = 'ERROR: Tracking not found'
+ if res != 'NONE':
+ if event_type:
+ _logger.info(
+ "sendgrid: event '%s' process '%s'",
+ event_type, res)
+ else:
+ _logger.info("sendgrid: event process '%s'", res)
+ return res
diff --git a/mail_sendgrid/models/mail_mail.py b/mail_sendgrid/models/mail_mail.py
new file mode 100644
index 00000000..9d1b8503
--- /dev/null
+++ b/mail_sendgrid/models/mail_mail.py
@@ -0,0 +1,242 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from odoo import models, fields, api, tools
+from odoo.tools.config import config
+from odoo.tools.safe_eval import safe_eval
+
+import base64
+import logging
+import re
+import time
+
+
+_logger = logging.getLogger(__name__)
+
+
+try:
+ from sendgrid import SendGridAPIClient
+ from sendgrid.helpers.mail import Email, Attachment, CustomArg, Content, \
+ Personalization, Substitution, Mail, Header
+except ImportError:
+ _logger.error("ImportError raised while loading module.")
+ _logger.debug("ImportError details:", exc_info=True)
+
+
+STATUS_OK = 202
+
+
+class MailMessage(models.Model):
+ """ Add SendGrid related fields so that they dispatch in all
+ subclasses of mail.message object
+ """
+ _inherit = 'mail.message'
+
+ body_text = fields.Text(help='Text only version of the body')
+ sent_date = fields.Datetime(copy=False)
+ substitution_ids = fields.Many2many(
+ 'sendgrid.substitution', string='Substitutions', copy=True)
+ sendgrid_template_id = fields.Many2one(
+ 'sendgrid.template', 'Sendgrid Template')
+ send_method = fields.Char(compute='_compute_send_method')
+
+ @api.multi
+ def _compute_send_method(self):
+ """ Check whether to use traditional send method, sendgrid or disable.
+ """
+ send_method = self.env['ir.config_parameter'].get_param(
+ 'mail_sendgrid.send_method', 'traditional')
+ for email in self:
+ email.send_method = send_method
+
+
+class MailMail(models.Model):
+ """ Email message sent through SendGrid """
+ _inherit = 'mail.mail'
+
+ tracking_email_ids = fields.One2many(
+ 'mail.tracking.email', 'mail_id', string='Registered events',
+ readonly=True)
+ click_count = fields.Integer(
+ compute='_compute_tracking', store=True, readonly=True)
+ opened = fields.Boolean(
+ compute='_compute_tracking', store=True, readonly=True)
+ tracking_event_ids = fields.One2many(
+ 'mail.tracking.event', compute='_compute_events')
+
+ @api.depends('tracking_email_ids', 'tracking_email_ids.click_count',
+ 'tracking_email_ids.state')
+ def _compute_tracking(self):
+ for email in self:
+ click_count = sum(email.tracking_email_ids.mapped(
+ 'click_count'))
+ opened = self.env['mail.tracking.email'].search_count([
+ ('state', '=', 'opened'),
+ ('mail_id', '=', email.id)
+ ])
+ email.update({
+ 'click_count': click_count,
+ 'opened': opened > 0
+ })
+
+ def _compute_events(self):
+ for email in self:
+ email.tracking_event_ids = email.tracking_email_ids.mapped(
+ 'tracking_event_ids')
+
+ @api.multi
+ def send(self, auto_commit=False, raise_exception=False):
+ """ Override send to select the method to send the e-mail. """
+ traditional = self.filtered(lambda e: e.send_method == 'traditional')
+ sendgrid = self.filtered(lambda e: e.send_method == 'sendgrid')
+ if traditional:
+ super(MailMail, traditional).send(auto_commit, raise_exception)
+ if sendgrid:
+ sendgrid.send_sendgrid()
+ return True
+
+ @api.multi
+ def send_sendgrid(self):
+ """ Use sendgrid transactional e-mails : e-mails are sent one by
+ one. """
+ outgoing = self.filtered(lambda em: em.state == 'outgoing')
+ api_key = config.get('sendgrid_api_key')
+ if outgoing and not api_key:
+ _logger.error(
+ 'Missing sendgrid_api_key in conf file. Skipping Sendgrid '
+ 'send.'
+ )
+ return
+
+ sg = SendGridAPIClient(apikey=api_key)
+ for email in outgoing:
+ try:
+ response = sg.client.mail.send.post(
+ request_body=email._prepare_sendgrid_data().get())
+ except Exception as e:
+ _logger.error(e.message or "mail not sent.")
+ continue
+
+ status = response.status_code
+ msg = response.body
+
+ if status == STATUS_OK:
+ _logger.info("e-mail sent. " + str(msg))
+ email._track_sendgrid_emails()
+ email.write({
+ 'sent_date': fields.Datetime.now(),
+ 'state': 'sent'
+ })
+ if not self.env.context.get('test_mode'):
+ # Commit at each e-mail processed to avoid any errors
+ # invalidating state.
+ self.env.cr.commit() # pylint: disable=invalid-commit
+ email._postprocess_sent_message(mail_sent=True)
+ else:
+ email._postprocess_sent_message(mail_sent=False)
+ _logger.error("Failed to send email: {}".format(str(msg)))
+
+ def _prepare_sendgrid_data(self):
+ """
+ Prepare and creates the Sendgrid Email object
+ :return: sendgrid.helpers.mail.Email object
+ """
+ self.ensure_one()
+ s_mail = Mail()
+ s_mail.from_email = Email(self.email_from)
+ if self.reply_to:
+ s_mail.reply_to = Email(self.reply_to)
+
+ # Add custom fields to match the tracking
+ s_mail.add_custom_arg(CustomArg('odoo_id', self.message_id))
+ s_mail.add_custom_arg(CustomArg('odoo_db', self.env.cr.dbname))
+
+ headers = {
+ 'Message-Id': self.message_id
+ }
+ if self.headers:
+ try:
+ headers.update(safe_eval(self.headers))
+ except Exception:
+ pass
+ for h_name, h_val in headers.iteritems():
+ s_mail.add_header(Header(h_name, h_val))
+
+ html = self.body_html or ' '
+
+ p = re.compile(r'<.*?>') # Remove HTML markers
+ text_only = self.body_text or p.sub('', html.replace('
', '\n'))
+
+ s_mail.add_content(Content("text/plain", text_only or ' '))
+ s_mail.add_content(Content("text/html", html))
+
+ test_address = config.get('sendgrid_test_address')
+
+ # We use only one personalization for transactional e-mail
+ personalization = Personalization()
+ subject = self.subject and self.subject.encode(
+ "utf_8") or "(No subject)"
+ personalization.subject = subject
+ addresses = set()
+ if not test_address:
+ if self.email_to:
+ addresses = set(self.email_to.split(','))
+ for address in addresses:
+ personalization.add_to(Email(address))
+ for recipient in self.recipient_ids:
+ if recipient.email not in addresses:
+ personalization.add_to(Email(recipient.email))
+ addresses.add(recipient.email)
+ if self.email_cc and self.email_cc not in addresses:
+ personalization.add_cc(Email(self.email_cc))
+ else:
+ _logger.info('Sending email to test address {}'.format(
+ test_address))
+ personalization.add_to(Email(test_address))
+ self.email_to = test_address
+
+ if self.sendgrid_template_id:
+ s_mail.template_id = self.sendgrid_template_id.remote_id
+
+ for substitution in self.substitution_ids:
+ personalization.add_substitution(Substitution(
+ substitution.key, substitution.value.encode('utf-8')))
+
+ s_mail.add_personalization(personalization)
+
+ for attachment in self.attachment_ids:
+ s_attachment = Attachment()
+ # Datas are not encoded properly for sendgrid
+ s_attachment.content = base64.b64encode(base64.b64decode(
+ attachment.datas))
+ s_attachment.filename = attachment.name
+ s_mail.add_attachment(s_attachment)
+
+ return s_mail
+
+ def _track_sendgrid_emails(self):
+ """ Create tracking e-mails after successfully sent with Sendgrid. """
+ self.ensure_one()
+ m_tracking = self.env['mail.tracking.email'].sudo()
+ track_vals = self._prepare_sendgrid_tracking()
+ for recipient in tools.email_split_and_format(self.email_to):
+ track_vals['recipient'] = recipient
+ m_tracking += m_tracking.create(track_vals)
+ for partner in self.recipient_ids:
+ track_vals.update({
+ 'partner_id': partner.id,
+ 'recipient': partner.email,
+ })
+ m_tracking += m_tracking.create(track_vals)
+ return m_tracking
+
+ def _prepare_sendgrid_tracking(self):
+ ts = time.time()
+ return {
+ 'name': self.subject,
+ 'timestamp': '%.6f' % ts,
+ 'time': fields.Datetime.now(),
+ 'mail_id': self.id,
+ 'mail_message_id': self.mail_message_id.id,
+ 'sender': self.email_from,
+ }
diff --git a/mail_sendgrid/models/mail_tracking_event.py b/mail_sendgrid/models/mail_tracking_event.py
new file mode 100644
index 00000000..0a8ed15a
--- /dev/null
+++ b/mail_sendgrid/models/mail_tracking_event.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Copyright 2017 Emanuel Cino -
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from odoo import models, api
+
+
+class MailTrackingEvent(models.Model):
+ _inherit = "mail.tracking.event"
+
+ @api.model
+ def process_sent(self, tracking_email, metadata):
+ return self._process_status(
+ tracking_email, metadata, 'sent', 'sent')
diff --git a/mail_sendgrid/models/sendgrid_template.py b/mail_sendgrid/models/sendgrid_template.py
new file mode 100644
index 00000000..d96bf641
--- /dev/null
+++ b/mail_sendgrid/models/sendgrid_template.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from odoo import models, fields, api, exceptions, _
+from odoo.tools.config import config
+
+import json
+import re
+import logging
+
+
+_logger = logging.getLogger(__name__)
+
+
+try:
+ import sendgrid
+except ImportError:
+ _logger.error("ImportError raised while loading module.")
+ _logger.debug("ImportError details:", exc_info=True)
+
+
+class SendgridTemplate(models.Model):
+ """ Reference to a template available on the SendGrid user account. """
+ _name = 'sendgrid.template'
+
+ ##########################################################################
+ # FIELDS #
+ ##########################################################################
+ name = fields.Char()
+ remote_id = fields.Char(readonly=True)
+ html_content = fields.Html(readonly=True)
+ plain_content = fields.Text(readonly=True)
+ detected_keywords = fields.Char(compute='_compute_keywords')
+
+ def _compute_keywords(self):
+ for template in self:
+ if template.html_content:
+ keywords = template.get_keywords()
+ self.detected_keywords = ';'.join(keywords)
+
+ @api.model
+ def update_templates(self):
+ api_key = config.get('sendgrid_api_key')
+ if not api_key:
+ raise exceptions.UserError(
+ _('Missing sendgrid_api_key in conf file'))
+
+ sg = sendgrid.SendGridAPIClient(apikey=api_key)
+ template_client = sg.client.templates
+ msg = template_client.get().body
+ result = json.loads(msg)
+
+ for template in result.get("templates", list()):
+ id = template["id"]
+ msg = template_client._(id).get().body
+ template_versions = json.loads(msg)['versions']
+ for version in template_versions:
+ if version['active']:
+ template_vals = version
+ break
+ else:
+ continue
+
+ vals = {
+ "remote_id": id,
+ "name": template["name"],
+ "html_content": template_vals["html_content"],
+ "plain_content": template_vals["plain_content"],
+ }
+ record = self.search([('remote_id', '=', id)])
+ if record:
+ record.write(vals)
+ else:
+ self.create(vals)
+ return True
+
+ def get_keywords(self):
+ """ Search in the Sendgrid template for keywords included with the
+ following syntax: {keyword_name} and returns the list of keywords.
+ keyword_name shouldn't be longer than 50 characters and not contain
+ whitespaces.
+ You can replace the substitution prefix and suffix by adding values
+ in the system parameters
+ - mail_sendgrid.substitution_prefix
+ - mail_sendgrid.substitution_suffix
+ """
+ self.ensure_one()
+ params = self.env['ir.config_parameter']
+ prefix = params.search([
+ ('key', '=', 'mail_sendgrid.substitution_prefix')
+ ]).value or '{'
+ suffix = params.search([
+ ('key', '=', 'mail_sendgrid.substitution_suffix')
+ ]) or '}'
+ pattern = prefix + r'\S{1,50}' + suffix
+ return list(set(re.findall(pattern, self.html_content)))
diff --git a/mail_sendgrid/models/substitution.py b/mail_sendgrid/models/substitution.py
new file mode 100644
index 00000000..7f9c9bba
--- /dev/null
+++ b/mail_sendgrid/models/substitution.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import models, fields
+
+
+class Substitution(models.Model):
+ """ Substitution values for a SendGrid email message """
+ _name = 'sendgrid.substitution'
+
+ ##########################################################################
+ # FIELDS #
+ ##########################################################################
+ key = fields.Char()
+ lang = fields.Char()
+ email_template_id = fields.Many2one(
+ 'mail.template', ondelete='cascade')
+ email_id = fields.Many2one(
+ 'mail.mail', ondelete='cascade')
+ value = fields.Char()
diff --git a/mail_sendgrid/security/ir.model.access.csv b/mail_sendgrid/security/ir.model.access.csv
new file mode 100644
index 00000000..6e7d2f02
--- /dev/null
+++ b/mail_sendgrid/security/ir.model.access.csv
@@ -0,0 +1,4 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_sendgrid_substitution,Full access on sendgrid_substitution,model_sendgrid_substitution,base.group_user,1,1,1,1
+access_sendgrid_template,Full access on sendgrid_template,model_sendgrid_template,base.group_user,1,1,1,1
+access_sendgrid_lang_template,Full access on sendgrid_lang_template,model_sendgrid_email_lang_template,base.group_user,1,1,1,1
diff --git a/mail_sendgrid/static/description/icon.png b/mail_sendgrid/static/description/icon.png
new file mode 100644
index 00000000..5567773a
Binary files /dev/null and b/mail_sendgrid/static/description/icon.png differ
diff --git a/mail_sendgrid/static/description/icon.svg b/mail_sendgrid/static/description/icon.svg
new file mode 100644
index 00000000..8661fee8
--- /dev/null
+++ b/mail_sendgrid/static/description/icon.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/mail_sendgrid/tests/__init__.py b/mail_sendgrid/tests/__init__.py
new file mode 100644
index 00000000..7853a92b
--- /dev/null
+++ b/mail_sendgrid/tests/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import test_mail_sendgrid
diff --git a/mail_sendgrid/tests/test_mail_sendgrid.py b/mail_sendgrid/tests/test_mail_sendgrid.py
new file mode 100644
index 00000000..be51d74b
--- /dev/null
+++ b/mail_sendgrid/tests/test_mail_sendgrid.py
@@ -0,0 +1,270 @@
+# -*- coding: utf-8 -*-
+# © 2017 Emanuel Cino -
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+import json
+
+import mock
+from odoo.tests.common import HttpCase
+from ..controllers.json_request import RESTJsonRequest
+
+mock_base_send = 'odoo.addons.mail.models.mail_mail.MailMail.send'
+mock_sendgrid_api_client = ('odoo.addons.mail_sendgrid.models.mail_mail'
+ '.SendGridAPIClient')
+mock_sendgrid_send = ('odoo.addons.mail_sendgrid.models.mail_mail.'
+ 'MailMail.send_sendgrid')
+mock_config = ('odoo.addons.mail_sendgrid.models.mail_mail.'
+ 'config')
+
+mock_config_template = ('odoo.addons.mail_sendgrid.models.sendgrid_template.'
+ 'config')
+mock_template_api_client = ('odoo.addons.mail_sendgrid.models.'
+ 'sendgrid_template.sendgrid.SendGridAPIClient')
+
+mock_json_request = 'odoo.http.Root.get_request'
+
+
+def side_effect_json(http_request):
+ return RESTJsonRequest(http_request)
+
+
+class FakeClient(object):
+ """ Mock Sendgrid APIClient """
+ status_code = 202
+ body = 'ok'
+
+ def __init__(self):
+ self.client = self
+ self.mail = self
+ self.send = self
+
+ def post(self, **kwargs):
+ return self
+
+
+class FakeRequest(object):
+ """ Simulate a Sendgrid JSON request """
+ def __init__(self, data):
+ self.jsonrequest = [data]
+
+
+class FakeTemplateClient(object):
+ """ Simulate the Sendgrid Template api"""
+ def __init__(self):
+ self.client = self
+ self.templates = self
+ self.body = json.dumps({
+ "templates": [{
+ "id": "fake_id",
+ "name": "Fake Template"
+ }],
+ "versions": [{
+ "active": True,
+ "html_content": "fake
",
+ "plain_content": "fake",
+ }],
+ })
+
+ def get(self):
+ return self
+
+ def _(self, id):
+ return self
+
+
+class TestMailSendgrid(HttpCase):
+ def setUp(self):
+ super(TestMailSendgrid, self).setUp()
+ self.sendgrid_template = self.env['sendgrid.template'].create({
+ 'name': 'Test Template',
+ 'remote_id': 'a74795d7-f926-4bad-8e7a-ae95fabd70fc',
+ 'html_content': u'Test Sendgrid
<%body%>{footer}'
+ })
+ self.mail_template = self.env['mail.template'].create({
+ 'name': 'Test Template',
+ 'model_id': self.env.ref('base.model_res_partner').id,
+ 'subject': 'Test e-mail',
+ 'body_html': u'Dear ${object.name}, hello!',
+ 'sendgrid_template_ids': [
+ (0, 0, {'lang': 'en_US', 'sendgrid_template_id':
+ self.sendgrid_template.id})]
+ })
+ self.recipient = self.env.ref('base.partner_demo')
+ self.mail_wizard = self.env['mail.compose.message'].create({
+ 'template_id': self.mail_template.id,
+ 'composition_mode': 'comment',
+ 'model': 'res.partner',
+ 'res_id': self.recipient.id
+ }).with_context(active_id=self.recipient.id)
+ self.mail_wizard.onchange_template_id_wrapper()
+ self.timestamp = u'1471021089'
+ self.event = {
+ 'timestamp': self.timestamp,
+ 'sg_event_id': u"f_JoKtrLQaOXUc4thXgROg",
+ 'email': self.recipient.email,
+ 'odoo_db': self.env.cr.dbname,
+ 'odoo_id': u''
+ }
+ self.metadata = {
+ 'ip': '127.0.0.1',
+ 'user_agent': False,
+ 'os_family': False,
+ 'ua_family': False,
+ }
+ self.request = FakeRequest(self.event)
+
+ def create_email(self, vals=None):
+ mail_vals = self.mail_wizard.get_mail_values(self.recipient.ids)[
+ self.recipient.id]
+ mail_vals['recipient_ids'] = [(6, 0, self.recipient.ids)]
+ if vals is not None:
+ mail_vals.update(vals)
+ return self.env['mail.mail'].with_context(test_mode=True).create(
+ mail_vals)
+
+ def test_preview(self):
+ """
+ Test the preview email_template is getting the Sendgrid template
+ """
+ preview_wizard = self.env['email_template.preview'].with_context(
+ template_id=self.mail_template.id,
+ default_res_id=self.recipient.id
+ ).create({})
+ # For a strange reason, res_id is converted to string
+ preview_wizard.res_id = self.recipient.id
+ preview_wizard.on_change_res_id()
+ self.assertIn(u'Test Sendgrid
', preview_wizard.body_html)
+ self.assertIn(self.recipient.name, preview_wizard.body_html)
+
+ def test_substitutions(self):
+ """ Test substitutions in templates. """
+ self.assertEqual(self.sendgrid_template.detected_keywords, "{footer}")
+ self.mail_template.update_substitutions()
+ substitutions = self.mail_template.substitution_ids
+ self.assertEqual(len(substitutions), 1)
+ self.assertEqual(substitutions.key, '{footer}')
+
+ def test_create_email(self):
+ """ Test that Sendgrid template is pushed in e-mail. """
+ self.mail_template.update_substitutions()
+ mail_values = self.mail_wizard.get_mail_values(self.recipient.ids)[
+ self.recipient.id]
+ # Test Sendgrid HTML preview
+ self.assertEqual(
+ self.mail_wizard.body_sendgrid,
+ self.sendgrid_template.html_content.replace(
+ '<%body%>', mail_values['body'])
+ )
+ mail = self.env['mail.mail'].create(mail_values)
+ self.assertEqual(mail.sendgrid_template_id.id,
+ self.sendgrid_template.id)
+ self.assertEqual(len(mail.substitution_ids), 1)
+
+ @mock.patch(mock_base_send)
+ @mock.patch(mock_sendgrid_send)
+ def test_send_email_default(self, mock_sendgrid, mock_email):
+ """ Tests that sending an e-mail by default doesn't use Sendgrid,
+ and that Sendgrid is used when system parameter is set.
+ """
+ self.env['ir.config_parameter'].set_param(
+ 'mail_sendgrid.send_method', False)
+ mock_sendgrid.return_value = True
+ mock_email.return_value = True
+ mail = self.create_email()
+ mail.send()
+ self.assertTrue(mock_email.called)
+ self.assertFalse(mock_sendgrid.called)
+
+ self.env['ir.config_parameter'].set_param(
+ 'mail_sendgrid.send_method', 'sendgrid')
+ # Force again computation of send_method
+ self.env.invalidate_all()
+ mail.send()
+ self.assertEqual(mock_email.call_count, 1)
+ self.assertEqual(mock_sendgrid.call_count, 1)
+
+ @mock.patch(mock_sendgrid_api_client)
+ @mock.patch(mock_config)
+ def test_mail_tracking(self, m_config, mock_sendgrid):
+ """ Test various tracking events. """
+ self.env['ir.config_parameter'].set_param(
+ 'mail_sendgrid.send_method', 'sendgrid')
+ mock_sendgrid.return_value = FakeClient()
+ m_config.get.return_value = "ushuwejhfkj"
+
+ # Send mail
+ mail = self.create_email()
+ mail.send()
+ self.assertEqual(mock_sendgrid.called, True)
+ self.assertEqual(mail.state, 'sent')
+ mail_tracking = mail.tracking_email_ids
+ self.assertEqual(len(mail_tracking), 1)
+ self.assertFalse(mail_tracking.state)
+
+ # Test mail processed
+ self.event.update({
+ 'event': u'processed',
+ 'odoo_id': mail.message_id
+ })
+ response = self.env['mail.tracking.email'].event_process(
+ self.request, self.event, self.metadata)
+ self.assertEqual(response, 'OK')
+ self.assertEqual(mail_tracking.state, 'sent')
+
+ # Test mail delivered
+ self.event['event'] = 'delivered'
+ self.env['mail.tracking.email'].event_process(
+ self.request, self.event, self.metadata)
+ self.assertEqual(mail_tracking.state, 'delivered')
+ self.assertEqual(mail_tracking.recipient, self.recipient.email)
+ self.assertFalse(mail.opened)
+
+ # Test mail opened
+ self.event['event'] = 'open'
+ self.env['mail.tracking.email'].event_process(
+ self.request, self.event, self.metadata)
+ self.assertEqual(mail_tracking.state, 'opened')
+ self.assertTrue(mail.opened)
+
+ # Test click e-mail
+ self.event['event'] = 'click'
+ self.env['mail.tracking.email'].event_process(
+ self.request, self.event, self.metadata)
+ self.assertEqual(mail_tracking.state, 'opened')
+ self.assertEqual(mail.click_count, 1)
+
+ # Test events are linked to e-mail
+ self.assertEquals(len(mail.tracking_event_ids), 4)
+
+ def test_controller(self):
+ """ Check the controller is working """
+ event_data = [self.event]
+ with mock.patch(mock_json_request,
+ side_effect=side_effect_json) as json_mock:
+ json_mock.return_value = True
+ result = self.url_open(
+ '/mail/tracking/sendgrid/' + self.session.db,
+ json.dumps(event_data)
+ )
+ self.assertTrue(json_mock.called)
+ self.assertTrue(result)
+ # Invalid request
+ self.url_open(
+ '/mail/tracking/sendgrid/' + self.session.db,
+ "[{'invalid': True}]"
+ )
+
+ @mock.patch(mock_template_api_client)
+ @mock.patch(mock_config_template)
+ def test_update_templates(self, m_config, m_sendgrid):
+ m_config.return_value = "ldkfjsOIWJRksfj"
+ m_sendgrid.return_value = FakeTemplateClient()
+ self.env['sendgrid.template'].update_templates()
+ template = self.env['sendgrid.template'].search([
+ ('remote_id', '=', 'fake_id')
+ ])
+ self.assertTrue(template)
+
+ def tearDown(self):
+ super(TestMailSendgrid, self).tearDown()
+ self.env['ir.config_parameter'].set_param(
+ 'mail_sendgrid.send_method', 'traditional')
diff --git a/mail_sendgrid/views/email_template_view.xml b/mail_sendgrid/views/email_template_view.xml
new file mode 100644
index 00000000..15e2191c
--- /dev/null
+++ b/mail_sendgrid/views/email_template_view.xml
@@ -0,0 +1,31 @@
+
+
+
+
+ sendgrid.sendgrid.form
+ mail.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mail_sendgrid/views/mail_compose_message_view.xml b/mail_sendgrid/views/mail_compose_message_view.xml
new file mode 100644
index 00000000..c4fa7adb
--- /dev/null
+++ b/mail_sendgrid/views/mail_compose_message_view.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ mail.compose.sendgrid.form
+ mail.compose.message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mail_sendgrid/views/sendgrid_email_view.xml b/mail_sendgrid/views/sendgrid_email_view.xml
new file mode 100644
index 00000000..5aeb6a05
--- /dev/null
+++ b/mail_sendgrid/views/sendgrid_email_view.xml
@@ -0,0 +1,77 @@
+
+
+
+
+ mail.mail.sendgrid
+ mail.mail
+
+
+
+
+ html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mail.mail.sendgrid.tree
+ mail.mail
+
+
+
+
+
+
+
+
+
+
+
+ mail.mail.sendgrid.search
+ mail.mail
+
+
+
+
+
+
+
+
+
+
+
+ sendgrid.substitution.tree
+ sendgrid.substitution
+
+
+
+
+
+
+
+
diff --git a/mail_sendgrid/views/sendgrid_template_view.xml b/mail_sendgrid/views/sendgrid_template_view.xml
new file mode 100644
index 00000000..53f2f983
--- /dev/null
+++ b/mail_sendgrid/views/sendgrid_template_view.xml
@@ -0,0 +1,72 @@
+
+
+
+
+ sendgrid.template.tree
+ sendgrid.template
+
+
+
+
+
+
+
+
+
+
+ sendgrid.template.form
+ sendgrid.template
+
+
+
+
+
+
+
+ Template
+ sendgrid.template
+ form
+ form,tree
+
+
+
+
+ Update Sendgrid Templates
+
+
+env['sendgrid.template'].update_templates()
+action = {
+ 'name': 'Sendgrid templates',
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'sendgrid.template',
+ 'view_mode': 'tree,form'
+}
+
+
+
+
+
+
+
diff --git a/mail_sendgrid/wizards/__init__.py b/mail_sendgrid/wizards/__init__.py
new file mode 100644
index 00000000..476a596f
--- /dev/null
+++ b/mail_sendgrid/wizards/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import mail_compose_message
+from . import email_template_preview
diff --git a/mail_sendgrid/wizards/email_template_preview.py b/mail_sendgrid/wizards/email_template_preview.py
new file mode 100644
index 00000000..2d1dfda1
--- /dev/null
+++ b/mail_sendgrid/wizards/email_template_preview.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import models, api
+
+
+class EmailTemplatePreview(models.TransientModel):
+ """ Put the preview inside sendgrid template """
+ _inherit = 'email_template.preview'
+
+ @api.onchange('res_id')
+ @api.multi
+ def on_change_res_id(self):
+ result = super(EmailTemplatePreview, self).on_change_res_id()
+ body_html = self.body_html
+ template_id = self.env.context.get('template_id')
+ template = self.env['mail.template'].browse(template_id)
+ sendgrid_template = template.sendgrid_localized_template
+ if sendgrid_template:
+ self.body_html = sendgrid_template.html_content.replace(
+ '<%body%>', body_html)
+ return result
diff --git a/mail_sendgrid/wizards/mail_compose_message.py b/mail_sendgrid/wizards/mail_compose_message.py
new file mode 100644
index 00000000..f2932c90
--- /dev/null
+++ b/mail_sendgrid/wizards/mail_compose_message.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# Copyright 2015-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import models, fields, api
+
+
+class EmailComposeMessage(models.TransientModel):
+ """ Email message sent through SendGrid """
+ _inherit = 'mail.compose.message'
+
+ body_sendgrid = fields.Html(compute='_compute_sendgrid_view')
+
+ @api.depends('body')
+ def _compute_sendgrid_view(self):
+ for wizard in self:
+ template = wizard.template_id
+ sendgrid_template = template.sendgrid_localized_template
+ res_id = self.env.context.get('active_id')
+ render_body = self.render_template(
+ wizard.body, wizard.model, [res_id], post_process=True)[res_id]
+ if sendgrid_template and wizard.body:
+ wizard.body_sendgrid = sendgrid_template.html_content.replace(
+ '<%body%>', render_body)
+ else:
+ wizard.body_sendgrid = render_body
+
+ @api.multi
+ def get_mail_values(self, res_ids):
+ """ Attach sendgrid template to e-mail and render substitutions """
+ mail_values = super(EmailComposeMessage, self).get_mail_values(res_ids)
+ template = self.template_id
+ sendgrid_template_id = template.sendgrid_localized_template.id
+
+ if sendgrid_template_id:
+ substitutions = template.render_substitutions(res_ids)
+
+ for res_id, value in mail_values.iteritems():
+ value['sendgrid_template_id'] = sendgrid_template_id
+ value['substitution_ids'] = substitutions[res_id]
+
+ return mail_values
diff --git a/mail_sendgrid_mass_mailing/README.rst b/mail_sendgrid_mass_mailing/README.rst
new file mode 100644
index 00000000..41652f72
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/README.rst
@@ -0,0 +1,89 @@
+.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png
+ :target: https://www.gnu.org/licenses/agpl
+ :alt: License: AGPL-3
+
+=========================
+SendGrid for mass mailing
+=========================
+
+Links mass mailing and mail statistics objects with Sendgrid.
+Note that the mass mailing campaign will be sent with Sendgrid transactional
+e-emails (not to mix up with Sendgrid marketing campaigns)
+
+Installation
+============
+
+This addon will be automatically installed when 'mail_sendgrid' and
+'mass_mailing' are both installed.
+
+Configuration
+=============
+None
+
+Usage
+=====
+
+From mass mailing, you can use Sendgrid templates.
+
+#. If you select a Sendgrid template, the campaign will be sent through
+ Sendgrid. Otherwise it will use what you set in your system preference
+ (see module sendgrid).
+#. You can force usage of a language for the template.
+
+
+.. 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/10.0
+
+.. repo_id is available in https://github.com/OCA/maintainer-tools/blob/master/tools/repos_with_ids.txt
+.. branch is "8.0" for example
+
+Known issues / Roadmap
+======================
+
+* Use Sendgrid marketing campaigns API
+
+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 smash it by providing detailed and welcomed feedback.
+
+Credits
+=======
+
+Images
+------
+
+* Odoo Community Association: `Icon `_.
+
+Contributors
+------------
+
+* Emanuel Cino
+
+Do not contact contributors directly about support or help with technical issues.
+
+Funders
+-------
+
+The development of this module has been financially supported by:
+
+* Compassion Switzerland
+
+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_sendgrid_mass_mailing/__init__.py b/mail_sendgrid_mass_mailing/__init__.py
new file mode 100644
index 00000000..211f3a48
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import models
+from . import wizards
diff --git a/mail_sendgrid_mass_mailing/__manifest__.py b/mail_sendgrid_mass_mailing/__manifest__.py
new file mode 100644
index 00000000..cef66bcd
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/__manifest__.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+{
+ 'name': 'Mass Mailing with SendGrid',
+ 'version': '10.0.1.0.0',
+ 'category': 'Social Network',
+ 'author': 'Compassion CH, Odoo Community Association (OCA)',
+ 'license': 'AGPL-3',
+ 'website': 'https://github.com/OCA/social',
+ 'depends': ['mail_sendgrid', 'mail_tracking_mass_mailing'],
+ 'data': [
+ 'views/mass_mailing_view.xml'
+ ],
+ 'demo': [],
+ 'installable': True,
+ 'auto_install': True,
+ 'external_dependencies': {
+ 'python': ['sendgrid'],
+ },
+}
diff --git a/mail_sendgrid_mass_mailing/models/__init__.py b/mail_sendgrid_mass_mailing/models/__init__.py
new file mode 100644
index 00000000..1cfb554a
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import mass_mailing
+from . import mail_mail
+from . import email_tracking
diff --git a/mail_sendgrid_mass_mailing/models/email_tracking.py b/mail_sendgrid_mass_mailing/models/email_tracking.py
new file mode 100644
index 00000000..d17cad35
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/models/email_tracking.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from odoo import models, fields, api
+
+
+class MailTrackingEvent(models.Model):
+ """ Push events to campaign_statistics
+ """
+ _inherit = 'mail.tracking.event'
+
+ @api.model
+ def process_delivered(self, tracking_email, metadata):
+ res = super(MailTrackingEvent, self).process_delivered(
+ tracking_email, metadata)
+ mail_mail_stats = self.sudo().env['mail.mail.statistics'].search([
+ ('mail_mail_id_int', '=', tracking_email.mail_id_int)])
+ mail_mail_stats.write({
+ 'sent': fields.Datetime.now()
+ })
+ return res
+
+ @api.model
+ def process_reject(self, tracking_email, metadata):
+ res = super(MailTrackingEvent, self).process_reject(
+ tracking_email, metadata)
+ mail_mail_stats = self.sudo().env['mail.mail.statistics'].search([
+ ('mail_mail_id_int', '=', tracking_email.mail_id_int)])
+ mail_mail_stats.write({
+ 'exception': fields.Datetime.now()
+ })
+ return res
diff --git a/mail_sendgrid_mass_mailing/models/mail_mail.py b/mail_sendgrid_mass_mailing/models/mail_mail.py
new file mode 100644
index 00000000..d75a1547
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/models/mail_mail.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from odoo import models
+import logging
+
+_logger = logging.getLogger(__name__)
+
+try:
+ from sendgrid.helpers.mail.mail import TrackingSettings, \
+ SubscriptionTracking
+except ImportError:
+ _logger.error("ImportError raised while loading module.")
+ _logger.debug("ImportError details:", exc_info=True)
+
+
+class MailMail(models.Model):
+ _inherit = "mail.mail"
+
+ def _prepare_sendgrid_tracking(self):
+ track_vals = super(MailMail, self)._prepare_sendgrid_tracking()
+ track_vals.update({
+ 'mail_id_int': self.id,
+ 'mass_mailing_id': self.mailing_id.id,
+ 'mail_stats_id': self.statistics_ids[:1].id
+ if self.statistics_ids else False
+ })
+ return track_vals
+
+ def _track_sendgrid_emails(self):
+ """ Push tracking_email in mass_mail_statistic """
+ tracking_emails = super(MailMail, self)._track_sendgrid_emails()
+ for tracking in tracking_emails.filtered('mail_stats_id'):
+ tracking.mail_stats_id.mail_tracking_id = tracking.id
+ return tracking_emails
+
+ def _prepare_sendgrid_data(self):
+ """
+ Add unsubscribe options in mass mailings
+ :return: Sendgrid Email
+ """
+ s_mail = super(MailMail, self)._prepare_sendgrid_data()
+ tracking_settings = TrackingSettings()
+ if self.mailing_id.enable_unsubscribe:
+ sub_settings = SubscriptionTracking(
+ enable=True,
+ text=self.mailing_id.unsubscribe_text,
+ html=self.mailing_id.unsubscribe_text,
+ )
+ if self.mailing_id.unsubscribe_tag:
+ sub_settings.substitution_tag = \
+ self.mailing_id.unsubscribe_tag
+ tracking_settings.subscription_tracking = sub_settings
+ else:
+ tracking_settings.subscription_tracking = SubscriptionTracking(
+ enable=False)
+
+ s_mail.tracking_settings = tracking_settings
+ return s_mail
diff --git a/mail_sendgrid_mass_mailing/models/mass_mailing.py b/mail_sendgrid_mass_mailing/models/mass_mailing.py
new file mode 100644
index 00000000..f4c681d1
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/models/mass_mailing.py
@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import api, models, fields, _
+from odoo.exceptions import Warning as UserError
+from odoo.tools.safe_eval import safe_eval
+
+
+class MassMailing(models.Model):
+ """ Add a direct link to an e-mail template in order to retrieve all
+ Sendgrid configuration into the e-mails. Add ability to force a
+ template language.
+ """
+ _inherit = 'mail.mass_mailing'
+
+ email_template_id = fields.Many2one(
+ 'mail.template', 'Sengdrid Template',
+ )
+ lang = fields.Many2one(
+ comodel_name="res.lang", string="Force language")
+ body_sendgrid = fields.Html(compute='_compute_sendgrid_view')
+ # Trick to save html when taken from the e-mail template
+ html_copy = fields.Html(
+ compute='_compute_sendgrid_view', inverse='_inverse_html_copy')
+ # Trick to display another widget when using Sendgrid
+ html_unframe = fields.Html(related='body_html')
+ enable_unsubscribe = fields.Boolean()
+ unsubscribe_text = fields.Char(
+ default='If you would like to unsubscribe and stop receiving these '
+ 'emails <% clickhere %>.')
+ unsubscribe_tag = fields.Char()
+
+ @api.depends('body_html')
+ def _compute_sendgrid_view(self):
+ for wizard in self:
+ template = wizard.email_template_id.with_context(
+ lang=self.lang.code or self.env.context['lang'])
+ sendgrid_template = template.sendgrid_localized_template
+ if sendgrid_template and wizard.body_html:
+ res_id = self.env[wizard.mailing_model].search(safe_eval(
+ wizard.mailing_domain), limit=1).id
+ if res_id:
+ body = template.render_template(
+ wizard.body_html, template.model, [res_id],
+ post_process=True)[res_id]
+ wizard.body_sendgrid = \
+ sendgrid_template.html_content.replace('<%body%>',
+ body)
+ else:
+ wizard.body_sendgrid = wizard.body_html
+ wizard.html_copy = wizard.body_html
+
+ def _inverse_html_copy(self):
+ for wizard in self:
+ wizard.body_html = wizard.html_copy
+
+ @api.onchange('email_template_id')
+ def onchange_email_template_id(self):
+ if self.email_template_id:
+ template = self.email_template_id.with_context(
+ lang=self.lang.code or self.env.context['lang'])
+ if template.email_from:
+ self.email_from = template.email_from
+ self.name = template.subject
+ self.body_html = template.body_html
+
+ @api.onchange('lang')
+ def onchange_lang(self):
+ if self.lang and self.mailing_model == 'res.partner':
+ domain = safe_eval(self.mailing_domain)
+ lang_tuple = False
+ for tuple in domain:
+ if tuple[0] == 'lang':
+ lang_tuple = tuple
+ break
+ if lang_tuple:
+ domain.remove(lang_tuple)
+ domain.append(('lang', '=', self.lang.code))
+ self.mailing_domain = str(domain)
+ self.onchange_email_template_id()
+
+ @api.multi
+ def action_test_mailing(self):
+ wizard = self
+ if self.email_template_id:
+ wizard = self.with_context(
+ lang=self.lang.code or self.env.context['lang'])
+ return super(MassMailing, wizard).action_test_mailing()
+
+ @api.multi
+ def send_mail(self):
+ sendgrid = self.filtered('email_template_id')
+ emails = self.env['mail.mail']
+ for mailing in sendgrid:
+ # use E-mail Template
+ res_ids = mailing.get_recipients()
+ if not res_ids:
+ raise UserError(_('Please select recipients.'))
+ lang = mailing.lang.code or self.env.context.get('lang', 'en_US')
+ mailing = mailing.with_context(lang=lang)
+ composer_values = mailing._send_mail_get_composer_values()
+ if mailing.reply_to_mode == 'email':
+ composer_values['reply_to'] = mailing.reply_to
+ composer = self.env['mail.compose.message'].with_context(
+ lang=lang, active_ids=res_ids)
+ emails += composer.mass_mailing_sendgrid(res_ids, composer_values)
+ mailing.write({
+ 'state': 'done',
+ 'sent_date': fields.Datetime.now(),
+ })
+ # Traditional sending
+ super(MassMailing, self - sendgrid).send_mail()
+ return emails
+
+ def _send_mail_get_composer_values(self):
+ """
+ Get the values used for the mail.compose.message wizard that will
+ generate the e-mails of a mass mailing campaign.
+ :return: dictionary of mail.compose.message values
+ """
+ template = self.email_template_id
+ author = self.mass_mailing_campaign_id.user_id.partner_id or \
+ self.env.user.partner_id
+ return {
+ 'template_id': template.id,
+ 'composition_mode': 'mass_mail',
+ 'model': template.model,
+ 'author_id': author.id,
+ 'attachment_ids': [(4, attachment.id) for attachment in
+ self.attachment_ids],
+ 'email_from': self.email_from,
+ 'body': self.body_html,
+ 'subject': self.name,
+ 'record_name': False,
+ 'mass_mailing_id': self.id,
+ 'mailing_list_ids': [(4, l.id) for l in
+ self.contact_list_ids],
+ 'no_auto_thread': self.reply_to_mode != 'thread',
+ }
diff --git a/mail_sendgrid_mass_mailing/static/description/icon.png b/mail_sendgrid_mass_mailing/static/description/icon.png
new file mode 100644
index 00000000..5567773a
Binary files /dev/null and b/mail_sendgrid_mass_mailing/static/description/icon.png differ
diff --git a/mail_sendgrid_mass_mailing/static/description/icon.svg b/mail_sendgrid_mass_mailing/static/description/icon.svg
new file mode 100644
index 00000000..8661fee8
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/static/description/icon.svg
@@ -0,0 +1,10 @@
+
+
diff --git a/mail_sendgrid_mass_mailing/tests/__init__.py b/mail_sendgrid_mass_mailing/tests/__init__.py
new file mode 100644
index 00000000..b5c1ba78
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/tests/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import test_mass_mailing
diff --git a/mail_sendgrid_mass_mailing/tests/test_mass_mailing.py b/mail_sendgrid_mass_mailing/tests/test_mass_mailing.py
new file mode 100644
index 00000000..fb77a9c6
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/tests/test_mass_mailing.py
@@ -0,0 +1,173 @@
+# -*- coding: utf-8 -*-
+# © 2017 Emanuel Cino -
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+import mock
+from odoo.tests.common import SavepointCase
+
+mock_sendgrid_api_client = ('odoo.addons.mail_sendgrid.models.mail_mail'
+ '.SendGridAPIClient')
+mock_config = ('odoo.addons.mail_sendgrid.models.mail_mail.'
+ 'config')
+
+
+class FakeClient(object):
+ """ Mock Sendgrid APIClient """
+ status_code = 202
+ body = 'ok'
+
+ def __init__(self):
+ self.client = self
+ self.mail = self
+ self.send = self
+
+ def post(self, **kwargs):
+ return self
+
+
+class FakeRequest(object):
+ """ Simulate a Sendgrid JSON request """
+ def __init__(self, data):
+ self.jsonrequest = [data]
+
+
+class TestMailSendgrid(SavepointCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestMailSendgrid, cls).setUpClass()
+ cls.sendgrid_template = cls.env['sendgrid.template'].create({
+ 'name': 'Test Template',
+ 'remote_id': 'a74795d7-f926-4bad-8e7a-ae95fabd70fc',
+ 'html_content': u'Test Sendgrid
<%body%>{footer}'
+ })
+ cls.mail_template = cls.env['mail.template'].create({
+ 'name': 'Test Template',
+ 'model_id': cls.env.ref('base.model_res_partner').id,
+ 'subject': 'Test e-mail',
+ 'body_html': u'Dear ${object.name}, hello!',
+ 'sendgrid_template_ids': [
+ (0, 0, {'lang': 'en_US', 'sendgrid_template_id':
+ cls.sendgrid_template.id})]
+ })
+ cls.recipient = cls.env.ref('base.partner_demo')
+ cls.mass_mailing = cls.env['mail.mass_mailing'].create({
+ 'email_from': 'admin@yourcompany.example.com',
+ 'name': 'Test Mass Mailing Sendgrid',
+ 'mailing_model': 'res.partner',
+ 'mailing_domain': "[('id', '=', %d)]" % cls.recipient.id,
+ 'email_template_id': cls.mail_template.id,
+ 'body_html': u'Dear ${object.name}, hello!',
+ 'reply_to_mode': 'email',
+ 'enable_unsubscribe': True,
+ 'unsubscribe_tag': '[unsub]'
+ }).with_context(lang='en_US', test_mode=True)
+ cls.timestamp = u'1471021089'
+ cls.event = {
+ 'timestamp': cls.timestamp,
+ 'sg_event_id': u"f_JoKtrLQaOXUc4thXgROg",
+ 'email': cls.recipient.email,
+ 'odoo_db': cls.env.cr.dbname,
+ 'odoo_id': u''
+ }
+ cls.metadata = {
+ 'ip': '127.0.0.1',
+ 'user_agent': False,
+ 'os_family': False,
+ 'ua_family': False,
+ }
+ cls.request = FakeRequest(cls.event)
+
+ def test_sendgrid_preview(self):
+ """
+ Test the preview field is getting the Sendgrid template
+ """
+ self.mass_mailing.html_copy = self.mass_mailing.body_html
+ preview = self.mass_mailing.body_sendgrid
+ self.assertIn(u'Test Sendgrid
', preview)
+ self.assertIn('hello!', preview)
+
+ def test_change_language(self):
+ """
+ Test changing the language is changing the domain
+ """
+ domain = self.mass_mailing.mailing_domain
+ self.mass_mailing.lang = self.env['res.lang'].search([], limit=1)
+ self.mass_mailing.onchange_lang()
+ self.assertTrue(len(self.mass_mailing.mailing_domain) > len(domain))
+
+ @mock.patch(mock_sendgrid_api_client)
+ @mock.patch(mock_config)
+ def test_send_campaign(self, m_config, mock_sendgrid):
+ """
+ Test sending mass campaign with Sendgrid template
+ and statistics update
+ """
+ self.env['ir.config_parameter'].set_param(
+ 'mail_sendgrid.send_method', 'sendgrid')
+ mock_sendgrid.return_value = FakeClient()
+ m_config.get.return_value = 'we4iorujeriu'
+
+ # Test campaign
+ self.mass_mailing.action_test_mailing()
+ self.env['mail.mass_mailing.test'].create({
+ 'mass_mailing_id': self.mass_mailing.id,
+ 'email_to': 'test@sendgrid.com'
+ }).with_context(lang='en_US', test_mode=True).send_mail_test()
+ self.assertTrue(mock_sendgrid.called)
+ mock_sendgrid.reset_mock()
+
+ # Send campaign
+ emails = self.mass_mailing.send_mail()
+ # Dont delete emails sent
+ emails.write({'auto_delete': False})
+ self.assertEqual(len(emails), 1)
+ self.assertEqual(emails.state, 'outgoing')
+ self.assertEqual(emails.sendgrid_template_id.id,
+ self.sendgrid_template.id)
+
+ emails.send()
+ self.assertTrue(mock_sendgrid.called)
+ self.assertEqual(emails.state, 'sent')
+ mail_tracking = emails.tracking_email_ids
+ self.assertEqual(len(mail_tracking), 1)
+ self.assertFalse(mail_tracking.state)
+ stats = self.mass_mailing.statistics_ids
+ self.assertEqual(len(stats), 1)
+ self.assertTrue(stats.sent)
+
+ # Test delivered
+ self.event.update({
+ 'event': 'delivered',
+ 'odoo_id': emails.message_id
+ })
+ self.env['mail.tracking.email'].event_process(
+ self.request, self.event, self.metadata)
+ self.assertTrue(stats.sent)
+
+ # Test click e-mail
+ self.event.update({
+ 'event': 'click',
+ })
+
+ self.env['mail.tracking.email'].event_process(
+ self.request, self.event, self.metadata)
+ self.assertEqual(emails.click_count, 1)
+ events = stats.tracking_event_ids
+ self.assertEqual(len(events), 2)
+ self.assertIn('delivered', events.mapped('event_type'))
+ self.assertIn('click', events.mapped('event_type'))
+ self.assertEqual(stats.state, 'sent')
+
+ # Test reject
+ self.event.update({
+ 'event': 'dropped',
+ })
+ self.env['mail.tracking.email'].event_process(
+ self.request, self.event, self.metadata)
+ self.assertEqual(stats.state, 'exception')
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.env['ir.config_parameter'].set_param(
+ 'mail_sendgrid.send_method', 'traditional')
+ super(TestMailSendgrid, cls).tearDownClass()
diff --git a/mail_sendgrid_mass_mailing/views/mass_mailing_view.xml b/mail_sendgrid_mass_mailing/views/mass_mailing_view.xml
new file mode 100644
index 00000000..5a144187
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/views/mass_mailing_view.xml
@@ -0,0 +1,34 @@
+
+
+
+
+ mass.mailing.sendgrid.form
+ mail.mass_mailing
+
+
+
+ {'invisible': [('email_template_id', '!=', False)]}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mail_sendgrid_mass_mailing/wizards/__init__.py b/mail_sendgrid_mass_mailing/wizards/__init__.py
new file mode 100644
index 00000000..133c9590
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/wizards/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from . import mail_compose_message
+from . import test_mailing
diff --git a/mail_sendgrid_mass_mailing/wizards/mail_compose_message.py b/mail_sendgrid_mass_mailing/wizards/mail_compose_message.py
new file mode 100644
index 00000000..47a80f64
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/wizards/mail_compose_message.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import models, api
+
+
+class EmailComposeMessage(models.TransientModel):
+ _inherit = 'mail.compose.message'
+
+ @api.model
+ def mass_mailing_sendgrid(self, res_ids, composer_values):
+ """ Helper to generate a new e-mail given a template and objects.
+
+ :param res_ids: ids of the resource objects
+ :param composer_values: values for the composer wizard
+ :return: browse records of created e-mails (one per resource object)
+ """
+ if not isinstance(res_ids, list):
+ res_ids = [res_ids]
+ wizard = self.create(composer_values)
+ all_mail_values = wizard.get_mail_values(res_ids)
+ email_obj = self.env['mail.mail']
+ emails = email_obj
+ for res_id in res_ids:
+ mail_values = all_mail_values[res_id]
+ obj = self.env[wizard.model].browse(res_id)
+ if wizard.model == 'res.partner':
+ mail_values['recipient_ids'] = [(6, 0, obj.ids)]
+ else:
+ mail_values['email_to'] = obj.email
+ emails += email_obj.create(mail_values)
+ return emails
diff --git a/mail_sendgrid_mass_mailing/wizards/test_mailing.py b/mail_sendgrid_mass_mailing/wizards/test_mailing.py
new file mode 100644
index 00000000..ccb8f175
--- /dev/null
+++ b/mail_sendgrid_mass_mailing/wizards/test_mailing.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Copyright 2016-2017 Compassion CH (http://www.compassion.ch)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import models, api, tools
+
+
+class TestMassMailing(models.TransientModel):
+ _inherit = 'mail.mass_mailing.test'
+
+ @api.multi
+ def send_mail_test(self):
+ """ Send with Sendgrid if needed.
+ """
+ self.ensure_one()
+ mailing = self.mass_mailing_id
+ template = mailing.email_template_id.with_context(
+ lang=mailing.lang.code or self.env.context['lang'])
+ if template:
+ # Send with SendGrid (and use E-mail Template)
+ sendgrid_template = template.sendgrid_localized_template
+ res_id = self.env.user.partner_id.id
+ body = template.render_template(
+ mailing.body_html, template.model, [res_id],
+ post_process=True)[res_id]
+ test_emails = tools.email_split(self.email_to)
+ emails = self.env['mail.mail']
+ for test_mail in test_emails:
+ email_vals = {
+ 'email_from': mailing.email_from,
+ 'reply_to': mailing.reply_to,
+ 'email_to': test_mail,
+ 'subject': mailing.name,
+ 'body_html': body,
+ 'sendgrid_template_id': sendgrid_template.id,
+ 'substitution_ids': template.render_substitutions(
+ res_id)[res_id],
+ 'notification': True,
+ 'mailing_id': mailing.id,
+ 'attachment_ids': [(4, attachment.id) for attachment in
+ mailing.attachment_ids],
+ }
+ emails += emails.create(email_vals)
+ emails.send_sendgrid()
+ else:
+ super(TestMassMailing, self).send_mail_test()
+
+ return True