From 3b5330e9bcf66d7f66ff5b51dbb60adcf2197f67 Mon Sep 17 00:00:00 2001
From: Heinrich Lee Yu <heinrich@gitlab.com>
Date: Wed, 3 Nov 2021 09:18:07 +0000
Subject: [PATCH] Use the To header when sending pipeline emails

A missing To header can cause issues with spam filters. And since we no
longer render job logs in these emails, generating these emails
individually isn't expensive anymore.

Changelog: fixed
---
 app/mailers/emails/pipelines.rb               | 21 +++++-----
 app/models/integrations/emails_on_push.rb     |  2 +-
 app/models/integrations/pipelines_email.rb    | 17 ++++++++-
 app/services/notification_service.rb          |  4 +-
 locale/gitlab.pot                             |  6 +--
 spec/mailers/emails/pipelines_spec.rb         | 21 +++++++++-
 spec/models/ci/pipeline_spec.rb               |  2 +-
 .../integrations/pipelines_email_spec.rb      | 38 ++++++++++++++++++-
 spec/services/notification_service_spec.rb    | 24 ++++++------
 9 files changed, 100 insertions(+), 35 deletions(-)

diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb
index 1b27d06239136..5363ad637715f 100644
--- a/app/mailers/emails/pipelines.rb
+++ b/app/mailers/emails/pipelines.rb
@@ -2,21 +2,23 @@
 
 module Emails
   module Pipelines
-    def pipeline_success_email(pipeline, recipients)
-      pipeline_mail(pipeline, recipients, 'Successful')
+    def pipeline_success_email(pipeline, recipient)
+      pipeline_mail(pipeline, recipient, 'Successful')
     end
 
-    def pipeline_failed_email(pipeline, recipients)
-      pipeline_mail(pipeline, recipients, 'Failed')
+    def pipeline_failed_email(pipeline, recipient)
+      pipeline_mail(pipeline, recipient, 'Failed')
     end
 
-    def pipeline_fixed_email(pipeline, recipients)
-      pipeline_mail(pipeline, recipients, 'Fixed')
+    def pipeline_fixed_email(pipeline, recipient)
+      pipeline_mail(pipeline, recipient, 'Fixed')
     end
 
     private
 
-    def pipeline_mail(pipeline, recipients, status)
+    def pipeline_mail(pipeline, recipient, status)
+      raise ArgumentError if recipient.is_a?(Array)
+
       @project = pipeline.project
       @pipeline = pipeline
 
@@ -28,10 +30,7 @@ def pipeline_mail(pipeline, recipients, status)
 
       add_headers
 
-      # We use bcc here because we don't want to generate these emails for a
-      # thousand times. This could be potentially expensive in a loop, and
-      # recipients would contain all project watchers so it could be a lot.
-      mail(bcc: recipients,
+      mail(to: recipient,
            subject: subject(pipeline_subject(status))) do |format|
         format.html { render layout: 'mailer' }
         format.text { render layout: 'mailer' }
diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb
index 26c47d53918ab..a9cd67550dc3e 100644
--- a/app/models/integrations/emails_on_push.rb
+++ b/app/models/integrations/emails_on_push.rb
@@ -97,7 +97,7 @@ def number_of_recipients_within_limit
       return if recipients.blank?
 
       if self.class.valid_recipients(recipients).size > RECIPIENTS_LIMIT
-        errors.add(:recipients, s_("EmailsOnPushService|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT })
+        errors.add(:recipients, s_("Integrations|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT })
       end
     end
   end
diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb
index e5d94bab2db5b..6dc41958daa8b 100644
--- a/app/models/integrations/pipelines_email.rb
+++ b/app/models/integrations/pipelines_email.rb
@@ -4,9 +4,12 @@ module Integrations
   class PipelinesEmail < Integration
     include NotificationBranchSelection
 
+    RECIPIENTS_LIMIT = 30
+
     prop_accessor :recipients, :branches_to_be_notified
     boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch
     validates :recipients, presence: true, if: :validate_recipients?
+    validate :number_of_recipients_within_limit, if: :validate_recipients?
 
     def initialize_properties
       if properties.nil?
@@ -49,7 +52,7 @@ def execute(data, force: false)
       return unless supported_events.include?(data[:object_kind])
       return unless force || should_pipeline_be_notified?(data)
 
-      all_recipients = retrieve_recipients(data)
+      all_recipients = retrieve_recipients
 
       return unless all_recipients.any?
 
@@ -99,8 +102,18 @@ def notify_for_pipeline?(data)
       end
     end
 
-    def retrieve_recipients(data)
+    def retrieve_recipients
       recipients.to_s.split(/[,\r\n ]+/).reject(&:empty?)
     end
+
+    private
+
+    def number_of_recipients_within_limit
+      return if recipients.blank?
+
+      if retrieve_recipients.size > RECIPIENTS_LIMIT
+        errors.add(:recipients, s_("Integrations|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT })
+      end
+    end
   end
 end
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index afc9015e758bf..6ad3a74b85d51 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -598,8 +598,8 @@ def pipeline_finished(pipeline, ref_status: nil, recipients: nil)
       user.notification_email_for(pipeline.project.group)
     end
 
-    if recipients.any?
-      mailer.public_send(email_template, pipeline, recipients).deliver_later
+    recipients.each do |recipient|
+      mailer.public_send(email_template, pipeline, recipient).deliver_later
     end
   end
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 9a07fac83681a..86b5cbf638ce7 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -12637,9 +12637,6 @@ msgstr ""
 msgid "EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains})."
 msgstr ""
 
-msgid "EmailsOnPushService|can't exceed %{recipients_limit}"
-msgstr ""
-
 msgid "EmailsOnPushService|tanuki@example.com gitlab@example.com"
 msgstr ""
 
@@ -18602,6 +18599,9 @@ msgstr ""
 msgid "Integrations|ZenTao issues display here when you create issues in your project in ZenTao."
 msgstr ""
 
+msgid "Integrations|can't exceed %{recipients_limit}"
+msgstr ""
+
 msgid "Interactive mode"
 msgstr ""
 
diff --git a/spec/mailers/emails/pipelines_spec.rb b/spec/mailers/emails/pipelines_spec.rb
index b9bc53625ac9b..3a2eb105964c9 100644
--- a/spec/mailers/emails/pipelines_spec.rb
+++ b/spec/mailers/emails/pipelines_spec.rb
@@ -71,10 +71,19 @@
     end
   end
 
+  shared_examples_for 'only accepts a single recipient' do
+    let(:recipient) { ['test@gitlab.com', 'test2@gitlab.com'] }
+
+    it 'raises an ArgumentError' do
+      expect { subject.deliver_now }.to raise_error(ArgumentError)
+    end
+  end
+
   describe '#pipeline_success_email' do
-    subject { Notify.pipeline_success_email(pipeline, pipeline.user.try(:email)) }
+    subject { Notify.pipeline_success_email(pipeline, recipient) }
 
     let(:pipeline) { create(:ci_pipeline, project: project, ref: ref, sha: sha) }
+    let(:recipient) { pipeline.user.try(:email) }
     let(:ref) { 'master' }
     let(:sha) { project.commit(ref).sha }
 
@@ -93,12 +102,15 @@
         stub_config_setting(email_subject_suffix: email_subject_suffix)
       end
     end
+
+    it_behaves_like 'only accepts a single recipient'
   end
 
   describe '#pipeline_failed_email' do
-    subject { Notify.pipeline_failed_email(pipeline, pipeline.user.try(:email)) }
+    subject { Notify.pipeline_failed_email(pipeline, recipient) }
 
     let(:pipeline) { create(:ci_pipeline, project: project, ref: ref, sha: sha) }
+    let(:recipient) { pipeline.user.try(:email) }
     let(:ref) { 'master' }
     let(:sha) { project.commit(ref).sha }
 
@@ -106,12 +118,15 @@
       let(:status) { 'Failed' }
       let(:status_text) { "Pipeline ##{pipeline.id} has failed!" }
     end
+
+    it_behaves_like 'only accepts a single recipient'
   end
 
   describe '#pipeline_fixed_email' do
     subject { Notify.pipeline_fixed_email(pipeline, pipeline.user.try(:email)) }
 
     let(:pipeline) { create(:ci_pipeline, project: project, ref: ref, sha: sha) }
+    let(:recipient) { pipeline.user.try(:email) }
     let(:ref) { 'master' }
     let(:sha) { project.commit(ref).sha }
 
@@ -119,5 +134,7 @@
       let(:status) { 'Fixed' }
       let(:status_text) { "Pipeline has been fixed and ##{pipeline.id} has passed!" }
     end
+
+    it_behaves_like 'only accepts a single recipient'
   end
 end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 8d25502ebb510..e573a6ef780a8 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -3361,7 +3361,7 @@ def create_pipeline(status, ref, sha)
 
     shared_examples 'sending a notification' do
       it 'sends an email', :sidekiq_might_not_need_inline do
-        should_only_email(pipeline.user, kind: :bcc)
+        should_only_email(pipeline.user)
       end
     end
 
diff --git a/spec/models/integrations/pipelines_email_spec.rb b/spec/models/integrations/pipelines_email_spec.rb
index afd9d71ebc425..d70f104b965df 100644
--- a/spec/models/integrations/pipelines_email_spec.rb
+++ b/spec/models/integrations/pipelines_email_spec.rb
@@ -35,6 +35,42 @@
 
       it { is_expected.not_to validate_presence_of(:recipients) }
     end
+
+    describe 'validates number of recipients' do
+      before do
+        stub_const("#{described_class}::RECIPIENTS_LIMIT", 2)
+      end
+
+      subject(:integration) { described_class.new(project: project, recipients: recipients, active: true) }
+
+      context 'valid number of recipients' do
+        let(:recipients) { 'foo@bar.com, , ' }
+
+        it 'does not count empty emails' do
+          is_expected.to be_valid
+        end
+      end
+
+      context 'invalid number of recipients' do
+        let(:recipients) { 'foo@bar.com bar@foo.com bob@gitlab.com' }
+
+        it { is_expected.not_to be_valid }
+
+        it 'adds an error message' do
+          integration.valid?
+
+          expect(integration.errors).to contain_exactly('Recipients can\'t exceed 2')
+        end
+
+        context 'when integration is not active' do
+          before do
+            integration.active = false
+          end
+
+          it { is_expected.to be_valid }
+        end
+      end
+    end
   end
 
   shared_examples 'sending email' do |branches_to_be_notified: nil|
@@ -50,7 +86,7 @@
     it 'sends email' do
       emails = receivers.map { |r| double(notification_email_or_default: r) }
 
-      should_only_email(*emails, kind: :bcc)
+      should_only_email(*emails)
     end
   end
 
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 48718cbc24a82..fbf5b1833658a 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -3040,7 +3040,7 @@ def create_pipeline(user, status)
           it 'emails only the creator' do
             notification.pipeline_finished(pipeline)
 
-            should_only_email(u_custom_notification_enabled, kind: :bcc)
+            should_only_email(u_custom_notification_enabled)
           end
 
           it_behaves_like 'project emails are disabled' do
@@ -3063,7 +3063,7 @@ def create_pipeline(user, status)
             it 'sends to group notification email' do
               notification.pipeline_finished(pipeline)
 
-              expect(email_recipients(kind: :bcc).first).to eq(group_notification_email)
+              expect(email_recipients.first).to eq(group_notification_email)
             end
           end
         end
@@ -3076,7 +3076,7 @@ def create_pipeline(user, status)
           it 'emails only the creator' do
             notification.pipeline_finished(pipeline)
 
-            should_only_email(u_member, kind: :bcc)
+            should_only_email(u_member)
           end
 
           it_behaves_like 'project emails are disabled' do
@@ -3098,7 +3098,7 @@ def create_pipeline(user, status)
             it 'sends to group notification email' do
               notification.pipeline_finished(pipeline)
 
-              expect(email_recipients(kind: :bcc).first).to eq(group_notification_email)
+              expect(email_recipients.first).to eq(group_notification_email)
             end
           end
         end
@@ -3110,7 +3110,7 @@ def create_pipeline(user, status)
           end
 
           it 'emails only the creator' do
-            should_only_email(u_watcher, kind: :bcc)
+            should_only_email(u_watcher)
           end
         end
 
@@ -3121,7 +3121,7 @@ def create_pipeline(user, status)
           end
 
           it 'emails only the creator' do
-            should_only_email(u_custom_notification_unset, kind: :bcc)
+            should_only_email(u_custom_notification_unset)
           end
         end
 
@@ -3143,7 +3143,7 @@ def create_pipeline(user, status)
           end
 
           it 'emails only the creator' do
-            should_only_email(u_custom_notification_enabled, kind: :bcc)
+            should_only_email(u_custom_notification_enabled)
           end
         end
 
@@ -3170,7 +3170,7 @@ def create_pipeline(user, status)
           it 'emails only the creator' do
             notification.pipeline_finished(pipeline, ref_status: ref_status)
 
-            should_only_email(u_member, kind: :bcc)
+            should_only_email(u_member)
           end
 
           it_behaves_like 'project emails are disabled' do
@@ -3192,7 +3192,7 @@ def create_pipeline(user, status)
             it 'sends to group notification email' do
               notification.pipeline_finished(pipeline, ref_status: ref_status)
 
-              expect(email_recipients(kind: :bcc).first).to eq(group_notification_email)
+              expect(email_recipients.first).to eq(group_notification_email)
             end
           end
         end
@@ -3204,7 +3204,7 @@ def create_pipeline(user, status)
           end
 
           it 'emails only the creator' do
-            should_only_email(u_watcher, kind: :bcc)
+            should_only_email(u_watcher)
           end
         end
 
@@ -3215,7 +3215,7 @@ def create_pipeline(user, status)
           end
 
           it 'emails only the creator' do
-            should_only_email(u_custom_notification_unset, kind: :bcc)
+            should_only_email(u_custom_notification_unset)
           end
         end
 
@@ -3236,7 +3236,7 @@ def create_pipeline(user, status)
 
             notification.pipeline_finished(pipeline, ref_status: ref_status)
 
-            should_only_email(u_custom_notification_enabled, kind: :bcc)
+            should_only_email(u_custom_notification_enabled)
           end
         end
       end
-- 
GitLab