diff --git a/app/services/incoming_emails.rb b/app/services/incoming_emails.rb new file mode 100644 index 00000000000..b99355d4066 --- /dev/null +++ b/app/services/incoming_emails.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module IncomingEmails + class UnauthorizedAction < StandardError; end + class MissingInformation < StandardError; end +end diff --git a/app/services/incoming_emails/dispatch_service.rb b/app/services/incoming_emails/dispatch_service.rb index 61df87c1751..d550192c1e3 100644 --- a/app/services/incoming_emails/dispatch_service.rb +++ b/app/services/incoming_emails/dispatch_service.rb @@ -28,10 +28,6 @@ # See COPYRIGHT and LICENSE files for more details. #++ module IncomingEmails - class UnauthorizedAction < StandardError; end - - class MissingInformation < StandardError; end - class DispatchService include ActionView::Helpers::SanitizeHelper @@ -54,6 +50,10 @@ module IncomingEmails handlers.unshift(handler_class) end + def self.remove_handler(handler_class) + handlers.delete(handler_class) + end + attr_reader :email, :sender_email, :user, :options, :success, :logs def initialize(email, options:) @@ -94,6 +94,7 @@ module IncomingEmails # the subject. Additionally, the subject structure might change, e.g. via localization changes. def dispatch call = call_matching_handler + return if call.nil? @success = call.success? log_handler_call(call) @@ -101,13 +102,10 @@ module IncomingEmails call.result rescue ActiveRecord::RecordInvalid => e log "could not save record: #{e.message}", :error - nil rescue MissingInformation => e log "missing information from #{user}: #{e.message}", :error - nil rescue UnauthorizedAction log "unauthorized attempt from #{user}", :error - nil end def log_handler_call(call) @@ -127,7 +125,7 @@ module IncomingEmails :info, report: false - false + nil end end @@ -262,10 +260,10 @@ module IncomingEmails message = "MailHandler: #{message}" Rails.logger.public_send(level, message) + nil end - def assign_options(value) - # rubocop:disable Metrics/AbcSize + def assign_options(value) # rubocop:disable Metrics/AbcSize options = value.dup options[:issue] ||= {} @@ -280,6 +278,7 @@ module IncomingEmails options[:allow_override] << :type unless options[:issue].has_key?(:type) # Priority overridable by default options[:allow_override] << :priority unless options[:issue].has_key?(:priority) + options[:no_permission_check] = ActiveRecord::Type::Boolean.new.cast(options[:no_permission_check]) options diff --git a/app/services/incoming_emails/handlers/base.rb b/app/services/incoming_emails/handlers/base.rb index 35d51648d26..661ab141257 100644 --- a/app/services/incoming_emails/handlers/base.rb +++ b/app/services/incoming_emails/handlers/base.rb @@ -49,6 +49,10 @@ module IncomingEmails::Handlers raise NotImplementedError, "Subclasses must implement handle method" end + def cleaned_up_text_body + cleanup_body(plain_text_body) + end + protected # The receive_* methods have been moved to specific handler classes: @@ -188,7 +192,7 @@ module IncomingEmails::Handlers # * parse the email To field # * specific project (eg. Setting.mail_handler_target_project) target = Project.find_by(identifier: get_keyword(:project)) - raise MissingInformation.new("Unable to determine target project") if target.nil? + raise IncomingEmails::MissingInformation.new("Unable to determine target project") if target.nil? target end @@ -209,10 +213,6 @@ module IncomingEmails::Handlers end end - def cleaned_up_text_body - cleanup_body(plain_text_body) - end - # Removes the email body of text after the truncation configurations. def cleanup_body(body) delimiters = Setting.mail_handler_body_delimiters.to_s.split(/[\r\n]+/).compact_blank.map { |s| Regexp.escape(s) } diff --git a/app/services/incoming_emails/handlers/message_reply.rb b/app/services/incoming_emails/handlers/message_reply.rb index c5fe2e831cb..d2b839eda37 100644 --- a/app/services/incoming_emails/handlers/message_reply.rb +++ b/app/services/incoming_emails/handlers/message_reply.rb @@ -42,7 +42,7 @@ module IncomingEmails::Handlers private # Receives a reply to a forum message - def receive_message_reply(message_id) # rubocop:disable Metrics/AbcSize + def receive_message_reply(message_id) message = Message.find_by(id: message_id) if message message = message.root @@ -55,17 +55,22 @@ module IncomingEmails::Handlers ServiceResult.failure(message: "ignoring reply from [#{sender_email}] to a locked topic", mesage_type: :warn) else - reply = Message.new(subject: email.subject.gsub(%r{^.*msg\d+\]}, "").strip, - content: cleaned_up_text_body) - reply.author = user - reply.forum = message.forum - message.children << reply - add_attachments(reply) - reply - ServiceResult.success(result: reply, - message: "Reply added to message ##{message.id}") + create_reply_message(message) end end end + + def create_reply_message(root) + reply = Message.new(subject: email.subject.gsub(%r{^.*msg\d+\]}, "").strip, + content: cleaned_up_text_body) + reply.author = user + reply.forum = root.forum + root.children << reply + add_attachments(reply) + reply + + ServiceResult.success(result: reply, + message: "Reply added to message ##{root.id}") + end end end diff --git a/app/services/incoming_emails/handlers/work_package.rb b/app/services/incoming_emails/handlers/work_package.rb index edc8fe3a771..afeef87433e 100644 --- a/app/services/incoming_emails/handlers/work_package.rb +++ b/app/services/incoming_emails/handlers/work_package.rb @@ -73,17 +73,18 @@ module IncomingEmails::Handlers call = create_work_package(project) - call.message = if call.success? - "work_package created by #{user}" - else - "work_package could not be created by #{user} due to ##{call.errors.full_messages}" - end + call.message = + if call.success? + "work_package created by #{user}" + else + "work_package could not be created by #{user} due to ##{call.errors.full_messages}" + end call end # Adds a note to an existing work package - def receive_work_package_reply(work_package_id) + def receive_work_package_reply(work_package_id) # rubocop:disable Metrics/AbcSize work_package = ::WorkPackage.find_by(id: work_package_id) return unless work_package diff --git a/app/services/incoming_emails/mail_handler.rb b/app/services/incoming_emails/mail_handler.rb index ebdc15602f0..4bde340166d 100644 --- a/app/services/incoming_emails/mail_handler.rb +++ b/app/services/incoming_emails/mail_handler.rb @@ -33,7 +33,8 @@ module IncomingEmails ## # Code copied from base class and extended with optional options parameter # as well as force_encoding support. - def self.receive(raw_mail, options = {}) + def self.receive(input, options = {}) + raw_mail = input.dup raw_mail.force_encoding("ASCII-8BIT") if raw_mail.respond_to?(:force_encoding) ActiveSupport::Notifications.instrument("receive.action_mailer") do |payload| diff --git a/app/services/incoming_emails/user_creator.rb b/app/services/incoming_emails/user_creator.rb index f7b3f9af434..c1e465b2468 100644 --- a/app/services/incoming_emails/user_creator.rb +++ b/app/services/incoming_emails/user_creator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH # diff --git a/lib/redmine/pop3.rb b/lib/redmine/pop3.rb index f65b8413247..57b34bd4e0b 100644 --- a/lib/redmine/pop3.rb +++ b/lib/redmine/pop3.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -31,7 +33,7 @@ require "net/pop" module Redmine module POP3 class << self - def check(pop_options = {}, options = {}) + def check(pop_options = {}, options = {}) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity host = pop_options[:host] || "127.0.0.1" port = pop_options[:port] || "110" apop = (pop_options[:apop].to_s == "1") diff --git a/spec/models/mail_handler_spec.rb b/spec/models/mail_handler_spec.rb index ecddc7f31e1..129d455b9c4 100644 --- a/spec/models/mail_handler_spec.rb +++ b/spec/models/mail_handler_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe IncomingEmails::MailHandler do +RSpec.describe IncomingEmails::MailHandler do # rubocop:disable RSpec/SpecFilePathFormat # we need these run first so the anonymous and system users are created and # there is a default work package priority to save any work packages shared_let(:anno_user) { User.anonymous } diff --git a/spec/services/incoming_emails/dispatch_service_spec.rb b/spec/services/incoming_emails/dispatch_service_spec.rb new file mode 100644 index 00000000000..08960895555 --- /dev/null +++ b/spec/services/incoming_emails/dispatch_service_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe IncomingEmails::DispatchService do + let(:email) { instance_double(Mail::Message) } + let(:text_part) { instance_double(Mail::Part) } + let(:body) { instance_double(Mail::Body) } + let(:options) { {} } + let(:service) { described_class.new(email, options:) } + + before do + allow(body).to receive_messages(decoded: "Test body".dup) + allow(text_part).to receive_messages(body:, charset: "UTF-8") + allow(email).to receive_messages(text_part: text_part, from: ["test@example.com"]) + end + + describe "#initialize" do + it "initializes with email and options" do + expect(service.email).to eq(email) + expect(service.options).to include(options) + end + + it "extracts sender email from the email" do + expect(service.sender_email).to eq("test@example.com") + end + + it "initializes empty logs array" do + expect(service.logs).to eq([]) + end + end + + describe ".handlers" do + it "returns the default handlers" do + expect(described_class.handlers).to include( + IncomingEmails::Handlers::MessageReply, + IncomingEmails::Handlers::WorkPackage + ) + end + end + + describe ".register_handler" do + let(:custom_handler) { class_double(IncomingEmails::Handlers::WorkPackage) } + + after do + described_class.remove_handler(custom_handler) + end + + it "adds handler to the beginning of the handlers list" do + described_class.register_handler(custom_handler) + expect(described_class.handlers).to include(custom_handler) + end + end + + describe "#call!" do + let(:user) { build_stubbed(:user) } + + before do + allow(service).to receive(:determine_actor) + allow(service).to receive_messages(ignore_mail?: false, user: user) + allow(service).to receive(:dispatch) + end + + it "calls determine_actor" do + service.call! + expect(service).to have_received(:determine_actor) + end + + it "calls dispatch when user is present" do + service.call! + expect(service).to have_received(:dispatch) + end + + it "does not call dispatch when user is not present" do + allow(service).to receive(:user).and_return(nil) + service.call! + expect(service).not_to have_received(:dispatch) + end + + it "returns early when mail should be ignored" do + allow(service).to receive(:ignore_mail?).and_return(true) + service.call! + expect(service).not_to have_received(:determine_actor) + end + end + + describe "#ignore_mail?" do + it "returns false by default" do + allow(service).to receive_messages(mail_from_system?: false, ignored_by_header?: false, ignored_user?: false) + + expect(service.send(:ignore_mail?)).to be_falsey + end + end + + describe "#mail_from_system?" do + context "when email is from system address" do + before do + allow(Setting).to receive(:mail_from).and_return("system@example.com") + allow(email).to receive(:from).and_return(["system@example.com"]) + end + + it "returns true" do + service = described_class.new(email, options:) + expect(service.send(:mail_from_system?)).to be_truthy + end + end + + context "when email is not from system address" do + before do + allow(Setting).to receive(:mail_from).and_return("system@example.com") + allow(email).to receive(:from).and_return(["user@example.com"]) + end + + it "returns false" do + service = described_class.new(email, options:) + expect(service.send(:mail_from_system?)).to be_falsey + end + end + end + + describe "#ignored_by_header?" do + context "with auto-response suppress header" do + before do + allow(email).to receive(:header).and_return({ "X-Auto-Response-Suppress" => "OOF" }) + end + + it "returns true" do + expect(service.send(:ignored_by_header?)).to be_truthy + end + end + + context "with auto-submitted header" do + before do + allow(email).to receive(:header).and_return({ "Auto-Submitted" => "auto-replied" }) + end + + it "returns true" do + expect(service.send(:ignored_by_header?)).to be_truthy + end + end + + context "without ignored headers" do + before do + allow(email).to receive(:header).and_return({}) + end + + it "returns false" do + expect(service.send(:ignored_by_header?)).to be_falsey + end + end + end + + describe "#object_reference_from_header" do + context "with valid reference header" do + before do + allow(email).to receive(:references).and_return([""]) + end + + it "extracts object reference" do + reference = service.send(:object_reference_from_header) + expect(reference).to eq({ klass: "work_packages", id: 123 }) + end + end + + context "without valid reference header" do + before do + allow(email).to receive(:references).and_return([]) + end + + it "returns empty hash" do + reference = service.send(:object_reference_from_header) + expect(reference).to eq({}) + end + end + end + + describe "#instantiate_matching_handler" do + let(:handler_class) { class_double(IncomingEmails::Handlers::WorkPackage) } + let(:handler_instance) { instance_double(IncomingEmails::Handlers::WorkPackage) } + + before do + allow(service).to receive(:object_reference_from_header).and_return({}) + allow(described_class).to receive(:handlers).and_return([handler_class]) + allow(handler_class).to receive_messages(handles?: true, new: handler_instance) + end + + it "finds and instantiates matching handler" do + result = service.send(:instantiate_matching_handler) + expect(result).to eq(handler_instance) + end + + it "returns nil when no handler matches" do + allow(handler_class).to receive(:handles?).and_return(false) + result = service.send(:instantiate_matching_handler) + expect(result).to be_nil + end + end +end diff --git a/spec/services/incoming_emails/handlers/base_spec.rb b/spec/services/incoming_emails/handlers/base_spec.rb index 4ad4d7f2a98..c3773665b1c 100644 --- a/spec/services/incoming_emails/handlers/base_spec.rb +++ b/spec/services/incoming_emails/handlers/base_spec.rb @@ -55,5 +55,104 @@ RSpec.describe IncomingEmails::Handlers::Base do expect(subject.cleaned_up_text_body).to eq("Subject:foo\nDescription:bar") end end + + context "with string delimiters" do + before do + allow(Setting).to receive_messages(mail_handler_body_delimiters: "---", mail_handler_body_delimiter_regex: "") + end + + it "removes content after delimiter" do + body = "Before delimiter\n---\nAfter delimiter" + result = subject.send(:cleanup_body, body) + expect(result).to eq("Before delimiter") + end + end + end + + describe "#get_keyword" do + let(:plain_text_body) { "Project: test\nStatus: Open\nSome content here".dup } + let(:options) { { allow_override: [:project], issue: {} } } + + it "extracts keyword from body" do + result = subject.send(:get_keyword, :project) + expect(result).to eq("test") + end + + it "returns nil for non-existent keyword" do + result = subject.send(:get_keyword, :nonexistent) + expect(result).to be_nil + end + + it "respects allow_override option" do + result = subject.send(:get_keyword, :status) + expect(result).to be_nil # status not in allow_override + end + end + + describe "#extract_keyword!" do + it "extracts and removes keyword from text" do + text = "Project: test\nStatus: Open\nSome content here".dup + result = subject.send(:extract_keyword!, text, :project, nil) + expect(result).to eq("test") + expect(text).not_to include("Project: test") + end + + it "handles case insensitive matching" do + text = "PROJECT: test\nContent".dup + result = subject.send(:extract_keyword!, text, :project, nil) + expect(result).to eq("test") + end + end + + describe "#human_attr_translations" do + let(:user) { build_stubbed(:user, language: "en") } + + it "returns array of attribute translations" do + result = subject.send(:human_attr_translations, :project) + expect(result).to include("project", "Project") + end + + it "includes translations for user language" do + allow(Setting).to receive(:default_language).and_return("en") + result = subject.send(:human_attr_translations, :project) + expect(result).to be_an(Array) + expect(result).not_to be_empty + end + end + + describe "#ignored_filename?" do + before do + allow(Setting).to receive(:mail_handler_ignore_filenames).and_return("signature.asc\n*.tmp") + end + + it "returns true for ignored filenames" do + expect(subject.send(:ignored_filename?, "signature.asc")).to be_truthy + end + + it "returns false for non-ignored filenames" do + expect(subject.send(:ignored_filename?, "document.pdf")).to be_falsey + end + end + + describe "#lookup_case_insensitive_key" do + let(:scope) { class_double(Status) } + let(:status) { build_stubbed(:status) } + let(:options) { { allow_override: [:status], issue: {} } } + let(:plain_text_body) { "Status: resolved".dup } + + before do + allow(scope).to receive(:find_by).with("lower(name) = ?", "resolved").and_return(status) + end + + it "finds record case insensitively" do + result = subject.send(:lookup_case_insensitive_key, scope, :status) + expect(result).to eq(status.id) + end + + it "returns nil when keyword not found" do + allow(scope).to receive(:find_by).and_return(nil) + result = subject.send(:lookup_case_insensitive_key, scope, :status) + expect(result).to be_nil + end end end diff --git a/spec/services/incoming_emails/handlers/message_reply_spec.rb b/spec/services/incoming_emails/handlers/message_reply_spec.rb new file mode 100644 index 00000000000..43ab1a4e2e6 --- /dev/null +++ b/spec/services/incoming_emails/handlers/message_reply_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe IncomingEmails::Handlers::MessageReply do + let(:email) { instance_double(Mail::Message, attachments: [], subject: "Email subject") } + let(:user) { build_stubbed(:user) } + let(:reference) { {} } + let(:options) { {} } + let(:plain_text_body) { "Test body" } + + subject(:handler) do + described_class.new(email, user:, reference:, plain_text_body:, options:) + end + + describe ".handles?" do + context "with message reference" do + let(:reference) { { klass: "message", id: 123 } } + + it "returns true" do + expect(described_class).to be_handles(email, reference:) + end + end + + context "without message reference" do + let(:reference) { {} } + + it "returns false" do + expect(described_class).not_to be_handles(email, reference:) + end + end + + context "with work package reference" do + let(:reference) { { klass: "work_packages", id: 123 } } + + it "returns false" do + expect(described_class).not_to be_handles(email, reference:) + end + end + end + + describe "#process" do + let(:project) { create(:project) } + let(:forum) { create(:forum, project:) } + let(:message) { create(:message, forum:) } + let(:reference) { { klass: "messages", id: message.id } } + + before do + allow(Message).to receive(:find_by).with(id: message.id).and_return(message) + end + + context "when not allowed to create a message" do + it "raises an exception" do + expect { handler.process }.to raise_error(IncomingEmails::UnauthorizedAction) + end + end + + context "when allowed to create a message" do + before do + mock_permissions_for(user) do |mock| + mock.allow_in_project(:add_messages, project:) + end + end + + it "creates a reply message" do + call = handler.process + expect(call).to be_a(ServiceResult) + + message = call.result + expect(message).to be_a(Message) + end + end + + context "when parent message is not found" do + before do + allow(Message).to receive(:find_by).and_return(nil) + end + + it "returns nil" do + result = handler.process + expect(result).to be_nil + end + end + end +end diff --git a/spec/services/incoming_emails/handlers/work_package_spec.rb b/spec/services/incoming_emails/handlers/work_package_spec.rb new file mode 100644 index 00000000000..ec815e83fa0 --- /dev/null +++ b/spec/services/incoming_emails/handlers/work_package_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe IncomingEmails::Handlers::WorkPackage do + let(:email) { instance_double(Mail::Message, attachments: [], subject: "My message") } + let(:user) { build_stubbed(:user) } + let(:reference) { {} } + let(:options) { { issue: { project: "foobar" }, allow_override: [] } } + let(:plain_text_body) { "Test body".dup } + + subject(:handler) do + described_class.new(email, user:, reference:, plain_text_body:, options:) + end + + describe ".handles?" do + context "with work package reference" do + let(:reference) { { klass: "work_package", id: 123 } } + + it "returns true" do + expect(described_class).to be_handles(email, reference:) + end + end + + context "without work package reference" do + let(:reference) { {} } + + it "returns true for new work package creation" do + expect(described_class).to be_handles(email, reference:) + end + end + + context "with message reference" do + let(:reference) { { klass: "messages", id: 123 } } + + it "returns false" do + expect(described_class).not_to be_handles(email, reference:) + end + end + end + + describe "#process" do + let(:service_result) { ServiceResult.success(result: build_stubbed(:work_package)) } + let(:service_instance) { instance_double(WorkPackages::CreateService, call: service_result) } + let(:project) { build_stubbed(:project) } + + before do + allow(WorkPackages::CreateService).to receive(:new).and_return(service_instance) + allow(Project).to receive(:find_by).with(identifier: "foobar").and_return(project) + end + + it "creates a work package" do + result = handler.process + expect(WorkPackages::CreateService).to have_received(:new) + expect(result).to be_a(ServiceResult) + end + + context "when work package creation fails" do + let(:errors) { ActiveModel::Errors.new(nil) } + let(:service_result) { ServiceResult.failure(errors:) } + + it "returns the failed result" do + result = handler.process + expect(result).to be_a(ServiceResult) + expect(result).not_to be_success + expect(result.errors).to eq(errors) + end + end + end +end diff --git a/spec/services/incoming_emails/mail_handler_integration_spec.rb b/spec/services/incoming_emails/mail_handler_integration_spec.rb new file mode 100644 index 00000000000..4b10ad44e11 --- /dev/null +++ b/spec/services/incoming_emails/mail_handler_integration_spec.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe IncomingEmails::MailHandler, "integration", type: :service do + # Integration tests to ensure backwards compatibility after refactoring + + shared_let(:project) { create(:valid_project, identifier: "test-project") } + shared_let(:user) { create(:user, mail: "test@example.com") } + shared_let(:priority) { create(:priority_low, is_default: true) } + + describe "MailHandler.receive" do + let(:raw_email) do + <<~EMAIL + From: test@example.com + To: project@example.com + Subject: Test Work Package + Content-Type: text/plain + + Project: #{project.identifier} + + This is a test work package created via email. + EMAIL + end + + it "still processes emails through the old API" do + result = described_class.receive(raw_email) + + expect(result).to be_a(WorkPackage) + expect(result.subject).to eq("Test Work Package") + expect(result.project).to eq(project) + expect(result.author).to eq(user) + expect(result.description).to include("This is a test work package created via email.") + end + + it "handles options parameter" do + result = described_class.receive(raw_email, { allow_override: "priority" }) + + expect(result).to be_a(WorkPackage) + expect(result.project).to eq(project) + end + + context "with unknown user" do + let(:unknown_email) do + <<~EMAIL + From: unknown@example.com + To: project@example.com + Subject: Test from unknown + Content-Type: text/plain + + Project: #{project.identifier} + + This is from an unknown user. + EMAIL + end + + it "handles unknown_user option, but does not skip permission checks" do + result = described_class.receive(unknown_email, { unknown_user: "accept" }) + expect(result).to be_a(WorkPackage) + expect(result.project).to eq(project) + + expect(result.errors.symbols_for(:base)).to contain_exactly(:error_unauthorized) + end + end + + context "with auto-reply headers" do + let(:auto_reply_email) do + <<~EMAIL + From: test@example.com + To: project@example.com + Subject: Test Auto Reply + X-Auto-Response-Suppress: OOF + Content-Type: text/plain + + Project: #{project.identifier} + + This is an auto-reply. + EMAIL + end + + it "ignores auto-reply emails" do + result = described_class.receive(auto_reply_email) + + expect(result).to be_nil + end + end + + context "with work package reference" do + let!(:work_package) { create(:work_package, project:) } + let(:options) { {} } + let(:reply_email) do + <<~EMAIL + From: test@example.com + To: project@example.com + Subject: Re: #{work_package.subject} + References: + Content-Type: text/plain + + This is a reply to the work package. + EMAIL + end + + subject { described_class.receive(reply_email, options) } + + shared_examples "successful work package reply" do + it "processes work package replies" do + expect { subject }.to change(Journal, :count).by(1) + + expect(subject).to be_a(Journal) + expect(subject.journable).to eq(work_package) + expect(subject.notes).to include("This is a reply to the work package.") + end + end + + context "with permission" do + let!(:role) { create(:project_role, permissions: %i[view_work_packages add_work_package_comments]) } + let!(:member) { create(:member, project:, user:, roles: [role]) } + + it_behaves_like "successful work package reply" + end + + context "without permission" do + it "fails to process" do + expect { subject }.not_to change(Journal, :count) + end + end + + context "without permission, but no_permission_check set" do + let(:options) do + { no_permission_check: true } + end + + it_behaves_like "successful work package reply" + end + end + + context "with message reference" do + let(:options) { {} } + let!(:message) { create(:message, forum: create(:forum, project:)) } + let(:message_reply_email) do + <<~EMAIL + From: test@example.com + To: project@example.com + Subject: Re: #{message.subject} + References: + Content-Type: text/plain + + This is a reply to the message. + EMAIL + end + + subject { described_class.receive(message_reply_email, options) } + + shared_examples "successful message reply" do + it "processes message replies" do + expect { subject }.to change(Message, :count).by(1) + + expect(subject).to be_a(Message) + expect(subject.parent).to eq(message) + expect(subject.content).to include("This is a reply to the message.") + end + end + + context "with permission" do + let!(:role) { create(:project_role, permissions: [:add_messages]) } + let!(:member) { create(:member, project:, user:, roles: [role]) } + + it_behaves_like "successful message reply" + end + + context "without permission" do + it "fails to process" do + expect { subject }.not_to change(Message, :count) + expect(subject).to be_nil + end + end + + context "without permission, but no_permission_check set" do + let(:options) do + { no_permission_check: true } + end + + it_behaves_like "successful message reply" + end + end + end + + describe "Error handling" do + let(:invalid_email) do + <<~EMAIL + From: test@example.com + To: project@example.com + Subject: Invalid Email + Content-Type: text/plain + + Project: nonexistent-project + + This references a nonexistent project. + EMAIL + end + + it "handles errors gracefully" do + expect { described_class.receive(invalid_email) }.not_to raise_error + end + end + + describe "Handler registration" do + let(:custom_handler) do + Class.new(IncomingEmails::Handlers::Base) do + def self.handles?(email, **) + email.subject&.include?("CUSTOM") + end + + def process + ServiceResult.success(message: "Custom handler processed", result: "foo result") + end + end + end + + let(:custom_email) do + <<~EMAIL + From: test@example.com + To: project@example.com + Subject: CUSTOM Test Email + Content-Type: text/plain + + This should be handled by the custom handler. + EMAIL + end + + it "allows custom handlers to be registered" do + IncomingEmails::DispatchService.register_handler(custom_handler) + + result = described_class.receive(custom_email.dup) + + expect(result).to eq("foo result") + end + end +end diff --git a/spec/models/mail_handler/user_creator_spec.rb b/spec/services/incoming_emails/user_creator_spec.rb similarity index 98% rename from spec/models/mail_handler/user_creator_spec.rb rename to spec/services/incoming_emails/user_creator_spec.rb index 51ca264b90a..bf16adac9ee 100644 --- a/spec/models/mail_handler/user_creator_spec.rb +++ b/spec/services/incoming_emails/user_creator_spec.rb @@ -28,7 +28,7 @@ require "spec_helper" -RSpec.describe MailHandler::UserCreator do +RSpec.describe IncomingEmails::UserCreator do describe ".new_user_from_attributes" do context "with sufficient information" do # [address, name] => [login, firstname, lastname]