From 7e6e751d69811836299e4a59d70156253865f2db Mon Sep 17 00:00:00 2001 From: Rolf Schmidt Date: Fri, 13 Aug 2021 14:39:09 +0200 Subject: [PATCH] Fixes #3695 - Detect Jira follow-ups. --- .../channel/filter/base_external_check.rb | 94 +++++++++++++++++++ app/models/channel/filter/jira_check.rb | 7 ++ .../channel/filter/service_now_check.rb | 86 +---------------- db/migrate/20210812000001_jira_config.rb | 30 ++++++ db/seeds/settings.rb | 18 ++++ spec/models/channel/email_parser_spec.rb | 44 +++++++++ test/data/mail/mail103.box | 18 ++++ test/data/mail/mail104.box | 18 ++++ 8 files changed, 233 insertions(+), 82 deletions(-) create mode 100644 app/models/channel/filter/base_external_check.rb create mode 100644 app/models/channel/filter/jira_check.rb create mode 100644 db/migrate/20210812000001_jira_config.rb create mode 100644 test/data/mail/mail103.box create mode 100644 test/data/mail/mail104.box diff --git a/app/models/channel/filter/base_external_check.rb b/app/models/channel/filter/base_external_check.rb new file mode 100644 index 000000000..e2710f068 --- /dev/null +++ b/app/models/channel/filter/base_external_check.rb @@ -0,0 +1,94 @@ +# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ + +class Channel::Filter::BaseExternalCheck + + # required mail header to start the detection + MAIL_HEADER = 'x-example-header'.freeze + + # regex to detect the id in the subject of the email + SOURCE_ID_REGEX = %r{\s(EXAMPLE-MATCH\d+)\s}.freeze + + # External sync references source name prefix + SOURCE_NAME_PREFIX = 'Example'.freeze + + # This filter will run pre and post + def self.run(_channel, mail, ticket_or_transaction_params = nil, _article = nil, _session_user = nil) + return if mail[const_get(:MAIL_HEADER)].blank? + + source_id = self.source_id(subject: mail[:subject]) + return if source_id.blank? + + source_name = self.source_name(from: mail[:from]) + return if source_name.blank? + + # check if we can followup by existing service now relation + if ticket_or_transaction_params.blank? || ticket_or_transaction_params.is_a?(Hash) + from_sync_entry( + mail: mail, + source_name: source_name, + source_id: source_id, + ) + return + end + + ExternalSync.create_with(source_id: source_id).find_or_create_by(source: source_name, object: 'Ticket', o_id: ticket_or_transaction_params.id) + end + +=begin + +This function returns the source id of the service now email if given. + + source_id = Channel::Filter::ServiceNowCheck.source_id( + from: 'test@service-now.com', + subject: 'Incident INC12345 --- test', + ) + +returns: + + source_id = 'INC12345' + +=end + + def self.source_id(subject: '') + + # check if we can find the service now relation + source_id = nil + if subject =~ const_get(:SOURCE_ID_REGEX) + source_id = $1 + end + + source_id + end + +=begin + +This function returns the sync id of the service now email if given. + + source_name = Channel::Filter::ServiceNowCheck.source_name( + from: 'test@service-now.com', + ) + +returns: + + source_name = 'ServiceNow-test@service-now.com' + +=end + + def self.source_name(from:) + address = Mail::AddressList.new(from).addresses.first.address.downcase + "#{const_get(:SOURCE_NAME_PREFIX)}-#{address}" + rescue => e + Rails.logger.info "Unable to parse email address in '#{from}': #{e.message}" + end + + def self.from_sync_entry(mail:, source_name:, source_id:) + sync_entry = ExternalSync.find_by( + source: source_name, + source_id: source_id, + object: 'Ticket', + ) + return if sync_entry.blank? + + mail[ :'x-zammad-ticket-id' ] = sync_entry.o_id + end +end diff --git a/app/models/channel/filter/jira_check.rb b/app/models/channel/filter/jira_check.rb new file mode 100644 index 000000000..3575ae959 --- /dev/null +++ b/app/models/channel/filter/jira_check.rb @@ -0,0 +1,7 @@ +# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ + +class Channel::Filter::JiraCheck < Channel::Filter::BaseExternalCheck + MAIL_HEADER = 'x-jira-fingerprint'.freeze + SOURCE_ID_REGEX = %r{\[JIRA\]\s\((\w+-\d+)\)}.freeze + SOURCE_NAME_PREFIX = 'Jira'.freeze +end diff --git a/app/models/channel/filter/service_now_check.rb b/app/models/channel/filter/service_now_check.rb index e7322397b..cd18a8d3f 100644 --- a/app/models/channel/filter/service_now_check.rb +++ b/app/models/channel/filter/service_now_check.rb @@ -1,85 +1,7 @@ # Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ -module Channel::Filter::ServiceNowCheck - - # This filter will run pre and post - def self.run(_channel, mail, ticket_or_transaction_params = nil, _article = nil, _session_user = nil) - return if mail['x-servicenow-generated'].blank? - - source_id = self.source_id(subject: mail[:subject]) - return if source_id.blank? - - source_name = self.source_name(from: mail[:from]) - return if source_name.blank? - - # check if we can followup by existing service now relation - if ticket_or_transaction_params.blank? || ticket_or_transaction_params.is_a?(Hash) - from_sync_entry( - mail: mail, - source_name: source_name, - source_id: source_id, - ) - return - end - - ExternalSync.create_with(source_id: source_id).find_or_create_by(source: source_name, object: 'Ticket', o_id: ticket_or_transaction_params.id) - end - -=begin - -This function returns the source id of the service now email if given. - - source_id = Channel::Filter::ServiceNowCheck.source_id( - from: 'test@service-now.com', - subject: 'Incident INC12345 --- test', - ) - -returns: - - source_id = 'INC12345' - -=end - - def self.source_id(subject: '') - - # check if we can find the service now relation - source_id = nil - if subject =~ %r{\s(INC\d+)\s} - source_id = $1 - end - - source_id - end - -=begin - -This function returns the sync id of the service now email if given. - - source_name = Channel::Filter::ServiceNowCheck.source_name( - from: 'test@service-now.com', - ) - -returns: - - source_name = 'ServiceNow-test@service-now.com' - -=end - - def self.source_name(from:) - address = Mail::AddressList.new(from).addresses.first.address.downcase - "ServiceNow-#{address}" - rescue => e - Rails.logger.info "Unable to parse email address in '#{from}': #{e.message}" - end - - def self.from_sync_entry(mail:, source_name:, source_id:) - sync_entry = ExternalSync.find_by( - source: source_name, - source_id: source_id, - object: 'Ticket', - ) - return if sync_entry.blank? - - mail[ :'x-zammad-ticket-id' ] = sync_entry.o_id - end +class Channel::Filter::ServiceNowCheck < Channel::Filter::BaseExternalCheck + MAIL_HEADER = 'x-servicenow-generated'.freeze + SOURCE_ID_REGEX = %r{\s(INC\d+)\s}.freeze + SOURCE_NAME_PREFIX = 'ServiceNow'.freeze end diff --git a/db/migrate/20210812000001_jira_config.rb b/db/migrate/20210812000001_jira_config.rb new file mode 100644 index 000000000..49ce18ec1 --- /dev/null +++ b/db/migrate/20210812000001_jira_config.rb @@ -0,0 +1,30 @@ +# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/ + +class JiraConfig < ActiveRecord::Migration[4.2] + def up + + # return if it's a new setup + return if !Setting.exists?(name: 'system_init_done') + + Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5400_postmaster_filter_jira_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify jira mails for correct follow-ups.', + options: {}, + state: 'Channel::Filter::JiraCheck', + frontend: false + ) + + Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5401_postmaster_filter_jira_check', + area: 'Postmaster::PostFilter', + description: 'Defines postmaster filter to identify jira mails for correct follow-ups.', + options: {}, + state: 'Channel::Filter::JiraCheck', + frontend: false + ) + end + +end diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index 6f3b761e4..6674947d9 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -3538,6 +3538,24 @@ Setting.create_if_not_exists( state: 'Channel::Filter::ServiceNowCheck', frontend: false ) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5400_postmaster_filter_jira_check', + area: 'Postmaster::PreFilter', + description: 'Defines postmaster filter to identify jira mails for correct follow-ups.', + options: {}, + state: 'Channel::Filter::JiraCheck', + frontend: false +) +Setting.create_if_not_exists( + title: 'Defines postmaster filter.', + name: '5401_postmaster_filter_jira_check', + area: 'Postmaster::PostFilter', + description: 'Defines postmaster filter to identify jira mails for correct follow-ups.', + options: {}, + state: 'Channel::Filter::JiraCheck', + frontend: false +) Setting.create_if_not_exists( title: 'Define postmaster filter.', name: '5500_postmaster_internal_article_check', diff --git a/spec/models/channel/email_parser_spec.rb b/spec/models/channel/email_parser_spec.rb index 425356b2f..528b37c45 100644 --- a/spec/models/channel/email_parser_spec.rb +++ b/spec/models/channel/email_parser_spec.rb @@ -1169,6 +1169,50 @@ RSpec.describe Channel::EmailParser, type: :model do end end + describe 'Jira handling' do + + context 'new Ticket' do + let(:mail_file) { Rails.root.join('test/data/mail/mail103.box') } + + it 'creates an ExternalSync reference' do + described_class.new.process({}, raw_mail) + + expect(ExternalSync.last).to have_attributes( + source: 'Jira-example@jira.com', + source_id: 'SYS-422', + object: 'Ticket', + o_id: Ticket.last.id, + ) + end + end + + context 'follow up' do + + let(:mail_file) { Rails.root.join('test/data/mail/mail104.box') } + let(:ticket) { create(:ticket) } + let!(:external_sync) do + create(:external_sync, + source: 'Jira-example@jira.com', + source_id: 'SYS-422', + object: 'Ticket', + o_id: ticket.id,) + end + + it 'adds Article to existing Ticket' do + expect { described_class.new.process({}, raw_mail) }.to change { ticket.reload.articles.count } + end + + context 'key insensitive sender address' do + + let(:raw_mail) { super().gsub('example@service-now.com', 'Example@Service-Now.com') } + + it 'adds Article to existing Ticket' do + expect { described_class.new.process({}, raw_mail) }.to change { ticket.reload.articles.count } + end + end + end + end + describe 'XSS protection' do let(:article) { described_class.new.process({}, raw_mail).second } diff --git a/test/data/mail/mail103.box b/test/data/mail/mail103.box new file mode 100644 index 000000000..a69bcb94a --- /dev/null +++ b/test/data/mail/mail103.box @@ -0,0 +1,18 @@ +From: IT example +To: support@example.com +Message-ID: <18659453.58107.1576665116411@app129169.gva3.jira.com> +Subject: [JIRA] (SYS-422) test zammad 3 +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_Part_58105_29005161.1576665006397" +X-JIRA-FingerPrint: 978a1eb52201518931e77cb5725717ff + +------=_Part_58105_29005161.1576665006397 +Content-Type: multipart/alternative; + boundary="----=_Part_58106_4969544.1576665006398" + +------=_Part_58106_4969544.1576665006398 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Incident content diff --git a/test/data/mail/mail104.box b/test/data/mail/mail104.box new file mode 100644 index 000000000..9ad504140 --- /dev/null +++ b/test/data/mail/mail104.box @@ -0,0 +1,18 @@ +From: IT example +To: support@example.com +Message-ID: <18659453.58107.1576665116411@app129169.gva3.jira.com> +Subject: [JIRA] (SYS-422) booob +MIME-Version: 1.0 +Content-Type: multipart/mixed; + boundary="----=_Part_58105_29005161.1576665006397" +X-JIRA-FingerPrint: 978a1eb52201518931e77cb5725717ff + +------=_Part_58105_29005161.1576665006397 +Content-Type: multipart/alternative; + boundary="----=_Part_58106_4969544.1576665006398" + +------=_Part_58106_4969544.1576665006398 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Incident content