- <%= portal.header_text.presence || 'How can we help?' %>
+ <%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>
<%= I18n.t('public_portal.hero.sub_title') %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
index 46d6f3558..00f408d70 100644
--- a/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/_topbar.html.erb
@@ -11,7 +11,7 @@
<% if portal.logo.present? %>
<% end %>
- <%= portal.name %>
+ <%= portal.localized_value('name', locale) %>
<%= I18n.t('public_portal.sidebar.help_center') %>
diff --git a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
index a236722ae..aeed7ff6c 100644
--- a/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/articles/_meta_head.html.erb
@@ -1,4 +1,4 @@
-
<%= article.title %> | <%= portal.display_title %>
+
<%= article.title %> | <%= portal.display_title(article.locale) %>
<% if article.meta["title"].present? %>
">
">
diff --git a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
index 7c8dec8d2..ac36496df 100644
--- a/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
+++ b/app/views/public/api/v1/portals/documentation_layout/categories/_meta_head.html.erb
@@ -1,5 +1,5 @@
-
<%= category.name %> | <%= portal.display_title %>
-
+
<%= category.name %> | <%= portal.display_title(category.locale) %>
+
<% if category.description.present? %>
diff --git a/app/views/public/api/v1/portals/search/index.html+documentation.erb b/app/views/public/api/v1/portals/search/index.html+documentation.erb
index 8577a5f4e..f77517618 100644
--- a/app/views/public/api/v1/portals/search/index.html+documentation.erb
+++ b/app/views/public/api/v1/portals/search/index.html+documentation.erb
@@ -1,5 +1,5 @@
<% content_for :head do %>
-
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %>
+
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %>
<% end %>
diff --git a/app/views/public/api/v1/portals/search/index.html.erb b/app/views/public/api/v1/portals/search/index.html.erb
index 82c29775f..b4a1e77ed 100644
--- a/app/views/public/api/v1/portals/search/index.html.erb
+++ b/app/views/public/api/v1/portals/search/index.html.erb
@@ -1,5 +1,5 @@
<% content_for :head do %>
-
<%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %>
+ <%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %>
<% end %>
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
diff --git a/enterprise/app/services/captain/llm/translate_query_service.rb b/enterprise/app/services/captain/llm/translate_query_service.rb
index 93f68b05b..3e05244d3 100644
--- a/enterprise/app/services/captain/llm/translate_query_service.rb
+++ b/enterprise/app/services/captain/llm/translate_query_service.rb
@@ -32,6 +32,10 @@ class Captain::Llm::TranslateQueryService < Captain::BaseTaskService
@llm_credential ||= system_llm_credential
end
+ def counts_toward_usage?
+ false
+ end
+
def query_in_target_language?(query)
detector = CLD3::NNetLanguageIdentifier.new(0, 1000)
result = detector.find_language(query)
diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb
index aa40e8000..c45559165 100644
--- a/enterprise/lib/captain/conversation_completion_service.rb
+++ b/enterprise/lib/captain/conversation_completion_service.rb
@@ -62,6 +62,10 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
@llm_credential ||= system_llm_credential
end
+ def counts_toward_usage?
+ false
+ end
+
def event_name
'captain.conversation_completion'
end
diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb
index a043d38e2..d382204a5 100644
--- a/lib/captain/base_task_service.rb
+++ b/lib/captain/base_task_service.rb
@@ -150,13 +150,12 @@ class Captain::BaseTaskService
end
# Extension point consulted by the Enterprise quota wrapper. Subclasses
- # whose calls run on the operator's key (e.g. internal/onboarding tasks)
- # should override this to return false. When false, the wrapper neither
- # blocks the call on an exhausted captain_responses quota nor decrements
- # it on success — the call participates in the quota system in neither
- # direction.
+ # whose calls should not consume captain_responses should override this to
+ # return false. When false, the wrapper neither blocks the call on an
+ # exhausted captain_responses quota nor decrements it on success — the call
+ # participates in the quota system in neither direction.
def counts_toward_usage?
- true
+ llm_credential&.dig(:source) != :hook
end
def api_key_configured?
@@ -168,7 +167,15 @@ class Captain::BaseTaskService
end
def llm_credential
- @llm_credential ||= hook_llm_credential || system_llm_credential
+ @llm_credential ||= if use_account_openai_hook?
+ hook_llm_credential || system_llm_credential
+ else
+ system_llm_credential
+ end
+ end
+
+ def use_account_openai_hook?
+ false
end
def hook_llm_credential
diff --git a/lib/captain/csat_utility_analysis_service.rb b/lib/captain/csat_utility_analysis_service.rb
index e04a98a7f..7aab18e6c 100644
--- a/lib/captain/csat_utility_analysis_service.rb
+++ b/lib/captain/csat_utility_analysis_service.rb
@@ -63,4 +63,8 @@ class Captain::CsatUtilityAnalysisService < Captain::BaseTaskService
def event_name
'csat_utility_analysis'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/follow_up_service.rb b/lib/captain/follow_up_service.rb
index f02ba9408..c4c1225be 100644
--- a/lib/captain/follow_up_service.rb
+++ b/lib/captain/follow_up_service.rb
@@ -103,4 +103,8 @@ class Captain::FollowUpService < Captain::BaseTaskService
def event_name
'follow_up'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/label_suggestion_service.rb b/lib/captain/label_suggestion_service.rb
index 02f8bd89a..a0e030963 100644
--- a/lib/captain/label_suggestion_service.rb
+++ b/lib/captain/label_suggestion_service.rb
@@ -87,6 +87,10 @@ class Captain::LabelSuggestionService < Captain::BaseTaskService
'label_suggestion'
end
+ def use_account_openai_hook?
+ true
+ end
+
def build_follow_up_context?
false
end
diff --git a/lib/captain/reply_suggestion_service.rb b/lib/captain/reply_suggestion_service.rb
index 2daf0615c..039bdcf26 100644
--- a/lib/captain/reply_suggestion_service.rb
+++ b/lib/captain/reply_suggestion_service.rb
@@ -37,6 +37,10 @@ class Captain::ReplySuggestionService < Captain::BaseTaskService
def event_name
'reply_suggestion'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
Captain::ReplySuggestionService.prepend_mod_with('Captain::ReplySuggestionService')
diff --git a/lib/captain/rewrite_service.rb b/lib/captain/rewrite_service.rb
index 3a217d3c6..6f880e775 100644
--- a/lib/captain/rewrite_service.rb
+++ b/lib/captain/rewrite_service.rb
@@ -56,4 +56,8 @@ class Captain::RewriteService < Captain::BaseTaskService
def event_name
operation
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/captain/summary_service.rb b/lib/captain/summary_service.rb
index 030c0e510..f06aa42ca 100644
--- a/lib/captain/summary_service.rb
+++ b/lib/captain/summary_service.rb
@@ -24,4 +24,8 @@ class Captain::SummaryService < Captain::BaseTaskService
def event_name
'summarize'
end
+
+ def use_account_openai_hook?
+ true
+ end
end
diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml
index 25d0e5196..006a862b2 100644
--- a/lib/filters/filter_keys.yml
+++ b/lib/filters/filter_keys.yml
@@ -44,6 +44,12 @@ conversations:
- "not_equal_to"
- "is_present"
- "is_not_present"
+ contact_id:
+ attribute_type: "standard"
+ data_type: "number"
+ filter_operators:
+ - "equal_to"
+ - "not_equal_to"
priority:
attribute_type: "standard"
data_type: "text"
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index 860791c0e..ccb5d7449 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
],
'default_locale' => 'en',
'layout' => 'classic',
- 'social_profiles' => {}
+ 'social_profiles' => {},
+ 'locale_translations' => {}
}
)
end
diff --git a/spec/drops/contact_drop_spec.rb b/spec/drops/contact_drop_spec.rb
index d00a0924d..cd6d1a185 100644
--- a/spec/drops/contact_drop_spec.rb
+++ b/spec/drops/contact_drop_spec.rb
@@ -11,6 +11,11 @@ describe ContactDrop do
expect(subject.first_name).to eq 'John'
end
+ it 'returns the single word (capitalized) as first name when name has only one word' do
+ contact.update!(name: 'john')
+ expect(subject.first_name).to eq 'John'
+ end
+
it('return the capitalized name') do
contact.update!(name: 'john doe')
expect(subject.name).to eq 'John Doe'
diff --git a/spec/drops/user_drop_spec.rb b/spec/drops/user_drop_spec.rb
index 1093ec4a0..34f8f5eaa 100644
--- a/spec/drops/user_drop_spec.rb
+++ b/spec/drops/user_drop_spec.rb
@@ -11,6 +11,11 @@ describe UserDrop do
expect(subject.first_name).to eq 'John'
end
+ it 'returns the single word as first name when name has only one word' do
+ user.update!(name: 'John')
+ expect(subject.first_name).to eq 'John'
+ end
+
it('return the capitalized first name') do
user.update!(name: 'john doe')
expect(subject.first_name).to eq 'John'
diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb
index b3dc473eb..fb970f726 100644
--- a/spec/enterprise/lib/captain/base_task_service_spec.rb
+++ b/spec/enterprise/lib/captain/base_task_service_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
before do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
end
context 'when usage limit is exceeded' do
@@ -111,6 +112,68 @@ RSpec.describe Captain::BaseTaskService, type: :model do
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
end
+ context 'when account has its own OpenAI hook key' do
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'still increments usage for services that do not opt into BYOK' do
+ expect(account).to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when the captain_responses quota is exhausted on Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'returns usage limit exceeded error for services that do not opt into BYOK' do
+ result = service.perform
+ expect(result[:error]).to eq(I18n.t('captain.copilot_limit'))
+ expect(result[:error_code]).to eq(429)
+ end
+ end
+ end
+
+ context 'when subclass opts into account OpenAI hook usage' do
+ let(:test_service_class) do
+ result = perform_result
+ klass = Class.new(described_class) do
+ define_method(:perform) { result }
+ define_method(:event_name) { 'test_event' }
+ define_method(:use_account_openai_hook?) { true }
+ end
+ klass.prepend(Enterprise::Captain::BaseTaskService)
+ klass
+ end
+
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'does not increment usage on a successful result' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when the captain_responses quota is exhausted on Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'bypasses the 429 gate and returns the underlying result' do
+ result = service.perform
+ expect(result).to eq(perform_result)
+ end
+ end
+ end
+
context 'when captain is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
diff --git a/spec/jobs/mutex_application_job_spec.rb b/spec/jobs/mutex_application_job_spec.rb
index 91a56407d..4c8fa3394 100644
--- a/spec/jobs/mutex_application_job_spec.rb
+++ b/spec/jobs/mutex_application_job_spec.rb
@@ -55,4 +55,57 @@ RSpec.describe MutexApplicationJob do
end.to raise_error(StandardError)
end
end
+
+ describe '.retry_on_lock_conflict' do
+ let(:job_class) do
+ Class.new(described_class) do
+ retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
+
+ attr_reader :fallback_args
+
+ def perform(lock_key, _payload)
+ with_lock(lock_key) { raise 'lock should not be acquired' }
+ end
+
+ def process_without_lock(lock_key, payload)
+ @fallback_args = [lock_key, payload]
+ end
+ end
+ end
+
+ let(:payload) { { 'message' => 'hello' } }
+
+ before do
+ stub_const('LockConflictTestJob', job_class)
+ end
+
+ it 'runs the configured handler with the original job arguments when lock retries are exhausted' do
+ allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
+
+ job = job_class.new(lock_key, payload)
+
+ expect { job.perform_now }.not_to raise_error
+ expect(job.fallback_args).to eq([lock_key, payload])
+ end
+
+ context 'without an exhaustion handler' do
+ let(:job_class) do
+ Class.new(described_class) do
+ retry_on_lock_conflict wait: 1.second, attempts: 1
+
+ def perform(lock_key)
+ with_lock(lock_key) { raise 'lock should not be acquired' }
+ end
+ end
+ end
+
+ it 'raises the lock acquisition error when retries are exhausted' do
+ allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
+
+ expect do
+ job_class.perform_now(lock_key)
+ end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
+ end
+ end
+ end
end
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
index 5112e47ed..34c889967 100644
--- a/spec/lib/captain/base_task_service_spec.rb
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -260,11 +260,12 @@ RSpec.describe Captain::BaseTaskService do
expect(result[:request_messages]).to eq(messages)
end
- it 'does not track exceptions for account hook failures' do
+ it 'tracks exceptions against the system key when an account hook exists' do
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' })
- expect(Llm::Config).to receive(:with_api_key).with('hook-key', api_base: anything).and_raise(error)
- expect(ChatwootExceptionTracker).not_to receive(:new)
+ expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_raise(error)
+ expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
+ expect(exception_tracker).to receive(:capture_exception)
result = service.send(:make_api_call, model: model, messages: messages)
@@ -279,11 +280,60 @@ RSpec.describe Captain::BaseTaskService do
before { hook }
+ it 'uses system api key by default' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+
+ context 'when subclass opts into account OpenAI hook usage' do
+ let(:test_service_class) do
+ Class.new(described_class) do
+ def event_name
+ 'test_event'
+ end
+
+ def use_account_openai_hook?
+ true
+ end
+ end
+ end
+
+ before do
+ create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
+ end
+
it 'uses api key from hook' do
expect(service.send(:api_key)).to eq('hook-key')
end
end
+ it 'uses account OpenAI hook for editor task services' do
+ create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
+ user = create(:user, account: account)
+ follow_up_context = {
+ 'event_name' => 'professional',
+ 'original_context' => 'Original text',
+ 'last_response' => 'Last response'
+ }
+
+ editor_services = [
+ Captain::RewriteService.new(account: account, content: 'Text', operation: 'improve', conversation_display_id: conversation.display_id),
+ Captain::SummaryService.new(account: account, conversation_display_id: conversation.display_id),
+ Captain::ReplySuggestionService.new(account: account, conversation_display_id: conversation.display_id, user: user),
+ Captain::LabelSuggestionService.new(account: account, conversation_display_id: conversation.display_id),
+ Captain::FollowUpService.new(
+ account: account,
+ follow_up_context: follow_up_context,
+ user_message: 'Make it shorter',
+ conversation_display_id: conversation.display_id
+ )
+ ]
+
+ editor_services.each do |editor_service|
+ expect(editor_service.send(:api_key)).to eq('hook-key')
+ end
+ end
+
context 'when openai hook is not configured' do
it 'uses system api key' do
expect(service.send(:api_key)).to eq('test-key')
diff --git a/spec/lib/captain/csat_utility_analysis_service_spec.rb b/spec/lib/captain/csat_utility_analysis_service_spec.rb
index e4e980e01..34e0c9ece 100644
--- a/spec/lib/captain/csat_utility_analysis_service_spec.rb
+++ b/spec/lib/captain/csat_utility_analysis_service_spec.rb
@@ -4,6 +4,11 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
+ end
+
describe '#perform' do
before do
allow(account).to receive(:feature_enabled?).and_call_original
@@ -21,4 +26,22 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
end
end
+
+ describe '#api_key' do
+ context 'when account has an OpenAI hook key' do
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'uses the account hook key' do
+ expect(service.send(:api_key)).to eq('customer-own-key')
+ end
+ end
+
+ context 'when account does not have an OpenAI hook key' do
+ it 'uses the system key' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+ end
end
diff --git a/spec/mailboxes/imap/imap_mailbox_spec.rb b/spec/mailboxes/imap/imap_mailbox_spec.rb
index 309a38a65..9a6797aaf 100644
--- a/spec/mailboxes/imap/imap_mailbox_spec.rb
+++ b/spec/mailboxes/imap/imap_mailbox_spec.rb
@@ -99,6 +99,33 @@ RSpec.describe Imap::ImapMailbox do
end
end
+ context 'when a new email contains null bytes' do
+ let(:inbound_mail) do
+ Mail.new.tap do |mail|
+ mail.from = 'email@gmail.com'
+ mail.to = 'imap@gmail.com'
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail['In-Reply-To'] = "source\u0000@example.com"
+ mail.references = ["reference\u0000@example.com"]
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ end
+ end
+
+ it 'creates sanitized conversation and message records' do
+ expect { class_instance.process(inbound_mail, channel) }.to change(Conversation, :count).by(1)
+
+ message = conversation.messages.last
+
+ expect(conversation.additional_attributes['in_reply_to']).to eq('source@example.com')
+ expect(conversation.additional_attributes['mail_subject']).to eq('Hello')
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
+
context 'when a new email with invalid from' do
let(:inbound_mail) { create_inbound_email_from_mail(from: 'invalidemail', to: 'imap@gmail.com', subject: 'Hello!') }
diff --git a/spec/mailboxes/mailbox_helper_spec.rb b/spec/mailboxes/mailbox_helper_spec.rb
index 613040cb3..4b02205ec 100644
--- a/spec/mailboxes/mailbox_helper_spec.rb
+++ b/spec/mailboxes/mailbox_helper_spec.rb
@@ -47,6 +47,31 @@ RSpec.describe MailboxHelper do
helper_instance.send(:create_message)
end
end
+
+ context 'when message data contains null bytes' do
+ let(:mail) do
+ mail = Mail.new
+ mail.from = 'Sender '
+ mail.to = 'Inbox '
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ mail
+ end
+
+ it 'creates the message with sanitized values' do
+ helper_instance = mailbox_helper_obj.new(conversation, processed_mail)
+
+ expect { helper_instance.send(:create_message) }.to change(conversation.messages, :count).by(1)
+
+ message = conversation.messages.last
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.dig('email', 'message_id')).to eq('message@example.com')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
end
describe '#embed_plain_text_email_with_inline_image' do
diff --git a/spec/mailboxes/reply_mailbox_spec.rb b/spec/mailboxes/reply_mailbox_spec.rb
index d062c7d73..20ce60dad 100644
--- a/spec/mailboxes/reply_mailbox_spec.rb
+++ b/spec/mailboxes/reply_mailbox_spec.rb
@@ -67,6 +67,40 @@ RSpec.describe ReplyMailbox do
end
end
+ context 'when new conversation email contains null bytes' do
+ let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
+ let(:null_byte_mail) { create_inbound_email_from_mail(from: 'sender@example.com', to: email_channel.email, subject: 'Hello') }
+ let(:mail_with_null_bytes) do
+ Mail.new.tap do |mail|
+ mail.from = 'sender@example.com'
+ mail.to = email_channel.email
+ mail.subject = "Hello\u0000"
+ mail.message_id = "message\u0000@example.com"
+ mail['In-Reply-To'] = "source\u0000@example.com"
+ mail.references = ["reference\u0000@example.com"]
+ mail.content_type = 'text/plain'
+ mail.body = "Body\u0000 text"
+ end
+ end
+
+ before do
+ allow(null_byte_mail).to receive(:mail).and_return(mail_with_null_bytes)
+ end
+
+ it 'creates sanitized conversation and message records' do
+ expect { described_class.receive null_byte_mail }.to change(Conversation, :count).by(1)
+
+ conversation = Conversation.last
+ message = conversation.messages.last
+
+ expect(conversation.additional_attributes['in_reply_to']).to eq('source@example.com')
+ expect(conversation.additional_attributes['mail_subject']).to eq('Hello')
+ expect(message.source_id).to eq('message@example.com')
+ expect(message.content).to eq('Body text')
+ expect(message.content_attributes.to_json).not_to include('\u0000')
+ end
+ end
+
context 'with inline attachments' do
let(:mail_with_inline_images) { create_inbound_email_from_fixture('mail_with_inline_images.eml') }
let(:described_subject) { described_class.receive mail_with_inline_images }
diff --git a/spec/models/portal_spec.rb b/spec/models/portal_spec.rb
index 38d9a7da6..c71a458fd 100644
--- a/spec/models/portal_spec.rb
+++ b/spec/models/portal_spec.rb
@@ -60,6 +60,94 @@ RSpec.describe Portal do
portal.update(custom_domain: '')
expect(portal.custom_domain).to be_nil
end
+
+ context 'with locale_translations' do
+ it 'allows valid locale translations' do
+ portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro', 'page_title' => 'Título', 'header_text' => 'Hola' } } })
+
+ expect(portal).to be_valid
+ end
+
+ it 'rejects unknown fields within a locale translation' do
+ portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'tagline' => 'nope' } } })
+
+ expect(portal).not_to be_valid
+ end
+
+ it 'retains a locale override after it becomes the default so it can still be edited' do
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro' } } })
+
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
+
+ expect(portal.config['locale_translations']).to eq({ 'es' => { 'name' => 'Centro' } })
+ end
+ end
+ end
+ end
+
+ describe '#localized_value' do
+ let!(:account) { create(:account) }
+ let!(:portal) do
+ create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
+ end
+
+ it 'returns the override for the requested locale' do
+ expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
+ end
+
+ it 'falls back to the base column when the locale has no override for the field' do
+ expect(portal.localized_value('page_title', 'es')).to eq('Help Center | Acme')
+ end
+
+ it 'falls back to the base column when the locale has no overrides at all' do
+ expect(portal.localized_value('name', 'fr')).to eq('Help Center')
+ end
+
+ it 'keeps serving the override for a locale that has become the default' do
+ portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
+
+ expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
+ end
+
+ it "inherits the default locale's override for a locale without its own" do
+ portal.update!(config: { allowed_locales: %w[en es fr], default_locale: 'es' })
+
+ expect(portal.localized_value('name', 'fr')).to eq('Centro de ayuda')
+ end
+
+ it 'uses the default locale when no locale is given' do
+ expect(portal.localized_value('name')).to eq('Help Center')
+ end
+ end
+
+ describe '#display_title' do
+ let!(:account) { create(:account) }
+
+ it 'prefers the localized page_title' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'page_title' => 'Centro | Acme' } } })
+
+ expect(portal.display_title('es')).to eq('Centro | Acme')
+ end
+
+ it 'falls back to the localized name when no page_title is set' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center',
+ config: { allowed_locales: %w[en es], default_locale: 'en',
+ locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
+
+ expect(portal.display_title('es')).to eq('Centro de ayuda')
+ end
+
+ it 'uses the base values for the default locale' do
+ portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme')
+
+ expect(portal.display_title).to eq('Help Center | Acme')
end
end
end
diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb
index 1bf5c219d..fa054b330 100644
--- a/spec/services/conversations/filter_service_spec.rb
+++ b/spec/services/conversations/filter_service_spec.rb
@@ -72,8 +72,8 @@ describe Conversations::FilterService do
it 'filter conversations by additional_attributes and status' do
params[:payload] = payload
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
- expect(result[:count][:all_count]).to be conversations.count
+ conversations = account.conversations.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filter conversations by priority' do
@@ -133,12 +133,84 @@ describe Conversations::FilterService do
expect(result[:conversations].pluck(:id)).to include(low_priority.id, medium_priority.id)
end
+ it 'filters conversations by contact' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ other_contact = create(:contact, :with_email, account: account)
+ matching_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: other_contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(matching_conversation.id)
+ end
+
+ it 'filters conversations using not_equal_to contact operator' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ other_contact = create(:contact, :with_email, account: account)
+ create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ other_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: other_contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'not_equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(other_conversation.id)
+ end
+
+ it 'applies inbox permissions when filtering conversations by contact' do
+ account.conversations.destroy_all
+
+ contact = create(:contact, :with_email, account: account)
+ restricted_inbox = create(:inbox, account: account)
+ accessible_conversation = create(:conversation, account: account, inbox: inbox, assignee: user_1, contact: contact)
+ create(:conversation, account: account, inbox: restricted_inbox, contact: contact)
+
+ params[:payload] = [
+ {
+ attribute_key: 'contact_id',
+ filter_operator: 'equal_to',
+ values: [contact.id],
+ query_operator: nil,
+ custom_attribute_type: ''
+ }.with_indifferent_access
+ ]
+
+ result = filter_service.new(params, user_1, account).perform
+
+ expect(result[:count][:all_count]).to eq 1
+ expect(result[:conversations].pluck(:id)).to contain_exactly(accessible_conversation.id)
+ end
+
it 'filter conversations by additional_attributes and status with pagination' do
params[:payload] = payload
params[:page] = 2
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
- expect(result[:count][:all_count]).to be conversations.count
+ conversations = account.conversations.where("additional_attributes ->> 'browser_language' IN (?) AND status IN (?)", ['en'], [1, 2])
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filters items with contains filter_operator with values being an array' do
@@ -184,10 +256,10 @@ describe Conversations::FilterService do
custom_attribute_type: 'conversation_attribute' }.with_indifferent_access]
params[:payload] = payload
result = filter_service.new(params, user_1, account).perform
- conversations = Conversation.where(
+ conversations = account.conversations.where(
"custom_attributes ->> 'conversation_type' NOT IN (?) OR custom_attributes ->> 'conversation_type' IS NULL", ['platinum']
)
- expect(result[:count][:all_count]).to be conversations.count
+ expect(result[:count][:all_count]).to eq conversations.count
end
it 'filter conversations by tags' do
@@ -413,8 +485,8 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
- expect(result[:conversations].length).to be expected_count
+ expected_count = account.conversations.where('created_at > ?', DateTime.parse('2022-01-20')).count
+ expect(result[:conversations].length).to eq expected_count
end
it 'binds created_at comparison values as dates' do
@@ -470,10 +542,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where("created_at > ? AND custom_attributes->>'conversation_type' = ?", DateTime.parse('2022-01-20'),
- 'platinum').count
+ expected_count = account.conversations.where("created_at > ? AND custom_attributes->>'conversation_type' = ?",
+ DateTime.parse('2022-01-20'), 'platinum').count
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
context 'with x_days_before filter' do
@@ -502,11 +574,11 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
- expected_count = Conversation.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?", (Time.zone.today - 3.days),
- 'platinum').count
+ expected_count = account.conversations.where("last_activity_at < ? AND custom_attributes->>'conversation_type' = ?",
+ (Time.zone.today - 3.days), 'platinum').count
result = filter_service.new(params, user_1, account).perform
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
it 'filter by last_activity_at 2_days_before' do
@@ -520,10 +592,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
- expected_count = Conversation.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
+ expected_count = account.conversations.where('last_activity_at < ?', (Time.zone.today - 2.days)).count
result = filter_service.new(params, user_1, account).perform
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
end
end
@@ -548,10 +620,10 @@ describe Conversations::FilterService do
}.with_indifferent_access
]
result = filter_service.new(params, user_1, account).perform
- expected_count = Conversation.where('created_at > ?', DateTime.parse('2022-01-20')).count
+ expected_count = account.conversations.where('created_at > ?', DateTime.parse('2022-01-20')).count
expect(Current.account).to be_nil
- expect(result[:conversations].length).to be expected_count
+ expect(result[:conversations].length).to eq expected_count
end
end
end
diff --git a/spec/services/facebook/send_on_facebook_service_spec.rb b/spec/services/facebook/send_on_facebook_service_spec.rb
index f99b1c469..0a2b6407c 100644
--- a/spec/services/facebook/send_on_facebook_service_spec.rb
+++ b/spec/services/facebook/send_on_facebook_service_spec.rb
@@ -73,8 +73,7 @@ describe Facebook::SendOnFacebookService do
expect(bot).to have_received(:deliver).with({
recipient: { id: contact_inbox.source_id },
message: { text: message.content },
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
expect(bot).to have_received(:deliver).with({
recipient: { id: contact_inbox.source_id },
@@ -86,17 +85,26 @@ describe Facebook::SendOnFacebookService do
}
}
},
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
end
+ it 'sends as a standard RESPONSE without a tag by default' do
+ message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
+ described_class.new(message: message).perform
+ expect(bot).to have_received(:deliver).with(
+ hash_including(messaging_type: 'RESPONSE'),
+ { page_id: facebook_channel.page_id }
+ )
+ expect(bot).not_to have_received(:deliver).with(hash_including(:tag), anything)
+ end
+
it 'sends with HUMAN_AGENT tag when ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT is enabled' do
with_modified_env ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT: 'true' do
message = create(:message, message_type: 'outgoing', inbox: facebook_inbox, account: account, conversation: conversation)
described_class.new(message: message).perform
expect(bot).to have_received(:deliver).with(
- hash_including(tag: 'HUMAN_AGENT'),
+ hash_including(messaging_type: 'MESSAGE_TAG', tag: 'HUMAN_AGENT'),
{ page_id: facebook_channel.page_id }
)
end
@@ -201,8 +209,7 @@ describe Facebook::SendOnFacebookService do
{ content_type: 'text', payload: 'text 2', title: 'text 2' }
]
},
- messaging_type: 'MESSAGE_TAG',
- tag: 'ACCOUNT_UPDATE'
+ messaging_type: 'RESPONSE'
}, { page_id: facebook_channel.page_id })
end
end