From f57c83eaf7e7156497e44600045d2e1b4466a2cc Mon Sep 17 00:00:00 2001 From: LauraCForgeFlow Date: Tue, 9 Jun 2026 10:26:19 +0200 Subject: [PATCH] [ADD] mail_restrict_access_button --- mail_restrict_access_button/README.rst | 91 ++++ mail_restrict_access_button/__init__.py | 1 + mail_restrict_access_button/__manifest__.py | 15 + .../models/__init__.py | 1 + .../models/mail_thread.py | 39 ++ mail_restrict_access_button/pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 1 + .../readme/DESCRIPTION.md | 16 + .../static/description/index.html | 435 ++++++++++++++++++ mail_restrict_access_button/tests/__init__.py | 1 + .../tests/test_mail_restrict_access_button.py | 166 +++++++ 11 files changed, 769 insertions(+) create mode 100644 mail_restrict_access_button/README.rst create mode 100644 mail_restrict_access_button/__init__.py create mode 100644 mail_restrict_access_button/__manifest__.py create mode 100644 mail_restrict_access_button/models/__init__.py create mode 100644 mail_restrict_access_button/models/mail_thread.py create mode 100644 mail_restrict_access_button/pyproject.toml create mode 100644 mail_restrict_access_button/readme/CONTRIBUTORS.md create mode 100644 mail_restrict_access_button/readme/DESCRIPTION.md create mode 100644 mail_restrict_access_button/static/description/index.html create mode 100644 mail_restrict_access_button/tests/__init__.py create mode 100644 mail_restrict_access_button/tests/test_mail_restrict_access_button.py diff --git a/mail_restrict_access_button/README.rst b/mail_restrict_access_button/README.rst new file mode 100644 index 000000000..7ef9079fd --- /dev/null +++ b/mail_restrict_access_button/README.rst @@ -0,0 +1,91 @@ +=========================== +Mail Restrict Access Button +=========================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:df4530293527760b0a34870365e162d80fa49666e0fe945235bda915f8937888 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fmail-lightgray.png?logo=github + :target: https://github.com/OCA/mail/tree/18.0/mail_restrict_access_button + :alt: OCA/mail +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/mail-18-0/mail-18-0-mail_restrict_access_button + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/mail&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Odoo notification emails sent to non-internal recipients can include an +access button, granting access to the Odoo instance and the related +document without logging in. + +This module restricts that access button so it is only shown to +recipients who have a user account (internal users or portal users) and +never to unregistered external partners. These external recipients will +still receive the email, but the button will be removed. + +This logic applies dynamically across all recipient groups, including +mixed ones (portal_customer) that contain both types of contacts. + +A registered recipient in a mixed group keeps the button (and the +group), while an unregistered one in that same context is automatically +moved to the next buttonless group or the fallback group (buttonless +too). + +**Table of contents** + +.. contents:: + :local: + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- Laura Cazorla <> + +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/mail `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_restrict_access_button/__init__.py b/mail_restrict_access_button/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/mail_restrict_access_button/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/mail_restrict_access_button/__manifest__.py b/mail_restrict_access_button/__manifest__.py new file mode 100644 index 000000000..617ed00ac --- /dev/null +++ b/mail_restrict_access_button/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Mail Restrict Access Button", + "version": "18.0.1.0.0", + "category": "Discuss", + "license": "AGPL-3", + "summary": "Show the notification access button only to recipients with " + "a user account (internal or portal), never to unregistered partners.", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/mail", + "depends": ["mail"], + "installable": True, +} diff --git a/mail_restrict_access_button/models/__init__.py b/mail_restrict_access_button/models/__init__.py new file mode 100644 index 000000000..b70a9f2d0 --- /dev/null +++ b/mail_restrict_access_button/models/__init__.py @@ -0,0 +1 @@ +from . import mail_thread diff --git a/mail_restrict_access_button/models/mail_thread.py b/mail_restrict_access_button/models/mail_thread.py new file mode 100644 index 000000000..f2298b9bb --- /dev/null +++ b/mail_restrict_access_button/models/mail_thread.py @@ -0,0 +1,39 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class MailThread(models.AbstractModel): + _inherit = "mail.thread" + + def _notify_get_recipients_groups_fillup( + self, groups, model_description, msg_vals=None + ): + groups = groups + [ + [ + "unregistered_external", + lambda recipient: recipient["type"] == "customer", + {"has_button_access": False}, + ] + ] + groups = super()._notify_get_recipients_groups_fillup( + groups, model_description, msg_vals=msg_vals + ) + for index, (name, matches_recipient, group_data) in enumerate(groups): + if group_data.get("has_button_access") and group_data.get("active"): + groups[index] = [ + name, + self._without_unregistered(matches_recipient), + group_data, + ] + return groups + + @staticmethod + def _without_unregistered(matches_recipient): + # Wrap a group's matching conditions so it never matches unregistered + # external partners (without user account) + def predicate(recipient): + return matches_recipient(recipient) and recipient["type"] != "customer" + + return predicate diff --git a/mail_restrict_access_button/pyproject.toml b/mail_restrict_access_button/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/mail_restrict_access_button/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/mail_restrict_access_button/readme/CONTRIBUTORS.md b/mail_restrict_access_button/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..dbb3f590c --- /dev/null +++ b/mail_restrict_access_button/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Laura Cazorla \<\<\>\> diff --git a/mail_restrict_access_button/readme/DESCRIPTION.md b/mail_restrict_access_button/readme/DESCRIPTION.md new file mode 100644 index 000000000..83d4ae62d --- /dev/null +++ b/mail_restrict_access_button/readme/DESCRIPTION.md @@ -0,0 +1,16 @@ +Odoo notification emails sent to non-internal recipients can include an +access button, granting access to the Odoo instance and the related +document without logging in. + +This module restricts that access button so it is only shown to +recipients who have a user account (internal users or portal users) and +never to unregistered external partners. These external recipients will +still receive the email, but the button will be removed. + +This logic applies dynamically across all recipient groups, including +mixed ones (portal_customer) that contain both types of contacts. + +A registered recipient in a mixed group keeps the button (and the +group), while an unregistered one in that same context is automatically +moved to the next buttonless group or the fallback group (buttonless +too). diff --git a/mail_restrict_access_button/static/description/index.html b/mail_restrict_access_button/static/description/index.html new file mode 100644 index 000000000..27d870977 --- /dev/null +++ b/mail_restrict_access_button/static/description/index.html @@ -0,0 +1,435 @@ + + + + + +Mail Restrict Access Button + + + +
+

Mail Restrict Access Button

+ + +

Beta License: AGPL-3 OCA/mail Translate me on Weblate Try me on Runboat

+

Odoo notification emails sent to non-internal recipients can include an +access button, granting access to the Odoo instance and the related +document without logging in.

+

This module restricts that access button so it is only shown to +recipients who have a user account (internal users or portal users) and +never to unregistered external partners. These external recipients will +still receive the email, but the button will be removed.

+

This logic applies dynamically across all recipient groups, including +mixed ones (portal_customer) that contain both types of contacts.

+

A registered recipient in a mixed group keeps the button (and the +group), while an unregistered one in that same context is automatically +moved to the next buttonless group or the fallback group (buttonless +too).

+

Table of contents

+ +
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

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/mail project on GitHub.

+

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

+
+
+
+ + diff --git a/mail_restrict_access_button/tests/__init__.py b/mail_restrict_access_button/tests/__init__.py new file mode 100644 index 000000000..e49d2a492 --- /dev/null +++ b/mail_restrict_access_button/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mail_restrict_access_button diff --git a/mail_restrict_access_button/tests/test_mail_restrict_access_button.py b/mail_restrict_access_button/tests/test_mail_restrict_access_button.py new file mode 100644 index 000000000..8c8b7b9b6 --- /dev/null +++ b/mail_restrict_access_button/tests/test_mail_restrict_access_button.py @@ -0,0 +1,166 @@ +# Copyright 2026 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest.mock import patch + +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestMailRestrictAccessButton(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.record = cls.env.ref("base.main_partner") + + def _base_groups(self): + original_groups = self.record._notify_get_recipients_groups( + self.env["mail.message"], + False, + ) + for group in original_groups: + group[2]["active"] = True + main_customer_group = [ + "main_customer", + lambda r: r["id"] == 1, + {"has_button_access": True, "active": True}, + ] + return [main_customer_group] + original_groups + + def _pdata(self, rtype, is_follower=False): + return [ + { + "id": 1, + "uid": False if rtype == "customer" else 1, + "type": rtype, + "is_follower": is_follower, + "active": True, + } + ] + + def test_unregistered_customer_has_no_button(self): + # Unregistered customer does not have button access + with patch.object( + type(self.record), + "_notify_get_recipients_groups", + return_value=self._base_groups(), + ): + group_data = self.record._notify_get_recipients_classify( + False, self._pdata("customer"), "Document" + ) + self.assertEqual(len(group_data), 1) + name = group_data[0].get("notification_group_name") + has_button_access = group_data[0].get("has_button_access") + self.assertEqual(name, "customer") + self.assertFalse(has_button_access) + + def test_registered_portal_user_keeps_button(self): + # Portal recipient has button access even in main_customer group + with patch.object( + type(self.record), + "_notify_get_recipients_groups", + return_value=self._base_groups(), + ): + group_data = self.record._notify_get_recipients_classify( + False, self._pdata("portal"), "Document" + ) + self.assertEqual(len(group_data), 1) + name = group_data[0].get("notification_group_name") + has_button_access = group_data[0].get("has_button_access") + self.assertEqual(name, "main_customer") + self.assertTrue(has_button_access) + + def test_internal_user_keeps_button(self): + # Internal user recipient has button access even in main_customer group + with patch.object( + type(self.record), + "_notify_get_recipients_groups", + return_value=self._base_groups(), + ): + group_data = self.record._notify_get_recipients_classify( + False, self._pdata("user"), "Document" + ) + self.assertEqual(len(group_data), 1) + name = group_data[0].get("notification_group_name") + has_button_access = group_data[0].get("has_button_access") + self.assertEqual(name, "main_customer") + self.assertTrue(has_button_access) + + def test_unregistered_filter_applies_to_any_button_group(self): + # Unregistered follower lands in follower because it has no button + base_groups = self._base_groups() + with patch.object( + type(self.record), "_notify_get_recipients_groups", return_value=base_groups + ): + group_data = self.record._notify_get_recipients_classify( + False, self._pdata("customer", is_follower=True), "Document" + ) + self.assertEqual(len(group_data), 1) + name = group_data[0].get("notification_group_name") + has_button_access = group_data[0].get("has_button_access") + self.assertEqual(name, "follower") + self.assertFalse(has_button_access) + # Registered portal user matches main_customer and keeps their button + base_groups = self._base_groups() + with patch.object( + type(self.record), "_notify_get_recipients_groups", return_value=base_groups + ): + group_data = self.record._notify_get_recipients_classify( + False, self._pdata("portal", is_follower=True), "Document" + ) + self.assertEqual(len(group_data), 1) + name = group_data[0].get("notification_group_name") + has_button_access = group_data[0].get("has_button_access") + self.assertEqual(name, "main_customer") + self.assertTrue(has_button_access) + # Modify 'follower' to have a button + # Unregistered user must now be blocked and drop to customer + base_groups = self._base_groups() + next(g for g in base_groups if g[0] == "follower")[2]["has_button_access"] = ( + True + ) + with patch.object( + type(self.record), "_notify_get_recipients_groups", return_value=base_groups + ): + group_data = self.record._notify_get_recipients_classify( + False, self._pdata("customer", is_follower=True), "Document" + ) + self.assertEqual(len(group_data), 1) + name = group_data[0].get("notification_group_name") + has_button_access = group_data[0].get("has_button_access") + self.assertEqual(name, "customer") + self.assertFalse(has_button_access) + # Modifying follower doesn't block the registered portal user + base_groups = self._base_groups() + next(g for g in base_groups if g[0] == "follower")[2]["has_button_access"] = ( + True + ) + with patch.object( + type(self.record), "_notify_get_recipients_groups", return_value=base_groups + ): + group_data = self.record._notify_get_recipients_classify( + False, self._pdata("portal", is_follower=True), "Document" + ) + self.assertEqual(len(group_data), 1) + name = group_data[0].get("notification_group_name") + has_button_access = group_data[0].get("has_button_access") + self.assertEqual(name, "main_customer") + self.assertTrue(has_button_access) + + def test_unregistered_classified_when_every_group_has_a_button(self): + # If every group is turned into a button group, the unregistered + # customer still matches the buttonless fallback group + base_groups = self._base_groups() + for group in base_groups: + group[2]["has_button_access"] = True + with patch.object( + type(self.record), "_notify_get_recipients_groups", return_value=base_groups + ): + group_data = self.record._notify_get_recipients_classify( + False, self._pdata("customer"), "Document" + ) + self.assertEqual(len(group_data), 1) + name = group_data[0].get("notification_group_name") + has_button_access = group_data[0].get("has_button_access") + self.assertEqual(name, "unregistered_external") + self.assertFalse(has_button_access)