Merge pull request #4252 from techAPJ/invite-email-improvements

FEATURE: customize invite email message
This commit is contained in:
Jeff Atwood 2016-06-06 14:24:39 -07:00
commit 5c3e36aec2
10 changed files with 153 additions and 32 deletions

View File

@ -6,6 +6,8 @@ export default Ember.Controller.extend(ModalFunctionality, {
// If this isn't defined, it will proxy to the user model on the preferences // If this isn't defined, it will proxy to the user model on the preferences
// page which is wrong. // page which is wrong.
emailOrUsername: null, emailOrUsername: null,
hasCustomMessage: false,
customMessage: null,
inviteIcon: "envelope", inviteIcon: "envelope",
isAdmin: function(){ isAdmin: function(){
@ -27,6 +29,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
}.property('isAdmin', 'emailOrUsername', 'invitingToTopic', 'isPrivateTopic', 'model.groupNames', 'model.saving'), }.property('isAdmin', 'emailOrUsername', 'invitingToTopic', 'isPrivateTopic', 'model.groupNames', 'model.saving'),
disabledCopyLink: function() { disabledCopyLink: function() {
if (this.get('hasCustomMessage')) return true;
if (this.get('model.saving')) return true; if (this.get('model.saving')) return true;
if (Ember.isEmpty(this.get('emailOrUsername'))) return true; if (Ember.isEmpty(this.get('emailOrUsername'))) return true;
const emailOrUsername = this.get('emailOrUsername').trim(); const emailOrUsername = this.get('emailOrUsername').trim();
@ -37,7 +40,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
// when inviting to private topic via email, group name must be specified // when inviting to private topic via email, group name must be specified
if (this.get('isPrivateTopic') && Ember.isEmpty(this.get('model.groupNames')) && Discourse.Utilities.emailValid(emailOrUsername)) return true; if (this.get('isPrivateTopic') && Ember.isEmpty(this.get('model.groupNames')) && Discourse.Utilities.emailValid(emailOrUsername)) return true;
return false; return false;
}.property('emailOrUsername', 'model.saving', 'isPrivateTopic', 'model.groupNames'), }.property('emailOrUsername', 'model.saving', 'isPrivateTopic', 'model.groupNames', 'hasCustomMessage'),
buttonTitle: function() { buttonTitle: function() {
return this.get('model.saving') ? 'topic.inviting' : 'topic.invite_reply.action'; return this.get('model.saving') ? 'topic.inviting' : 'topic.invite_reply.action';
@ -71,6 +74,11 @@ export default Ember.Controller.extend(ModalFunctionality, {
return this.get('isAdmin') && (Discourse.Utilities.emailValid(this.get('emailOrUsername')) || this.get('isPrivateTopic') || !this.get('invitingToTopic')) && !Discourse.SiteSettings.enable_sso && Discourse.SiteSettings.enable_local_logins && !this.get('isMessage'); return this.get('isAdmin') && (Discourse.Utilities.emailValid(this.get('emailOrUsername')) || this.get('isPrivateTopic') || !this.get('invitingToTopic')) && !Discourse.SiteSettings.enable_sso && Discourse.SiteSettings.enable_local_logins && !this.get('isMessage');
}.property('isAdmin', 'emailOrUsername', 'isPrivateTopic', 'isMessage', 'invitingToTopic'), }.property('isAdmin', 'emailOrUsername', 'isPrivateTopic', 'isMessage', 'invitingToTopic'),
// Show Custom Message textarea? (only shown when inviting new user to forum)
showCustomMessage: function() {
return this.get('model') === this.currentUser;
}.property('model'),
// Instructional text for the modal. // Instructional text for the modal.
inviteInstructions: function() { inviteInstructions: function() {
if (Discourse.SiteSettings.enable_sso || !Discourse.SiteSettings.enable_local_logins) { if (Discourse.SiteSettings.enable_sso || !Discourse.SiteSettings.enable_local_logins) {
@ -136,9 +144,15 @@ export default Ember.Controller.extend(ModalFunctionality, {
'topic.invite_private.email_or_username_placeholder'; 'topic.invite_private.email_or_username_placeholder';
}.property(), }.property(),
customMessagePlaceholder: function() {
return I18n.t('invite.custom_message_placeholder');
}.property(),
// Reset the modal to allow a new user to be invited. // Reset the modal to allow a new user to be invited.
reset() { reset() {
this.set('emailOrUsername', null); this.set('emailOrUsername', null);
this.set('hasCustomMessage', false);
this.set('customMessage', null);
this.get('model').setProperties({ this.get('model').setProperties({
groupNames: null, groupNames: null,
error: false, error: false,
@ -147,7 +161,6 @@ export default Ember.Controller.extend(ModalFunctionality, {
inviteLink: null inviteLink: null
}); });
}, },
actions: { actions: {
createInvite() { createInvite() {
@ -162,7 +175,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
model.setProperties({ saving: true, error: false }); model.setProperties({ saving: true, error: false });
return this.get('model').createInvite(this.get('emailOrUsername').trim(), groupNames).then(result => { return this.get('model').createInvite(this.get('emailOrUsername').trim(), groupNames, this.get('customMessage')).then(result => {
model.setProperties({ saving: false, finished: true }); model.setProperties({ saving: false, finished: true });
if (!this.get('invitingToTopic')) { if (!this.get('invitingToTopic')) {
Invite.findInvitedBy(this.currentUser, userInvitedController.get('filter')).then(invite_model => { Invite.findInvitedBy(this.currentUser, userInvitedController.get('filter')).then(invite_model => {
@ -213,6 +226,15 @@ export default Ember.Controller.extend(ModalFunctionality, {
} }
model.setProperties({ saving: false, error: true }); model.setProperties({ saving: false, error: true });
}); });
},
showCustomMessageBox() {
this.toggleProperty('hasCustomMessage');
if (this.get('hasCustomMessage')) {
this.set('customMessage', I18n.t('invite.custom_message_template'));
} else {
this.set('customMessage', null);
}
} }
} }

View File

@ -321,10 +321,10 @@ const User = RestModel.extend({
Discourse.SiteSettings['newuser_max_' + type + 's'] > 0; Discourse.SiteSettings['newuser_max_' + type + 's'] > 0;
}, },
createInvite(email, group_names) { createInvite(email, group_names, custom_message) {
return Discourse.ajax('/invites', { return Discourse.ajax('/invites', {
type: 'POST', type: 'POST',
data: { email, group_names } data: { email, group_names, custom_message }
}); });
}, },

View File

@ -22,6 +22,12 @@
<label>{{{groupInstructions}}}</label> <label>{{{groupInstructions}}}</label>
{{group-selector groupFinder=groupFinder groupNames=model.groupNames placeholderKey="topic.invite_private.group_name"}} {{group-selector groupFinder=groupFinder groupNames=model.groupNames placeholderKey="topic.invite_private.group_name"}}
{{/if}} {{/if}}
{{#if showCustomMessage}}
<br><label><a {{action "showCustomMessageBox"}}>{{i18n 'invite.custom_message'}}</a></label>
{{#if hasCustomMessage}}{{textarea value=customMessage placeholder=customMessagePlaceholder}}{{/if}}
{{/if}}
{{/if}} {{/if}}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -43,7 +43,7 @@ class InvitesController < ApplicationController
end end
begin begin
if Invite.invite_by_email(params[:email], current_user, _topic=nil, group_ids) if Invite.invite_by_email(params[:email], current_user, _topic=nil, group_ids, params[:custom_message])
render json: success_json render json: success_json
else else
render json: failed_json, status: 422 render json: failed_json, status: 422

View File

@ -9,7 +9,7 @@ module Jobs
raise Discourse::InvalidParameters.new(:invite_id) unless args[:invite_id].present? raise Discourse::InvalidParameters.new(:invite_id) unless args[:invite_id].present?
invite = Invite.find_by(id: args[:invite_id]) invite = Invite.find_by(id: args[:invite_id])
message = InviteMailer.send_invite(invite) message = InviteMailer.send_invite(invite, args[:custom_message])
Email::Sender.new(message, :invite).send Email::Sender.new(message, :invite).send
end end

View File

@ -3,7 +3,11 @@ require_dependency 'email/message_builder'
class InviteMailer < ActionMailer::Base class InviteMailer < ActionMailer::Base
include Email::BuildEmailHelper include Email::BuildEmailHelper
def send_invite(invite) class UserNotificationRenderer < ActionView::Base
include UserNotificationsHelper
end
def send_invite(invite, custom_message=nil)
# Find the first topic they were invited to # Find the first topic they were invited to
first_topic = invite.topics.order(:created_at).first first_topic = invite.topics.order(:created_at).first
@ -31,15 +35,29 @@ class InviteMailer < ActionMailer::Base
site_description: SiteSetting.site_description, site_description: SiteSetting.site_description,
site_title: SiteSetting.title) site_title: SiteSetting.title)
else else
html = nil
if custom_message.present? && custom_message =~ /{invite_link}/
custom_message.gsub!("{invite_link}", "#{Discourse.base_url}/invites/#{invite.invite_key}")
custom_message.gsub!("{site_title}", SiteSetting.title) if custom_message =~ /{site_title}/
custom_message.gsub!("{site_description}", SiteSetting.site_description) if custom_message =~ /{site_description}/
html = UserNotificationRenderer.new(Rails.configuration.paths["app/views"]).render(
template: 'email/invite',
format: :html,
locals: { message: PrettyText.cook(custom_message).html_safe,
classes: 'custom-invite-email' }
)
end
build_email(invite.email, build_email(invite.email,
template: 'invite_forum_mailer', template: 'invite_forum_mailer',
html_override: html,
invitee_name: invitee_name, invitee_name: invitee_name,
site_domain_name: Discourse.current_hostname, site_domain_name: Discourse.current_hostname,
invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}", invite_link: "#{Discourse.base_url}/invites/#{invite.invite_key}",
site_description: SiteSetting.site_description, site_description: SiteSetting.site_description,
site_title: SiteSetting.title) site_title: SiteSetting.title)
end end
end end
def send_password_instructions(user) def send_password_instructions(user)

View File

@ -72,8 +72,8 @@ class Invite < ActiveRecord::Base
end end
end end
def self.invite_by_email(email, invited_by, topic=nil, group_ids=nil) def self.invite_by_email(email, invited_by, topic=nil, group_ids=nil, custom_message=nil)
create_invite_by_email(email, invited_by, topic, group_ids, true) create_invite_by_email(email, invited_by, topic, group_ids, true, custom_message)
end end
# generate invite link # generate invite link
@ -85,7 +85,7 @@ class Invite < ActiveRecord::Base
# Create an invite for a user, supplying an optional topic # Create an invite for a user, supplying an optional topic
# #
# Return the previously existing invite if already exists. Returns nil if the invite can't be created. # Return the previously existing invite if already exists. Returns nil if the invite can't be created.
def self.create_invite_by_email(email, invited_by, topic=nil, group_ids=nil, send_email=true) def self.create_invite_by_email(email, invited_by, topic=nil, group_ids=nil, send_email=true, custom_message=nil)
lower_email = Email.downcase(email) lower_email = Email.downcase(email)
user = User.find_by(email: lower_email) user = User.find_by(email: lower_email)
@ -126,7 +126,7 @@ class Invite < ActiveRecord::Base
end end
end end
Jobs.enqueue(:invite_email, invite_id: invite.id) if send_email Jobs.enqueue(:invite_email, invite_id: invite.id, custom_message: custom_message) if send_email
invite.reload invite.reload
invite invite

View File

@ -3039,3 +3039,21 @@ en:
top: "There are no more top topics." top: "There are no more top topics."
bookmarks: "There are no more bookmarked topics." bookmarks: "There are no more bookmarked topics."
search: "There are no more search results." search: "There are no more search results."
invite:
custom_message: "Make your invite a little bit more personal by writing a custom message (optional)."
custom_message_placeholder: "Enter your custom message, use {invite_link} for specifying invite link."
custom_message_template: |
Hello,
You've been invited you to join
> **{site_title}**
>
> {site_description}
If you're interested, click the link below:
{invite_link}
This invitation is from a trusted user, so you won't need to log in.

View File

@ -16,7 +16,7 @@ describe Jobs::InviteEmail do
it 'delegates to the test mailer' do it 'delegates to the test mailer' do
Email::Sender.any_instance.expects(:send) Email::Sender.any_instance.expects(:send)
InviteMailer.expects(:send_invite).with(invite).returns(mailer) InviteMailer.expects(:send_invite).with(invite, nil).returns(mailer)
Jobs::InviteEmail.new.execute(invite_id: invite.id) Jobs::InviteEmail.new.execute(invite_id: invite.id)
end end
@ -26,4 +26,3 @@ describe Jobs::InviteEmail do
end end

View File

@ -6,6 +6,8 @@ describe InviteMailer do
context "invite to site" do context "invite to site" do
let(:invite) { Fabricate(:invite) } let(:invite) { Fabricate(:invite) }
context "default invite message" do
let(:invite_mail) { InviteMailer.send_invite(invite) } let(:invite_mail) { InviteMailer.send_invite(invite) }
it 'renders the invitee email' do it 'renders the invitee email' do
@ -33,6 +35,62 @@ describe InviteMailer do
end end
end end
context "custom invite message" do
context "custom message includes invite link" do
let(:custom_invite_mail) { InviteMailer.send_invite(invite, "Hello,\n\nYou've been invited you to join\n\n<a href=\"javascript:alert('HACK!')\">Click me.</a>\n\n> **{site_title}**\n>\n> {site_description}\n\nIf you're interested, click the link below:\n\n{invite_link}\n\nThis invitation is from a trusted user, so you won't need to log in.") }
it 'renders the invitee email' do
expect(custom_invite_mail.to).to eql([invite.email])
end
it 'renders the subject' do
expect(custom_invite_mail.subject).to be_present
end
it 'renders site domain name in subject' do
expect(custom_invite_mail.subject).to match(Discourse.current_hostname)
end
it 'renders the html' do
expect(custom_invite_mail.html_part).to be_present
end
it 'renders custom_message' do
expect(custom_invite_mail.html_part.to_s).to match("You've been invited you to join")
end
it 'renders the inviter email' do
expect(custom_invite_mail.from).to eql([SiteSetting.notification_email])
end
it 'sanitizes HTML' do
expect(custom_invite_mail.html_part.to_s).to_not match("HACK!")
end
it 'renders invite link' do
expect(custom_invite_mail.html_part.to_s).to match("#{Discourse.base_url}/invites/#{invite.invite_key}")
end
end
context "custom message does not include invite link" do
let(:custom_invite_without_link) { InviteMailer.send_invite(invite, "Hello,\n\nYou've been invited you to join\n\n> **{site_title}**\n>\n> {site_description}") }
it 'renders default body' do
expect(custom_invite_without_link.body).to be_present
end
it 'does not render html' do
expect(custom_invite_without_link.html_part).to eq(nil)
end
it 'renders invite link' do
expect(custom_invite_without_link.body.encoded).to match("#{Discourse.base_url}/invites/#{invite.invite_key}")
end
end
end
end
context "invite to topic" do context "invite to topic" do
let(:topic) { Fabricate(:topic, excerpt: "Topic invite support is now available in Discourse!") } let(:topic) { Fabricate(:topic, excerpt: "Topic invite support is now available in Discourse!") }
let(:invite) { topic.invite(topic.user, 'name@example.com') } let(:invite) { topic.invite(topic.user, 'name@example.com') }