UX: add preview to email templates (#36657)

Adds a preview text to the beginning of the email body based on user
notification type. The value is taken from the translation key, which
can be customized for each email within Admin -> Appearance -> Site
Texts (ie. translation overrides).

For html emails this preview text is hidden with `display: none` to
prevent it appearing within the body of the email. For the plain text
version of the email it will appear within the body of the email.

Co-authored-by: Martin Brennan <martin@discourse.org>
This commit is contained in:
David Battersby
2026-01-08 11:33:53 +04:00
committed by GitHub
co-authored by Martin Brennan
parent cd858b2d64
commit 17e6ea96ab
10 changed files with 90 additions and 2 deletions
+4 -1
View File
@@ -27,11 +27,14 @@ module EmailHelper
raw "<a href='#{Discourse.base_url_no_prefix}#{url}' style='color: ##{@anchor_color}'>#{title}</a>"
end
def email_html_template
def email_html_template(preview: nil)
email_preview = preview.present? ? PrettyText.cook(preview, sanitize: false).html_safe : ""
EmailStyle
.new
.html
.sub("%{email_content}") { capture { yield } }
.gsub("%{email_preview}", email_preview)
.gsub("%{html_lang}", html_lang)
.gsub("%{dark_mode_meta_tags}", dark_mode_meta_tags)
.gsub("%{dark_mode_styles}", dark_mode_styles)
+2
View File
@@ -31,6 +31,8 @@
</style>
<![endif]-->
<div style='display: none'>%{email_preview}</div>
%{email_content}
<!-- prevent Gmail on iOS font size manipulation -->
+2
View File
@@ -5,6 +5,8 @@
</div>
<% end %>
<div class='email-preview'>%{email_preview}</div>
<div class='header-instructions'>%{header_instructions}</div>
<%- if SiteSetting.private_email? %>
+2 -1
View File
@@ -2,7 +2,8 @@
<%= yield %>
<% if defined?(html_body) %><%= html_body %><% end %>
<% else %>
<%= email_html_template do %>
<% preview = defined?(email_preview) ? email_preview : nil %>
<%= email_html_template(preview: preview) do %>
<%= yield %>
<% if defined?(html_body) %><%= html_body %><% end %>
<% end %>
+22
View File
@@ -3399,6 +3399,7 @@ en:
post_hidden:
title: "Post Hidden"
preview: "Post hidden due to flags by community members."
subject_template: "Post hidden by community flags"
text_body_template: |
Hello,
@@ -3463,6 +3464,7 @@ en:
post_hidden_again:
title: "Post Hidden again"
preview: "Post hidden again by community members."
subject_template: "Post hidden by community flags, staff notified"
text_body_template: |
Hello,
@@ -3557,6 +3559,7 @@ en:
welcome_user:
title: "Welcome User"
preview: "Welcome to %{site_name}!"
subject_template: "Welcome to %{site_name}!"
text_body_template: |
Thanks for joining %{site_name}, and welcome!
@@ -3586,6 +3589,7 @@ en:
With great power comes great responsibility. If youre new to moderating, please refer to the [Moderation Guide](https://meta.discourse.org/t/discourse-moderation-guide/63116).
welcome_invite:
title: "Welcome Invite"
preview: "Welcome to %{site_name}!"
subject_template: "Welcome to %{site_name}!"
text_body_template: |
Thanks for accepting your invitation to %{site_name} -- welcome!
@@ -3994,6 +3998,7 @@ en:
too_many_spam_flags:
title: "Too Many Spam Flags"
preview: "Posts temporarily hidden due to spam flags."
subject_template: "New account on hold"
text_body_template: |
Hello,
@@ -4016,6 +4021,7 @@ en:
For additional guidance, please refer to our [community guidelines](%{base_url}/guidelines).
silenced_by_staff:
title: "Silenced by Staff"
preview: "Account temporarily placed on hold."
subject_template: "Account temporarily on hold"
text_body_template: |
Hello,
@@ -4065,6 +4071,7 @@ en:
unsilenced:
title: "Unsilenced"
preview: "Account no longer on hold."
subject_template: "Account no longer on hold"
text_body_template: |
Hello,
@@ -4243,6 +4250,7 @@ en:
user_replied:
title: "User Replied"
preview: "Someone replied to your post."
subject_template: "[%{email_prefix}] %{topic_title}"
text_body_template: |
%{header_instructions}
@@ -4267,6 +4275,7 @@ en:
user_quoted:
title: "User Quoted"
preview: "Someone quoted you in a post."
subject_template: "[%{email_prefix}] %{topic_title}"
text_body_template: |
%{header_instructions}
@@ -4279,6 +4288,7 @@ en:
user_linked:
title: "User Linked"
preview: "Post linked in another topic."
subject_template: "[%{email_prefix}] %{topic_title}"
text_body_template: |
%{header_instructions}
@@ -4291,6 +4301,7 @@ en:
user_mentioned:
title: "User Mentioned"
preview: "Someone mentioned you in a post."
subject_template: "[%{email_prefix}] %{topic_title}"
text_body_template: |
%{header_instructions}
@@ -4351,6 +4362,7 @@ en:
user_posted:
title: "User Posted"
preview: "Someone replied to a topic you are Watching."
subject_template: "[%{email_prefix}] %{topic_title}"
text_body_template: |
%{header_instructions}
@@ -4387,6 +4399,7 @@ en:
user_posted_pm:
title: "User Posted PM"
preview: "Someone sent you a PM."
subject_template: "[%{email_prefix}] [PM] %{topic_title}"
text_body_template: |
%{header_instructions}
@@ -4513,6 +4526,7 @@ en:
forgot_password:
title: "Forgot Password"
preview: "Reset your password."
subject_template: "[%{email_prefix}] Password reset"
text_body_template: |
Somebody asked to reset your password on [%{site_name}](%{base_url}).
@@ -4524,6 +4538,7 @@ en:
email_login:
title: "Log in via link"
preview: "Log in via link."
subject_template: "[%{email_prefix}] Log in via link"
text_body_template: |
Here's your link to log in at [%{site_name}](%{base_url}).
@@ -4535,6 +4550,7 @@ en:
set_password:
title: "Set Password"
preview: "Set account password."
subject_template: "[%{email_prefix}] Set Password"
text_body_template: |
Somebody asked to add a password to your account on [%{site_name}](%{base_url}). Alternatively, you can log in using any supported online service (Google, Facebook, etc) that is associated with this validated email address.
@@ -4546,6 +4562,7 @@ en:
admin_login:
title: "Admin Login"
preview: "Admin login requested."
subject_template: "[%{email_prefix}] Login"
text_body_template: |
Somebody asked to log in to your account on [%{site_name}](%{base_url}).
@@ -4557,6 +4574,7 @@ en:
account_created:
title: "Account Created"
preview: "New account created for you."
subject_template: "[%{email_prefix}] Your New Account"
text_body_template: |
A new account was created for you at %{site_name}
@@ -4610,6 +4628,7 @@ en:
notify_old_email:
title: "Notify Old Email"
preview: "Email address updated."
subject_template: "[%{email_prefix}] Your email address has been changed"
text_body_template: |
This is an automated message to let you know that your email address for
@@ -4634,6 +4653,7 @@ en:
signup_after_approval:
title: "Signup After Approval"
preview: "New account approved."
subject_template: "You've been approved on %{site_name}!"
text_body_template: |
Welcome to %{site_name}!
@@ -4661,6 +4681,7 @@ en:
signup:
title: "Signup"
preview: "Confirm your account."
subject_template: "[%{email_prefix}] Confirm your new account"
text_body_template: |
Welcome to %{site_name}!
@@ -4685,6 +4706,7 @@ en:
suspicious_login:
title: "New Login Alert"
preview: "Verify your device."
subject_template: "[%{site_name}] New Login from %{location}"
text_body_template: |
Hello,
+15
View File
@@ -20,6 +20,10 @@ module Email
hostname: Discourse.current_hostname,
}.merge!(@opts)
if @opts[:template].present? && I18n.exists?("#{@opts[:template]}.preview")
@template_args[:email_preview] ||= I18n.t("#{@opts[:template]}.preview", @template_args)
end
return if @template_args[:url].blank?
@template_args[:header_instructions] ||= I18n.t(
@@ -150,6 +154,13 @@ module Email
html_override.gsub!("%{unsubscribe_instructions}", "")
end
if @template_args[:email_preview].present?
email_preview = PrettyText.cook(@template_args[:email_preview], sanitize: false).html_safe
html_override.gsub!("%{email_preview}", email_preview)
else
html_override.gsub!("%{email_preview}", "")
end
if @template_args[:header_instructions].present?
header_instructions =
PrettyText.cook(@template_args[:header_instructions], sanitize: false).html_safe
@@ -234,6 +245,10 @@ module Email
def header_args
result = {}
if @template_args[:email_preview].present?
result["X-Discourse-Email-Preview"] = @template_args[:email_preview]
end
if @opts[:add_unsubscribe_link]
if unsubscribe_url = @template_args[:unsubscribe_url].presence
result["List-Unsubscribe"] = "<#{unsubscribe_url}>"
+2
View File
@@ -15,6 +15,7 @@ module Email
end
def html
email_preview = @message.header["X-Discourse-Email-Preview"]&.value
style =
if @message.html_part
Email::Styles.new(@message.html_part.body.to_s, @opts)
@@ -25,6 +26,7 @@ module Email
format: :html,
locals: {
html_body: PrettyText.cook(text).html_safe,
email_preview: email_preview,
},
)
Email::Styles.new(unstyled, @opts)
+1
View File
@@ -263,6 +263,7 @@ module 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:Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.3;margin:0;min-width:100%;padding:0;width:100%",
)
style(".email-preview", "display: none;")
style(".previous-discussion", "font-size: 17px; color: #444; margin-bottom:10px;")
style(
".notification-date",
+5
View File
@@ -24,6 +24,10 @@ class SystemMessage
params = defaults.merge(params)
if I18n.exists?("system_messages.#{type}.preview", @recipient.effective_locale)
params[:email_preview] = I18n.t("system_messages.#{type}.preview", params)
end
title =
params[:message_title] ||
I18n.with_locale(@recipient.effective_locale) do
@@ -102,6 +106,7 @@ class SystemMessage
end,
site_password: "",
base_url: Discourse.base_url,
email_preview: "",
}
end
end
+35
View File
@@ -60,12 +60,19 @@ RSpec.describe UserNotifications do
describe ".signup" do
subject(:email) { UserNotifications.signup(user) }
let(:email_html) { Email::Renderer.new(email).html }
it "works" do
expect(email.to).to eq([user.email])
expect(email.subject).to be_present
expect(email.from).to eq([SiteSetting.notification_email])
expect(email.body).to be_present
end
it "shows the correct preview text in html" do
preview_text = I18n.t("user_notifications.signup.preview")
expect(email_html.scan(/#{preview_text}/).count).to eq(1)
end
end
describe ".forgot_password" do
@@ -77,6 +84,18 @@ RSpec.describe UserNotifications do
expect(email.from).to eq([SiteSetting.notification_email])
expect(email.body).to be_present
end
it "shows the correct preview text in forgot password" do
preview_text = I18n.t("user_notifications.forgot_password.preview")
expect(Email::Renderer.new(email).html.scan(/#{preview_text}/).count).to eq(1)
end
it "shows the correct preview text when setting password" do
email = UserNotifications.forgot_password(Fabricate(:user, password: nil))
preview_text = I18n.t("user_notifications.set_password.preview")
expect(Email::Renderer.new(email).html.scan(/#{preview_text}/).count).to eq(1)
end
end
describe ".post_approved" do
@@ -125,6 +144,8 @@ RSpec.describe UserNotifications do
Fabricate(:email_token, user: user, scope: EmailToken.scopes[:email_login]).token
end
let(:email_html) { Email::Renderer.new(email).html }
it "generates the right email" do
expect(email.to).to eq([user.email])
expect(email.from).to eq([SiteSetting.notification_email])
@@ -133,12 +154,16 @@ RSpec.describe UserNotifications do
I18n.t("user_notifications.email_login.subject_template", email_prefix: SiteSetting.title),
)
preview_text = I18n.t("user_notifications.email_login.preview")
expect(email_html.scan(/#{preview_text}/).count).to eq(1)
expect(email.body.to_s).to match(
I18n.t(
"user_notifications.email_login.text_body_template",
site_name: SiteSetting.title,
base_url: Discourse.base_url,
email_token: email_token,
email_preview: preview_text,
),
)
end
@@ -530,6 +555,9 @@ RSpec.describe UserNotifications do
expect(mail.subject).to match(/Taggie/)
mail_html = mail.html_part.body.to_s
preview_text = I18n.t("user_notifications.user_replied.preview")
expect(mail_html.scan(/#{preview_text}/).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)
@@ -755,6 +783,9 @@ RSpec.describe UserNotifications do
# subject should not include category name
expect(mail.subject).not_to match(/Uncategorized/)
preview_text = I18n.t("user_notifications.user_posted.preview")
expect(mail.html_part.body.to_s.scan(/#{preview_text}/).count).to eq(1)
# 1 respond to links as no context by default
expect(mail.html_part.body.to_s.scan(/to respond/).count).to eq(1)
@@ -847,6 +878,10 @@ RSpec.describe UserNotifications do
# subject should include "[PM]"
expect(mail.subject).to include("[PM] ")
# note that translation key differs from method name (ie. user_posted_pm)
preview_text = I18n.t("user_notifications.user_posted_pm.preview")
expect(mail.html_part.body.to_s.scan(/#{preview_text}/).count).to eq(1)
# 1 "visit message" link
expect(mail.html_part.body.to_s.scan(/Visit Message/).count).to eq(1)