fix: call authorization_error! on IMAP auth failures (#13560)

## Notion document

https://www.notion.so/chatwoot/Email-IMAP-Issue-30aa5f274c928062aa6bddc2e5877a63?showMoveTo=true&saveParent=true

## Description

PLAIN IMAP channels (non-OAuth) were silently retrying failed
authentication every minute, forever. When credentials are
wrong/expired, Net::IMAP::NoResponseError was caught and logged but
channel.authorization_error! was never called — so the Redis error
counter never incremented, reauthorization_required? was never set, and
admins were never notified. OAuth channels already had this handled
correctly via the Reauthorizable concern.
Additionally, Net::IMAP::ResponseParseError (raised by non-RFC-compliant
IMAP servers) was falling through to the StandardError catch-all,
flooding
Estimated impact before fix: ~70–75 broken IMAP inboxes generating
~700k–750k wasted Sidekiq jobs/week.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Tanmay Deep Sharma
2026-02-26 18:01:23 +05:30
committed by GitHub
parent 9ca03c1af3
commit 7acd239c70
13 changed files with 218 additions and 14 deletions

View File

@@ -15,6 +15,7 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
return false if inbox.account.suspended?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
return false if inbox.channel.in_backoff?
return true unless ChatwootApp.chatwoot_cloud?
return false if default_plan?(inbox.account)

View File

@@ -6,26 +6,29 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
def perform(channel, interval = 1)
return unless should_fetch_email?(channel)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) do
process_email_for_channel(channel, interval)
end
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error "Authorization error for email channel - #{channel.inbox.id} : #{e.message}"
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::InvalidResponseError,
Net::IMAP::ResponseParseError, Net::IMAP::ResponseReadError, Net::IMAP::ResponseTooLargeError => e
Rails.logger.error "Error for email channel - #{channel.inbox.id} : #{e.message}"
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
fetch_emails_with_backoff(channel, interval)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
private
def fetch_emails_with_backoff(channel, interval)
key = format(::Redis::Alfred::EMAIL_MESSAGE_MUTEX, inbox_id: channel.inbox.id)
with_lock(key, 5.minutes) { process_email_for_channel(channel, interval) }
channel.clear_backoff!
rescue Imap::AuthenticationError => e
Rails.logger.error "#{channel.backoff_log_identifier} authentication error : #{e.message}"
channel.authorization_error!
rescue *ExceptionList::IMAP_TRANSIENT_EXCEPTIONS => e
Rails.logger.error "#{channel.backoff_log_identifier} transient error : #{e.message}"
channel.apply_backoff!
rescue LockAcquisitionError
Rails.logger.error "Lock failed for #{channel.inbox.id}"
end
def should_fetch_email?(channel)
channel.imap_enabled? && !channel.reauthorization_required?
channel.imap_enabled? && !channel.reauthorization_required? && !channel.in_backoff?
end
def process_email_for_channel(channel, interval)

View File

@@ -72,6 +72,10 @@ class Channel::Email < ApplicationRecord
imap_enabled && imap_address == 'imap.gmail.com'
end
def backoff_log_identifier
"Error for email channel - #{inbox.id}"
end
private
def ensure_forward_to_email

View File

@@ -0,0 +1,70 @@
# Backoffable provides transient-error retry backoff for models that depend on external services.
#
# When a transient error occurs (network hiccup, SSL failure, etc.) call apply_backoff!.
# The wait time ramps from 1 minute up to BACKOFF_MAX_INTERVAL_MINUTES, then holds at that
# ceiling for BACKOFF_MAX_INTERVAL_COUNT more attempts before calling prompt_reauthorization!.
#
# Call clear_backoff! after a successful operation to reset the counter.
module Backoffable
extend ActiveSupport::Concern
def backoff_log_identifier
inbox_id = respond_to?(:inbox) && inbox&.id
inbox_id ? "#{self.class.name} - #{inbox_id}" : "#{self.class.name}##{id}"
end
def backoff_retry_count
::Redis::Alfred.get(backoff_retry_count_key).to_i
end
def in_backoff?
val = ::Redis::Alfred.get(backoff_retry_after_key)
val.present? && Time.zone.at(val.to_f) > Time.current
end
def apply_backoff!
new_count = backoff_retry_count + 1
max_interval, max_retries = backoff_limits
if new_count > max_retries
exhaust_backoff(new_count)
else
schedule_backoff_retry(new_count, max_interval, max_retries)
end
end
def clear_backoff!
::Redis::Alfred.delete(backoff_retry_count_key)
::Redis::Alfred.delete(backoff_retry_after_key)
end
private
def backoff_limits
max_interval = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_MINUTES', 5).to_i
max_count = GlobalConfigService.load('BACKOFF_MAX_INTERVAL_COUNT', 10).to_i
[max_interval, (max_interval - 1) + max_count]
end
def exhaust_backoff(new_count)
Rails.logger.warn "#{backoff_log_identifier} backoff exhausted (#{new_count} failures), prompting reauthorization"
clear_backoff!
prompt_reauthorization!
end
def schedule_backoff_retry(new_count, max_interval, max_retries)
wait_minutes = [new_count, max_interval].min
::Redis::Alfred.set(backoff_retry_count_key, new_count.to_s, ex: 24.hours)
::Redis::Alfred.set(backoff_retry_after_key, wait_minutes.minutes.from_now.to_f.to_s, ex: 24.hours)
Rails.logger.warn "#{backoff_log_identifier} backoff retry #{new_count}/#{max_retries}, next attempt in #{wait_minutes}m"
end
def backoff_retry_count_key
format(::Redis::Alfred::BACKOFF_RETRY_COUNT, obj_type: self.class.table_name.singularize, obj_id: id)
end
def backoff_retry_after_key
format(::Redis::Alfred::BACKOFF_RETRY_AFTER, obj_type: self.class.table_name.singularize, obj_id: id)
end
end

View File

@@ -13,6 +13,8 @@
module Reauthorizable
extend ActiveSupport::Concern
include Backoffable
AUTHORIZATION_ERROR_THRESHOLD = 2
# model attribute
@@ -65,6 +67,7 @@ module Reauthorizable
def reauthorized!
::Redis::Alfred.delete(authorization_error_count_key)
::Redis::Alfred.delete(reauthorization_required_key)
clear_backoff!
invalidate_inbox_cache unless instance_of?(::AutomationRule)
end

View File

@@ -0,0 +1 @@
class Imap::AuthenticationError < StandardError; end

View File

@@ -107,7 +107,11 @@ class Imap::BaseFetchEmailService
def build_imap_client
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: true)
imap.authenticate(authentication_type, channel.imap_login, imap_password)
begin
imap.authenticate(authentication_type, channel.imap_login, imap_password)
rescue Net::IMAP::NoResponseError => e
raise Imap::AuthenticationError, e.message
end
imap.select('INBOX')
imap
end