From 23dc578be7cf7e7574851a8e69ff57b506712510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 22 May 2024 15:24:37 +0200 Subject: [PATCH] [IMP] mass_mailing: add unsubscribe headers in mailing emails Globally a backport of odoo/odoo@c0775de8d0a4977446e9eb0e9f92570c73951e98 where mail sent through mass mailing now have unsubscribe headers set. This requires a small update in the send method to allow email-specific headers; previously to this commit only generic headers from the mail.mail were considered. With some fixes, notably csrf is disabled on unsubscribe route as it is now called directly as one-click, without form and csrf. This is necessary notably to allow famous email readers to include links directly in their UI. Task-3932001 (Mail: Unsubscribe Headers Everywhere) --- addons/mail/models/mail_mail.py | 12 +++- addons/mass_mailing/controllers/main.py | 4 +- addons/mass_mailing/models/mail_mail.py | 33 +++++++--- .../tests/test_mailing_internals.py | 61 ++++++++++++++++++- 4 files changed, 98 insertions(+), 12 deletions(-) diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 2e32ce22451dd..52e551a5f5161 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -368,6 +368,16 @@ def _send(self, auto_commit=False, raise_exception=False, smtp_session=None): # build an RFC2822 email.message.Message object and send it without queuing res = None for email in email_list: + # support headers specific to the specific outgoing email + if email.get('headers'): + email_headers = headers.copy() + try: + email_headers.update(email.get('headers')) + except Exception: + pass + else: + email_headers = headers + msg = IrMailServer.build_email( email_from=email_from, email_to=email.get('email_to'), @@ -382,7 +392,7 @@ def _send(self, auto_commit=False, raise_exception=False, smtp_session=None): object_id=mail.res_id and ('%s-%s' % (mail.res_id, mail.model)), subtype='html', subtype_alternative='plain', - headers=headers) + headers=email_headers) processing_pid = email.pop("partner_id", None) try: res = IrMailServer.send_email( diff --git a/addons/mass_mailing/controllers/main.py b/addons/mass_mailing/controllers/main.py index ec0798634c827..b0bce066a2224 100644 --- a/addons/mass_mailing/controllers/main.py +++ b/addons/mass_mailing/controllers/main.py @@ -28,7 +28,9 @@ def unsubscribe_placeholder_link(self, **post): """Dummy route so placeholder is not prefixed by language, MUST have multilang=False""" raise werkzeug.exceptions.NotFound() - @http.route(['/mail/mailing//unsubscribe'], type='http', website=True, auth='public') + # csrf is disabled here because it will be called by the MUA with unpredictable session at that time + @http.route(['/mail/mailing//unsubscribe'], type='http', website=True, auth='public', + csrf=False) def mailing(self, mailing_id, email=None, res_id=None, token="", **post): mailing = request.env['mailing.mailing'].sudo().browse(mailing_id) if mailing.exists(): diff --git a/addons/mass_mailing/models/mail_mail.py b/addons/mass_mailing/models/mail_mail.py index 4339dfdf6bce2..47e0a1102cac4 100644 --- a/addons/mass_mailing/models/mail_mail.py +++ b/addons/mass_mailing/models/mail_mail.py @@ -63,18 +63,33 @@ def _send_prepare_values(self, partner=None): # TDE: temporary addition (mail was parameter) due to semi-new-API res = super(MailMail, self)._send_prepare_values(partner) base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url').rstrip('/') - if self.mailing_id and res.get('body') and res.get('email_to'): + if self.mailing_id and res.get('email_to'): emails = tools.email_split(res.get('email_to')[0]) email_to = emails and emails[0] or False - urls_to_replace = [ - (base_url + '/unsubscribe_from_list', self.mailing_id._get_unsubscribe_url(email_to, self.res_id)), - (base_url + '/view', self.mailing_id._get_view_url(email_to, self.res_id)) - ] - - for url_to_replace, new_url in urls_to_replace: - if url_to_replace in res['body']: - res['body'] = res['body'].replace(url_to_replace, new_url if new_url else '#') + unsubscribe_url = self.mailing_id._get_unsubscribe_url(email_to, self.res_id) + view_url = self.mailing_id._get_view_url(email_to, self.res_id) + + # replace links in body + if not tools.is_html_empty(res.get('body')): + if f'{base_url}/unsubscribe_from_list' in res['body']: + res['body'] = res['body'].replace( + f'{base_url}/unsubscribe_from_list', + unsubscribe_url, + ) + if f'{base_url}/view' in res.get('body'): + res['body'] = res['body'].replace( + f'{base_url}/view', + view_url, + ) + + # add headers + res.setdefault("headers", {}).update({ + 'List-Unsubscribe': f'<{unsubscribe_url}>', + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + 'Precedence': 'list', + 'X-Auto-Response-Suppress': 'OOF', # avoid out-of-office replies from MS Exchange + }) return res def _postprocess_sent_message(self, success_pids, failure_reason=False, failure_type=None): diff --git a/addons/mass_mailing/tests/test_mailing_internals.py b/addons/mass_mailing/tests/test_mailing_internals.py index 2107b7c2d2d92..a4d70c3fbc716 100644 --- a/addons/mass_mailing/tests/test_mailing_internals.py +++ b/addons/mass_mailing/tests/test_mailing_internals.py @@ -4,7 +4,7 @@ from ast import literal_eval from odoo.addons.mass_mailing.tests.common import MassMailCommon -from odoo.tests.common import users, Form, tagged +from odoo.tests.common import users, Form, HttpCase, tagged from odoo.tools import formataddr, mute_logger @@ -333,3 +333,62 @@ def test_mailing_shortener(self): link_info, link_params=link_params, ) + + +@tagged("mail_mail") +class TestMailingHeaders(MassMailCommon, HttpCase): + """ Test headers + linked controllers """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_mailing_list() + cls.test_mailing = cls.env['mailing.mailing'].with_user(cls.user_marketing).create({ + "body_html": """ +

Hello + UNSUBSCRIBE + VIEW +

""", + "contact_list_ids": [(4, cls.mailing_list_1.id)], + "mailing_model_id": cls.env["ir.model"]._get("mailing.list").id, + "mailing_type": "mail", + "name": "TestMailing", + "subject": "Test for {{ object.name }}", + }) + + @users('user_marketing') + def test_mailing_unsubscribe_headers(self): + """ Check unsubscribe headers are present in outgoing emails and work + as one-click """ + test_mailing = self.test_mailing.with_env(self.env) + test_mailing.action_put_in_queue() + + with self.mock_mail_gateway(mail_unlink_sent=False): + test_mailing.action_send_mail() + + for contact in self.mailing_list_1.contact_ids: + new_mail = self._find_mail_mail_wrecord(contact) + # check mail.mail still have default links + self.assertIn("/unsubscribe_from_list", new_mail.body) + self.assertIn("/view", new_mail.body) + + # check outgoing email headers (those are put into outgoing email + # not in the mail.mail record) + email = self._find_sent_mail_wemail(contact.email) + headers = email.get("headers") + unsubscribe_url = test_mailing._get_unsubscribe_url(contact.email, contact.id) + self.assertTrue(headers, "Mass mailing emails should have headers for unsubscribe") + self.assertEqual(headers.get("List-Unsubscribe"), f"<{unsubscribe_url}>") + self.assertEqual(headers.get("List-Unsubscribe-Post"), "List-Unsubscribe=One-Click") + self.assertEqual(headers.get("Precedence"), "list") + + # check outgoing email has real links + view_url = test_mailing._get_view_url(contact.email, contact.id) + self.assertNotIn("/unsubscribe_from_list", email["body"]) + + # unsubscribe in one-click + unsubscribe_url = headers["List-Unsubscribe"].strip("<>") + self.opener.post(unsubscribe_url) + + # should be unsubscribed + self.assertTrue(contact.subscription_list_ids.opt_out)