diff --git a/Gemfile b/Gemfile index 2db7fffbcec..dde65dcd2aa 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 3e50a65a278..c0438f2fdfa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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 diff --git a/app/assets/javascripts/admin/adapters/email-style.js.es6 b/app/assets/javascripts/admin/adapters/email-style.js.es6 new file mode 100644 index 00000000000..c9f3865d4c3 --- /dev/null +++ b/app/assets/javascripts/admin/adapters/email-style.js.es6 @@ -0,0 +1,7 @@ +import RestAdapter from "discourse/adapters/rest"; + +export default RestAdapter.extend({ + pathFor() { + return "/admin/customize/email_style"; + } +}); diff --git a/app/assets/javascripts/admin/components/email-styles-editor.js.es6 b/app/assets/javascripts/admin/components/email-styles-editor.js.es6 new file mode 100644 index 00000000000..d0e569421f6 --- /dev/null +++ b/app/assets/javascripts/admin/components/email-styles-editor.js.es6 @@ -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"); + } + } + ); + } + } +}); diff --git a/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 b/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 new file mode 100644 index 00000000000..b8054c8bc3f --- /dev/null +++ b/app/assets/javascripts/admin/controllers/admin-customize-email-style-edit.js.es6 @@ -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)); + } + } + } +}); diff --git a/app/assets/javascripts/admin/models/email-style.js.es6 b/app/assets/javascripts/admin/models/email-style.js.es6 new file mode 100644 index 00000000000..29d7568aba0 --- /dev/null +++ b/app/assets/javascripts/admin/models/email-style.js.es6 @@ -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); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-email-style-edit.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-email-style-edit.js.es6 new file mode 100644 index 00000000000..74649c1a004 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-email-style-edit.js.es6 @@ -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(); + } + } + ); + } + } + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-customize-email-style.js.es6 b/app/assets/javascripts/admin/routes/admin-customize-email-style.js.es6 new file mode 100644 index 00000000000..8e202e62bd8 --- /dev/null +++ b/app/assets/javascripts/admin/routes/admin-customize-email-style.js.es6 @@ -0,0 +1,9 @@ +export default Ember.Route.extend({ + model() { + return this.store.find("email-style"); + }, + + redirect() { + this.transitionTo("adminCustomizeEmailStyle.edit", "html"); + } +}); diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 index a20165db02e..478a851d864 100644 --- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 +++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6 @@ -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" }); + } + ); } ); diff --git a/app/assets/javascripts/admin/templates/components/email-styles-editor.hbs b/app/assets/javascripts/admin/templates/components/email-styles-editor.hbs new file mode 100644 index 00000000000..883149644d2 --- /dev/null +++ b/app/assets/javascripts/admin/templates/components/email-styles-editor.hbs @@ -0,0 +1,20 @@ +
+
+ +
+
+ +{{ace-editor content=editorContents mode=fieldName editorId=editorId}} + + diff --git a/app/assets/javascripts/admin/templates/customize-email-style-edit.hbs b/app/assets/javascripts/admin/templates/customize-email-style-edit.hbs new file mode 100644 index 00000000000..1e68fd0c1a2 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-email-style-edit.hbs @@ -0,0 +1,9 @@ +{{email-styles-editor styles=model fieldName=fieldName}} + + diff --git a/app/assets/javascripts/admin/templates/customize-email-style.hbs b/app/assets/javascripts/admin/templates/customize-email-style.hbs new file mode 100644 index 00000000000..d46e7326039 --- /dev/null +++ b/app/assets/javascripts/admin/templates/customize-email-style.hbs @@ -0,0 +1,7 @@ +
+

{{i18n 'admin.customize.email_style.heading'}}

+ +

{{i18n 'admin.customize.email_style.instructions'}}

+
+ +{{outlet}} diff --git a/app/assets/javascripts/admin/templates/customize.hbs b/app/assets/javascripts/admin/templates/customize.hbs index d5d38163108..768eaf29a26 100644 --- a/app/assets/javascripts/admin/templates/customize.hbs +++ b/app/assets/javascripts/admin/templates/customize.hbs @@ -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'}} diff --git a/app/assets/stylesheets/common/admin/customize.scss b/app/assets/stylesheets/common/admin/customize.scss index e9f8fc23e2a..ce30f493126 100644 --- a/app/assets/stylesheets/common/admin/customize.scss +++ b/app/assets/stylesheets/common/admin/customize.scss @@ -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; + } + } +} diff --git a/app/controllers/admin/email_styles_controller.rb b/app/controllers/admin/email_styles_controller.rb new file mode 100644 index 00000000000..5649708bfe4 --- /dev/null +++ b/app/controllers/admin/email_styles_controller.rb @@ -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 diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index 9687138ecaa..c579473a4d2 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -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 "#{title}" 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) diff --git a/app/mailers/invite_mailer.rb b/app/mailers/invite_mailer.rb index 4c12e697a9f..469a3804158 100644 --- a/app/mailers/invite_mailer.rb +++ b/app/mailers/invite_mailer.rb @@ -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 diff --git a/app/mailers/user_notifications.rb b/app/mailers/user_notifications.rb index cf73aa5abe8..0ffc6f77ba0 100644 --- a/app/mailers/user_notifications.rb +++ b/app/mailers/user_notifications.rb @@ -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 diff --git a/app/models/email_style.rb b/app/models/email_style.rb new file mode 100644 index 00000000000..6d460ccbd19 --- /dev/null +++ b/app/models/email_style.rb @@ -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 diff --git a/app/serializers/email_style_serializer.rb b/app/serializers/email_style_serializer.rb new file mode 100644 index 00000000000..16225f7f533 --- /dev/null +++ b/app/serializers/email_style_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class EmailStyleSerializer < ApplicationSerializer + attributes :id, :html, :css, :default_html, :default_css +end diff --git a/app/services/email_style_updater.rb b/app/services/email_style_updater.rb new file mode 100644 index 00000000000..d0fb9186365 --- /dev/null +++ b/app/services/email_style_updater.rb @@ -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 diff --git a/app/services/user_notification_renderer.rb b/app/services/user_notification_renderer.rb new file mode 100644 index 00000000000..8ad7d209145 --- /dev/null +++ b/app/services/user_notification_renderer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UserNotificationRenderer < ActionView::Base + include ApplicationHelper + include UserNotificationsHelper + include EmailHelper +end diff --git a/app/views/email/default_template.html b/app/views/email/default_template.html new file mode 100644 index 00000000000..382f939273b --- /dev/null +++ b/app/views/email/default_template.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + +%{email_content} + + +
                                    +                        
+ + + diff --git a/app/views/email/invite.html.erb b/app/views/email/invite.html.erb deleted file mode 100644 index 9903735c528..00000000000 --- a/app/views/email/invite.html.erb +++ /dev/null @@ -1,11 +0,0 @@ -
> - -
%{header_instructions}
- - <% if message.present? %> -
<%= message %>
- <% end %> - - - -
diff --git a/app/views/email/template.html.erb b/app/views/email/template.html.erb deleted file mode 100644 index 052a9b561f0..00000000000 --- a/app/views/email/template.html.erb +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - -
- - -
- <%= raw(html_body) %> -
diff --git a/app/views/email/unsubscribe.html.erb b/app/views/email/unsubscribe.html.erb index aee145e5ba3..4bc4a3d0dc0 100644 --- a/app/views/email/unsubscribe.html.erb +++ b/app/views/email/unsubscribe.html.erb @@ -54,22 +54,22 @@ <% if @digest_unsubscribe %>

- + <% if @digest_frequencies[:current] %>

<%= t( - 'unsubscribe.digest_frequency.title', + 'unsubscribe.digest_frequency.title', frequency: t("unsubscribe.digest_frequency.#{@digest_frequencies[:current]}") ) %>


<% end %> - + - <%= - 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' %>

<% end %> diff --git a/app/views/layouts/email_template.html.erb b/app/views/layouts/email_template.html.erb new file mode 100644 index 00000000000..80da92efc20 --- /dev/null +++ b/app/views/layouts/email_template.html.erb @@ -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 %> diff --git a/app/views/user_notifications/digest.html.erb b/app/views/user_notifications/digest.html.erb index ec36e142ba2..dc237d15f00 100644 --- a/app/views/user_notifications/digest.html.erb +++ b/app/views/user_notifications/digest.html.erb @@ -1,19 +1,4 @@ - - - - - - - - - - - - +
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index d00ba1c3d7d..4bafe972516 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 6dc6bd93c9c..6290dcaf63e 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -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}" diff --git a/config/routes.rb b/config/routes.rb index d1e4bf0bb2a..68b1b00fe93 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/config/site_settings.yml b/config/site_settings.yml index 5535315930d..29dc73a1dd5 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -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 diff --git a/lib/email/message_builder.rb b/lib/email/message_builder.rb index 92f4a2bab89..88669eb729d 100644 --- a/lib/email/message_builder.rb +++ b/lib/email/message_builder.rb @@ -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 diff --git a/lib/email/renderer.rb b/lib/email/renderer.rb index f8d0328dc40..0c616be0436 100644 --- a/lib/email/renderer.rb +++ b/lib/email/renderer.rb @@ -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 diff --git a/lib/email/styles.rb b/lib/email/styles.rb index 7dc7f14b3ae..1b6373878d0 100644 --- a/lib/email/styles.rb +++ b/lib/email/styles.rb @@ -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 diff --git a/spec/integration/email_style_spec.rb b/spec/integration/email_style_spec.rb new file mode 100644 index 00000000000..e035aec1028 --- /dev/null +++ b/spec/integration/email_style_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe EmailStyle do + before do + SiteSetting.email_custom_template = "

FOR YOU

%{email_content}
" + 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('

FOR YOU

').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(/])+/) + 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('

FOR YOU

').count).to eq(1) + matches = mail_html.match(/
#{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('

FOR YOU

').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('

FOR YOU

').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(/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('

FOR YOU

').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('

FOR YOU

') + expect(mail_html).to_not include('FOR YOU') + expect(mail_html).to include(popular_topic.title) + end + end +end diff --git a/spec/mailers/user_notifications_spec.rb b/spec/mailers/user_notifications_spec.rb index 7106d4641f2..f86892b3281 100644 --- a/spec/mailers/user_notifications_spec.rb +++ b/spec/mailers/user_notifications_spec.rb @@ -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 diff --git a/spec/requests/admin/email_styles_controller_spec.rb b/spec/requests/admin/email_styles_controller_spec.rb new file mode 100644 index 00000000000..9d1f297e7eb --- /dev/null +++ b/spec/requests/admin/email_styles_controller_spec.rb @@ -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 diff --git a/spec/services/email_style_updater_spec.rb b/spec/services/email_style_updater_spec.rb new file mode 100644 index 00000000000..3ef6f1c7ea9 --- /dev/null +++ b/spec/services/email_style_updater_spec.rb @@ -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