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`
121 lines
3.6 KiB
Ruby
121 lines
3.6 KiB
Ruby
module ConversationReplyMailerHelper
|
|
include ConversationReplyMailerAttachmentHelper
|
|
|
|
def prepare_mail(cc_bcc_enabled)
|
|
@options = {
|
|
to: to_emails,
|
|
from: email_from,
|
|
reply_to: email_reply_to,
|
|
subject: mail_subject,
|
|
message_id: custom_message_id,
|
|
in_reply_to: in_reply_to_email,
|
|
references: references_header
|
|
}
|
|
|
|
if cc_bcc_enabled
|
|
@options[:cc] = cc_bcc_emails[0]
|
|
@options[:bcc] = cc_bcc_emails[1]
|
|
end
|
|
oauth_smtp_settings
|
|
set_delivery_method
|
|
|
|
# Email type detection logic:
|
|
# - email_reply: Sets @message with a single message
|
|
# - Other actions: Set @messages with a collection of messages
|
|
#
|
|
# So this check implicitly determines we're handling an email_reply
|
|
# and not one of the other email types (summary, transcript, etc.)
|
|
process_attachments_as_files_for_email_reply if @message&.attachments.present?
|
|
mail(@options)
|
|
end
|
|
|
|
private
|
|
|
|
def oauth_smtp_settings
|
|
return unless @inbox.email? && @channel.imap_enabled
|
|
return unless oauth_provider_domain
|
|
|
|
@options[:delivery_method] = :smtp
|
|
@options[:delivery_method_options] = base_smtp_settings(oauth_provider_domain)
|
|
end
|
|
|
|
def oauth_provider_domain
|
|
return 'smtp.gmail.com' if @inbox.channel.google?
|
|
return 'smtp.office365.com' if @inbox.channel.microsoft?
|
|
end
|
|
|
|
def base_smtp_settings(domain)
|
|
{
|
|
address: domain,
|
|
port: 587,
|
|
user_name: @channel.imap_login,
|
|
password: @channel.provider_config['access_token'],
|
|
domain: domain,
|
|
tls: false,
|
|
enable_starttls_auto: true,
|
|
openssl_verify_mode: 'none',
|
|
**smtp_timeout_settings,
|
|
authentication: 'xoauth2'
|
|
}
|
|
end
|
|
|
|
def set_delivery_method
|
|
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled
|
|
|
|
smtp_settings = {
|
|
address: @channel.smtp_address,
|
|
port: @channel.smtp_port,
|
|
user_name: @channel.smtp_login,
|
|
password: @channel.smtp_password,
|
|
domain: @channel.smtp_domain,
|
|
tls: @channel.smtp_enable_ssl_tls,
|
|
enable_starttls_auto: @channel.smtp_enable_starttls_auto,
|
|
openssl_verify_mode: @channel.smtp_openssl_verify_mode,
|
|
**smtp_timeout_settings,
|
|
authentication: @channel.smtp_authentication
|
|
}
|
|
|
|
@options[:delivery_method] = :smtp
|
|
@options[:delivery_method_options] = smtp_settings
|
|
end
|
|
|
|
def smtp_timeout_settings
|
|
{
|
|
open_timeout: ENV['SMTP_OPEN_TIMEOUT'].presence || 15,
|
|
read_timeout: ENV['SMTP_READ_TIMEOUT'].presence || 30
|
|
}.transform_values(&:to_i)
|
|
end
|
|
|
|
def email_smtp_enabled?
|
|
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
|
|
end
|
|
|
|
def email_imap_enabled?
|
|
@inbox.inbox_type == 'Email' && @channel.imap_enabled
|
|
end
|
|
|
|
def email_oauth_enabled?
|
|
@inbox.inbox_type == 'Email' && (@channel.microsoft? || @channel.google?)
|
|
end
|
|
|
|
def email_from
|
|
return Email::FromBuilder.new(inbox: @inbox, message: current_message).build if @account.feature_enabled?(:reply_mailer_migration)
|
|
|
|
email_oauth_enabled? || email_smtp_enabled? ? channel_email_with_name : from_email_with_name
|
|
end
|
|
|
|
def email_reply_to
|
|
return Email::ReplyToBuilder.new(inbox: @inbox, message: current_message).build if @account.feature_enabled?(:reply_mailer_migration)
|
|
|
|
email_imap_enabled? ? @channel.email : reply_email
|
|
end
|
|
|
|
# Use channel email domain in case of account email domain is not set for custom message_id and in_reply_to
|
|
def channel_email_domain
|
|
return @account.inbound_email_domain if @account.inbound_email_domain.present?
|
|
|
|
email = @inbox.channel.try(:email)
|
|
email.present? ? email.split('@').last : raise(StandardError, 'Channel email domain not present.')
|
|
end
|
|
end
|