feat: include support mailbox cases

- Build conversation without persisting in NewConversationStrategy
- Wrap conversation persistence with message creation in single transaction
- Extract persist_conversation_if_needed method for clarity
- Update tests to reflect build-not-create behavior
- Add edge case handling from old SupportMailbox
This commit is contained in:
Shivam Mishra
2025-11-03 13:20:22 +05:30
parent e5e4c51936
commit 6fc825d304
3 changed files with 45 additions and 26 deletions
+18 -3
View File
@@ -7,9 +7,15 @@ class ReplyMailbox < ApplicationMailbox
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
return unless @conversation
decorate_mail
create_message
add_attachments_to_message
# Wrap everything in a transaction to ensure atomicity
# This prevents orphan conversations if message/attachment creation fails
# and ensures idempotency on job retry (conversation won't be duplicated)
ActiveRecord::Base.transaction do
persist_conversation_if_needed
decorate_mail
create_message
add_attachments_to_message
end
end
private
@@ -20,6 +26,15 @@ class ReplyMailbox < ApplicationMailbox
Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
end
def persist_conversation_if_needed
# Save the conversation if it's a new record (from NewConversationStrategy)
# We persist here instead of in the strategy to maintain transaction integrity
return unless @conversation.new_record?
@conversation.save!
Rails.logger.info "Created new conversation #{@conversation.id} for email #{mail.message_id}"
end
def decorate_mail
@processed_mail = MailPresenter.new(mail, @conversation.account)
end
@@ -4,8 +4,12 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
attr_accessor :processed_mail, :account, :inbox, :contact, :contact_inbox, :conversation
# This strategy always succeeds by creating a new conversation if none was found.
# It should be used as the last strategy in the chain to ensure emails are never dropped.
# This strategy prepares a new conversation but doesn't persist it yet.
# Why we don't use create! here:
# - Avoids orphan conversations if message/attachment creation fails later
# - Prevents duplicate conversations on job retry (no idempotency issue)
# - Follows the pattern from old SupportMailbox where everything was in one transaction
# The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
def find
channel = EmailChannelFinder.new(mail).perform
return nil unless channel # No valid channel found
@@ -21,12 +25,11 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
existing_conversation = find_conversation_by_in_reply_to
return existing_conversation if existing_conversation
ActiveRecord::Base.transaction do
find_or_create_contact
create_conversation
end
# Prepare contact (persisted) but not conversation
find_or_create_contact
@conversation
# Build conversation without saving - ReplyMailbox will handle persistence
build_conversation
end
private
@@ -48,8 +51,9 @@ class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::
@processed_mail.sender_name || @processed_mail.from.first.split('@').first
end
def create_conversation
@conversation = ::Conversation.create!(
def build_conversation
# Build but don't persist - ReplyMailbox will save in transaction with message
@conversation = ::Conversation.new(
account_id: @account.id,
inbox_id: @inbox.id,
contact_id: @contact.id,
@@ -15,16 +15,17 @@ RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
describe '#find' do
context 'when channel is found' do
context 'with new contact' do
it 'creates a new conversation with new contact' do
it 'builds a new conversation with new contact' do
strategy = described_class.new(mail)
expect do
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true) # Not persisted yet
expect(conversation.inbox).to eq(email_channel.inbox)
expect(conversation.account).to eq(account)
end.to change(Conversation, :count).by(1)
.and change(Contact, :count).by(1)
end.to change(Conversation, :count).by(0) # No conversation created yet
.and change(Contact, :count).by(1) # Contact is created
.and change(ContactInbox, :count).by(1)
end
@@ -53,14 +54,15 @@ RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
create(:contact_inbox, contact: existing_contact, inbox: email_channel.inbox)
end
it 'creates conversation with existing contact' do
it 'builds conversation with existing contact' do
strategy = described_class.new(mail)
expect do
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true) # Not persisted yet
expect(conversation.contact).to eq(existing_contact)
end.to change(Conversation, :count).by(1)
end.to change(Conversation, :count).by(0) # No conversation created yet
.and not_change(Contact, :count)
.and not_change(ContactInbox, :count)
end
@@ -142,19 +144,17 @@ RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
context 'when conversation creation fails' do
before do
# Make conversation creation fail
allow(Conversation).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
# Make conversation build fail with invalid attributes
allow(Conversation).to receive(:new).and_return(Conversation.new)
end
it 'rolls back the transaction' do
it 'returns invalid conversation object' do
strategy = described_class.new(mail)
expect do
strategy.find
end.to raise_error(ActiveRecord::RecordInvalid)
.and not_change(Conversation, :count)
.and not_change(Contact, :count)
.and not_change(ContactInbox, :count)
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true)
expect(conversation.valid?).to be(false)
end
end
end