diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index a300b5f59..c65a3031d 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -6,8 +6,7 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts { redirect_uri: "#{base_url}/microsoft/callback", scope: scope, - state: state, - prompt: 'consent' + state: state } ) if redirect_url 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 3da526fc0..09dc23819 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -32,7 +32,6 @@ import { CONVERSATION_EVENTS, CAPTAIN_EVENTS, } from 'dashboard/helper/AnalyticsHelper/events'; -import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor'; import { messageSchema, @@ -43,6 +42,7 @@ import { MessageMarkdownSerializer, EditorState, Selection, + imageResizeView, } from '@chatwoot/prosemirror-schema'; import { suggestionsPlugin, @@ -57,7 +57,6 @@ import { insertAtCursor, removeSignature as removeSignatureHelper, scrollCursorIntoView, - setURLWithQueryAndSize, getFormattingForEditor, getSelectionCoords, calculateMenuPosition, @@ -72,6 +71,7 @@ import { import { createTypingIndicator } from '@chatwoot/utils'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; const props = defineProps({ modelValue: { type: String, default: '' }, @@ -93,7 +93,6 @@ const props = defineProps({ channelType: { type: String, default: '' }, conversationId: { type: Number, default: null }, medium: { type: String, default: '' }, - showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar focusOnMount: { type: Boolean, default: true }, }); @@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000; const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const DEFAULT_FORMATTING = 'Context::Default'; const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote'; +const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature'; +const INLINE_IMAGE_PASTE_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', +]; const effectiveChannelType = computed(() => getEffectiveChannelType(props.channelType, props.medium) @@ -192,12 +199,8 @@ const cannedSearchTerm = ref(''); const variableSearchTerm = ref(''); const emojiSearchTerm = ref(''); const range = ref(null); -const isImageNodeSelected = ref(false); -const toolbarPosition = ref({ top: 0, left: 0 }); -const selectedImageNode = ref(null); const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection const showSelectionMenu = ref(false); -const sizes = MESSAGE_EDITOR_IMAGE_RESIZES; // element ref const editorRoot = useTemplateRef('editorRoot'); @@ -475,16 +478,6 @@ function removeSignature() { reloadState(content); } -function setToolbarPosition() { - const editorRect = editorRoot.value.getBoundingClientRect(); - const rect = selectedImageNode.value.getBoundingClientRect(); - - toolbarPosition.value = { - top: `${rect.top - editorRect.top - 30}px`, - left: `${rect.left - editorRect.left - 4}px`, - }; -} - function setMenubarPosition({ selection } = {}) { const wrapper = editorRoot.value; if (!selection || !wrapper) return; @@ -520,30 +513,6 @@ function checkSelection(editorState) { if (hasSelection) setMenubarPosition(editorState); } -function setURLWithQueryAndImageSize(size) { - if (!props.showImageResizeToolbar) { - return; - } - setURLWithQueryAndSize(selectedImageNode.value, size, editorView); - isImageNodeSelected.value = false; -} - -function isEditorMouseFocusedOnAnImage() { - if (!props.showImageResizeToolbar) { - return; - } - selectedImageNode.value = document.querySelector( - 'img.ProseMirror-selectednode' - ); - if (selectedImageNode.value) { - isImageNodeSelected.value = !!selectedImageNode.value; - // Get the position of the selected node - setToolbarPosition(); - } else { - isImageNodeSelected.value = false; - } -} - function emitOnChange() { emit('input', contentFromEditor()); emit('update:modelValue', contentFromEditor()); @@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) { emitOnChange(); } -function updateImgToolbarOnDelete() { - // check if the selected node is present or not on keyup - // this is needed because the user can select an image and then delete it - // in that case, the selected node will be null and we need to hide the toolbar - // otherwise, the toolbar will be visible even when the image is deleted and cause some errors - if (selectedImageNode.value) { - const hasImgSelectedNode = document.querySelector( - 'img.ProseMirror-selectednode' - ); - if (!hasImgSelectedNode) { - isImageNodeSelected.value = false; - } - } -} - function isEnterToSendEnabled() { return isEditorHotKeyEnabled('enter'); } @@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() { return isEditorHotKeyEnabled('cmd_enter'); } -useKeyboardEvents({ - 'Alt+KeyP': { - action: focusEditorInputField, - allowOnFocusedInput: false, - }, - 'Alt+KeyL': { - action: focusEditorInputField, - allowOnFocusedInput: false, - }, -}); - function onImageInsertInEditor(fileUrl) { const { tr } = editorView.state; @@ -617,7 +560,11 @@ async function uploadImageToStorage(file) { onImageInsertInEditor(fileUrl); } useAlert( - t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS') + props.channelType === MESSAGE_SIGNATURE_FORMATTING + ? t( + 'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS' + ) + : t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS') ); } catch (error) { useAlert( @@ -626,8 +573,8 @@ async function uploadImageToStorage(file) { } } -function onFileChange() { - const file = imageUpload.value.files[0]; +function uploadImageIfWithinSizeLimit(file) { + if (!file) return; if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) { uploadImageToStorage(file); } else { @@ -640,10 +587,61 @@ function onFileChange() { ) ); } - - imageUpload.value = ''; } +function onFileChange() { + const input = imageUpload.value; + uploadImageIfWithinSizeLimit(input.files[0]); + input.value = ''; +} + +const allowsInlineImagePaste = computed( + () => + !props.isPrivate && + (props.channelType === INBOX_TYPES.EMAIL || + props.channelType === INBOX_TYPES.WEB) +); + +// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This +// gesture's native paste event carries no image, so clipboard.read() is the +// only way to get the bytes. No preventDefault: text still pastes natively. +async function pasteInlineImageFromClipboard() { + if (!editorView?.hasFocus()) return; + if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return; + try { + const items = await navigator.clipboard.read(); + const imageItem = items.find(item => + item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type)) + ); + if (!imageItem) return; + const imageType = imageItem.types.find(type => + INLINE_IMAGE_PASTE_TYPES.includes(type) + ); + const blob = await imageItem.getType(imageType); + uploadImageIfWithinSizeLimit( + new File([blob], 'pasted-image', { type: imageType }) + ); + } catch (error) { + // clipboard-read denied/unfocused (NotAllowedError): image can't be read. + // Text paste is unaffected — ProseMirror handles it from the native event. + } +} + +useKeyboardEvents({ + 'Alt+KeyP': { + action: focusEditorInputField, + allowOnFocusedInput: false, + }, + 'Alt+KeyL': { + action: focusEditorInputField, + allowOnFocusedInput: false, + }, + '$mod+Shift+KeyV': { + action: pasteInlineImageFromClipboard, + allowOnFocusedInput: true, + }, +}); + function handleLineBreakWhenEnterToSendEnabled(event) { if ( hasPressedEnterAndNotCmdOrShift(event) && @@ -736,6 +734,9 @@ function createEditorView() { editorView = new EditorView(editor.value, { state: state, editable: () => !props.disabled, + nodeViews: { + image: imageResizeView, + }, dispatchTransaction: tx => { state = state.apply(tx); editorView.updateState(state); @@ -748,12 +749,10 @@ function createEditorView() { keyup: () => { if (!props.disabled) { typingIndicator.start(); - updateImgToolbarOnDelete(); } }, keydown: (view, event) => !props.disabled && onKeydown(event), focus: () => !props.disabled && emit('focus'), - click: () => !props.disabled && isEditorMouseFocusedOnAnImage(), blur: () => { if (props.disabled) return; typingIndicator.stop(); @@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @change="onFileChange" />
-
')
+ markdown = ''
+ expect(render_markdown(markdown)).to include('
')
+ end
+ end
+
+ context 'when image has a width' do
+ it 'renders the img tag with the correct attributes' do
+ markdown = ''
+ expect(render_markdown(markdown)).to include(
+ '
'
+ )
+ end
+ end
+
+ context 'when the sizing param contains an attribute-injection payload' do
+ it 'drops the malicious height value' do
+ markdown = ')'
+ rendered = render_markdown(markdown)
+ expect(rendered).not_to include('style=')
+ expect(rendered).not_to include('onmouseover="')
+ end
+
+ it 'drops the malicious width value' do
+ markdown = ')'
+ rendered = render_markdown(markdown)
+ expect(rendered).not_to include('style=')
+ expect(rendered).not_to include('onmouseover="')
end
end
diff --git a/spec/lib/integrations/llm_instrumentation_spec.rb b/spec/lib/integrations/llm_instrumentation_spec.rb
index 0be62f437..f291cd7b2 100644
--- a/spec/lib/integrations/llm_instrumentation_spec.rb
+++ b/spec/lib/integrations/llm_instrumentation_spec.rb
@@ -144,7 +144,10 @@ RSpec.describe Integrations::LlmInstrumentation do
expect(mock_span).to have_received(:set_attribute).with('langfuse.user.id', '123')
expect(mock_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
- expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', '["reply_suggestion"]')
+ expect(mock_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['reply_suggestion'])
+ expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123')
+ expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
+ expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion')
end
it 'sets completion message attributes when result contains message' do
@@ -253,6 +256,76 @@ RSpec.describe Integrations::LlmInstrumentation do
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', result_data.to_json)
end
+ it 'propagates trace attributes as observation metadata to child tool spans' do
+ root_span = instance_double(OpenTelemetry::Trace::Span)
+ tool_span = instance_double(OpenTelemetry::Trace::Span)
+ tool_instance = test_class.new
+ allow(root_span).to receive(:set_attribute)
+ allow(tool_span).to receive(:set_attribute)
+ allow(instance).to receive(:tracer).and_return(mock_tracer)
+ allow(tool_instance).to receive(:tracer).and_return(mock_tracer)
+ allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
+ allow(mock_tracer).to receive(:in_span).with('tool.search').and_yield(tool_span)
+
+ instance.instrument_agent_session(params) do
+ tool_instance.instrument_tool_call('search', { query: 'test' }) { 'tool result' }
+ end
+
+ expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.user_id', '123')
+ expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
+ expect(tool_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'reply_suggestion')
+ end
+
+ it 'keeps inherited session metadata for nested service spans with their own feature tag' do
+ root_span = instance_double(OpenTelemetry::Trace::Span)
+ nested_span = instance_double(OpenTelemetry::Trace::Span)
+ nested_instance = test_class.new
+ nested_params = params.merge(span_name: 'llm.translate_query', conversation_id: nil, feature_name: 'translate_query')
+ allow(root_span).to receive(:set_attribute)
+ allow(nested_span).to receive(:set_attribute)
+ allow(instance).to receive(:tracer).and_return(mock_tracer)
+ allow(nested_instance).to receive(:tracer).and_return(mock_tracer)
+ allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
+ allow(mock_tracer).to receive(:in_span).with('llm.translate_query').and_yield(nested_span)
+
+ instance.instrument_agent_session(params) do
+ nested_instance.instrument_llm_call(nested_params) { 'translated query' }
+ end
+
+ expect(nested_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
+ expect(nested_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['translate_query'])
+ expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
+ expect(nested_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'translate_query')
+ end
+
+ it 'propagates session metadata to nested embedding spans' do
+ root_span = instance_double(OpenTelemetry::Trace::Span)
+ embedding_span = instance_double(OpenTelemetry::Trace::Span)
+ embedding_instance = test_class.new
+ embedding_params = {
+ span_name: 'llm.captain.embedding',
+ account_id: 123,
+ feature_name: 'embedding',
+ model: 'text-embedding-3-small',
+ input: 'search result'
+ }
+ allow(root_span).to receive(:set_attribute)
+ allow(embedding_span).to receive(:set_attribute)
+ allow(instance).to receive(:tracer).and_return(mock_tracer)
+ allow(embedding_instance).to receive(:tracer).and_return(mock_tracer)
+ allow(mock_tracer).to receive(:in_span).with('llm.test').and_yield(root_span)
+ allow(mock_tracer).to receive(:in_span).with('llm.captain.embedding').and_yield(embedding_span)
+
+ instance.instrument_agent_session(params) do
+ embedding_instance.instrument_embedding_call(embedding_params) { [0.1, 0.2, 0.3] }
+ end
+
+ expect(embedding_span).to have_received(:set_attribute).with('langfuse.session.id', '123_456')
+ expect(embedding_span).to have_received(:set_attribute).with('langfuse.trace.tags', ['embedding'])
+ expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.session_id', '123_456')
+ expect(embedding_span).to have_received(:set_attribute).with('langfuse.observation.metadata.feature_name', 'embedding')
+ end
+
# Regression test for Langfuse double-counting bug.
# Setting gen_ai.request.model on parent spans causes Langfuse to classify them as
# GENERATIONs instead of SPANs, resulting in cost being counted multiple times
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
diff --git a/spec/services/messages/webhook_content_normalizer_spec.rb b/spec/services/messages/webhook_content_normalizer_spec.rb
new file mode 100644
index 000000000..ca5023b9d
--- /dev/null
+++ b/spec/services/messages/webhook_content_normalizer_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe Messages::WebhookContentNormalizer do
+ describe '.normalize' do
+ it 'returns nil unchanged' do
+ expect(described_class.normalize(nil)).to be_nil
+ end
+
+ it 'returns blank string unchanged' do
+ expect(described_class.normalize('')).to eq('')
+ end
+
+ it 'strips trailing newlines added by TipTap/ProseMirror' do
+ expect(described_class.normalize("hello\n\n\n")).to eq('hello')
+ end
+
+ it 'preserves intentional trailing spaces' do
+ expect(described_class.normalize("hello \n\n")).to eq('hello ')
+ end
+
+ it 'replaces CommonMark hard line breaks (backslash-newline) with plain newlines' do
+ expect(described_class.normalize("hello\\\nworld")).to eq("hello\nworld")
+ end
+
+ it 'replaces CommonMark hard line breaks with CRLF with plain newlines' do
+ expect(described_class.normalize("hello\\\r\nworld")).to eq("hello\nworld")
+ end
+
+ it 'preserves intentional internal newlines' do
+ expect(described_class.normalize("line one\nline two")).to eq("line one\nline two")
+ end
+
+ it 'strips trailing CRLF newlines without leaving dangling carriage returns' do
+ expect(described_class.normalize("hello\r\n\r\n")).to eq('hello')
+ end
+
+ it 'handles both hard line breaks and trailing newlines together' do
+ expect(described_class.normalize("hello\\\nworld\n\n\n")).to eq("hello\nworld")
+ end
+ end
+end
diff --git a/spec/services/whatsapp/incoming_message_service_spec.rb b/spec/services/whatsapp/incoming_message_service_spec.rb
index fe6b179c1..430aa1561 100644
--- a/spec/services/whatsapp/incoming_message_service_spec.rb
+++ b/spec/services/whatsapp/incoming_message_service_spec.rb
@@ -381,6 +381,32 @@ describe Whatsapp::IncomingMessageService do
expect(location_attachment.coordinates_long).to eq(-122.3895553)
expect(location_attachment.external_url).to eq('http://location_url.test')
end
+
+ it 'truncates long fallback titles to avoid dropping location messages' do
+ long_place_name = [
+ 'Gremi de Fusters, 33, Edificio VIP Asima, Piso 2, Local 2, Norte',
+ '07009 Poligon industrial de Son Castello, Illes Balears, Espana'
+ ].join(', ')
+ source_id = 'wamid.long-location-fallback-title'
+ params = {
+ 'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
+ 'messages' => [{ 'from' => '2423423243', 'id' => source_id,
+ 'location' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
+ :address => long_place_name,
+ :latitude => 37.7893768,
+ :longitude => -122.3895553,
+ :name => long_place_name,
+ :url => 'http://location_url.test' },
+ 'timestamp' => '1633034394', 'type' => 'location' }]
+ }.with_indifferent_access
+
+ expect { described_class.new(inbox: whatsapp_channel.inbox, params: params).perform }
+ .to change { Message.where(source_id: source_id).count }.from(0).to(1)
+
+ location_attachment = Message.find_by!(source_id: source_id).attachments.first
+ expect(location_attachment.fallback_title).to eq("#{long_place_name}, #{long_place_name}".first(255))
+ expect(location_attachment.fallback_title.length).to eq(255)
+ end
end
context 'when valid contact message params' do
diff --git a/tailwind.config.js b/tailwind.config.js
index b4c4b9078..cc2aebb63 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -106,24 +106,30 @@ const tailwindConfig = {
textDecoration: 'underline',
},
ul: {
- paddingInlineStart: '0.625em',
+ paddingInlineStart: '0',
+ listStylePosition: 'inside',
},
ol: {
- paddingInlineStart: '0.625em',
+ paddingInlineStart: '0',
+ listStylePosition: 'inside',
},
- 'ul li': {
- margin: '0 0 0.5em 1em',
+ 'ul > li': {
+ marginBlockEnd: '0.5em',
listStyleType: 'disc',
- '[dir="rtl"] &': {
- margin: '0 1em 0.5em 0',
- },
+ paddingInlineStart: '1.5em',
+ textIndent: '-1.5em',
},
- 'ol li': {
- margin: '0 0 0.5em 1em',
+ 'ol > li': {
+ marginBlockEnd: '0.5em',
listStyleType: 'decimal',
- '[dir="rtl"] &': {
- margin: '0 1em 0.5em 0',
- },
+ paddingInlineStart: '1.5em',
+ textIndent: '-1.5em',
+ },
+ 'li > p:first-child': {
+ display: 'inline',
+ },
+ 'li > *': {
+ textIndent: '0',
},
blockquote: {
color: 'rgb(var(--slate-11))',