diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml
index f21a97f78..6489d0194 100644
--- a/app/helpers/super_admin/features.yml
+++ b/app/helpers/super_admin/features.yml
@@ -34,6 +34,12 @@ disable_branding:
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-sailbot-fill'
enterprise: true
+voice_calls:
+ name: 'Voice Calls'
+ description: 'Enable voice calling capabilities for your agents and customers.'
+ enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
+ icon: 'icon-voice-line'
+ enterprise: true
# ------- Product Features ------- #
help_center:
diff --git a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js
index 13f61a16c..14dd56ec9 100644
--- a/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js
+++ b/app/javascript/dashboard/api/channel/voice/twilioVoiceClient.js
@@ -48,6 +48,12 @@ class TwilioVoiceClient extends EventTarget {
return !!this.activeConnection;
}
+ setMuted(shouldMute) {
+ if (!this.activeConnection) return false;
+ this.activeConnection.mute(shouldMute);
+ return shouldMute;
+ }
+
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
diff --git a/app/javascript/dashboard/components-next/call/CallCard.vue b/app/javascript/dashboard/components-next/call/CallCard.vue
index 207129282..55da3dacf 100644
--- a/app/javascript/dashboard/components-next/call/CallCard.vue
+++ b/app/javascript/dashboard/components-next/call/CallCard.vue
@@ -197,9 +197,9 @@ const channelIcon = computed(() => {
? $t('CONVERSATION.VOICE_WIDGET.END_CALL')
: $t('CONVERSATION.VOICE_WIDGET.REJECT_CALL')
"
- icon="i-ph-phone-x-bold"
+ icon="i-ph-phone-bold"
ruby
- class="!rounded-full rotate-[134deg]"
+ class="!rounded-full rotate-[135deg]"
@click="isOngoing ? $emit('end') : $emit('reject')"
/>
diff --git a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue
index 86d9e979c..7758500a0 100644
--- a/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue
+++ b/app/javascript/dashboard/components-next/call/FloatingCallWidget.vue
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { setWhatsappCallMuted } from 'dashboard/composables/useWhatsappCallSession';
+import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { VOICE_CALL_PROVIDERS } from 'dashboard/helper/inbox';
import { VOICE_CALL_DIRECTION } from 'dashboard/components-next/message/constants';
@@ -11,7 +12,7 @@ import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilit
import CallCard from 'dashboard/components-next/call/CallCard.vue';
import countriesList from 'shared/constants/countries.js';
-const RINGTONE_URL = '/audio/dashboard/bell.mp3';
+const RINGTONE_URL = '/audio/dashboard/ringtone.mp3';
const route = useRoute();
const router = useRouter();
@@ -29,8 +30,8 @@ const {
formattedCallDuration,
} = useCallSession();
-// Mute is currently WhatsApp-only — Twilio calls are mediated server-side and
-// don't expose a mic track on the browser side.
+// Mute routes by provider: WhatsApp toggles the local mic track, Twilio uses
+// the Voice SDK connection's native mute. Both surface the same button.
const isMuted = ref(false);
const isWhatsappActive = computed(
() => activeCall.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
@@ -63,7 +64,11 @@ const stackedCardState = call =>
const toggleMute = () => {
isMuted.value = !isMuted.value;
- setWhatsappCallMuted(isMuted.value);
+ if (isWhatsappActive.value) {
+ setWhatsappCallMuted(isMuted.value);
+ } else {
+ TwilioVoiceClient.setMuted(isMuted.value);
+ }
};
watch(hasActiveCall, active => {
@@ -256,7 +261,7 @@ onBeforeUnmount(stopRingtone);
:call-info="getCallInfo(activeCall || primaryIncomingCall)"
:duration="hasActiveCall ? formattedCallDuration : ''"
:is-muted="isMuted"
- :show-mute="hasActiveCall && isWhatsappActive"
+ :show-mute="hasActiveCall"
@accept="handleJoinCall(primaryIncomingCall)"
@reject="rejectIncomingCall(primaryIncomingCall?.callSid)"
@dismiss="dismissCall(primaryIncomingCall?.callSid)"
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
index a8f2d0a2c..09dc23819 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue
@@ -979,10 +979,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
@apply text-n-slate-11;
}
}
-
- ol li {
- @apply list-item list-decimal;
- }
}
}
diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb
index dc3e0c3fd..88266c14d 100644
--- a/app/mailers/conversation_reply_mailer_helper.rb
+++ b/app/mailers/conversation_reply_mailer_helper.rb
@@ -54,8 +54,7 @@ module ConversationReplyMailerHelper
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
- open_timeout: 15,
- read_timeout: 15,
+ **smtp_timeout_settings,
authentication: 'xoauth2'
}
end
@@ -72,6 +71,7 @@ module ConversationReplyMailerHelper
tls: @channel.smtp_enable_ssl_tls,
enable_starttls_auto: @channel.smtp_enable_starttls_auto,
openssl_verify_mode: @channel.smtp_openssl_verify_mode,
+ **smtp_timeout_settings,
authentication: @channel.smtp_authentication
}
@@ -79,6 +79,13 @@ module ConversationReplyMailerHelper
@options[:delivery_method_options] = smtp_settings
end
+ def smtp_timeout_settings
+ {
+ open_timeout: ENV['SMTP_OPEN_TIMEOUT'].presence || 15,
+ read_timeout: ENV['SMTP_READ_TIMEOUT'].presence || 30
+ }.transform_values(&:to_i)
+ end
+
def email_smtp_enabled?
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
end
diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb
index 5a6d3537c..24dd22589 100644
--- a/app/services/imap/base_fetch_email_service.rb
+++ b/app/services/imap/base_fetch_email_service.rb
@@ -58,8 +58,9 @@ class Imap::BaseFetchEmailService
return if email_already_present?(channel, message_id)
- # Fetch the original mail content using the sequence no
- mail_str = imap_client.fetch(seq_no, 'RFC822')[0].attr['RFC822']
+ # Fetch the original mail content using the sequence no.
+ # BODY.PEEK[] avoids RFC822 parser failures seen with some IMAP servers.
+ mail_str = imap_client.fetch(seq_no, 'BODY.PEEK[]')[0].attr['BODY[]']
if mail_str.blank?
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetch failed for #{channel.email} with message-id <#{message_id}>."
diff --git a/app/views/super_admin/application/_icons.html.erb b/app/views/super_admin/application/_icons.html.erb
index e80d1e164..39253c969 100644
--- a/app/views/super_admin/application/_icons.html.erb
+++ b/app/views/super_admin/application/_icons.html.erb
@@ -128,6 +128,10 @@
+
+
+
+
diff --git a/public/audio/dashboard/ringtone.mp3 b/public/audio/dashboard/ringtone.mp3
new file mode 100644
index 000000000..c2af2b6d1
Binary files /dev/null and b/public/audio/dashboard/ringtone.mp3 differ
diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb
index 86a2363e9..df4c4f41f 100644
--- a/spec/mailers/conversation_reply_mailer_spec.rb
+++ b/spec/mailers/conversation_reply_mailer_spec.rb
@@ -462,6 +462,26 @@ RSpec.describe ConversationReplyMailer do
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
+ expect(mail.delivery_method.settings[:open_timeout]).to eq 15
+ expect(mail.delivery_method.settings[:read_timeout]).to eq 30
+ end
+
+ it 'uses configured smtp timeout values' do
+ with_modified_env SMTP_OPEN_TIMEOUT: '10', SMTP_READ_TIMEOUT: '30' do
+ mail = described_class.email_reply(message)
+
+ expect(mail.delivery_method.settings[:open_timeout]).to eq 10
+ expect(mail.delivery_method.settings[:read_timeout]).to eq 30
+ end
+ end
+
+ it 'uses default smtp timeout values when env values are blank' do
+ with_modified_env SMTP_OPEN_TIMEOUT: '', SMTP_READ_TIMEOUT: '' do
+ mail = described_class.email_reply(message)
+
+ expect(mail.delivery_method.settings[:open_timeout]).to eq 15
+ expect(mail.delivery_method.settings[:read_timeout]).to eq 30
+ end
end
it 'renders sender name in the from address' do
diff --git a/spec/services/imap/fetch_email_service_spec.rb b/spec/services/imap/fetch_email_service_spec.rb
index a40a46343..1f74cf0b7 100644
--- a/spec/services/imap/fetch_email_service_spec.rb
+++ b/spec/services/imap/fetch_email_service_spec.rb
@@ -80,11 +80,11 @@ RSpec.describe Imap::FetchEmailService do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
- imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1])
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
- allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: imap_email_channel).perform
@@ -93,7 +93,7 @@ RSpec.describe Imap::FetchEmailService do
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
- expect(imap).to have_received(:fetch).with(1, 'RFC822')
+ expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]')
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{imap_email_channel.email}, found 1.")
expect(imap).to have_received(:logout)
end
@@ -115,7 +115,7 @@ RSpec.describe Imap::FetchEmailService do
expect(result.length).to eq 0
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
- expect(imap).not_to have_received(:fetch).with(1, 'RFC822')
+ expect(imap).not_to have_received(:fetch).with(1, 'BODY.PEEK[]')
end
end
@@ -129,12 +129,12 @@ RSpec.describe Imap::FetchEmailService do
Net::IMAP::FetchData.new(seq_num, 'BODY[HEADER]' => eml_content_without_message_id)
end
valid_email_header = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[HEADER]' => eml_content_with_message_id)
- imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'RFC822' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[]' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return(empty_message_id_seq_nums + [valid_message_seq_num])
allow(imap).to receive(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]').and_return(empty_message_id_headers)
allow(imap).to receive(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]').and_return([valid_email_header])
- allow(imap).to receive(:fetch).with(valid_message_seq_num, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:fetch).with(valid_message_seq_num, 'BODY.PEEK[]').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: imap_email_channel).perform
@@ -143,7 +143,7 @@ RSpec.describe Imap::FetchEmailService do
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]')
expect(imap).to have_received(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]')
- expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'RFC822')
+ expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'BODY.PEEK[]')
end
end
end
diff --git a/spec/services/imap/microsoft_fetch_email_service_spec.rb b/spec/services/imap/microsoft_fetch_email_service_spec.rb
index a4a0a62d1..cc20d5b35 100644
--- a/spec/services/imap/microsoft_fetch_email_service_spec.rb
+++ b/spec/services/imap/microsoft_fetch_email_service_spec.rb
@@ -30,11 +30,11 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
- imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return([1])
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
- allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: microsoft_channel).perform
@@ -45,7 +45,7 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:search).with(%w[SINCE 25-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
- expect(imap).to have_received(:fetch).with(1, 'RFC822')
+ expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]')
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.")
end
end
@@ -56,11 +56,11 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
travel_to '26.10.2020 10:00'.to_datetime do
email_object = create_inbound_email_from_fixture('only_text.eml')
email_header = Net::IMAP::FetchData.new(1, 'BODY[HEADER]' => eml_content_with_message_id)
- imap_fetch_mail = Net::IMAP::FetchData.new(1, 'RFC822' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(1, 'BODY[]' => eml_content_with_message_id)
allow(imap).to receive(:search).with(%w[SINCE 18-Oct-2020]).and_return([1])
allow(imap).to receive(:fetch).with([1], 'BODY.PEEK[HEADER]').and_return([email_header])
- allow(imap).to receive(:fetch).with(1, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:fetch).with(1, 'BODY.PEEK[]').and_return([imap_fetch_mail])
allow(imap).to receive(:logout)
result = described_class.new(channel: microsoft_channel, interval: 8).perform
@@ -71,7 +71,7 @@ RSpec.describe Imap::MicrosoftFetchEmailService do
expect(result[0].message_id).to eq email_object.message_id
expect(imap).to have_received(:search).with(%w[SINCE 18-Oct-2020])
expect(imap).to have_received(:fetch).with([1], 'BODY.PEEK[HEADER]')
- expect(imap).to have_received(:fetch).with(1, 'RFC822')
+ expect(imap).to have_received(:fetch).with(1, 'BODY.PEEK[]')
expect(logger).to have_received(:info).with("[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{microsoft_channel.email}, found 1.")
end
end