feat: add timeout for imap email job and skip problematic emails (#11981)

# Pull Request Template

## Description

Large emails (2MB+ with multiple attachments) were causing IMAP email
processing jobs to timeout silently, blocking all subsequent emails from
being processed. This created an infinite loop where:
- Problematic emails were repeatedly fetched but never successfully
processed
- Other emails in the queue were never processed as we iterated
sequentially
  - silent failures


  ### Solution

Enhanced the FetchImapEmailsJob with individual email processing
isolation:

  ### Key Changes

1. Individual Email Processing: Changed from map to each for better
memory efficiency
2. Timeout Protection: Added configurable timeout per email (default: 60
seconds)
3. Failure Tracking: Track failed emails with 6-hour expiry for retry
opportunities
4. Skip Logic: Skip emails that have failed 3+ times to prevent infinite
loops
  5. Error Isolation: Each email is processed in its own error boundary

  ### Configuration

- Timeout: Configurable via EMAIL_PROCESSING_TIMEOUT_SECONDS using
GlobalConfigService
  - Default: 60 seconds per email
  - Failure Limit: 3 attempts before skipping
- Retry Window: 6 hours so that emails get 8 more chances in the 2 day
window

  ### Benefits

  - Prevents queue blocking: One problematic email cannot stop others
- Maintains email order: Older emails (customers waiting longer)
processed first
  - Automatic recovery: Failed emails get retry opportunities
  - Better monitoring: Clear logging when emails timeout or are skipped
- Configurable: Deployments can adjust the timeout based on their needs

This fix ensures email processing reliability while maintaining existing
functionality.

## Type of change

Please delete options that are not relevant.

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.


## 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:
Vishnu Narayanan
2026-05-25 15:16:52 +05:30
committed by GitHub
parent 03fb6591e0
commit 52da165cb7
3 changed files with 75 additions and 7 deletions
+2
View File
@@ -98,6 +98,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
MAILER_INBOUND_EMAIL_DOMAIN=
# Maximum time in seconds to process a single IMAP email
# EMAIL_PROCESSING_TIMEOUT_SECONDS=60
# Set this to the appropriate ingress channel with regards to incoming emails
# Possible values are :
# relay for Exim, Postfix, Qmail
+34 -6
View File
@@ -36,7 +36,8 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
else
Imap::FetchEmailService.new(channel: channel, interval: interval).perform
end
inbound_emails.map do |inbound_mail|
inbound_emails.each do |inbound_mail|
process_mail(inbound_mail, channel)
end
rescue OAuth2::Error => e
@@ -44,11 +45,38 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
channel.authorization_error!
end
def should_skip_email?(message_id)
failure_count = Rails.cache.read("email_failures:#{message_id}") || 0
failure_count >= 3
end
def mark_email_as_failed(message_id)
failure_count = Rails.cache.read("email_failures:#{message_id}") || 0
Rails.cache.write("email_failures:#{message_id}", failure_count + 1, expires_in: 6.hours)
end
def process_mail(inbound_mail, channel)
Imap::ImapMailbox.new.process(inbound_mail, channel)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
Rails.logger.error("
#{channel.provider} Email dropped: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}")
# Skip if this email has failed multiple times recently
if should_skip_email?(inbound_mail.message_id)
Rails.logger.warn "[IMAP] Skipping problematic email: #{inbound_mail.message_id}"
return
end
begin
Timeout.timeout(email_processing_timeout) do
Imap::ImapMailbox.new.process(inbound_mail, channel)
end
rescue Timeout::Error
mark_email_as_failed(inbound_mail.message_id)
Rails.logger.error "[IMAP] Email processing timeout (#{email_processing_timeout}s): #{inbound_mail.message_id}"
rescue StandardError => e
mark_email_as_failed(inbound_mail.message_id)
Rails.logger.error "[IMAP] Failed to process email #{inbound_mail.message_id}: #{e.message}"
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
end
def email_processing_timeout
GlobalConfigService.load('EMAIL_PROCESSING_TIMEOUT_SECONDS', 60).to_i
end
end
@@ -88,7 +88,10 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
end
context 'when the fetch service returns the email objects' do
let(:inbound_mail) { create_inbound_email_from_fixture('welcome.eml').mail }
let(:inbound_mail) { instance_double(Mail::Message, message_id: 'message-id') }
let(:failure_cache_key) { "email_failures:#{inbound_mail.message_id}" }
let(:second_inbound_mail) { instance_double(Mail::Message, message_id: 'second-message-id') }
let(:second_failure_cache_key) { "email_failures:#{second_inbound_mail.message_id}" }
let(:mailbox) { double }
let(:exception_tracker) { double }
let(:fetch_service) { double }
@@ -101,6 +104,11 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
allow(fetch_service).to receive(:perform).and_return([inbound_mail])
end
after do
Rails.cache.delete(failure_cache_key)
Rails.cache.delete(second_failure_cache_key)
end
it 'calls the mailbox to create emails' do
allow(mailbox).to receive(:process)
@@ -111,6 +119,36 @@ RSpec.describe Inboxes::FetchImapEmailsJob do
described_class.perform_now(imap_email_channel)
end
it 'marks the email as failed when processing times out' do
allow(Timeout).to receive(:timeout).and_raise(Timeout::Error)
allow(Rails.cache).to receive(:read).and_call_original
allow(Rails.cache).to receive(:read).with(failure_cache_key).and_return(nil)
expect(Rails.cache).to receive(:write).with(failure_cache_key, 1, expires_in: 6.hours)
described_class.perform_now(imap_email_channel)
end
it 'continues processing remaining emails when one email fails' do
allow(fetch_service).to receive(:perform).and_return([inbound_mail, second_inbound_mail])
allow(mailbox).to receive(:process).with(inbound_mail, imap_email_channel).and_raise(StandardError)
allow(mailbox).to receive(:process).with(second_inbound_mail, imap_email_channel)
allow(exception_tracker).to receive(:capture_exception)
described_class.perform_now(imap_email_channel)
expect(mailbox).to have_received(:process).with(second_inbound_mail, imap_email_channel)
end
it 'skips emails that have failed multiple times recently' do
allow(Rails.cache).to receive(:read).and_call_original
allow(Rails.cache).to receive(:read).with(failure_cache_key).and_return(3)
expect(mailbox).not_to receive(:process)
described_class.perform_now(imap_email_channel)
end
it 'logs errors if mailbox returns errors' do
allow(mailbox).to receive(:process).and_raise(StandardError)