Send notifications to group-specific email address

- Select notification email by walking up group/subgroup path
- Add settings UI to set group email notification address
- Add tests
This commit is contained in:
Wei-Meng Lee 2019-02-16 01:56:13 +08:00
parent 6d73ce696c
commit 1a402d888c
20 changed files with 182 additions and 44 deletions

View file

@ -39,6 +39,7 @@ export default class Profile {
bindEvents() {
$('.js-preferences-form').on('change.preference', 'input[type=radio]', this.submitForm);
$('.js-group-notification-email').on('change', this.submitForm);
$('#user_notification_email').on('change', this.submitForm);
$('#user_notified_of_own_activity').on('change', this.submitForm);
this.form.on('submit', this.onSubmitForm);

View file

@ -4,6 +4,34 @@
.dropdown-menu {
@extend .dropdown-menu-right;
}
@include media-breakpoint-down(sm) {
.notification-dropdown {
width: 100%;
}
.notification-form {
display: block;
}
.notifications-btn,
.btn-group {
width: 100%;
}
.table-section {
border-top: 0;
min-height: unset;
&:not(:first-child) {
padding-top: 0;
}
}
.update-notifications {
width: 100%;
}
}
}
.notification {

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Profiles::GroupsController < Profiles::ApplicationController
include RoutableActions
def update
group = find_routable!(Group, params[:id])
notification_setting = current_user.notification_settings.find_by(source: group) # rubocop: disable CodeReuse/ActiveRecord
if notification_setting.update(update_params)
flash[:notice] = "Notification settings for #{group.name} saved"
else
flash[:alert] = "Failed to save new settings for #{group.name}"
end
redirect_back_or_default(default: profile_notifications_path)
end
private
def update_params
params.require(:notification_setting).permit(:notification_email)
end
end

View file

@ -83,7 +83,7 @@ module Emails
@project = Project.find(project_id)
@results = results
mail(to: @user.notification_email, subject: subject('Imported issues')) do |format|
mail(to: recipient(@user.id, @project.group), subject: subject('Imported issues')) do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
@ -103,7 +103,7 @@ module Emails
def issue_thread_options(sender_id, recipient_id, reason)
{
from: sender(sender_id),
to: recipient(recipient_id),
to: recipient(recipient_id, @project.group),
subject: subject("#{@issue.title} (##{@issue.iid})"),
'X-GitLab-NotificationReason' => reason
}

View file

@ -58,9 +58,8 @@ module Emails
@member_source_type = member_source_type
@member_source = member_source_class.find(source_id)
@invite_email = invite_email
inviter = User.find(created_by_id)
mail(to: inviter.notification_email,
mail(to: recipient(created_by_id, member_source_type == 'project' ? @member_source.group : @member_source),
subject: subject('Invitation declined'))
end

View file

@ -110,7 +110,7 @@ module Emails
def merge_request_thread_options(sender_id, recipient_id, reason = nil)
{
from: sender(sender_id),
to: recipient(recipient_id),
to: recipient(recipient_id, @project.group),
subject: subject("#{@merge_request.title} (#{@merge_request.to_reference})"),
'X-GitLab-NotificationReason' => reason
}

View file

@ -51,7 +51,7 @@ module Emails
def note_thread_options(recipient_id)
{
from: sender(@note.author_id),
to: recipient(recipient_id),
to: recipient(recipient_id, @group),
subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})")
}
end

View file

@ -7,7 +7,7 @@ module Emails
@project = domain.project
mail(
to: recipient.notification_email,
to: recipient(recipient.id, @project.group),
subject: subject("GitLab Pages domain '#{domain.domain}' has been enabled")
)
end
@ -17,7 +17,7 @@ module Emails
@project = domain.project
mail(
to: recipient.notification_email,
to: recipient(recipient.id, @project.group),
subject: subject("GitLab Pages domain '#{domain.domain}' has been disabled")
)
end
@ -27,7 +27,7 @@ module Emails
@project = domain.project
mail(
to: recipient.notification_email,
to: recipient(recipient.id, @project.group),
subject: subject("Verification succeeded for GitLab Pages domain '#{domain.domain}'")
)
end
@ -37,7 +37,7 @@ module Emails
@project = domain.project
mail(
to: recipient.notification_email,
to: recipient(recipient.id, @project.group),
subject: subject("ACTION REQUIRED: Verification failed for GitLab Pages domain '#{domain.domain}'")
)
end

View file

@ -7,20 +7,20 @@ module Emails
@project = Project.find project_id
@target_url = project_url(@project)
@old_path_with_namespace = old_path_with_namespace
mail(to: @user.notification_email,
mail(to: recipient(user_id, @project.group),
subject: subject("Project was moved"))
end
def project_was_exported_email(current_user, project)
@project = project
mail(to: current_user.notification_email,
mail(to: recipient(current_user.id, project.group),
subject: subject("Project was exported"))
end
def project_was_not_exported_email(current_user, project, errors)
@project = project
@errors = errors
mail(to: current_user.notification_email,
mail(to: recipient(current_user.id, @project.group),
subject: subject("Project export error"))
end
@ -28,7 +28,7 @@ module Emails
@project = project
@user = user
mail(to: user.notification_email, subject: subject("Project cleanup has completed"))
mail(to: recipient(user.id, project.group), subject: subject("Project cleanup has completed"))
end
def repository_cleanup_failure_email(project, user, error)
@ -36,7 +36,7 @@ module Emails
@user = user
@error = error
mail(to: user.notification_email, subject: subject("Project cleanup failure"))
mail(to: recipient(user.id, project.group), subject: subject("Project cleanup failure"))
end
def repository_push_email(project_id, opts = {})

View file

@ -6,7 +6,7 @@ module Emails
@remote_mirror = RemoteMirrorFinder.new(id: remote_mirror_id).execute
@project = @remote_mirror.project
mail(to: recipient(recipient_id), subject: subject('Remote mirror update failed'))
mail(to: recipient(recipient_id, @project.group), subject: subject('Remote mirror update failed'))
end
end
end

View file

@ -73,12 +73,31 @@ class Notify < BaseMailer
# Look up a User by their ID and return their email address
#
# recipient_id - User ID
# recipient_id - User ID
# notification_group - The parent group of the notification
#
# Returns a String containing the User's email address.
def recipient(recipient_id)
def recipient(recipient_id, notification_group = nil)
@current_user = User.find(recipient_id)
@current_user.notification_email
if notification_group
group_notification_email = nil
# Get notification group's and ancestors' notification settings
group_ids = notification_group.self_and_ancestors_ids
notification_settings = notification_group.notification_settings.where(user: @current_user) # rubocop: disable CodeReuse/ActiveRecord
# Exploit notification_group.self_and_ancestors_ids being ordered from
# most nested to least nested to iterate through group ancestors
group_ids.each do |group_id|
group_notification_email = notification_settings.find { |ns| ns.source_id == group_id }&.notification_email
break if group_notification_email.present?
end
end
# Return group-specific email address if present, otherwise return global
# email address
group_notification_email.presence || @current_user.notification_email
end
# Formats arguments into a String suitable for use as an email subject

View file

@ -26,7 +26,9 @@
%li
Your Commit Email will be used for web based operations, such as edits and merges.
%li
Your Notification Email will be used for account notifications.
Your Default Notification Email will be used for account notifications if a
= link_to 'group-specific email address', profile_notifications_path
is not set.
%li
Your Public Email will be displayed on your public profile.
%li
@ -41,7 +43,7 @@
- if @primary_email === current_user.public_email
%span.badge.badge-info Public email
- if @primary_email === current_user.notification_email
%span.badge.badge-info Notification email
%span.badge.badge-info Default notification email
- @emails.each do |email|
%li
= render partial: 'shared/email_with_badge', locals: { email: email.email, verified: email.confirmed? }

View file

@ -1,12 +1,17 @@
%li.notification-list-item
%span.notification.fa.fa-holder.append-right-5
- if setting.global?
= notification_icon(current_user.global_notification_setting.level)
- else
= notification_icon(setting.level)
.gl-responsive-table-row.notification-list-item
.table-section.section-40
%span.notification.fa.fa-holder.append-right-5
- if setting.global?
= notification_icon(current_user.global_notification_setting.level)
- else
= notification_icon(setting.level)
%span.str-truncated
= link_to group.name, group_path(group)
%span.str-truncated
= link_to group.name, group_path(group)
.float-right
.table-section.section-30.text-right
= render 'shared/notifications/button', notification_setting: setting
.table-section.section-30
= form_for @user.notification_settings.find { |ns| ns.source == group }, url: profile_notifications_group_path(group), method: :put, html: { class: 'update-notifications' } do |f|
= f.select :notification_email, @user.all_emails, { include_blank: 'Global notification email' }, class: 'select2 js-group-notification-email'

View file

@ -41,9 +41,8 @@
%h5
= _('Groups (%{count})') % { count: @group_notifications.count }
%div
%ul.bordered-list
- @group_notifications.each do |setting|
= render 'group_settings', setting: setting, group: setting.source
- @group_notifications.each do |setting|
= render 'group_settings', setting: setting, group: setting.source
%h5
= _('Projects (%{count})') % { count: @project_notifications.count }
%p.account-well

View file

@ -1,14 +1,14 @@
- btn_class = local_assigns.fetch(:btn_class, nil)
- if notification_setting
.js-notification-dropdown.notification-dropdown.home-panel-action-button.dropdown.inline
.js-notification-dropdown.notification-dropdown.mr-md-2.home-panel-action-button.dropdown.inline
= form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f|
= hidden_setting_source_input(notification_setting)
= f.hidden_field :level, class: "notification_setting_level"
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
%button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
@ -16,9 +16,11 @@
.sr-only Toggle dropdown
- else
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
= icon("caret-down")
.float-left
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
.float-right
= icon("caret-down")
= render "shared/notifications/notification_dropdown", notification_setting: notification_setting

View file

@ -0,0 +1,5 @@
---
title: Add ability to define notification email addresses for groups you belong to.
merge_request: 25299
author:
type: added

View file

@ -17,7 +17,11 @@ resource :profile, only: [:show, :update] do
delete :unlink
end
end
resource :notifications, only: [:show, :update]
resource :notifications, only: [:show, :update] do
resources :groups, only: :update
end
resource :password, only: [:new, :create, :edit, :update] do
member do
put :reset

View file

@ -5,11 +5,13 @@ describe Emails::PagesDomains do
include EmailSpec::Matchers
include_context 'gitlab email notification'
set(:project) { create(:project) }
set(:domain) { create(:pages_domain, project: project) }
set(:user) { project.owner }
set(:user) { project.creator }
shared_examples 'a pages domain email' do
let(:test_recipient) { user }
it_behaves_like 'an email sent to a user'
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'

View file

@ -45,6 +45,10 @@ describe Notify do
context 'for a project' do
shared_examples 'an assignee email' do
let(:test_recipient) { assignee }
it_behaves_like 'an email sent to a user'
it 'is sent to the assignee as the author' do
sender = subject.header[:from].addrs.first
@ -618,8 +622,10 @@ describe Notify do
end
describe 'project was moved' do
let(:test_recipient) { user }
subject { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") }
it_behaves_like 'an email sent to a user'
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"
@ -1083,8 +1089,6 @@ describe Notify do
end
context 'for a group' do
set(:group) { create(:group) }
describe 'group access requested' do
let(:group) { create(:group, :public, :access_requestable) }
let(:group_member) do

View file

@ -1,5 +1,7 @@
shared_context 'gitlab email notification' do
set(:project) { create(:project, :repository, name: 'a-known-name') }
set(:group) { create(:group) }
set(:subgroup) { create(:group, parent: group) }
set(:project) { create(:project, :repository, name: 'a-known-name', group: group) }
set(:recipient) { create(:user, email: 'recipient@example.com') }
let(:gitlab_sender_display_name) { Gitlab.config.gitlab.email_display_name }
@ -39,6 +41,48 @@ shared_examples 'an email sent from GitLab' do
end
end
shared_examples 'an email sent to a user' do
let(:group_notification_email) { 'user+group@example.com' }
it 'is sent to user\'s global notification email address' do
expect(subject).to deliver_to(test_recipient.notification_email)
end
context 'that is part of a project\'s group' do
it 'is sent to user\'s group notification email address when set' do
create(:notification_setting, user: test_recipient, source: project.group, notification_email: group_notification_email)
expect(subject).to deliver_to(group_notification_email)
end
it 'is sent to user\'s global notification email address when no group email set' do
create(:notification_setting, user: test_recipient, source: project.group, notification_email: '')
expect(subject).to deliver_to(test_recipient.notification_email)
end
end
context 'when project is in a sub-group' do
before do
project.group = subgroup
project.save!
end
it 'is sent to user\'s subgroup notification email address when set' do
# Set top-level group notification email address to make sure it doesn't get selected
create(:notification_setting, user: test_recipient, source: group, notification_email: group_notification_email)
subgroup_notification_email = 'user+subgroup@example.com'
create(:notification_setting, user: test_recipient, source: subgroup, notification_email: subgroup_notification_email)
expect(subject).to deliver_to(subgroup_notification_email)
end
it 'is sent to user\'s group notification email address when set and subgroup email address not set' do
create(:notification_setting, user: test_recipient, source: subgroup, notification_email: '')
expect(subject).to deliver_to(test_recipient.notification_email)
end
end
end
shared_examples 'an email that contains a header with author username' do
it 'has X-GitLab-Author header containing author\'s username' do
is_expected.to have_header 'X-GitLab-Author', user.username