FEATURE: Add attachments to outgoing emails

FEATURE: Add attachments to outgoing emails

This feature is off by default and can can be configured with the email_total_attachment_size_limit_kb site setting.

Co-authored-by: Maja Komel maja.komel@gmail.com

diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index afce1c4..f7013fd 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1812,6 +1812,7 @@ en:
     enable_forwarded_emails: "[BETA] Allow users to create a topic by forwarding an email in."
     always_show_trimmed_content: "Always show trimmed part of incoming emails. WARNING: might reveal email addresses."
     private_email: "Don't include content from posts or topics in email title or email body. NOTE: also disables digest emails."
+    email_total_attachment_size_limit_kb: "Max total size of files attached to outgoing emails. Set to 0 to disable sending of attachments."
 
     manual_polling_enabled: "Push emails using the API for email replies."
     pop3_polling_enabled: "Poll via POP3 for email replies."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 3b5736c..4c68601 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -1021,6 +1021,9 @@ email:
   enable_forwarded_emails: false
   always_show_trimmed_content: false
   private_email: false
+  email_total_attachment_size_limit_kb:
+    default: 0
+    max: 51200
 
 files:
   max_image_size_kb:
diff --git a/lib/email/sender.rb b/lib/email/sender.rb
index 464431a..60c3e9a 100644
--- a/lib/email/sender.rb
+++ b/lib/email/sender.rb
@@ -100,6 +100,8 @@ module Email
         # guards against deleted posts
         return skip(SkippedEmailLog.reason_types[:sender_post_deleted]) unless post
 
+        add_attachments(post)
+
         topic = post.topic
         first_post = topic.ordered_posts.first
 
@@ -239,6 +241,38 @@ module Email
 
     private
 
+    def add_attachments(post)
+      max_email_size = SiteSetting.email_total_attachment_size_limit_kb.kilobytes
+      return if max_email_size == 0
+
+      email_size = 0
+      post.uploads.each do |upload|
+        next if FileHelper.is_supported_image?(upload.original_filename)
+        next if email_size + upload.filesize > max_email_size
+
+        begin
+          path = if upload.local?
+            Discourse.store.path_for(upload)
+          else
+            Discourse.store.download(upload).path
+          end
+
+          @message.attachments[upload.original_filename] = File.read(path)
+          email_size += File.size(path)
+        rescue => e
+          Discourse.warn_exception(
+            e,
+            message: "Failed to attach file to email",
+            env: {
+              post_id: post.id,
+              upload_id: upload.id,
+              filename: upload.original_filename
+            }
+          )
+        end
+      end
+    end
+
     def header_value(name)
       header = @message.header[name]
       return nil unless header
diff --git a/spec/components/email/sender_spec.rb b/spec/components/email/sender_spec.rb
index 995014f..b2a7a58 100644
--- a/spec/components/email/sender_spec.rb
+++ b/spec/components/email/sender_spec.rb
@@ -351,6 +351,69 @@ describe Email::Sender do
     end
   end
 
+  context "with attachments" do
+    fab!(:small_pdf) do
+      SiteSetting.authorized_extensions = 'pdf'
+      UploadCreator.new(file_from_fixtures("small.pdf", "pdf"), "small.pdf")
+        .create_for(Discourse.system_user.id)
+    end
+    fab!(:large_pdf) do
+      SiteSetting.authorized_extensions = 'pdf'
+      UploadCreator.new(file_from_fixtures("large.pdf", "pdf"), "large.pdf")
+        .create_for(Discourse.system_user.id)
+    end
+    fab!(:csv_file) do
+      SiteSetting.authorized_extensions = 'csv'
+      UploadCreator.new(file_from_fixtures("words.csv", "csv"), "words.csv")
+        .create_for(Discourse.system_user.id)
+    end
+    fab!(:image) do
+      SiteSetting.authorized_extensions = 'png'
+      UploadCreator.new(file_from_fixtures("logo.png", "images"), "logo.png")
+        .create_for(Discourse.system_user.id)
+    end
+    fab!(:post) { Fabricate(:post) }
+    fab!(:reply) do
+      raw = <<~RAW
+        Hello world!
+        #{DiscourseMarkdown.attachment_markdown(small_pdf)}
+        #{DiscourseMarkdown.attachment_markdown(large_pdf)}
+        #{DiscourseMarkdown.image_markdown(image)}
+        #{DiscourseMarkdown.attachment_markdown(csv_file)}
+      RAW
+      reply = Fabricate(:post, raw: raw, topic: post.topic, user: Fabricate(:user))
+      reply.link_post_uploads
+      reply
+    end
+    fab!(:notification) { Fabricate(:posted_notification, user: post.user, post: reply) }
+    let(:message) do
+      UserNotifications.user_posted(
+        post.user,
+        post: reply,
+        notification_type: notification.notification_type,
+        notification_data_hash: notification.data_hash
+      )
+    end
+
+    it "adds only non-image uploads as attachments to the email" do
+      SiteSetting.email_total_attachment_size_limit_kb = 10_000
+      Email::Sender.new(message, :valid_type).send
+
+      expect(message.attachments.length).to eq(3)
+      expect(message.attachments.map(&:filename))
+        .to contain_exactly(*[small_pdf, large_pdf, csv_file].map(&:original_filename))
+    end
+
+    it "respects the size limit and attaches only files that fit into the max email size" do
+      SiteSetting.email_total_attachment_size_limit_kb = 40
+      Email::Sender.new(message, :valid_type).send
+
+      expect(message.attachments.length).to eq(2)
+      expect(message.attachments.map(&:filename))
+        .to contain_exactly(*[small_pdf, csv_file].map(&:original_filename))
+    end
+  end
+
   context 'with a deleted post' do
 
     it 'should skip sending the email' do
diff --git a/spec/fixtures/pdf/large.pdf b/spec/fixtures/pdf/large.pdf
new file mode 100644
index 0000000..59e719e
Binary files /dev/null and b/spec/fixtures/pdf/large.pdf differ

GitHub sha: 7e0eeed2

2 Likes

This commit has been mentioned on Discourse Meta. There might be relevant details there: