Files
chatwoot/app/mailers/conversation_reply_mailer_helper.rb
Sojan JoseandGitHub 8e42307bdc fix: improve email inbox IMAP and SMTP compatibility (#14589)
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`
2026-06-03 15:56:54 +05:30

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