diff --git a/mail_browser_view/README.rst b/mail_browser_view/README.rst new file mode 100644 index 00000000..d9189b2b --- /dev/null +++ b/mail_browser_view/README.rst @@ -0,0 +1,104 @@ +================= +Mail Browser View +================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/11.0/mail_browser_view + :alt: OCA/social +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/social-11-0/social-11-0-mail_browser_view + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/205/11.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module enables you to add a link in your mail templates, +so the users can view the resulting email in the browser in case +they are badly displayed in their clients. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +You can configure an expiration time (in hours) by going to +*Settings > Technical > System Parameters* +and changing the value for `mail_browser_view.token_expiration_hours`. + +Any zero or negative values will disable the token expiration. +Default value is 720 hours (1 month). + +Usage +===== + +Upon module installation, a secure token will be generated for each mail, +allowing it to be reached *via* a constructed URL. +You can then put the following placeholder:: + + View this mail in browser + +anywhere in your mail templates (of course, the link text can be changed). +If you use templates not managed through Odoo editor, it is strongly advised +to use the `mail_inline_style` module so the styles do not get messed up. + +Be aware that this feature will not work for templates +having "Auto-Delete" value set to `True`. +To avoid any unwanted 404 errors, all the placeholders within such templates +will be removed automatically in the generated mails. + +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Simone Orsi +* Patrick Tombez + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_browser_view/__init__.py b/mail_browser_view/__init__.py new file mode 100644 index 00000000..91c5580f --- /dev/null +++ b/mail_browser_view/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/mail_browser_view/__manifest__.py b/mail_browser_view/__manifest__.py new file mode 100644 index 00000000..b392241e --- /dev/null +++ b/mail_browser_view/__manifest__.py @@ -0,0 +1,23 @@ +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +{ + "name": "Mail Browser View", + "summary": "Add 'View this email in browser' feature", + "version": "11.0.1.0.0", + "category": "Social Network", + "website": "https://github.com/OCA/social", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "mail", + ], + "data": [ + "data/ir_config.xml", + ], + "demo": [ + "demo/mail.xml", + ] +} diff --git a/mail_browser_view/controllers/__init__.py b/mail_browser_view/controllers/__init__.py new file mode 100644 index 00000000..2ca30efb --- /dev/null +++ b/mail_browser_view/controllers/__init__.py @@ -0,0 +1 @@ +from . import browser_view diff --git a/mail_browser_view/controllers/browser_view.py b/mail_browser_view/controllers/browser_view.py new file mode 100644 index 00000000..0c35f6ff --- /dev/null +++ b/mail_browser_view/controllers/browser_view.py @@ -0,0 +1,16 @@ +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo import http +from odoo.http import request + + +class EmailBrowserViewController(http.Controller): + + @http.route(['/email/view/'], + type='http', auth='public', website=True) + def email_view(self, token, **kwargs): + record = request.env['mail.mail'].get_record_for_token(token) + if not record: + return request.not_found() + return request.make_response(record.body_html) diff --git a/mail_browser_view/data/ir_config.xml b/mail_browser_view/data/ir_config.xml new file mode 100644 index 00000000..ac1d358b --- /dev/null +++ b/mail_browser_view/data/ir_config.xml @@ -0,0 +1,7 @@ + + + + mail_browser_view.token_expiration_hours + 720 + + diff --git a/mail_browser_view/demo/mail.xml b/mail_browser_view/demo/mail.xml new file mode 100644 index 00000000..5391ce25 --- /dev/null +++ b/mail_browser_view/demo/mail.xml @@ -0,0 +1,18 @@ + + + + + + + +

Here is placeholder example:

+ View this mail in browser +

For everything else, there is Google

+ + +]]> +
+
+
+
diff --git a/mail_browser_view/models/__init__.py b/mail_browser_view/models/__init__.py new file mode 100644 index 00000000..08a6892c --- /dev/null +++ b/mail_browser_view/models/__init__.py @@ -0,0 +1 @@ +from . import mail_mail diff --git a/mail_browser_view/models/mail_mail.py b/mail_browser_view/models/mail_mail.py new file mode 100644 index 00000000..6e9be217 --- /dev/null +++ b/mail_browser_view/models/mail_mail.py @@ -0,0 +1,136 @@ +# Copyright 2018 Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, models, fields +from odoo.exceptions import MissingError +from uuid import uuid4 +from base64 import urlsafe_b64encode, urlsafe_b64decode +import binascii +import lxml +from werkzeug.urls import url_parse +from datetime import date +from dateutil.relativedelta import relativedelta + + +class Mail(models.Model): + _inherit = 'mail.mail' + + access_token = fields.Char( + 'Security Token', + compute="_compute_access_token", + store=True, readonly=True + ) + view_in_browser_url = fields.Char( + 'View URL', + compute="_compute_browser_url" + ) + is_token_alive = fields.Boolean( + "Is Token alive", + compute="_compute_token_alive" + ) + + @api.model + def create(self, vals): + rec = super(Mail, self).create(vals) + rec._replace_view_url() + return rec + + @api.model + def get_record_for_token(self, token): + """Parse the URL token to get the matching record. + + The token is a base 64 encoded string containing: + * 32 positions access token + * Record ID + Returns a record matching the token or empty recordset if not found + """ + try: + token = urlsafe_b64decode(token).decode() + access_token, rec_id = token[:32], token[32:] + rec = self.sudo().search([ + ('id', '=', int(rec_id)), + ('access_token', '=', access_token) + ]) + res = rec.is_token_alive and rec + except (ValueError, MissingError, binascii.Error): + res = False + finally: + return res or self.browse() + + @api.multi + def _get_full_url(self): + self.ensure_one() + base_url = self.env['ir.config_parameter'].sudo().get_param( + 'web.base.url') + base = url_parse(base_url) + + return url_parse( + self.view_in_browser_url or '#' + ).replace( + scheme=base.scheme, netloc=base.netloc + ).to_url() + + @api.multi + def _replace_view_url(self): + """Replace placeholders with record URL. + + Replace the 'href' attribute of all `` tags + having the 'class' attribute equal to 'view_in_browser_url' + with the URL generated for this mail.mail record + inside the rendered 'body_html' from the template. + In case the value `auto_delete` for the record is `True`, + the placeholders will be removed. + """ + self.ensure_one() + + root_html = lxml.html.fromstring(self.body_html) + link_nodes = root_html.xpath("//a[hasclass('view_in_browser_url')]") + + if link_nodes: + if self.auto_delete: + for node in link_nodes: + node.drop_tree() + else: + full_url = self._get_full_url() + for node in link_nodes: + node.set('href', full_url) + + self.body_html = lxml.html.tostring( + root_html, + pretty_print=False, + method='html', + encoding='unicode' + ) + + @api.depends('create_date') + def _compute_access_token(self): + for rec in self: + rec.access_token = uuid4().hex + + @api.depends('access_token') + def _compute_browser_url(self): + for rec in self: + url_token = urlsafe_b64encode( + (rec.access_token + str(rec.id)).encode() + ).decode() + rec.view_in_browser_url = '/email/view/{}'.format(url_token) + + @api.depends('mail_message_id', + 'mail_message_id.date') + def _compute_token_alive(self): + expiration_time = int( + self.env['ir.config_parameter'].sudo().get_param( + 'mail_browser_view.token_expiration_hours' + ) or '0' + ) + if expiration_time > 0: + max_delta = relativedelta(hours=expiration_time) + for rec in self: + mail_date = fields.Datetime.from_string( + rec.mail_message_id.date + ) + rec.is_token_alive = ( + (mail_date + max_delta).date() >= date.today() + ) + else: + self.update({'is_token_alive': True}) diff --git a/mail_browser_view/readme/CONFIGURE.rst b/mail_browser_view/readme/CONFIGURE.rst new file mode 100644 index 00000000..cabe7eef --- /dev/null +++ b/mail_browser_view/readme/CONFIGURE.rst @@ -0,0 +1,6 @@ +You can configure an expiration time (in hours) by going to +*Settings > Technical > System Parameters* +and changing the value for `mail_browser_view.token_expiration_hours`. + +Any zero or negative values will disable the token expiration. +Default value is 720 hours (1 month). diff --git a/mail_browser_view/readme/CONTRIBUTORS.rst b/mail_browser_view/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..0062f3d3 --- /dev/null +++ b/mail_browser_view/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Simone Orsi +* Patrick Tombez diff --git a/mail_browser_view/readme/DESCRIPTION.rst b/mail_browser_view/readme/DESCRIPTION.rst new file mode 100644 index 00000000..f63d009a --- /dev/null +++ b/mail_browser_view/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module enables you to add a link in your mail templates, +so the users can view the resulting email in the browser in case +they are badly displayed in their clients. diff --git a/mail_browser_view/readme/USAGE.rst b/mail_browser_view/readme/USAGE.rst new file mode 100644 index 00000000..439b8818 --- /dev/null +++ b/mail_browser_view/readme/USAGE.rst @@ -0,0 +1,14 @@ +Upon module installation, a secure token will be generated for each mail, +allowing it to be reached *via* a constructed URL. +You can then put the following placeholder:: + + View this mail in browser + +anywhere in your mail templates (of course, the link text can be changed). +If you use templates not managed through Odoo editor, it is strongly advised +to use the `mail_inline_style` module so the styles do not get messed up. + +Be aware that this feature will not work for templates +having "Auto-Delete" value set to `True`. +To avoid any unwanted 404 errors, all the placeholders within such templates +will be removed automatically in the generated mails. diff --git a/mail_browser_view/static/description/icon.png b/mail_browser_view/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/mail_browser_view/static/description/icon.png differ diff --git a/mail_browser_view/static/description/index.html b/mail_browser_view/static/description/index.html new file mode 100644 index 00000000..3d780d10 --- /dev/null +++ b/mail_browser_view/static/description/index.html @@ -0,0 +1,448 @@ + + + + + + +Mail Browser View + + + +
+

Mail Browser View

+ + +

Beta License: AGPL-3 OCA/social Translate me on Weblate Try me on Runbot

+

This module enables you to add a link in your mail templates, +so the users can view the resulting email in the browser in case +they are badly displayed in their clients.

+

Table of contents

+ +
+

Configuration

+

You can configure an expiration time (in hours) by going to +Settings > Technical > System Parameters +and changing the value for mail_browser_view.token_expiration_hours.

+

Any zero or negative values will disable the token expiration. +Default value is 720 hours (1 month).

+
+
+

Usage

+

Upon module installation, a secure token will be generated for each mail, +allowing it to be reached via a constructed URL. +You can then put the following placeholder:

+
+<a href="#" class="view_in_browser_url">View this mail in browser</a>
+
+

anywhere in your mail templates (of course, the link text can be changed). +If you use templates not managed through Odoo editor, it is strongly advised +to use the mail_inline_style module so the styles do not get messed up.

+

Be aware that this feature will not work for templates +having “Auto-Delete” value set to True. +To avoid any unwanted 404 errors, all the placeholders within such templates +will be removed automatically in the generated mails.

+
+
+

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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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.

+

This module is part of the OCA/social project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/mail_browser_view/tests/__init__.py b/mail_browser_view/tests/__init__.py new file mode 100644 index 00000000..f69a6bb8 --- /dev/null +++ b/mail_browser_view/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mail_browser_view diff --git a/mail_browser_view/tests/test_mail_browser_view.py b/mail_browser_view/tests/test_mail_browser_view.py new file mode 100644 index 00000000..2a78381c --- /dev/null +++ b/mail_browser_view/tests/test_mail_browser_view.py @@ -0,0 +1,95 @@ +# Copyright 2018 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html) + +from odoo.tests.common import SavepointCase +from odoo import fields +from base64 import urlsafe_b64encode +from datetime import datetime +from lxml import html +from werkzeug.urls import url_parse +from ..controllers.browser_view import EmailBrowserViewController +from mock import patch + + +class MailBrowserView(SavepointCase): + + @classmethod + def setUpClass(cls): + super(MailBrowserView, cls).setUpClass() + cls.mail = cls.env['mail.mail'] + cls.mail0 = cls.env.ref('mail_browser_view.browser_view_demo') + cls.valid_token = cls.mail0.view_in_browser_url.split('/')[-1] + + def _forge_token(self, access_token, rec_id): + return urlsafe_b64encode( + (access_token + str(rec_id)).encode() + ).decode() + + def _test_token(self, token, expected_result): + rec = self.mail.get_record_for_token(token) + self.assertEqual(rec, expected_result) + + def test_mail_browser_view(self): + self._test_token(self.valid_token, self.mail0) + + def test_invalid_b64(self): + self._test_token(self.valid_token[::2], self.mail) + + def test_invalid_access_token(self): + bad_token = self._forge_token('0000000', self.mail0.id) + self._test_token(bad_token, self.mail) + + def test_nonexistent_id(self): + bad_token = self._forge_token(self.mail0.access_token, 999999) + self._test_token(bad_token, self.mail) + + def test_token_expiration(self): + self.mail0.mail_message_id.date = fields.Datetime.to_string( + datetime.fromtimestamp(0) + ) + self._test_token(self.valid_token, self.mail) + + self.env.ref('mail_browser_view.token_expiration_hours').value = '0' + self.mail0.refresh() + self._test_token(self.valid_token, self.mail0) + + def test_html_render(self): + html_node = html.fromstring(self.mail0.body_html) + link_node = html_node.xpath("//a[hasclass('view_in_browser_url')]") + self.assertEqual( + url_parse(link_node[0].get('href')).path, + self.mail0.view_in_browser_url + ) + + self.mail0.auto_delete = True + self.mail0._replace_view_url() + self.mail0.refresh() + html_node = html.fromstring(self.mail0.body_html) + + link_node = html_node.xpath("//a[hasclass('view_in_browser_url')]") + self.assertEqual(link_node, []) + + link_node = html_node.xpath( + "//a[not(hasclass('view_in_browser_url'))]" + ) + self.assertEqual(len(link_node), 1) + self.assertEqual(link_node[0].get('href'), 'https://www.google.com') + + p_node = html_node.xpath("//p") + self.assertEqual(len(p_node), 2) + + @patch('odoo.addons.mail_browser_view.' + 'controllers.browser_view.request') + def test_controller(self, req): + # Mock + req.env = self.env + controller = EmailBrowserViewController() + + controller.email_view(self.valid_token[::2]) + req.not_found.assert_called_once_with() + req.make_response.assert_not_called() + req.reset_mock() + + controller.email_view(self.valid_token) + req.not_found.assert_not_called() + req.make_response.assert_called_with(self.mail0.body_html)