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