Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe AdministratorNotifications::AccountComplianceMailer do
let(:account) do
create(:account, custom_attributes: { 'marked_for_deletion_at' => 1.day.ago.iso8601, 'marked_for_deletion_reason' => 'user_requested' })
end
let(:soft_deleted_users) do
[
{ id: 1, original_email: 'user1@example.com' },
{ id: 2, original_email: 'user2@example.com' }
]
end
describe 'account_deleted' do
it 'has the right subject format' do
subject = described_class.new.send(:subject_for, account)
expect(subject).to eq("Account Deletion Notice for #{account.id} - #{account.name}")
end
it 'includes soft deleted users in meta when provided' do
mailer_instance = described_class.new
allow(mailer_instance).to receive(:params).and_return(
{ soft_deleted_users: soft_deleted_users }
)
meta = mailer_instance.send(:build_meta, account)
expect(meta['deleted_user_count']).to eq(2)
expect(meta['soft_deleted_users'].size).to eq(2)
expect(meta['soft_deleted_users'].first['user_id']).to eq('1')
expect(meta['soft_deleted_users'].first['user_email']).to eq('user1@example.com')
end
end
end

View File

@@ -0,0 +1,46 @@
require 'rails_helper'
RSpec.describe AdministratorNotifications::AccountNotificationMailer do
let(:account) { create(:account, name: 'Test Account') }
let(:mailer) { described_class.with(account: account) }
let(:class_instance) { described_class.new }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
account.custom_attributes['marked_for_deletion_at'] = 7.days.from_now.iso8601
account.save!
end
describe '#account_deletion_user_initiated' do
it 'sets the correct subject for user-initiated deletion' do
mail = mailer.account_deletion_user_initiated(account, 'manual_deletion')
expect(mail.subject).to eq('Your Chatwoot account deletion has been scheduled')
end
end
describe '#account_deletion_for_inactivity' do
it 'sets the correct subject for system-initiated deletion' do
mail = mailer.account_deletion_for_inactivity(account, 'Account Inactive')
expect(mail.subject).to eq('Your Chatwoot account is scheduled for deletion due to inactivity')
end
end
describe '#format_deletion_date' do
it 'formats a valid date string' do
date_str = '2024-12-31T12:00:00Z'
formatted = described_class.new.send(:format_deletion_date, date_str)
expect(formatted).to eq('December 31, 2024')
end
it 'handles blank dates' do
formatted = described_class.new.send(:format_deletion_date, nil)
expect(formatted).to eq('Unknown')
end
it 'handles invalid dates' do
formatted = described_class.new.send(:format_deletion_date, 'invalid-date')
expect(formatted).to eq('Unknown')
end
end
end

View File

@@ -0,0 +1,74 @@
require 'rails_helper'
RSpec.describe AdministratorNotifications::BaseMailer do
let!(:account) { create(:account) }
let!(:admin1) { create(:user, account: account, role: :administrator) }
let!(:admin2) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
let(:mailer) { described_class.new }
let!(:inbox) { create(:inbox, account: account) }
before do
Current.account = account
end
describe 'admin_emails' do
it 'returns emails of all administrators' do
# Call the private method
admin_emails = mailer.send(:admin_emails)
expect(admin_emails).to contain_exactly(admin1.email, admin2.email)
expect(admin_emails).not_to include(agent.email)
end
end
describe 'helper methods' do
it 'generates correct inbox URL' do
url = mailer.inbox_url(inbox)
expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/inboxes/#{inbox.id}"
expect(url).to eq(expected_url)
end
it 'generates correct settings URL' do
url = mailer.settings_url('automation/list')
expected_url = "#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/automation/list"
expect(url).to eq(expected_url)
end
end
describe 'send_notification' do
before do
allow(mailer).to receive(:smtp_config_set_or_development?).and_return(true)
end
it 'sends email with correct parameters' do
subject = 'Test Subject'
action_url = 'https://example.com'
meta = { 'key' => 'value' }
# Mock the send_mail_with_liquid method
expect(mailer).to receive(:send_mail_with_liquid).with(
to: contain_exactly(admin1.email, admin2.email),
subject: subject
).and_return(true)
mailer.send_notification(subject, action_url: action_url, meta: meta)
# Check that instance variables are set correctly
expect(mailer.instance_variable_get(:@action_url)).to eq(action_url)
expect(mailer.instance_variable_get(:@meta)).to eq(meta)
end
it 'uses provided email addresses when specified' do
subject = 'Test Subject'
custom_email = 'custom@example.com'
expect(mailer).to receive(:send_mail_with_liquid).with(
to: custom_email,
subject: subject
).and_return(true)
mailer.send_notification(subject, to: custom_email)
end
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe AdministratorNotifications::ChannelNotificationsMailer do
include_context 'with smtp config'
let(:class_instance) { described_class.new }
let!(:account) { create(:account) }
let!(:administrator) { create(:user, :administrator, email: 'agent1@example.com', account: account) }
let!(:another_administrator) { create(:user, :administrator, email: 'agent2@example.com', account: account) }
describe 'facebook_disconnect' do
before do
stub_request(:post, /graph.facebook.com/)
end
let!(:facebook_channel) { create(:channel_facebook_page, account: account) }
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
context 'when sending the actual email' do
let(:mail) { described_class.with(account: account).facebook_disconnect(facebook_inbox).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Facebook page connection has expired')
end
it 'renders the receiver email' do
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
end
describe 'whatsapp_disconnect' do
let!(:whatsapp_channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
let!(:whatsapp_inbox) { create(:inbox, channel: whatsapp_channel, account: account) }
let(:mail) { described_class.with(account: account).whatsapp_disconnect(whatsapp_inbox).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Whatsapp connection has expired')
end
it 'renders the receiver email' do
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
describe 'instagram_disconnect' do
let!(:instagram_channel) { create(:channel_instagram, account: account) }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account) }
let(:mail) { described_class.with(account: account).instagram_disconnect(instagram_inbox).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Instagram connection has expired')
end
it 'renders the receiver email' do
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
end

View File

@@ -0,0 +1,42 @@
require 'rails_helper'
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
RSpec.describe AdministratorNotifications::IntegrationsNotificationMailer do
include_context 'with smtp config'
let!(:account) { create(:account) }
let!(:administrator) { create(:user, :administrator, email: 'admin@example.com', account: account) }
let!(:another_administrator) { create(:user, :administrator, email: 'owner@example.com', account: account) }
describe 'slack_disconnect' do
let(:mail) { described_class.with(account: account).slack_disconnect.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Slack integration has expired')
end
it 'renders the receiver email' do
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
it 'includes reconnect instructions in the body' do
expect(mail.body.encoded).to include('To continue receiving messages on Slack, please delete the integration and connect your workspace again')
end
end
describe 'dialogflow_disconnect' do
let(:mail) { described_class.with(account: account).dialogflow_disconnect.deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq('Your Dialogflow integration was disconnected')
end
it 'renders the content' do
expect(mail.body.encoded).to include('Your Dialogflow integration was disconnected because of permission issues')
end
it 'renders the receiver email' do
expect(mail.to).to contain_exactly(administrator.email, another_administrator.email)
end
end
end

View File

@@ -0,0 +1,11 @@
# frozen_string_literal: true
RSpec.shared_context 'with smtp config' do
before do
# We need to use allow_any_instance_of here because smtp_config_set_or_development?
# is defined in ApplicationMailer and needs to be stubbed for all mailer instances
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(ApplicationMailer).to receive(:smtp_config_set_or_development?).and_return(true)
# rubocop:enable RSpec/AnyInstance
end
end

View File

@@ -0,0 +1,108 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AgentNotifications::ConversationNotificationsMailer do
let(:class_instance) { described_class.new }
let!(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let(:conversation) { create(:conversation, assignee: agent, account: account) }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
end
describe 'conversation_creation' do
let(:mail) { described_class.with(account: account).conversation_creation(conversation, agent, nil).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("#{agent.available_name}, A new conversation [ID - #{conversation
.display_id}] has been created in #{conversation.inbox&.sanitized_name}.")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
end
describe 'conversation_assignment' do
let(:mail) { described_class.with(account: account).conversation_assignment(conversation, agent, nil).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("#{agent.available_name}, A new conversation [ID - #{conversation.display_id}] has been assigned to you.")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
end
describe 'conversation_mention' do
let(:contact) { create(:contact, name: nil, account: account) }
let(:another_agent) { create(:user, email: 'agent2@example.com', account: account) }
let(:message) { create(:message, conversation: conversation, account: account, sender: another_agent) }
let(:mail) { described_class.with(account: account).conversation_mention(conversation, agent, message).deliver_now }
let(:contact_inbox) { create(:contact_inbox, account: account, inbox: conversation.inbox) }
before do
create(:message, conversation: conversation, account: account, sender: contact)
create(:message, conversation: conversation, account: account, sender: contact)
create(:message, conversation: conversation, account: account, sender: contact)
end
it 'renders the subject' do
expect(mail.subject).to eq("#{agent.available_name}, You have been mentioned in conversation [ID - #{conversation.display_id}]")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
it 'renders the senders name' do
expect(mail.body.encoded).to match("You've been mentioned in a conversation. <b>#{another_agent.display_name}</b> wrote:")
end
it 'renders Customer if contacts name not available in the conversation' do
expect(contact.name).to be_nil
expect(conversation.recent_messages).not_to be_empty
expect(mail.body.encoded).to match('Incoming Message')
end
end
describe 'assigned_conversation_new_message' do
let(:message) { create(:message, conversation: conversation, account: account) }
let(:mail) { described_class.with(account: account).assigned_conversation_new_message(conversation, agent, message).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("#{agent.available_name}, New message in your assigned conversation [ID - #{message.conversation.display_id}].")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
it 'will not send email if agent is online' do
OnlineStatusTracker.update_presence(conversation.account.id, 'User', agent.id)
expect(mail).to be_nil
end
end
describe 'participating_conversation_new_message' do
let(:message) { create(:message, conversation: conversation, account: account) }
let(:mail) { described_class.with(account: account).participating_conversation_new_message(conversation, agent, message).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("#{agent.available_name}, New message in your participating conversation [ID - #{message.conversation.display_id}].")
end
it 'renders the receiver email' do
expect(mail.to).to eq([agent.email])
end
it 'will not send email if agent is online' do
OnlineStatusTracker.update_presence(conversation.account.id, 'User', agent.id)
expect(mail).to be_nil
end
end
end

View File

@@ -0,0 +1,95 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Devise::Mailer' do
describe 'notify' do
let(:account) { create(:account) }
let!(:confirmable_user) { create(:user, inviter: inviter_val, account: account) }
let(:inviter_val) { nil }
let(:mail) { Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {}) }
before do
# to verify the token in email
confirmable_user.update!(confirmed_at: nil)
confirmable_user.send(:generate_confirmation_token)
end
it 'has the correct header data' do
expect(mail.reply_to).to contain_exactly('accounts@chatwoot.com')
expect(mail.to).to contain_exactly(confirmable_user.email)
expect(mail.subject).to eq('Confirmation Instructions')
end
it 'uses the user\'s name' do
expect(mail.body).to match("Hi #{CGI.escapeHTML(confirmable_user.name)},")
end
it 'does not refer to the inviter and their account' do
expect(mail.body).not_to match('has invited you to try out Chatwoot!')
expect(mail.body).to match('We have a suite of powerful tools ready for you to explore.')
end
it 'sends a confirmation link' do
expect(mail.body).to include("app/auth/confirmation?confirmation_token=#{confirmable_user.confirmation_token}")
expect(mail.body).not_to include('app/auth/password/edit')
end
context 'when there is an inviter' do
let(:inviter_val) { create(:user, :administrator, skip_confirmation: true, account: account) }
it 'refers to the inviter and their account' do
expect(mail.body).to match(
"#{CGI.escapeHTML(inviter_val.name)}, with #{CGI.escapeHTML(account.name)}, has invited you to try out Chatwoot."
)
expect(mail.body).not_to match('We have a suite of powerful tools ready for you to explore.')
end
it 'sends a password reset link' do
expect(mail.body).to include('app/auth/password/edit?reset_password_token')
expect(mail.body).not_to include('app/auth/confirmation')
end
end
context 'when user updates the email' do
before do
confirmable_user.update!(email: 'user@example.com')
end
it 'sends a confirmation link' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
expect(confirmation_mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmation_mail.body).not_to include('app/auth/password/edit')
expect(confirmable_user.unconfirmed_email.blank?).to be false
end
end
context 'when user is confirmed and updates the email' do
before do
confirmable_user.confirm
confirmable_user.update!(email: 'user@example.com')
end
it 'sends a confirmation link' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
expect(confirmation_mail.body).to include('app/auth/confirmation?confirmation_token')
expect(confirmation_mail.body).not_to include('app/auth/password/edit')
expect(confirmable_user.unconfirmed_email.blank?).to be false
end
end
context 'when user already confirmed' do
before do
confirmable_user.confirm
confirmable_user.account_users.last.destroy!
end
it 'send instructions with the link to login' do
confirmation_mail = Devise::Mailer.confirmation_instructions(confirmable_user.reload, nil, {})
expect(confirmation_mail.body).to include('/auth/sign_in')
end
end
end
end

View File

@@ -0,0 +1,697 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ConversationReplyMailer do
describe 'reply' do
let!(:account) { create(:account) }
let!(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let(:class_instance) { described_class.new }
let(:email_channel) { create(:channel_email, account: account) }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
end
context 'with summary' do
let(:conversation) { create(:conversation, account: account, assignee: agent) }
let(:message) do
create(:message,
account: account,
conversation: conversation,
content_attributes: {
cc_emails: 'agent_cc1@example.com',
bcc_emails: 'agent_bcc1@example.com'
})
end
let(:new_message) do
create(:message,
account: account,
conversation: conversation,
content_attributes: {
cc_emails: 'agent_cc2@example.com',
bcc_emails: 'agent_bcc2@example.com'
})
end
let(:cc_message) do
create(:message,
account: account,
message_type: :outgoing,
conversation: conversation,
content_attributes: {
cc_emails: 'agent_cc1@example.com',
bcc_emails: 'agent_bcc1@example.com'
})
end
let(:private_message) { create(:message, account: account, content: 'This is a private message', conversation: conversation) }
let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now }
let(:cc_mail) { described_class.reply_with_summary(cc_message.conversation, message.id).deliver_now }
it 'renders the default subject' do
expect(mail.subject).to eq("[##{message.conversation.display_id}] New messages on this conversation")
end
it 'renders the subject in conversation as reply' do
conversation.additional_attributes = { 'mail_subject': 'Mail Subject' }
conversation.save!
new_message.save!
expect(mail.subject).to eq('Re: Mail Subject')
end
it 'not have private notes' do
# make the message private
private_message.private = true
private_message.save!
expect(mail.body.decoded).not_to include(private_message.content)
expect(mail.body.decoded).to include(message.content)
end
it 'will not send email if conversation is already viewed by contact' do
create(:message, message_type: 'outgoing', account: account, conversation: conversation)
conversation.update(contact_last_seen_at: Time.zone.now)
expect(mail).to be_nil
end
it 'will send email to cc and bcc email addresses' do
expect(cc_mail.cc.first).to eq(cc_message.content_attributes[:cc_emails])
expect(cc_mail.bcc.first).to eq(cc_message.content_attributes[:bcc_emails])
end
end
context 'without assignee' do
let(:conversation) { create(:conversation, assignee: nil) }
let(:message) { create(:message, message_type: :outgoing, conversation: conversation) }
let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now }
it 'has correct name' do
expect(mail[:from].display_names).to eq(["#{message.sender.available_name} from #{message.conversation.inbox.sanitized_name}"])
end
end
context 'without summary' do
let(:conversation) { create(:conversation, assignee: agent, account: account).reload }
let(:message_1) { create(:message, conversation: conversation, account: account, content: 'Outgoing Message 1').reload }
let(:message_2) { build(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
let(:private_message) do
create(:message,
content: 'This is a private message',
conversation: conversation,
account: account,
message_type: 'outgoing').reload
end
let(:mail) { described_class.reply_without_summary(message_2.conversation, message_2.id).deliver_now }
before do
message_2.save!
end
it 'renders the default subject' do
expect(mail.subject).to eq("[##{message_2.conversation.display_id}] New messages on this conversation")
end
it 'renders the subject in conversation' do
conversation.additional_attributes = { 'mail_subject': 'Mail Subject' }
conversation.save!
expect(mail.subject).to eq('Mail Subject')
end
it 'not have private notes' do
# make the message private
private_message.private = true
private_message.save!
expect(mail.body.decoded).not_to include(private_message.content)
end
it 'onlies have the messages sent by the agent' do
expect(mail.body.decoded).not_to include(message_1.content)
expect(mail.body.decoded).to include(message_2.content)
end
it 'will not send email if conversation is already viewed by contact' do
create(:message, message_type: 'outgoing', account: account, conversation: conversation)
conversation.update(contact_last_seen_at: Time.zone.now)
expect(mail).to be_nil
end
end
context 'with references header' do
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
let(:mail) { described_class.email_reply(message).deliver_now }
context 'when starting a new conversation' do
let(:first_outgoing_message) do
create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'First outgoing message')
end
let(:mail) { described_class.email_reply(first_outgoing_message).deliver_now }
it 'has only the conversation reference' do
# When starting a conversation, references will have the default conversation ID
# Extract domain from the actual references header to handle dynamic domain selection
actual_domain = mail.references.split('@').last
expected_reference = "account/#{account.id}/conversation/#{conversation.uuid}@#{actual_domain}"
expect(mail.references).to eq(expected_reference)
end
end
context 'when replying to a message with no references' do
let(:incoming_message) do
create(:message,
conversation: conversation,
account: account,
message_type: 'incoming',
source_id: '<incoming-123@example.com>',
content: 'Incoming message',
content_attributes: {
'email' => {
'message_id' => 'incoming-123@example.com'
}
})
end
let(:reply_message) do
create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Reply to incoming')
end
let(:mail) { described_class.email_reply(reply_message).deliver_now }
before do
incoming_message
end
it 'includes only the in_reply_to id in references' do
# References should only have the incoming message ID when no prior references exist
expect(mail.references).to eq('incoming-123@example.com')
end
end
context 'when replying to a message that has references' do
let(:incoming_message_with_refs) do
create(:message,
conversation: conversation,
account: account,
message_type: 'incoming',
source_id: '<incoming-456@example.com>',
content: 'Incoming with references',
content_attributes: {
'email' => {
'message_id' => 'incoming-456@example.com',
'references' => ['<ref-1@example.com>', '<ref-2@example.com>']
}
})
end
let(:reply_message) do
create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Reply to message with refs')
end
let(:mail) { described_class.email_reply(reply_message).deliver_now }
before do
incoming_message_with_refs
end
it 'includes existing references plus the in_reply_to id' do
# Rails returns references as an array when multiple values are present
expected_references = ['ref-1@example.com', 'ref-2@example.com', 'incoming-456@example.com']
expect(mail.references).to eq(expected_references)
end
end
end
context 'with email reply' do
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
let(:mail) { described_class.email_reply(message).deliver_now }
it 'renders the subject' do
expect(mail.subject).to eq("[##{message.conversation.display_id}] New messages on this conversation")
end
it 'renders the body' do
expect(mail.decoded).to include message.content
end
it 'builds messageID properly' do
expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
end
context 'when message is a CSAT survey' do
let(:csat_message) do
create(:message, conversation: conversation, account: account, message_type: 'template',
content_type: 'input_csat', content: 'How would you rate our support?', sender: agent)
end
it 'includes CSAT survey URL in outgoing_content' do
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
mail = described_class.email_reply(csat_message).deliver_now
expect(mail.decoded).to include "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
end
end
it 'uses outgoing_content for CSAT message body' do
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
mail = described_class.email_reply(csat_message).deliver_now
expect(mail.decoded).to include csat_message.outgoing_content
end
end
end
context 'with email attachments' do
it 'includes small attachments as email attachments' do
message_with_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
content: 'Message with small attachment')
attachment = message_with_attachment.attachments.new(account_id: account.id, file_type: :file)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
attachment.save!
mail = described_class.email_reply(message_with_attachment).deliver_now
# Should be attached to the email
expect(mail.attachments.map(&:filename).map(&:to_s)).to include('avatar.png')
# Should not be in large_attachments
expect(mail.body.encoded).not_to include('Attachments:')
end
it 'renders large attachments as links in the email body' do
message_with_large_attachment = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
content: 'Message with large attachment')
attachment = message_with_large_attachment.attachments.new(account_id: account.id, file_type: :file)
attachment.file.attach(io: Rails.root.join('spec/assets/large_file.pdf').open, filename: 'large_file.pdf', content_type: 'application/pdf')
attachment.save!
mail = described_class.email_reply(message_with_large_attachment).deliver_now
# Should NOT be attached to the email
expect(mail.attachments.map(&:filename).map(&:to_s)).not_to include('large_file.pdf')
# Should be rendered as a link in the body
expect(mail.body.encoded).to include('Attachments:')
expect(mail.body.encoded).to include('large_file.pdf')
# Should render a link with large_file.pdf as the link text
expect(mail.body.encoded).to match(%r{<a [^>]*>large_file\.pdf</a>})
# Small file should not be rendered as a link in the body
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
end
it 'handles both small and large attachments correctly' do
message_with_mixed_attachments = create(:message, conversation: conversation, account: account, message_type: 'outgoing',
content: 'Message with mixed attachments')
# Small attachment
small_attachment = message_with_mixed_attachments.attachments.new(account_id: account.id, file_type: :file)
small_attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
small_attachment.save!
# Large attachment
large_attachment = message_with_mixed_attachments.attachments.new(account_id: account.id, file_type: :file)
large_attachment.file.attach(io: Rails.root.join('spec/assets/large_file.pdf').open, filename: 'large_file.pdf',
content_type: 'application/pdf')
large_attachment.save!
mail = described_class.email_reply(message_with_mixed_attachments).deliver_now
# Small file should be attached
expect(mail.attachments.map(&:filename).map(&:to_s)).to include('avatar.png')
# Large file should NOT be attached
expect(mail.attachments.map(&:filename).map(&:to_s)).not_to include('large_file.pdf')
# Large file should be rendered as a link in the body
expect(mail.body.encoded).to include('Attachments:')
expect(mail.body.encoded).to include('large_file.pdf')
# Should render a link with large_file.pdf as the link text
expect(mail.body.encoded).to match(%r{<a [^>]*>large_file\.pdf</a>})
# Small file should not be rendered as a link in the body
expect(mail.body.encoded).not_to match(%r{<a [^>]*>avatar\.png</a>})
end
end
context 'with custom email content' do
it 'uses custom HTML content when available and creates multipart email' do
message_with_custom_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular message content',
content_attributes: {
email: {
html_content: {
reply: '<p>Custom <strong>HTML</strong> content for email</p>'
},
text_content: {
reply: 'Custom text content for email'
}
}
})
mail = described_class.email_reply(message_with_custom_content).deliver_now
# Check HTML part contains custom HTML content
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<p>Custom <strong>HTML</strong> content for email</p>')
expect(html_part.body.encoded).not_to include('Regular message content')
# Check text part contains custom text content
text_part = mail.text_part
if text_part
expect(text_part.body.encoded).to include('Custom text content for email')
expect(text_part.body.encoded).not_to include('Regular message content')
end
end
it 'falls back to markdown rendering when custom HTML content is not available' do
message_without_custom_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content')
mail = described_class.email_reply(message_without_custom_content).deliver_now
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<strong>markdown</strong>')
expect(html_part.body.encoded).to include('Regular')
end
it 'handles empty custom HTML content gracefully' do
message_with_empty_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content',
content_attributes: {
email: {
html_content: {
reply: ''
}
}
})
mail = described_class.email_reply(message_with_empty_content).deliver_now
html_part = mail.html_part || mail
expect(html_part.body.encoded).to include('<strong>markdown</strong>')
expect(html_part.body.encoded).to include('Regular')
end
it 'handles nil custom HTML content gracefully' do
message_with_nil_content = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular **markdown** content',
content_attributes: {
email: {
html_content: {
reply: nil
}
}
})
mail = described_class.email_reply(message_with_nil_content).deliver_now
expect(mail.body.encoded).to include('<strong>markdown</strong>')
expect(mail.body.encoded).to include('Regular')
end
it 'uses custom text content in text part when only text is provided' do
message_with_text_only = create(:message,
conversation: conversation,
account: account,
message_type: 'outgoing',
content: 'Regular message content',
content_attributes: {
email: {
text_content: {
reply: 'Custom text content only'
}
}
})
mail = described_class.email_reply(message_with_text_only).deliver_now
text_part = mail.text_part
if text_part
expect(text_part.body.encoded).to include('Custom text content only')
expect(text_part.body.encoded).not_to include('Regular message content')
end
end
end
end
context 'when smtp enabled for email channel' do
let(:smtp_channel) do
create(:channel_email, smtp_enabled: true, smtp_address: 'smtp.gmail.com', smtp_port: 587, smtp_login: 'smtp@gmail.com',
smtp_password: 'password', smtp_domain: 'smtp.gmail.com', account: account)
end
let(:conversation) { create(:conversation, assignee: agent, inbox: smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do
mail = described_class.email_reply(message)
expect(mail.delivery_method.settings.empty?).to be false
expect(mail.delivery_method.settings[:address]).to eq 'smtp.gmail.com'
expect(mail.delivery_method.settings[:port]).to eq 587
end
it 'renders sender name in the from address' do
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders sender name even when assignee is not present' do
conversation.update(assignee_id: nil)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{message.sender.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders assignee name in the from address when sender_name not available' do
message.update(sender_id: nil)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{conversation.assignee.available_name} from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders inbox name as sender and assignee or business_name not present' do
message.update(sender_id: nil)
conversation.update(assignee_id: nil)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "Notifications from #{smtp_channel.inbox.sanitized_name} <#{smtp_channel.email}>"
end
context 'when friendly name enabled' do
before do
conversation.inbox.update(sender_name_type: 0)
conversation.inbox.update(business_name: 'Business Name')
end
it 'renders sender name as sender and assignee and business_name not present' do
message.update(sender_id: nil)
conversation.update(assignee_id: nil)
conversation.inbox.update(business_name: nil)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "Notifications from #{conversation.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders sender name as sender and assignee nil and business_name present' do
message.update(sender_id: nil)
conversation.update(assignee_id: nil)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq(
"Notifications from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
)
end
it 'renders sender name as sender nil and assignee and business_name present' do
message.update(sender_id: nil)
conversation.update(assignee_id: agent.id)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{agent.available_name} from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
end
it 'renders sender name as sender and assignee and business_name present' do
agent_2 = create(:user, email: 'agent2@example.com', account: account)
message.update(sender_id: agent_2.id)
conversation.update(assignee_id: agent.id)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{agent_2.available_name} from #{conversation.inbox.business_name} <#{smtp_channel.email}>"
end
end
context 'when friendly name disabled' do
before do
conversation.inbox.update(sender_name_type: 1)
conversation.inbox.update(business_name: 'Business Name')
end
it 'renders sender name as business_name not present' do
message.update(sender_id: nil)
conversation.update(assignee_id: nil)
conversation.inbox.update(business_name: nil)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{conversation.inbox.sanitized_name} <#{smtp_channel.email}>"
end
it 'renders sender name as business_name present' do
message.update(sender_id: nil)
conversation.update(assignee_id: nil)
mail = described_class.email_reply(message)
expect(mail['from'].value).to eq "#{conversation.inbox.business_name} <#{smtp_channel.email}>"
end
end
end
context 'when smtp enabled for microsoft email channel' do
let(:ms_smtp_channel) do
create(:channel_email, imap_login: 'smtp@outlook.com',
imap_enabled: true, account: account, provider: 'microsoft', provider_config: { access_token: 'access_token' })
end
let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do
mail = described_class.email_reply(message)
expect(mail.delivery_method.settings.empty?).to be false
expect(mail.delivery_method.settings[:address]).to eq 'smtp.office365.com'
expect(mail.delivery_method.settings[:port]).to eq 587
end
end
context 'when smtp enabled for google email channel' do
let(:ms_smtp_channel) do
create(:channel_email, imap_login: 'smtp@gmail.com',
imap_enabled: true, account: account, provider: 'google', provider_config: { access_token: 'access_token' })
end
let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do
mail = described_class.email_reply(message)
expect(mail.delivery_method.settings.empty?).to be false
expect(mail.delivery_method.settings[:address]).to eq 'smtp.gmail.com'
expect(mail.delivery_method.settings[:port]).to eq 587
end
end
context 'when smtp disabled for email channel', :test do
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use default mail server' do
mail = described_class.email_reply(message)
expect(mail.delivery_method.settings).to be_empty
end
end
context 'when custom domain and email is not enabled' do
let(:inbox) { create(:inbox, account: account) }
let(:inbox_member) { create(:inbox_member, user: agent, inbox: inbox) }
let(:conversation) { create(:conversation, assignee: agent, inbox: inbox_member.inbox, account: account) }
let!(:message) { create(:message, conversation: conversation, account: account) }
let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now }
let(:domain) { account.inbound_email_domain }
it 'renders the receiver email' do
expect(mail.to).to eq([message&.conversation&.contact&.email])
end
it 'renders the reply to email' do
expect(mail.reply_to).to eq([message&.conversation&.assignee&.email])
end
it 'sets the correct custom message id' do
expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{domain}")
end
it 'sets the correct in reply to id' do
expect(mail.in_reply_to).to eq("account/#{conversation.account.id}/conversation/#{conversation.uuid}@#{domain}")
end
end
context 'when inbox email address is available' do
let(:inbox) { create(:inbox, account: account, email_address: 'noreply@chatwoot.com') }
let(:conversation) { create(:conversation, assignee: agent, inbox: inbox, account: account) }
let!(:message) { create(:message, conversation: conversation, account: account) }
let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now }
it 'set reply to email address as inbox email address' do
expect(mail.from).to eq([inbox.email_address])
expect(mail.reply_to).to eq([inbox.email_address])
end
end
context 'when the custom domain emails are enabled' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, assignee: agent, account: account).reload }
let(:message) { create(:message, message_type: :outgoing, conversation: conversation, account: account, inbox: conversation.inbox) }
let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now }
before do
account = conversation.account
account.domain = 'example.com'
account.support_email = 'support@example.com'
account.enable_features('inbound_emails')
account.save!
end
it 'sets reply to email to be based on the domain' do
reply_to_email = "reply+#{message.conversation.uuid}@#{conversation.account.domain}"
reply_to = "#{message.sender.available_name} from #{conversation.inbox.sanitized_name} <#{reply_to_email}>"
expect(mail['REPLY-TO'].value).to eq(reply_to)
expect(mail.reply_to).to eq([reply_to_email])
end
it 'sets the from email to be the support email' do
expect(mail['FROM'].value).to eq("#{conversation.messages.last.sender.available_name} from Inbox <#{conversation.account.support_email}>")
expect(mail.from).to eq([conversation.account.support_email])
end
it 'sets the correct custom message id' do
expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
end
it 'sets the correct in reply to id' do
expect(mail.in_reply_to).to eq("account/#{conversation.account.id}/conversation/#{conversation.uuid}@#{conversation.account.domain}")
end
end
context 'when inbound email domain is not enabled' do
let(:new_account) { create(:account, domain: nil) }
let!(:email_channel) { create(:channel_email, account: new_account) }
let!(:inbox) { create(:inbox, channel: email_channel, account: new_account) }
let(:inbox_member) { create(:inbox_member, user: agent, inbox: inbox) }
let(:conversation) { create(:conversation, assignee: agent, inbox: inbox_member.inbox, account: new_account) }
let!(:message) { create(:message, conversation: conversation, account: new_account) }
let(:mail) { described_class.reply_with_summary(message.conversation, message.id).deliver_now }
let(:domain) { inbox.channel.email.split('@').last }
it 'sets the correct custom message id' do
expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{domain}")
end
it 'sets the correct in reply to id' do
expect(mail.in_reply_to).to eq("account/#{conversation.account.id}/conversation/#{conversation.uuid}@#{domain}")
end
end
end
end

View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PortalInstructionsMailer do
describe 'send_cname_instructions' do
let!(:account) { create(:account) }
let!(:portal) { create(:portal, account: account, custom_domain: 'help.example.com') }
let(:recipient_email) { 'admin@example.com' }
let(:class_instance) { described_class.new }
before do
allow(described_class).to receive(:new).and_return(class_instance)
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
end
context 'when target domain is available' do
it 'sends email with cname instructions' do
with_modified_env HELPCENTER_URL: 'https://help.chatwoot.com' do
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
expect(mail.to).to eq([recipient_email])
expect(mail.subject).to eq("Finish setting up #{portal.custom_domain}")
expect(mail.body.encoded).to include('help.example.com CNAME help.chatwoot.com')
end
end
end
context 'when helpcenter url is not available but frontend url is' do
it 'uses frontend url as target domain' do
with_modified_env HELPCENTER_URL: '', FRONTEND_URL: 'https://app.chatwoot.com' do
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
expect(mail.to).to eq([recipient_email])
expect(mail.body.encoded).to include('help.example.com CNAME app.chatwoot.com')
end
end
end
context 'when no target domain is available' do
it 'does not send email' do
with_modified_env HELPCENTER_URL: '', FRONTEND_URL: '' do
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
expect(mail).to be_nil
end
end
end
end
end

View File

@@ -0,0 +1,164 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ReferencesHeaderBuilder do
include described_class
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, account: account) }
let(:inbox) { create(:inbox, channel: email_channel, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
describe '#build_references_header' do
let(:in_reply_to_message_id) { '<reply-to-123@example.com>' }
context 'when no message is found with the in_reply_to_message_id' do
it 'returns only the in_reply_to message ID' do
result = build_references_header(conversation, in_reply_to_message_id)
expect(result).to eq('<reply-to-123@example.com>')
end
end
context 'when a message is found with matching source_id' do
context 'with stored References' do
let(:original_message) do
create(:message, conversation: conversation, account: account,
source_id: '<reply-to-123@example.com>',
content_attributes: {
'email' => {
'references' => ['<thread-001@example.com>', '<thread-002@example.com>']
}
})
end
before do
original_message
end
it 'includes stored References plus in_reply_to' do
result = build_references_header(conversation, in_reply_to_message_id)
expect(result).to eq("<thread-001@example.com>\r\n <thread-002@example.com>\r\n <reply-to-123@example.com>")
end
it 'removes duplicates while preserving order' do
# If in_reply_to is already in the References, it should appear only once at the end
original_message.content_attributes['email']['references'] = ['<thread-001@example.com>', '<reply-to-123@example.com>']
original_message.save!
result = build_references_header(conversation, in_reply_to_message_id)
message_ids = result.split("\r\n ").map(&:strip)
expect(message_ids).to eq(['<thread-001@example.com>', '<reply-to-123@example.com>'])
end
end
context 'without stored References' do
let(:original_message) do
create(:message, conversation: conversation, account: account,
source_id: 'reply-to-123@example.com', # without angle brackets
content_attributes: { 'email' => {} })
end
before do
original_message
end
it 'returns only the in_reply_to message ID (no rebuild)' do
result = build_references_header(conversation, in_reply_to_message_id)
expect(result).to eq('<reply-to-123@example.com>')
end
end
end
context 'with folding multiple References' do
let(:original_message) do
create(:message, conversation: conversation, account: account,
source_id: '<reply-to-123@example.com>',
content_attributes: {
'email' => {
'references' => ['<msg-001@example.com>', '<msg-002@example.com>', '<msg-003@example.com>']
}
})
end
before do
original_message
end
it 'folds the header with CRLF between message IDs' do
result = build_references_header(conversation, in_reply_to_message_id)
expect(result).to include("\r\n")
lines = result.split("\r\n")
# First line has no leading space, continuation lines do
expect(lines.first).not_to start_with(' ')
expect(lines[1..]).to all(start_with(' '))
end
end
context 'with source_id in different formats' do
it 'finds message with source_id without angle brackets' do
create(:message, conversation: conversation, account: account,
source_id: 'test-123@example.com',
content_attributes: {
'email' => {
'references' => ['<ref-1@example.com>']
}
})
result = build_references_header(conversation, '<test-123@example.com>')
expect(result).to eq("<ref-1@example.com>\r\n <test-123@example.com>")
end
it 'finds message with source_id with angle brackets' do
create(:message, conversation: conversation, account: account,
source_id: '<test-456@example.com>',
content_attributes: {
'email' => {
'references' => ['<ref-2@example.com>']
}
})
result = build_references_header(conversation, 'test-456@example.com')
expect(result).to eq("<ref-2@example.com>\r\n test-456@example.com")
end
end
end
describe '#fold_references_header' do
it 'returns single message ID without folding' do
single_array = ['<msg1@example.com>']
result = fold_references_header(single_array)
expect(result).to eq('<msg1@example.com>')
expect(result).not_to include("\r\n")
end
it 'folds multiple message IDs with CRLF + space' do
multiple_array = ['<msg1@example.com>', '<msg2@example.com>', '<msg3@example.com>']
result = fold_references_header(multiple_array)
expect(result).to eq("<msg1@example.com>\r\n <msg2@example.com>\r\n <msg3@example.com>")
end
it 'ensures RFC 5322 compliance with continuation line spacing' do
multiple_array = ['<msg1@example.com>', '<msg2@example.com>']
result = fold_references_header(multiple_array)
lines = result.split("\r\n")
# First line has no leading space (not a continuation line)
expect(lines.first).to eq('<msg1@example.com>')
expect(lines.first).not_to start_with(' ')
# Second line starts with space (continuation line)
expect(lines[1]).to eq(' <msg2@example.com>')
expect(lines[1]).to start_with(' ')
end
it 'handles empty array' do
result = fold_references_header([])
expect(result).to eq('')
end
end
end