Просмотр исходного кода

Maintenance: Improves email drivers.

- Added POP3 driver test.
- Improved IMAP driver test.
- Unified repeating code into a base class.
- Structural code improvements, increasing maintainability.
Mantas Masalskis 1 месяц назад
Родитель
Сommit
ef47d6c1ed

+ 0 - 12
.dev/rubocop/todo.yml

@@ -52,9 +52,6 @@ Metrics/AbcSize:
     - 'app/models/calendar.rb'
     - 'app/models/channel.rb'
     - 'app/models/channel/assets.rb'
-    - 'app/models/channel/driver/imap.rb'
-    - 'app/models/channel/driver/pop3.rb'
-    - 'app/models/channel/driver/smtp.rb'
     - 'app/models/channel/driver/twitter.rb'
     - 'app/models/channel/email_build.rb'
     - 'app/models/channel/email_parser.rb'
@@ -178,8 +175,6 @@ Metrics/BlockLength:
     - 'app/models/application_model/can_creates_and_updates.rb'
     - 'app/models/application_model/can_lookup.rb'
     - 'app/models/channel.rb'
-    - 'app/models/channel/driver/imap.rb'
-    - 'app/models/channel/driver/pop3.rb'
     - 'app/models/channel/email_parser.rb'
     - 'app/models/channel/filter/bounce_delivery_permanent_failed.rb'
     - 'app/models/channel/filter/database.rb'
@@ -275,9 +270,6 @@ Metrics/CyclomaticComplexity:
     - 'app/models/calendar.rb'
     - 'app/models/channel.rb'
     - 'app/models/channel/assets.rb'
-    - 'app/models/channel/driver/imap.rb'
-    - 'app/models/channel/driver/pop3.rb'
-    - 'app/models/channel/driver/smtp.rb'
     - 'app/models/channel/driver/twitter.rb'
     - 'app/models/channel/email_build.rb'
     - 'app/models/channel/email_parser.rb'
@@ -405,9 +397,6 @@ Metrics/PerceivedComplexity:
     - 'app/models/calendar.rb'
     - 'app/models/channel.rb'
     - 'app/models/channel/assets.rb'
-    - 'app/models/channel/driver/imap.rb'
-    - 'app/models/channel/driver/pop3.rb'
-    - 'app/models/channel/driver/smtp.rb'
     - 'app/models/channel/driver/twitter.rb'
     - 'app/models/channel/email_build.rb'
     - 'app/models/channel/email_parser.rb'
@@ -504,7 +493,6 @@ Style/OptionalBooleanParameter:
     - 'app/models/channel/driver/sms/massenversand.rb'
     - 'app/models/channel/driver/sms/message_bird.rb'
     - 'app/models/channel/driver/sms/twilio.rb'
-    - 'app/models/channel/driver/smtp.rb'
     - 'app/models/channel/driver/telegram.rb'
     - 'app/models/channel/driver/twitter.rb'
     - 'app/models/channel/driver/whatsapp.rb'

+ 128 - 69
app/models/channel/driver/base_email_inbound.rb

@@ -1,17 +1,9 @@
 # Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
 class Channel::Driver::BaseEmailInbound < Channel::EmailParser
-  def fetch(_options, _channel)
-    raise 'not implemented'
-  end
+  ACTIVE_CHECK_INTERVAL = 20
 
-  def check(_options)
-    raise 'not implemented'
-  end
-
-  def verify(_options, _verify_string)
-    raise 'not implemented'
-  end
+  MessageResult = Struct.new(:success, :after_action, keyword_init: true)
 
   def fetchable?(_channel)
     true
@@ -35,86 +27,153 @@ class Channel::Driver::BaseEmailInbound < Channel::EmailParser
     true
   end
 
-  # Checks if email is not too big for processing
+  # Fetches emails
+  #
+  # @param options [Hash]. See subclass for options
+  # @param channel [Channel]
   #
-  # @param [Integer] size in bytes
+  # @return [Hash]
   #
-  # This method is used by IMAP and MicrosoftGraphInbound only
-  # It may be possible to reuse them with POP3 too, but it needs further refactoring
-  def too_large?(message_meta_size)
-    max_message_size = Setting.get('postmaster_max_size').to_f
-    real_message_size = message_meta_size.to_f / 1024 / 1024
-    if real_message_size > max_message_size
-      return [real_message_size, max_message_size]
+  #  {
+  #    result: 'ok',
+  #    fetched: 123,
+  #    notice: 'e. g. message about to big emails in mailbox',
+  #  }
+  def fetch(options, channel)
+    @channel        = channel
+    @options        = options
+    @keep_on_server = ActiveModel::Type::Boolean.new.cast(options[:keep_on_server])
+
+    setup_connection(options)
+
+    collection, count_all = messages_iterator(@keep_on_server, options)
+    count_fetched         = 0
+    too_large_messages    = []
+    result                = 'ok'
+    notice                = ''
+
+    collection.each.with_index(1) do |message_id, count|
+      break if stop_fetching?(count)
+
+      Rails.logger.info " - message #{count}/#{count_all}"
+
+      message_result = fetch_single_message(message_id, count, count_all)
+
+      count_fetched += 1 if message_result.success
+
+      case message_result.after_action
+      in [:too_large_ignored, message]
+        notice += message
+        too_large_messages << message
+      in [:notice, message]
+        notice += message
+      in [:result_error, message]
+        result = 'error'
+        notice += message
+      else
+      end
     end
 
-    false
-  end
+    fetch_wrap_up
 
-  # Checks if a message with the given headers is a Zammad verify message
-  #
-  # This method is used by IMAP and MicrosoftGraphInbound only
-  # It may be possible to reuse them with POP3 too, but it needs further refactoring
-  def messages_is_verify_message?(headers)
-    return true if headers['X-Zammad-Verify'] == 'true'
+    if count_all.zero?
+      Rails.logger.info ' - no message'
+    end
 
-    false
-  end
+    # Error is raised if one of the messages was too large AND postmaster_send_reject_if_mail_too_large is turned off.
+    # This effectivelly marks channels as stuck and gets highlighted for the admin.
+    # New emails are still processed! But large email is not touched, so error keeps being re-raised on every fetch.
+    if too_large_messages.present?
+      raise too_large_messages.join("\n")
+    end
 
-  # Checks if a message with the given headers marked to be ignored by Zammad
-  #
-  # This method is used by IMAP and MicrosoftGraphInbound only
-  # It may be possible to reuse them with POP3 too, but it needs further refactoring
-  def messages_is_ignore_message?(headers)
-    return true if headers['X-Zammad-Ignore'] == 'true'
+    {
+      result:  result,
+      fetched: count_fetched,
+      notice:  notice,
+    }
+  end
 
-    false
+  def stop_fetching?(count)
+    (count % ACTIVE_CHECK_INTERVAL).zero? && channel_has_changed?(@channel)
   end
 
-  # Checks if a message is an old Zammad verify message
+  def fetch_wrap_up; end
+
+  # Checks if mailbox has anything besides Zammad verification emails.
+  # If any real messages exists, return the real count including messages to be ignored when importing.
+  # If only verification messages found, return 0.
   #
-  # Returns false only if a verify message is less than 30 minutes old
+  # @param options [Hash] driver-specific server setup. See #fetch in the corresponding driver.
   #
-  # This method is used by IMAP and MicrosoftGraphInbound only
-  # It may be possible to reuse them with POP3 too, but it needs further refactoring
-  def messages_is_too_old_verify?(headers, count, count_all)
-    return true if !messages_is_verify_message?(headers)
-    return true if headers['X-Zammad-Verify-Time'].blank?
-
-    begin
-      verify_time = Time.zone.parse(headers['X-Zammad-Verify-Time'])
-    rescue => e
-      Rails.logger.error e
-      return true
-    end
-    return true if verify_time < 30.minutes.ago
+  # @return [Hash]
+  #
+  # {
+  #   result: 'ok'
+  #   content_messages: 123 # or 0 if there're none
+  # }
+  def check_configuration(options)
+    setup_connection(options, check: true)
 
-    Rails.logger.info "  - ignore message #{count}/#{count_all} - because message has a verify message"
+    Rails.logger.info 'check only mode, fetch no emails'
 
-    false
+    collection, count_all = messages_iterator(false, options)
+
+    has_content_messages = collection
+      .any? do |message_id|
+        validator = check_single_message(message_id)
+
+        next if !validator
+
+        !validator.verify_message? && !validator.ignore?
+      end
+
+    disconnect
+
+    {
+      result:           'ok',
+      content_messages: has_content_messages ? count_all : 0,
+    }
   end
 
-  # Checks if a message is already imported in a given channel
-  # This check is skipped for channels which do not keep messages on the server
+  # Checks if probing email has arrived
   #
-  # This method is used by IMAP and MicrosoftGraphInbound only
-  # It may be possible to reuse them with POP3 too, but it needs further refactoring
-  def already_imported?(headers, keep_on_server, channel)
-    return false if !keep_on_server
+  # This method is used for custom IMAP only.
+  # It is not used in conjunction with Micrsofot365 or Gogle OAuth channels.
+  #
+  # @param options [Hash] driver-specific server setup. See #fetch in the corresponding driver.
+  # @param verify_string [String] to check with
+  #
+  # @return [Hash]
+  #
+  # {
+  #   result: 'ok' # or 'verify not ok' in case of failure
+  # }
+  def verify_transport(options, verify_string)
+    setup_connection(options)
+
+    collection, _count_all = messages_iterator(false, options, reverse: true)
 
-    return false if !headers
+    Rails.logger.info "verify mode, fetch no emails #{verify_string}"
 
-    local_message_id = headers['Message-ID']
-    return false if local_message_id.blank?
+    verify_regexp = %r{#{verify_string}}
 
-    local_message_id_md5 = Digest::MD5.hexdigest(local_message_id)
-    article = Ticket::Article.where(message_id_md5: local_message_id_md5).reorder('created_at DESC, id DESC').limit(1).first
-    return false if !article
+    # check for verify message
+    verify_message_id = collection.find do |message_id|
+      verify_single_message(message_id, verify_regexp)
+    end
 
-    # verify if message is already imported via same channel, if not, import it again
-    ticket = article.ticket
-    return false if ticket&.preferences && ticket.preferences[:channel_id].present? && channel.present? && ticket.preferences[:channel_id] != channel[:id]
+    result = if verify_message_id
+               Rails.logger.info " - verify email #{verify_string} found"
+               verify_message_cleanup(verify_message_id)
 
-    true
+               'ok'
+             else
+               'verify not ok'
+             end
+
+    disconnect
+
+    { result:, }
   end
 end

+ 88 - 0
app/models/channel/driver/base_email_inbound/message_validator.rb

@@ -0,0 +1,88 @@
+# Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
+
+class Channel::Driver::BaseEmailInbound
+  class MessageValidator
+    attr_reader :headers, :size
+
+    # @param headers [Hash] in key-value format
+    # @param size [Integer] in bytes
+    def initialize(headers, size = nil)
+      @headers = headers
+      @size    = size
+    end
+
+    # Checks if email is not too big for processing
+    #
+    # This method is used by IMAP and MicrosoftGraphInbound only
+    # It may be possible to reuse them with POP3 too, but it needs further refactoring
+    def too_large?
+      max_message_size = Setting.get('postmaster_max_size').to_f
+      real_message_size = size.to_f / 1024 / 1024
+      if real_message_size > max_message_size
+        return [real_message_size, max_message_size]
+      end
+
+      false
+    end
+
+    # Checks if a message with the given headers is a Zammad verify message
+    #
+    # This method is used by IMAP and MicrosoftGraphInbound only
+    # It may be possible to reuse them with POP3 too, but it needs further refactoring
+    def verify_message?
+      headers['X-Zammad-Verify'] == 'true'
+    end
+
+    # Checks if a message with the given headers marked to be ignored by Zammad
+    #
+    # This method is used by IMAP and MicrosoftGraphInbound only
+    # It may be possible to reuse them with POP3 too, but it needs further refactoring
+    def ignore?
+      headers['X-Zammad-Ignore'] == 'true'
+    end
+
+    # Checks if a message is a new Zammad verify message
+    #
+    # Returns false only if a verify message is less than 30 minutes old
+    #
+    # This method is used by IMAP and MicrosoftGraphInbound only
+    # It may be possible to reuse them with POP3 too, but it needs further refactoring
+    def fresh_verify_message?
+      return false if !verify_message?
+      return false if headers['X-Zammad-Verify-Time'].blank?
+
+      begin
+        verify_time = Time.zone.parse(headers['X-Zammad-Verify-Time'])
+      rescue => e
+        Rails.logger.error e
+        return false
+      end
+
+      verify_time > 30.minutes.ago
+    end
+
+    # Checks if a message is already imported in a given channel
+    # This check is skipped for channels which do not keep messages on the server
+    #
+    # This method is used by IMAP and MicrosoftGraphInbound only
+    # It may be possible to reuse them with POP3 too, but it needs further refactoring
+    def already_imported?(keep_on_server, channel)
+      return false if !keep_on_server
+
+      return false if !headers
+
+      local_message_id = headers['Message-ID']
+      return false if local_message_id.blank?
+
+      local_message_id_md5 = Digest::MD5.hexdigest(local_message_id)
+      article = Ticket::Article.where(message_id_md5: local_message_id_md5).reorder('created_at DESC, id DESC').limit(1).first
+      return false if !article
+
+      # verify if message is already imported via same channel, if not, import it again
+      ticket = article.ticket
+      return false if ticket&.preferences && ticket.preferences[:channel_id].present? && channel.present? && ticket.preferences[:channel_id] != channel[:id]
+
+      true
+    end
+  end
+end

+ 1 - 6
app/models/channel/driver/base_email_outbound.rb

@@ -3,11 +3,6 @@
 class Channel::Driver::BaseEmailOutbound
   include Channel::EmailHelper
 
-  # We're using the same timeouts like in Net::SMTP gem
-  # but we would like to have the possibility to mock them for tests
-  DEFAULT_OPEN_TIMEOUT = 30.seconds
-  DEFAULT_READ_TIMEOUT = 60.seconds
-
   def deliver(_options, _attr, _notification = false) # rubocop:disable Style/OptionalBooleanParameter
     raise 'not implemented'
   end
@@ -25,7 +20,7 @@ class Channel::Driver::BaseEmailOutbound
     prepare_idn_outbound(attr)
   end
 
-  def deliver_mail(attr, notification, method, options)
+  def deliver_mail(attr, notification, method, options = nil)
     mail = Channel::EmailBuild.build(attr, notification)
     mail.delivery_method method, options
     mail.deliver

+ 255 - 337
app/models/channel/driver/imap.rb

@@ -5,296 +5,49 @@ require 'net/imap'
 class Channel::Driver::Imap < Channel::Driver::BaseEmailInbound
 
   FETCH_METADATA_TIMEOUT = 2.minutes
-  FETCH_MSG_TIMEOUT = 4.minutes
-  EXPUNGE_TIMEOUT = 16.minutes
-  DEFAULT_TIMEOUT = 45.seconds
-  CHECK_ONLY_TIMEOUT = 8.seconds
-
-=begin
-
-fetch emails from IMAP account
-
-  instance = Channel::Driver::Imap.new
-  result = instance.fetch(params[:inbound][:options], channel, 'verify', subject_looking_for)
-
-returns
-
-  {
-    result: 'ok',
-    fetched: 123,
-    notice: 'e. g. message about to big emails in mailbox',
-  }
-
-check if connect to IMAP account is possible, return count of mails in mailbox
-
-  instance = Channel::Driver::Imap.new
-  result = instance.fetch(params[:inbound][:options], channel, 'check')
-
-returns
-
-  {
-    result: 'ok',
-    content_messages: 123,
-  }
-
-verify IMAP account, check if search email is in there
-
-  instance = Channel::Driver::Imap.new
-  result = instance.fetch(params[:inbound][:options], channel, 'verify', subject_looking_for)
-
-returns
-
-  {
-    result: 'ok', # 'verify not ok'
-  }
-
-example
-
-  params = {
-    host: 'outlook.office365.com',
-    user: 'xxx@zammad.onmicrosoft.com',
-    password: 'xxx',
-    keep_on_server: true,
-  }
-
-  OR
-
-  params = {
-    host: 'imap.gmail.com',
-    user: 'xxx@gmail.com',
-    password: 'xxx',
-    keep_on_server: true,
-    auth_type: 'XOAUTH2'
-  }
-
-  channel = Channel.last
-  instance = Channel::Driver::Imap.new
-  result = instance.fetch(params, channel, 'verify')
-
-=end
-
-  def fetch(options, channel)
-    setup_connection(options)
-
-    keep_on_server = false
-    if [true, 'true'].include?(options[:keep_on_server])
-      keep_on_server = true
-    end
-
-    message_ids_result = Timeout.timeout(6.minutes) do
-      if keep_on_server
-        fetch_unread_message_ids
-      else
-        fetch_all_message_ids
-      end
-    end
-
-    message_ids = message_ids_result[:result]
-
-    # fetch regular messages
-    count_all             = message_ids.count
-    count                 = 0
-    count_fetched         = 0
-    count_max             = 5000
-    too_large_messages    = []
-    active_check_interval = 20
-    result                = 'ok'
-    notice                = ''
-    message_ids.each do |message_id|
-      count += 1
-
-      break if (count % active_check_interval).zero? && channel_has_changed?(channel)
-      break if max_process_count_was_reached?(channel, count, count_max)
-
-      Rails.logger.info " - message #{count}/#{count_all}"
-
-      message_meta = nil
-      Timeout.timeout(FETCH_METADATA_TIMEOUT) do
-        message_meta = @imap.fetch(message_id, ['RFC822.SIZE', 'FLAGS', 'INTERNALDATE', 'RFC822.HEADER'])[0]
-      rescue Net::IMAP::ResponseParseError => e
-        raise if e.message.exclude?('unknown token')
-
-        result = 'error'
-        notice += <<~NOTICE
-          One of your incoming emails could not be imported (#{e.message}).
-          Please remove it from your inbox directly
-          to prevent Zammad from trying to import it again.
-        NOTICE
-        Rails.logger.error "Net::IMAP failed to parse message #{message_id}: #{e.message} (#{e.class})"
-        Rails.logger.error '(See https://github.com/zammad/zammad/issues/2754 for more details)'
-      end
-
-      next if message_meta.nil?
-
-      # ignore verify messages
-      next if !messages_is_too_old_verify?(self.class.extract_rfc822_headers(message_meta), count, count_all)
-
-      # ignore deleted messages
-      next if deleted?(message_meta, count, count_all)
-
-      # ignore already imported
-      if already_imported?(self.class.extract_rfc822_headers(message_meta), keep_on_server, channel)
-        Timeout.timeout(1.minute) do
-          @imap.store(message_id, '+FLAGS', [:Seen])
-        end
-        Rails.logger.info "  - ignore message #{count}/#{count_all} - because message message id already imported"
-
-        next
-      end
-
-      # delete email from server after article was created
-      msg = nil
-      begin
-        Timeout.timeout(FETCH_MSG_TIMEOUT) do
-          key = fetch_message_body_key(options)
-          msg = @imap.fetch(message_id, key)[0].attr[key]
-        end
-      rescue Timeout::Error => e
-        Rails.logger.error "Unable to fetch email from #{count}/#{count_all} from server (#{options[:host]}/#{options[:user]}): #{e.inspect}"
-        raise e
-      end
-      next if !msg
-
-      # do not process too big messages, instead download & send postmaster reply
-      too_large_info = too_large?(message_meta.attr['RFC822.SIZE'])
-      if too_large_info
-        if Setting.get('postmaster_send_reject_if_mail_too_large') == true
-          info = "  - download message #{count}/#{count_all} - ignore message because it's too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB)"
-          Rails.logger.info info
-          notice += "#{info}\n"
-          process_oversized_mail(channel, msg)
-        else
-          info = "  - ignore message #{count}/#{count_all} - because message is too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB)"
-          Rails.logger.info info
-          notice += "#{info}\n"
-          too_large_messages.push info
-          next
-        end
-      else
-        process(channel, msg, false)
-      end
-
-      begin
-        Timeout.timeout(FETCH_MSG_TIMEOUT) do
-          if keep_on_server
-            @imap.store(message_id, '+FLAGS', [:Seen])
-          else
-            @imap.store(message_id, '+FLAGS', [:Deleted])
-          end
-        end
-      rescue Timeout::Error => e
-        Rails.logger.error "Unable to set +FLAGS for email #{count}/#{count_all} on server (#{options[:host]}/#{options[:user]}): #{e.inspect}"
-        raise e
-      end
-      count_fetched += 1
-    end
-
-    if !keep_on_server
-      begin
-        Timeout.timeout(EXPUNGE_TIMEOUT) do
-          @imap.expunge
-        end
-      rescue Timeout::Error => e
-        Rails.logger.error "Unable to expunge server (#{options[:host]}/#{options[:user]}): #{e.inspect}"
-        raise e
-      end
-    end
-    disconnect
-    if count.zero?
-      Rails.logger.info ' - no message'
-    end
-
-    # Error is raised if one of the messages was too large AND postmaster_send_reject_if_mail_too_large is turned off.
-    # This effectivelly marks channels as stuck and gets highlighted for the admin.
-    # New emails are still processed! But large email is not touched, so error keeps being re-raised on every fetch.
-    if too_large_messages.present?
-      raise too_large_messages.join("\n")
-    end
-
-    {
-      result:  result,
-      fetched: count_fetched,
-      notice:  notice,
-    }
-  end
-
-  # Checks if mailbox has anything besides Zammad verification emails.
-  # If any real messages exists, return the real count including messages to be ignored when importing.
-  # If only verification messages found, return 0.
-  def check_configuration(options)
-    setup_connection(options, check: true)
-
-    message_ids_result = Timeout.timeout(6.minutes) do
-      fetch_all_message_ids
-    end
-
-    message_ids = message_ids_result[:result]
-
-    Rails.logger.info 'check only mode, fetch no emails'
-
-    has_content_messages = message_ids
-      .first(5000)
-      .any? do |message_id|
-        message_meta = Timeout.timeout(1.minute) do
-          @imap.fetch(message_id, ['RFC822.HEADER'])[0]
-        end
-
-        # check how many content messages we have, for notice used
-        headers = self.class.extract_rfc822_headers(message_meta)
-
-        !messages_is_verify_message?(headers) && !messages_is_ignore_message?(headers)
-      end
-
-    disconnect
-
-    {
-      result:           'ok',
-      content_messages: has_content_messages ? message_ids.count : 0,
-    }
-  end
-
-  # This method is used for custom IMAP only.
-  # It is not used in conjunction with Micrsofot365 or Gogle OAuth channels.
-  def verify(options, verify_string)
-    setup_connection(options)
-
-    message_ids_result = Timeout.timeout(6.minutes) do
-      fetch_all_message_ids
-    end
-
-    message_ids = message_ids_result[:result]
-
-    Rails.logger.info "verify mode, fetch no emails #{verify_string}"
-
-    # check for verify message
-    message_ids.reverse_each do |message_id|
-
-      message_meta = nil
-      Timeout.timeout(FETCH_METADATA_TIMEOUT) do
-        message_meta = @imap.fetch(message_id, ['RFC822.HEADER'])[0]
-      end
-
-      # check if verify message exists
-      headers = self.class.extract_rfc822_headers(message_meta)
-      subject = headers['Subject']
-      next if !subject
-      next if !subject.match?(%r{#{verify_string}})
-
-      Rails.logger.info " - verify email #{verify_string} found"
-      Timeout.timeout(600) do
-        @imap.store(message_id, '+FLAGS', [:Deleted])
-        @imap.expunge
-      end
-      disconnect
-      return {
-        result: 'ok',
-      }
-    end
-
-    disconnect
-    {
-      result: 'verify not ok',
-    }
+  FETCH_MSG_TIMEOUT      = 4.minutes
+  LIST_MESSAGES_TIMEOUT  = 6.minutes
+  EXPUNGE_TIMEOUT        = 16.minutes
+  DEFAULT_TIMEOUT        = 45.seconds
+  CHECK_ONLY_TIMEOUT     = 8.seconds
+  FETCH_COUNT_MAX        = 5_000
+
+  # Fetches emails from IMAP server
+  #
+  # @param options [Hash]
+  # @option options [String] :folder to fetch emails from
+  # @option options [String] :user to login with
+  # @option options [String] :password to login with
+  # @option options [String] :host
+  # @option options [Integer, String] :port
+  # @option options [Boolean] :ssl_verify
+  # @option options [String] :ssl off to turn off ssl
+  # @option options [String] :auth_type XOAUTH2 for Google/Microsoft365 or fitting authentication type for other
+  # @param channel [Channel]
+  #
+  # @return [Hash]
+  #
+  #  {
+  #    result: 'ok',
+  #    fetched: 123,
+  #    notice: 'e. g. message about to big emails in mailbox',
+  #  }
+  #
+  # @example
+  #
+  #  params = {
+  #    user: 'xxx@example.com',
+  #    password: 'xxx',
+  #    host: 'example'com'
+  #  }
+  #
+  #  channel = Channel.last
+  #  instance = Channel::Driver::Pop3.new
+  #  result = instance.fetch(params, channel)
+  def fetch(...) # rubocop:disable Lint/UselessMethodDefinition
+    # fetch() method is defined in superclass, but options are subclass-specific,
+    #   so define it here for documentation purposes.
+    super
   end
 
   def fetch_all_message_ids
@@ -380,82 +133,247 @@ returns
     true
   end
 
-=begin
+  def setup_connection(options, check: false)
+    server_settings = setup_connection_server_settings(options)
 
-check if maximal fetching email count has reached
+    setup_connection_server_log(server_settings)
 
-  Channel::Driver::IMAP.max_process_count_was_reached?(channel, count, count_max)
+    Certificate::ApplySSLCertificates.ensure_fresh_ssl_context if server_settings[:ssl_or_starttls]
 
-returns
+    # on check, reduce open_timeout to have faster probing
+    timeout = check ? CHECK_ONLY_TIMEOUT : DEFAULT_TIMEOUT
 
-  true|false
+    @imap = Timeout.timeout(timeout) do
+      Net::IMAP.new(server_settings[:host], port: server_settings[:port], ssl: server_settings[:ssl_settings])
+        .tap do |conn|
+          next  if server_settings[:ssl_or_starttls] != :starttls
 
-=end
+          conn.starttls(verify_mode: server_settings[:ssl_verify] ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE)
+        end
+    end
 
-  def max_process_count_was_reached?(channel, count, count_max)
-    return false if count < count_max
+    Timeout.timeout(timeout) do
+      if server_settings[:auth_type].present?
+        @imap.authenticate(server_settings[:auth_type], server_settings[:user], server_settings[:password])
+      else
+        @imap.login(server_settings[:user], server_settings[:password].dup&.force_encoding('ascii-8bit'))
+      end
+    end
 
-    Rails.logger.info "Maximal fetched emails (#{count_max}) reached for this interval for Channel with id #{channel.id}."
-    true
+    Timeout.timeout(timeout) do
+      # select folder
+      @imap.select(server_settings[:folder])
+    end
+
+    @imap
   end
 
-  def setup_connection(options, check: false)
-    ssl            = true
-    ssl_verify     = options.fetch(:ssl_verify, true)
-    starttls       = false
-    keep_on_server = false
-    folder         = 'INBOX'
-    if [true, 'true'].include?(options[:keep_on_server])
-      keep_on_server = true
+  def setup_connection_server_log(server_settings)
+    settings = [
+      "#{server_settings[:host]}/#{server_settings[:user]} port=#{server_settings[:port]}",
+      "ssl=#{server_settings[:ssl_or_starttls] == :ssl}",
+      "starttls=#{server_settings[:ssl_or_starttls] == :starttls}",
+      "folder=#{server_settings[:folder]}",
+      "keep_on_server=#{server_settings[:keep_on_server]}",
+      "auth_type=#{server_settings.fetch(:auth_type, 'LOGIN')}",
+      "ssl_verify=#{server_settings[:ssl_verify]}"
+    ]
+
+    Rails.logger.info "fetching imap (#{settings.join(',')}"
+  end
+
+  def setup_connection_server_settings(options)
+    ssl_or_starttls = setup_connection_ssl_or_starttls(options)
+    ssl_verify      = options.fetch(:ssl_verify, true)
+    ssl_settings    = setup_connection_ssl_settings(ssl_or_starttls, ssl_verify)
+
+    options
+      .slice(:host, :user, :password, :auth_type)
+      .merge(
+        ssl_or_starttls:,
+        ssl_verify:,
+        ssl_settings:,
+        port:            setup_connection_port(options, ssl_or_starttls),
+        folder:          options[:folder].presence || 'INBOX',
+        keep_on_server:  ActiveModel::Type::Boolean.new.cast(options[:keep_on_server]),
+      )
+  end
+
+  def setup_connection_ssl_settings(ssl_or_starttls, ssl_verify)
+    if ssl_or_starttls != :ssl
+      false
+    elsif ssl_verify
+      true
+    else
+      { verify_mode: OpenSSL::SSL::VERIFY_NONE }
     end
+  end
 
+  def setup_connection_ssl_or_starttls(options)
     case options[:ssl]
     when 'off'
-      ssl = false
+      false
     when 'starttls'
-      ssl = false
-      starttls = true
+      :starttls
+    else
+      :ssl
     end
+  end
 
-    port = if options.key?(:port) && options[:port].present?
-             options[:port].to_i
-           elsif ssl == true
-             993
-           else
-             143
-           end
+  def setup_connection_port(options, ssl_or_starttls)
+    if options.key?(:port) && options[:port].present?
+      options[:port].to_i
+    elsif ssl_or_starttls == :ssl
+      993
+    else
+      143
+    end
+  end
 
-    if options[:folder].present?
-      folder = options[:folder]
+  def fetch_single_message(message_id, count, count_all) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+    message_meta = Timeout.timeout(FETCH_METADATA_TIMEOUT) do
+      @imap.fetch(message_id, ['RFC822.SIZE', 'FLAGS', 'INTERNALDATE', 'RFC822.HEADER'])[0]
+    rescue Net::IMAP::ResponseParseError => e
+      raise if e.message.exclude?('unknown token')
+
+      notice += <<~NOTICE
+        One of your incoming emails could not be imported (#{e.message}).
+        Please remove it from your inbox directly
+        to prevent Zammad from trying to import it again.
+      NOTICE
+      Rails.logger.error "Net::IMAP failed to parse message #{message_id}: #{e.message} (#{e.class})"
+      Rails.logger.error '(See https://github.com/zammad/zammad/issues/2754 for more details)'
+
+      return MessageResult.new(success: false, after_action: [:result_error, notice])
     end
 
-    Rails.logger.info "fetching imap (#{options[:host]}/#{options[:user]} port=#{port},ssl=#{ssl},starttls=#{starttls},folder=#{folder},keep_on_server=#{keep_on_server},auth_type=#{options.fetch(:auth_type, 'LOGIN')})"
+    return MessageResult.new(sucess: false) if message_meta.nil?
 
-    # on check, reduce open_timeout to have faster probing
-    check_type_timeout = check ? CHECK_ONLY_TIMEOUT : DEFAULT_TIMEOUT
+    message_validator = MessageValidator.new(self.class.extract_rfc822_headers(message_meta), message_meta.attr['RFC822.SIZE'])
 
-    Certificate::ApplySSLCertificates.ensure_fresh_ssl_context if ssl || starttls
+    if message_validator.fresh_verify_message?
+      Rails.logger.info "  - ignore message #{count}/#{count_all} - because message has a verify message"
 
-    Timeout.timeout(check_type_timeout) do
-      ssl_settings = false
-      ssl_settings = (ssl_verify ? true : { verify_mode: OpenSSL::SSL::VERIFY_NONE }) if ssl
-      @imap = ::Net::IMAP.new(options[:host], port: port, ssl: ssl_settings)
-      if starttls
-        @imap.starttls(verify_mode: ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE)
+      return MessageResult.new(sucess: false)
+    end
+
+    # ignore deleted messages
+    if deleted?(message_meta, count, count_all)
+      return MessageResult.new(sucess: false)
+    end
+
+    # ignore already imported
+    if message_validator.already_imported?(@keep_on_server, @channel)
+      Timeout.timeout(1.minute) do
+        @imap.store(message_id, '+FLAGS', [:Seen])
       end
+      Rails.logger.info "  - ignore message #{count}/#{count_all} - because message message id already imported"
+
+      return MessageResult.new(sucess: false)
     end
 
-    Timeout.timeout(check_type_timeout) do
-      if options[:auth_type].present?
-        @imap.authenticate(options[:auth_type], options[:user], options[:password])
+    # delete email from server after article was created
+    msg = begin
+      Timeout.timeout(FETCH_MSG_TIMEOUT) do
+        key = fetch_message_body_key(@options)
+        @imap.fetch(message_id, key)[0].attr[key]
+      end
+    rescue Timeout::Error => e
+      Rails.logger.error "Unable to fetch email from #{count}/#{count_all} from server (#{@options[:host]}/#{@options[:user]}): #{e.inspect}"
+      raise e
+    end
+
+    if !msg
+      return MessageResult.new(sucess: false)
+    end
+
+    # do not process too big messages, instead download & send postmaster reply
+    if (too_large_info = message_validator.too_large?)
+      if Setting.get('postmaster_send_reject_if_mail_too_large') == true
+        info = "  - download message #{count}/#{count_all} - ignore message because it's too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB)"
+        Rails.logger.info info
+        after_action = [:notice, "#{info}\n"]
+        process_oversized_mail(@channel, msg)
       else
-        @imap.login(options[:user], options[:password].dup&.force_encoding('ascii-8bit'))
+        info = "  - ignore message #{count}/#{count_all} - because message is too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB)"
+        Rails.logger.info info
+
+        return MessageResult.new(success: false, after_action: [:too_large_ignored, "#{info}\n"])
       end
+    else
+      process(@channel, msg, false)
     end
 
-    Timeout.timeout(check_type_timeout) do
-      # select folder
-      @imap.select(folder)
+    begin
+      Timeout.timeout(FETCH_MSG_TIMEOUT) do
+        if @keep_on_server
+          @imap.store(message_id, '+FLAGS', [:Seen])
+        else
+          @imap.store(message_id, '+FLAGS', [:Deleted])
+        end
+      end
+    rescue Timeout::Error => e
+      Rails.logger.error "Unable to set +FLAGS for email #{count}/#{count_all} on server (#{@options[:host]}/#{@options[:user]}): #{e.inspect}"
+      raise e
+    end
+
+    MessageResult.new(success: true, after_action: after_action)
+  end
+
+  def messages_iterator(keep_on_server, _options, reverse: false)
+    message_ids_result = Timeout.timeout(LIST_MESSAGES_TIMEOUT) do
+      if keep_on_server
+        fetch_unread_message_ids
+      else
+        fetch_all_message_ids
+      end
+    end
+
+    ids = message_ids_result[:result]
+
+    ids.reverse! if reverse
+
+    [ids.first(FETCH_COUNT_MAX), ids.count]
+  end
+
+  def fetch_wrap_up
+    if !@keep_on_server
+      begin
+        Timeout.timeout(EXPUNGE_TIMEOUT) do
+          @imap.expunge
+        end
+      rescue Timeout::Error => e
+        Rails.logger.error "Unable to expunge server (#{@options[:host]}/#{@options[:user]}): #{e.inspect}"
+        raise e
+      end
+    end
+
+    disconnect
+  end
+
+  def check_single_message(message_id)
+    message_meta = Timeout.timeout(FETCH_METADATA_TIMEOUT) do
+      @imap.fetch(message_id, ['RFC822.HEADER'])[0]
+    end
+
+    MessageValidator.new(self.class.extract_rfc822_headers(message_meta))
+  end
+
+  def verify_single_message(message_id, verify_string)
+    message_meta = Timeout.timeout(FETCH_METADATA_TIMEOUT) do
+      @imap.fetch(message_id, ['RFC822.HEADER'])[0]
+    end
+
+    # check if verify message exists
+    headers = self.class.extract_rfc822_headers(message_meta)
+
+    headers['Subject']&.match?(%r{#{verify_string}})
+  end
+
+  def verify_message_cleanup(message_id)
+    Timeout.timeout(EXPUNGE_TIMEOUT) do
+      @imap.store(message_id, '+FLAGS', [:Deleted])
+      @imap.expunge
     end
   end
 end

+ 108 - 176
app/models/channel/driver/microsoft_graph_inbound.rb

@@ -2,219 +2,151 @@
 
 class Channel::Driver::MicrosoftGraphInbound < Channel::Driver::BaseEmailInbound
 
-=begin
-
-fetch emails from IMAP account
-
-  instance = Channel::Driver::Imap.new
-  result = instance.fetch(params[:inbound][:options], channel, 'verify', subject_looking_for)
-
-returns
-
-  {
-    result: 'ok',
-    fetched: 123,
-    notice: 'e. g. message about to big emails in mailbox',
-  }
-
-check if connect to IMAP account is possible, return count of mails in mailbox
-
-  instance = Channel::Driver::Imap.new
-  result = instance.fetch(params[:inbound][:options], channel, 'check')
-
-returns
-
-  {
-    result: 'ok',
-    content_messages: 123,
-  }
-
-verify IMAP account, check if search email is in there
-
-  instance = Channel::Driver::Imap.new
-  result = instance.fetch(params[:inbound][:options], channel, 'verify', subject_looking_for)
+  # Fetches emails from Microsfot 365 account via Graph API
+  #
+  # @param options [Hash]
+  # @option options [Bool, String] :keep_on_server
+  # @option options [String] :folder_id to fetch emails from
+  # @option options [String] :user to login with
+  # @option options [String] :shared_mailbox optional
+  # @option options [String] :password Graph API access token
+  # @option options [String] :auth_type must be XOAUTH2
+  # @param channel [Channel]
+  #
+  # @return [Hash]
+  #
+  #  {
+  #    result: 'ok',
+  #    fetched: 123,
+  #    notice: 'e. g. message about to big emails in mailbox',
+  #  }
+  #
+  # @example
+  #
+  #  params = {
+  #    user: 'xxx@zammad.onmicrosoft.com',
+  #    password: 'xxx',
+  #    shared_mailbox: 'yyy@zammad.onmicrosoft.com',
+  #    keep_on_server: true,
+  #    auth_type: 'XOAUTH2'
+  #  }
+  #
+  #  channel = Channel.last
+  #  instance = Channel::Driver::MicrosoftGraphInbound.new
+  #  result = instance.fetch(params, channel)
+  def fetch(...) # rubocop:disable Lint/UselessMethodDefinition
+    # fetch() method is defined in superclass, but options are subclass-specific,
+    #   so define it here for documentation purposes.
+    super
+  end
 
-returns
+  # Checks if mailbox has any messages.
+  # It does not check if email is Zammad verification email or not like other drivers due to Graph API limitations.
+  # X-Zammad-Verify and X-Zammad-Ignore headers are removed from mails sent via Graph API.
+  # Thus it's not possible to verify Graph API connection by sending email with such header to yourself.
+  def check_configuration(options)
+    setup_connection(options)
 
-  {
-    result: 'ok', # 'verify not ok'
-  }
+    _collection, count_all = messages_iterator(false, options)
 
-example
+    Rails.logger.info 'check only mode, fetch no emails'
 
-  params = {
-    host: 'outlook.office365.com',
-    user: 'xxx@zammad.onmicrosoft.com',
-    password: 'xxx',
-    keep_on_server: true,
-  }
+    {
+      result:           'ok',
+      content_messages: count_all,
+    }
+  end
 
-  OR
+  def verify_transport(_options, _verify_string)
+    raise 'Microsoft Graph email channel is never verified. Thus this method is not implemented.' # rubocop:disable Zammad/DetectTranslatableString
+  end
 
-  params = {
-    host: 'imap.gmail.com',
-    user: 'xxx@gmail.com',
-    password: 'xxx',
-    keep_on_server: true,
-    auth_type: 'XOAUTH2'
-  }
+  def fetch_single_message(message_id, count, count_all) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
+    message_meta = @graph.get_message_basic_details(message_id)
 
-  channel = Channel.last
-  instance = Channel::Driver::Imap.new
-  result = instance.fetch(params, channel, 'verify')
+    message_validator = MessageValidator.new(message_meta[:headers], message_meta[:size])
 
-=end
+    # ignore fresh verify messages
+    if message_validator.fresh_verify_message?
+      Rails.logger.info "  - ignore message #{count}/#{count_all} - because message has a verify message"
 
-  def fetch(options, channel) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
-    setup_connection(options)
+      return MessageResult.new(success: false)
+    end
 
-    keep_on_server = ActiveModel::Type::Boolean.new.cast(options[:keep_on_server])
+    # ignore already imported
+    if message_validator.already_imported?(@keep_on_server, @channel)
+      begin
+        @graph.mark_message_as_read(message_id)
+        Rails.logger.info "Ignore message #{count}/#{count_all}, because message message id already imported. Graph API Message ID: #{message_id}."
+      rescue MicrosoftGraph::ApiError => e
+        Rails.logger.error "Unable to mark email as read #{count}/#{count_all} from Microsoft Graph server (#{@options[:user]}). Graph API Message ID: #{message_id}. #{e.inspect}"
+        raise e
+      end
 
-    if options[:folder_id].present?
-      folder_id = options[:folder_id]
-      verify_folder!(folder_id, options)
+      return MessageResult.new(success: false)
     end
 
-    # Taking first page of messages only effectivelly applies 1000-messages-in-one-go limit
+    # delete email from server after article was created
     begin
-      messages_details = @graph.list_messages(unread_only: keep_on_server, folder_id:, follow_pagination: false)
-
-      message_ids = messages_details
-        .fetch(:items)
-        .pluck(:id)
+      msg = @graph.get_raw_message(message_id)
     rescue MicrosoftGraph::ApiError => e
-      Rails.logger.error "Unable to list emails from Microsoft Graph server (#{options[:user]}): #{e.inspect}"
+      Rails.logger.error "Unable to fetch email #{count}/#{count_all} from Microsoft Graph server (#{@options[:user]}). Graph API Message ID: #{message_id}. #{e.inspect}"
       raise e
     end
 
-    # fetch regular messages
-    count_all             = messages_details[:total_count]
-    count                 = 0
-    count_fetched         = 0
-    too_large_messages    = []
-    active_check_interval = 20
-    result                = 'ok'
-    notice                = ''
-    message_ids.each do |message_id| # rubocop:disable Metrics/BlockLength
-      count += 1
-
-      break if (count % active_check_interval).zero? && channel_has_changed?(channel)
-
-      Rails.logger.info " - message #{count}/#{count_all}"
-
-      message_meta = @graph.get_message_basic_details(message_id)
-
-      next if message_meta.nil?
-
-      # ignore verify messages
-      next if !messages_is_too_old_verify?(message_meta[:headers], count, count_all)
-
-      # ignore already imported
-      if already_imported?(message_meta[:headers], keep_on_server, channel)
-        begin
-          @graph.mark_message_as_read(message_id)
-          Rails.logger.info "Ignore message #{count}/#{count_all}, because message message id already imported. Graph API Message ID: #{message_id}."
-        rescue MicrosoftGraph::ApiError => e
-          Rails.logger.error "Unable to mark email as read #{count}/#{count_all} from Microsoft Graph server (#{options[:user]}). Graph API Message ID: #{message_id}. #{e.inspect}"
-          raise e
-        end
+    # do not process too big messages, instead download & send postmaster reply
+    too_large_info = message_validator.too_large?
+    if too_large_info
+      if Setting.get('postmaster_send_reject_if_mail_too_large') == true
+        info = "  - download message #{count}/#{count_all} - ignore message because it's too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB) - Graph API Message ID: #{message_id}"
+        Rails.logger.info info
+        after_action = [:notice, "#{info}\n"]
+        process_oversized_mail(@channel, msg)
+      else
+        info = "  - ignore message #{count}/#{count_all} - because message is too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB) - Graph API Message ID: #{message_id}"
+        Rails.logger.info info
 
-        next
+        return MessageResult.new(success: false, after_action: [:too_large_ignored, "#{info}\n"])
       end
+    else
+      process(@channel, msg, false)
+    end
 
-      # delete email from server after article was created
+    if @keep_on_server
       begin
-        msg = @graph.get_raw_message(message_id)
+        @graph.mark_message_as_read(message_id)
       rescue MicrosoftGraph::ApiError => e
-        Rails.logger.error "Unable to fetch email #{count}/#{count_all} from Microsoft Graph server (#{options[:user]}). Graph API Message ID: #{message_id}. #{e.inspect}"
+        Rails.logger.error "Unable to mark email as read #{count}/#{count_all} from Microsoft Graph server (#{@options[:user]}). Graph API Message ID: #{message_id}. #{e.inspect}"
         raise e
       end
-
-      # do not process too big messages, instead download & send postmaster reply
-      too_large_info = too_large?(message_meta[:size])
-      if too_large_info
-        if Setting.get('postmaster_send_reject_if_mail_too_large') == true
-          info = "  - download message #{count}/#{count_all} - ignore message because it's too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB) - Graph API Message ID: #{message_id}"
-          Rails.logger.info info
-          notice += "#{info}\n"
-          process_oversized_mail(channel, msg)
-        else
-          info = "  - ignore message #{count}/#{count_all} - because message is too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB) - Graph API Message ID: #{message_id}"
-          Rails.logger.info info
-          notice += "#{info}\n"
-          too_large_messages.push info
-          next
-        end
-      else
-        process(channel, msg, false)
-      end
-
-      if keep_on_server
-        begin
-          @graph.mark_message_as_read(message_id)
-        rescue MicrosoftGraph::ApiError => e
-          Rails.logger.error "Unable to mark email as read #{count}/#{count_all} from Microsoft Graph server (#{options[:user]}). Graph API Message ID: #{message_id}. #{e.inspect}"
-          raise e
-        end
-      else
-        begin
-          @graph.delete_message(message_id)
-        rescue MicrosoftGraph::ApiError => e
-          Rails.logger.error "Unable to delete #{count}/#{count_all} from Microsoft Graph server (#{options[:user]}). Graph API Message ID: #{message_id}. #{e.inspect}"
-          raise e
-        end
+    else
+      begin
+        @graph.delete_message(message_id)
+      rescue MicrosoftGraph::ApiError => e
+        Rails.logger.error "Unable to delete #{count}/#{count_all} from Microsoft Graph server (#{@options[:user]}). Graph API Message ID: #{message_id}. #{e.inspect}"
+        raise e
       end
-
-      count_fetched += 1
-    end
-
-    if count.zero?
-      Rails.logger.info ' - no message'
     end
 
-    # Error is raised if one of the messages was too large AND postmaster_send_reject_if_mail_too_large is turned off.
-    # This effectivelly marks channels as stuck and gets highlighted for the admin.
-    # New emails are still processed! But large email is not touched, so error keeps being re-raised on every fetch.
-    if too_large_messages.present?
-      raise too_large_messages.join("\n")
-    end
-
-    {
-      result:  result,
-      fetched: count_fetched,
-      notice:  notice,
-    }
+    MessageResult.new(success: true, after_action: after_action)
   end
 
-  # Checks if mailbox has any messages.
-  # It does not check if email is Zammad verification email or not like other drivers due to Graph API limitations.
-  # X-Zammad-Verify and X-Zammad-Ignore headers are removed from mails sent via Graph API.
-  # Thus it's not possible to verify Graph API connection by sending email with such header to yourself.
-  def check_configuration(options)
-    setup_connection(options)
-
-    Rails.logger.info 'check only mode, fetch no emails'
-
+  def messages_iterator(keep_on_server, options)
     if options[:folder_id].present?
       folder_id = options[:folder_id]
       verify_folder!(folder_id, options)
     end
 
-    begin
-      messages_details = @graph.list_messages(folder_id:, follow_pagination: false)
-    rescue MicrosoftGraph::ApiError => e
-      Rails.logger.error "Unable to list emails from Microsoft Graph server (#{options[:user]}): #{e.inspect}"
-      raise e
-    end
+    # Taking first page of messages only effectivelly applies 1000-messages-in-one-go limit
+    messages_details = @graph.list_messages(unread_only: keep_on_server, folder_id:, follow_pagination: false)
 
-    {
-      result:           'ok',
-      content_messages: messages_details[:total_count],
-    }
-  end
+    ids   = messages_details.fetch(:items).pluck(:id)
+    count = messages_details.fetch(:total_count)
 
-  def verify(_options, _verify_string)
-    raise 'Microsoft Graph email channel is never verified. Thus this method is not implemented.' # rubocop:disable Zammad/DetectTranslatableString
+    [ids, count]
+  rescue MicrosoftGraph::ApiError => e
+    Rails.logger.error "Unable to list emails from Microsoft Graph server (#{options[:user]}): #{e.inspect}"
+    raise e
   end
 
   private

+ 119 - 178
app/models/channel/driver/pop3.rb

@@ -3,180 +3,47 @@
 require 'net/pop'
 
 class Channel::Driver::Pop3 < Channel::Driver::BaseEmailInbound
-
-=begin
-
-fetch emails from Pop3 account
-
-  instance = Channel::Driver::Pop3.new
-  result = instance.fetch(params[:inbound][:options], channel, 'verify', subject_looking_for)
-
-returns
-
-  {
-    result: 'ok',
-    fetched: 123,
-    notice: 'e. g. message about to big emails in mailbox',
-  }
-
-check if connect to Pop3 account is possible, return count of mails in mailbox
-
-  instance = Channel::Driver::Pop3.new
-  result = instance.fetch(params[:inbound][:options], channel, 'check')
-
-returns
-
-  {
-    result: 'ok',
-    content_messages: 123,
-  }
-
-verify Pop3 account, check if search email is in there
-
-  instance = Channel::Driver::Pop3.new
-  result = instance.fetch(params[:inbound][:options], channel, 'verify', subject_looking_for)
-
-returns
-
-  {
-    result: 'ok', # 'verify not ok'
-  }
-
-=end
-
-  def fetch(options, channel)
-    setup_connection(options)
-
-    mails = @pop.mails
-
-    # fetch regular messages
-    count_all             = mails.size
-    count                 = 0
-    count_fetched         = 0
-    too_large_messages    = []
-    active_check_interval = 20
-    notice                = ''
-    mails.first(2000).each do |m|
-      count += 1
-
-      break if (count % active_check_interval).zero? && channel_has_changed?(channel)
-
-      Rails.logger.info " - message #{count}/#{count_all}"
-      mail = m.pop
-      next if !mail
-
-      # ignore verify messages
-      if mail.match?(%r{(X-Zammad-Ignore: true|X-Zammad-Verify: true)}) && mail =~ %r{X-Zammad-Verify-Time:\s(.+?)\s}
-        begin
-          verify_time = Time.zone.parse($1)
-          if verify_time > 30.minutes.ago
-            info = "  - ignore message #{count}/#{count_all} - because it's a verify message"
-            Rails.logger.info info
-            next
-          end
-        rescue => e
-          Rails.logger.error e
-        end
-      end
-
-      # do not process too large messages, instead download and send postmaster reply
-      max_message_size = Setting.get('postmaster_max_size').to_f
-      real_message_size = mail.size.to_f / 1024 / 1024
-      if real_message_size > max_message_size
-        if Setting.get('postmaster_send_reject_if_mail_too_large') == true
-          info = "  - download message #{count}/#{count_all} - ignore message because it's too large (is:#{real_message_size} MB/max:#{max_message_size} MB)"
-          Rails.logger.info info
-          notice += "#{info}\n"
-          process_oversized_mail(channel, mail)
-        else
-          info = "  - ignore message #{count}/#{count_all} - because message is too large (is:#{real_message_size} MB/max:#{max_message_size} MB)"
-          Rails.logger.info info
-          notice += "#{info}\n"
-          too_large_messages.push info
-          next
-        end
-
-      # delete email from server after article was created
-      else
-        process(channel, m.pop, false)
-      end
-
-      m.delete
-      count_fetched += 1
-    end
-    disconnect
-    if count.zero?
-      Rails.logger.info ' - no message'
-    end
-
-    # Error is raised if one of the messages was too large AND postmaster_send_reject_if_mail_too_large is turned off.
-    # This effectivelly marks channels as stuck and gets highlighted for the admin.
-    # New emails are still processed! But large email is not touched, so error keeps being re-raised on every fetch.
-    if too_large_messages.present?
-      raise too_large_messages.join("\n")
-    end
-
-    Rails.logger.info 'done'
-    {
-      result:  'ok',
-      fetched: count_fetched,
-      notice:  notice,
-    }
-  end
-
-  # Checks if mailbox has anything besides Zammad verification emails.
-  # If any real messages exists, return the real count including messages to be ignored when importing.
-  # If only verification messages found, return 0.
-  def check_configuration(options)
-    setup_connection(options, check: true)
-
-    mails = @pop.mails
-
-    Rails.logger.info 'check only mode, fetch no emails'
-
-    has_content_messages = mails
-      .first(2000)
-      .any? do |m|
-        mail = m.pop
-
-        mail.present? && !mail.match?(%r{(X-Zammad-Ignore: true|X-Zammad-Verify: true)})
-      end
-
-    disconnect
-
-    {
-      result:           'ok',
-      content_messages: has_content_messages ? mails.count : 0,
-    }
-  end
-
-  def verify(options, verify_string)
-    setup_connection(options)
-
-    mails = @pop.mails
-
-    Rails.logger.info 'verify mode, fetch no emails'
-    mails.reverse!
-
-    # check for verify message
-    mails.first(2000).each do |m|
-      mail = m.pop
-      next if !mail
-
-      # check if verify message exists
-      next if !mail.match?(%r{#{verify_string}})
-
-      Rails.logger.info " - verify email #{verify_string} found"
-      m.delete
-      disconnect
-      return {
-        result: 'ok',
-      }
-    end
-
-    {
-      result: 'verify not ok',
-    }
+  FETCH_COUNT_MAX    = 2_000
+  OPEN_TIMEOUT       = 16
+  OPEN_CHECK_TIMEOUT = 4
+  READ_TIMEOUT       = 45
+  READ_CHECK_TIMEOUT = 6
+
+  # Fetches emails from POP3 server
+  #
+  # @param options [Hash]
+  # @option options [String] :folder to fetch emails from
+  # @option options [String] :user to login with
+  # @option options [String] :password to login with
+  # @option options [String] :host
+  # @option options [Integer, String] :port
+  # @option options [Boolean] :ssl_verify
+  # @option options [String] :ssl off to turn off ssl
+  # @param channel [Channel]
+  #
+  # @return [Hash]
+  #
+  #  {
+  #    result: 'ok',
+  #    fetched: 123,
+  #    notice: 'e. g. message about to big emails in mailbox',
+  #  }
+  #
+  # @example
+  #
+  #  params = {
+  #    user: 'xxx@zammad.onmicrosoft.com',
+  #    password: 'xxx',
+  #    host: 'example'com'
+  #  }
+  #
+  #  channel = Channel.last
+  #  instance = Channel::Driver::Pop3.new
+  #  result = instance.fetch(params, channel)
+  def fetch(...) # rubocop:disable Lint/UselessMethodDefinition
+    # fetch() method is defined in superclass, but options are subclass-specific,
+    #   so define it here for documentation purposes.
+    super
   end
 
   def disconnect
@@ -207,11 +74,11 @@ returns
 
     # on check, reduce open_timeout to have faster probing
     if check
-      @pop.open_timeout = 4
-      @pop.read_timeout = 6
+      @pop.open_timeout = OPEN_CHECK_TIMEOUT
+      @pop.read_timeout = READ_CHECK_TIMEOUT
     else
-      @pop.open_timeout = 16
-      @pop.read_timeout = 45
+      @pop.open_timeout = OPEN_TIMEOUT
+      @pop.read_timeout = READ_TIMEOUT
     end
 
     if ssl
@@ -221,4 +88,78 @@ returns
     @pop.start(options[:user], options[:password])
   end
 
+  def messages_iterator(_keep_on_server, _options, reverse: false)
+    all = @pop.mails
+
+    all.reverse! if reverse
+
+    [all.first(FETCH_COUNT_MAX), all.size]
+  end
+
+  def fetch_single_message(message, count, count_all)
+    mail = message.pop
+
+    return MessageResult.new(success: false) if !mail
+
+    message_validator = MessageValidator.new(self.class.extract_headers(mail), mail.size)
+
+    if message_validator.fresh_verify_message?
+      Rails.logger.info "  - ignore message #{count}/#{count_all} - because message has a verify message"
+
+      return MessageResult.new(sucess: false)
+    end
+
+    # do not process too large messages, instead download and send postmaster reply
+    if (too_large_info = message_validator.too_large?)
+      if Setting.get('postmaster_send_reject_if_mail_too_large') == true
+        info = "  - download message #{count}/#{count_all} - ignore message because it's too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB)"
+        Rails.logger.info info
+        after_action = [:notice, "#{info}\n"]
+        process_oversized_mail(@channel, mail)
+      else
+        info = "  - ignore message #{count}/#{count_all} - because message is too large (is:#{too_large_info[0]} MB/max:#{too_large_info[1]} MB)"
+        Rails.logger.info info
+
+        return MessageResult.new(success: false, after_action: [:too_large_ignored, "#{info}\n"])
+      end
+    else
+      process(@channel, message.pop, false)
+    end
+
+    message.delete
+
+    MessageResult.new(success: true, after_action: after_action)
+  end
+
+  def fetch_wrap_up
+    disconnect
+  end
+
+  def check_single_message(message_id)
+    mail = message_id.pop
+
+    return if !mail
+
+    MessageValidator.new(self.class.extract_headers(mail), mail.size)
+  end
+
+  def verify_single_message(message_id, verify_regexp)
+    mail = message_id.pop
+    return if !mail
+
+    # check if verify message exists
+    mail.match?(verify_regexp)
+  end
+
+  def verify_message_cleanup(message_id)
+    message_id.delete
+  end
+
+  def self.extract_headers(mail)
+    {
+      'X-Zammad-Verify'      => mail.include?('X-Zammad-Ignore: true') ? 'true' : 'false',
+      'X-Zammad-Ignore'      => mail.include?('X-Zammad-Verify: true') ? 'true' : 'false',
+      'X-Zammad-Verify-Time' => mail.match(%r{X-Zammad-Verify-Time:\s(.+?)\s})&.captures&.first,
+    }.with_indifferent_access
+  end
 end

+ 18 - 19
app/models/channel/driver/sendmail.rb

@@ -1,37 +1,36 @@
 # Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
-class Channel::Driver::Sendmail
+class Channel::Driver::Sendmail < Channel::Driver::BaseEmailOutbound
   include Channel::EmailHelper
 
+  # Sends a message via Sendmail
   def deliver(_options, attr, notification = false)
 
     # return if we run import mode
     return if Setting.get('import_mode')
 
-    # set system_bcc of config if defined
-    system_bcc = Setting.get('system_bcc')
-    email_address_validation = EmailAddressValidation.new(system_bcc)
-    if system_bcc.present? && email_address_validation.valid?
-      attr[:bcc] ||= ''
-      attr[:bcc] += ', ' if attr[:bcc].present?
-      attr[:bcc] += system_bcc
-    end
-    attr = prepare_idn_outbound(attr)
+    attr = prepare_message_attrs(attr)
 
-    mail = Channel::EmailBuild.build(attr, notification)
-    delivery_method(mail)
-    mail.deliver
+    deliver_mail(attr, notification)
   end
 
   private
 
-  def delivery_method(mail)
+  # Sendmail driver is (ab)used in testing and development environments
+  #
+  # Normally this driver sends mails via sendmail command
+  # The special rails test adapter is used in testing
+  #
+  # ZAMMAD_MAIL_TO_FILE is for debugging outgoing mails.
+  # It allows to easily inspect contents of the outgoing messsages
+  def deliver_mail(attr, notification)
     if ENV['ZAMMAD_MAIL_TO_FILE'].present?
-      return mail.delivery_method :file, { location: Rails.root.join('tmp/mails'), extension: '.eml' }
+      super(attr, notification, :file, { location: Rails.root.join('tmp/mails'), extension: '.eml' })
+    elsif Rails.env.test?
+      # test
+      super(attr, notification, :test)
+    else
+      super(attr, notification, :sendmail)
     end
-
-    return mail.delivery_method :test if Rails.env.test?
-
-    mail.delivery_method :sendmail
   end
 end

+ 23 - 20
app/models/channel/driver/smtp.rb

@@ -1,26 +1,29 @@
 # Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
 class Channel::Driver::Smtp < Channel::Driver::BaseEmailOutbound
-=begin
-
-  instance = Channel::Driver::Smtp.new
-  instance.send(
-    {
-      host:                 'some.host',
-      port:                 25,
-      enable_starttls_auto: true, # optional
-      openssl_verify_mode:  'none', # optional
-      user:                 'someuser',
-      password:             'somepass'
-      authentication:       nil, # nil, autodetection - to use certain schema use 'plain', 'login', 'xoauth2' or 'cram_md5'
-    },
-    mail_attributes,
-    notification
-  )
-
-=end
-
-  def deliver(options, attr, notification = false)
+  # We're using the same timeouts like in Net::SMTP gem
+  # but we would like to have the possibility to mock them for tests
+  DEFAULT_OPEN_TIMEOUT = 30.seconds
+  DEFAULT_READ_TIMEOUT = 60.seconds
+
+  # Sends a message via SMTP
+  #
+  # @example
+  # instance = Channel::Driver::Smtp.new
+  # instance.deliver(
+  #  {
+  #    host:                 'some.host',
+  #    port:                 25,
+  #    enable_starttls_auto: true, # optional
+  #    openssl_verify_mode:  'none', # optional
+  #    user:                 'someuser',
+  #    password:             'somepass'
+  #    authentication:       nil, # nil, autodetection - to use certain schema use 'plain', 'login', 'xoauth2' or 'cram_md5'
+  #  },
+  #  mail_attributes,
+  #  notification
+  # )
+  def deliver(options, attr, notification = false) # rubocop:disable Style/OptionalBooleanParameter
     # return if we run import mode
     return if Setting.get('import_mode')
 

+ 1 - 1
lib/email_helper/verify.rb

@@ -35,7 +35,7 @@ class EmailHelper
         begin
           driver_class    = "Channel::Driver::#{adapter.to_classname}".constantize
           driver_instance = driver_class.new
-          fetch_result    = driver_instance.verify(params[:inbound][:options], subject)
+          fetch_result    = driver_instance.verify_transport(params[:inbound][:options], subject)
         rescue => e
           result = {
             result:        'invalid',

Некоторые файлы не были показаны из-за большого количества измененных файлов