Commit 6f460d5e authored by Fabio Pitino's avatar Fabio Pitino

Merge branch 'nicolasdular/new-invite-mail' into 'master'

Add invite email experiment

See merge request gitlab-org/gitlab!39628
parents fd8bdf62 e548cbb7
...@@ -117,6 +117,7 @@ linters: ...@@ -117,6 +117,7 @@ linters:
- "app/views/import/bitbucket_server/status.html.haml" - "app/views/import/bitbucket_server/status.html.haml"
- "app/views/invites/show.html.haml" - "app/views/invites/show.html.haml"
- "app/views/layouts/_mailer.html.haml" - "app/views/layouts/_mailer.html.haml"
- "app/views/layouts/experiment_mailer.html.haml"
- "app/views/layouts/header/_default.html.haml" - "app/views/layouts/header/_default.html.haml"
- "app/views/layouts/header/_new_dropdown.haml" - "app/views/layouts/header/_new_dropdown.haml"
- "app/views/layouts/notify.html.haml" - "app/views/layouts/notify.html.haml"
......
...@@ -48,6 +48,22 @@ a { ...@@ -48,6 +48,22 @@ a {
font-weight: 500; font-weight: 500;
} }
.invite-header {
margin-top: 0;
}
.invite-actions {
margin-top: 24px;
}
.invite-btn-join {
border-radius: $border-radius-default;
padding: $gl-btn-vert-padding $gl-btn-horz-padding;
cursor: pointer;
background-color: $purple;
color: $white;
}
tr td { tr td {
font-family: $mailer-font; font-family: $mailer-font;
} }
......
...@@ -12,11 +12,13 @@ class InvitesController < ApplicationController ...@@ -12,11 +12,13 @@ class InvitesController < ApplicationController
respond_to :html respond_to :html
def show def show
track_experiment('opened')
accept if skip_invitation_prompt? accept if skip_invitation_prompt?
end end
def accept def accept
if member.accept_invite!(current_user) if member.accept_invite!(current_user)
track_experiment('accepted')
redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") % redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") %
{ member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] } { member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] }
else else
...@@ -96,4 +98,17 @@ class InvitesController < ApplicationController ...@@ -96,4 +98,17 @@ class InvitesController < ApplicationController
} }
end end
end end
def track_experiment(action)
return unless params[:new_user_invite]
property = params[:new_user_invite] == 'experiment' ? 'experiment_group' : 'control_group'
Gitlab::Tracking.event(
Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category],
action,
property: property,
value: Digest::MD5.hexdigest(member.to_global_id.to_s)
)
end
end end
...@@ -51,9 +51,32 @@ module Emails ...@@ -51,9 +51,32 @@ module Emails
return unless member_exists? return unless member_exists?
member_email_with_layout( subject_line = subject("Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")
to: member.invite_email,
subject: subject("Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")) if member.invite_to_unknown_user? && Feature.enabled?(:invite_email_experiment)
subject_line = subject("#{member.created_by.name} invited you to join GitLab") if member.created_by
@invite_url_params = { new_user_invite: 'experiment' }
member_email_with_layout(
to: member.invite_email,
subject: subject_line,
template: 'member_invited_email_experiment',
layout: 'experiment_mailer'
)
Gitlab::Tracking.event(Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], 'sent', property: 'experiment_group')
else
@invite_url_params = member.invite_to_unknown_user? ? { new_user_invite: 'control' } : {}
member_email_with_layout(
to: member.invite_email,
subject: subject_line
)
if member.invite_to_unknown_user?
Gitlab::Tracking.event(Gitlab::Experimentation::EXPERIMENTS[:invite_email][:tracking_category], 'sent', property: 'control_group')
end
end
end end
def member_invite_accepted_email(member_source_type, member_id) def member_invite_accepted_email(member_source_type, member_id)
...@@ -107,10 +130,15 @@ module Emails ...@@ -107,10 +130,15 @@ module Emails
@member_source_type.classify.constantize @member_source_type.classify.constantize
end end
def member_email_with_layout(to:, subject:) def member_email_with_layout(to:, subject:, template: nil, layout: 'mailer')
mail(to: to, subject: subject) do |format| mail(to: to, subject: subject) do |format|
format.html { render layout: 'mailer' } if template
format.text { render layout: 'mailer' } format.html { render template, layout: layout }
format.text { render template, layout: layout }
else
format.html { render layout: layout }
format.text { render layout: layout }
end
end end
end end
end end
......
...@@ -395,6 +395,10 @@ class Member < ApplicationRecord ...@@ -395,6 +395,10 @@ class Member < ApplicationRecord
end end
end end
def invite_to_unknown_user?
invite? && user_id.nil?
end
private private
def send_invite def send_invite
......
...@@ -25,5 +25,5 @@ ...@@ -25,5 +25,5 @@
- if !member? - if !member?
.actions .actions
= link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn btn-success" = link_to _("Accept invitation"), accept_invite_url(@token, new_user_invite: params[:new_user_invite]), method: :post, class: "btn btn-success"
= link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger gl-ml-3" = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger gl-ml-3"
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
%html{ lang: "en" }
%head
%meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/
%meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/
%meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }/
%title= message.subject
-# Avoid premailer processing of client-specific styles (@media tag not supported)
-# We need to inline the contents here because mail clients (e.g. iOS Mail, Outlook)
-# do not support linked stylesheets.
%style{ type: 'text/css', 'data-premailer': 'ignore' }
= asset_to_string('mailer_client_specific.css').html_safe
= stylesheet_link_tag 'mailer.css'
%body
%table#body{ border: "0", cellpadding: "0", cellspacing: "0" }
%tbody
%tr.line
%td
%tr.header
%td
= html_header_message
= header_logo
%tr
%td
%table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0" }
%tbody
%tr
%td.wrapper-cell{ style: "padding: 0" }
%table.content{ border: "0", cellpadding: "0", cellspacing: "0" }
%tbody
= yield
= render_if_exists 'layouts/mailer/additional_text'
%tr.footer
%td{ style: "padding: 24px 0" }
%img{ alt: "GitLab", height: "33", width: "90", src: image_url('mailers/gitlab_footer_logo.gif') }
%p{ style: "color: #949ba5; max-width: 640px; margin: 0 auto; text-align: left; font-size: 12px;" }
GitLab is a complete DevOps platform, delivered as a single application, fundamentally changing the way
%br
Development, Security, and Ops teams collaborate.
= yield :additional_footer
%tr
%td.footer-message
= html_footer_message
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
#{member_source.model_name.singular} as #{content_tag :span, member.human_access, class: :highlight}. #{member_source.model_name.singular} as #{content_tag :span, member.human_access, class: :highlight}.
%p %p
= link_to 'Accept invitation', invite_url(@token) = link_to 'Accept invitation', invite_url(@token, @invite_url_params)
or or
= link_to 'decline', decline_invite_url(@token) = link_to 'decline', decline_invite_url(@token)
You have been invited <%= "by #{sanitize_name(member.created_by.name)} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>. You have been invited <%= "by #{sanitize_name(member.created_by.name)} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>.
Accept invitation: <%= invite_url(@token) %> Accept invitation: <%= invite_url(@token, @invite_url_params) %>
Decline invitation: <%= decline_invite_url(@token) %> Decline invitation: <%= decline_invite_url(@token) %>
%tr
%td.text-content
%h2.invite-header
= s_('InviteEmail|You are invited!')
%p
- if member.created_by
= html_escape(s_("InviteEmail|%{inviter} invited you")) % { inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe }
= html_escape(s_("InviteEmail|to join the %{strong_start}%{project_or_group_name}%{strong_end}")) % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_or_group_name: member_source.human_name }
%br
= s_("InviteEmail|%{project_or_group} as a %{role}") % { project_or_group: member_source.model_name.singular, role: member.human_access.downcase }
%p.invite-actions
= link_to s_('InviteEmail|Join now'), invite_url(@token, @invite_url_params), class: 'invite-btn-join'
<% project_and_role = s_('InviteEmail|to join the %{project_or_group_name} %{project_or_group} as a %{role}') \
% { project_or_group_name: member_source.human_name, project_or_group: member_source.model_name.singular, role: member.human_access.downcase } %>
<% if member.created_by %>
<%= s_('InviteEmail|%{inviter} invited you') % { inviter: sanitize_name(member.created_by.name) } %> <%= project_and_role %>
<% else %>
<%= s_('InviteEmail|You have been invited') %> <%= project_and_role %>
<% end %>
Join now: <%= invite_url(@token, @invite_url_params) %>
...@@ -62,6 +62,9 @@ module Gitlab ...@@ -62,6 +62,9 @@ module Gitlab
}, },
customize_homepage: { customize_homepage: {
tracking_category: 'Growth::Expansion::Experiment::CustomizeHomepage' tracking_category: 'Growth::Expansion::Experiment::CustomizeHomepage'
},
invite_email: {
tracking_category: 'Growth::Acquisition::Experiment::InviteEmail'
} }
}.freeze }.freeze
......
...@@ -13417,6 +13417,27 @@ msgstr "" ...@@ -13417,6 +13417,27 @@ msgstr ""
msgid "Invite teammates (optional)" msgid "Invite teammates (optional)"
msgstr "" msgstr ""
msgid "InviteEmail|%{inviter} invited you"
msgstr ""
msgid "InviteEmail|%{project_or_group} as a %{role}"
msgstr ""
msgid "InviteEmail|Join now"
msgstr ""
msgid "InviteEmail|You are invited!"
msgstr ""
msgid "InviteEmail|You have been invited"
msgstr ""
msgid "InviteEmail|to join the %{project_or_group_name} %{project_or_group} as a %{role}"
msgstr ""
msgid "InviteEmail|to join the %{strong_start}%{project_or_group_name}%{strong_end}"
msgstr ""
msgid "Invited" msgid "Invited"
msgstr "" msgstr ""
......
...@@ -7,6 +7,7 @@ RSpec.describe InvitesController do ...@@ -7,6 +7,7 @@ RSpec.describe InvitesController do
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:member) { create(:project_member, :invited, invite_token: token, invite_email: user.email) } let(:member) { create(:project_member, :invited, invite_token: token, invite_email: user.email) }
let(:project_members) { member.source.users } let(:project_members) { member.source.users }
let(:md5_member_global_id) { Digest::MD5.hexdigest(member.to_global_id.to_s) }
before do before do
controller.instance_variable_set(:@member, member) controller.instance_variable_set(:@member, member)
...@@ -14,9 +15,13 @@ RSpec.describe InvitesController do ...@@ -14,9 +15,13 @@ RSpec.describe InvitesController do
end end
describe 'GET #show' do describe 'GET #show' do
let(:params) { { id: token } }
subject(:request) { get :show, params: params }
it 'accepts user if invite email matches signed in user' do it 'accepts user if invite email matches signed in user' do
expect do expect do
get :show, params: { id: token } request
end.to change { project_members.include?(user) }.from(false).to(true) end.to change { project_members.include?(user) }.from(false).to(true)
expect(response).to have_gitlab_http_status(:found) expect(response).to have_gitlab_http_status(:found)
...@@ -27,11 +32,105 @@ RSpec.describe InvitesController do ...@@ -27,11 +32,105 @@ RSpec.describe InvitesController do
member.invite_email = 'bogus@email.com' member.invite_email = 'bogus@email.com'
expect do expect do
get :show, params: { id: token } request
end.not_to change { project_members.include?(user) } end.not_to change { project_members.include?(user) }
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(flash[:notice]).to be_nil expect(flash[:notice]).to be_nil
end end
context 'when new_user_invite is not set' do
it 'does not track the user as experiment group' do
expect(Gitlab::Tracking).not_to receive(:event)
request
end
end
context 'when new_user_invite is experiment' do
let(:params) { { id: token, new_user_invite: 'experiment' } }
it 'tracks the user as experiment group' do
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'opened',
property: 'experiment_group',
value: md5_member_global_id
)
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'accepted',
property: 'experiment_group',
value: md5_member_global_id
)
request
end
end
context 'when new_user_invite is control' do
let(:params) { { id: token, new_user_invite: 'control' } }
it 'tracks the user as control group' do
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'opened',
property: 'control_group',
value: md5_member_global_id
)
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'accepted',
property: 'control_group',
value: md5_member_global_id
)
request
end
end
end
describe 'POST #accept' do
let(:params) { { id: token } }
subject(:request) { post :accept, params: params }
context 'when new_user_invite is not set' do
it 'does not track an event' do
expect(Gitlab::Tracking).not_to receive(:event)
request
end
end
context 'when new_user_invite is experiment' do
let(:params) { { id: token, new_user_invite: 'experiment' } }
it 'tracks the user as experiment group' do
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'accepted',
property: 'experiment_group',
value: md5_member_global_id
)
request
end
end
context 'when new_user_invite is control' do
let(:params) { { id: token, new_user_invite: 'control' } }
it 'tracks the user as control group' do
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'accepted',
property: 'control_group',
value: md5_member_global_id
)
request
end
end
end end
end end
...@@ -868,36 +868,116 @@ RSpec.describe Notify do ...@@ -868,36 +868,116 @@ RSpec.describe Notify do
end end
end end
def invite_to_project(project, inviter:) def invite_to_project(project, inviter:, user: nil)
create( create(
:project_member, :project_member,
:developer, :developer,
project: project, project: project,
invite_token: '1234', invite_token: '1234',
invite_email: 'toto@example.com', invite_email: 'toto@example.com',
user: nil, user: user,
created_by: inviter created_by: inviter
) )
end end
describe 'project invitation' do describe 'project invitation' do
let(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } } let(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } }
let(:project_member) { invite_to_project(project, inviter: maintainer) } let(:project_member) { invite_to_project(project, inviter: inviter) }
let(:inviter) { maintainer }
subject { described_class.member_invited_email('project', project_member.id, project_member.invite_token) } subject { described_class.member_invited_email('project', project_member.id, project_member.invite_token) }
it_behaves_like 'an email sent from GitLab' context 'when invite_email_experiment is disabled' do
it_behaves_like 'it should not have Gmail Actions links' before do
it_behaves_like "a user cannot unsubscribe through footer link" stub_feature_flags(invite_email_experiment: false)
it_behaves_like 'appearance header and footer enabled' end
it_behaves_like 'appearance header and footer not enabled'
it 'contains all the useful information' do it_behaves_like 'an email sent from GitLab'
is_expected.to have_subject "Invitation to join the #{project.full_name} project" it_behaves_like 'it should not have Gmail Actions links'
is_expected.to have_body_text project.full_name it_behaves_like "a user cannot unsubscribe through footer link"
is_expected.to have_body_text project.full_name it_behaves_like 'appearance header and footer enabled'
is_expected.to have_body_text project_member.human_access it_behaves_like 'appearance header and footer not enabled'
is_expected.to have_body_text project_member.invite_token
it 'contains all the useful information' do
is_expected.to have_subject "Invitation to join the #{project.full_name} project"
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access
is_expected.to have_body_text project_member.invite_token
end
context 'when member is invited via an email address' do
it 'does add a param to the invite link' do
is_expected.to have_body_text 'new_user_invite=control'
end
it 'tracks an event' do
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'sent',
property: 'control_group'
)
subject.deliver_now
end
end
context 'when member is already a user' do
let(:project_member) { invite_to_project(project, inviter: maintainer, user: create(:user)) }
it 'does not add a param to the invite link' do
is_expected.not_to have_body_text 'new_user_invite'
end
it 'does not track an event' do
expect(Gitlab::Tracking).not_to receive(:event)
subject.deliver_now
end
end
end
context 'when invite_email_experiment is enabled' do
before do
stub_feature_flags(invite_email_experiment: true)
end
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like "a user cannot unsubscribe through footer link"
context 'when there is no inviter' do
let(:inviter) { nil }
it 'contains all the useful information' do
is_expected.to have_subject "Invitation to join the #{project.full_name} project"
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access.downcase
is_expected.to have_body_text project_member.invite_token
end
end
context 'when there is an inviter' do
it 'contains all the useful information' do
is_expected.to have_subject "#{inviter.name} invited you to join GitLab"
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access.downcase
is_expected.to have_body_text project_member.invite_token
end
end
it 'adds a param to the invite link' do
is_expected.to have_body_text 'new_user_invite=experiment'
end
it 'tracks an event' do
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'sent',
property: 'experiment_group'
)
subject.deliver_now
end
end end
end end
...@@ -1416,37 +1496,115 @@ RSpec.describe Notify do ...@@ -1416,37 +1496,115 @@ RSpec.describe Notify do
end end
end end
def invite_to_group(group, inviter:) def invite_to_group(group, inviter:, user: nil)
create( create(
:group_member, :group_member,
:developer, :developer,
group: group, group: group,
invite_token: '1234', invite_token: '1234',
invite_email: 'toto@example.com', invite_email: 'toto@example.com',
user: nil, user: user,
created_by: inviter created_by: inviter
) )
end end
describe 'group invitation' do describe 'group invitation' do
let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } }
let(:group_member) { invite_to_group(group, inviter: owner) } let(:group_member) { invite_to_group(group, inviter: inviter) }
let(:inviter) { owner }
subject { described_class.member_invited_email('group', group_member.id, group_member.invite_token) } subject { described_class.member_invited_email('group', group_member.id, group_member.invite_token) }
it_behaves_like 'an email sent from GitLab' context 'when invite_email_experiment is disabled' do
it_behaves_like 'it should not have Gmail Actions links' before do
it_behaves_like "a user cannot unsubscribe through footer link" stub_feature_flags(invite_email_experiment: false)
it_behaves_like 'appearance header and footer enabled' end
it_behaves_like 'appearance header and footer not enabled'
it_behaves_like 'it requires a group'
it 'contains all the useful information' do it_behaves_like 'an email sent from GitLab'
is_expected.to have_subject "Invitation to join the #{group.name} group" it_behaves_like 'it should not have Gmail Actions links'
is_expected.to have_body_text group.name it_behaves_like "a user cannot unsubscribe through footer link"
is_expected.to have_body_text group.web_url it_behaves_like 'appearance header and footer enabled'
is_expected.to have_body_text group_member.human_access it_behaves_like 'appearance header and footer not enabled'
is_expected.to have_body_text group_member.invite_token it_behaves_like 'it requires a group'
it 'contains all the useful information' do
is_expected.to have_subject "Invitation to join the #{group.name} group"
is_expected.to have_body_text group.name
is_expected.to have_body_text group.web_url
is_expected.to have_body_text group_member.human_access
is_expected.to have_body_text group_member.invite_token
end
context 'when member is invited via an email address' do
it 'does add a param to the invite link' do
is_expected.to have_body_text 'new_user_invite=control'
end
it 'tracks an event' do
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'sent',
property: 'control_group'
)
subject.deliver_now
end
end
context 'when member is already a user' do
let(:group_member) { invite_to_group(group, inviter: owner, user: create(:user)) }
it 'does not add a param to the invite link' do
is_expected.not_to have_body_text 'new_user_invite'
end
it 'does not track an event' do
expect(Gitlab::Tracking).not_to receive(:event)
subject.deliver_now
end
end
end
context 'when invite_email_experiment is enabled' do
it_behaves_like 'an email sent from GitLab'
it_behaves_like 'it should not have Gmail Actions links'
it_behaves_like "a user cannot unsubscribe through footer link"
it_behaves_like 'it requires a group'
context 'when there is no inviter' do
let(:inviter) { nil }
it 'contains all the useful information' do
is_expected.to have_subject "Invitation to join the #{group.name} group"
is_expected.to have_body_text group.name
is_expected.to have_body_text group_member.human_access.downcase
is_expected.to have_body_text group_member.invite_token
end
end
context 'when there is an inviter' do
it 'contains all the useful information' do
is_expected.to have_subject "#{group_member.created_by.name} invited you to join GitLab"
is_expected.to have_body_text group.name
is_expected.to have_body_text group_member.human_access.downcase
is_expected.to have_body_text group_member.invite_token
end
end
it 'does add a param to the invite link' do
is_expected.to have_body_text 'new_user_invite'
end
it 'tracks an event' do
expect(Gitlab::Tracking).to receive(:event).with(
'Growth::Acquisition::Experiment::InviteEmail',
'sent',
property: 'experiment_group'
)
subject.deliver_now
end
end end
end end
......
...@@ -617,6 +617,24 @@ RSpec.describe Member do ...@@ -617,6 +617,24 @@ RSpec.describe Member do
end end
end end
describe "#invite_to_unknown_user?" do
subject { member.invite_to_unknown_user? }
let(:member) { create(:project_member, invite_email: "user@example.com", invite_token: '1234', user: user) }
context 'when user is nil' do
let(:user) { nil }
it { is_expected.to eq(true) }
end
context 'when user is set' do
let(:user) { build(:user) }
it { is_expected.to eq(false) }
end
end
describe "destroying a record", :delete do describe "destroying a record", :delete do
it "refreshes user's authorized projects" do it "refreshes user's authorized projects" do
project = create(:project, :private) project = create(:project, :private)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment