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 'mustache'
gem 'nokogiri'
gem 'css_parser', require: false
gem 'omniauth'
gem 'omniauth-openid'

View File

@ -88,6 +88,8 @@ GEM
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.4)
css_parser (1.7.0)
addressable
debug_inspector (0.0.3)
diff-lcs (1.3)
diffy (3.3.0)
@ -438,6 +440,7 @@ DEPENDENCIES
certified
colored2
cppjieba_rb
css_parser
diffy
discourse-ember-source (~> 3.10.0)
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",
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='adminSiteText' label='admin.site_text.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='adminEmojis' label='admin.emoji.title'}}
{{nav-item route='adminPermalinks' label='admin.permalink.title'}}

View File

@ -790,3 +790,18 @@
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
require 'erb'
module EmailHelper
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>"
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
def extract_details(topic)

View File

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

View File

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

@ -54,22 +54,22 @@
<% if @digest_unsubscribe %>
<p>
<% if @digest_frequencies[:current] %>
<h3>
<%= t(
'unsubscribe.digest_frequency.title',
'unsubscribe.digest_frequency.title',
frequency: t("unsubscribe.digest_frequency.#{@digest_frequencies[:current]}")
) %>
</h3>
<br/>
<% end %>
<label><%= t 'unsubscribe.digest_frequency.select_title' %></label>
<%=
select_tag :digest_after_minutes,
options_for_select(@digest_frequencies[:frequencies], @digest_frequencies[:selected]),
class: 'combobox'
<%=
select_tag :digest_after_minutes,
options_for_select(@digest_frequencies[:frequencies], @digest_frequencies[:selected]),
class: 'combobox'
%>
</p>
<% end %>

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">
<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]-->
<div class="summary-email">
<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 %>
@ -425,10 +410,4 @@ body, table, td, th, h1, h2, h3 {font-family: Helvetica, Arial, sans-serif !impo
<%= digest_custom_html("below_footer") %>
<!-- 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>
</div>

View File

@ -3644,6 +3644,16 @@ en:
title: "Override your site's robots.txt file:"
warning: "This will permanently override any related site settings."
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:
title: "Emails"
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."
digest_suppress_categories: "Suppress these categories from summary emails."
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_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')."
@ -4554,3 +4555,6 @@ en:
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."
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'
put 'robots.json' => 'robots_txt#update'
delete 'robots.json' => 'robots_txt#reset'
resource :email_style, only: [:show, :update]
get 'email_style/:field' => 'email_styles#show', constraints: { field: /html|css/ }
end
resources :embeddable_hosts, constraints: AdminConstraint.new

View File

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

View File

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

View File

@ -17,15 +17,21 @@ module Email
end
def html
if @message.html_part
style = Email::Styles.new(@message.html_part.body.to_s, @opts)
style.format_basic
style.format_html
style = if @message.html_part
Email::Styles.new(@message.html_part.body.to_s, @opts)
else
style = Email::Styles.new(PrettyText.cook(text), @opts)
style.format_basic
unstyled = UserNotificationRenderer.with_view_paths(
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
style.format_basic
style.format_html
style.to_html
end

View File

@ -16,6 +16,7 @@ module Email
@html = html
@opts = opts || {}
@fragment = Nokogiri::HTML.fragment(@html)
@custom_styles = nil
end
def self.register_plugin_style(&block)
@ -32,6 +33,26 @@ module Email
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
uri = URI(Discourse.base_url)
@ -83,29 +104,6 @@ module Email
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
# Links to other topics
style('aside.quote', 'padding: 12px 25px 2px 12px; margin-bottom: 10px;')
@ -164,6 +162,16 @@ module Email
end
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('h4', 'color: #222;')
style('h3', 'margin: 15px 0 20px 0;')
@ -177,11 +185,39 @@ module Email
style('code', 'background-color: #f1f1ff; padding: 2px 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('.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
plugin_styles
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
# this method is reserved for styles specific to plugin
@ -240,7 +276,7 @@ module Email
end
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;"
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(/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(/In Reply To/).count).to eq(1)
@ -287,7 +287,7 @@ describe UserNotifications do
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.display_name_on_posts = true
@ -304,7 +304,7 @@ describe UserNotifications do
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(/>bobmarley/).count).to eq(0)
@ -317,7 +317,7 @@ describe UserNotifications do
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(/>bobmarley/).count).to eq(1)
end
@ -331,8 +331,8 @@ describe UserNotifications do
notification_data_hash: notification.data_hash
)
expect(mail.html_part.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(response.raw)
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(topic.url)
end
@ -365,10 +365,10 @@ describe UserNotifications do
expect(mail.subject).not_to match(/Uncategorized/)
# 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
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
tu = TopicUser.get(post.topic_id, user)
@ -384,7 +384,7 @@ describe UserNotifications do
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)
end
@ -451,13 +451,13 @@ describe UserNotifications do
expect(mail.subject).to include("[PM] ")
# 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
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
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
tu = TopicUser.get(topic.id, user)
@ -473,8 +473,8 @@ describe UserNotifications do
notification_data_hash: notification.data_hash
)
expect(mail.html_part.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(response.raw)
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(topic.url)
end
@ -635,7 +635,7 @@ describe UserNotifications do
# 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.
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))
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