diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index d2b79224dd..acbc27b2be 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -50,6 +50,8 @@ def make( send_after=None, 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. @@ -69,10 +71,12 @@ 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 + :param add_css: Add default CSS from hooks/email_css to the email template (default **True**) """ - 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", @@ -82,6 +86,20 @@ 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") + ): + 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, @@ -107,6 +125,8 @@ def make( send_after=send_after, print_language=print_language, now=now, + raw_html=raw_html, + add_css=add_css, ) @@ -135,6 +155,8 @@ def _make( send_after=None, 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.""" @@ -165,7 +187,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_cached_value("Email Template", email_template, "use_html") + ) comm.insert(ignore_permissions=True) # if not committed, delayed task doesn't find the communication @@ -190,6 +214,8 @@ def _make( print_letterhead=print_letterhead, 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 4318a451e5..edf14baca5 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -258,6 +258,8 @@ class CommunicationEmailMixin: print_letterhead=None, 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: @@ -307,6 +309,8 @@ class CommunicationEmailMixin: "is_notification": (self.sent_or_received == "Received"), "print_letterhead": print_letterhead, "send_after": self.send_after, + "raw_html": raw_html, + "add_css": add_css, } def send_email( @@ -318,6 +322,8 @@ class CommunicationEmailMixin: is_inbound_mail_communcation=None, print_language=None, now=False, + raw_html=False, + add_css=True, ): if input_dict := self.sendmail_input_dict( print_html=print_html, @@ -326,5 +332,7 @@ class CommunicationEmailMixin: print_letterhead=print_letterhead, 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/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 1557b90ce8..2cf28a8de3 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -557,7 +557,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 44bb362f8d..1200ca38bd 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -150,6 +150,8 @@ def sendmail( email_read_tracker_url=None, 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**. @@ -179,6 +181,8 @@ 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 + :param add_css: Whether to add CSS from hooks/email_css to the email template """ from frappe.utils.jinja import get_email_from_template @@ -238,6 +242,8 @@ def sendmail( email_read_tracker_url=email_read_tracker_url, 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.json b/frappe/email/doctype/email_queue/email_queue.json index cc01c9f56a..6ae3d60934 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,21 @@ "fieldtype": "Code", "label": "Unsubscribe Params", "read_only": 1 + }, + { + "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", + "read_only": 1 } ], "icon": "fa fa-envelope", "idx": 1, "in_create": 1, "links": [], - "modified": "2025-03-07 15:56:13.341958", + "modified": "2026-01-06 05:45:35.503215", "modified_by": "Administrator", "module": "Email", "name": "Email Queue", @@ -175,4 +184,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..3ce20a0d12 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,8 @@ class QueueBuilder: email_read_tracker_url=None, x_priority: Literal[1, 3, 5] = 3, email_headers=None, + raw_html=False, + add_css=True, ): """Add email to sending queue (Email Queue) @@ -545,6 +548,8 @@ 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 + :param add_css: Add default CSS from hooks/email_css to the email template (default True) """ self._unsubscribe_method = unsubscribe_method @@ -582,6 +587,8 @@ 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 + self.add_css = add_css @property def unsubscribe_method(self): @@ -638,6 +645,8 @@ class QueueBuilder: email_account=email_account, 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): @@ -843,6 +852,8 @@ 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, + "add_css": self.add_css, } if include_recipients: diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index b37598e051..bf2647c13c 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -37,19 +37,37 @@ 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")} + + if email_account := EmailAccount.find_outgoing(**kwargs): + 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/email/email_body.py b/frappe/email/email_body.py index 707d301546..25a92802db 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -381,29 +381,38 @@ def get_formatted_html( unsubscribe_link: frappe._dict | None = None, sender=None, with_container=False, + raw_html=False, + add_css=True, ): 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, - } - ) + params = { + "site_url": get_url(), + "title": subject, + "print_html": print_html, + "subject": subject, + } + + if raw_html: + rendered_email = frappe.render_template(message, params) + else: + params.update( + { + "brand_logo": get_brand_logo(email_account) if with_container or header else None, + "with_container": with_container, + "header": get_header(header), + "content": message, + "footer": get_footer(email_account, footer), + } + ) + rendered_email = frappe.get_template("templates/emails/standard.html").render(params) 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=add_css) @frappe.whitelist() @@ -418,17 +427,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 58e7456d2a..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", @@ -108,12 +99,30 @@ frappe.views.CommunicationComposer = class { fieldtype: "Link", options: "Email Template", fieldname: "email_template", + onchange: async function () { + const email_template = this.value; + if (!email_template) { + return me.hide_use_html_field(); + } + await me.check_email_template_html(email_template); + }, }, { fieldtype: "HTML", label: __("Clear & Add template"), fieldname: "clear_and_add_template", }, + { + label: __("Use HTML"), + fieldtype: "Check", + fieldname: "use_html", + default: 0, + hidden: 1, + description: "Use Raw HTML email editor.", + onchange: (event) => { + me.on_use_html_toggle(event); + }, + }, { fieldtype: "Section Break" }, { label: __("Subject"), @@ -127,13 +136,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: __("Message"), - fieldtype: "HTML Editor", - fieldname: "content_html", - hidden: 1, + 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", }, { fieldtype: "Button", @@ -180,6 +191,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", @@ -221,6 +239,14 @@ frappe.views.CommunicationComposer = class { return fields; } + get_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) { if (this.frm?.events.get_email_recipients) { return (this.frm.events.get_email_recipients(this.frm, fieldname) || []).join(", "); @@ -254,6 +280,22 @@ 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 + } + 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); @@ -419,7 +461,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); } @@ -428,6 +470,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); @@ -452,6 +495,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() { @@ -520,12 +564,13 @@ 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) { 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; } @@ -823,6 +868,8 @@ 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, + add_css: form_values.add_css, }, btn, callback(r) { @@ -1002,11 +1049,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() || ""; } @@ -1015,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")); + } } };