From 20ac8cb7e84903f963039637ed1b4288f578b17f Mon Sep 17 00:00:00 2001 From: Borruso Date: Thu, 30 Jul 2026 16:11:54 +0200 Subject: [PATCH 1/3] [FIX] mail_composer_cc_bcc: fix outgoing email recipients --- mail_composer_cc_bcc/README.rst | 26 +++++++++---------- mail_composer_cc_bcc/models/ir_mail_server.py | 14 ++-------- mail_composer_cc_bcc/models/mail_mail.py | 3 +++ .../tests/test_mail_cc_bcc.py | 2 +- .../wizards/mail_compose_message.py | 17 +++++++++--- 5 files changed, 33 insertions(+), 29 deletions(-) diff --git a/mail_composer_cc_bcc/README.rst b/mail_composer_cc_bcc/README.rst index a0278cc76..6905691da 100644 --- a/mail_composer_cc_bcc/README.rst +++ b/mail_composer_cc_bcc/README.rst @@ -45,11 +45,11 @@ all headers (To, Cc, Bcc) in all emails Features -------- -- Add Cc and Bcc fields to company form to use them as default in mail - composer form. -- Add Bcc field to mail template form. Use Cc and Bcc fields to lookup - partners by email then add them to corresponding fields in mail - composer form. +- Add Cc and Bcc fields to company form to use them as default in mail + composer form. +- Add Bcc field to mail template form. Use Cc and Bcc fields to lookup + partners by email then add them to corresponding fields in mail + composer form. .. IMPORTANT:: This is an alpha version, the data model and design can change at any time without warning. @@ -113,18 +113,18 @@ Authors Contributors ------------ -- `Trobz `__: +- `Trobz `__: - - Hai N. Le - - Son Ho - - Tri Doan + - Hai N. Le + - Son Ho + - Tri Doan -- Alberto Nieto alberto.nieto@braintec.com (https://braintec.com) +- Alberto Nieto alberto.nieto@braintec.com (https://braintec.com) -- `Camptocamp `__: +- `Camptocamp `__: - - Cyril Jeanneret - - Italo Lopes + - Cyril Jeanneret + - Italo Lopes Other credits ------------- diff --git a/mail_composer_cc_bcc/models/ir_mail_server.py b/mail_composer_cc_bcc/models/ir_mail_server.py index df6b1af39..2c20e5963 100644 --- a/mail_composer_cc_bcc/models/ir_mail_server.py +++ b/mail_composer_cc_bcc/models/ir_mail_server.py @@ -1,17 +1,13 @@ # Copyright 2024 Camptocamp SA # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -import logging - from odoo import models -_logger = logging.getLogger(__name__) - class IrMailServer(models.Model): _inherit = "ir.mail_server" - def _prepare_email_message(self, message, smtp_session): + def _prepare_email_message__(self, message, smtp_session): # noqa: PLW3201 """ Define smtp_to based on context instead of To+Cc+Bcc """ @@ -22,14 +18,8 @@ def _prepare_email_message(self, message, smtp_session): if x_odoo_bcc_value: message["Bcc"] = x_odoo_bcc_value - smtp_from, smtp_to_list, message = super()._prepare_email_message( + smtp_from, smtp_to_list, message = super()._prepare_email_message__( message, smtp_session ) - is_from_composer = self.env.context.get("is_from_composer", False) - if is_from_composer and self.env.context.get("recipients", False): - smtp_to = self.env.context["recipients"].pop(0) - _logger.debug("smtp_to: %s", smtp_to) - smtp_to_list = [smtp_to] - return smtp_from, smtp_to_list, message diff --git a/mail_composer_cc_bcc/models/mail_mail.py b/mail_composer_cc_bcc/models/mail_mail.py index 0504827e3..6ca1f42a3 100644 --- a/mail_composer_cc_bcc/models/mail_mail.py +++ b/mail_composer_cc_bcc/models/mail_mail.py @@ -49,6 +49,9 @@ def _prepare_outgoing_list(self, mail_server=False, doc_to_followers=None): # with the same To, Cc headers (to be shown by email client as users expect) recipients = set() for m in res: + # Odoo reuses the headers dictionary for all outgoing entries. + # Copy it before adding recipient-specific headers. + m["headers"] = dict(m["headers"]) rcpt_to = None if m["email_to"]: rcpt_to = extract_rfc2822_addresses(m["email_to"][0])[0] diff --git a/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py b/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py index bb423f636..638e278b4 100644 --- a/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py +++ b/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py @@ -13,7 +13,7 @@ ) VALID_HASHES = { - "mail.composer:_compute_partner_ids": ["4e49f0d6c53f41ac24b02176e97b600a"], + "mail.composer:_compute_partner_ids": ["c3903ab78bf2e5d9aba078ac2689d974"], } diff --git a/mail_composer_cc_bcc/wizards/mail_compose_message.py b/mail_composer_cc_bcc/wizards/mail_compose_message.py index 9185ee7bf..02fe3d4ac 100644 --- a/mail_composer_cc_bcc/wizards/mail_compose_message.py +++ b/mail_composer_cc_bcc/wizards/mail_compose_message.py @@ -82,7 +82,13 @@ def _compute_partner_cc_bcc_ids(self): composer.partner_bcc_ids = self.env.company.default_partner_bcc_ids @api.depends( - "composition_mode", "model", "parent_id", "res_domain", "res_ids", "template_id" + "composition_mode", + "model", + "parent_id", + "res_domain", + "res_ids", + "subtype_id", + "template_id", ) def _compute_partner_ids(self): """ @@ -91,17 +97,22 @@ def _compute_partner_ids(self): return: field Recipients filled with value from 'email_to', 'partner_ids' """ for composer in self: + template = composer.template_id if ( - composer.template_id + template and composer.composition_mode == "comment" and not composer.composition_batch + and (not template.use_default_to or not composer.partner_ids) ): res_ids = composer._evaluate_res_ids() or [0] rendered_values = composer._generate_template_for_composer( res_ids, # DIFFERENT FROM ODOO NATIVE: {"email_to", "partner_ids"}, - allow_suggested=False, + allow_suggested=( + composer.message_type == "comment" + and not composer.subtype_is_log + ), find_or_create_partners=True, )[res_ids[0]] if rendered_values.get("partner_ids"): From 467a8f458f3824b78b694f85ee6b91cae6ee7e8d Mon Sep 17 00:00:00 2001 From: skanndar Date: Wed, 5 Aug 2026 21:50:29 +0200 Subject: [PATCH 2/3] [FIX] mail_composer_cc_bcc: one email per recipient, no Bcc disclosure Two issues show up on 19.0 when sending from the composer with Cc/Bcc: * the Cc-only email Odoo builds when mail.email_to is empty (odoo/odoo@46bad8f0) is a duplicate here, since every Cc partner is a recipient and already gets its own email. It was dropped by comparing lengths and popping the last entry, which actually removed the *last recipient's* email instead: with two Bcc recipients, the second one never received anything. Filter that entry out explicitly instead. * 19.0 fills the 'X-Msg-To-Add' header with every external recipient (MailThread._notify_by_email_get_headers) and merges it into the To header at send time (IrMailServer._alter_message__) to enable Reply-All. This module builds the whole To / Cc itself, so the header has to be dropped: otherwise the Cc and Bcc addresses end up in the To of every email. Both are covered by tests. --- mail_composer_cc_bcc/models/mail_mail.py | 35 +++++----- mail_composer_cc_bcc/readme/CONTRIBUTORS.md | 2 +- .../tests/test_mail_cc_bcc.py | 67 +++++++++++++++++++ 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/mail_composer_cc_bcc/models/mail_mail.py b/mail_composer_cc_bcc/models/mail_mail.py index 6ca1f42a3..259878c7b 100644 --- a/mail_composer_cc_bcc/models/mail_mail.py +++ b/mail_composer_cc_bcc/models/mail_mail.py @@ -36,6 +36,11 @@ def _prepare_outgoing_list(self, mail_server=False, doc_to_followers=None): if is_out_of_scope or not is_from_composer: return res + # In the absence of self.email_to, Odoo builds an extra Cc-only email + # (see odoo/odoo@46bad8f0). Every Cc partner is also a recipient and + # already gets its own email, so that one is a duplicate here. + res = [m for m in res if m["email_to"]] + # Prepare values for To, Cc headers partners_cc_bcc = self.recipient_cc_ids + self.recipient_bcc_ids partner_to_ids = [r.id for r in self.recipient_ids if r not in partners_cc_bcc] @@ -45,13 +50,20 @@ def _prepare_outgoing_list(self, mail_server=False, doc_to_followers=None): email_cc = format_emails_str(self.recipient_cc_ids) email_bcc = [r.email for r in self.recipient_bcc_ids if r.email] - # Collect recipients (RCPT TO) and update all emails - # with the same To, Cc headers (to be shown by email client as users expect) - recipients = set() + # Update all emails with the same To, Cc headers (to be shown by the + # email client as users expect) for m in res: - # Odoo reuses the headers dictionary for all outgoing entries. - # Copy it before adding recipient-specific headers. - m["headers"] = dict(m["headers"]) + # Odoo reuses the headers dictionary for all outgoing entries: + # copy it before adding recipient-specific headers. Odoo 19 also adds every external recipient to 'X-Msg-To-Add', which + # every external recipient to 'X-Msg-To-Add', which + # IrMailServer._alter_message__ merges into the To header to enable + # Reply-All: here it would put the Cc *and the Bcc* recipients in To, + # so drop it — this module builds the whole To / Cc itself. + m["headers"] = { + key: value + for key, value in m["headers"].items() + if key != "X-Msg-To-Add" + } rcpt_to = None if m["email_to"]: rcpt_to = extract_rfc2822_addresses(m["email_to"][0])[0] @@ -64,14 +76,6 @@ def _prepare_outgoing_list(self, mail_server=False, doc_to_followers=None): if rcpt_to in email_bcc: m["headers"].update({"X-Odoo-Bcc": m["email_to"][0]}) - # in the absence of self.email_to, Odoo creates one special mail for CC - # see https://github.com/odoo/odoo/commit/46bad8f0 - elif m["email_cc"]: - rcpt_to = extract_rfc2822_addresses(m["email_cc"][0])[0] - - if rcpt_to: - recipients.add(rcpt_to) - m.update( { "email_to": email_to, @@ -80,7 +84,4 @@ def _prepare_outgoing_list(self, mail_server=False, doc_to_followers=None): } ) - if len(res) > len(recipients): - res.pop() - return res diff --git a/mail_composer_cc_bcc/readme/CONTRIBUTORS.md b/mail_composer_cc_bcc/readme/CONTRIBUTORS.md index 75be04f9c..8724158e6 100644 --- a/mail_composer_cc_bcc/readme/CONTRIBUTORS.md +++ b/mail_composer_cc_bcc/readme/CONTRIBUTORS.md @@ -9,4 +9,4 @@ > - Cyril Jeanneret \<\> > - Italo Lopes \<\> - +- Alex Abbas Deselaers \<\> diff --git a/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py b/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py index 638e278b4..efdf4917d 100644 --- a/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py +++ b/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py @@ -6,6 +6,8 @@ from odoo import tools from odoo.tests import Form, tagged +from odoo.addons.base.models.ir_mail_server import extract_rfc2822_addresses + from odoo.addons.mail.tests.common import MailCase from odoo.addons.mail.tests.test_mail_composer import TestMailComposerForm from odoo.addons.mail.wizard.mail_compose_message import ( @@ -125,6 +127,71 @@ def test_email_cc_bcc(self): expecting = '"partner_bcc" ' self.assertEqual(mail.email_bcc, expecting) + def test_email_cc_bcc_one_email_per_recipient(self): + """Every recipient gets exactly one email, and no Bcc address leaks. + + Regression test: the Cc-only email Odoo builds when ``mail.email_to`` + is empty is a duplicate here, and dropping it by length (``res.pop()``) + removed the *last* recipient instead, silently losing an email. + """ + partner_bcc2 = self.env["res.partner"].create( + {"name": "partner_bcc2", "email": "partner_bcc2@example.com"} + ) + self.test_record.email = "test@example.com" + form = self.open_mail_composer_form() + composer = form.save() + composer.partner_cc_ids = self.partner_cc + composer.partner_cc_ids |= self.partner_cc2 + composer.partner_bcc_ids = self.partner_bcc + composer.partner_bcc_ids |= partner_bcc2 + + with self.mock_mail_gateway(): + composer._action_send_mail() + + # 1 To + 2 Cc + 2 Bcc = 5 recipients, 5 emails, no duplicate, none lost + self.assertEqual(len(self._mails), 5) + + bcc_emails = {self.partner_bcc.email, partner_bcc2.email} + expected_cc = ", ".join( + [ + '"partner_cc" ', + '"partner_cc2" ', + ] + ) + seen_bcc = set() + for mail in self._mails: + headers = mail.get("headers") or {} + # The To / Cc headers are the same on every email... + self.assertEqual(mail["email_cc"], expected_cc) + # ... and never expose a Bcc recipient + visible = f"{mail['email_to']} {mail['email_cc']}" + for bcc_email in bcc_emails: + self.assertNotIn(bcc_email, visible) + if headers.get("X-Odoo-Bcc"): + seen_bcc.add(extract_rfc2822_addresses(headers["X-Odoo-Bcc"])[0]) + + # each Bcc recipient got its own email + self.assertEqual(seen_bcc, bcc_emails) + + def test_email_cc_bcc_no_reply_all_header(self): + """'X-Msg-To-Add' must not survive: 19.0 merges it into the To header. + + ``MailThread._notify_by_email_get_headers`` fills it with every external + recipient and ``IrMailServer._alter_message__`` merges it into ``To`` at + send time to enable Reply-All, which would disclose the Bcc recipients. + """ + self.test_record.email = "test@example.com" + form = self.open_mail_composer_form() + composer = form.save() + composer.partner_cc_ids = self.partner_cc + composer.partner_bcc_ids = self.partner_bcc + + with self.mock_mail_gateway(): + composer._action_send_mail() + + for mail in self._mails: + self.assertNotIn("X-Msg-To-Add", mail.get("headers") or {}) + def test_template_cc_bcc(self): env = self.env # Company default values From 0d84265fddcf872019b8926dbfd8d37a7a64f0ab Mon Sep 17 00:00:00 2001 From: skanndar Date: Sun, 9 Aug 2026 13:45:49 +0200 Subject: [PATCH 3/3] [IMP] mail_composer_cc_bcc: document the composer-only scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending flows that do not go through mail.compose.message — invoices via account.move.send being the main one, which calls message_post() directly — are not covered by this module. Requested during review. --- mail_composer_cc_bcc/readme/DESCRIPTION.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mail_composer_cc_bcc/readme/DESCRIPTION.md b/mail_composer_cc_bcc/readme/DESCRIPTION.md index b2fa1d3da..116b56d3d 100644 --- a/mail_composer_cc_bcc/readme/DESCRIPTION.md +++ b/mail_composer_cc_bcc/readme/DESCRIPTION.md @@ -14,3 +14,18 @@ From Odoo 17.0, this module sends one mail per recipient and keeps same all head - Add Bcc field to mail template form. Use Cc and Bcc fields to lookup partners by email then add them to corresponding fields in mail composer form. + +## Scope + +This module extends the **Mail Composer** flow: the Cc / Bcc recipients it +adds are taken into account when the email goes through +`mail.compose.message` (the *Send message* wizard, e.g. sending a sale +order). + +Flows that build their emails from a template without the composer are **not** +covered — invoice sending is the main one, since `account.move.send` calls +`message_post()` directly (core even notes there that it should "use standard +composer / template code to be sure it is aligned with standard recipients +management"). Supporting Bcc there would mean carrying the template value +through the standard `mail.template` → `mail.mail` generation, which is a +separate feature rather than part of this module.