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
20 changes: 11 additions & 9 deletions mail_composer_cc_bcc/models/ir_mail_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,22 @@ def _prepare_email_message(self, message, smtp_session):
"""
Define smtp_to based on context instead of To+Cc+Bcc
"""
x_odoo_bcc_value = next(
(value for key, value in message._headers if key == "X-Odoo-Bcc"), None
)
# Add Bcc field inside message to pass validation
if x_odoo_bcc_value:
message["Bcc"] = x_odoo_bcc_value

smtp_from, smtp_to_list, message = super()._prepare_email_message(
message, smtp_session
)

# Each recipients gets its own email
# See method `_prepare_outgoing_list`
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)
if is_from_composer:
# Empty recipients means there is a bug.
# => refuse to send, otherwise it would
# - send duplicate emails
# - leak Bcc
recipients = self.env.context.get("recipients")
if not recipients:
raise ValueError("Could not determine the recipient of this email")
smtp_to = recipients.pop(0)
_logger.debug("smtp_to: %s", smtp_to)
smtp_to_list = [smtp_to]

Expand Down
70 changes: 42 additions & 28 deletions mail_composer_cc_bcc/models/mail_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).


import os

from odoo import fields, models, tools

from odoo.addons.base.models.ir_mail_server import extract_rfc2822_addresses
Expand All @@ -25,6 +27,17 @@ class MailMail(models.Model):

email_bcc = fields.Char("Bcc", help="Blind Cc message recipients")

def _expose_bcc_marker(self):
"""Whether to also add the informational ``X-Odoo-Bcc`` marker header.

Disabled by default: the marker survives sending and would expose the
bcc recipient on every copy. Enable it through the ``expose_x_odoo_bcc``
context key or the ``EXPOSE_X_ODOO_BCC`` environment variable.
"""
if self.env.context.get("expose_x_odoo_bcc"):
return True
return tools.str2bool(os.environ.get("EXPOSE_X_ODOO_BCC") or "", False)

def _prepare_outgoing_list(
self, mail_server=False, recipients_follower_status=None
):
Expand All @@ -33,44 +46,47 @@ def _prepare_outgoing_list(
mail_server=mail_server,
recipients_follower_status=recipients_follower_status,
)
is_out_of_scope = len(self.ids) > 1
is_from_composer = self.env.context.get("is_from_composer", False)

if is_out_of_scope or not is_from_composer:
if not is_from_composer:
return res

# Prepare values for To, Cc headers
# Every Cc partner is also a recipient and gets its own email,
# so Odoo's Cc-only email is always a duplicate here.
res = [m for m in res if m["email_to"]]

# The To, Cc headers must be the same on every email, but no record
# holds the whole audience: partner_ids is empty for followers, and the
# mail.mail of the other langs are unlinked as they are sent.
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]
partner_to = self.env["res.partner"].browse(partner_to_ids)
all_recipients = self.env["res.partner"].browse(
self.env.context.get("composer_recipient_ids") or []
)
partner_to = all_recipients - partners_cc_bcc
email_to = format_emails(partner_to)
email_to_raw = format_emails_raw(partner_to)
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()
recipients = []
for m in res:
rcpt_to = None
if m["email_to"]:
rcpt_to = extract_rfc2822_addresses(m["email_to"][0])[0]

# If the recipient is a Bcc, we had an explicit header X-Odoo-Bcc
# - It won't be shown by the email client, but can be useful for a recipient # noqa: E501
# to understand why he received a given email
# - Also note that in python3, the smtp.send_message method does not
# transmit the Bcc field of a Message object
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_email_to = m["email_to"][0]
rcpt_to = extract_rfc2822_addresses(m_email_to)[0]
recipients.append(rcpt_to)

# If the recipient is a Bcc, set a real Bcc header.
# _prepare_email_message uses it to build the envelope
# and then strips it, so it never leaks.
if rcpt_to in email_bcc:
# Avoid mutating the shared headers by making a copy
m["headers"] = {**m["headers"], "Bcc": m_email_to}
# Optional legacy marker. Unlike Bcc it survives sending,
# so only add it when explicitly enabled (it would expose
# the bcc recipient otherwise).
if self._expose_bcc_marker():
m["headers"]["X-Odoo-Bcc"] = m_email_to

m.update(
{
Expand All @@ -80,9 +96,7 @@ def _prepare_outgoing_list(
}
)

# Propagate recipients to override smtp_to `_prepare_email_message`
self.env.context = {**self.env.context, "recipients": list(recipients)}

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

return res
14 changes: 14 additions & 0 deletions mail_composer_cc_bcc/models/mail_thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def _notify_get_recipients(self, message, msg_vals, **kwargs):
"notif": data.get("notif") and data.get("notif") or notif,
"type": msg_type,
"is_follower": data.get("is_follower"),
"lang": data.get("lang"),
"uid": False,
}
rdata.append(pdata)
Expand Down Expand Up @@ -123,6 +124,19 @@ def _notify_get_recipients_classify(
customer_data["recipients"] += ids
return [customer_data]

def _notify_thread_by_email(self, message, recipients_data, **kwargs):
# Pass the whole audience to `_prepare_outgoing_list`
# (only known here)
if self.env.context.get("is_from_composer") and not self.env.context.get(
"skip_adding_cc_bcc"
):
self = self.with_context(
composer_recipient_ids=[
data["id"] for data in recipients_data if data["notif"] == "email"
]
)
return super()._notify_thread_by_email(message, recipients_data, **kwargs)

def _notify_thread(self, message, msg_vals=False, **kwargs):
if message.message_type == "notification":
self = self.with_context(skip_adding_cc_bcc=True)
Expand Down
1 change: 1 addition & 0 deletions mail_composer_cc_bcc/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_mail_cc_bcc
from . import test_mail_cc_bcc_recipients
11 changes: 8 additions & 3 deletions mail_composer_cc_bcc/tests/test_mail_cc_bcc.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,17 @@ def test_template_cc_bcc(self):
expecting = self.partner_cc2 + self.partner_bcc
self.assertEqual(composer.partner_bcc_ids, expecting)

def _set_parent_partner(self, parent, childs):
# Ensure assign works even when other modules are installed
# e.g. account: expect single record
for c in childs:
c.parent_id = parent

def test_template_cc_bcc_with_placeholders(self):
"""Test that template with placeholders for email_cc and email_bcc"""
# Add child record to test_record
self.test_record.child_ids |= (
self.partner_cc + self.partner_cc2 + self.partner_cc3
)
child_ids = self.partner_cc + self.partner_cc2 + self.partner_cc3
self._set_parent_partner(self.test_record, child_ids)

# Partner template values
tmpl_model = self.env["ir.model"].search([("model", "=", "res.partner")])
Expand Down
Loading
Loading