FEATURE: add "email in-reply-to user option"

We no longer include previous replies as "context", instead
we include and excerpt of the post being replied to at the bottom
of notifications, this information was previously missing.

Users may opt in to emailing previous replies if they wish
or opt out of "in-reply-to" which makes sense in some email clients that
are smarter about displaying a tree of replies.
This commit is contained in:
Sam Saffron 2016-02-26 00:05:40 +11:00
parent a01b2a48d5
commit 820a435af8
14 changed files with 92 additions and 36 deletions

View file

@ -158,6 +158,7 @@ const User = RestModel.extend({
'external_links_in_new_tab', 'external_links_in_new_tab',
'email_digests', 'email_digests',
'email_direct', 'email_direct',
'email_in_reply_to',
'email_private_messages', 'email_private_messages',
'email_previous_replies', 'email_previous_replies',
'dynamic_favicon', 'dynamic_favicon',

View file

@ -180,6 +180,7 @@
<label>{{i18n 'user.email_previous_replies.title'}}</label> <label>{{i18n 'user.email_previous_replies.title'}}</label>
{{combo-box valueAttribute="value" content=previousRepliesOptions value=model.user_option.email_previous_replies}} {{combo-box valueAttribute="value" content=previousRepliesOptions value=model.user_option.email_previous_replies}}
</div> </div>
{{preference-checkbox labelKey="user.email_in_reply_to" checked=model.user_option.email_in_reply_to}}
{{preference-checkbox labelKey="user.email_private_messages" checked=model.user_option.email_private_messages}} {{preference-checkbox labelKey="user.email_private_messages" checked=model.user_option.email_private_messages}}
{{preference-checkbox labelKey="user.email_direct" checked=model.user_option.email_direct}} {{preference-checkbox labelKey="user.email_direct" checked=model.user_option.email_direct}}
<span class="pref-mailing-list-mode">{{preference-checkbox labelKey="user.mailing_list_mode" checked=model.user_option.mailing_list_mode}}</span> <span class="pref-mailing-list-mode">{{preference-checkbox labelKey="user.mailing_list_mode" checked=model.user_option.mailing_list_mode}}</span>

View file

@ -193,9 +193,9 @@ class UserNotifications < ActionMailer::Base
include UserNotificationsHelper include UserNotificationsHelper
end end
def self.get_context_posts(post, topic_user) def self.get_context_posts(post, topic_user, user)
user_option = topic_user.try(:user).try(:user_option)
if user_option && (user_option.email_previous_replies == UserOption.previous_replies_type[:never]) if user.user_option.email_previous_replies == UserOption.previous_replies_type[:never]
return [] return []
end end
@ -210,7 +210,7 @@ class UserNotifications < ActionMailer::Base
.order('created_at desc') .order('created_at desc')
.limit(SiteSetting.email_posts_context) .limit(SiteSetting.email_posts_context)
if topic_user && topic_user.last_emailed_post_number && user_option.try(:email_previous_replies) == UserOption.previous_replies_type[:unless_emailed] if topic_user && topic_user.last_emailed_post_number && user.user_option.email_previous_replies == UserOption.previous_replies_type[:unless_emailed]
context_posts = context_posts.where("post_number > ?", topic_user.last_emailed_post_number) context_posts = context_posts.where("post_number > ?", topic_user.last_emailed_post_number)
end end
@ -280,7 +280,7 @@ class UserNotifications < ActionMailer::Base
context = "" context = ""
tu = TopicUser.get(post.topic_id, user) tu = TopicUser.get(post.topic_id, user)
context_posts = self.class.get_context_posts(post, tu) context_posts = self.class.get_context_posts(post, tu, user)
# make .present? cheaper # make .present? cheaper
context_posts = context_posts.to_a context_posts = context_posts.to_a
@ -296,11 +296,13 @@ class UserNotifications < ActionMailer::Base
if opts[:use_template_html] if opts[:use_template_html]
topic_excerpt = post.excerpt.gsub("\n", " ") if post.is_first_post? && post.excerpt topic_excerpt = post.excerpt.gsub("\n", " ") if post.is_first_post? && post.excerpt
else else
in_reply_to_post = post.reply_to_post if user.user_option.email_in_reply_to
html = UserNotificationRenderer.new(Rails.configuration.paths["app/views"]).render( html = UserNotificationRenderer.new(Rails.configuration.paths["app/views"]).render(
template: 'email/notification', template: 'email/notification',
format: :html, format: :html,
locals: { context_posts: context_posts, locals: { context_posts: context_posts,
post: post, post: post,
in_reply_to_post: in_reply_to_post,
classes: RTL.new(user).css_class classes: RTL.new(user).css_class
} }
) )

View file

@ -16,6 +16,7 @@ class UserOption < ActiveRecord::Base
self.automatically_unpin_topics = SiteSetting.default_topics_automatic_unpin self.automatically_unpin_topics = SiteSetting.default_topics_automatic_unpin
self.email_private_messages = SiteSetting.default_email_private_messages self.email_private_messages = SiteSetting.default_email_private_messages
self.email_previous_replies = SiteSetting.default_email_previous_replies self.email_previous_replies = SiteSetting.default_email_previous_replies
self.email_in_reply_to = SiteSetting.default_email_in_reply_to
self.enable_quoting = SiteSetting.default_other_enable_quoting self.enable_quoting = SiteSetting.default_other_enable_quoting
self.external_links_in_new_tab = SiteSetting.default_other_external_links_in_new_tab self.external_links_in_new_tab = SiteSetting.default_other_external_links_in_new_tab

View file

@ -14,7 +14,8 @@ class UserOptionSerializer < ApplicationSerializer
:edit_history_public, :edit_history_public,
:auto_track_topics_after_msecs, :auto_track_topics_after_msecs,
:new_topic_duration_minutes, :new_topic_duration_minutes,
:email_previous_replies :email_previous_replies,
:email_in_reply_to
def include_edit_history_public? def include_edit_history_public?

View file

@ -21,7 +21,8 @@ class UserUpdater
:digest_after_days, :digest_after_days,
:new_topic_duration_minutes, :new_topic_duration_minutes,
:auto_track_topics_after_msecs, :auto_track_topics_after_msecs,
:email_previous_replies :email_previous_replies,
:email_in_reply_to
] ]
def initialize(actor, user) def initialize(actor, user)
@ -53,10 +54,10 @@ class UserUpdater
save_options = false save_options = false
OPTION_ATTR.each do |attribute|
if attributes[attribute].present?
save_options = true
OPTION_ATTR.each do |attribute|
if attributes.key?(attribute)
save_options = true
if [true,false].include?(user.user_option.send(attribute)) if [true,false].include?(user.user_option.send(attribute))
val = attributes[attribute].to_s == 'true' val = attributes[attribute].to_s == 'true'

View file

@ -1,4 +1,4 @@
<table class='post-wrapper <%= post.whisper? ? "whisper" : "" %>'> <table class='post-wrapper <%= post.whisper? ? "whisper" : "" %> <%= use_excerpt ? "excerpt" : ""%>'>
<tbody> <tbody>
<tr> <tr>
<td> <td>
@ -23,7 +23,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td class='body'><%= format_for_email(post.cooked) %></td> <td class='body'><%= format_for_email(use_excerpt ? post.excerpt : post.cooked) %></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -2,17 +2,22 @@
<div class='header-instructions'>%{header_instructions}</div> <div class='header-instructions'>%{header_instructions}</div>
<%= render partial: 'email/post', locals: { post: post } %> <%= render partial: 'email/post', locals: { post: post, use_excerpt: false } %>
<% if in_reply_to_post.present? || context_posts.present? %>
<div class='footer'>%{respond_instructions}</div>
<hr>
<% end %>
<% if in_reply_to_post.present? %>
<h4 class='.previous-discussion'><%= t "user_notifications.in_reply_to" %></h4>
<%= render partial: 'email/post', locals: { post: in_reply_to_post, use_excerpt: true} %>
<% end %>
<% if context_posts.present? %> <% if context_posts.present? %>
<div class='footer'>%{respond_instructions}</div>
<hr>
<h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4> <h4 class='.previous-discussion'><%= t "user_notifications.previous_discussion" %></h4>
<% context_posts.each do |p| %> <% context_posts.each do |p| %>
<%= render partial: 'email/post', locals: { post: p } %> <%= render partial: 'email/post', locals: { post: p, use_excerpt: false } %>
<% end %> <% end %>
<% end %> <% end %>

View file

@ -628,7 +628,7 @@ en:
website: "Web Site" website: "Web Site"
email_settings: "Email" email_settings: "Email"
email_previous_replies: email_previous_replies:
title: "Include previous replies" title: "Include previous replies at the bottom of emails"
unless_emailed: "unless previously sent" unless_emailed: "unless previously sent"
always: "always" always: "always"
never: "never" never: "never"
@ -639,6 +639,7 @@ en:
weekly: "weekly" weekly: "weekly"
every_two_weeks: "every two weeks" every_two_weeks: "every two weeks"
email_in_reply_to: "Include an excerpt of replied to post in emails"
email_direct: "Send me an email when someone quotes me, replies to my post, mentions my @username, or invites me to a topic" email_direct: "Send me an email when someone quotes me, replies to my post, mentions my @username, or invites me to a topic"
email_private_messages: "Send me an email when someone messages me" email_private_messages: "Send me an email when someone messages me"
email_always: "Send me email notifications even when I am active on the site" email_always: "Send me email notifications even when I am active on the site"

View file

@ -1243,6 +1243,8 @@ en:
default_email_always: "Send an email notification even when the user is active by default." default_email_always: "Send an email notification even when the user is active by default."
default_email_previous_replies: "Include previous replies in emails by default." default_email_previous_replies: "Include previous replies in emails by default."
default_email_in_reply_to: "Include excerpt of replied to post in emails by default."
default_other_new_topic_duration_minutes: "Global default condition for which a topic is considered new." default_other_new_topic_duration_minutes: "Global default condition for which a topic is considered new."
default_other_auto_track_topics_after_msecs: "Global default time before a topic is automatically tracked." default_other_auto_track_topics_after_msecs: "Global default time before a topic is automatically tracked."
default_other_external_links_in_new_tab: "Open external links in a new tab by default." default_other_external_links_in_new_tab: "Open external links in a new tab by default."
@ -1995,6 +1997,7 @@ en:
user_notifications: user_notifications:
previous_discussion: "Previous Replies" previous_discussion: "Previous Replies"
in_reply_to: "In Reply To"
unsubscribe: unsubscribe:
title: "Unsubscribe" title: "Unsubscribe"
description: "Not interested in getting these emails? No problem! Click below to unsubscribe instantly:" description: "Not interested in getting these emails? No problem! Click below to unsubscribe instantly:"

View file

@ -1063,7 +1063,9 @@ user_preferences:
default_email_always: false default_email_always: false
default_email_previous_replies: default_email_previous_replies:
enum: 'PreviousRepliesSiteSetting' enum: 'PreviousRepliesSiteSetting'
default: 1 default: 2
default_email_in_reply_to:
default: true
default_other_new_topic_duration_minutes: default_other_new_topic_duration_minutes:
enum: 'NewTopicDurationSiteSetting' enum: 'NewTopicDurationSiteSetting'

View file

@ -0,0 +1,11 @@
class AddEmailInReplyToToUserOptions < ActiveRecord::Migration
def up
add_column :user_options, :email_in_reply_to, :boolean, null: false, default: true
change_column :user_options, :email_previous_replies, :integer, default: 2, null: false
execute 'UPDATE user_options SET email_previous_replies = 2'
end
def down
remove_column :user_options, :email_in_reply_to
end
end

View file

@ -15,11 +15,16 @@ describe UserNotifications do
_post7 = Fabricate(:post, topic: post1.topic, post_type: Post.types[:whisper]) _post7 = Fabricate(:post, topic: post1.topic, post_type: Post.types[:whisper])
last = Fabricate(:post, topic: post1.topic) last = Fabricate(:post, topic: post1.topic)
post1.user.user_option.email_previous_replies = UserOption.previous_replies_type[:always]
# default is only post #1 # default is only post #1
expect(UserNotifications.get_context_posts(last, nil).count).to eq(1) expect(UserNotifications.get_context_posts(last, nil, post1.user).count).to eq(1)
# staff members can also see the whisper # staff members can also see the whisper
tu = TopicUser.new(topic: post1.topic, user: build(:moderator)) moderator = build(:moderator)
expect(UserNotifications.get_context_posts(last, tu).count).to eq(2) moderator.user_option = UserOption.new
moderator.user_option.email_previous_replies = UserOption.previous_replies_type[:always]
tu = TopicUser.new(topic: post1.topic, user: moderator)
expect(UserNotifications.get_context_posts(last, tu, tu.user).count).to eq(2)
end end
it "allows users to control context" do it "allows users to control context" do
@ -32,13 +37,15 @@ describe UserNotifications do
topic_user = TopicUser.find_by(user_id: user.id, topic_id: post1.topic_id) topic_user = TopicUser.find_by(user_id: user.id, topic_id: post1.topic_id)
# to avoid reloads after update_columns # to avoid reloads after update_columns
user = topic_user.user user = topic_user.user
expect(UserNotifications.get_context_posts(post3, topic_user).count).to eq(1) user.user_option.update_columns(email_previous_replies: UserOption.previous_replies_type[:unless_emailed])
expect(UserNotifications.get_context_posts(post3, topic_user, user).count).to eq(1)
user.user_option.update_columns(email_previous_replies: UserOption.previous_replies_type[:never]) user.user_option.update_columns(email_previous_replies: UserOption.previous_replies_type[:never])
expect(UserNotifications.get_context_posts(post3, topic_user).count).to eq(0) expect(UserNotifications.get_context_posts(post3, topic_user, user).count).to eq(0)
user.user_option.update_columns(email_previous_replies: UserOption.previous_replies_type[:always]) user.user_option.update_columns(email_previous_replies: UserOption.previous_replies_type[:always])
expect(UserNotifications.get_context_posts(post3, topic_user).count).to eq(2) expect(UserNotifications.get_context_posts(post3, topic_user, user).count).to eq(2)
end end
end end
@ -110,12 +117,14 @@ describe UserNotifications do
let(:response_by_user) { Fabricate(:user, name: "John Doe") } let(:response_by_user) { Fabricate(:user, name: "John Doe") }
let(:category) { Fabricate(:category, name: 'India') } let(:category) { Fabricate(:category, name: 'India') }
let(:topic) { Fabricate(:topic, category: category) } let(:topic) { Fabricate(:topic, category: category) }
let(:post) { Fabricate(:post, topic: topic) } let(:post) { Fabricate(:post, topic: topic, raw: 'This is My super duper cool topic') }
let(:response) { Fabricate(:post, topic: post.topic, user: response_by_user)} let(:response) { Fabricate(:post, reply_to_post_number: 1, topic: post.topic, user: response_by_user)}
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
let(:notification) { Fabricate(:notification, user: user) } let(:notification) { Fabricate(:notification, user: user) }
it 'generates a correct email' do it 'generates a correct email' do
# Fabricator is not fabricating this ...
SiteSetting.enable_names = true SiteSetting.enable_names = true
SiteSetting.display_name_on_posts = true SiteSetting.display_name_on_posts = true
mail = UserNotifications.user_replied(response.user, mail = UserNotifications.user_replied(response.user,
@ -123,25 +132,41 @@ describe UserNotifications do
notification_type: notification.notification_type, notification_type: notification.notification_type,
notification_data_hash: notification.data_hash notification_data_hash: notification.data_hash
) )
# from should include full user name # from should include full user name
expect(mail[:from].display_names).to eql(['John Doe']) expect(mail[:from].display_names).to eql(['John Doe'])
# subject should include category name # subject should include category name
expect(mail.subject).to match(/India/) expect(mail.subject).to match(/India/)
mail_html = mail.html_part.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)
# 2 "visit topic" link # 2 "visit topic" link
expect(mail.html_part.to_s.scan(/Visit Topic/).count).to eq(2) expect(mail_html.scan(/Visit Topic/).count).to eq(2)
# 2 respond to links cause we have 1 context post # 2 respond to links cause we have 1 context post
expect(mail.html_part.to_s.scan(/to respond/).count).to eq(2) expect(mail_html.scan(/to respond/).count).to eq(2)
# 1 unsubscribe # 1 unsubscribe
expect(mail.html_part.to_s.scan(/To unsubscribe/).count).to eq(1) expect(mail_html.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, response.user) tu = TopicUser.get(post.topic_id, response.user)
expect(tu.last_emailed_post_number).to eq(response.post_number) expect(tu.last_emailed_post_number).to eq(response.post_number)
# no In Reply To if user opts out
response.user.user_option.email_in_reply_to = false
mail = UserNotifications.user_replied(response.user,
post: response,
notification_type: notification.notification_type,
notification_data_hash: notification.data_hash
)
expect(mail.html_part.to_s.scan(/In Reply To/).count).to eq(0)
end end
end end
@ -169,8 +194,8 @@ describe UserNotifications do
# subject should not include category name # subject should not include category name
expect(mail.subject).not_to match(/Uncategorized/) expect(mail.subject).not_to match(/Uncategorized/)
# 2 respond to links cause we have 1 context post # 1 respond to links as no context by default
expect(mail.html_part.to_s.scan(/to respond/).count).to eq(2) expect(mail.html_part.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.to_s.scan(/To unsubscribe/).count).to eq(1)

View file

@ -48,7 +48,8 @@ describe UserUpdater do
mailing_list_mode: true, mailing_list_mode: true,
digest_after_days: "8", digest_after_days: "8",
new_topic_duration_minutes: 100, new_topic_duration_minutes: 100,
auto_track_topics_after_msecs: 101 auto_track_topics_after_msecs: 101,
email_in_reply_to: false
) )
user.reload user.reload
@ -58,6 +59,7 @@ describe UserUpdater do
expect(user.user_option.digest_after_days).to eq 8 expect(user.user_option.digest_after_days).to eq 8
expect(user.user_option.new_topic_duration_minutes).to eq 100 expect(user.user_option.new_topic_duration_minutes).to eq 100
expect(user.user_option.auto_track_topics_after_msecs).to eq 101 expect(user.user_option.auto_track_topics_after_msecs).to eq 101
expect(user.user_option.email_in_reply_to).to eq false
end end
context 'when update succeeds' do context 'when update succeeds' do