Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions mail_composer_cc_bcc/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -113,18 +113,18 @@ Authors
Contributors
------------

- `Trobz <https://www.trobz.com>`__:
- `Trobz <https://www.trobz.com>`__:

- Hai N. Le <hailn@trobz.com>
- Son Ho <sonhd@trobz.com>
- Tri Doan <tridm@trobz.com>
- Hai N. Le <hailn@trobz.com>
- Son Ho <sonhd@trobz.com>
- Tri Doan <tridm@trobz.com>

- Alberto Nieto alberto.nieto@braintec.com (https://braintec.com)
- Alberto Nieto alberto.nieto@braintec.com (https://braintec.com)

- `Camptocamp <https://www.camptocamp.com>`__:
- `Camptocamp <https://www.camptocamp.com>`__:

- Cyril Jeanneret <cyril.jeanneret@camptocam.com>
- Italo Lopes <italo.lopes@camptocam.com>
- Cyril Jeanneret <cyril.jeanneret@camptocam.com>
- Italo Lopes <italo.lopes@camptocam.com>

Other credits
-------------
Expand Down
14 changes: 2 additions & 12 deletions mail_composer_cc_bcc/models/ir_mail_server.py
Original file line number Diff line number Diff line change
@@ -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
"""
Expand All @@ -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
32 changes: 18 additions & 14 deletions mail_composer_cc_bcc/models/mail_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -45,10 +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. 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]
Expand All @@ -61,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,
Expand All @@ -77,7 +84,4 @@ def _prepare_outgoing_list(self, mail_server=False, doc_to_followers=None):
}
)

if len(res) > len(recipients):
res.pop()

return res
2 changes: 1 addition & 1 deletion mail_composer_cc_bcc/readme/CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@

> - Cyril Jeanneret \<<cyril.jeanneret@camptocam.com>\>
> - Italo Lopes \<<italo.lopes@camptocam.com>\>

- Alex Abbas Deselaers \<<skanndar@skanndar.top>\>
15 changes: 15 additions & 0 deletions mail_composer_cc_bcc/readme/DESCRIPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
69 changes: 68 additions & 1 deletion mail_composer_cc_bcc/tests/test_mail_cc_bcc.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
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 (
MailComposeMessage as MailComposer_upstream,
)

VALID_HASHES = {
"mail.composer:_compute_partner_ids": ["4e49f0d6c53f41ac24b02176e97b600a"],
"mail.composer:_compute_partner_ids": ["c3903ab78bf2e5d9aba078ac2689d974"],
}


Expand Down Expand Up @@ -125,6 +127,71 @@ def test_email_cc_bcc(self):
expecting = '"partner_bcc" <partner_bcc@example.com>'
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_cc@example.com>',
'"partner_cc2" <partner_cc2@example.com>',
]
)
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
Expand Down
17 changes: 14 additions & 3 deletions mail_composer_cc_bcc/wizards/mail_compose_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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"):
Expand Down