Merge pull request #34801 from alexleach/html_emails

feat: Raw Html Emails
This commit is contained in:
Akhil Narang 2026-01-06 12:21:03 +05:30 committed by GitHub
commit 93e2b64c0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 191 additions and 56 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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")

View file

@ -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.

View file

@ -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
}
}

View file

@ -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:

View file

@ -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)

View file

@ -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 here-->", 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

View file

@ -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}<br>${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"));
}
}
};