Stex/ar_mailer_revised

View on GitHub
lib/ar_mailer_revised/mailman.rb

Summary

Maintainability
A
1 hr
Test Coverage
#
# This class handles the actual email sending.
# It is called by the +ar_sendmail+ executable in /bin
# with command line arguments
#
# @author Stefan Exner
#

require 'net/smtp'
require 'ar_mailer_revised/version'
require 'ar_mailer_revised/helpers/command_line'
require 'ar_mailer_revised/helpers/general'

module ArMailerRevised
  class Mailman
    include ArMailerRevised::Helpers::General
    include ArMailerRevised::Helpers::CommandLine

    #
    # Simply holds a copy of the options given in from command line
    #
    def initialize(options = {})
      @options = options
    end

    def run
     logger.debug "ArMailerRevised initialized with the following options:\n" + Hirb::Helpers::AutoTable.render(@options)
     deliver_emails
    end

    private

    #
    # Performs a single email sending for the given batch size
    # Only emails which are ready for sending are actually sent.
    # "Ready for sending" means in this case, that +delivery_time+ is +nil+
    # or set to a time which is <= Time.now
    #
    # Take a look at +EmailScaffold+ for more information
    # about the used scopes
    #
    # @todo: Check if we should delete emails which cause SMTPFatalErrors
    # @todo: Probably add better error handling than simple re-tries
    #
    def deliver_emails
      total_mail_count = ArMailerRevised.email_class.ready_to_deliver.count
      emails           = ArMailerRevised.email_class.ready_to_deliver.with_batch_size(@options[:batch_size])

      if emails.empty?
        logger.info 'No emails to be sent, existing'
        return
      end

      logger.info "Starting batch sending process, sending #{emails.count} / #{total_mail_count} mails"

      group_emails_by_settings(emails).each do |settings_hash, grouped_emails|
        setting = OpenStruct.new(settings_hash)
        logger.info "Using setting #{setting.address}:#{setting.port}/#{setting.user_name}"

        smtp = Net::SMTP.new(setting.address, setting.port)
        smtp.open_timeout = 10
        smtp.read_timeout = 10
        setup_tls(smtp, setting)

        #Connect to the server and handle possible errors
        begin
          smtp.start(setting.domain, setting.user_name, setting.password, setting.authentication) do
            grouped_emails.each do |email|
              send_email(smtp, email)
            end
          end
        rescue Net::SMTPAuthenticationError => e
          handle_smtp_authentication_error(setting, e, grouped_emails)
        rescue Net::SMTPSyntaxError => e
          handle_smtp_syntax_error(setting, e, grouped_emails)
        rescue Net::SMTPServerBusy => e
          logger.warn 'Server is busy, trying again next batch.'
          logger.warn 'Complete Error: ' + e.to_s
        rescue Net::OpenTimeout, Net::ReadTimeout => e
          handle_smtp_timeout(setting, e, grouped_emails)
        rescue Net::SMTPFatalError, Net::SMTPUnknownError => e
          #TODO: Should we remove the custom SMTP settings here as well?
          logger.warn 'Other SMTP error, trying again next batch.'
          logger.warn 'Complete Error: ' + e.to_s
        rescue OpenSSL::SSL::SSLError => e
          handle_ssl_error(setting, e, grouped_emails)
        rescue Exception => e
          handle_other_exception(setting, e, grouped_emails)
        end
      end
    end

    #
    # As there may be multiple emails using the same SMTP settings,
    # it would just slow down the sending having to connect to the server
    # multiple times. Therefore, all emails with the same settings
    # are grouped together.
    #
    # @param [Array<Email>] emails
    #   Emails to be grouped together
    #
    # @return [Hash<Setting, Email>]
    #   Hash mapping SMTP settings to emails.
    #   All emails which did not have custom SMTP settings are
    #   grouped together under the default SMTP settings.
    #
    def group_emails_by_settings(emails)
      emails.inject({}) do |hash, email|
        setting = ActionMailer::Base.smtp_settings

        if email.smtp_settings
          setting = email.smtp_settings.clone
          setting[:custom_setting] = true
        end

        hash[setting] ||= []
        hash[setting] << email

        hash
      end
    end

    #
    # Sets the wished TLS / StartTLS options in the
    # given SMTP instance, based on what the user defined
    # in his application's / the email's SMTP settings.
    #
    # Available Settings are (descending importance, meaning that
    # a higher importance setting will override a lower importance setting)
    #
    # 1. +:enable_starttls_auto+ enables STARTTLS if the serves is capable to handle it
    # 2. +:enable_starttls+ forces the usage of STARTTLS, whether the server is capable of it or not
    # 3. +:tls+ forces the usage of TLS (SSL SMTP)
    #
    def setup_tls(smtp, setting)
      if setting.enable_starttls_auto
        logger.debug 'Using STARTTLS, if the server accepts it'
        smtp.enable_starttls_auto(build_ssl_context(setting))
      elsif setting.enable_starttls
        logger.debug 'Forcing STARTTLS'
        smtp.enable_starttls(build_ssl_context(setting))
      elsif setting.tls
        logger.debug 'Forcing TLS'
        smtp.enable_tls(build_ssl_context(setting))
      else
        logger.debug 'Disabling TLS'
        smtp.disable_tls
      end
    end

    #
    # @return [Boolean] +true+ if TLS is used in any kind (STARTLS auto, STARTLS or TLS)
    #
    def use_tls?(setting)
      setting.enable_starttls_auto || setting.enable_starttls || setting.enable_tls
    end

    #
    # Builds an SSL context to be used if TLS is enabled for the given setting
    # At the moment it only sets the chosen verify_mode, but it might be extended later.
    #
    def build_ssl_context(setting)
      c = OpenSSL::SSL::SSLContext.new
      if use_tls?(setting)
        logger.debug "Using SSL verify mode: #{setting.openssl_verify_mode}"
        c.verify_mode = setting.openssl_verify_mode
      end
      c
    end

    #
    # Performs an email sending attempt
    #
    # @param [Net::SMTP] smtp
    #   The SMTP connection which already has to be established
    #
    # @param [Email]
    #   The email record to be sent.
    #
    # Error handling works as follows:
    #
    #   - If the server is busy while sending the email (SMTPServerBusy),
    #     the system will leave the email at its old place in the queue and try
    #     again next batch as we simply assume that the server failure is just temporarily
    #     and the email will not cause the whole email sending to stagnate
    #
    #   - If another error occurs, the system will adjust the last_send_attempt
    #     in the email record and therefore move it to the end of the queue to
    #     ensure that other (working) emails are sent without being held up
    #     in the queue by this probably malformed one.
    #
    # Errors are logged with the :warn level.
    #
    def send_email(smtp, email)
      logger.info "Sending Email ##{email.id}"
      smtp.send_message(email.mail, email.from, email.to)
      email.destroy
    rescue Net::SMTPServerBusy => e
      logger.warn 'Server is currently busy, trying again next batch'
      logger.warn 'Complete Error: ' + e.to_s
    rescue Net::SMTPSyntaxError, Net::SMTPFatalError, Net::SMTPUnknownError, Net::ReadTimeout => e
      logger.warn 'Other exception, trying again next batch: ' + e.to_s
      adjust_last_send_attempt!(email)
    end

    #-----------------------------------------------------------------
    #                    SMTP connection error handling
    # These errors happen directly when connecting to the SMTP server
    #-----------------------------------------------------------------

    #
    # Handles Net::OpenTimeout and Net::ReadTimeout occurring
    # while connecting to an SMTP server.
    #
    # If the setting was a custom SMTP setting, it will be removed from
    # all given emails - but only if it failed before.
    # With this, each email setting gets 2 tries.
    #
    # @param [OpenStruct] setting
    #   The used SMTP settings
    #
    # @param [Exception] exception
    #   The exception thrown
    #
    # @param [Array<Email>] emails
    #   All emails to be delivered using this system (in the current batch)
    #
    def handle_smtp_timeout(setting, exception, emails)
      logger.warn "SMTP connection timeout while connecting to '#{setting.address}:#{setting.port}'"
      logger.warn 'Complete Error: ' + exception.to_s

      if setting.custom_setting
        logger.warn 'Setting default SMTP settings for all affected emails, they will be sent next batch.'
        emails.each do |email|
          if email.previously_attempted?
            remove_custom_smtp_settings!(email)
          else
            adjust_last_send_attempt!(email)
          end
        end
      end
    end

    #
    # Handles SSL errors (mostly invalid certificates)
    # @see #handle_smtp_timeout
    #
    # Custom SMTP settings will be deleted and the default server will be used.
    #
    def handle_ssl_error(setting, exception, emails)
      logger.warn "SSL error while connecting to '#{setting.address}:#{setting.port}'"
      logger.warn 'Complete Error: ' + exception.to_s
      handle_custom_setting_removal(setting, emails)
    end

    #
    # Handles authentication errors occuring while connecting to an SMTP server.
    # @see #handle_smtp_timeout
    #
    # The main difference is, that custom SMTP settings will be deleted directly
    # as it isn't very likely that time will solve the error.
    #
    def handle_smtp_authentication_error(setting, exception, emails)
      logger.warn "SMTP authentication error while connecting to '#{setting.address}:#{setting.port}'"
      logger.warn 'Complete Error: ' + exception.to_s
      handle_custom_setting_removal(setting, emails)
    end

    #
    # Handles SMTP syntax errors.
    # @see #handle_smtp_timeout
    #
    def handle_smtp_syntax_error(setting, exception, emails)
      logger.warn "SMTP syntax error while connecting to '#{setting.host}:#{setting.port}'"
      logger.warn 'Complete Error: ' + exception.to_s
      handle_custom_setting_removal(setting, emails)
    end

    #
    # Handles other errors occuring while sending the email
    # Custom settings are removed here as well as the gem itself
    # most likely has to be altered to send these emails out using
    # the custom settings - which might take a while.
    #
    def handle_other_exception(setting, exception, emails)
      logger.warn "Other error while connecting to '#{setting.host}:#{setting.port}'"
      logger.warn "Complete Error (#{exception.class.to_s}): " + exception.to_s
      handle_custom_setting_removal(setting, emails)
    end

    #----------------------------------------------------------------
    #                    Email Record Alteration
    #----------------------------------------------------------------

    #
    # Removes custom settings for all given emails
    #
    def handle_custom_setting_removal(setting, emails)
      if setting.custom_setting
        logger.warn 'Setting default SMTP settings for all affected emails, they will be sent next batch.'
        emails.each { |email| remove_custom_smtp_settings!(email) }
      else
        emails.each { |email| adjust_last_send_attempt!(email) }
        logger.error "Your application's base setting ('#{setting.host}:#{setting.port}') produced an error!"
      end
    end

    #
    # Adjusts the last send attempt timestamp in the given
    # email to the current time.
    #
    def adjust_last_send_attempt!(email)
      logger.info "Setting last send attempt for email ##{email.id} (was: #{email.last_send_attempt})"
      email.last_send_attempt = Time.now.to_i
      email.save(:validate => false)
    end

    #
    # Removes the custom smtp settings from a given email record
    # and saves it without validations
    #
    def remove_custom_smtp_settings!(email)
      logger.info "Removing custom SMTP settings (#{email.smtp_settings[:address]}:#{email.smtp_settings[:port]}) for email ##{email.id}"
      email.smtp_settings = nil
      email.save(:validate => false)
    end
  end
end