diff --git a/mail_composer_cc_bcc/README.rst b/mail_composer_cc_bcc/README.rst new file mode 100644 index 000000000..fa2e61621 --- /dev/null +++ b/mail_composer_cc_bcc/README.rst @@ -0,0 +1,137 @@ +================ +Email CC and BCC +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0c7083411c956f0bb0a56d0339e1ea49c39986e9d1b0fd3f73164cdd77515d50 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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%2Fsocial-lightgray.png?logo=github + :target: https://github.com/OCA/social/tree/15.0/mail_composer_cc_bcc + :alt: OCA/social +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/social-15-0/social-15-0-mail_composer_cc_bcc + :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/social&target_branch=15.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Odoo native does not support defining a Cc field in the Mail Composer +by default; instead, it only has a unique Recipients fields, which is +confusing for a lot of end users. + +This module allows to properly separate To:, Cc:, and Bcc: fields +in the Mail Composer. + +Features +~~~~~~~~ + +* Add Cc and Bcc fields to mail composer form. Send only once to multiple email + addresses. +* 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. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +In company form there are two fields to set default cc and bcc +partners. + + .. image:: https://raw.githubusercontent.com/OCA/social/15.0/mail_composer_cc_bcc/static/img/res_company_form_default_cc_bcc.png + +In template form there are two fields to set cc and bcc emails. + + .. image:: https://raw.githubusercontent.com/OCA/social/15.0/mail_composer_cc_bcc/static/img/email_template_form_cc_bcc.png + +Usage +===== + +The partners cc and bcc from company form will be used to fill in mail composer +form. + + .. image:: https://raw.githubusercontent.com/OCA/social/15.0/mail_composer_cc_bcc/static/img/mail_compose_message_default_cc_bcc.png + +When select a template that has cc and/or bcc emails, the emails will be used +to lookup partners then found partners will be added to corresponding mail +composer's fields. + + .. image:: https://raw.githubusercontent.com/OCA/social/15.0/mail_composer_cc_bcc/static/img/mail_compose_message_template_cc_bcc.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 +~~~~~~~ + +* Camptocamp SA + +Contributors +~~~~~~~~~~~~ + +* `Trobz `_: + + * Hai N. Le + +Other credits +~~~~~~~~~~~~~ + +The creation of this module was financially supported by Camptocamp. + +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-hailangvn2023| image:: https://github.com/hailangvn2023.png?size=40px + :target: https://github.com/hailangvn2023 + :alt: hailangvn2023 + +Current `maintainer `__: + +|maintainer-hailangvn2023| + +This module is part of the `OCA/social `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/mail_composer_cc_bcc/__init__.py b/mail_composer_cc_bcc/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/mail_composer_cc_bcc/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/mail_composer_cc_bcc/__manifest__.py b/mail_composer_cc_bcc/__manifest__.py new file mode 100644 index 000000000..549dc4c83 --- /dev/null +++ b/mail_composer_cc_bcc/__manifest__.py @@ -0,0 +1,30 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Email CC and BCC", + "summary": "This module enables sending mail to CC and BCC partners in mail composer form.", + "version": "15.0.1.0.0", + "development_status": "Alpha", + "category": "Social", + "website": "https://github.com/OCA/social", + "author": "Camptocamp SA, Odoo Community Association (OCA)", + "maintainers": ["hailangvn2023"], + "license": "AGPL-3", + "application": False, + "installable": True, + "preloadable": True, + "depends": [ + "mail", + "account", + ], + "data": [ + "views/res_company_views.xml", + "views/mail_mail_views.xml", + "views/mail_message_views.xml", + "views/mail_template_views.xml", + "wizards/account_invoice_send_views.xml", + "wizards/mail_compose_message_view.xml", + ], + "demo": [], + "qweb": [], +} diff --git a/mail_composer_cc_bcc/models/__init__.py b/mail_composer_cc_bcc/models/__init__.py new file mode 100644 index 000000000..3a18c06a2 --- /dev/null +++ b/mail_composer_cc_bcc/models/__init__.py @@ -0,0 +1,7 @@ +# These modules are sorted by calling sequence, i.e. mail_thread calls +# mail_message, etc. +from . import res_company +from . import mail_template +from . import mail_thread +from . import mail_message +from . import mail_mail diff --git a/mail_composer_cc_bcc/models/mail_mail.py b/mail_composer_cc_bcc/models/mail_mail.py new file mode 100644 index 000000000..95d4c3556 --- /dev/null +++ b/mail_composer_cc_bcc/models/mail_mail.py @@ -0,0 +1,269 @@ +import ast +import base64 +import logging +import re +import smtplib + +import psycopg2 + +from odoo import _, fields, models, tools + +from odoo.addons.base.models.ir_mail_server import MailDeliveryException + +_logger = logging.getLogger(__name__) + + +def format_emails(partners): + emails = [ + tools.formataddr((p.name or "False", p.email or "False")) for p in partners + ] + return ", ".join(emails) + + +class MailMail(models.Model): + _inherit = "mail.mail" + + email_bcc = fields.Char("Bcc", help="Blind Cc message recipients") + + def _send( # noqa: max-complexity: 4 + self, auto_commit=False, raise_exception=False, smtp_session=None + ): + env = self.env + IrMailServer = env["ir.mail_server"] + IrAttachment = env["ir.attachment"] + ICP = env["ir.config_parameter"].sudo() + # Mail composer only sends 1 mail at a time. + is_out_of_scope = len(self.ids) > 1 + if is_out_of_scope or not (self.email_cc or self.email_bcc): + return super()._send( + auto_commit=auto_commit, + raise_exception=raise_exception, + smtp_session=smtp_session, + ) + mail = self + success_pids = [] + failure_type = None + # ===== Same with native Odoo ===== + # https://github.com/odoo/odoo/blob/6ec4ba7ba22626219ddd09241c274b09a21fac0b + # /addons/mail/models/mail_mail.py#L375 + try: + if mail.state != "outgoing": + if mail.state != "exception" and mail.auto_delete: + mail.sudo().unlink() + return True + + # remove attachments if user send the link with the access_token + body = mail.body_html or "" + attachments = mail.attachment_ids + for link in re.findall(r"/web/(?:content|image)/([0-9]+)", body): + attachments = attachments - IrAttachment.browse(int(link)) + + # load attachment binary data with a separate read(), as + # prefetching all `datas` (binary field) could bloat the browse + # cache, triggerring soft/hard mem limits with temporary data. + attachments = [ + (a["name"], base64.b64decode(a["datas"]), a["mimetype"]) + for a in attachments.sudo().read(["name", "datas", "mimetype"]) + if a["datas"] is not False + ] + + # ===== Different than native Odoo ===== + email = mail._send_prepare_values() + # ===== Same with native Odoo ===== + # headers + headers = {} + bounce_alias = ICP.get_param("mail.bounce.alias") + catchall_domain = ICP.get_param("mail.catchall.domain") + if bounce_alias and catchall_domain: + headers["Return-Path"] = "%s@%s" % (bounce_alias, catchall_domain) + if mail.headers: + try: + headers.update(ast.literal_eval(mail.headers)) + except Exception as e: + # ===== Different than native Odoo ===== + _logger.warning("Error during update headers: %s" % e) + + # ===== Same with native Odoo ===== + # Writing on the mail object may fail (e.g. lock on user) which + # would trigger a rollback *after* actually sending the email. + # To avoid sending twice the same email, provoke the failure earlier + mail.write( + { + "state": "exception", + "failure_reason": _( + "Error without exception. Probably due do sending an email" + " without computed recipients." + ), + } + ) + # Update notification in a transient exception state to avoid concurrent + # update in case an email bounces while sending all emails related to current + # mail record. + notifs = self.env["mail.notification"].search( + [ + ("notification_type", "=", "email"), + ("mail_mail_id", "in", mail.ids), + ("notification_status", "not in", ("sent", "canceled")), + ] + ) + if notifs: + notif_msg = _( + "Error without exception. Probably due do concurrent access" + " update of notification records. Please see with an administrator." + ) + notifs.sudo().write( + { + "notification_status": "exception", + "failure_type": "unknown", + "failure_reason": notif_msg, + } + ) + # `test_mail_bounce_during_send`, force immediate update to obtain the lock. + # see rev. 56596e5240ef920df14d99087451ce6f06ac6d36 + notifs.flush( + fnames=["notification_status", "failure_type", "failure_reason"], + records=notifs, + ) + + # build an RFC2822 email.message.Message object and send it without queuing + res = None + # TDE note: could be great to pre-detect missing to/cc and skip sending it + # to go directly to failed state update + # ===== Different than native Odoo ===== + msg = self.build_email( + email, + attachments=attachments, + headers=headers, + ) + try: + res = IrMailServer.send_email( + msg, + mail_server_id=mail.mail_server_id.id, + smtp_session=smtp_session, + ) + success_pids += mail.recipient_ids.ids + # ===== Same with native Odoo ===== + except AssertionError as error: + if str(error) == IrMailServer.NO_VALID_RECIPIENT: + # if we have a list of void emails for email_list + # -> email missing, otherwise generic email + # failure + if ( + not email.get("email_to") + and failure_type != "mail_email_invalid" + ): + failure_type = "mail_email_missing" + else: + failure_type = "mail_email_invalid" + # No valid recipient found for this particular + # mail item -> ignore error to avoid blocking + # delivery to next recipients, if any. If this is + # the only recipient, the mail will show as failed. + _logger.info( + "Ignoring invalid recipients for mail.mail %s: %s", + mail.message_id, + email.get("email_to"), + ) + else: + raise + if res: # mail has been sent at least once, no major exception occurred + mail.write( + {"state": "sent", "message_id": res, "failure_reason": False} + ) + _logger.info( + "Mail with ID %r and Message-Id %r successfully sent", + mail.id, + mail.message_id, + ) + # /!\ can't use mail.state here, as mail.refresh() will cause an error + # see revid:odo@openerp.com-20120622152536-42b2s28lvdv3odyr in 6.1 + mail._postprocess_sent_message( + success_pids=success_pids, failure_type=failure_type + ) + except MemoryError: + # prevent catching transient MemoryErrors, bubble up to + # notify user or abort cron job instead of marking the + # mail as failed + _logger.exception( + "MemoryError while processing mail with ID %r and Msg-Id %r." + " Consider raising the --limit-memory-hard startup option", + mail.id, + mail.message_id, + ) + # mail status will stay on ongoing since transaction will be rollback + raise + except (psycopg2.Error, smtplib.SMTPServerDisconnected): + # If an error with the database or SMTP session occurs, + # chances are that the cursor or SMTP session are + # unusable, causing further errors when trying to save the + # state. + _logger.exception( + "Exception while processing mail with ID %r and Msg-Id %r.", + mail.id, + mail.message_id, + ) + raise + except Exception as e: + failure_reason = tools.ustr(e) + _logger.exception( + "failed sending mail (id: %s) due to %s", mail.id, failure_reason + ) + mail.write({"state": "exception", "failure_reason": failure_reason}) + mail._postprocess_sent_message( + success_pids=success_pids, + failure_reason=failure_reason, + failure_type="unknown", + ) + if raise_exception: + if isinstance(e, (AssertionError, UnicodeEncodeError)): + if isinstance(e, UnicodeEncodeError): + value = "Invalid text: %s" % e.object + else: + value = ". ".join(e.args) + raise MailDeliveryException(value) from e + raise + + # ===== Different than native Odoo ===== + # As we only send one email, auto_commit has no value + return True + + def build_email(self, email, attachments=None, headers=None): + env = self.env + mail = self + IrMailServer = env["ir.mail_server"] + # ===== Same with native Odoo ===== + # https://github.com/odoo/odoo/blob/6ec4ba7ba22626219ddd09241c274b09a21fac0b + # /addons/mail/models/mail_mail.py#L447 + msg = IrMailServer.build_email( + email_from=mail.email_from, + email_to=email.get("email_to"), + subject=mail.subject, + body=email.get("body"), + body_alternative=email.get("body_alternative"), + # ===== Different than native Odoo ===== + email_cc=mail.email_cc, + email_bcc=mail.email_bcc, + # ===== Same with native Odoo ===== + reply_to=mail.reply_to, + attachments=attachments, + message_id=mail.message_id, + references=mail.references, + object_id=mail.res_id and ("%s-%s" % (mail.res_id, mail.model)), + subtype="html", + subtype_alternative="plain", + headers=headers, + ) + return msg + + def _send_prepare_values(self, partner=None): + res = super()._send_prepare_values(partner=partner) + is_from_composer = self.env.context.get("is_from_composer", False) + if not is_from_composer: + return res + 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) + res["email_to"] = format_emails(partner_to) + res["email_cc"] = format_emails(self.recipient_cc_ids) + res["email_bcc"] = format_emails(self.recipient_bcc_ids) + return res diff --git a/mail_composer_cc_bcc/models/mail_message.py b/mail_composer_cc_bcc/models/mail_message.py new file mode 100644 index 000000000..d4740f0ae --- /dev/null +++ b/mail_composer_cc_bcc/models/mail_message.py @@ -0,0 +1,25 @@ +# Copyright 2023 Camptocamp +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class MailMessage(models.Model): + _inherit = "mail.message" + + recipient_cc_ids = fields.Many2many( + "res.partner", + "mail_message_res_partner_cc_rel", + "mail_message_id", + "parent_id", + string="Cc (Partners)", + context={"active_test": False}, + ) + recipient_bcc_ids = fields.Many2many( + "res.partner", + "mail_message_res_partner_bcc_rel", + "mail_message_id", + "parent_id", + string="Bcc (Partners)", + context={"active_test": False}, + ) diff --git a/mail_composer_cc_bcc/models/mail_template.py b/mail_composer_cc_bcc/models/mail_template.py new file mode 100644 index 000000000..4dff4140c --- /dev/null +++ b/mail_composer_cc_bcc/models/mail_template.py @@ -0,0 +1,52 @@ +# Copyright 2023 Camptocamp +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models, tools + +from ..wizards.mail_compose_message import CC_BCC_FIELDS + + +class MailTemplate(models.Model): + _inherit = "mail.template" + + email_bcc = fields.Char( + "Bcc", help="Blind cc recipients (placeholders may be used here)" + ) + + def generate_recipients(self, results, res_ids): + res = super().generate_recipients(results, res_ids) + is_from_composer = self.env.context.get("is_from_composer", False) + if not is_from_composer or not (self.email_cc or self.email_bcc): + return res + ctx = {"tpl_partners_only": False} + ctx.update(self.env.context) + tmpl_ctx = super().with_context(**ctx) + template_values = {} + tmpl_ctx._render_fields(res_ids, CC_BCC_FIELDS.keys(), template_values) + for res_id, values in template_values.items(): + email_cc_bcc = tools.email_split(values["email_cc"]) + email_cc_bcc += tools.email_split(values["email_bcc"]) + for_emails = [("email", "in", email_cc_bcc)] + partner_cc_bcc_ids = self.env["res.partner"].search(for_emails).ids + if not partner_cc_bcc_ids: + continue + res[res_id]["partner_ids"] = [ + _id + for _id in res[res_id]["partner_ids"] + if _id not in partner_cc_bcc_ids + ] + return res + + def _render_fields(self, res_ids, field_names, results): + template = self + template_res_ids = res_ids + for field in field_names: + generated_field_values = template._render_field( + field, + template_res_ids, + options={"render_safe": field == "subject"}, + post_process=(field == "body_html"), + ) + for res_id, field_value in generated_field_values.items(): + results.setdefault(res_id, dict())[field] = field_value + return results diff --git a/mail_composer_cc_bcc/models/mail_thread.py b/mail_composer_cc_bcc/models/mail_thread.py new file mode 100644 index 000000000..0cb73eaf8 --- /dev/null +++ b/mail_composer_cc_bcc/models/mail_thread.py @@ -0,0 +1,117 @@ +# Copyright 2023 Camptocamp +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + +from .mail_mail import format_emails + + +class MailThread(models.AbstractModel): + _inherit = "mail.thread" + + def _message_create(self, values_list): + context = self.env.context + res = super()._message_create(values_list) + partners_cc = context.get("partner_cc_ids", None) + if partners_cc: + res.recipient_cc_ids = partners_cc + partners_bcc = context.get("partner_bcc_ids", None) + if partners_bcc: + res.recipient_bcc_ids = partners_bcc + return res + + def _notify_by_email_add_values(self, base_mail_values): + """ + This is to add cc, bcc addresses to mail.mail objects so that email + can be sent to those addresses. + """ + context = self.env.context + + partners_cc = context.get("partner_cc_ids", None) + if partners_cc: + base_mail_values["email_cc"] = format_emails(partners_cc) + partners_bcc = context.get("partner_bcc_ids", None) + if partners_bcc: + base_mail_values["email_bcc"] = format_emails(partners_bcc) + res = super()._notify_by_email_add_values(base_mail_values) + return res + + def _notify_compute_recipients(self, message, msg_vals): + """ + This is to add cc, bcc recipients so that they can be grouped with + other recipients. + """ + ResPartner = self.env["res.partner"] + MailFollowers = self.env["mail.followers"] + rdata = super()._notify_compute_recipients(message, msg_vals) + context = self.env.context + is_from_composer = context.get("is_from_composer", False) + if not is_from_composer: + return rdata + for pdata in rdata: + pdata["type"] = "customer" + partners_cc_bcc = context.get("partner_cc_ids", ResPartner) + partners_cc_bcc += context.get("partner_bcc_ids", ResPartner) + msg_sudo = message.sudo() + message_type = ( + msg_vals.get("message_type") if msg_vals else msg_sudo.message_type + ) + subtype_id = msg_vals.get("subtype_id") if msg_vals else msg_sudo.subtype_id.id + recipients_cc_bcc = MailFollowers._get_recipient_data( + None, message_type, subtype_id, partners_cc_bcc.ids + ) + for pid, active, pshare, notif, groups in recipients_cc_bcc: + if not pid: + continue + if not notif: # notif is False, has no user, is therefore customer + notif = "email" + msg_type = "customer" + pdata = { + "id": pid, + "active": active, + "share": pshare, + "groups": groups or [], + "notif": notif, + "type": msg_type, + } + rdata.append(pdata) + return rdata + + def _notify_email_recipient_values(self, recipient_ids): + """ + This is to add cc, bcc recipients' ids to recipient_ids of mail.mail + """ + res = super()._notify_email_recipient_values(recipient_ids) + context = self.env.context + r_ids = list(recipient_ids) + partners_cc = context.get("partner_cc_ids", None) + if partners_cc: + r_ids += partners_cc.ids + partners_bcc = context.get("partner_bcc_ids", None) + if partners_bcc: + r_ids += partners_bcc.ids + if partners_cc or partners_bcc: + res["recipient_ids"] = tuple(set(r_ids)) + return res + + def _notify_classify_recipients(self, recipient_data, model_name, msg_vals=None): + res = super()._notify_classify_recipients( + recipient_data, model_name, msg_vals=msg_vals + ) + is_from_composer = self.env.context.get("is_from_composer", False) + if not is_from_composer: + return res + ids = [] + customer_data = None + for rcpt_data in res: + if rcpt_data["notification_group_name"] == "customer": + customer_data = rcpt_data + else: + ids += rcpt_data["recipients"] + if not customer_data: + customer_data = res[0] + customer_data["notification_group_name"] = "customer" + customer_data["recipients"] = ids + else: + customer_data["recipients"] += ids + return [customer_data] diff --git a/mail_composer_cc_bcc/models/res_company.py b/mail_composer_cc_bcc/models/res_company.py new file mode 100644 index 000000000..47ea9e7ea --- /dev/null +++ b/mail_composer_cc_bcc/models/res_company.py @@ -0,0 +1,23 @@ +# Copyright 2023 Camptocamp +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class Company(models.Model): + _inherit = "res.company" + + default_partner_cc_ids = fields.Many2many( + "res.partner", + "res_company_res_partner_cc_rel", + "company_id", + "partner_id", + string="Default Cc", + ) + default_partner_bcc_ids = fields.Many2many( + "res.partner", + "res_company_res_partner_bcc_rel", + "company_id", + "partner_id", + string="Default Bcc", + ) diff --git a/mail_composer_cc_bcc/readme/CONFIGURE.rst b/mail_composer_cc_bcc/readme/CONFIGURE.rst new file mode 100644 index 000000000..6011282f6 --- /dev/null +++ b/mail_composer_cc_bcc/readme/CONFIGURE.rst @@ -0,0 +1,8 @@ +In company form there are two fields to set default cc and bcc +partners. + + .. image:: ../static/img/res_company_form_default_cc_bcc.png + +In template form there are two fields to set cc and bcc emails. + + .. image:: ../static/img/email_template_form_cc_bcc.png diff --git a/mail_composer_cc_bcc/readme/CONTRIBUTORS.rst b/mail_composer_cc_bcc/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..796a20add --- /dev/null +++ b/mail_composer_cc_bcc/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Trobz `_: + + * Hai N. Le diff --git a/mail_composer_cc_bcc/readme/CREDITS.rst b/mail_composer_cc_bcc/readme/CREDITS.rst new file mode 100644 index 000000000..ac19123b0 --- /dev/null +++ b/mail_composer_cc_bcc/readme/CREDITS.rst @@ -0,0 +1 @@ +The creation of this module was financially supported by Camptocamp. diff --git a/mail_composer_cc_bcc/readme/DESCRIPTION.rst b/mail_composer_cc_bcc/readme/DESCRIPTION.rst new file mode 100644 index 000000000..5bc4d650f --- /dev/null +++ b/mail_composer_cc_bcc/readme/DESCRIPTION.rst @@ -0,0 +1,16 @@ +Odoo native does not support defining a Cc field in the Mail Composer +by default; instead, it only has a unique Recipients fields, which is +confusing for a lot of end users. + +This module allows to properly separate To:, Cc:, and Bcc: fields +in the Mail Composer. + +Features +~~~~~~~~ + +* Add Cc and Bcc fields to mail composer form. Send only once to multiple email + addresses. +* 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. diff --git a/mail_composer_cc_bcc/readme/USAGE.rst b/mail_composer_cc_bcc/readme/USAGE.rst new file mode 100644 index 000000000..5f24f3804 --- /dev/null +++ b/mail_composer_cc_bcc/readme/USAGE.rst @@ -0,0 +1,10 @@ +The partners cc and bcc from company form will be used to fill in mail composer +form. + + .. image:: ../static/img/mail_compose_message_default_cc_bcc.png + +When select a template that has cc and/or bcc emails, the emails will be used +to lookup partners then found partners will be added to corresponding mail +composer's fields. + + .. image:: ../static/img/mail_compose_message_template_cc_bcc.png diff --git a/mail_composer_cc_bcc/static/description/index.html b/mail_composer_cc_bcc/static/description/index.html new file mode 100644 index 000000000..13ad4cc7b --- /dev/null +++ b/mail_composer_cc_bcc/static/description/index.html @@ -0,0 +1,477 @@ + + + + + + +Email CC and BCC + + + +
+

Email CC and BCC

+ + +

Alpha License: AGPL-3 OCA/social Translate me on Weblate Try me on Runboat

+

Odoo native does not support defining a Cc field in the Mail Composer +by default; instead, it only has a unique Recipients fields, which is +confusing for a lot of end users.

+

This module allows to properly separate To:, Cc:, and Bcc: fields +in the Mail Composer.

+
+

Features

+
    +
  • Add Cc and Bcc fields to mail composer form. Send only once to multiple email +addresses.
  • +
  • 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. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+

In company form there are two fields to set default cc and bcc +partners.

+
+https://raw.githubusercontent.com/OCA/social/15.0/mail_composer_cc_bcc/static/img/res_company_form_default_cc_bcc.png +
+

In template form there are two fields to set cc and bcc emails.

+
+https://raw.githubusercontent.com/OCA/social/15.0/mail_composer_cc_bcc/static/img/email_template_form_cc_bcc.png +
+
+
+

Usage

+

The partners cc and bcc from company form will be used to fill in mail composer +form.

+
+https://raw.githubusercontent.com/OCA/social/15.0/mail_composer_cc_bcc/static/img/mail_compose_message_default_cc_bcc.png +
+

When select a template that has cc and/or bcc emails, the emails will be used +to lookup partners then found partners will be added to corresponding mail +composer’s fields.

+
+https://raw.githubusercontent.com/OCA/social/15.0/mail_composer_cc_bcc/static/img/mail_compose_message_template_cc_bcc.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.

+
+ +
+
+

Authors

+
    +
  • Camptocamp SA
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The creation of this module was financially supported by Camptocamp.

+
+
+

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:

+

hailangvn2023

+

This module is part of the OCA/social project on GitHub.

+

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

+
+
+ + diff --git a/mail_composer_cc_bcc/static/img/email_template_form_cc_bcc.png b/mail_composer_cc_bcc/static/img/email_template_form_cc_bcc.png new file mode 100644 index 000000000..336e4c1a9 Binary files /dev/null and b/mail_composer_cc_bcc/static/img/email_template_form_cc_bcc.png differ diff --git a/mail_composer_cc_bcc/static/img/mail_compose_message_default_cc_bcc.png b/mail_composer_cc_bcc/static/img/mail_compose_message_default_cc_bcc.png new file mode 100644 index 000000000..7e1b07066 Binary files /dev/null and b/mail_composer_cc_bcc/static/img/mail_compose_message_default_cc_bcc.png differ diff --git a/mail_composer_cc_bcc/static/img/mail_compose_message_template_cc_bcc.png b/mail_composer_cc_bcc/static/img/mail_compose_message_template_cc_bcc.png new file mode 100644 index 000000000..f1de0f5e5 Binary files /dev/null and b/mail_composer_cc_bcc/static/img/mail_compose_message_template_cc_bcc.png differ diff --git a/mail_composer_cc_bcc/static/img/res_company_form_default_cc_bcc.png b/mail_composer_cc_bcc/static/img/res_company_form_default_cc_bcc.png new file mode 100644 index 000000000..8393e3466 Binary files /dev/null and b/mail_composer_cc_bcc/static/img/res_company_form_default_cc_bcc.png differ diff --git a/mail_composer_cc_bcc/tests/__init__.py b/mail_composer_cc_bcc/tests/__init__.py new file mode 100644 index 000000000..7a8ae2177 --- /dev/null +++ b/mail_composer_cc_bcc/tests/__init__.py @@ -0,0 +1 @@ +from . import test_mail_cc_bcc diff --git a/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py b/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py new file mode 100644 index 000000000..49f44094c --- /dev/null +++ b/mail_composer_cc_bcc/tests/test_mail_cc_bcc.py @@ -0,0 +1,180 @@ +# Copyright 2023 Camptocamp +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import hashlib +import inspect + +from odoo import tools +from odoo.tests import Form + +from odoo.addons.mail.models.mail_mail import MailMail as upstream +from odoo.addons.mail.tests.test_mail_composer import TestMailComposer + +VALID_HASHES = ["5f8b9bd28ccfe4f4ef1702002b2ab3fc"] + + +class TestMailCcBcc(TestMailComposer): + @classmethod + def setUpClass(cls): + super().setUpClass() + env = cls.env + cls.partner = env.ref("base.res_partner_address_31") + cls.partner_cc = env.ref("base.partner_demo") + cls.partner_cc2 = env.ref("base.partner_demo_portal") + cls.partner_cc3 = env.ref("base.res_partner_main1") + cls.partner_bcc = env.ref("base.res_partner_main2") + + def open_mail_composer_form(self): + # Use form to populate data + test_record = self.test_record.with_env(self.env) + ctx = { + "default_partner_ids": test_record.ids, + "default_model": test_record._name, + "default_res_id": test_record.id, + } + form = Form(self.env["mail.compose.message"].with_context(**ctx)) + form.body = "

Hello

" + return form + + def open_invoice_mail_composer_form(self): + # Use form to populate data + for_name = [("name", "=", "INV/2023/00003")] + self.test_invoice = test_record = self.env["account.move"].search(for_name) + ctx = { + "active_ids": test_record.ids, + "default_model": "account.move", + "default_res_id": test_record.id, + } + form = Form(self.env["account.invoice.send"].with_context(**ctx)) + form.body = "

Hello

" + return form + + def test_upstream_file_hash(self): + """Test that copied upstream function hasn't received fixes""" + func = inspect.getsource(upstream._send).encode() + func_hash = hashlib.md5(func).hexdigest() + self.assertIn(func_hash, VALID_HASHES) + + def test_email_cc_bcc(self): + form = self.open_mail_composer_form() + composer = form.save() + # Use object to update Many2many fields (form can't do like this) + composer.partner_cc_ids = self.partner_cc + composer.partner_cc_ids |= self.partner_cc2 + composer.partner_cc_ids |= self.partner_cc3 + composer.partner_bcc_ids = self.partner_bcc + + with self.mock_mail_gateway(): + composer._action_send_mail() + + # Verify recipients of mail.message + message = self.test_record.message_ids[0] + self.assertEqual(len(message.recipient_cc_ids), 3) + self.assertEqual(len(message.recipient_bcc_ids), 1) + # Verify notification + for_message = [ + ("mail_message_id", "=", message.id), + ("notification_type", "=", "email"), + ] + notif = self.env["mail.notification"].search(for_message) + self.assertEqual(len(notif), 5) + # Verify data of mail.mail + mail = message.mail_ids + expecting = ", ".join( + [ + '"Marc Demo" ', + '"Joel Willis" ', + '"Chester Reed" ', + ] + ) + self.assertEqual(mail.email_cc, expecting) + expecting = '"Dwayne Newman" ' + self.assertEqual(mail.email_bcc, expecting) + + def test_template_cc_bcc(self): + env = self.env + # Company default values + env.company.default_partner_cc_ids = self.partner_cc3 + env.company.default_partner_bcc_ids = self.partner_cc2 + # Product template values + tmpl_model = env["ir.model"].search([("model", "=", "product.template")]) + partner_cc = self.partner_cc + partner_bcc = self.partner_bcc + vals = { + "name": "Product Template: Re: [E-COM11] Cabinet with Doors", + "model_id": tmpl_model.id, + "subject": "Re: [E-COM11] Cabinet with Doors", + "body_html": """

+Test Template

""", + "email_cc": tools.formataddr( + (partner_cc.name or "False", partner_cc.email or "False") + ), + "email_bcc": tools.formataddr( + (partner_bcc.name or "False", partner_bcc.email or "False") + ), + } + prod_tmpl = env["mail.template"].create(vals) + # Open mail composer form and check for default values from company + form = self.open_mail_composer_form() + composer = form.save() + self.assertEqual(composer.partner_cc_ids, self.partner_cc3) + self.assertEqual(composer.partner_bcc_ids, self.partner_cc2) + # Change email template and check for values from it + form.template_id = prod_tmpl + composer = form.save() + # Beside existing Cc and Bcc, add template's ones + form = Form(composer) + form.template_id = prod_tmpl + composer = form.save() + expecting = self.partner_cc3 + self.partner_cc + self.assertEqual(composer.partner_cc_ids, expecting) + expecting = self.partner_cc2 + self.partner_bcc + self.assertEqual(composer.partner_bcc_ids, expecting) + # But not add Marc Demo from cc field to partner_ids field + self.assertEqual(len(composer.partner_ids), 1) + self.assertEqual(composer.partner_ids.display_name, "Test") + # Selecting the template again doesn't add as the partners already + # in the list + form = Form(composer) + form.template_id = env["mail.template"] + form.save() + self.assertFalse(form.template_id) + form.template_id = prod_tmpl + composer = form.save() + expecting = self.partner_cc3 + self.partner_cc + self.assertEqual(composer.partner_cc_ids, expecting) + expecting = self.partner_cc2 + self.partner_bcc + self.assertEqual(composer.partner_bcc_ids, expecting) + + def set_company(self): + company = self.env.company + # Company default values + company.default_partner_cc_ids = self.partner_cc3 + company.default_partner_bcc_ids = self.partner_cc2 + + def test_recipient_ids_and_cc_bcc(self): + self.set_company() + form = self.open_mail_composer_form() + composer = form.save() + composer.partner_ids = self.partner + self.partner_cc + + with self.mock_mail_gateway(): + composer._action_send_mail() + message = self.test_record.message_ids[0] + self.assertEqual(len(message.mail_ids), 1) + # Only 4 partners notified + self.assertEqual(len(message.notified_partner_ids), 4) + self.assertEqual(len(message.notification_ids), 4) + + def test_invoice_mail_cc_bcc(self): + self.set_company() + form = self.open_invoice_mail_composer_form() + form.subject = "Hello" + composer = form.save() + with self.mock_mail_gateway(): + composer._send_email() + message = self.test_invoice.message_ids[0] + self.assertEqual(len(message.mail_ids), 1) + # Only 4 partners notified + self.assertEqual(len(message.notified_partner_ids), 4) + self.assertEqual(len(message.notification_ids), 4) diff --git a/mail_composer_cc_bcc/views/mail_mail_views.xml b/mail_composer_cc_bcc/views/mail_mail_views.xml new file mode 100644 index 000000000..58c8a8d1c --- /dev/null +++ b/mail_composer_cc_bcc/views/mail_mail_views.xml @@ -0,0 +1,16 @@ + + + + email.mail.form.inherit + mail.mail + + + + + + + + diff --git a/mail_composer_cc_bcc/views/mail_message_views.xml b/mail_composer_cc_bcc/views/mail_message_views.xml new file mode 100644 index 000000000..13a2a2739 --- /dev/null +++ b/mail_composer_cc_bcc/views/mail_message_views.xml @@ -0,0 +1,22 @@ + + + + mail.message.form.inherit + mail.message + + + + + + + + + diff --git a/mail_composer_cc_bcc/views/mail_template_views.xml b/mail_composer_cc_bcc/views/mail_template_views.xml new file mode 100644 index 000000000..c54c355e7 --- /dev/null +++ b/mail_composer_cc_bcc/views/mail_template_views.xml @@ -0,0 +1,17 @@ + + + + email.template.form.inherit + mail.template + + + + + + + + diff --git a/mail_composer_cc_bcc/views/res_company_views.xml b/mail_composer_cc_bcc/views/res_company_views.xml new file mode 100644 index 000000000..a1f6555d5 --- /dev/null +++ b/mail_composer_cc_bcc/views/res_company_views.xml @@ -0,0 +1,22 @@ + + + + res.company.form.inherit + res.company + + + + + + + + + diff --git a/mail_composer_cc_bcc/wizards/__init__.py b/mail_composer_cc_bcc/wizards/__init__.py new file mode 100644 index 000000000..b528d997d --- /dev/null +++ b/mail_composer_cc_bcc/wizards/__init__.py @@ -0,0 +1 @@ +from . import mail_compose_message diff --git a/mail_composer_cc_bcc/wizards/account_invoice_send_views.xml b/mail_composer_cc_bcc/wizards/account_invoice_send_views.xml new file mode 100644 index 000000000..c618c098b --- /dev/null +++ b/mail_composer_cc_bcc/wizards/account_invoice_send_views.xml @@ -0,0 +1,26 @@ + + + + + account.invoice.send.form.inherit + account.invoice.send + + + + + + + + + + diff --git a/mail_composer_cc_bcc/wizards/mail_compose_message.py b/mail_composer_cc_bcc/wizards/mail_compose_message.py new file mode 100644 index 000000000..f3af1dfd5 --- /dev/null +++ b/mail_composer_cc_bcc/wizards/mail_compose_message.py @@ -0,0 +1,81 @@ +# Copyright 2023 Camptocamp +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, api, fields, models, tools + +CC_BCC_FIELDS = { + "email_cc": "partner_cc_ids", + "email_bcc": "partner_bcc_ids", +} + + +class MailComposeMessage(models.TransientModel): + _inherit = "mail.compose.message" + + @api.model + def default_get(self, fields_list): + company = self.env.company + res = super().default_get(fields_list) + partner_cc = company.default_partner_cc_ids + if partner_cc: + res["partner_cc_ids"] = [Command.set(partner_cc.ids)] + partner_bcc = company.default_partner_bcc_ids + if partner_bcc: + res["partner_bcc_ids"] = [Command.set(partner_bcc.ids)] + return res + + partner_cc_ids = fields.Many2many( + "res.partner", + "mail_compose_message_res_partner_cc_rel", + "wizard_id", + "partner_id", + string="Cc", + ) + partner_bcc_ids = fields.Many2many( + "res.partner", + "mail_compose_message_res_partner_bcc_rel", + "wizard_id", + "partner_id", + string="Bcc", + ) + + def _onchange_template_id(self, template_id, composition_mode, model, res_id): + if not template_id: + return {"value": {}} + ctx = {"is_from_composer": True} + ctx.update(self.env.context) + self_ctx = self.with_context(**ctx) + res = super(MailComposeMessage, self_ctx)._onchange_template_id( + template_id, composition_mode, model, res_id + ) + res_ids = [res_id] + # tpl_partners_only need to be False for email_cc value + tmpl_ctx = self.env["mail.template"].with_context(tpl_partners_only=False) + mail_tmpl = tmpl_ctx.browse(template_id) + template_values = mail_tmpl.generate_email(res_ids, CC_BCC_FIELDS) + values = template_values[res_id] + for fname in CC_BCC_FIELDS: + value = values.get(fname, None) + if not value: + continue + self._set_partner_field(CC_BCC_FIELDS[fname], value) + return res + + def _set_partner_field(self, field_name, email): + if field_name not in CC_BCC_FIELDS.values(): + return + for_email = [("email", "in", tools.email_split(email))] + partner = self.env["res.partner"].search(for_email) + current_partners = getattr(self, field_name) + setattr(self, field_name, current_partners + partner) + + def _action_send_mail(self, auto_commit=False): + context = { + "is_from_composer": True, + "partner_cc_ids": self.partner_cc_ids, + "partner_bcc_ids": self.partner_bcc_ids, + } + context.update(self.env.context) + self_super = super(MailComposeMessage, self.with_context(**context)) + res = self_super._action_send_mail(auto_commit) + return res diff --git a/mail_composer_cc_bcc/wizards/mail_compose_message_view.xml b/mail_composer_cc_bcc/wizards/mail_compose_message_view.xml new file mode 100644 index 000000000..144f3266e --- /dev/null +++ b/mail_composer_cc_bcc/wizards/mail_compose_message_view.xml @@ -0,0 +1,24 @@ + + + + mail.compose.message.form.inherit + mail.compose.message + + + + + + + + + diff --git a/setup/mail_composer_cc_bcc/odoo/addons/mail_composer_cc_bcc b/setup/mail_composer_cc_bcc/odoo/addons/mail_composer_cc_bcc new file mode 120000 index 000000000..5bf6eaabc --- /dev/null +++ b/setup/mail_composer_cc_bcc/odoo/addons/mail_composer_cc_bcc @@ -0,0 +1 @@ +../../../../mail_composer_cc_bcc \ No newline at end of file diff --git a/setup/mail_composer_cc_bcc/setup.py b/setup/mail_composer_cc_bcc/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/mail_composer_cc_bcc/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)