From b82b336891bbe1b02ee7a3e1295c046b41cde5ad Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Sun, 16 Nov 2025 20:39:48 +0000 Subject: [PATCH 01/15] feat: Raw HTML emails --- .../public/js/frappe/views/communication.js | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 58e7456d2a..286bf6ac52 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -127,6 +127,15 @@ frappe.views.CommunicationComposer = class { fieldtype: "Text Editor", fieldname: "content", onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300), + depends_on: "eval:!doc.use_html", + }, + { + label: __("HTML Message"), + fieldtype: "Code", + fieldname: "html_content", + onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300), + depends_on: "eval:doc.use_html", + options: "HTML", }, { label: __("Message"), @@ -180,6 +189,19 @@ frappe.views.CommunicationComposer = class { depends_on: "attach_document_print", }, { fieldtype: "Column Break" }, + { + label: __("Use HTML"), + fieldtype: "Check", + fieldname: "use_html", + default: 0, + onchange: function (e) { + if (e.target.checked) { + me.dialog.set_value("html_content", me.dialog.get_value("content")); + } else { + me.dialog.set_value("content", me.dialog.get_value("html_content")); + } + }, + }, { label: __("Select Attachments"), fieldtype: "HTML", @@ -520,7 +542,7 @@ frappe.views.CommunicationComposer = class { if (this.message) return; const last_edited = this.get_last_edited_communication(); - if (!last_edited.content && !last_edited.content_html) return; + if (!last_edited.content && !last_edited.html_content) return; // prevent re-triggering of email template if (last_edited.email_template) { From 75b481a4f9e174db15f234c8ee57f9bf39f04e2b Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 19 Nov 2025 08:09:36 +0000 Subject: [PATCH 02/15] feat: Inject footer and header within an HTML Email Template --- .../doctype/email_template/email_template.py | 25 ++++++++++++++++--- .../public/js/frappe/views/communication.js | 1 + 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index b37598e051..b06e5c3977 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -37,19 +37,38 @@ class EmailTemplate(Document): def get_formatted_response(self, doc): return frappe.render_template(self.response_, doc) - def get_formatted_email(self, doc): + def get_formatted_email(self, doc, sender=None): if isinstance(doc, str): doc = json.loads(doc) + if self.use_html: + doc = self.inject_email_account(doc, sender) + return { "subject": self.get_formatted_subject(doc), "message": self.get_formatted_response(doc), } + def inject_email_account(self, doc, sender=None): + from frappe.email.doctype.email_account.email_account import EmailAccount + from frappe.email.email_body import get_footer, get_signature + + if sender: + kwargs = {"match_by_email": sender} + else: + kwargs = {"match_by_doctype": doc.get("doctype")} + email_account = EmailAccount.find_outgoing(**kwargs) + + if email_account: + doc.update( + {"email_signature": get_signature(email_account), "email_footer": get_footer(email_account)} + ) + return doc + @frappe.whitelist() -def get_email_template(template_name, doc): +def get_email_template(template_name, doc, sender=None): """Return the processed HTML of a email template with the given doc""" email_template = frappe.get_doc("Email Template", template_name) - return email_template.get_formatted_email(doc) + return email_template.get_formatted_email(doc, sender=sender) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 286bf6ac52..0d3c7fe1be 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -450,6 +450,7 @@ frappe.views.CommunicationComposer = class { args: { template_name: email_template, doc: me.doc, + sender: me.dialog.get_value("sender") || "", }, callback(r) { prepend_reply(r.message); From e835f1c7c9b316f17cdc87f3c11b4b3bf452b07c Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 19 Nov 2025 14:23:06 +0000 Subject: [PATCH 03/15] feat: Add raw_html parameter to all relevant doctypes --- frappe/core/doctype/communication/email.py | 5 ++ frappe/core/doctype/communication/mixins.py | 4 ++ frappe/core/doctype/user/user.py | 2 +- frappe/email/__init__.py | 3 + .../doctype/email_queue/email_queue.json | 14 ++++- .../email/doctype/email_queue/email_queue.py | 6 ++ frappe/email/email_body.py | 56 ++++++++++++------- .../public/js/frappe/views/communication.js | 1 + frappe/utils/jinja.py | 1 + 9 files changed, 68 insertions(+), 24 deletions(-) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index d2b79224dd..55e3d2c125 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -50,6 +50,7 @@ def make( send_after=None, print_language=None, now=False, + raw_html=False, **kwargs, ) -> dict[str, str]: """Make a new communication. Checks for email permissions for specified Document. @@ -69,6 +70,7 @@ def make( :param send_me_a_copy: Send a copy to the sender (default **False**). :param email_template: Template which is used to compose mail . :param send_after: Send after the given datetime. + :param raw_html: Whether to use html version of email template """ if kwargs: from frappe.utils.commands import warn @@ -107,6 +109,7 @@ def make( send_after=send_after, print_language=print_language, now=now, + raw_html=raw_html, ) @@ -135,6 +138,7 @@ def _make( send_after=None, print_language=None, now=False, + raw_html=False, ) -> dict[str, str]: """Internal method to make a new communication that ignores Permission checks.""" @@ -190,6 +194,7 @@ def _make( print_letterhead=print_letterhead, print_language=print_language, now=now, + raw_html=raw_html, ) emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy) diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index 4318a451e5..68dd0bce06 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -258,6 +258,7 @@ class CommunicationEmailMixin: print_letterhead=None, is_inbound_mail_communcation=None, print_language=None, + raw_html=False, ) -> dict: outgoing_email_account = self.get_outgoing_email_account() if not outgoing_email_account: @@ -307,6 +308,7 @@ class CommunicationEmailMixin: "is_notification": (self.sent_or_received == "Received"), "print_letterhead": print_letterhead, "send_after": self.send_after, + "raw_html": raw_html, } def send_email( @@ -318,6 +320,7 @@ class CommunicationEmailMixin: is_inbound_mail_communcation=None, print_language=None, now=False, + raw_html=False, ): if input_dict := self.sendmail_input_dict( print_html=print_html, @@ -326,5 +329,6 @@ class CommunicationEmailMixin: print_letterhead=print_letterhead, is_inbound_mail_communcation=is_inbound_mail_communcation, print_language=print_language, + raw_html=raw_html, ): frappe.sendmail(now=now, **input_dict) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 4fbc28a96a..79987fe22b 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -555,7 +555,7 @@ class User(Document): if custom_template: from frappe.email.doctype.email_template.email_template import get_email_template - email_template = get_email_template(custom_template, args) + email_template = get_email_template(custom_template, args, sender=sender) subject = email_template.get("subject") content = email_template.get("message") diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index c73ae65f04..ad5198bf87 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -150,6 +150,7 @@ def sendmail( email_read_tracker_url=None, x_priority: Literal[1, 3, 5] = 3, email_headers=None, + raw_html=False, ) -> EmailQueue | None: """Send email using user's default **Email Account** or global default **Email Account**. @@ -179,6 +180,7 @@ def sendmail( :param with_container: Wraps email inside a styled container :param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST :param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present. + :param raw_html: Whether to treat email template as a complete HTML file """ from frappe.utils.jinja import get_email_from_template @@ -238,6 +240,7 @@ def sendmail( email_read_tracker_url=email_read_tracker_url, x_priority=x_priority, email_headers=email_headers, + raw_html=raw_html, ) # build email queue and send the email if send_now is True. diff --git a/frappe/email/doctype/email_queue/email_queue.json b/frappe/email/doctype/email_queue/email_queue.json index cc01c9f56a..1379c1fcb6 100644 --- a/frappe/email/doctype/email_queue/email_queue.json +++ b/frappe/email/doctype/email_queue/email_queue.json @@ -25,7 +25,8 @@ "expose_recipients", "attachments", "retry", - "email_account" + "email_account", + "raw_html" ], "fields": [ { @@ -148,13 +149,20 @@ "fieldtype": "Code", "label": "Unsubscribe Params", "read_only": 1 + }, + { + "default": "0", + "fieldname": "raw_html", + "fieldtype": "Check", + "label": "Send As Raw HTML", + "read_only": 1 } ], "icon": "fa fa-envelope", "idx": 1, "in_create": 1, "links": [], - "modified": "2025-03-07 15:56:13.341958", + "modified": "2025-11-19 11:18:45.574190", "modified_by": "Administrator", "module": "Email", "name": "Email Queue", @@ -175,4 +183,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 7df03de990..3a85e5ad38 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -60,6 +60,7 @@ class EmailQueue(Document): message: DF.Code | None message_id: DF.SmallText | None priority: DF.Int + raw_html: DF.Check recipients: DF.Table[EmailQueueRecipient] reference_doctype: DF.Link | None reference_name: DF.Data | None @@ -518,6 +519,7 @@ class QueueBuilder: email_read_tracker_url=None, x_priority: Literal[1, 3, 5] = 3, email_headers=None, + raw_html=False, ): """Add email to sending queue (Email Queue) @@ -545,6 +547,7 @@ class QueueBuilder: :param email_read_tracker_url: A URL for tracking whether an email is read by the recipient. :param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST :param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present. + :param raw_html: Whether to treat email template as a complete HTML file """ self._unsubscribe_method = unsubscribe_method @@ -582,6 +585,7 @@ class QueueBuilder: self.print_letterhead = print_letterhead self.email_read_tracker_url = email_read_tracker_url self.email_headers = email_headers + self.raw_html = raw_html @property def unsubscribe_method(self): @@ -638,6 +642,7 @@ class QueueBuilder: email_account=email_account, unsubscribe_link=self.unsubscribe_message(), with_container=self.with_container, + raw_html=self.raw_html, ) def should_include_unsubscribe_link(self): @@ -843,6 +848,7 @@ class QueueBuilder: "show_as_bcc": ",".join(self.final_bcc()), "email_account": email_account_name or None, "email_read_tracker_url": self.email_read_tracker_url, + "raw_html": self.raw_html, } if include_recipients: diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 707d301546..713004845a 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -381,29 +381,42 @@ def get_formatted_html( unsubscribe_link: frappe._dict | None = None, sender=None, with_container=False, + raw_html=False, ): email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender) - rendered_email = frappe.get_template("templates/emails/standard.html").render( - { - "brand_logo": get_brand_logo(email_account) if with_container or header else None, - "with_container": with_container, - "site_url": get_url(), - "header": get_header(header), - "content": message, - "footer": get_footer(email_account, footer), - "title": subject, - "print_html": print_html, - "subject": subject, - } - ) + if raw_html: + rendered_email = frappe.render_template( + message, + { + "site_url": get_url(), + "title": subject, + "print_html": print_html, + "subject": subject, + }, + ) + + else: + rendered_email = frappe.get_template("templates/emails/standard.html").render( + { + "brand_logo": get_brand_logo(email_account) if with_container or header else None, + "with_container": with_container, + "site_url": get_url(), + "header": get_header(header), + "content": message, + "footer": get_footer(email_account, footer), + "title": subject, + "print_html": print_html, + "subject": subject, + } + ) html = scrub_urls(rendered_email) if unsubscribe_link: html = html.replace("", unsubscribe_link.html) - return inline_style_in_html(html) + return inline_style_in_html(html, add_css=not raw_html) @frappe.whitelist() @@ -418,17 +431,20 @@ def get_email_html(template, args, subject, header=None, with_container=False): return get_formatted_html(subject, email[0], header=header, with_container=with_container) -def inline_style_in_html(html): +def inline_style_in_html(html, add_css=True): """Convert email.css and html to inline-styled html.""" from premailer import Premailer from frappe.utils.jinja_globals import bundled_asset - # get email css files from hooks - css_files = frappe.get_hooks("email_css") - css_files = [bundled_asset(path) for path in css_files] - css_files = [path.lstrip("/") for path in css_files] - css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))] + if add_css: + # get email css files from hooks + css_files = frappe.get_hooks("email_css") + css_files = [bundled_asset(path) for path in css_files] + css_files = [path.lstrip("/") for path in css_files] + css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))] + else: + css_files = None p = Premailer( html=html, external_styles=css_files, strip_important=False, allow_loading_external_files=True diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 0d3c7fe1be..35bc700446 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -846,6 +846,7 @@ frappe.views.CommunicationComposer = class { print_letterhead: me.is_print_letterhead_checked(), send_after: form_values.send_after ? form_values.send_after : null, print_language: form_values.print_language, + raw_html: form_values.use_html, }, btn, callback(r) { diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py index b8c979796a..7ef65d5902 100644 --- a/frappe/utils/jinja.py +++ b/frappe/utils/jinja.py @@ -134,6 +134,7 @@ def render_template(template, context=None, is_path=None, safe_render=True): title="Jinja Template Error", msg=f"
{template}
{html.escape(get_traceback())}
", ) + return "" import time From dd50155b89e0d0c81641f9fbd094c39df475af93 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Thu, 20 Nov 2025 12:21:19 +0000 Subject: [PATCH 04/15] fix: Add Server-side safety checks on use of Raw HTML messages --- frappe/core/doctype/communication/email.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 55e3d2c125..0f8e0f6e22 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -72,9 +72,9 @@ def make( :param send_after: Send after the given datetime. :param raw_html: Whether to use html version of email template """ - if kwargs: - from frappe.utils.commands import warn + from frappe.utils.commands import warn + if kwargs: warn( f"Options {kwargs} used in frappe.core.doctype.communication.email.make " "are deprecated or unsupported", @@ -84,6 +84,16 @@ def make( if doctype and name: frappe.has_permission(doctype, doc=name, ptype="email", throw=True) + if raw_html and email_template and not frappe.get_value("Email Template", email_template, "use_html"): + warn( + _( + "Raw HTML can be used only with Email Templates having 'Use HTML' checked. " + "Proceeding with plain text email." + ), + category=UserWarning, + ) + raw_html = False + return _make( doctype=doctype, name=name, @@ -169,7 +179,9 @@ def _make( "send_after": send_after, } ) - comm.flags.skip_add_signature = not add_signature + comm.flags.skip_add_signature = not add_signature or ( + raw_html and frappe.get_value("Email Template", email_template, "use_html") + ) comm.insert(ignore_permissions=True) # if not committed, delayed task doesn't find the communication From 9b75fa54878a5a1c74d6f2470220e220b082553b Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Thu, 20 Nov 2025 12:25:29 +0000 Subject: [PATCH 05/15] fix: UI/UX improvements wrt 'Use HTML' toggle --- .../public/js/frappe/views/communication.js | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 35bc700446..95e8633217 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -108,12 +108,48 @@ frappe.views.CommunicationComposer = class { fieldtype: "Link", options: "Email Template", fieldname: "email_template", + onchange: function () { + const email_template = this.value; + if (!email_template) { + return me.hide_use_html_field(); + } + + frappe.db + .get_value("Email Template", email_template, "use_html") + .then((r) => { + // Show or hide "Use HTML" based on the Email Template's use_html value + if (r.message?.use_html === 1) { + // Show the field. + me.dialog.fields_dict.use_html.toggle(true); + } else { + me.hide_use_html_field(); + } + }) + .catch((e) => { + console.error("Failed to load template", e); + me.hide_use_html_field(); + }); + }, }, { fieldtype: "HTML", label: __("Clear & Add template"), fieldname: "clear_and_add_template", }, + { + label: __("Use HTML"), + fieldtype: "Check", + fieldname: "use_html", + default: 0, + hidden: 1, + onchange: function (e) { + if (e.target.checked) { + me.dialog.set_value("html_content", me.dialog.get_value("content")); + } else { + me.dialog.set_value("content", me.dialog.get_value("html_content")); + } + }, + }, { fieldtype: "Section Break" }, { label: __("Subject"), @@ -189,19 +225,6 @@ frappe.views.CommunicationComposer = class { depends_on: "attach_document_print", }, { fieldtype: "Column Break" }, - { - label: __("Use HTML"), - fieldtype: "Check", - fieldname: "use_html", - default: 0, - onchange: function (e) { - if (e.target.checked) { - me.dialog.set_value("html_content", me.dialog.get_value("content")); - } else { - me.dialog.set_value("content", me.dialog.get_value("html_content")); - } - }, - }, { label: __("Select Attachments"), fieldtype: "HTML", @@ -276,6 +299,11 @@ frappe.views.CommunicationComposer = class { this.dialog.set_value("print_language", lang); } + hide_use_html_field() { + this.dialog.fields_dict.use_html.set_input(false); // reset the value + this.dialog.fields_dict.use_html.toggle(false); // hide the field + } + toggle_more_options(show_options) { show_options = show_options || this.dialog.fields_dict.more_options.df.hidden; this.dialog.set_df_property("more_options", "hidden", !show_options); @@ -475,6 +503,7 @@ frappe.views.CommunicationComposer = class { ]; frappe.utils.add_select_group_button(clear_and_add_template, email_template_actions); + $(fields.use_html.wrapper).addClass("mt-2 text-center").appendTo(clear_and_add_template); } setup_last_edited_communication() { From a1cb7430e893cde56fcbf33ea6b64c8233bd211b Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 26 Nov 2025 16:22:43 +0000 Subject: [PATCH 06/15] fix: Email Dialog use_html field hidden after re-opening --- .../public/js/frappe/views/communication.js | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 95e8633217..20a4ae6ab1 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -108,27 +108,12 @@ frappe.views.CommunicationComposer = class { fieldtype: "Link", options: "Email Template", fieldname: "email_template", - onchange: function () { + onchange: async function () { const email_template = this.value; if (!email_template) { return me.hide_use_html_field(); } - - frappe.db - .get_value("Email Template", email_template, "use_html") - .then((r) => { - // Show or hide "Use HTML" based on the Email Template's use_html value - if (r.message?.use_html === 1) { - // Show the field. - me.dialog.fields_dict.use_html.toggle(true); - } else { - me.hide_use_html_field(); - } - }) - .catch((e) => { - console.error("Failed to load template", e); - me.hide_use_html_field(); - }); + await me.check_email_template_html(email_template); }, }, { @@ -143,6 +128,7 @@ frappe.views.CommunicationComposer = class { default: 0, hidden: 1, onchange: function (e) { + if (!e) return; if (e.target.checked) { me.dialog.set_value("html_content", me.dialog.get_value("content")); } else { @@ -299,6 +285,17 @@ frappe.views.CommunicationComposer = class { this.dialog.set_value("print_language", lang); } + async check_email_template_html(email_template) { + const r = await frappe.db.get_value("Email Template", email_template, "use_html"); + // Show or hide "Use HTML" based on the Email Template's use_html value + if (r.message?.use_html === 1) { + // Show the field. + this.dialog.fields_dict.use_html.toggle(true); + } else { + this.hide_use_html_field(); + } + } + hide_use_html_field() { this.dialog.fields_dict.use_html.set_input(false); // reset the value this.dialog.fields_dict.use_html.toggle(false); // hide the field @@ -469,7 +466,7 @@ frappe.views.CommunicationComposer = class { let content = content_field.get_value() || ""; - content_field.set_value(`${reply.message}
${content}`); + content_field.set_value(reply.message + content); subject_field.set_value(reply.subject); } @@ -578,6 +575,7 @@ frappe.views.CommunicationComposer = class { if (last_edited.email_template) { const template_field = this.dialog.fields_dict.email_template; await template_field.set_model_value(last_edited.email_template); + await this.check_email_template_html(last_edited.email_template); delete last_edited.email_template; } From 79eedf4fb71f2a31623f94f0650212e4d0f27e43 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Thu, 27 Nov 2025 08:40:59 +0000 Subject: [PATCH 07/15] refactor: Add get_content_field function to communication.js --- frappe/public/js/frappe/views/communication.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 20a4ae6ab1..38456fa9d7 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -252,6 +252,13 @@ frappe.views.CommunicationComposer = class { return fields; } + get_content_field() { + const content_field = this.dialog.fields_dict.use_html.value + ? this.dialog.fields_dict.html_content + : this.dialog.fields_dict.content; + return content_field; + } + get_default_recipients(fieldname) { if (this.frm?.events.get_email_recipients) { return (this.frm.events.get_email_recipients(this.frm, fieldname) || []).join(", "); From 4c9cf3f4cf3e997f5bd423ea6bb8f880f45ff311 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Thu, 27 Nov 2025 10:40:30 +0000 Subject: [PATCH 08/15] revert: remove return statement added to prevent linter warnings --- frappe/utils/jinja.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py index 7ef65d5902..b8c979796a 100644 --- a/frappe/utils/jinja.py +++ b/frappe/utils/jinja.py @@ -134,7 +134,6 @@ def render_template(template, context=None, is_path=None, safe_render=True): title="Jinja Template Error", msg=f"
{template}
{html.escape(get_traceback())}
", ) - return "" import time From 84bf12711b1fb69ab28223052b731e84bb965ddd Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Sun, 4 Jan 2026 01:22:00 +0000 Subject: [PATCH 09/15] refactor: share params between render email template calls --- frappe/email/email_body.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 713004845a..bb65442389 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -385,31 +385,26 @@ def get_formatted_html( ): email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender) + params = { + "site_url": get_url(), + "title": subject, + "print_html": print_html, + "subject": subject, + } if raw_html: - rendered_email = frappe.render_template( - message, - { - "site_url": get_url(), - "title": subject, - "print_html": print_html, - "subject": subject, - }, - ) + rendered_email = frappe.render_template(message, params) else: - rendered_email = frappe.get_template("templates/emails/standard.html").render( + params.update( { "brand_logo": get_brand_logo(email_account) if with_container or header else None, "with_container": with_container, - "site_url": get_url(), "header": get_header(header), "content": message, "footer": get_footer(email_account, footer), - "title": subject, - "print_html": print_html, - "subject": subject, } ) + rendered_email = frappe.get_template("templates/emails/standard.html").render(params) html = scrub_urls(rendered_email) From e8d1d40ff1cff0d7988271b22096e93cad18e254 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Sun, 4 Jan 2026 01:36:58 +0000 Subject: [PATCH 10/15] feat(email): add option to exclude default CSS --- frappe/core/doctype/communication/email.py | 5 +++++ frappe/core/doctype/communication/mixins.py | 4 ++++ frappe/email/__init__.py | 3 +++ frappe/email/doctype/email_queue/email_queue.py | 5 +++++ frappe/email/email_body.py | 3 ++- frappe/public/js/frappe/views/communication.js | 8 ++++++++ 6 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 0f8e0f6e22..2aa543af27 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -51,6 +51,7 @@ def make( print_language=None, now=False, raw_html=False, + add_css=True, **kwargs, ) -> dict[str, str]: """Make a new communication. Checks for email permissions for specified Document. @@ -71,6 +72,7 @@ def make( :param email_template: Template which is used to compose mail . :param send_after: Send after the given datetime. :param raw_html: Whether to use html version of email template + :param add_css: Add default CSS from hooks/email_css to the email template (default **True**) """ from frappe.utils.commands import warn @@ -120,6 +122,7 @@ def make( print_language=print_language, now=now, raw_html=raw_html, + add_css=add_css, ) @@ -149,6 +152,7 @@ def _make( print_language=None, now=False, raw_html=False, + add_css=True, ) -> dict[str, str]: """Internal method to make a new communication that ignores Permission checks.""" @@ -207,6 +211,7 @@ def _make( print_language=print_language, now=now, raw_html=raw_html, + add_css=add_css, ) emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy) diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index 68dd0bce06..edf14baca5 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -259,6 +259,7 @@ class CommunicationEmailMixin: is_inbound_mail_communcation=None, print_language=None, raw_html=False, + add_css=True, ) -> dict: outgoing_email_account = self.get_outgoing_email_account() if not outgoing_email_account: @@ -309,6 +310,7 @@ class CommunicationEmailMixin: "print_letterhead": print_letterhead, "send_after": self.send_after, "raw_html": raw_html, + "add_css": add_css, } def send_email( @@ -321,6 +323,7 @@ class CommunicationEmailMixin: print_language=None, now=False, raw_html=False, + add_css=True, ): if input_dict := self.sendmail_input_dict( print_html=print_html, @@ -330,5 +333,6 @@ class CommunicationEmailMixin: is_inbound_mail_communcation=is_inbound_mail_communcation, print_language=print_language, raw_html=raw_html, + add_css=add_css, ): frappe.sendmail(now=now, **input_dict) diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index ad5198bf87..5bf23b655e 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -151,6 +151,7 @@ def sendmail( x_priority: Literal[1, 3, 5] = 3, email_headers=None, raw_html=False, + add_css=True, ) -> EmailQueue | None: """Send email using user's default **Email Account** or global default **Email Account**. @@ -181,6 +182,7 @@ def sendmail( :param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST :param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present. :param raw_html: Whether to treat email template as a complete HTML file + :param add_css: Whether to add CSS from hooks/email_css to the email template """ from frappe.utils.jinja import get_email_from_template @@ -241,6 +243,7 @@ def sendmail( x_priority=x_priority, email_headers=email_headers, raw_html=raw_html, + add_css=add_css, ) # build email queue and send the email if send_now is True. diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 3a85e5ad38..3ce20a0d12 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -520,6 +520,7 @@ class QueueBuilder: x_priority: Literal[1, 3, 5] = 3, email_headers=None, raw_html=False, + add_css=True, ): """Add email to sending queue (Email Queue) @@ -548,6 +549,7 @@ class QueueBuilder: :param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST :param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present. :param raw_html: Whether to treat email template as a complete HTML file + :param add_css: Add default CSS from hooks/email_css to the email template (default True) """ self._unsubscribe_method = unsubscribe_method @@ -586,6 +588,7 @@ class QueueBuilder: self.email_read_tracker_url = email_read_tracker_url self.email_headers = email_headers self.raw_html = raw_html + self.add_css = add_css @property def unsubscribe_method(self): @@ -643,6 +646,7 @@ class QueueBuilder: unsubscribe_link=self.unsubscribe_message(), with_container=self.with_container, raw_html=self.raw_html, + add_css=self.add_css, ) def should_include_unsubscribe_link(self): @@ -849,6 +853,7 @@ class QueueBuilder: "email_account": email_account_name or None, "email_read_tracker_url": self.email_read_tracker_url, "raw_html": self.raw_html, + "add_css": self.add_css, } if include_recipients: diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index bb65442389..a71955fdda 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -382,6 +382,7 @@ def get_formatted_html( sender=None, with_container=False, raw_html=False, + add_css=True, ): email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender) @@ -411,7 +412,7 @@ def get_formatted_html( if unsubscribe_link: html = html.replace("", unsubscribe_link.html) - return inline_style_in_html(html, add_css=not raw_html) + return inline_style_in_html(html, add_css=add_css) @frappe.whitelist() diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 38456fa9d7..05b32f1781 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -211,6 +211,13 @@ frappe.views.CommunicationComposer = class { depends_on: "attach_document_print", }, { fieldtype: "Column Break" }, + { + label: __("Add CSS"), + fieldtype: "Check", + fieldname: "add_css", + default: 1, + depends_on: "eval:doc.use_html", + }, { label: __("Select Attachments"), fieldtype: "HTML", @@ -881,6 +888,7 @@ frappe.views.CommunicationComposer = class { send_after: form_values.send_after ? form_values.send_after : null, print_language: form_values.print_language, raw_html: form_values.use_html, + add_css: form_values.add_css, }, btn, callback(r) { From ab52f39b9433f812b42eaa9bed436b0f7ce70a64 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Sun, 4 Jan 2026 04:27:57 +0000 Subject: [PATCH 11/15] refactor: consolidate CommunicationComposer get_content_field method --- frappe/public/js/frappe/views/communication.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 05b32f1781..70ac07a94f 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -260,10 +260,11 @@ frappe.views.CommunicationComposer = class { } get_content_field() { - const content_field = this.dialog.fields_dict.use_html.value - ? this.dialog.fields_dict.html_content - : this.dialog.fields_dict.content; - return content_field; + if (this.dialog.fields_dict.use_html.value) { + return this.dialog.fields_dict.html_content; + } else { + return this.dialog.fields_dict.content; + } } get_default_recipients(fieldname) { @@ -1068,11 +1069,6 @@ frappe.views.CommunicationComposer = class { return text.replace(/\n{3,}/g, "\n\n"); } - get_content_field() { - const use_html = this.dialog.get_value("use_html"); - return use_html ? this.dialog.fields_dict.content_html : this.dialog.fields_dict.content; - } - get_email_content() { return this.get_content_field().get_value() || ""; } From b9e6ba7f527da6dbdbe5f874294c67c9be97e311 Mon Sep 17 00:00:00 2001 From: "ALB.Leach" Date: Tue, 6 Jan 2026 01:30:04 +0000 Subject: [PATCH 12/15] refactor: use cached Email Templates Co-authored-by: Akhil Narang --- frappe/core/doctype/communication/email.py | 4 ++-- frappe/email/doctype/email_template/email_template.py | 3 +-- frappe/email/email_body.py | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 2aa543af27..6c73257abf 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -86,7 +86,7 @@ def make( if doctype and name: frappe.has_permission(doctype, doc=name, ptype="email", throw=True) - if raw_html and email_template and not frappe.get_value("Email Template", email_template, "use_html"): + if raw_html and email_template and not frappe.get_cached_value("Email Template", email_template, "use_html"): warn( _( "Raw HTML can be used only with Email Templates having 'Use HTML' checked. " @@ -184,7 +184,7 @@ def _make( } ) comm.flags.skip_add_signature = not add_signature or ( - raw_html and frappe.get_value("Email Template", email_template, "use_html") + raw_html and frappe.get_cached_value("Email Template", email_template, "use_html") ) comm.insert(ignore_permissions=True) diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index b06e5c3977..bf2647c13c 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -57,9 +57,8 @@ class EmailTemplate(Document): kwargs = {"match_by_email": sender} else: kwargs = {"match_by_doctype": doc.get("doctype")} - email_account = EmailAccount.find_outgoing(**kwargs) - if email_account: + if email_account := EmailAccount.find_outgoing(**kwargs): doc.update( {"email_signature": get_signature(email_account), "email_footer": get_footer(email_account)} ) diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index a71955fdda..25a92802db 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -392,9 +392,9 @@ def get_formatted_html( "print_html": print_html, "subject": subject, } + if raw_html: rendered_email = frappe.render_template(message, params) - else: params.update( { From 0ccce2af11c6bd389c382653ec0d8e84bc52ad63 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Tue, 6 Jan 2026 05:51:25 +0000 Subject: [PATCH 13/15] fix: remove duplicate Use HTML email buttons and conflicting functionality. Introduced in af5d474084d1a356632e63c0584d00936b339f68 --- .../public/js/frappe/views/communication.js | 41 ++++++------------- 1 file changed, 12 insertions(+), 29 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 70ac07a94f..bd6c0b7865 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -89,15 +89,6 @@ frappe.views.CommunicationComposer = class { fieldtype: "Datetime", fieldname: "send_after", }, - { - label: __("Use HTML"), - fieldtype: "Check", - fieldname: "use_html", - default: 0, - onchange: () => { - me.on_use_html_toggle(); - }, - }, { fieldtype: "Section Break", fieldname: "email_template_section_break", @@ -127,13 +118,9 @@ frappe.views.CommunicationComposer = class { fieldname: "use_html", default: 0, hidden: 1, - onchange: function (e) { - if (!e) return; - if (e.target.checked) { - me.dialog.set_value("html_content", me.dialog.get_value("content")); - } else { - me.dialog.set_value("content", me.dialog.get_value("html_content")); - } + description: "Use Raw HTML email editor.", + onchange: (event) => { + me.on_use_html_toggle(event); }, }, { fieldtype: "Section Break" }, @@ -159,13 +146,6 @@ frappe.views.CommunicationComposer = class { depends_on: "eval:doc.use_html", options: "HTML", }, - { - label: __("Message"), - fieldtype: "HTML Editor", - fieldname: "content_html", - hidden: 1, - onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300), - }, { fieldtype: "Button", label: __("Add Signature"), @@ -1077,13 +1057,16 @@ frappe.views.CommunicationComposer = class { return this.get_content_field().set_value(value); } - on_use_html_toggle() { + on_use_html_toggle(event) { + if (!event) return; + this.save_as_draft(); - const use_html = this.dialog.get_value("use_html"); + const use_html = event.target.checked; - this.dialog.set_df_property("content", "hidden", use_html); - this.dialog.set_df_property("content_html", "hidden", !use_html); - - this.dialog.set_value("email_template", ""); + if (use_html) { + this.dialog.set_value("html_content", this.dialog.get_value("content")); + } else { + this.dialog.set_value("content", this.dialog.get_value("html_content")); + } } }; From f4c07a1c5a31638681ad342fbb18dc922c982de9 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Tue, 6 Jan 2026 05:59:35 +0000 Subject: [PATCH 14/15] docs: Add description to Email Queue Raw HTML field --- frappe/email/doctype/email_queue/email_queue.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/email/doctype/email_queue/email_queue.json b/frappe/email/doctype/email_queue/email_queue.json index 1379c1fcb6..6ae3d60934 100644 --- a/frappe/email/doctype/email_queue/email_queue.json +++ b/frappe/email/doctype/email_queue/email_queue.json @@ -152,6 +152,7 @@ }, { "default": "0", + "description": "Raw HTML emails are rendered as complete Jinja templates. Otherwise, emails are wrapped in the standard.html email template, which inserts brand_logo, header and footer.", "fieldname": "raw_html", "fieldtype": "Check", "label": "Send As Raw HTML", @@ -162,7 +163,7 @@ "idx": 1, "in_create": 1, "links": [], - "modified": "2025-11-19 11:18:45.574190", + "modified": "2026-01-06 05:45:35.503215", "modified_by": "Administrator", "module": "Email", "name": "Email Queue", From dd6357fdaeefd784b1c5852b849dab044d622602 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Tue, 6 Jan 2026 06:12:56 +0000 Subject: [PATCH 15/15] style: fix linter error --- frappe/core/doctype/communication/email.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 6c73257abf..acbc27b2be 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -86,7 +86,11 @@ def make( if doctype and name: frappe.has_permission(doctype, doc=name, ptype="email", throw=True) - if raw_html and email_template and not frappe.get_cached_value("Email Template", email_template, "use_html"): + if ( + raw_html + and email_template + and not frappe.get_cached_value("Email Template", email_template, "use_html") + ): warn( _( "Raw HTML can be used only with Email Templates having 'Use HTML' checked. "