diff --git a/mail_recipient_blocklist/README.rst b/mail_recipient_blocklist/README.rst new file mode 100644 index 000000000..2dcaca1c5 --- /dev/null +++ b/mail_recipient_blocklist/README.rst @@ -0,0 +1,132 @@ +======================== +Mail Recipient Blocklist +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:92ada95dc311d150c4053c599f2f1ab2bb8dda677eab30715d89b4bafb33d4aa + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_recipient_blocklist + :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_recipient_blocklist + :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| + +Blocks outgoing emails addressed to recipients matching configurable +block rules. Rules can match exact emails, domains, contained text, +wildcard patterns or regular expressions. The block is enforced both on +queued Odoo emails (``mail.mail``) and on direct SMTP deliveries +(``ir.mail_server``). + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +To configure blocked recipients: + +1. Go to **Settings > Technical > Email > Recipient Block Rules**. + + |Recipient block rule menu| + +2. Create a new rule or edit an existing one. For each rule, set a name, + choose the pattern type, define the pattern, and keep the rule + active. + + |Recipient block rule form| + +3. Choose the pattern type according to the recipients you need to + block: + + - **Exact email**: matches one complete email address. + - **Domain**: matches all recipients in a domain. + - **Contains**: matches recipients containing the configured text. + - **Wildcard**: supports shell-style wildcards such as + ``*@example.com``. + - **Regular expression**: supports Python regular expressions. + +4. Save the rule. Outgoing emails are filtered before delivery. If all + recipients are blocked, the email is cancelled. If only some + recipients are blocked, Odoo sends the email to the remaining allowed + recipients. + + |Recipient block rule result| + +A default rule blocks Amazon Marketplace aliases matching +``@marketplace.amazon.``. + +Archived rules are ignored. If all matching rules are archived, Odoo +will use its standard outgoing mail flow for those recipients. + +.. |Recipient block rule menu| image:: https://raw.githubusercontent.com/OCA/mail/18.0/mail_recipient_blocklist/static/description/recipient_block_rule_menu.png +.. |Recipient block rule form| image:: https://raw.githubusercontent.com/OCA/mail/18.0/mail_recipient_blocklist/static/description/recipient_block_rule_form.png +.. |Recipient block rule result| image:: https://raw.githubusercontent.com/OCA/mail/18.0/mail_recipient_blocklist/static/description/recipient_block_rule_result.png + +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 +------- + +* Tecnativa + +Contributors +------------ + +- `Tecnativa `__: + + - Sergio Teruel + +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. + +.. |maintainer-sergio-teruel| image:: https://github.com/sergio-teruel.png?size=40px + :target: https://github.com/sergio-teruel + :alt: sergio-teruel + +Current `maintainer `__: + +|maintainer-sergio-teruel| + +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_recipient_blocklist/__init__.py b/mail_recipient_blocklist/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/mail_recipient_blocklist/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/mail_recipient_blocklist/__manifest__.py b/mail_recipient_blocklist/__manifest__.py new file mode 100644 index 000000000..787cf7334 --- /dev/null +++ b/mail_recipient_blocklist/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Tecnativa - Sergio Teruel +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Mail Recipient Blocklist", + "summary": "Block outgoing emails by configurable recipient patterns", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "author": "Tecnativa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/mail", + "category": "Discuss", + "depends": ["mail"], + "data": [ + "security/ir.model.access.csv", + "data/mail_recipient_block_rule_data.xml", + "views/mail_recipient_block_rule_views.xml", + ], + "maintainers": ["sergio-teruel"], + "application": False, + "installable": True, +} diff --git a/mail_recipient_blocklist/data/mail_recipient_block_rule_data.xml b/mail_recipient_blocklist/data/mail_recipient_block_rule_data.xml new file mode 100644 index 000000000..6ea8033f1 --- /dev/null +++ b/mail_recipient_blocklist/data/mail_recipient_block_rule_data.xml @@ -0,0 +1,16 @@ + + + + Amazon Marketplace + 10 + contains + @marketplace.amazon. + + Blocks Amazon marketplace alias addresses such as + abc123@marketplace.amazon.es. + + + diff --git a/mail_recipient_blocklist/i18n/es.po b/mail_recipient_blocklist/i18n/es.po new file mode 100644 index 000000000..67dd3fb0e --- /dev/null +++ b/mail_recipient_blocklist/i18n/es.po @@ -0,0 +1,182 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_recipient_blocklist +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-02 15:47+0000\n" +"PO-Revision-Date: 2026-07-02 17:53+0200\n" +"Last-Translator: Sergio Teruel \n" +"Language-Team: \n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.4.2\n" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__active +msgid "Active" +msgstr "Activo" + +#. module: mail_recipient_blocklist +#: model:mail.recipient.block.rule,name:mail_recipient_blocklist.mail_recipient_block_rule_amazon_marketplace +msgid "Amazon Marketplace" +msgstr "Amazon Marketplace" + +#. module: mail_recipient_blocklist +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_form +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_search +msgid "Archived" +msgstr "Archivado" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__contains +msgid "Contains" +msgstr "Contiene" + +#. module: mail_recipient_blocklist +#: model_terms:ir.actions.act_window,help:mail_recipient_blocklist.mail_recipient_block_rule_action +msgid "Create a recipient block rule" +msgstr "Crear una regla de bloqueo de destinatarios" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__domain +msgid "Domain" +msgstr "Dominio" + +#. module: mail_recipient_blocklist +#. odoo-python +#: code:addons/mail_recipient_blocklist/models/mail_mail.py:0 +msgid "Email delivery blocked by recipient blocklist rules." +msgstr "" +"El envío del correo electrónico ha sido bloqueado por las reglas de " +"la lista de bloqueados del destinatario." + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__exact +msgid "Exact email" +msgstr "Email exacto" + +#. module: mail_recipient_blocklist +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_search +msgid "Group By" +msgstr "Agrupado por" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__id +msgid "ID" +msgstr "ID" + +#. module: mail_recipient_blocklist +#. odoo-python +#: code:addons/mail_recipient_blocklist/models/mail_recipient_block_rule.py:0 +msgid "Invalid regular expression: %s" +msgstr "Expresión regular invalida (%s)" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: mail_recipient_blocklist +#: model:ir.model,name:mail_recipient_blocklist.model_mail_recipient_block_rule +msgid "Mail Recipient Block Rule" +msgstr "Regla de bloqueo de destinatarios de correo" + +#. module: mail_recipient_blocklist +#: model:ir.model,name:mail_recipient_blocklist.model_ir_mail_server +msgid "Mail Server" +msgstr "Servidor de correo" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__name +msgid "Name" +msgstr "Nombre" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__notes +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_form +msgid "Notes" +msgstr "Notas" + +#. module: mail_recipient_blocklist +#: model:ir.model,name:mail_recipient_blocklist.model_mail_mail +msgid "Outgoing Mails" +msgstr "Correos salientes" + +#. module: mail_recipient_blocklist +#: model_terms:ir.actions.act_window,help:mail_recipient_blocklist.mail_recipient_block_rule_action +msgid "" +"Outgoing emails matching these rules will not be delivered to those " +"recipients." +msgstr "" +"Los correos electrónicos salientes que cumplan estas reglas no se " +"entregarán a dichos destinatarios." + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__pattern +msgid "Pattern" +msgstr "Patrón" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__pattern_type +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_search +msgid "Pattern Type" +msgstr "Tipo de patrón" + +#. module: mail_recipient_blocklist +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_form +msgid "Recipient Block Rule" +msgstr "Regla de bloqueo de destinatarios de correo" + +#. module: mail_recipient_blocklist +#: model:ir.actions.act_window,name:mail_recipient_blocklist.mail_recipient_block_rule_action +#: model:ir.ui.menu,name:mail_recipient_blocklist.mail_recipient_block_rule_menu +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_list +msgid "Recipient Block Rules" +msgstr "Reglas de bloqueo de destinatarios de correo" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__regex +msgid "Regular expression" +msgstr "Expresión regular" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__sequence +msgid "Sequence" +msgstr "Secuencia" + +#. module: mail_recipient_blocklist +#. odoo-python +#: code:addons/mail_recipient_blocklist/models/mail_recipient_block_rule.py:0 +msgid "The block pattern cannot be empty." +msgstr "El patrón de bloqueo no puede estar vacío." + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__wildcard +msgid "Wildcard" +msgstr "Carácter comodín" diff --git a/mail_recipient_blocklist/i18n/mail_recipient_blocklist.pot b/mail_recipient_blocklist/i18n/mail_recipient_blocklist.pot new file mode 100644 index 000000000..07e16fac2 --- /dev/null +++ b/mail_recipient_blocklist/i18n/mail_recipient_blocklist.pot @@ -0,0 +1,176 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * mail_recipient_blocklist +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-02 15:47+0000\n" +"PO-Revision-Date: 2026-07-02 15:47+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__active +msgid "Active" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:mail.recipient.block.rule,name:mail_recipient_blocklist.mail_recipient_block_rule_amazon_marketplace +msgid "Amazon Marketplace" +msgstr "" + +#. module: mail_recipient_blocklist +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_form +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_search +msgid "Archived" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__contains +msgid "Contains" +msgstr "" + +#. module: mail_recipient_blocklist +#: model_terms:ir.actions.act_window,help:mail_recipient_blocklist.mail_recipient_block_rule_action +msgid "Create a recipient block rule" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__create_uid +msgid "Created by" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__create_date +msgid "Created on" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__display_name +msgid "Display Name" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__domain +msgid "Domain" +msgstr "" + +#. module: mail_recipient_blocklist +#. odoo-python +#: code:addons/mail_recipient_blocklist/models/mail_mail.py:0 +msgid "Email delivery blocked by recipient blocklist rules." +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__exact +msgid "Exact email" +msgstr "" + +#. module: mail_recipient_blocklist +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_search +msgid "Group By" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__id +msgid "ID" +msgstr "" + +#. module: mail_recipient_blocklist +#. odoo-python +#: code:addons/mail_recipient_blocklist/models/mail_recipient_block_rule.py:0 +msgid "Invalid regular expression: %s" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__write_date +msgid "Last Updated on" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model,name:mail_recipient_blocklist.model_mail_recipient_block_rule +msgid "Mail Recipient Block Rule" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model,name:mail_recipient_blocklist.model_ir_mail_server +msgid "Mail Server" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__name +msgid "Name" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__notes +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_form +msgid "Notes" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model,name:mail_recipient_blocklist.model_mail_mail +msgid "Outgoing Mails" +msgstr "" + +#. module: mail_recipient_blocklist +#: model_terms:ir.actions.act_window,help:mail_recipient_blocklist.mail_recipient_block_rule_action +msgid "" +"Outgoing emails matching these rules will not be delivered to those " +"recipients." +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__pattern +msgid "Pattern" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__pattern_type +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_search +msgid "Pattern Type" +msgstr "" + +#. module: mail_recipient_blocklist +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_form +msgid "Recipient Block Rule" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.actions.act_window,name:mail_recipient_blocklist.mail_recipient_block_rule_action +#: model:ir.ui.menu,name:mail_recipient_blocklist.mail_recipient_block_rule_menu +#: model_terms:ir.ui.view,arch_db:mail_recipient_blocklist.mail_recipient_block_rule_view_list +msgid "Recipient Block Rules" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__regex +msgid "Regular expression" +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields,field_description:mail_recipient_blocklist.field_mail_recipient_block_rule__sequence +msgid "Sequence" +msgstr "" + +#. module: mail_recipient_blocklist +#. odoo-python +#: code:addons/mail_recipient_blocklist/models/mail_recipient_block_rule.py:0 +msgid "The block pattern cannot be empty." +msgstr "" + +#. module: mail_recipient_blocklist +#: model:ir.model.fields.selection,name:mail_recipient_blocklist.selection__mail_recipient_block_rule__pattern_type__wildcard +msgid "Wildcard" +msgstr "" diff --git a/mail_recipient_blocklist/models/__init__.py b/mail_recipient_blocklist/models/__init__.py new file mode 100644 index 000000000..6fdb32c9f --- /dev/null +++ b/mail_recipient_blocklist/models/__init__.py @@ -0,0 +1,3 @@ +from . import mail_recipient_block_rule +from . import ir_mail_server +from . import mail_mail diff --git a/mail_recipient_blocklist/models/ir_mail_server.py b/mail_recipient_blocklist/models/ir_mail_server.py new file mode 100644 index 000000000..aedfb6f3f --- /dev/null +++ b/mail_recipient_blocklist/models/ir_mail_server.py @@ -0,0 +1,65 @@ +# Copyright 2026 Tecnativa - Sergio Teruel +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, models, tools +from odoo.tools import formataddr + +_logger = logging.getLogger(__name__) + + +class IrMailServer(models.Model): + _inherit = "ir.mail_server" + + @api.model + def _filter_blocked_recipient_header(self, header_value): + allowed = [] + blocked = [] + BlockRule = self.env["mail.recipient.block.rule"] + for name, email in tools.mail.email_split_tuples(str(header_value or "")): + recipient = formataddr((name, email)) + if BlockRule._is_blocked_email(email): + blocked.append(recipient) + else: + allowed.append(recipient) + return ", ".join(allowed), blocked + + @api.model + def _filter_blocked_message_recipients(self, message): + blocked_recipients = [] + for header in ("To", "Cc", "Bcc"): + header_value = message[header] + if not header_value: + continue + allowed_header_value, blocked = self._filter_blocked_recipient_header( + header_value + ) + if not blocked: + continue + del message[header] + if allowed_header_value: + message[header] = allowed_header_value + blocked_recipients += blocked + if blocked_recipients: + _logger.warning( + "Blocked SMTP recipient(s): %s", + ", ".join(blocked_recipients), + ) + has_remaining_recipients = bool( + message["To"] or message["Cc"] or message["Bcc"] + ) + return has_remaining_recipients, blocked_recipients + + def send_email(self, message, *args, **kwargs): + has_remaining_recipients, blocked_recipients = ( + self._filter_blocked_message_recipients(message) + ) + if blocked_recipients and not has_remaining_recipients: + _logger.warning( + "Cancelled direct SMTP delivery addressed only to blocked " + "recipients. Message-Id: %s", + message["Message-Id"], + ) + return message["Message-Id"] + return super().send_email(message, *args, **kwargs) diff --git a/mail_recipient_blocklist/models/mail_mail.py b/mail_recipient_blocklist/models/mail_mail.py new file mode 100644 index 000000000..c245e6f98 --- /dev/null +++ b/mail_recipient_blocklist/models/mail_mail.py @@ -0,0 +1,102 @@ +# Copyright 2026 Tecnativa - Sergio Teruel +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, models, tools + +_logger = logging.getLogger(__name__) + + +class MailMail(models.Model): + _inherit = "mail.mail" + + def _blocked_recipient_emails(self): + self.ensure_one() + emails = [] + emails += tools.mail.email_normalize_all(self.email_to) + emails += tools.mail.email_normalize_all(self.email_cc) + emails += self.recipient_ids.mapped("email") + BlockRule = self.env["mail.recipient.block.rule"] + return [ + email for email in emails if email and BlockRule._is_blocked_email(email) + ] + + def _has_only_blocked_recipients(self): + self.ensure_one() + emails = [] + emails += tools.mail.email_normalize_all(self.email_to) + emails += tools.mail.email_normalize_all(self.email_cc) + emails += self.recipient_ids.mapped("email") + emails = [email for email in emails if email] + blocked_emails = self._blocked_recipient_emails() + return bool(emails) and len(emails) == len(blocked_emails) + + def _cancel_blocked_recipient_delivery(self): + reason = _("Email delivery blocked by recipient blocklist rules.") + self.write({"state": "cancel", "failure_reason": reason}) + notifications = ( + self.env["mail.notification"] + .sudo() + .search( + [ + ("notification_type", "=", "email"), + ("mail_mail_id", "in", self.ids), + ("notification_status", "not in", ("sent", "canceled")), + ] + ) + ) + if notifications: + notifications.write({"notification_status": "canceled"}) + + def _prepare_outgoing_list( + self, mail_server=False, recipients_follower_status=None + ): + email_list = super()._prepare_outgoing_list( + mail_server=mail_server, + recipients_follower_status=recipients_follower_status, + ) + BlockRule = self.env["mail.recipient.block.rule"] + filtered_email_list = [] + blocked_recipients = [] + for email_values in email_list: + email_values = dict(email_values) + email_to, blocked_to = BlockRule._filter_blocked_recipients( + email_values.get("email_to") + ) + email_cc, blocked_cc = BlockRule._filter_blocked_recipients( + email_values.get("email_cc") + ) + email_values["email_to"] = email_to + email_values["email_cc"] = email_cc + email_values["email_to_normalized"] = [ + email + for email in email_values.get("email_to_normalized", []) + if not BlockRule._is_blocked_email(email) + ] + blocked_recipients += blocked_to + blocked_cc + if email_to or email_cc: + filtered_email_list.append(email_values) + if blocked_recipients: + _logger.warning( + "Blocked recipient(s) for mail.mail %s: %s", + self.id, + ", ".join(blocked_recipients), + ) + return filtered_email_list + + def send(self, auto_commit=False, raise_exception=False, post_send_callback=None): + blocked_mails = self.filtered(lambda mail: mail._has_only_blocked_recipients()) + if blocked_mails: + blocked_mails._cancel_blocked_recipient_delivery() + _logger.warning( + "Cancelled %s outgoing email(s) addressed only to blocked " + "recipients: %s", + len(blocked_mails), + blocked_mails.ids, + ) + return super(MailMail, self - blocked_mails).send( + auto_commit=auto_commit, + raise_exception=raise_exception, + post_send_callback=post_send_callback, + ) diff --git a/mail_recipient_blocklist/models/mail_recipient_block_rule.py b/mail_recipient_blocklist/models/mail_recipient_block_rule.py new file mode 100644 index 000000000..fc4b6c934 --- /dev/null +++ b/mail_recipient_blocklist/models/mail_recipient_block_rule.py @@ -0,0 +1,81 @@ +# Copyright 2026 Tecnativa - Sergio Teruel +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import fnmatch +import re + +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError + + +class MailRecipientBlockRule(models.Model): + _name = "mail.recipient.block.rule" + _description = "Mail Recipient Block Rule" + _order = "sequence, id" + + name = fields.Char(required=True, translate=True) + active = fields.Boolean(default=True) + sequence = fields.Integer(default=10) + pattern_type = fields.Selection( + selection=[ + ("exact", "Exact email"), + ("domain", "Domain"), + ("contains", "Contains"), + ("wildcard", "Wildcard"), + ("regex", "Regular expression"), + ], + required=True, + default="contains", + ) + pattern = fields.Char(required=True) + notes = fields.Text() + + @api.constrains("pattern", "pattern_type") + def _check_pattern(self): + for rule in self: + if not (rule.pattern or "").strip(): + raise ValidationError(_("The block pattern cannot be empty.")) + if rule.pattern_type == "regex": + try: + re.compile(rule.pattern) + except re.error as error: + raise ValidationError( + _("Invalid regular expression: %s", error) + ) from error + + def _match_email(self, email): + self.ensure_one() + normalized_email = tools.email_normalize(email) or (email or "").lower() + pattern = (self.pattern or "").strip().lower() + if not normalized_email or not pattern: + return False + if self.pattern_type == "exact": + return normalized_email == pattern + if self.pattern_type == "domain": + domain = normalized_email.rsplit("@", 1)[-1] + pattern_domain = pattern.lstrip("@") + return domain == pattern_domain + if self.pattern_type == "contains": + return pattern in normalized_email + if self.pattern_type == "wildcard": + return fnmatch.fnmatchcase(normalized_email, pattern) + if self.pattern_type == "regex": + return bool(re.search(self.pattern, normalized_email, re.I)) + return False + + @api.model + def _is_blocked_email(self, email): + return any(rule._match_email(email) for rule in self.search([])) + + @api.model + def _filter_blocked_recipients(self, recipients): + allowed = [] + blocked = [] + for recipient in recipients or []: + normalized_emails = tools.mail.email_normalize_all(recipient) + emails_to_check = normalized_emails or [recipient] + if any(self._is_blocked_email(email) for email in emails_to_check): + blocked.append(recipient) + else: + allowed.append(recipient) + return allowed, blocked diff --git a/mail_recipient_blocklist/pyproject.toml b/mail_recipient_blocklist/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/mail_recipient_blocklist/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/mail_recipient_blocklist/readme/CONTRIBUTORS.md b/mail_recipient_blocklist/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..2bdd1eb30 --- /dev/null +++ b/mail_recipient_blocklist/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Tecnativa](https://www.tecnativa.com): + - Sergio Teruel diff --git a/mail_recipient_blocklist/readme/DESCRIPTION.md b/mail_recipient_blocklist/readme/DESCRIPTION.md new file mode 100644 index 000000000..f3d4dcad1 --- /dev/null +++ b/mail_recipient_blocklist/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +Blocks outgoing emails addressed to recipients matching configurable block rules. +Rules can match exact emails, domains, contained text, wildcard patterns or regular expressions. +The block is enforced both on queued Odoo emails (`mail.mail`) and on direct SMTP deliveries (`ir.mail_server`). diff --git a/mail_recipient_blocklist/readme/USAGE.md b/mail_recipient_blocklist/readme/USAGE.md new file mode 100644 index 000000000..95eb152e8 --- /dev/null +++ b/mail_recipient_blocklist/readme/USAGE.md @@ -0,0 +1,29 @@ +To configure blocked recipients: + +1. Go to **Settings > Technical > Email > Recipient Block Rules**. + + ![Recipient block rule menu](../static/description/recipient_block_rule_menu.png) + +2. Create a new rule or edit an existing one. For each rule, set a name, choose + the pattern type, define the pattern, and keep the rule active. + + ![Recipient block rule form](../static/description/recipient_block_rule_form.png) + +3. Choose the pattern type according to the recipients you need to block: + + - **Exact email**: matches one complete email address. + - **Domain**: matches all recipients in a domain. + - **Contains**: matches recipients containing the configured text. + - **Wildcard**: supports shell-style wildcards such as `*@example.com`. + - **Regular expression**: supports Python regular expressions. + +4. Save the rule. Outgoing emails are filtered before delivery. If all + recipients are blocked, the email is cancelled. If only some recipients are + blocked, Odoo sends the email to the remaining allowed recipients. + + ![Recipient block rule result](../static/description/recipient_block_rule_result.png) + +A default rule blocks Amazon Marketplace aliases matching `@marketplace.amazon.`. + +Archived rules are ignored. If all matching rules are archived, Odoo will use its +standard outgoing mail flow for those recipients. diff --git a/mail_recipient_blocklist/security/ir.model.access.csv b/mail_recipient_blocklist/security/ir.model.access.csv new file mode 100644 index 000000000..8a3bba051 --- /dev/null +++ b/mail_recipient_blocklist/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mail_recipient_block_rule_system,mail.recipient.block.rule system,model_mail_recipient_block_rule,base.group_system,1,1,1,1 diff --git a/mail_recipient_blocklist/static/description/icon.png b/mail_recipient_blocklist/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/mail_recipient_blocklist/static/description/icon.png differ diff --git a/mail_recipient_blocklist/static/description/index.html b/mail_recipient_blocklist/static/description/index.html new file mode 100644 index 000000000..8805e6c8e --- /dev/null +++ b/mail_recipient_blocklist/static/description/index.html @@ -0,0 +1,468 @@ + + + + + +Mail Recipient Blocklist + + + +
+

Mail Recipient Blocklist

+ + +

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

+

Blocks outgoing emails addressed to recipients matching configurable +block rules. Rules can match exact emails, domains, contained text, +wildcard patterns or regular expressions. The block is enforced both on +queued Odoo emails (mail.mail) and on direct SMTP deliveries +(ir.mail_server).

+

Table of contents

+ +
+

Usage

+

To configure blocked recipients:

+
    +
  1. Go to Settings > Technical > Email > Recipient Block Rules.

    +

    Recipient block rule menu

    +
  2. +
  3. Create a new rule or edit an existing one. For each rule, set a name, +choose the pattern type, define the pattern, and keep the rule +active.

    +

    Recipient block rule form

    +
  4. +
  5. Choose the pattern type according to the recipients you need to +block:

    +
      +
    • Exact email: matches one complete email address.
    • +
    • Domain: matches all recipients in a domain.
    • +
    • Contains: matches recipients containing the configured text.
    • +
    • Wildcard: supports shell-style wildcards such as +*@example.com.
    • +
    • Regular expression: supports Python regular expressions.
    • +
    +
  6. +
  7. Save the rule. Outgoing emails are filtered before delivery. If all +recipients are blocked, the email is cancelled. If only some +recipients are blocked, Odoo sends the email to the remaining allowed +recipients.

    +

    Recipient block rule result

    +
  8. +
+

A default rule blocks Amazon Marketplace aliases matching +@marketplace.amazon..

+

Archived rules are ignored. If all matching rules are archived, Odoo +will use its standard outgoing mail flow for those recipients.

+
+
+

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

+
    +
  • Tecnativa
  • +
+
+
+

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.

+

Current maintainer:

+

sergio-teruel

+

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_recipient_blocklist/static/description/recipient_block_rule_form.png b/mail_recipient_blocklist/static/description/recipient_block_rule_form.png new file mode 100644 index 000000000..18fd43813 Binary files /dev/null and b/mail_recipient_blocklist/static/description/recipient_block_rule_form.png differ diff --git a/mail_recipient_blocklist/static/description/recipient_block_rule_menu.png b/mail_recipient_blocklist/static/description/recipient_block_rule_menu.png new file mode 100644 index 000000000..daa6762ef Binary files /dev/null and b/mail_recipient_blocklist/static/description/recipient_block_rule_menu.png differ diff --git a/mail_recipient_blocklist/static/description/recipient_block_rule_result.png b/mail_recipient_blocklist/static/description/recipient_block_rule_result.png new file mode 100644 index 000000000..44cad767d Binary files /dev/null and b/mail_recipient_blocklist/static/description/recipient_block_rule_result.png differ diff --git a/mail_recipient_blocklist/tests/__init__.py b/mail_recipient_blocklist/tests/__init__.py new file mode 100644 index 000000000..3e13e0c4f --- /dev/null +++ b/mail_recipient_blocklist/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mail_recipient_blocklist diff --git a/mail_recipient_blocklist/tests/test_mail_recipient_blocklist.py b/mail_recipient_blocklist/tests/test_mail_recipient_blocklist.py new file mode 100644 index 000000000..53cef98eb --- /dev/null +++ b/mail_recipient_blocklist/tests/test_mail_recipient_blocklist.py @@ -0,0 +1,145 @@ +# Copyright 2026 Tecnativa - Sergio Teruel +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from email.message import EmailMessage +from unittest import mock + +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestMailRecipientBlocklist(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.BlockRule = cls.env["mail.recipient.block.rule"] + + def test_default_amazon_rule_blocks_marketplace_aliases(self): + self.assertTrue( + self.BlockRule._is_blocked_email("abc123@marketplace.amazon.es") + ) + self.assertTrue( + self.BlockRule._is_blocked_email("abc123@marketplace.amazon.fr") + ) + self.assertFalse(self.BlockRule._is_blocked_email("customer@example.com")) + + def test_regex_rule_validation(self): + with self.assertRaises(ValidationError): + self.BlockRule.create( + { + "name": "Invalid regex", + "pattern_type": "regex", + "pattern": "[invalid", + } + ) + + def test_configurable_domain_rule_blocks_other_recipients(self): + self.BlockRule.create( + { + "name": "Example domain", + "pattern_type": "domain", + "pattern": "blocked.example.com", + } + ) + self.assertTrue(self.BlockRule._is_blocked_email("user@blocked.example.com")) + self.assertFalse(self.BlockRule._is_blocked_email("user@example.com")) + + def test_inactive_rule_does_not_block_recipients(self): + rule = self.BlockRule.create( + { + "name": "Inactive domain", + "pattern_type": "domain", + "pattern": "inactive.example.com", + "active": False, + } + ) + self.assertFalse(rule.active) + self.assertFalse(self.BlockRule._is_blocked_email("user@inactive.example.com")) + + def test_send_cancels_mail_addressed_only_to_blocked_recipient(self): + mail = self.env["mail.mail"].create( + { + "email_to": "Customer ", + "subject": "Blocked", + "body_html": "Blocked", + } + ) + with mock.patch( + "odoo.addons.base.models.ir_mail_server.IrMailServer.connect" + ) as connect_mock: + mail.send() + connect_mock.assert_not_called() + self.assertEqual(mail.state, "cancel") + self.assertIn("blocklist", mail.failure_reason) + + def test_prepare_outgoing_list_removes_blocked_recipient(self): + mail = self.env["mail.mail"].create( + { + "email_to": ( + "Customer , " + "Regular " + ), + "email_cc": "copy@marketplace.amazon.fr", + "subject": "Partially blocked", + "body_html": "Partially blocked", + } + ) + email_list = mail._prepare_outgoing_list() + self.assertEqual(len(email_list), 1) + self.assertEqual(email_list[0]["email_to"], ['"Regular" ']) + self.assertEqual(email_list[0]["email_cc"], []) + self.assertEqual(email_list[0]["email_to_normalized"], ["regular@example.com"]) + + def test_prepare_outgoing_list_removes_blocked_partner_recipient(self): + amazon_partner = self.env["res.partner"].create( + {"name": "Amazon", "email": "abc123@marketplace.amazon.it"} + ) + regular_partner = self.env["res.partner"].create( + {"name": "Regular", "email": "regular@example.com"} + ) + mail = self.env["mail.mail"].create( + { + "recipient_ids": [(6, 0, (amazon_partner | regular_partner).ids)], + "subject": "Partner recipients", + "body_html": "Partner recipients", + } + ) + email_list = mail._prepare_outgoing_list() + self.assertEqual(len(email_list), 1) + self.assertEqual(email_list[0]["partner_id"], regular_partner) + self.assertEqual(email_list[0]["email_to_normalized"], ["regular@example.com"]) + + def test_send_email_cancels_direct_smtp_to_blocked_recipient(self): + message = EmailMessage() + message["Message-Id"] = "" + message["From"] = "sender@example.com" + message["To"] = "Customer " + with mock.patch( + "odoo.addons.base.models.ir_mail_server.IrMailServer.connect" + ) as connect_mock: + result = self.env["ir.mail_server"].send_email(message) + connect_mock.assert_not_called() + self.assertEqual(result, "") + + def test_filter_direct_smtp_message_removes_blocked_recipient(self): + message = EmailMessage() + message["Message-Id"] = "" + message["From"] = "sender@example.com" + message["To"] = ( + "Customer , " "Regular " + ) + message["Cc"] = "copy@marketplace.amazon.fr" + has_remaining_recipients, blocked_recipients = self.env[ + "ir.mail_server" + ]._filter_blocked_message_recipients(message) + self.assertTrue(has_remaining_recipients) + self.assertEqual( + blocked_recipients, + [ + '"Customer" ', + "copy@marketplace.amazon.fr", + ], + ) + self.assertEqual(message["To"], "Regular ") + self.assertFalse(message["Cc"]) diff --git a/mail_recipient_blocklist/views/mail_recipient_block_rule_views.xml b/mail_recipient_blocklist/views/mail_recipient_block_rule_views.xml new file mode 100644 index 000000000..bc988ee66 --- /dev/null +++ b/mail_recipient_blocklist/views/mail_recipient_block_rule_views.xml @@ -0,0 +1,91 @@ + + + + mail.recipient.block.rule.list + mail.recipient.block.rule + + + + + + + + + + + + + mail.recipient.block.rule.form + mail.recipient.block.rule + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + mail.recipient.block.rule.search + mail.recipient.block.rule + + + + + + + + + + + + + + Recipient Block Rules + mail.recipient.block.rule + list,form + + +

Create a recipient block rule

+

Outgoing emails matching these rules will not be delivered to those recipients.

+
+
+ + +