mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
Fetch IMAP message content using `BODY.PEEK[]` instead of `RFC822` to avoid provider-specific parser failures while preserving unread state. This also applies the existing SMTP timeout configuration to custom SMTP email-channel replies, so provider SMTP responses have enough time to complete. Fixes: https://github.com/chatwoot/chatwoot/issues/12762 ## Why Some IMAP providers can return responses for `FETCH RFC822` that Ruby `net-imap` fails to parse with: `Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)` We reproduced this with iCloud IMAP. Authentication, `INBOX` selection, and header fetches worked, but fetching full message content with `RFC822` failed before Chatwoot received a `Mail::Message`. The same mailbox successfully returned full message content when fetched with `BODY.PEEK[]`. > During end-to-end iCloud validation, inbound fetch worked after the IMAP change, but outbound replies through the custom SMTP settings could still fail with a socket read timeout. The OAuth SMTP path already used explicit SMTP timeout values; the custom SMTP path was relying on mailer defaults instead. ## What this change does - Replaces the full message fetch from `RFC822` to `BODY.PEEK[]` - Reads the returned message content from `BODY[]`, which is how `net-imap` exposes the response attribute - Keeps the existing `BODY.PEEK[HEADER]` header-fetch behavior unchanged - Applies `SMTP_OPEN_TIMEOUT` and `SMTP_READ_TIMEOUT` to custom SMTP email-channel replies - Defaults custom SMTP reply delivery to `open_timeout: 15` and `read_timeout: 30` - Updates IMAP service specs for standard and Microsoft IMAP fetch flows - Updates mailer specs for custom SMTP timeout settings `BODY.PEEK[]` is preferable here because it fetches the full message content without marking messages as read. ## Validation - Configured a local email inbox against iCloud IMAP and SMTP - Confirmed `FETCH RFC822` reproduces `Net::IMAP::ResponseParseError: unexpected RPAR (expected ATOM or NIL)` - Confirmed `BODY[]` and `BODY.PEEK[]` fetch the same mailbox successfully - Confirmed Chatwoot imports iCloud messages after the IMAP change - Sent two outbound replies from the Chatwoot UI through iCloud SMTP after applying the timeout settings - Confirmed both UI-created outbound messages were marked `sent`, had iCloud SMTP `source_id` values, and had no `external_error` - Ran `bundle exec rspec spec/services/imap/fetch_email_service_spec.rb spec/services/imap/microsoft_fetch_email_service_spec.rb` - Ran `bundle exec rspec spec/mailers/conversation_reply_mailer_spec.rb`
157 lines
4.7 KiB
Ruby
157 lines
4.7 KiB
Ruby
require 'net/imap'
|
|
|
|
class Imap::BaseFetchEmailService
|
|
MAX_MESSAGES_PER_SYNC = 500
|
|
|
|
pattr_initialize [:channel!, :interval]
|
|
|
|
def fetch_emails
|
|
# Override this method
|
|
end
|
|
|
|
def perform
|
|
inbound_emails = fetch_emails
|
|
terminate_imap_connection
|
|
|
|
inbound_emails
|
|
end
|
|
|
|
private
|
|
|
|
def authentication_type
|
|
# Override this method
|
|
end
|
|
|
|
def imap_password
|
|
# Override this method
|
|
end
|
|
|
|
def imap_client
|
|
@imap_client ||= build_imap_client
|
|
end
|
|
|
|
def mail_info_logger(inbound_mail, seq_no)
|
|
return if Rails.env.test?
|
|
|
|
Rails.logger.info("
|
|
#{channel.provider} Email id: #{inbound_mail.from} - message_source_id: #{inbound_mail.message_id} - sequence id: #{seq_no}")
|
|
end
|
|
|
|
def email_already_present?(channel, message_id)
|
|
channel.inbox.messages.find_by(source_id: message_id).present?
|
|
end
|
|
|
|
def fetch_mail_for_channel
|
|
message_ids_with_seq = fetch_message_ids_with_sequence
|
|
message_ids_with_seq.filter_map do |message_id_with_seq|
|
|
process_message_id(message_id_with_seq)
|
|
end
|
|
end
|
|
|
|
def process_message_id(message_id_with_seq)
|
|
seq_no, message_id = message_id_with_seq
|
|
|
|
if message_id.blank?
|
|
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Empty message id for #{channel.email} with seq no. <#{seq_no}>."
|
|
return
|
|
end
|
|
|
|
return if email_already_present?(channel, message_id)
|
|
|
|
# Fetch the original mail content using the sequence no.
|
|
# BODY.PEEK[] avoids RFC822 parser failures seen with some IMAP servers.
|
|
mail_str = imap_client.fetch(seq_no, 'BODY.PEEK[]')[0].attr['BODY[]']
|
|
|
|
if mail_str.blank?
|
|
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
|
|
return
|
|
end
|
|
|
|
inbound_mail = build_mail_from_string(mail_str)
|
|
mail_info_logger(inbound_mail, seq_no)
|
|
inbound_mail
|
|
end
|
|
|
|
# Sends a FETCH command to retrieve data associated with a message in the mailbox.
|
|
# You can send batches of message sequence number in `.fetch` method.
|
|
def fetch_message_ids_with_sequence
|
|
seq_nums = fetch_available_mail_sequence_numbers
|
|
|
|
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
|
|
|
|
message_ids_with_seq = []
|
|
seq_nums.each_slice(MAX_MESSAGES_PER_SYNC).each do |batch|
|
|
append_message_ids_for_batch(batch, message_ids_with_seq)
|
|
if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
|
|
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Reached MAX_MESSAGES_PER_SYNC=#{MAX_MESSAGES_PER_SYNC} for #{channel.email}, stopping sync."
|
|
break
|
|
end
|
|
end
|
|
|
|
message_ids_with_seq
|
|
end
|
|
|
|
def append_message_ids_for_batch(batch, message_ids_with_seq)
|
|
# Fetch only message-id only without mail body or contents.
|
|
batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
|
|
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch for #{channel.email}. Found #{batch_message_ids&.length} messages."
|
|
|
|
# .fetch returns an array of Net::IMAP::FetchData or nil
|
|
# (instead of an empty array) if there is no matching message.
|
|
if batch_message_ids.blank?
|
|
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
|
|
return
|
|
end
|
|
|
|
batch_message_ids.each do |data|
|
|
entry = build_message_id_entry(data)
|
|
next if entry.nil?
|
|
|
|
message_ids_with_seq.push(entry)
|
|
break if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
|
|
end
|
|
end
|
|
|
|
def build_message_id_entry(data)
|
|
mail = build_mail_from_string(data.attr['BODY[HEADER]'])
|
|
return nil if MailPresenter.new(mail, channel.account).notification_email_from_chatwoot?
|
|
|
|
message_id = mail.message_id
|
|
return nil if message_id.blank?
|
|
return nil if email_already_present?(channel, message_id)
|
|
|
|
[data.seqno, message_id]
|
|
end
|
|
|
|
# Sends a SEARCH command to search the mailbox for messages that were
|
|
# created between yesterday (or given date) and today and returns message sequence numbers.
|
|
# Return <message set>
|
|
def fetch_available_mail_sequence_numbers
|
|
imap_client.search(['SINCE', since])
|
|
end
|
|
|
|
def build_imap_client
|
|
imap = Net::IMAP.new(channel.imap_address, port: channel.imap_port, ssl: channel.imap_enable_ssl)
|
|
Imap::Authentication.authenticate!(imap, authentication_type, channel.imap_login, imap_password)
|
|
|
|
imap.select('INBOX')
|
|
imap
|
|
end
|
|
|
|
def terminate_imap_connection
|
|
imap_client.logout
|
|
rescue Net::IMAP::Error => e
|
|
Rails.logger.info "Logout failed for #{channel.email} - #{e.message}."
|
|
imap_client.disconnect
|
|
end
|
|
|
|
def build_mail_from_string(raw_email_content)
|
|
Mail.read_from_string(raw_email_content)
|
|
end
|
|
|
|
def since
|
|
previous_day = Time.zone.today - (interval || 1).to_i
|
|
previous_day.strftime('%d-%b-%Y')
|
|
end
|
|
end
|