[IMP] digest: ease the way people can unsubscribe from a digest

Ease the way people can unsubscribe from a digest by adding information
to the email enabling a user agent to present a button to the user that
automate the unsubscription.

Technical notes:
To ease the way people can unsubscribe from a digest, we have followed the
advice of rfc8058 by adding the following header to the email:
- List-Unsubscribe: https_URL_to_unsubscribe
- List-Unsubscribe-Post: List-Unsubscribe=One-Click

The https_URL_to_unsubscribe
- must work with the POST method but not the GET. The GET version is disabled
to avoid unintended unsubscription that could be triggered by an anti-spam
accessing the URLs in the headers.
- must unsubscribe the user without any additional steps (one click)
- should contain an opaque identifier hard-to-forge to identify the list and
the user (our token fulfills that goal). The goal is to avoid that someone
could unregister someone else easily.
- must fulfill other conditions like no cookie, no redirection, ...

It requires also that List-Unsubscribe and List-Unsubscribe-Post be covered by
a DKIM signature.

Task-2800499

closes odoo/odoo#86934

Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
This commit is contained in:
Pierre-Yves Dufays
2022-09-20 11:33:43 +02:00
committed by Thibault Delavallée
parent 1ce603366e
commit e1f86d3d80
4 changed files with 84 additions and 18 deletions
+19 -2
View File
@@ -11,8 +11,25 @@ from odoo.tools import consteq
class DigestController(Controller):
@route('/digest/<int:digest_id>/unsubscribe', type='http', website=True, auth='public')
def digest_unsubscribe(self, digest_id, token=None, user_id=None):
# csrf is disabled here because it will be called by the MUA with unpredictable session at that time
@route('/digest/<int:digest_id>/unsubscribe', type='http', website=True, auth='public', methods=['GET', 'POST'],
csrf=False)
def digest_unsubscribe(self, digest_id, token=None, user_id=None, one_click=None):
""" Unsubscribe a given user from a given digest
:param int digest_id: id of digest to unsubscribe from
:param str token: token preventing URL forgery
:param user_id: id of user to unsubscribe
:param int one_click: set it to 1 when using the URL in the header of
the email to allow mail user agent to propose a one click button to the
user to unsubscribe as defined in rfc8058. When set to True, only POST
method is allowed preventing the risk that anti-spam trigger unwanted
unsubscribe (scenario explained in the same rfc). Note: this method
must support encoding method 'multipart/form-data' and 'application/x-www-form-urlencoded'.
"""
if one_click and int(one_click) and request.httprequest.method != "POST":
raise Forbidden()
digest_sudo = request.env['digest.digest'].sudo().browse(digest_id).exists()
# new route parameters
+36 -7
View File
@@ -7,6 +7,7 @@ import pytz
from datetime import datetime, date
from dateutil.relativedelta import relativedelta
from markupsafe import Markup
from werkzeug.urls import url_join
from odoo import api, fields, models, tools, _
from odoo.addons.base.models.ir_mail_server import MailDeliveryException
@@ -113,18 +114,38 @@ class Digest(models.Model):
self.periodicity = periodicity
def action_send(self):
to_slowdown = self._check_daily_logs()
""" Send digests emails to all the registered users. """
return self._action_send(update_periodicity=True)
def action_send_manual(self):
""" Manually send digests emails to all registered users. In that case
do not update periodicity as this is not an automated action that could
be considered as unwanted spam. """
return self._action_send(update_periodicity=False)
def _action_send(self, update_periodicity=True):
""" Send digests email to all the registered users.
:param bool update_periodicity: if True, check user logs to update
periodicity of digests. Purpose is to slow down digest whose users
do not connect to avoid spam;
"""
to_slowdown = self._check_daily_logs() if update_periodicity else self.env['digest.digest']
for digest in self:
for user in digest.user_ids:
digest.with_context(
digest_slowdown=digest in to_slowdown,
lang=user.lang
)._action_send_to_user(user, tips_count=1)
if digest in to_slowdown:
digest.write({'periodicity': self._get_next_periodicity()[0]})
digest.next_run_date = digest._get_next_run_date()
def _action_send_to_user(self, user, tips_count=1, consum_tips=True):
for digest in to_slowdown:
digest.periodicity = digest._get_next_periodicity()[0]
def _action_send_to_user(self, user, tips_count=1, consume_tips=True):
unsubscribe_token = self._get_unsubscribe_token(user.id)
rendered_body = self.env['mail.render.mixin']._render_template(
'digest.digest_mail_main',
'digest.digest',
@@ -136,12 +157,12 @@ class Digest(models.Model):
'top_button_url': self.get_base_url(),
'company': user.company_id,
'user': user,
'unsubscribe_token': self._get_unsubscribe_token(user.id),
'unsubscribe_token': unsubscribe_token,
'tips_count': tips_count,
'formatted_date': datetime.today().strftime('%B %d, %Y'),
'display_mobile_banner': True,
'kpi_data': self._compute_kpis(user.company_id, user),
'tips': self._compute_tips(user.company_id, user, tips_count=tips_count, consumed=consum_tips),
'tips': self._compute_tips(user.company_id, user, tips_count=tips_count, consumed=consume_tips),
'preferences': self._compute_preferences(user.company_id, user),
},
post_process=True,
@@ -156,16 +177,24 @@ class Digest(models.Model):
},
)
# create a mail_mail based on values, without attachments
unsub_url = url_join(self.get_base_url(),
f'/digest/{self.id}/unsubscribe?token={unsubscribe_token}&user_id={user.id}&one_click=1')
mail_values = {
'auto_delete': True,
'author_id': self.env.user.partner_id.id,
'body_html': full_mail,
'email_from': (
self.company_id.partner_id.email_formatted
or self.env.user.email_formatted
or self.env.ref('base.user_root').email_formatted
),
'email_to': user.email_formatted,
'body_html': full_mail,
# Add headers that allow the MUA to offer a one click button to unsubscribe (requires DKIM to work)
'headers': {
'List-Unsubscribe': f'<{unsub_url}>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
'X-Auto-Response-Suppress': 'OOF', # avoid out-of-office replies from MS Exchange
},
'state': 'outgoing',
'subject': '%s: %s' % (user.company_id.name, self.name),
}
+28 -8
View File
@@ -6,7 +6,7 @@ import random
from dateutil.relativedelta import relativedelta
from lxml import html
from werkzeug.urls import url_encode
from werkzeug.urls import url_encode, url_join
from odoo import fields, SUPERUSER_ID
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
@@ -256,6 +256,24 @@ class TestUnsubscribe(HttpCaseWithUserDemo):
self.test_digest.invalidate_recordset()
self.assertNotIn(self.user_demo, self.test_digest.user_ids)
def test_unsubscribe_token_one_click(self):
self.assertIn(self.user_demo, self.test_digest.user_ids)
self.authenticate(None, None)
# Ensure we cannot unregister using GET method (method not allowed)
response = self._url_unsubscribe(token=self.user_demo_unsubscribe_token, user_id=self.user_demo.id,
one_click='1', method='GET')
self.assertEqual(response.status_code, 403, 'GET method is forbidden')
self.test_digest.invalidate_recordset()
self.assertIn(self.user_demo, self.test_digest.user_ids)
# Ensure we can unregister with POST method
response = self._url_unsubscribe(token=self.user_demo_unsubscribe_token, user_id=self.user_demo.id,
one_click='1', method='POST')
self.assertEqual(response.status_code, 200)
self.test_digest.invalidate_recordset()
self.assertNotIn(self.user_demo, self.test_digest.user_ids)
def test_unsubscribe_public(self):
""" Check public users are redirected when trying to catch unsubscribe
route. """
@@ -264,16 +282,18 @@ class TestUnsubscribe(HttpCaseWithUserDemo):
response = self._url_unsubscribe()
self.assertEqual(response.status_code, 404)
def _url_unsubscribe(self, token=None, user_id=None):
def _url_unsubscribe(self, token=None, user_id=None, one_click=None, method='GET'):
url_params = {}
if token is not None:
url_params['token'] = token
if user_id is not None:
url_params['user_id'] = user_id
if one_click is not None:
url_params['one_click'] = one_click
url = "%s/digest/%s/unsubscribe?%s" % (
self.base_url,
self.test_digest.id,
url_encode(url_params)
)
return self.url_open(url)
url = url_join(self.base_url, f'digest/{self.test_digest.id}/unsubscribe?{url_encode(url_params)}')
if method == 'GET':
return self.opener.get(url, timeout=10, allow_redirects=True)
if method == 'POST':
return self.opener.post(url, timeout=10, allow_redirects=True)
raise Exception(f'Invalid method {method}')
+1 -1
View File
@@ -20,7 +20,7 @@
<form string="KPI Digest">
<field name="is_subscribed" invisible="1"/>
<header>
<button type="object" name="action_send" string="Send Now"
<button type="object" name="action_send_manual" string="Send Now"
class="oe_highlight"
attrs="{'invisible': [('state','=','deactivated')]}" groups="base.group_system"/>
<button type="object" name="action_deactivate" string="Deactivate"