FEATURE: customization of html emails (#7934)

This feature adds the ability to customize the HTML part of all emails using a custom HTML template and optionally some CSS to style it. The CSS will be parsed and converted into inline styles because CSS is poorly supported by email clients. When writing the custom HTML and CSS, be aware of what email clients support. Keep customizations very simple.

Customizations can be added and edited in Admin > Customize > Email Style.

Since the summary email is already heavily styled, there is a setting to disable custom styles for summary emails called "apply custom styles to digest" found in Admin > Settings > Email.

As part of this work, RTL locales are now rendered correctly for all emails.
This commit is contained in:
Neil Lalonde 2019-07-30 15:05:08 -04:00 committed by GitHub
parent 340173eb12
commit 9656a21fdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 720 additions and 134 deletions

View File

@ -78,6 +78,7 @@ gem 'discourse_image_optim', require: 'image_optim'
gem 'multi_json' gem 'multi_json'
gem 'mustache' gem 'mustache'
gem 'nokogiri' gem 'nokogiri'
gem 'css_parser', require: false
gem 'omniauth' gem 'omniauth'
gem 'omniauth-openid' gem 'omniauth-openid'

View File

@ -88,6 +88,8 @@ GEM
crack (0.4.3) crack (0.4.3)
safe_yaml (~> 1.0.0) safe_yaml (~> 1.0.0)
crass (1.0.4) crass (1.0.4)
css_parser (1.7.0)
addressable
debug_inspector (0.0.3) debug_inspector (0.0.3)
diff-lcs (1.3) diff-lcs (1.3)
diffy (3.3.0) diffy (3.3.0)
@ -438,6 +440,7 @@ DEPENDENCIES
certified certified
colored2 colored2
cppjieba_rb cppjieba_rb
css_parser
diffy diffy
discourse-ember-source (~> 3.10.0) discourse-ember-source (~> 3.10.0)
discourse_image_optim discourse_image_optim

View File

@ -0,0 +1,7 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
pathFor() {
return "/admin/customize/email_style";
}
});

View File

@ -0,0 +1,45 @@
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Component.extend({
editorId: Ember.computed.reads("fieldName"),
@computed("fieldName", "styles.html", "styles.css")
resetDisabled(fieldName) {
return (
this.get(`styles.${fieldName}`) ===
this.get(`styles.default_${fieldName}`)
);
},
@computed("styles", "fieldName")
editorContents: {
get(styles, fieldName) {
return styles[fieldName];
},
set(value, styles, fieldName) {
styles.setField(fieldName, value);
return value;
}
},
actions: {
reset() {
bootbox.confirm(
I18n.t("admin.customize.email_style.reset_confirm", {
fieldName: I18n.t(`admin.customize.email_style.${this.fieldName}`)
}),
I18n.t("no_value"),
I18n.t("yes_value"),
result => {
if (result) {
this.styles.setField(
this.fieldName,
this.styles.get(`default_${this.fieldName}`)
);
this.notifyPropertyChange("editorContents");
}
}
);
}
}
});

View File

@ -0,0 +1,33 @@
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Controller.extend({
@computed("model.isSaving")
saveButtonText(isSaving) {
return isSaving ? I18n.t("saving") : I18n.t("admin.customize.save");
},
@computed("model.changed", "model.isSaving")
saveDisabled(changed, isSaving) {
return !changed || isSaving;
},
actions: {
save() {
if (!this.model.saving) {
this.set("saving", true);
this.model
.update(this.model.getProperties("html", "css"))
.catch(e => {
const msg =
e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors
? I18n.t("admin.customize.email_style.save_error_with_reason", {
error: e.jqXHR.responseJSON.errors.join(". ")
})
: I18n.t("generic_error");
bootbox.alert(msg);
})
.finally(() => this.set("model.changed", false));
}
}
}
});

View File

@ -0,0 +1,10 @@
import RestModel from "discourse/models/rest";
export default RestModel.extend({
changed: false,
setField(fieldName, value) {
this.set(`${fieldName}`, value);
this.set("changed", true);
}
});

View File

@ -0,0 +1,39 @@
export default Ember.Route.extend({
model(params) {
return {
model: this.modelFor("adminCustomizeEmailStyle"),
fieldName: params.field_name
};
},
setupController(controller, model) {
controller.setProperties({
fieldName: model.fieldName,
model: model.model
});
this._shouldAlertUnsavedChanges = true;
},
actions: {
willTransition(transition) {
if (
this.get("controller.model.changed") &&
this._shouldAlertUnsavedChanges &&
transition.intent.name !== this.routeName
) {
transition.abort();
bootbox.confirm(
I18n.t("admin.customize.theme.unsaved_changes_alert"),
I18n.t("admin.customize.theme.discard"),
I18n.t("admin.customize.theme.stay"),
result => {
if (!result) {
this._shouldAlertUnsavedChanges = false;
transition.retry();
}
}
);
}
}
}
});

View File

@ -0,0 +1,9 @@
export default Ember.Route.extend({
model() {
return this.store.find("email-style");
},
redirect() {
this.transitionTo("adminCustomizeEmailStyle.edit", "html");
}
});

View File

@ -90,6 +90,13 @@ export default function() {
path: "/robots", path: "/robots",
resetNamespace: true resetNamespace: true
}); });
this.route(
"adminCustomizeEmailStyle",
{ path: "/email_style", resetNamespace: true },
function() {
this.route("edit", { path: "/:field_name" });
}
);
} }
); );

View File

@ -0,0 +1,20 @@
<div class='row'>
<div class='admin-controls'>
<nav>
<ul class='nav nav-pills'>
<li>{{#link-to 'adminCustomizeEmailStyle.edit' 'html' replace=true}}{{i18n 'admin.customize.email_style.html'}}{{/link-to}}</li>
<li>{{#link-to 'adminCustomizeEmailStyle.edit' 'css' replace=true}}{{i18n 'admin.customize.email_style.css'}}{{/link-to}}</li>
</ul>
</nav>
</div>
</div>
{{ace-editor content=editorContents mode=fieldName editorId=editorId}}
<div class='admin-footer'>
<div class='buttons'>
{{#d-button action=(action "reset") disabled=resetDisabled class='btn-default'}}
{{i18n 'admin.customize.email_style.reset'}}
{{/d-button}}
</div>
</div>

View File

@ -0,0 +1,9 @@
{{email-styles-editor styles=model fieldName=fieldName}}
<div class='admin-footer'>
<div class='buttons'>
{{#d-button action=(action "save") disabled=saveDisabled class='btn-primary'}}
{{saveButtonText}}
{{/d-button}}
</div>
</div>

View File

@ -0,0 +1,7 @@
<div class='row'>
<h2>{{i18n 'admin.customize.email_style.heading'}}</h2>
<p>{{i18n 'admin.customize.email_style.instructions'}}</p>
</div>
{{outlet}}

View File

@ -3,6 +3,7 @@
{{nav-item route='adminCustomize.colors' label='admin.customize.colors.title'}} {{nav-item route='adminCustomize.colors' label='admin.customize.colors.title'}}
{{nav-item route='adminSiteText' label='admin.site_text.title'}} {{nav-item route='adminSiteText' label='admin.site_text.title'}}
{{nav-item route='adminCustomizeEmailTemplates' label='admin.customize.email_templates.title'}} {{nav-item route='adminCustomizeEmailTemplates' label='admin.customize.email_templates.title'}}
{{nav-item route='adminCustomizeEmailStyle' label='admin.customize.email_style.title'}}
{{nav-item route='adminUserFields' label='admin.user_fields.title'}} {{nav-item route='adminUserFields' label='admin.user_fields.title'}}
{{nav-item route='adminEmojis' label='admin.emoji.title'}} {{nav-item route='adminEmojis' label='admin.emoji.title'}}
{{nav-item route='adminPermalinks' label='admin.permalink.title'}} {{nav-item route='adminPermalinks' label='admin.permalink.title'}}

View File

@ -790,3 +790,18 @@
height: 55vh; height: 55vh;
} }
} }
.admin-customize-email-style {
.ace-wrapper {
position: relative;
width: 100%;
height: 400px;
.ace_editor {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
}
}

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Admin::EmailStylesController < Admin::AdminController
def show
render_serialized(EmailStyle.new, EmailStyleSerializer)
end
def update
updater = EmailStyleUpdater.new(current_user)
if updater.update(params.require(:email_style).permit(:html, :css))
render_serialized(EmailStyle.new, EmailStyleSerializer)
else
render_json_error(updater.errors, status: 422)
end
end
end

View File

@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'erb'
module EmailHelper module EmailHelper
def mailing_list_topic(topic, post_count) def mailing_list_topic(topic, post_count)
@ -23,6 +25,14 @@ module EmailHelper
raw "<a href='#{Discourse.base_url}#{url}' style='color: ##{@anchor_color}'>#{title}</a>" raw "<a href='#{Discourse.base_url}#{url}' style='color: ##{@anchor_color}'>#{title}</a>"
end end
def email_html_template(binding_arg)
template = EmailStyle.new.html.sub(
'%{email_content}',
'<%= yield %><% if defined?(html_body) %><%= html_body %><% end %>'
)
ERB.new(template).result(binding_arg)
end
protected protected
def extract_details(topic) def extract_details(topic)

View File

@ -5,10 +5,7 @@ require_dependency 'email/message_builder'
class InviteMailer < ActionMailer::Base class InviteMailer < ActionMailer::Base
include Email::BuildEmailHelper include Email::BuildEmailHelper
class UserNotificationRenderer < ActionView::Base layout 'email_template'
include UserNotificationsHelper
include EmailHelper
end
def send_invite(invite) def send_invite(invite)
# Find the first topic they were invited to # Find the first topic they were invited to

View File

@ -12,6 +12,7 @@ class UserNotifications < ActionMailer::Base
include ApplicationHelper include ApplicationHelper
helper :application, :email helper :application, :email
default charset: 'UTF-8' default charset: 'UTF-8'
layout 'email_template'
include Email::BuildEmailHelper include Email::BuildEmailHelper
@ -362,11 +363,6 @@ class UserNotifications < ActionMailer::Base
result result
end end
class UserNotificationRenderer < ActionView::Base
include UserNotificationsHelper
include EmailHelper
end
def self.get_context_posts(post, topic_user, user) def self.get_context_posts(post, topic_user, user)
if (user.user_option.email_previous_replies == UserOption.previous_replies_type[:never]) || if (user.user_option.email_previous_replies == UserOption.previous_replies_type[:never]) ||
SiteSetting.private_email? SiteSetting.private_email?
@ -580,15 +576,7 @@ class UserNotifications < ActionMailer::Base
site_description: SiteSetting.site_description site_description: SiteSetting.site_description
) )
unless translation_override_exists html = PrettyText.cook(message, sanitize: false).html_safe
html = UserNotificationRenderer.with_view_paths(Rails.configuration.paths["app/views"]).render(
template: 'email/invite',
format: :html,
locals: { message: PrettyText.cook(message, sanitize: false).html_safe,
classes: Rtl.new(user).css_class
}
)
end
else else
reached_limit = SiteSetting.max_emails_per_day_per_user > 0 reached_limit = SiteSetting.max_emails_per_day_per_user > 0
reached_limit &&= (EmailLog.where(user_id: user.id) reached_limit &&= (EmailLog.where(user_id: user.id)
@ -608,7 +596,6 @@ class UserNotifications < ActionMailer::Base
end end
unless translation_override_exists unless translation_override_exists
html = UserNotificationRenderer.with_view_paths(Rails.configuration.paths["app/views"]).render( html = UserNotificationRenderer.with_view_paths(Rails.configuration.paths["app/views"]).render(
template: 'email/notification', template: 'email/notification',
format: :html, format: :html,
@ -651,7 +638,6 @@ class UserNotifications < ActionMailer::Base
site_description: SiteSetting.site_description, site_description: SiteSetting.site_description,
site_title: SiteSetting.title, site_title: SiteSetting.title,
site_title_url_encoded: URI.encode(SiteSetting.title), site_title_url_encoded: URI.encode(SiteSetting.title),
style: :notification,
locale: locale locale: locale
} }
@ -689,13 +675,6 @@ class UserNotifications < ActionMailer::Base
@anchor_color = ColorScheme.hex_for_name('tertiary') @anchor_color = ColorScheme.hex_for_name('tertiary')
@markdown_linker = MarkdownLinker.new(@base_url) @markdown_linker = MarkdownLinker.new(@base_url)
@unsubscribe_key = UnsubscribeKey.create_key_for(@user, "digest") @unsubscribe_key = UnsubscribeKey.create_key_for(@user, "digest")
end @disable_email_custom_styles = !SiteSetting.apply_custom_styles_to_digest
def apply_notification_styles(email)
email.html_part.body = Email::Styles.new(email.html_part.body.to_s).tap do |styles|
styles.format_basic
styles.format_notification
end.to_html
email
end end
end end

37
app/models/email_style.rb Normal file
View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
class EmailStyle
include ActiveModel::Serialization
attr_accessor :html, :css, :default_html, :default_css
def id
'email-style'
end
def html
SiteSetting.email_custom_template.presence || default_html
end
def css
SiteSetting.email_custom_css || default_css
end
def default_html
self.class.default_template
end
def default_css
self.class.default_css
end
def self.default_template
@_default_template ||= File.read(
File.join(Rails.root, 'app', 'views', 'email', 'default_template.html')
)
end
def self.default_css
''
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class EmailStyleSerializer < ApplicationSerializer
attributes :id, :html, :css, :default_html, :default_css
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
class EmailStyleUpdater
attr_reader :errors
def initialize(user)
@user = user
@errors = []
end
def update(attrs)
if attrs.has_key?(:html)
if attrs[:html] == EmailStyle.default_template
SiteSetting.remove_override!(:email_custom_template)
else
if !attrs[:html].include?('%{email_content}')
@errors << I18n.t(
'email_style.html_missing_placeholder',
placeholder: '%{email_content}'
)
else
SiteSetting.email_custom_template = attrs[:html]
end
end
end
if attrs.has_key?(:css)
if attrs[:css] == EmailStyle.default_css
SiteSetting.remove_override!(:email_custom_css)
else
SiteSetting.email_custom_css = attrs[:css]
end
end
@errors.empty?
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class UserNotificationRenderer < ActionView::Base
include ApplicationHelper
include UserNotificationsHelper
include EmailHelper
end

View File

@ -0,0 +1,24 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="%{html_lang}" xml:lang="%{html_lang}">
<head>
<meta http-equiv="Content-type" name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no, width=device-width">
<title></title>
</head>
<body>
<!--[if mso]>
<style type="text/css">
body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !important;}
</style>
<![endif]-->
%{email_content}
<!-- prevent Gmail on iOS font size manipulation -->
<div style="display:none;white-space:nowrap;font:15px courier;line-height:0">&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;</div>
</body>
</html>

View File

@ -1,11 +0,0 @@
<div id='main' class=<%= classes %>>
<div class='header-instructions'>%{header_instructions}</div>
<% if message.present? %>
<div><%= message %></div>
<% end %>
<div class='footer'>%{respond_instructions}</div>
</div>

View File

@ -1,13 +0,0 @@
<table cellspacing="0" cellpadding="0">
<tr>
<td style="padding: 10px;">
<a href="<%= Discourse.base_url %>">
<img src="<%= logo_url %>" style="max-width:100%"></a>
</td>
</tr>
<tr>
<td style="background-color: #fff; padding: 10px 10px; font-family: Arial, Helvetica, sans-serif; font-size: 14px;">
<%= raw(html_body) %>
</td>
</tr>
</table>

View File

@ -0,0 +1,6 @@
<% if @disable_email_custom_styles %>
<%= yield %>
<% if defined?(html_body) %><%= html_body %><% end %>
<% else %>
<%= email_html_template(binding).html_safe %>
<% end %>

View File

@ -1,19 +1,4 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <div class="summary-email">
<html xmlns="http://www.w3.org/1999/xhtml" lang="<%= html_lang %>" xml:lang="<%= html_lang %>">
<head>
<meta http-equiv="Content-type" name="viewport" content="initial-scale=1.0, maximum-scale=1.0, user-scalable=no, width=device-width">
<title></title>
</head>
<body dir="<%= rtl? ? 'rtl' : 'ltr' %>" style="-moz-box-sizing:border-box;-ms-text-size-adjust:100%;-webkit-box-sizing:border-box;-webkit-text-size-adjust:100%;box-sizing:border-box;color:#0a0a0a;font-family:Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.3;margin:0;min-width:100%;padding:0;text-align:<%= rtl? ? 'right' : 'left' %>;width:100%">
<!--[if mso]>
<style type="text/css">
body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !important;}
</style>
<![endif]-->
<span class="preheader" style="display:none!important;color:#f3f3f3;font-size:1px;line-height:1px;max-height:0;max-width:0;mso-hide:all!important;opacity:0;overflow:hidden;visibility:hidden"> <span class="preheader" style="display:none!important;color:#f3f3f3;font-size:1px;line-height:1px;max-height:0;max-width:0;mso-hide:all!important;opacity:0;overflow:hidden;visibility:hidden">
<%= @preheader_text %> <%= @preheader_text %>
@ -425,10 +410,4 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
<%= digest_custom_html("below_footer") %> <%= digest_custom_html("below_footer") %>
<!-- prevent Gmail on iOS font size manipulation --> </div>
<div style="display:none;white-space:nowrap;font:15px courier;line-height:0">&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;</div>
</body>
</html>

View File

@ -3644,6 +3644,16 @@ en:
title: "Override your site's robots.txt file:" title: "Override your site's robots.txt file:"
warning: "This will permanently override any related site settings." warning: "This will permanently override any related site settings."
overridden: Your site's default robots.txt file is overridden. overridden: Your site's default robots.txt file is overridden.
email_style:
title: "Email Style"
heading: "Customize Email Style"
html: "HTML Template"
css: "CSS"
reset: "Reset to default"
reset_confirm: "Are you sure you want to reset to the default %{fieldName} and lose all your changes?"
save_error_with_reason: "Your changes were not saved. %{error}"
instructions: "Customize the template in which all html emails are rendered, and style using CSS."
email: email:
title: "Emails" title: "Emails"
settings: "Settings" settings: "Settings"

View File

@ -1854,6 +1854,7 @@ en:
suppress_digest_email_after_days: "Suppress summary emails for users not seen on the site for more than (n) days." suppress_digest_email_after_days: "Suppress summary emails for users not seen on the site for more than (n) days."
digest_suppress_categories: "Suppress these categories from summary emails." digest_suppress_categories: "Suppress these categories from summary emails."
disable_digest_emails: "Disable summary emails for all users." disable_digest_emails: "Disable summary emails for all users."
apply_custom_styles_to_digest: "Custom email template and css are applied to summary emails."
email_accent_bg_color: "The accent color to be used as the background of some elements in HTML emails. Enter a color name ('red') or hex value ('#FF0000')." email_accent_bg_color: "The accent color to be used as the background of some elements in HTML emails. Enter a color name ('red') or hex value ('#FF0000')."
email_accent_fg_color: "The color of text rendered on the email bg color in HTML emails. Enter a color name ('white') or hex value ('#FFFFFF')." email_accent_fg_color: "The color of text rendered on the email bg color in HTML emails. Enter a color name ('white') or hex value ('#FFFFFF')."
email_link_color: "The color of links in HTML emails. Enter a color name ('blue') or hex value ('#0000FF')." email_link_color: "The color of links in HTML emails. Enter a color name ('blue') or hex value ('#0000FF')."
@ -4554,3 +4555,6 @@ en:
title: "Delete User" title: "Delete User"
confirm: "Are you sure you want to delete that user? This will remove all of their posts and block their email and IP address." confirm: "Are you sure you want to delete that user? This will remove all of their posts and block their email and IP address."
reason: "Deleted via review queue" reason: "Deleted via review queue"
email_style:
html_missing_placeholder: "The html template must include %{placeholder}"

View File

@ -244,6 +244,9 @@ Discourse::Application.routes.draw do
get 'robots' => 'robots_txt#show' get 'robots' => 'robots_txt#show'
put 'robots.json' => 'robots_txt#update' put 'robots.json' => 'robots_txt#update'
delete 'robots.json' => 'robots_txt#reset' delete 'robots.json' => 'robots_txt#reset'
resource :email_style, only: [:show, :update]
get 'email_style/:field' => 'email_styles#show', constraints: { field: /html|css/ }
end end
resources :embeddable_hosts, constraints: AdminConstraint.new resources :embeddable_hosts, constraints: AdminConstraint.new

View File

@ -911,6 +911,7 @@ email:
disable_digest_emails: disable_digest_emails:
default: false default: false
client: true client: true
apply_custom_styles_to_digest: true
email_accent_bg_color: "#2F70AC" email_accent_bg_color: "#2F70AC"
email_accent_fg_color: "#FFFFFF" email_accent_fg_color: "#FFFFFF"
email_link_color: "#006699" email_link_color: "#006699"
@ -1024,6 +1025,12 @@ email:
enable_forwarded_emails: false enable_forwarded_emails: false
always_show_trimmed_content: false always_show_trimmed_content: false
private_email: false private_email: false
email_custom_template:
default: ""
hidden: true
email_custom_css:
default: ""
hidden: true
email_total_attachment_size_limit_kb: email_total_attachment_size_limit_kb:
default: 0 default: 0
max: 51200 max: 51200

View File

@ -107,16 +107,17 @@ module Email
html_override.gsub!("%{respond_instructions}", "") html_override.gsub!("%{respond_instructions}", "")
end end
styled = Email::Styles.new(html_override, @opts) html = UserNotificationRenderer.with_view_paths(
styled.format_basic Rails.configuration.paths["app/views"]
).render(
if style = @opts[:style] template: 'layouts/email_template',
styled.public_send("format_#{style}") format: :html,
end locals: { html_body: html_override.html_safe }
)
Mail::Part.new do Mail::Part.new do
content_type 'text/html; charset=UTF-8' content_type 'text/html; charset=UTF-8'
body styled.to_html body html
end end
end end

View File

@ -17,15 +17,21 @@ module Email
end end
def html def html
if @message.html_part style = if @message.html_part
style = Email::Styles.new(@message.html_part.body.to_s, @opts) Email::Styles.new(@message.html_part.body.to_s, @opts)
style.format_basic
style.format_html
else else
style = Email::Styles.new(PrettyText.cook(text), @opts) unstyled = UserNotificationRenderer.with_view_paths(
style.format_basic Rails.configuration.paths["app/views"]
).render(
template: 'layouts/email_template',
format: :html,
locals: { html_body: PrettyText.cook(text).html_safe }
)
Email::Styles.new(unstyled, @opts)
end end
style.format_basic
style.format_html
style.to_html style.to_html
end end

View File

@ -16,6 +16,7 @@ module Email
@html = html @html = html
@opts = opts || {} @opts = opts || {}
@fragment = Nokogiri::HTML.fragment(@html) @fragment = Nokogiri::HTML.fragment(@html)
@custom_styles = nil
end end
def self.register_plugin_style(&block) def self.register_plugin_style(&block)
@ -32,6 +33,26 @@ module Email
end end
end end
def custom_styles
return @custom_styles unless @custom_styles.nil?
css = EmailStyle.new.css
@custom_styles = {}
if !css.blank?
require 'css_parser' unless defined?(CssParser)
parser = CssParser::Parser.new(import: false)
parser.load_string!(css)
parser.each_selector do |selector, value|
@custom_styles[selector] ||= +''
@custom_styles[selector] << value
end
end
@custom_styles
end
def format_basic def format_basic
uri = URI(Discourse.base_url) uri = URI(Discourse.base_url)
@ -83,29 +104,6 @@ module Email
end end
end end
def format_notification
style('.previous-discussion', 'font-size: 17px; color: #444; margin-bottom:10px;')
style('.notification-date', "text-align:right;color:#999999;padding-right:5px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;font-size:11px")
style('.username', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;color:#{SiteSetting.email_link_color};text-decoration:none;font-weight:bold")
style('.user-title', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:7px;color: #999;")
style('.user-name', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:7px;color: #{SiteSetting.email_link_color};font-weight:normal;")
style('.post-wrapper', "margin-bottom:25px;")
style('.user-avatar', 'vertical-align:top;width:55px;')
style('.user-avatar img', nil, width: '45', height: '45')
style('hr', 'background-color: #ddd; height: 1px; border: 1px;')
style('.rtl', 'direction: rtl;')
style('div.body', 'padding-top:5px;')
style('.whisper div.body', 'font-style: italic; color: #9c9c9c;')
style('.lightbox-wrapper .meta', 'display: none')
correct_first_body_margin
correct_footer_style
style('div.undecorated-link-footer a', "font-weight: normal;")
correct_footer_style_hilight_first
reset_tables
onebox_styles
plugin_styles
end
def onebox_styles def onebox_styles
# Links to other topics # Links to other topics
style('aside.quote', 'padding: 12px 25px 2px 12px; margin-bottom: 10px;') style('aside.quote', 'padding: 12px 25px 2px 12px; margin-bottom: 10px;')
@ -164,6 +162,16 @@ module Email
end end
def format_html def format_html
html_lang = SiteSetting.default_locale.sub("_", "-")
style('html', nil, lang: html_lang, 'xml:lang' => html_lang)
style('body', "text-align:#{ Rtl.new(nil).enabled? ? 'right' : 'left' };")
style('body', nil, dir: Rtl.new(nil).enabled? ? 'rtl' : 'ltr')
style('.with-dir',
"text-align:#{ Rtl.new(nil).enabled? ? 'right' : 'left' };",
dir: Rtl.new(nil).enabled? ? 'rtl' : 'ltr'
)
style('.with-accent-colors', "background-color: #{SiteSetting.email_accent_bg_color}; color: #{SiteSetting.email_accent_fg_color};") style('.with-accent-colors', "background-color: #{SiteSetting.email_accent_bg_color}; color: #{SiteSetting.email_accent_fg_color};")
style('h4', 'color: #222;') style('h4', 'color: #222;')
style('h3', 'margin: 15px 0 20px 0;') style('h3', 'margin: 15px 0 20px 0;')
@ -177,11 +185,39 @@ module Email
style('code', 'background-color: #f1f1ff; padding: 2px 5px;') style('code', 'background-color: #f1f1ff; padding: 2px 5px;')
style('pre code', 'display: block; background-color: #f1f1ff; padding: 5px;') style('pre code', 'display: block; background-color: #f1f1ff; padding: 5px;')
style('.featured-topic a', "text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color}; line-height:1.5em;") style('.featured-topic a', "text-decoration: none; font-weight: bold; color: #{SiteSetting.email_link_color}; line-height:1.5em;")
style('.summary-email', "-moz-box-sizing:border-box;-ms-text-size-adjust:100%;-webkit-box-sizing:border-box;-webkit-text-size-adjust:100%;box-sizing:border-box;color:#0a0a0a;font-family:Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.3;margin:0;min-width:100%;padding:0;width:100%")
style('.previous-discussion', 'font-size: 17px; color: #444; margin-bottom:10px;')
style('.notification-date', "text-align:right;color:#999999;padding-right:5px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;font-size:11px")
style('.username', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;color:#{SiteSetting.email_link_color};text-decoration:none;font-weight:bold")
style('.user-title', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:7px;color: #999;")
style('.user-name', "font-size:13px;font-family:'lucida grande',tahoma,verdana,arial,sans-serif;text-decoration:none;margin-left:7px;color: #{SiteSetting.email_link_color};font-weight:normal;")
style('.post-wrapper', "margin-bottom:25px;")
style('.user-avatar', 'vertical-align:top;width:55px;')
style('.user-avatar img', nil, width: '45', height: '45')
style('hr', 'background-color: #ddd; height: 1px; border: 1px;')
style('.rtl', 'direction: rtl;')
style('div.body', 'padding-top:5px;')
style('.whisper div.body', 'font-style: italic; color: #9c9c9c;')
style('.lightbox-wrapper .meta', 'display: none')
correct_first_body_margin
correct_footer_style
style('div.undecorated-link-footer a', "font-weight: normal;")
correct_footer_style_hilight_first
reset_tables
onebox_styles onebox_styles
plugin_styles plugin_styles
style('.post-excerpt img', "max-width: 50%; max-height: 400px;") style('.post-excerpt img', "max-width: 50%; max-height: 400px;")
format_custom
end
def format_custom
custom_styles.each do |selector, value|
style(selector, value)
end
end end
# this method is reserved for styles specific to plugin # this method is reserved for styles specific to plugin
@ -240,7 +276,7 @@ module Email
end end
def correct_first_body_margin def correct_first_body_margin
@fragment.css('.body p').each do |element| @fragment.css('div.body p').each do |element|
element['style'] = "margin-top:0; border: 0;" element['style'] = "margin-top:0; border: 0;"
end end
end end

View File

@ -0,0 +1,122 @@
# frozen_string_literal: true
require "rails_helper"
describe EmailStyle do
before do
SiteSetting.email_custom_template = "<body><h1>FOR YOU</h1><div>%{email_content}</div></body>"
SiteSetting.email_custom_css = 'h1 { color: red; } div.body { color: #FAB; }'
end
after do
SiteSetting.remove_override!(:email_custom_template)
SiteSetting.remove_override!(:email_custom_css)
end
context 'invite' do
fab!(:invite) { Fabricate(:invite) }
let(:invite_mail) { InviteMailer.send_invite(invite) }
subject(:mail_html) { Email::Renderer.new(invite_mail).html }
it 'applies customizations' do
expect(mail_html.scan('<h1 style="color: red;">FOR YOU</h1>').count).to eq(1)
expect(mail_html).to match("#{Discourse.base_url}/invites/#{invite.invite_key}")
end
it 'can apply RTL attrs' do
SiteSetting.default_locale = 'he'
body_attrs = mail_html.match(/<body ([^>])+/)
expect(body_attrs[0]&.downcase).to match(/text-align:\s*right/)
expect(body_attrs[0]&.downcase).to include('dir="rtl"')
end
end
context 'user_replied' do
let(:response_by_user) { Fabricate(:user, name: "John Doe") }
let(:category) { Fabricate(:category, name: 'India') }
let(:topic) { Fabricate(:topic, category: category, title: "Super cool topic") }
let(:post) { Fabricate(:post, topic: topic, raw: 'This is My super duper cool topic') }
let(:response) { Fabricate(:basic_reply, topic: post.topic, user: response_by_user) }
let(:user) { Fabricate(:user) }
let(:notification) { Fabricate(:replied_notification, user: user, post: response) }
let(:mail) do
UserNotifications.user_replied(
user,
post: response,
notification_type: notification.notification_type,
notification_data_hash: notification.data_hash
)
end
subject(:mail_html) { Email::Renderer.new(mail).html }
it "customizations are applied to html part of emails" do
expect(mail_html.scan('<h1 style="color: red;">FOR YOU</h1>').count).to eq(1)
matches = mail_html.match(/<div style="([^"]+)">#{post.raw}/)
expect(matches[1]).to include('color: #FAB;') # custom
expect(matches[1]).to include('padding-top:5px;') # div.body
end
# TODO: translation override
end
context 'signup' do
let(:signup_mail) { UserNotifications.signup(Fabricate(:user)) }
subject(:mail_html) { Email::Renderer.new(signup_mail).html }
it "customizations are applied to html part of emails" do
expect(mail_html.scan('<h1 style="color: red;">FOR YOU</h1>').count).to eq(1)
expect(mail_html).to include('activate-account')
end
context 'translation override' do
before do
TranslationOverride.upsert!(
'en',
'user_notifications.signup.text_body_template',
"CLICK THAT LINK: %{base_url}/u/activate-account/%{email_token}"
)
end
after do
TranslationOverride.revert!('en', ['user_notifications.signup.text_body_template'])
end
it "applies customizations when translation override exists" do
expect(mail_html.scan('<h1 style="color: red;">FOR YOU</h1>').count).to eq(1)
expect(mail_html.scan('CLICK THAT LINK').count).to eq(1)
end
end
context 'with some bad css' do
before do
SiteSetting.email_custom_css = '@import "nope.css"; h1 {{{ size: really big; '
end
it "can render the html" do
expect(mail_html.scan(/<h1\s*(?:style=""){0,1}>FOR YOU<\/h1>/).count).to eq(1)
expect(mail_html).to include('activate-account')
end
end
end
context 'digest' do
fab!(:popular_topic) { Fabricate(:topic, user: Fabricate(:coding_horror), created_at: 1.hour.ago) }
let(:summary_email) { UserNotifications.digest(Fabricate(:user)) }
subject(:mail_html) { Email::Renderer.new(summary_email).html }
it "customizations are applied to html part of emails" do
expect(mail_html.scan('<h1 style="color: red;">FOR YOU</h1>').count).to eq(1)
expect(mail_html).to include(popular_topic.title)
end
it "doesn't apply customizations if apply_custom_styles_to_digest is disabled" do
SiteSetting.apply_custom_styles_to_digest = false
expect(mail_html).to_not include('<h1 style="color: red;">FOR YOU</h1>')
expect(mail_html).to_not include('FOR YOU')
expect(mail_html).to include(popular_topic.title)
end
end
end

View File

@ -260,7 +260,7 @@ describe UserNotifications do
expect(mail.subject).to match(/Taggo/) expect(mail.subject).to match(/Taggo/)
expect(mail.subject).to match(/Taggie/) expect(mail.subject).to match(/Taggie/)
mail_html = mail.html_part.to_s mail_html = mail.html_part.body.to_s
expect(mail_html.scan(/My super duper cool topic/).count).to eq(1) expect(mail_html.scan(/My super duper cool topic/).count).to eq(1)
expect(mail_html.scan(/In Reply To/).count).to eq(1) expect(mail_html.scan(/In Reply To/).count).to eq(1)
@ -287,7 +287,7 @@ describe UserNotifications do
notification_data_hash: notification.data_hash notification_data_hash: notification.data_hash
) )
expect(mail.html_part.to_s.scan(/In Reply To/).count).to eq(0) expect(mail.html_part.body.to_s.scan(/In Reply To/).count).to eq(0)
SiteSetting.enable_names = true SiteSetting.enable_names = true
SiteSetting.display_name_on_posts = true SiteSetting.display_name_on_posts = true
@ -304,7 +304,7 @@ describe UserNotifications do
notification_data_hash: notification.data_hash notification_data_hash: notification.data_hash
) )
mail_html = mail.html_part.to_s mail_html = mail.html_part.body.to_s
expect(mail_html.scan(/>Bob Marley/).count).to eq(1) expect(mail_html.scan(/>Bob Marley/).count).to eq(1)
expect(mail_html.scan(/>bobmarley/).count).to eq(0) expect(mail_html.scan(/>bobmarley/).count).to eq(0)
@ -317,7 +317,7 @@ describe UserNotifications do
notification_data_hash: notification.data_hash notification_data_hash: notification.data_hash
) )
mail_html = mail.html_part.to_s mail_html = mail.html_part.body.to_s
expect(mail_html.scan(/>Bob Marley/).count).to eq(0) expect(mail_html.scan(/>Bob Marley/).count).to eq(0)
expect(mail_html.scan(/>bobmarley/).count).to eq(1) expect(mail_html.scan(/>bobmarley/).count).to eq(1)
end end
@ -331,8 +331,8 @@ describe UserNotifications do
notification_data_hash: notification.data_hash notification_data_hash: notification.data_hash
) )
expect(mail.html_part.to_s).to_not include(response.raw) expect(mail.html_part.body.to_s).to_not include(response.raw)
expect(mail.html_part.to_s).to_not include(topic.url) expect(mail.html_part.body.to_s).to_not include(topic.url)
expect(mail.text_part.to_s).to_not include(response.raw) expect(mail.text_part.to_s).to_not include(response.raw)
expect(mail.text_part.to_s).to_not include(topic.url) expect(mail.text_part.to_s).to_not include(topic.url)
end end
@ -365,10 +365,10 @@ describe UserNotifications do
expect(mail.subject).not_to match(/Uncategorized/) expect(mail.subject).not_to match(/Uncategorized/)
# 1 respond to links as no context by default # 1 respond to links as no context by default
expect(mail.html_part.to_s.scan(/to respond/).count).to eq(1) expect(mail.html_part.body.to_s.scan(/to respond/).count).to eq(1)
# 1 unsubscribe link # 1 unsubscribe link
expect(mail.html_part.to_s.scan(/To unsubscribe/).count).to eq(1) expect(mail.html_part.body.to_s.scan(/To unsubscribe/).count).to eq(1)
# side effect, topic user is updated with post number # side effect, topic user is updated with post number
tu = TopicUser.get(post.topic_id, user) tu = TopicUser.get(post.topic_id, user)
@ -384,7 +384,7 @@ describe UserNotifications do
notification_data_hash: notification.data_hash notification_data_hash: notification.data_hash
) )
expect(mail.html_part.to_s).to_not include(response.raw) expect(mail.html_part.body.to_s).to_not include(response.raw)
expect(mail.text_part.to_s).to_not include(response.raw) expect(mail.text_part.to_s).to_not include(response.raw)
end end
@ -451,13 +451,13 @@ describe UserNotifications do
expect(mail.subject).to include("[PM] ") expect(mail.subject).to include("[PM] ")
# 1 "visit message" link # 1 "visit message" link
expect(mail.html_part.to_s.scan(/Visit Message/).count).to eq(1) expect(mail.html_part.body.to_s.scan(/Visit Message/).count).to eq(1)
# 1 respond to link # 1 respond to link
expect(mail.html_part.to_s.scan(/to respond/).count).to eq(1) expect(mail.html_part.body.to_s.scan(/to respond/).count).to eq(1)
# 1 unsubscribe link # 1 unsubscribe link
expect(mail.html_part.to_s.scan(/To unsubscribe/).count).to eq(1) expect(mail.html_part.body.to_s.scan(/To unsubscribe/).count).to eq(1)
# side effect, topic user is updated with post number # side effect, topic user is updated with post number
tu = TopicUser.get(topic.id, user) tu = TopicUser.get(topic.id, user)
@ -473,8 +473,8 @@ describe UserNotifications do
notification_data_hash: notification.data_hash notification_data_hash: notification.data_hash
) )
expect(mail.html_part.to_s).to_not include(response.raw) expect(mail.html_part.body.to_s).to_not include(response.raw)
expect(mail.html_part.to_s).to_not include(topic.url) expect(mail.html_part.body.to_s).to_not include(topic.url)
expect(mail.text_part.to_s).to_not include(response.raw) expect(mail.text_part.to_s).to_not include(response.raw)
expect(mail.text_part.to_s).to_not include(topic.url) expect(mail.text_part.to_s).to_not include(topic.url)
end end
@ -635,7 +635,7 @@ describe UserNotifications do
# WARNING: you reached the limit of 100 email notifications per day. Further emails will be suppressed. # WARNING: you reached the limit of 100 email notifications per day. Further emails will be suppressed.
# Consider watching less topics or disabling mailing list mode. # Consider watching less topics or disabling mailing list mode.
expect(mail.html_part.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2)) expect(mail.html_part.body.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2))
expect(mail.body.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2)) expect(mail.body.to_s).to match(I18n.t("user_notifications.reached_limit", count: 2))
end end

View File

@ -0,0 +1,71 @@
# frozen_string_literal: true
require 'rails_helper'
describe Admin::EmailStylesController do
fab!(:admin) { Fabricate(:admin) }
let(:default_html) { File.read("#{Rails.root}/app/views/email/default_template.html") }
let(:default_css) { "" }
before do
sign_in(admin)
end
after do
SiteSetting.remove_override!(:email_custom_template)
SiteSetting.remove_override!(:email_custom_css)
end
describe 'show' do
it 'returns default values' do
get '/admin/customize/email_style.json'
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)['email_style']
expect(json['html']).to eq(default_html)
expect(json['css']).to eq(default_css)
end
it 'returns customized values' do
SiteSetting.email_custom_template = "For you: %{email_content}"
SiteSetting.email_custom_css = ".user-name { font-size: 24px; }"
get '/admin/customize/email_style.json'
expect(response.status).to eq(200)
json = ::JSON.parse(response.body)['email_style']
expect(json['html']).to eq("For you: %{email_content}")
expect(json['css']).to eq(".user-name { font-size: 24px; }")
end
end
describe 'update' do
let(:valid_params) do
{
html: 'For you: %{email_content}',
css: '.user-name { color: purple; }'
}
end
it 'changes the settings' do
SiteSetting.email_custom_css = ".user-name { font-size: 24px; }"
put '/admin/customize/email_style.json', params: { email_style: valid_params }
expect(response.status).to eq(200)
expect(SiteSetting.email_custom_template).to eq(valid_params[:html])
expect(SiteSetting.email_custom_css).to eq(valid_params[:css])
end
it 'reports errors' do
put '/admin/customize/email_style.json', params: {
email_style: valid_params.merge(html: 'No email content')
}
expect(response.status).to eq(422)
json = JSON.parse(response.body)
expect(json['errors']).to include(
I18n.t(
'email_style.html_missing_placeholder',
placeholder: '%{email_content}'
)
)
end
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'rails_helper'
describe EmailStyleUpdater do
fab!(:admin) { Fabricate(:admin) }
let(:default_html) { File.read("#{Rails.root}/app/views/email/default_template.html") }
let(:updater) { EmailStyleUpdater.new(admin) }
describe 'update' do
it 'can change the settings' do
expect(
updater.update(
html: 'For you: %{email_content}',
css: 'h1 { color: blue; }'
)
).to eq(true)
expect(SiteSetting.email_custom_template).to eq('For you: %{email_content}')
expect(SiteSetting.email_custom_css).to eq('h1 { color: blue; }')
end
it 'will not store defaults' do
updater.update(html: default_html, css: '')
expect(SiteSetting.email_custom_template).to_not be_present
expect(SiteSetting.email_custom_css).to_not be_present
end
it 'can clear settings if defaults given' do
SiteSetting.email_custom_template = 'For you: %{email_content}'
SiteSetting.email_custom_css = 'h1 { color: blue; }'
updater.update(html: default_html, css: '')
expect(SiteSetting.email_custom_template).to_not be_present
expect(SiteSetting.email_custom_css).to_not be_present
end
it 'fails if html is missing email_content' do
expect(updater.update(html: 'No email content', css: '')).to eq(false)
expect(updater.errors).to include(
I18n.t(
'email_style.html_missing_placeholder',
placeholder: '%{email_content}'
)
)
end
end
end