Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
136c2d8e58 | ||
|
|
38b1d8fbfa | ||
|
|
2736dc2f90 | ||
|
|
d3295b3838 | ||
|
|
c464b50854 | ||
|
|
1d174c085d | ||
|
|
690000fb97 | ||
|
|
c99d846348 | ||
|
|
2ff71c21e6 | ||
|
|
0862277564 | ||
|
|
a9388fe918 | ||
|
|
2e6998e57a | ||
|
|
d3e3809118 | ||
|
|
e973309610 | ||
|
|
81d2c018cd | ||
|
|
7f3db1fe29 | ||
|
|
a4d749b9b9 | ||
|
|
4deb4b04af | ||
|
|
5c0f0cf0a8 | ||
|
|
1ce7f1bd64 | ||
|
|
162929eb7a | ||
|
|
12ace33254 | ||
|
|
32158da454 | ||
|
|
3ad8156ea2 | ||
|
|
aa8cafb9b5 | ||
|
|
d5267057f6 | ||
|
|
0a31e6ee63 | ||
|
|
7633602ca5 | ||
|
|
b8ec6d6235 | ||
|
|
be10e450e3 | ||
|
|
e722bebed8 | ||
|
|
78ae5ef8fd | ||
|
|
0c5379147d | ||
|
|
9c5af11b84 | ||
|
|
43c640a76e | ||
|
|
09e76b21ca | ||
|
|
bfec3b066f | ||
|
|
9fafd37e3b | ||
|
|
6def703a60 | ||
|
|
6ca8b3abca | ||
|
|
cffd74fda3 | ||
|
|
2856818931 | ||
|
|
3e7df0e76a | ||
|
|
d747fbacf8 | ||
|
|
74c6caa982 | ||
|
|
0203ebb419 | ||
|
|
cf09aaadfc | ||
|
|
421597b1bc | ||
|
|
2db43948c0 | ||
|
|
7ab4343359 | ||
|
|
76dbc7540f | ||
|
|
cc44e7ab88 | ||
|
|
c1c488eafa | ||
|
|
b05312aab1 | ||
|
|
99d7bd9e7c | ||
|
|
7aefbba2ca | ||
|
|
7c8f4d26fe | ||
|
|
7b0aaa2721 | ||
|
|
00a9a5cdc2 | ||
|
|
ccd24c40e7 | ||
|
|
754d0ff515 | ||
|
|
f8d3a2a989 | ||
|
|
d1ac7211ec | ||
|
|
f4ca2529ba | ||
|
|
1094a3924e | ||
|
|
8326eae4fe | ||
|
|
e96f296414 | ||
|
|
abce44c737 | ||
|
|
ab9d10488c | ||
|
|
a4a749e0d5 | ||
|
|
f20ef2bfc7 | ||
|
|
65b38bb4e4 | ||
|
|
9ab40adc24 | ||
|
|
77feacb544 | ||
|
|
32664bb840 | ||
|
|
c710c55cf1 | ||
|
|
38741f9a15 |
@@ -9,16 +9,14 @@ class Messages::MessageBuilder
|
||||
@user = user
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@in_reply_to = content_attributes&.dig(:in_reply_to)
|
||||
@items = content_attributes&.dig(:items)
|
||||
process_content_attributes
|
||||
end
|
||||
|
||||
def perform
|
||||
process_forwarded_message if @forwarded_message_id.present?
|
||||
@message = @conversation.messages.build(message_params)
|
||||
process_attachments
|
||||
process_forwarded_attachments if @forwarded_message_id.present?
|
||||
process_emails
|
||||
@message.save!
|
||||
@message
|
||||
@@ -26,6 +24,44 @@ class Messages::MessageBuilder
|
||||
|
||||
private
|
||||
|
||||
def process_forwarded_message
|
||||
builder = Messages::ForwardedMessageBuilderService.new(@forwarded_message_id, { to_emails: @params[:to_emails] })
|
||||
@forwarded_attributes = builder.perform
|
||||
@forwarded_message_attachments = builder.forwarded_attachments
|
||||
|
||||
# Update content to include forwarded message
|
||||
original_content = @params[:content_original] || @params[:content]
|
||||
@params[:content] = builder.formatted_content(@params[:content])
|
||||
|
||||
# Update email data
|
||||
return unless @forwarded_attributes[:content_attributes] && @conversation.inbox&.inbox_type == 'Email'
|
||||
|
||||
# Ensure we have valid email data structure to avoid breaking the rendering
|
||||
@forwarded_attributes[:content_attributes][:email] = builder.forwarded_email_data(original_content)
|
||||
end
|
||||
|
||||
# Process attachments from the forwarded message
|
||||
def process_forwarded_attachments
|
||||
return if @forwarded_message_attachments.blank?
|
||||
|
||||
@forwarded_message_attachments.each do |source_attachment|
|
||||
# Create a new attachment for the current message
|
||||
attachment = @message.attachments.build(
|
||||
account_id: @message.account_id,
|
||||
file_type: source_attachment.file_type
|
||||
)
|
||||
|
||||
# Attach the file by directly copying it from the source attachment
|
||||
next unless source_attachment.file.attached?
|
||||
|
||||
attachment.file.attach(
|
||||
io: StringIO.new(source_attachment.file.download),
|
||||
filename: source_attachment.file.filename.to_s,
|
||||
content_type: source_attachment.file.content_type
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts content attributes from the given params.
|
||||
# - Converts ActionController::Parameters to a regular hash if needed.
|
||||
# - Attempts to parse a JSON string if content is a string.
|
||||
@@ -58,6 +94,18 @@ class Messages::MessageBuilder
|
||||
{}
|
||||
end
|
||||
|
||||
def process_content_attributes
|
||||
@automation_rule = content_attributes&.dig(:automation_rule_id)
|
||||
return unless @params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@forwarded_message_id = content_attributes&.dig(:forwarded_message_id)
|
||||
@in_reply_to = content_attributes&.dig(:in_reply_to)
|
||||
@items = content_attributes&.dig(:items)
|
||||
|
||||
# Store original content before any modifications
|
||||
@params[:content_original] = @params[:content].dup if @params[:content].present?
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return if @attachments.blank?
|
||||
|
||||
@@ -151,6 +199,11 @@ class Messages::MessageBuilder
|
||||
in_reply_to: @in_reply_to,
|
||||
echo_id: @params[:echo_id],
|
||||
source_id: @params[:source_id]
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
|
||||
}
|
||||
.merge(external_created_at)
|
||||
.merge(automation_rule_id)
|
||||
.merge(campaign_id)
|
||||
.merge(template_params)
|
||||
.merge(@forwarded_attributes || {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -148,6 +148,7 @@ const keyboardEvents = {
|
||||
emit('sendMessage');
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'$mod+Enter': {
|
||||
action: () => {
|
||||
@@ -159,6 +160,7 @@ const keyboardEvents = {
|
||||
emit('sendMessage');
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, ref, toRefs } from 'vue';
|
||||
import { onMounted, computed, ref, toRefs, useTemplateRef } from 'vue';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { provideMessageContext } from './provider.js';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
@@ -136,6 +136,7 @@ const emit = defineEmits(['retry']);
|
||||
const contextMenuPosition = ref({});
|
||||
const showBackgroundHighlight = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const emailBubbleRef = useTemplateRef('emailBubbleRef');
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -364,6 +365,16 @@ const contextMenuEnabledOptions = computed(() => {
|
||||
!props.private &&
|
||||
props.inboxSupportsReplyTo.outgoing &&
|
||||
!isFailedOrProcessing,
|
||||
// Forward email is enabled only when the message is not in progress and is not private and is an email inbox
|
||||
forwardEmail:
|
||||
props.isEmailInbox &&
|
||||
!props.private &&
|
||||
props.status !== MESSAGE_STATUS.PROGRESS &&
|
||||
![
|
||||
CONTENT_TYPES.FORM,
|
||||
CONTENT_TYPES.INPUT_CSAT,
|
||||
CONTENT_TYPES.CARDS,
|
||||
].includes(props.contentType),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -384,6 +395,9 @@ const shouldRenderMessage = computed(() => {
|
||||
});
|
||||
|
||||
function openContextMenu(e) {
|
||||
// Close forward modal, when opening context menu
|
||||
emailBubbleRef.value?.closeForwardModal();
|
||||
|
||||
const shouldSkipContextMenu =
|
||||
e.target?.classList.contains('skip-context-menu') ||
|
||||
['a', 'img'].includes(e.target?.tagName.toLowerCase());
|
||||
@@ -459,6 +473,11 @@ const setupHighlightTimer = () => {
|
||||
}, HIGHLIGHT_TIMER);
|
||||
};
|
||||
|
||||
const openForwardModal = ({ x, y }) => {
|
||||
// Open forward modal, with the event from context menu
|
||||
emailBubbleRef.value.openForwardModal({ x, y });
|
||||
};
|
||||
|
||||
onMounted(setupHighlightTimer);
|
||||
|
||||
provideMessageContext({
|
||||
@@ -519,7 +538,12 @@ provideMessageContext({
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
>
|
||||
<Component :is="componentToRender" />
|
||||
<component
|
||||
:is="componentToRender"
|
||||
:ref="
|
||||
componentToRender === EmailBubble ? 'emailBubbleRef' : undefined
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<MessageError
|
||||
v-if="contentAttributes.externalError"
|
||||
@@ -540,6 +564,7 @@ provideMessageContext({
|
||||
@open="openContextMenu"
|
||||
@close="closeContextMenu"
|
||||
@reply-to="handleReplyTo"
|
||||
@forward-email="openForwardModal"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,6 +97,16 @@ const getInReplyToMessage = parentMessage => {
|
||||
|
||||
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the address of the forwarded message
|
||||
* @param {Object} message - The message containing the forwarded message reference
|
||||
* @returns {Array|null} - The email addresses of the forwarded message, or null if not forwarded
|
||||
*/
|
||||
const getForwardedMessageAddress = message => {
|
||||
const { forwardedMessageId, toEmails } = message.contentAttributes || {};
|
||||
return forwardedMessageId ? toEmails : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -107,6 +117,11 @@ const getInReplyToMessage = parentMessage => {
|
||||
v-if="firstUnreadId && message.id === firstUnreadId"
|
||||
name="unreadBadge"
|
||||
/>
|
||||
<slot
|
||||
v-if="getForwardedMessageAddress(message)"
|
||||
:address="getForwardedMessageAddress(message)"
|
||||
name="forwardedMessageAddress"
|
||||
/>
|
||||
<Message
|
||||
v-bind="message"
|
||||
:is-email-inbox="isAnEmailChannel"
|
||||
|
||||
@@ -14,8 +14,8 @@ const fromEmail = computed(() => {
|
||||
});
|
||||
|
||||
const toEmail = computed(() => {
|
||||
const { toEmails, email } = contentAttributes.value;
|
||||
return email?.to ?? toEmails ?? [];
|
||||
const { forwardedMessageId, toEmails, email } = contentAttributes.value;
|
||||
return forwardedMessageId ? (toEmails ?? []) : (email?.to ?? []);
|
||||
});
|
||||
|
||||
const ccEmail = computed(() => {
|
||||
@@ -82,7 +82,7 @@ const showMeta = computed(() => {
|
||||
<{{ fromEmail[0] }}>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ fromEmail[0] }}
|
||||
{{ $t('EMAIL_HEADER.FROM') }}: {{ fromEmail[0] }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="toEmail.length">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef, ref, onMounted } from 'vue';
|
||||
import { computed, useTemplateRef, ref, onMounted, reactive } from 'vue';
|
||||
import { Letter } from 'vue-letter';
|
||||
import { sanitizeTextForRender } from '@chatwoot/utils';
|
||||
import { allowedCssProperties } from 'lettersanitizer';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { EmailQuoteExtractor } from './removeReply.js';
|
||||
@@ -11,20 +12,29 @@ import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import EmailMeta from './EmailMeta.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
import ForwardMessageForm from 'dashboard/components-next/message/forwardMessage/ForwardMessage.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import { MESSAGE_TYPES } from 'next/message/constants.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, contentAttributes, attachments, messageType } =
|
||||
const { id, content, contentAttributes, attachments, messageType } =
|
||||
useMessageContext();
|
||||
|
||||
const { inbox } = useInbox();
|
||||
|
||||
const isExpandable = ref(false);
|
||||
const isExpanded = ref(false);
|
||||
const showQuotedMessage = ref(false);
|
||||
const renderOriginal = ref(false);
|
||||
const contentContainer = useTemplateRef('contentContainer');
|
||||
|
||||
// Forward form - managed locally but can be triggered by parent
|
||||
const [showForwardMessageModal, toggleForwardModal] = useToggle();
|
||||
const forwardFormPosition = reactive({ top: 0, right: 0 });
|
||||
|
||||
onMounted(() => {
|
||||
isExpandable.value = contentContainer.value?.scrollHeight > 400;
|
||||
});
|
||||
@@ -32,6 +42,8 @@ onMounted(() => {
|
||||
const isOutgoing = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isIncoming = computed(() => !isOutgoing.value);
|
||||
|
||||
const isForwarded = computed(() => contentAttributes.value?.forwardedMessageId);
|
||||
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
|
||||
@@ -93,6 +105,23 @@ const translationKeySuffix = computed(() => {
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
|
||||
const closeForwardModal = () => {
|
||||
toggleForwardModal(false);
|
||||
forwardFormPosition.x = 0;
|
||||
forwardFormPosition.y = 0;
|
||||
};
|
||||
|
||||
const openForwardModal = ({ x, y }) => {
|
||||
forwardFormPosition.x = x;
|
||||
forwardFormPosition.y = y;
|
||||
toggleForwardModal(true);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openForwardModal,
|
||||
closeForwardModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -111,6 +140,7 @@ const handleSeeOriginal = () => {
|
||||
'border-b border-n-slate-8/20': isOutgoing,
|
||||
}"
|
||||
/>
|
||||
|
||||
<section ref="contentContainer" class="p-3">
|
||||
<div
|
||||
:class="{
|
||||
@@ -137,7 +167,7 @@ const handleSeeOriginal = () => {
|
||||
</button>
|
||||
</div>
|
||||
<FormattedContent
|
||||
v-if="isOutgoing && content"
|
||||
v-if="isOutgoing && content && !isForwarded"
|
||||
class="text-n-slate-12"
|
||||
:content="messageContent"
|
||||
/>
|
||||
@@ -200,6 +230,22 @@ const handleSeeOriginal = () => {
|
||||
>
|
||||
<AttachmentChips :attachments="attachments" class="gap-1" />
|
||||
</section>
|
||||
|
||||
<ContextMenu
|
||||
v-if="showForwardMessageModal"
|
||||
:x="forwardFormPosition.x"
|
||||
:y="forwardFormPosition.y"
|
||||
@close="closeForwardModal"
|
||||
>
|
||||
<ForwardMessageForm
|
||||
:message="contentAttributes?.email"
|
||||
:content="content"
|
||||
:inbox="inbox"
|
||||
:attachments="attachments"
|
||||
:message-id="id"
|
||||
@close="closeForwardModal"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { EmailQuoteExtractor } from 'dashboard/components-next/message/bubbles/Email/removeReply.js';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
searchContacts,
|
||||
createNewContact,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
|
||||
import ForwardMessageForm from './components/ForwardMessageForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
forwardType: {
|
||||
type: String,
|
||||
default: 'email',
|
||||
},
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
messageId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const contacts = ref([]);
|
||||
const selectedContact = ref(null);
|
||||
const isCreatingContact = ref(false);
|
||||
const isSearching = ref(false);
|
||||
|
||||
const messageSignature = useMapGetter('getMessageSignature');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const fromEmail = computed(() => props.inbox?.email);
|
||||
|
||||
const fullHTML = computed(() => {
|
||||
return (
|
||||
props.message?.htmlContent?.full ??
|
||||
props.message?.textContent?.full?.replace(/\n/g, '<br>')
|
||||
);
|
||||
});
|
||||
|
||||
const unquotedHTML = computed(() =>
|
||||
EmailQuoteExtractor.extractQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
const hasQuotedMessage = computed(() =>
|
||||
EmailQuoteExtractor.hasQuotes(fullHTML.value)
|
||||
);
|
||||
|
||||
const textToShow = computed(() => {
|
||||
const text = props.message?.textContent?.full;
|
||||
return text?.replace(/\n/g, '<br>');
|
||||
});
|
||||
|
||||
const onContactSearch = debounce(
|
||||
async query => {
|
||||
isSearching.value = true;
|
||||
contacts.value = [];
|
||||
try {
|
||||
contacts.value = await searchContacts(query);
|
||||
isSearching.value = false;
|
||||
} catch (error) {
|
||||
useAlert(t('FORWARD_MESSAGE_FORM.CONTACT_SEARCH.ERROR_MESSAGE'));
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
},
|
||||
300,
|
||||
false
|
||||
);
|
||||
|
||||
const handleClickOutside = () => {
|
||||
selectedContact.value = null;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleForwardMessage = async ({ state }) => {
|
||||
try {
|
||||
const messagePayload = {
|
||||
conversationId: currentChat.value?.id,
|
||||
message: state.message,
|
||||
toEmails: selectedContact.value?.email,
|
||||
private: false,
|
||||
contentAttributes: {
|
||||
forwarded_message_id: props.messageId,
|
||||
},
|
||||
sender: {
|
||||
name: currentUser.value?.name,
|
||||
thumbnail: currentUser.value?.avatar_url,
|
||||
},
|
||||
files: globalConfig.value?.directUploadsEnabled
|
||||
? state.attachedFiles.map(file => file.blobSignedId)
|
||||
: state.attachedFiles.map(file => file.resource.file),
|
||||
};
|
||||
await store.dispatch('createPendingMessageAndSend', messagePayload);
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
||||
// Close the forward message modal after sending
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.data?.error ||
|
||||
t('FORWARD_MESSAGE_FORM.FORWARD_MESSAGE.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
let contact;
|
||||
if (action === 'create') {
|
||||
isCreatingContact.value = true;
|
||||
try {
|
||||
contact = await createNewContact(value);
|
||||
isCreatingContact.value = false;
|
||||
} catch (error) {
|
||||
isCreatingContact.value = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
contact = rest;
|
||||
}
|
||||
selectedContact.value = contact;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||
{ ignore: ['div.ProseMirror-prompt'] },
|
||||
]"
|
||||
>
|
||||
<ForwardMessageForm
|
||||
:forward-type="forwardType"
|
||||
:contacts="contacts"
|
||||
:selected-contact="selectedContact"
|
||||
:is-loading="isSearching"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:from-email="fromEmail"
|
||||
:message="message"
|
||||
:attachments="attachments"
|
||||
:message-signature="messageSignature"
|
||||
:content="content"
|
||||
:is-plain-email="!message || !Object.keys(message).length"
|
||||
:full-html="fullHTML"
|
||||
:unquoted-html="unquotedHTML"
|
||||
:text-to-show="textToShow"
|
||||
:has-quoted-message="hasQuotedMessage"
|
||||
@search-contacts="onContactSearch"
|
||||
@update-selected-contact="handleSelectedContact"
|
||||
@clear-selected-contact="selectedContact = null"
|
||||
@discard="emit('close')"
|
||||
@forward-message="handleForwardMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Letter } from 'vue-letter';
|
||||
import { allowedCssProperties } from 'lettersanitizer';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import EmailMeta from 'dashboard/components-next/message/bubbles/Email/EmailMeta.vue';
|
||||
|
||||
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
defineProps({
|
||||
content: { type: String, default: '' },
|
||||
isPlainEmail: { type: Boolean, default: false },
|
||||
hasQuotedMessage: { type: Boolean, default: false },
|
||||
fullHtml: { type: String, default: '' },
|
||||
unquotedHtml: { type: String, default: '' },
|
||||
textToShow: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const showQuotedMessage = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full">
|
||||
<Editor
|
||||
v-model="modelValue"
|
||||
:placeholder="t('FORWARD_MESSAGE_FORM.EMAIL_EDITOR_PLACEHOLDER')"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px] [&_.ProseMirror-woot-style]:!min-h-fit"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
/>
|
||||
<div class="px-4 pb-4 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ t('FORWARD_MESSAGE_FORM.FORWARDED_MESSAGE') }}
|
||||
</span>
|
||||
</div>
|
||||
<EmailMeta />
|
||||
</div>
|
||||
<div class="px-4 pb-4">
|
||||
<FormattedContent
|
||||
v-if="isPlainEmail"
|
||||
class="text-n-slate-12"
|
||||
:content="content"
|
||||
/>
|
||||
<template v-else>
|
||||
<Letter
|
||||
v-if="showQuotedMessage"
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
'transform',
|
||||
'transform-origin',
|
||||
]"
|
||||
:html="fullHtml"
|
||||
:text="textToShow"
|
||||
/>
|
||||
<Letter
|
||||
v-else
|
||||
class-name="prose prose-bubble !max-w-none letter-render"
|
||||
:html="unquotedHtml"
|
||||
:allowed-css-properties="[
|
||||
...allowedCssProperties,
|
||||
'transform',
|
||||
'transform-origin',
|
||||
]"
|
||||
:text="textToShow"
|
||||
/>
|
||||
</template>
|
||||
<button
|
||||
v-if="hasQuotedMessage"
|
||||
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
|
||||
@click="showQuotedMessage = !showQuotedMessage"
|
||||
>
|
||||
<template v-if="showQuotedMessage">
|
||||
{{ t('FORWARD_MESSAGE_FORM.HIDE_QUOTED_TEXT') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('FORWARD_MESSAGE_FORM.SHOW_QUOTED_TEXT') }}
|
||||
</template>
|
||||
<Icon
|
||||
:icon="
|
||||
showQuotedMessage ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
<script setup>
|
||||
import { ref, computed, reactive } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { buildContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import ContactSelector from 'dashboard/components-next/NewConversation/components/ContactSelector.vue';
|
||||
import ActionButtons from 'dashboard/components-next/NewConversation/components/ActionButtons.vue';
|
||||
import AttachmentPreviews from 'dashboard/components-next/NewConversation/components/AttachmentPreviews.vue';
|
||||
import EmailMessageEditor from './EmailMessageEditor.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
|
||||
const props = defineProps({
|
||||
forwardType: { type: String, default: 'email' }, // eslint-disable-line vue/no-unused-properties
|
||||
contacts: { type: Array, default: () => [] },
|
||||
selectedContact: { type: Object, default: null },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
isCreatingContact: { type: Boolean, default: false },
|
||||
fromEmail: { type: String, default: null },
|
||||
messageSignature: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
isPlainEmail: { type: Boolean, default: false },
|
||||
fullHtml: { type: String, default: '' },
|
||||
unquotedHtml: { type: String, default: '' },
|
||||
textToShow: { type: String, default: '' },
|
||||
hasQuotedMessage: { type: Boolean, default: false },
|
||||
attachments: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'searchContacts',
|
||||
'updateSelectedContact',
|
||||
'clearSelectedContact',
|
||||
'discard',
|
||||
'forwardMessage',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
message: '',
|
||||
attachedFiles: [],
|
||||
});
|
||||
|
||||
const showContactsDropdown = ref(false);
|
||||
|
||||
const contactableInboxesList = computed(() => {
|
||||
return buildContactableInboxesList(props.selectedContact?.contactInboxes);
|
||||
});
|
||||
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
}));
|
||||
|
||||
const v$ = useVuelidate(validationRules, {
|
||||
selectedContact: computed(() => props.selectedContact),
|
||||
});
|
||||
|
||||
const validationStates = computed(() => ({
|
||||
isContactInvalid:
|
||||
v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid,
|
||||
}));
|
||||
|
||||
const handleContactSearch = value => {
|
||||
showContactsDropdown.value = true;
|
||||
emit('searchContacts', {
|
||||
keys: ['email'],
|
||||
query: value,
|
||||
});
|
||||
};
|
||||
|
||||
const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
emit('updateSelectedContact', { value, action, ...rest });
|
||||
showContactsDropdown.value = false;
|
||||
};
|
||||
|
||||
const clearSelectedContact = () => {
|
||||
emit('clearSelectedContact');
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const handleDropdownUpdate = (type, value) => {
|
||||
showContactsDropdown.value = value;
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
state.message += emoji;
|
||||
};
|
||||
|
||||
const handleAddSignature = signature => {
|
||||
state.message = appendSignature(state.message, signature);
|
||||
};
|
||||
|
||||
const handleRemoveSignature = signature => {
|
||||
state.message = removeSignature(state.message, signature);
|
||||
};
|
||||
|
||||
const handleAttachFile = files => {
|
||||
state.attachedFiles = files;
|
||||
};
|
||||
|
||||
const clearForm = () => {
|
||||
Object.assign(state, {
|
||||
message: '',
|
||||
attachedFiles: [],
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
if (!isValid) return;
|
||||
|
||||
try {
|
||||
const success = await emit('forwardMessage', { state });
|
||||
if (success) {
|
||||
clearForm();
|
||||
}
|
||||
} catch (error) {
|
||||
// Form will not be cleared if conversation creation fails
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] max-h-[31.25rem] divide-y divide-n-strong transition-all duration-300 ease-in-out top-full justify-between flex flex-col border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="relative flex-1 rounded-t-xl px-4 py-3 overflow-y-visible bg-n-alpha-3"
|
||||
>
|
||||
<div class="flex items-baseline w-full gap-3 min-h-7">
|
||||
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('FORWARD_MESSAGE_FORM.FROM') }}
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
|
||||
>
|
||||
<span class="text-sm truncate text-n-slate-12">
|
||||
{{ fromEmail }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContactSelector
|
||||
class="bg-n-alpha-3"
|
||||
:contacts="contacts"
|
||||
:selected-contact="selectedContact"
|
||||
:show-contacts-dropdown="showContactsDropdown"
|
||||
:is-loading="isLoading"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:show-inboxes-dropdown="false"
|
||||
:has-errors="validationStates.isContactInvalid"
|
||||
@search-contacts="handleContactSearch"
|
||||
@set-selected-contact="setSelectedContact"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@update-dropdown="handleDropdownUpdate"
|
||||
/>
|
||||
<div class="overflow-y-scroll">
|
||||
<EmailMessageEditor
|
||||
v-model="state.message"
|
||||
class="bg-n-alpha-3"
|
||||
:content="content"
|
||||
:is-plain-email="isPlainEmail"
|
||||
:has-quoted-message="hasQuotedMessage"
|
||||
:full-html="fullHtml"
|
||||
:unquoted-html="unquotedHtml"
|
||||
:text-to-show="textToShow"
|
||||
/>
|
||||
<section
|
||||
v-if="Array.isArray(attachments) && attachments.length"
|
||||
class="px-4 pb-4 pt-2 !border-t-0 space-y-2 bg-n-alpha-3"
|
||||
>
|
||||
<AttachmentChips
|
||||
:attachments="attachments"
|
||||
class="gap-1 !justify-start"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
:attachments="state.attachedFiles"
|
||||
class="bg-n-alpha-3"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
<ActionButtons
|
||||
class="bg-n-alpha-3 sticky bottom-0 backdrop-blur-[100px]"
|
||||
:attached-files="state.attachedFiles"
|
||||
is-email-or-web-widget-inbox
|
||||
channel-type="Channel::Email"
|
||||
:is-loading="false"
|
||||
:disable-send-button="false"
|
||||
has-selected-inbox
|
||||
:has-no-inbox="false"
|
||||
:is-dropdown-active="showContactsDropdown"
|
||||
:message-signature="messageSignature"
|
||||
@insert-emoji="onClickInsertEmoji"
|
||||
@add-signature="handleAddSignature"
|
||||
@remove-signature="handleRemoveSignature"
|
||||
@attach-file="handleAttachFile"
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,7 +100,7 @@ const MessageControl = Symbol('MessageControl');
|
||||
* @property {import('vue').ComputedRef<MessageVariant>} variant - The visual variant of the message
|
||||
* @property {import('vue').ComputedRef<boolean>} isBotOrAgentMessage - Does the message belong to the current user
|
||||
* @property {import('vue').ComputedRef<boolean>} isPrivate - Proxy computed value for private
|
||||
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is differnt from groupWithNext, this has a bypass for a failed message
|
||||
* @property {import('vue').ComputedRef<boolean>} shouldGroupWithNext - Should group with the next message or not, it is different from groupWithNext, this has a bypass for a failed message
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,7 @@ const menuRef = useTemplateRef('menuRef');
|
||||
|
||||
const scrollLockElement = computed(() => {
|
||||
if (!elementToLock?.value) return null;
|
||||
return elementToLock.value?.$el;
|
||||
return elementToLock.value.$el;
|
||||
});
|
||||
|
||||
const isLocked = useScrollLock(scrollLockElement);
|
||||
|
||||
@@ -11,6 +11,7 @@ import ReplyBox from './ReplyBox.vue';
|
||||
import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
// stores and apis
|
||||
@@ -44,6 +45,7 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
Icon,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -495,6 +497,16 @@ export default {
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<template #forwardedMessageAddress="{ address }">
|
||||
<li class="flex items-center gap-1 !mt-4 !mb-2.5 ltr:pl-9 rtl:pr-9 h-5">
|
||||
<Icon icon="i-lucide-forward" class="text-n-amber-10 size-4" />
|
||||
<span class="text-n-amber-10 text-xs font-medium leading-[20px]">
|
||||
{{
|
||||
$t('CONVERSATION.FORWARDED_TO', { address: address?.join(', ') })
|
||||
}}
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<template #after>
|
||||
<ConversationLabelSuggestion
|
||||
v-if="shouldShowLabelSuggestions"
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"RATING_TITLE": "Rating",
|
||||
"FEEDBACK_TITLE": "Feedback",
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"FORWARDED_TO": "forwarded to {address}",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels"
|
||||
@@ -258,6 +259,7 @@
|
||||
"TRANSLATE": "Translate",
|
||||
"COPY_PERMALINK": "Copy link to the message",
|
||||
"LINK_COPIED": "Message URL copied to the clipboard",
|
||||
"FORWARD_EMAIL": "Forward email",
|
||||
"DELETE_CONFIRMATION": {
|
||||
"TITLE": "Are you sure you want to delete this message?",
|
||||
"MESSAGE": "You cannot undo this action",
|
||||
@@ -270,6 +272,20 @@
|
||||
"COPILOT": "Copilot"
|
||||
}
|
||||
},
|
||||
"FORWARD_MESSAGE_FORM": {
|
||||
"FROM": "From :",
|
||||
"EMAIL_EDITOR_PLACEHOLDER": "Write your message here...",
|
||||
"FORWARDED_MESSAGE": "---------- Forwarded message ----------",
|
||||
"SHOW_QUOTED_TEXT": "Show quoted text",
|
||||
"HIDE_QUOTED_TEXT": "Hide quoted text",
|
||||
"CONTACT_SEARCH": {
|
||||
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
|
||||
},
|
||||
"FORWARD_MESSAGE": {
|
||||
"ERROR_MESSAGE": "We couldn’t able to forward the message. Please try again.",
|
||||
"SUCCESS_MESSAGE": "The message was forwarded successfully!"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"TITLE": "Send conversation transcript",
|
||||
"DESC": "Send a copy of the conversation transcript to the specified email address",
|
||||
|
||||
@@ -44,7 +44,7 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['open', 'close', 'replyTo'],
|
||||
emits: ['open', 'close', 'replyTo', 'forwardEmail'],
|
||||
setup() {
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
@@ -116,6 +116,13 @@ export default {
|
||||
handleClose(e) {
|
||||
this.$emit('close', e);
|
||||
},
|
||||
openForwardModal() {
|
||||
this.handleClose();
|
||||
this.$emit('forwardEmail', {
|
||||
x: this.contextMenuPosition.x,
|
||||
y: this.contextMenuPosition.y,
|
||||
});
|
||||
},
|
||||
handleTranslate() {
|
||||
const { locale } = this.getAccount(this.currentAccountId);
|
||||
this.$store.dispatch('translateMessage', {
|
||||
@@ -240,6 +247,15 @@ export default {
|
||||
variant="icon"
|
||||
@click.stop="showCannedResponseModal"
|
||||
/>
|
||||
<MenuItem
|
||||
v-if="enabledOptions['forwardEmail']"
|
||||
:option="{
|
||||
icon: 'forward',
|
||||
label: $t('CONVERSATION.CONTEXT_MENU.FORWARD_EMAIL'),
|
||||
}"
|
||||
variant="icon"
|
||||
@click.stop="openForwardModal"
|
||||
/>
|
||||
<hr v-if="enabledOptions['delete']" />
|
||||
<MenuItem
|
||||
v-if="enabledOptions['delete']"
|
||||
|
||||
@@ -285,5 +285,6 @@
|
||||
"M9.60364 9.20645C9.60364 8.67008 10.0385 8.23523 10.5749 8.23523C11.1113 8.23523 11.5461 8.67008 11.5461 9.20645V11.4511C11.5461 11.9875 11.1113 12.4223 10.5749 12.4223C10.0385 12.4223 9.60364 11.9875 9.60364 11.4511V9.20645Z",
|
||||
"M17.1442 5.57049C13.5275 5.06019 10.5793 5.04007 6.88135 5.56825C5.9466 5.70176 5.32812 5.79197 4.85654 5.92976C4.41928 6.05757 4.17061 6.20994 3.96492 6.43984C3.539 6.91583 3.48286 7.45419 3.4248 9.33184C3.36775 11.1772 3.48076 12.831 3.69481 14.6918C3.80887 15.6834 3.88736 16.3526 4.01268 16.8613C4.13155 17.3439 4.27532 17.6034 4.47513 17.802C4.67654 18.0023 4.93467 18.1435 5.40841 18.2581C5.90952 18.3793 6.56702 18.4526 7.5442 18.5592C10.7045 18.904 13.0702 18.9022 16.2423 18.561C17.2313 18.4546 17.8995 18.3813 18.4081 18.2609C18.8913 18.1465 19.1511 18.0063 19.3497 17.8118C19.5442 17.6213 19.6928 17.3587 19.8217 16.852C19.9561 16.3234 20.0476 15.624 20.18 14.5966C20.4162 12.7633 20.5863 11.1533 20.5929 9.3896C20.5999 7.50391 20.5613 6.96737 20.1306 6.46971C19.9226 6.22932 19.6696 6.0713 19.2224 5.93968C18.7395 5.79754 18.1042 5.70594 17.1442 5.57049ZM6.65555 3.98715C10.5078 3.43695 13.6072 3.45849 17.3674 3.98902L17.4224 3.99678C18.3127 4.12235 19.0648 4.22844 19.6733 4.40753C20.33 4.60078 20.8792 4.89417 21.3382 5.4245C22.2041 6.42482 22.1984 7.6117 22.1909 9.18858C22.1905 9.25686 22.1902 9.32584 22.19 9.3956C22.183 11.2604 22.0026 12.949 21.764 14.8006L21.7577 14.8496C21.6332 15.8159 21.5307 16.6121 21.3695 17.2458C21.2 17.9121 20.9467 18.4833 20.4672 18.9529C19.9919 19.4183 19.4302 19.6602 18.776 19.8151C18.1582 19.9613 17.3895 20.044 16.4629 20.1436L16.4131 20.149C13.1283 20.5023 10.6472 20.5043 7.37097 20.1469L7.32043 20.1414C6.40679 20.0417 5.64604 19.9587 5.03292 19.8104C4.38112 19.6527 3.82317 19.406 3.34911 18.9347C2.87346 18.4618 2.62363 17.8999 2.46191 17.2433C2.30938 16.6241 2.22071 15.8531 2.11393 14.9246L2.10815 14.8743C1.88863 12.9659 1.76823 11.23 1.82845 9.28246C1.83063 9.2118 1.83272 9.14191 1.83479 9.07281C1.8816 7.50776 1.91671 6.33374 2.7747 5.37486C3.22992 4.86612 3.76798 4.58399 4.40853 4.39678C5.00257 4.22316 5.73505 4.11858 6.60207 3.99479C6.61981 3.99225 6.63764 3.9897 6.65555 3.98715Z"
|
||||
],
|
||||
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0"
|
||||
"scan-person-outline": "M5.25 3.5A1.75 1.75 0 0 0 3.5 5.25v3a.75.75 0 0 1-1.5 0v-3A3.25 3.25 0 0 1 5.25 2h3a.75.75 0 0 1 0 1.5zm0 17a1.75 1.75 0 0 1-1.75-1.75v-3a.75.75 0 0 0-1.5 0v3A3.25 3.25 0 0 0 5.25 22h3a.75.75 0 0 0 .707-1l-.005-.015a.75.75 0 0 0-.702-.485zM20.5 5.25a1.75 1.75 0 0 0-1.75-1.75h-3a.75.75 0 0 1 0-1.5h3A3.25 3.25 0 0 1 22 5.25v3a.75.75 0 0 1-1.5 0zM18.75 20.5a1.75 1.75 0 0 0 1.75-1.75v-3a.75.75 0 0 1 1.5 0v3A3.25 3.25 0 0 1 18.75 22h-3a.75.75 0 0 1 0-1.5zM6.5 18.616q0 .465.258.884H5.25a1 1 0 0 1-.129-.011A3.1 3.1 0 0 1 5 18.616v-.366A2.25 2.25 0 0 1 7.25 16h9.5A2.25 2.25 0 0 1 19 18.25v.366c0 .31-.047.601-.132.875a1 1 0 0 1-.118.009h-1.543a1.56 1.56 0 0 0 .293-.884v-.366a.75.75 0 0 0-.75-.75h-9.5a.75.75 0 0 0-.75.75zm8.25-8.866a2.75 2.75 0 1 0-5.5 0a2.75 2.75 0 0 0 5.5 0m1.5 0a4.25 4.25 0 1 1-8.5 0a4.25 4.25 0 0 1 8.5 0",
|
||||
"forward-outline": "M14.72 6.28a.75.75 0 0 1 1.06-1.06l5 5a.75.75 0 0 1 0 1.06l-5 5a.75.75 0 1 1-1.06-1.06l3.72-3.72h-7.69a6.25 6.25 0 0 0-6.25 6.25v.5a.75.75 0 0 1-1.5 0v-.5A7.75 7.75 0 0 1 10.75 10h7.69z"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
|
||||
init_conversation_attributes(message.conversation)
|
||||
@message = message
|
||||
@has_forwarded_content = message.content_attributes['forwarded_message_id'].present?
|
||||
reply_mail_object = prepare_mail(true)
|
||||
message.update(source_id: reply_mail_object.message_id)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Messages::ForwardedMessageBuilderService
|
||||
attr_reader :message_id, :params
|
||||
|
||||
def initialize(message_id, params = {})
|
||||
@message_id = message_id
|
||||
@params = params || {}
|
||||
end
|
||||
|
||||
def perform
|
||||
return {} unless message_id
|
||||
return basic_attributes if forwarded_message.blank?
|
||||
|
||||
build_forwarded_attributes
|
||||
end
|
||||
|
||||
def formatted_content(original_content = '')
|
||||
return original_content.to_s if forwarded_message.blank?
|
||||
|
||||
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
|
||||
formatted_info = data_handler.formatted_info
|
||||
|
||||
content_builder = Messages::ForwardedMessageContentBuilderService.new(
|
||||
formatted_info,
|
||||
forwarded_message,
|
||||
email_data
|
||||
)
|
||||
|
||||
content_builder.formatted_content(original_content)
|
||||
end
|
||||
|
||||
def forwarded_email_data(original_content = '')
|
||||
return {} if forwarded_message.blank?
|
||||
|
||||
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
|
||||
data = data_handler.prepare_email_data
|
||||
formatted_info = data_handler.formatted_info
|
||||
|
||||
content_builder = Messages::ForwardedMessageContentBuilderService.new(
|
||||
formatted_info,
|
||||
forwarded_message,
|
||||
email_data
|
||||
)
|
||||
|
||||
process_email_content(data, content_builder, original_content)
|
||||
end
|
||||
|
||||
def forwarded_attachments
|
||||
forwarded_message.attachments if forwarded_message&.attachments.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_email_content(data, content_builder, original_content)
|
||||
full_content = content_builder.formatted_content(original_content)
|
||||
process_text_content(data, original_content, full_content)
|
||||
process_html_content(data, content_builder, original_content, full_content)
|
||||
add_attachments(data)
|
||||
data
|
||||
end
|
||||
|
||||
def process_text_content(data, original_content, full_content)
|
||||
# Convert markdown in original_content to plain text
|
||||
original_plain = Messages::ForwardedMessageFormatterService.markdown_to_plain_text(original_content.to_s)
|
||||
|
||||
# Convert full_content to plain text if it contains markdown
|
||||
full_plain = Messages::ForwardedMessageFormatterService.markdown_to_plain_text(full_content)
|
||||
|
||||
data['text_content'].merge!(
|
||||
'quoted' => original_plain,
|
||||
'reply' => full_plain,
|
||||
'full' => full_plain
|
||||
)
|
||||
end
|
||||
|
||||
def process_html_content(data, content_builder, original_content, full_content)
|
||||
stripped = Messages::ForwardedMessageFormatterService.strip_markdown(original_content.to_s)
|
||||
html_full = content_builder.formatted_html_content(original_content)
|
||||
|
||||
data['html_content'].merge!(
|
||||
'quoted' => stripped,
|
||||
'reply' => full_content,
|
||||
'full' => html_full
|
||||
)
|
||||
end
|
||||
|
||||
def add_attachments(data)
|
||||
return if forwarded_message.attachments.blank?
|
||||
|
||||
data['attachments'] = forwarded_message.attachments.map(&:serializable_hash)
|
||||
end
|
||||
|
||||
def build_forwarded_attributes
|
||||
{ content_attributes: { forwarded_message_id: message_id, email: prepare_email_data } }
|
||||
end
|
||||
|
||||
def prepare_email_data
|
||||
data_handler = Messages::ForwardedMessageDataHandlerService.new(forwarded_message, email_data, params)
|
||||
data_handler.prepare_email_data
|
||||
end
|
||||
|
||||
def forwarded_message
|
||||
@forwarded_message ||= Message.find_by(id: message_id)
|
||||
end
|
||||
|
||||
def email_data
|
||||
@email_data ||= forwarded_message&.content_attributes&.dig('email')
|
||||
end
|
||||
|
||||
def basic_attributes
|
||||
{ content_attributes: { forwarded_message_id: message_id } }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Messages::ForwardedMessageContentBuilderService
|
||||
attr_reader :formatted_info, :forwarded_message, :email_data
|
||||
|
||||
def initialize(formatted_info, forwarded_message, email_data)
|
||||
@formatted_info = formatted_info
|
||||
@forwarded_message = forwarded_message
|
||||
@email_data = email_data
|
||||
end
|
||||
|
||||
def forwarded_header_text
|
||||
return '' unless formatted_info.values.any?
|
||||
|
||||
build_header_lines.compact.join("\n")
|
||||
end
|
||||
|
||||
def forwarded_body_text
|
||||
return forwarded_message.content.to_s if email_data.blank?
|
||||
|
||||
text = email_data.dig('text_content', 'full')
|
||||
html = email_data.dig('html_content', 'full')
|
||||
if text.present?
|
||||
text
|
||||
elsif html.present?
|
||||
ActionView::Base.full_sanitizer.sanitize(html)
|
||||
else
|
||||
forwarded_message.content.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def formatted_content(original_content = '')
|
||||
return original_content.to_s if forwarded_message.blank?
|
||||
|
||||
original_content.to_s + forwarded_header_text + forwarded_body_text
|
||||
end
|
||||
|
||||
def formatted_html_content(original_content = '')
|
||||
return original_content if forwarded_message.blank?
|
||||
|
||||
# Always use markdown conversion since it handles plain text correctly
|
||||
converted_content = Messages::ForwardedMessageFormatterService.convert_markdown_to_html(original_content)
|
||||
|
||||
html_builder = Messages::ForwardedMessageHtmlBuilderService.new(formatted_info, forwarded_message, email_data)
|
||||
html_builder.html_wrapper(converted_content)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_header_lines
|
||||
[
|
||||
"\n\n---------- Forwarded message ---------",
|
||||
("From: #{formatted_info[:from]}" if formatted_info[:from].present?),
|
||||
("Date: #{formatted_info[:date]}" if formatted_info[:date].present?),
|
||||
("Subject: #{formatted_info[:subject]}" if formatted_info[:subject].present?),
|
||||
("To: <#{formatted_info[:to]}>" if formatted_info[:to].present?),
|
||||
"\n"
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Messages::ForwardedMessageDataHandlerService
|
||||
attr_reader :forwarded_message, :email_data, :params
|
||||
|
||||
def initialize(forwarded_message, email_data, params = {})
|
||||
@forwarded_message = forwarded_message
|
||||
@email_data = email_data
|
||||
@params = params || {}
|
||||
end
|
||||
|
||||
def prepare_email_data
|
||||
initialize_email_data
|
||||
end
|
||||
|
||||
def formatted_info
|
||||
{
|
||||
from: inbox_email.to_s,
|
||||
date: Messages::ForwardedMessageFormatterService.format_date_string(email_date),
|
||||
subject: subject.to_s,
|
||||
to: recipient_email.to_s
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_email_data
|
||||
data = base_email_data
|
||||
add_content_fields(data)
|
||||
add_header_fields(data)
|
||||
data
|
||||
end
|
||||
|
||||
def base_email_data
|
||||
email_data.present? ? email_data.dup || {} : {}
|
||||
end
|
||||
|
||||
def add_content_fields(data)
|
||||
data['html_content'] ||= {}
|
||||
data['text_content'] ||= {}
|
||||
end
|
||||
|
||||
def add_header_fields(data)
|
||||
data['from'] = [email_from_inbox] # Always overwrite with inbox email
|
||||
data['to'] = Array(params[:to_emails]) if params && params[:to_emails].present?
|
||||
data['subject'] ||= subject
|
||||
data['date'] ||= Time.zone.now.to_s
|
||||
end
|
||||
|
||||
def inbox_email
|
||||
email_from_data || email_from_inbox
|
||||
end
|
||||
|
||||
def email_from_data
|
||||
return nil unless email_data_has_from?
|
||||
|
||||
from_field = email_data['from'].first.to_s
|
||||
Messages::ForwardedMessageFormatterService.parse_from_field(from_field)
|
||||
end
|
||||
|
||||
def email_data_has_from?
|
||||
email_data.present? &&
|
||||
email_data['from'].present? &&
|
||||
email_data['from'].first.present?
|
||||
end
|
||||
|
||||
def email_from_inbox
|
||||
inbox = forwarded_message&.conversation&.inbox
|
||||
return nil if inbox.blank? || inbox.channel_type != 'Channel::Email'
|
||||
|
||||
email = inbox.channel&.email
|
||||
return nil if email.blank?
|
||||
|
||||
email
|
||||
end
|
||||
|
||||
def recipient_email
|
||||
return email_data&.dig('to', 0) if email_data&.dig('to', 0).present?
|
||||
|
||||
forwarded_message&.content_attributes&.dig('to_emails', 0).presence
|
||||
end
|
||||
|
||||
def subject
|
||||
email_data&.dig('subject').presence ||
|
||||
forwarded_message&.conversation&.additional_attributes&.dig('subject').presence ||
|
||||
'No Subject'
|
||||
end
|
||||
|
||||
def email_date
|
||||
email_data&.dig('date').presence || Time.zone.now.to_s
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Messages::ForwardedMessageFormatterService
|
||||
def self.parse_from_field(from_field)
|
||||
return '' if from_field.blank?
|
||||
|
||||
from_field =~ /(.*)<(.*)>/ ? Regexp.last_match(2).strip : from_field
|
||||
end
|
||||
|
||||
def self.format_plain_text_to_html(text)
|
||||
ERB::Util.html_escape(text.to_s).gsub("\n", '<br>')
|
||||
end
|
||||
|
||||
def self.extract_email(from_field)
|
||||
return '' if from_field.blank?
|
||||
|
||||
from_field =~ /<(.*)>/ ? Regexp.last_match(1) : from_field
|
||||
end
|
||||
|
||||
def self.format_date_string(date_str)
|
||||
return '' if date_str.blank?
|
||||
|
||||
begin
|
||||
parsed_date = DateTime.now
|
||||
parsed_date.strftime('%a, %b %-d, %Y at %-l:%M %p')
|
||||
rescue StandardError
|
||||
date_str
|
||||
end
|
||||
end
|
||||
|
||||
def self.strip_markdown(text)
|
||||
return '' if text.blank?
|
||||
|
||||
# Convert markdown to HTML using CommonMarker
|
||||
html = CommonMarker.render_html(text, :DEFAULT)
|
||||
|
||||
# Strip HTML tags to get plain text
|
||||
ActionView::Base.full_sanitizer.sanitize(html)
|
||||
end
|
||||
|
||||
def self.convert_markdown_to_html(text)
|
||||
return '' if text.blank?
|
||||
|
||||
# Use CommonMarker with GitHub Flavored Markdown options
|
||||
options = [:GITHUB_PRE_LANG, :UNSAFE]
|
||||
extensions = [:table, :strikethrough, :autolink]
|
||||
|
||||
CommonMarker.render_html(text, options, extensions)
|
||||
end
|
||||
|
||||
# Convert text to plain text, stripping any markdown formatting
|
||||
def self.markdown_to_plain_text(text)
|
||||
return '' if text.blank?
|
||||
|
||||
# Simply strip any markdown by converting to HTML and sanitizing
|
||||
strip_markdown(text)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Messages::ForwardedMessageHtmlBuilderService
|
||||
attr_reader :formatted_info, :forwarded_message, :email_data
|
||||
|
||||
def initialize(formatted_info, forwarded_message, email_data)
|
||||
@formatted_info = formatted_info
|
||||
@forwarded_message = forwarded_message
|
||||
@email_data = email_data
|
||||
end
|
||||
|
||||
def html_wrapper(content)
|
||||
base = '<div dir="ltr">'
|
||||
if content.blank?
|
||||
"#{base}#{forwarded_header_html}#{forwarded_body_html}</div>"
|
||||
else
|
||||
"#{base}#{content}<br><br>#{forwarded_header_html}#{forwarded_body_html}</div>"
|
||||
end
|
||||
end
|
||||
|
||||
def forwarded_header_html
|
||||
from_value = formatted_info[:from].to_s
|
||||
email = Messages::ForwardedMessageFormatterService.extract_email(from_value)
|
||||
|
||||
[
|
||||
'<div class="gmail_quote gmail_quote_container">',
|
||||
'<div dir="ltr" class="gmail_attr">---------- Forwarded message ---------<br>',
|
||||
build_email_html(email),
|
||||
build_field_html('Date', formatted_info[:date]),
|
||||
build_field_html('Subject', formatted_info[:subject]),
|
||||
build_to_html(formatted_info[:to]),
|
||||
'</div><br><br>'
|
||||
].compact.join
|
||||
end
|
||||
|
||||
def forwarded_body_html
|
||||
# Return HTML content directly if available
|
||||
return email_data.dig('html_content', 'full') if email_data&.dig('html_content', 'full').present?
|
||||
|
||||
# Otherwise format content to HTML
|
||||
content = if email_data&.dig('text_content', 'full').present?
|
||||
email_data.dig('text_content', 'full')
|
||||
else
|
||||
forwarded_message.content.to_s
|
||||
end
|
||||
|
||||
# Always use markdown conversion since it handles plain text correctly
|
||||
Messages::ForwardedMessageFormatterService.convert_markdown_to_html(content)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_email_html(email)
|
||||
return nil if email.blank?
|
||||
|
||||
"From: <<a href=\"mailto:#{email}\">#{email}</a>><br>"
|
||||
end
|
||||
|
||||
def build_field_html(label, value)
|
||||
value.present? ? "#{label}: #{value}<br>" : nil
|
||||
end
|
||||
|
||||
def build_to_html(to_email)
|
||||
return nil if to_email.blank?
|
||||
|
||||
"To: <<a href=\"mailto:#{to_email}\">#{to_email}</a>><br>"
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,23 @@
|
||||
<% if @message.content %>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
|
||||
<% if @message.content_attributes&.dig('forwarded_message_id').present? %>
|
||||
<% if @message.content_attributes&.dig('email', 'html_content', 'full').present? %>
|
||||
<%= @message.content_attributes.dig('email', 'html_content', 'full').html_safe %>
|
||||
<% elsif @message.content_attributes&.dig('email', 'text_content', 'full').present? %>
|
||||
<div>
|
||||
<%= simple_format(@message.content_attributes.dig('email', 'text_content', 'full')) %>
|
||||
</div>
|
||||
<% elsif @message.content %>
|
||||
<div>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.content).render_message.html_safe %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% if @message.content %>
|
||||
<div>
|
||||
<%= ChatwootMarkdownRenderer.new(@message.outgoing_content).render_message %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @large_attachments.present? %>
|
||||
<p>Attachments:</p>
|
||||
<% @large_attachments.each do |attachment| %>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<% if @message.content_attributes&.dig('forwarded_message_id').present? %>
|
||||
<% if @message.content_attributes&.dig('email', 'text_content', 'full').present? %>
|
||||
<%= @message.content_attributes&.dig('email', 'text_content', 'full') %>
|
||||
<% elsif @message.content %>
|
||||
<%= @message.content %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% if @message.content %>
|
||||
<%= @message.content %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @large_attachments.present? %>
|
||||
Attachments:
|
||||
<% @large_attachments.each do |attachment| %>
|
||||
<%= attachment.file.filename.to_s %>: <%= attachment.file_url %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -181,4 +181,247 @@ describe Messages::MessageBuilder do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with forwarded messages' do
|
||||
def create_email_channel_with_inbox
|
||||
channel = create(:channel_email, account: account)
|
||||
inbox = channel.inbox
|
||||
|
||||
{
|
||||
channel: channel,
|
||||
inbox: inbox
|
||||
}
|
||||
end
|
||||
|
||||
def create_email_conversations(inbox)
|
||||
{
|
||||
source: create(:conversation, inbox: inbox, account: account),
|
||||
target: create(:conversation, inbox: inbox, account: account)
|
||||
}
|
||||
end
|
||||
|
||||
def standard_email_data
|
||||
{
|
||||
'from' => ['sender@example.com'],
|
||||
'to' => ['recipient@example.com'],
|
||||
'subject' => 'Test Subject',
|
||||
'date' => '2025-04-29T14:29:07+05:30',
|
||||
'html_content' => {
|
||||
'full' => '<div>HTML content</div>',
|
||||
'quoted' => 'HTML content',
|
||||
'reply' => 'HTML content'
|
||||
},
|
||||
'text_content' => {
|
||||
'full' => 'Text content',
|
||||
'quoted' => 'Text content',
|
||||
'reply' => 'Text content'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def setup_email_environment
|
||||
channel_data = create_email_channel_with_inbox
|
||||
conversations = create_email_conversations(channel_data[:inbox])
|
||||
|
||||
{
|
||||
email_inbox: channel_data[:inbox],
|
||||
email_conversation: conversations[:source],
|
||||
target_conversation: conversations[:target],
|
||||
standard_email_data: standard_email_data
|
||||
}
|
||||
end
|
||||
|
||||
context 'with different message types' do
|
||||
let(:env) { setup_email_environment }
|
||||
|
||||
it 'preserves email data when forwarding' do
|
||||
# Create the original message to be forwarded
|
||||
forwarded_message = create(:message,
|
||||
conversation: env[:email_conversation],
|
||||
account: account,
|
||||
content_attributes: { email: env[:standard_email_data] })
|
||||
|
||||
# Setup params to forward the message
|
||||
forward_params = ActionController::Parameters.new({
|
||||
content: 'Forwarded message:',
|
||||
content_attributes: { forwarded_message_id: forwarded_message.id }
|
||||
})
|
||||
|
||||
message = described_class.new(user, env[:target_conversation], forward_params).perform
|
||||
|
||||
expect(message.content_attributes[:forwarded_message_id]).to eq(forwarded_message.id)
|
||||
expect(message.content_attributes[:email]).to be_present
|
||||
expect(message.content).to include('Forwarded message:')
|
||||
expect(message.content).to include('---------- Forwarded message ---------')
|
||||
html_content = message.content_attributes[:email]['html_content']['full']
|
||||
text_content = message.content_attributes[:email]['text_content']['full']
|
||||
expect(html_content).to include('<div>HTML content</div>')
|
||||
expect(text_content).to include('Text content')
|
||||
end
|
||||
|
||||
it 'handles markdown content in forwarded messages' do
|
||||
# Create the original message to be forwarded
|
||||
forwarded_message = create(:message,
|
||||
conversation: env[:email_conversation],
|
||||
account: account,
|
||||
content_attributes: { email: env[:standard_email_data] })
|
||||
|
||||
markdown_content = '**Bold text** and _italic text_'
|
||||
forward_params = ActionController::Parameters.new({
|
||||
content: markdown_content,
|
||||
content_attributes: { forwarded_message_id: forwarded_message.id }
|
||||
})
|
||||
|
||||
message = described_class.new(user, env[:email_conversation], forward_params).perform
|
||||
|
||||
html_content = message.content_attributes[:email]['html_content']['full']
|
||||
text_quoted = message.content_attributes[:email]['text_content']['quoted']
|
||||
full_text = message.content_attributes[:email]['text_content']['full']
|
||||
|
||||
expect(html_content).to include('<strong>Bold text</strong>')
|
||||
expect(html_content).to include('<em>italic text</em>')
|
||||
|
||||
expect(text_quoted.strip).to eq('Bold text and italic text')
|
||||
|
||||
expect(full_text).to include('Bold text and italic text')
|
||||
expect(full_text).to include('---------- Forwarded message ---------')
|
||||
end
|
||||
|
||||
it 'returns empty email data when forwarding a message with no email data' do
|
||||
regular_message = create(:message, conversation: conversation, account: account)
|
||||
|
||||
forward_params = ActionController::Parameters.new({
|
||||
content: 'Forwarding a regular message:',
|
||||
content_attributes: { forwarded_message_id: regular_message.id }
|
||||
})
|
||||
|
||||
# Create the forwarded message
|
||||
message = described_class.new(user, env[:email_conversation], forward_params).perform
|
||||
|
||||
# Updated expectation - we now expect email data to be present but won't check specific content
|
||||
expect(message.content_attributes[:forwarded_message_id]).to eq(regular_message.id)
|
||||
expect(message.content_attributes[:email]).to be_present
|
||||
end
|
||||
|
||||
it 'preserves multipart content in forwarded messages' do
|
||||
# Create a multipart email message
|
||||
multipart_data = env[:standard_email_data].merge(
|
||||
'html_content' => { 'full' => '<div>HTML <b>formatted</b> content</div>' },
|
||||
'text_content' => { 'full' => 'Plain text content' }
|
||||
)
|
||||
|
||||
multipart_message = create(:message,
|
||||
conversation: env[:email_conversation],
|
||||
account: account,
|
||||
content_attributes: { email: multipart_data })
|
||||
|
||||
forward_params = ActionController::Parameters.new({
|
||||
content: 'Forwarding multipart email:',
|
||||
content_attributes: { forwarded_message_id: multipart_message.id }
|
||||
})
|
||||
|
||||
message = described_class.new(user, env[:email_conversation], forward_params).perform
|
||||
|
||||
# Verify multipart content is preserved
|
||||
expect(message.content_attributes[:email]['html_content']['full']).to include('<div>HTML <b>formatted</b> content</div>')
|
||||
expect(message.content_attributes[:email]['text_content']['full']).to include('Plain text content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with attachments' do
|
||||
let(:env) { setup_email_environment }
|
||||
|
||||
def create_base_message(conversation)
|
||||
create(:message,
|
||||
conversation: conversation,
|
||||
account: account,
|
||||
content_attributes: {
|
||||
email: standard_email_data.merge(
|
||||
'html_content' => { 'full' => '<div>Message with attachments</div>' },
|
||||
'text_content' => { 'full' => 'Message with attachments' }
|
||||
)
|
||||
})
|
||||
end
|
||||
|
||||
def add_text_attachment(message)
|
||||
attachment = message.attachments.new(account_id: account.id, file_type: 'file')
|
||||
attachment.file.attach(
|
||||
io: StringIO.new('test file content'),
|
||||
filename: 'test.txt',
|
||||
content_type: 'text/plain'
|
||||
)
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
def add_image_attachment(message)
|
||||
attachment = message.attachments.new(account_id: account.id, file_type: 'image')
|
||||
attachment.file.attach(
|
||||
io: StringIO.new('fake image content'),
|
||||
filename: 'test.jpg',
|
||||
content_type: 'image/jpeg'
|
||||
)
|
||||
attachment.save!
|
||||
end
|
||||
|
||||
def create_message_with_attachments(conversation)
|
||||
message = create_base_message(conversation)
|
||||
add_text_attachment(message)
|
||||
add_image_attachment(message)
|
||||
message
|
||||
end
|
||||
|
||||
it 'copies attachments from the forwarded message' do
|
||||
message_with_attachments = create_message_with_attachments(env[:email_conversation])
|
||||
|
||||
forward_params = ActionController::Parameters.new({
|
||||
content: 'Forwarding message with attachments:',
|
||||
content_attributes: { forwarded_message_id: message_with_attachments.id }
|
||||
})
|
||||
|
||||
message = described_class.new(user, env[:email_conversation], forward_params).perform
|
||||
|
||||
# Verify attachments are copied
|
||||
expect(message.attachments.count).to eq(2)
|
||||
expect(message.attachments.map(&:file_type)).to include('file', 'image')
|
||||
expect(message.attachments.first.file).to be_attached
|
||||
|
||||
# Verify attachment data in content_attributes
|
||||
expect(message.content_attributes[:email]['attachments']).to be_present
|
||||
expect(message.content_attributes[:email]['attachments'].length).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nested forwarding' do
|
||||
let(:env) { setup_email_environment }
|
||||
|
||||
it 'maintains proper forwarding chain data' do
|
||||
# Create original message
|
||||
parent_message = create(:message,
|
||||
conversation: env[:email_conversation],
|
||||
account: account,
|
||||
content_attributes: { email: env[:standard_email_data] })
|
||||
|
||||
# Create first level forward
|
||||
first_level_params = ActionController::Parameters.new({
|
||||
content: 'First forwarded message:',
|
||||
content_attributes: { forwarded_message_id: parent_message.id }
|
||||
})
|
||||
|
||||
first_forward = described_class.new(user, env[:email_conversation], first_level_params).perform
|
||||
|
||||
# Create second level forward
|
||||
second_level_params = ActionController::Parameters.new({
|
||||
content: 'Forwarding a forwarded message:',
|
||||
content_attributes: { forwarded_message_id: first_forward.id }
|
||||
})
|
||||
|
||||
message = described_class.new(user, env[:email_conversation], second_level_params).perform
|
||||
|
||||
expect(message.content_attributes[:forwarded_message_id]).to be_present
|
||||
expect(message.content).to include('Forwarding a forwarded message:')
|
||||
expect(message.content).to include('---------- Forwarded message ---------')
|
||||
expect(message.content).to include('First forwarded message:')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -240,7 +240,15 @@ RSpec.describe ConversationReplyMailer do
|
||||
end
|
||||
|
||||
it 'renders the body' do
|
||||
expect(mail.decoded).to include message.content
|
||||
body_content = if mail.multipart?
|
||||
# Check either HTML part or text part for the content
|
||||
mail.html_part&.body&.decoded || mail.text_part&.body&.decoded
|
||||
else
|
||||
# Fallback to single-part handling
|
||||
mail.body.decoded
|
||||
end
|
||||
|
||||
expect(body_content).to include message.content
|
||||
end
|
||||
|
||||
it 'updates the source_id' do
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::ForwardedMessageBuilderService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
let(:base_email_content) do
|
||||
{
|
||||
'from' => ['sender@example.com'],
|
||||
'to' => ['recipient@example.com'],
|
||||
'subject' => 'Test Subject',
|
||||
'date' => '2025-04-29T14:29:07+05:30',
|
||||
'html_content' => {
|
||||
'full' => '<div>HTML content</div>',
|
||||
'quoted' => 'HTML content',
|
||||
'reply' => 'HTML content'
|
||||
},
|
||||
'text_content' => {
|
||||
'full' => 'Text content',
|
||||
'quoted' => 'Text content',
|
||||
'reply' => 'Text content'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
# Create a message with email data
|
||||
let(:forwarded_message) do
|
||||
create(:message, conversation: conversation, account: account,
|
||||
content_attributes: { email: base_email_content })
|
||||
end
|
||||
|
||||
# Create a message with multipart email data
|
||||
let(:multipart_message) do
|
||||
create(:message, conversation: conversation, account: account,
|
||||
content_attributes: {
|
||||
email: base_email_content.merge(
|
||||
'html_content' => { 'full' => '<div>HTML <b>formatted</b> content</div>' },
|
||||
'text_content' => { 'full' => 'Plain text content' }
|
||||
)
|
||||
})
|
||||
end
|
||||
|
||||
# Create a message with email data and attachments
|
||||
let(:message_with_attachments) do
|
||||
message = create(:message, conversation: conversation, account: account,
|
||||
content_attributes: {
|
||||
email: base_email_content.merge(
|
||||
'html_content' => { 'full' => '<div>Message with attachments</div>' },
|
||||
'text_content' => { 'full' => 'Message with attachments' }
|
||||
)
|
||||
})
|
||||
|
||||
# Add attachments to the message
|
||||
attachment1 = message.attachments.new(account_id: account.id, file_type: 'file')
|
||||
attachment1.file.attach(io: StringIO.new('test file content'), filename: 'test.txt', content_type: 'text/plain')
|
||||
attachment1.save!
|
||||
|
||||
attachment2 = message.attachments.new(account_id: account.id, file_type: 'image')
|
||||
attachment2.file.attach(io: StringIO.new('fake image content'), filename: 'test.jpg', content_type: 'image/jpeg')
|
||||
attachment2.save!
|
||||
|
||||
message
|
||||
end
|
||||
|
||||
# Create a message with no email data
|
||||
let(:regular_message) { create(:message, conversation: conversation, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when message_id is nil' do
|
||||
it 'returns an empty hash' do
|
||||
builder = described_class.new(nil)
|
||||
expect(builder.perform).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarded message is not found' do
|
||||
it 'returns basic attributes' do
|
||||
builder = described_class.new(999)
|
||||
expect(builder.perform).to eq(content_attributes: { forwarded_message_id: 999 })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarded message has no email data' do
|
||||
it 'returns basic attributes with empty email structure' do
|
||||
builder = described_class.new(regular_message.id)
|
||||
result = builder.perform
|
||||
|
||||
expect(result[:content_attributes][:forwarded_message_id]).to eq(regular_message.id)
|
||||
|
||||
expect(result[:content_attributes][:email]).to be_present
|
||||
expect(result[:content_attributes][:email]).to have_key('date')
|
||||
expect(result[:content_attributes][:email]).to have_key('subject')
|
||||
expect(result[:content_attributes][:email]).to have_key('from')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarded message has email data' do
|
||||
it 'returns forwarded attributes' do
|
||||
builder = described_class.new(forwarded_message.id)
|
||||
result = builder.perform
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:content_attributes]).to be_present
|
||||
expect(result[:content_attributes][:forwarded_message_id]).to eq(forwarded_message.id)
|
||||
expect(result[:content_attributes][:email]).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#formatted_content' do
|
||||
context 'when forwarded message is blank' do
|
||||
it 'returns original content' do
|
||||
builder = described_class.new(999) # Non-existent message ID
|
||||
expect(builder.formatted_content('Original')).to eq('Original')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarded message has no email data' do
|
||||
it 'returns formatted content with basic information' do
|
||||
builder = described_class.new(regular_message.id)
|
||||
result = builder.formatted_content('Original')
|
||||
|
||||
expect(result).to include('Original')
|
||||
expect(result).to include('---------- Forwarded message ---------')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarded message has email data' do
|
||||
it 'returns formatted content with header and body' do
|
||||
builder = described_class.new(forwarded_message.id)
|
||||
result = builder.formatted_content('Original')
|
||||
|
||||
expect(result).to include('Original')
|
||||
expect(result).to include('---------- Forwarded message ---------')
|
||||
expect(result).to include('Subject: Test Subject')
|
||||
expect(result).to include('Text content') # Should include the text content
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#forwarded_email_data' do
|
||||
context 'when forwarded message is blank' do
|
||||
it 'returns empty hash' do
|
||||
builder = described_class.new(999)
|
||||
expect(builder.forwarded_email_data('Original')).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarded message has email data' do
|
||||
it 'returns complete email data structure' do
|
||||
builder = described_class.new(forwarded_message.id)
|
||||
result = builder.forwarded_email_data('**Original**')
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result['html_content']).to be_present
|
||||
expect(result['text_content']).to be_present
|
||||
|
||||
expect(result['html_content']['full']).to include('Original')
|
||||
expect(result['text_content']['full']).to include('---------- Forwarded message ---------')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling multipart emails' do
|
||||
it 'preserves both HTML and text parts' do
|
||||
builder = described_class.new(multipart_message.id)
|
||||
result = builder.forwarded_email_data('New message')
|
||||
|
||||
expect(result['html_content']['full']).to include('<div>HTML <b>formatted</b> content</div>')
|
||||
expect(result['text_content']['full']).to include('Plain text content')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarding a message with attachments' do
|
||||
it 'includes attachments in the forwarded email data' do
|
||||
builder = described_class.new(message_with_attachments.id)
|
||||
result = builder.forwarded_email_data('Original content')
|
||||
|
||||
expect(result['attachments']).to be_present
|
||||
expect(result['attachments'].length).to eq(2)
|
||||
expect(result['attachments'].pluck('file_type')).to match_array(%w[file image])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when forwarding a message without attachments' do
|
||||
it 'does not include attachments in the forwarded email data' do
|
||||
builder = described_class.new(forwarded_message.id)
|
||||
result = builder.forwarded_email_data('Original content')
|
||||
|
||||
expect(result['attachments']).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#forwarded_attachments' do
|
||||
context 'with attachments' do
|
||||
it 'provides access to the forwarded message attachments' do
|
||||
builder = described_class.new(message_with_attachments.id)
|
||||
attachments = builder.forwarded_attachments
|
||||
|
||||
expect(attachments).to be_present
|
||||
expect(attachments.length).to eq(2)
|
||||
expect(attachments.map(&:file_type)).to match_array(%w[file image])
|
||||
expect(attachments.first.file).to be_attached
|
||||
end
|
||||
end
|
||||
|
||||
context 'without attachments' do
|
||||
it 'returns nil when getting forwarded attachments' do
|
||||
builder = described_class.new(forwarded_message.id)
|
||||
attachments = builder.forwarded_attachments
|
||||
|
||||
expect(attachments).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,144 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::ForwardedMessageContentBuilderService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
|
||||
|
||||
let(:formatted_info) do
|
||||
{
|
||||
from: 'sender@example.com',
|
||||
date: 'Wed, Apr 29, 2025 at 2:29 PM',
|
||||
subject: 'Test Subject',
|
||||
to: 'recipient@example.com'
|
||||
}
|
||||
end
|
||||
|
||||
let(:base_email_content) do
|
||||
{
|
||||
'html_content' => {
|
||||
'full' => '<div>HTML content</div>'
|
||||
},
|
||||
'text_content' => {
|
||||
'full' => 'Text content'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
let(:forwarded_message) do
|
||||
create(:message, conversation: conversation, account: account,
|
||||
content_attributes: { email: base_email_content })
|
||||
end
|
||||
|
||||
let(:html_only_message) do
|
||||
create(:message, conversation: conversation, account: account,
|
||||
content_attributes: {
|
||||
email: {
|
||||
'html_content' => { 'full' => '<div>HTML only content</div>' },
|
||||
'text_content' => {}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
let(:content_builder) { described_class.new(formatted_info, forwarded_message, base_email_content) }
|
||||
let(:html_only_builder) { described_class.new(formatted_info, html_only_message, html_only_message.content_attributes[:email]) }
|
||||
let(:empty_builder) { described_class.new(formatted_info, forwarded_message, {}) }
|
||||
|
||||
describe '#forwarded_header_text' do
|
||||
it 'returns a formatted header with all information' do
|
||||
header = content_builder.forwarded_header_text
|
||||
|
||||
expect(header).to include('---------- Forwarded message ---------')
|
||||
expect(header).to include('From: sender@example.com')
|
||||
expect(header).to include('Date: Wed, Apr 29, 2025 at 2:29 PM')
|
||||
expect(header).to include('Subject: Test Subject')
|
||||
expect(header).to include('To: <recipient@example.com>')
|
||||
end
|
||||
|
||||
it 'returns a formatted header without missing fields' do
|
||||
empty_info = { from: '', date: '', subject: '', to: '' }
|
||||
builder = described_class.new(empty_info, forwarded_message, base_email_content)
|
||||
|
||||
expect(builder.forwarded_header_text).to include('---------- Forwarded message ---------')
|
||||
expect(builder.forwarded_header_text).not_to include('From: ')
|
||||
expect(builder.forwarded_header_text).not_to include('Date: ')
|
||||
expect(builder.forwarded_header_text).not_to include('Subject: ')
|
||||
expect(builder.forwarded_header_text).not_to include('To: ')
|
||||
end
|
||||
|
||||
it 'handles partial information' do
|
||||
partial_info = { from: 'sender@example.com', subject: 'Test Subject' }
|
||||
builder = described_class.new(partial_info, forwarded_message, base_email_content)
|
||||
header = builder.forwarded_header_text
|
||||
|
||||
expect(header).to include('From: sender@example.com')
|
||||
expect(header).to include('Subject: Test Subject')
|
||||
expect(header).not_to include('Date:')
|
||||
expect(header).not_to include('To:')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#forwarded_body_text' do
|
||||
it 'returns text content when available' do
|
||||
expect(content_builder.forwarded_body_text).to eq('Text content')
|
||||
end
|
||||
|
||||
it 'returns sanitized HTML content when only HTML is available' do
|
||||
expect(html_only_builder.forwarded_body_text).to include('HTML only content')
|
||||
end
|
||||
|
||||
it 'returns message content when no email data is available' do
|
||||
expect(empty_builder.forwarded_body_text).to eq(forwarded_message.content.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#formatted_content' do
|
||||
it 'returns original content concatenated with header and body' do
|
||||
result = content_builder.formatted_content('Original message')
|
||||
|
||||
expect(result).to start_with('Original message')
|
||||
expect(result).to include('---------- Forwarded message ---------')
|
||||
expect(result).to include('Text content')
|
||||
end
|
||||
|
||||
it 'returns just the original content when forwarded message is blank' do
|
||||
builder = described_class.new(formatted_info, nil, base_email_content)
|
||||
expect(builder.formatted_content('Original message')).to eq('Original message')
|
||||
end
|
||||
|
||||
it 'handles empty original content' do
|
||||
result = content_builder.formatted_content('')
|
||||
|
||||
expect(result).to include('---------- Forwarded message ---------')
|
||||
expect(result).to include('Text content')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#formatted_html_content' do
|
||||
it 'converts markdown in original content to HTML' do
|
||||
html_builder = instance_double(Messages::ForwardedMessageHtmlBuilderService)
|
||||
allow(Messages::ForwardedMessageHtmlBuilderService).to receive(:new)
|
||||
.with(formatted_info, forwarded_message, base_email_content)
|
||||
.and_return(html_builder)
|
||||
|
||||
allow(Messages::ForwardedMessageFormatterService).to receive(:convert_markdown_to_html)
|
||||
.with('**Original** message')
|
||||
.and_return('<p>Converted HTML</p>')
|
||||
|
||||
allow(html_builder).to receive(:html_wrapper)
|
||||
.with('<p>Converted HTML</p>')
|
||||
.and_return('<div>Wrapped content</div>')
|
||||
|
||||
result = content_builder.formatted_html_content('**Original** message')
|
||||
|
||||
expect(Messages::ForwardedMessageFormatterService).to have_received(:convert_markdown_to_html)
|
||||
.with('**Original** message')
|
||||
expect(result).to eq('<div>Wrapped content</div>')
|
||||
end
|
||||
|
||||
it 'returns original content when forwarded message is blank' do
|
||||
builder = described_class.new(formatted_info, nil, base_email_content)
|
||||
expect(builder.formatted_html_content('Original message')).to eq('Original message')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,180 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::ForwardedMessageDataHandlerService do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, account: account) }
|
||||
let(:email_inbox) { create(:inbox, channel: email_channel, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: email_inbox, account: account) }
|
||||
let(:regular_inbox) { create(:inbox, account: account) }
|
||||
let(:regular_conversation) { create(:conversation, inbox: regular_inbox, account: account) }
|
||||
|
||||
let(:base_email_content) do
|
||||
{
|
||||
'from' => ['John Doe <sender@example.com>'],
|
||||
'to' => ['recipient@example.com'],
|
||||
'subject' => 'Test Subject',
|
||||
'date' => '2025-04-29T14:29:07+05:30'
|
||||
}
|
||||
end
|
||||
|
||||
# Message with email data and from an email inbox
|
||||
let(:email_message) do
|
||||
create(:message, conversation: conversation, account: account,
|
||||
content_attributes: { email: base_email_content })
|
||||
end
|
||||
|
||||
# Message with email data but from a regular inbox
|
||||
let(:regular_message) do
|
||||
create(:message, conversation: regular_conversation, account: account,
|
||||
content_attributes: { email: base_email_content })
|
||||
end
|
||||
|
||||
# Message with no email data
|
||||
let(:message_no_email) { create(:message, conversation: conversation, account: account) }
|
||||
|
||||
describe '#prepare_email_data' do
|
||||
it 'returns empty hash structure when email_data is nil' do
|
||||
handler = described_class.new(message_no_email, nil)
|
||||
result = handler.prepare_email_data
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result['html_content']).to eq({})
|
||||
expect(result['text_content']).to eq({})
|
||||
expect(result).to have_key('date')
|
||||
expect(result).to have_key('subject')
|
||||
end
|
||||
|
||||
it 'preserves existing email data and adds required fields' do
|
||||
handler = described_class.new(email_message, base_email_content)
|
||||
result = handler.prepare_email_data
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result['html_content']).to eq({})
|
||||
expect(result['text_content']).to eq({})
|
||||
expect(result['from']).to be_present
|
||||
expect(result['to']).to eq(['recipient@example.com'])
|
||||
expect(result['subject']).to eq('Test Subject')
|
||||
expect(result['date']).to eq('2025-04-29T14:29:07+05:30')
|
||||
end
|
||||
|
||||
it 'adds to_emails from params if provided' do
|
||||
handler = described_class.new(email_message, base_email_content, { to_emails: 'new@example.com' })
|
||||
result = handler.prepare_email_data
|
||||
|
||||
expect(result['to']).to eq(['new@example.com'])
|
||||
end
|
||||
|
||||
it 'overwrites from field with inbox email' do
|
||||
handler = described_class.new(email_message, base_email_content)
|
||||
allow(handler).to receive(:email_from_inbox).and_return('inbox@example.com')
|
||||
|
||||
result = handler.prepare_email_data
|
||||
|
||||
expect(result['from']).to eq(['inbox@example.com'])
|
||||
end
|
||||
end
|
||||
|
||||
describe '#formatted_info' do
|
||||
it 'returns hash with properly formatted fields' do
|
||||
allow(Messages::ForwardedMessageFormatterService).to receive(:format_date_string).and_return('Formatted Date')
|
||||
|
||||
handler = described_class.new(email_message, base_email_content)
|
||||
result = handler.formatted_info
|
||||
|
||||
expect(result[:from]).to be_present
|
||||
expect(result[:date]).to eq('Formatted Date')
|
||||
expect(result[:subject]).to eq('Test Subject')
|
||||
expect(result[:to]).to eq('recipient@example.com')
|
||||
|
||||
expect(Messages::ForwardedMessageFormatterService).to have_received(:format_date_string).with('2025-04-29T14:29:07+05:30')
|
||||
end
|
||||
|
||||
it 'works with missing email data' do
|
||||
# Create instance first, then stub its methods
|
||||
message_without_email = build(:message)
|
||||
handler = described_class.new(message_without_email, nil)
|
||||
|
||||
# Stub the methods on the specific instance
|
||||
allow(handler).to receive(:email_from_data).and_return(nil)
|
||||
allow(handler).to receive(:email_from_inbox).and_return(nil)
|
||||
|
||||
# For email_date, we should expect it to use Time.zone.now.to_s when email_data is nil
|
||||
# So we should mock the format_date_string to return a predictable value
|
||||
allow(Messages::ForwardedMessageFormatterService).to receive(:format_date_string).with(kind_of(String)).and_return('Fri, May 2, 2025 at 12:33 PM') # rubocop:disable Layout/LineLength
|
||||
|
||||
result = handler.formatted_info
|
||||
|
||||
expect(result[:from]).to eq('')
|
||||
expect(result[:date]).to eq('Fri, May 2, 2025 at 12:33 PM')
|
||||
expect(result[:subject]).to eq('No Subject')
|
||||
expect(result[:to]).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when private methods are called' do
|
||||
describe '#inbox_email' do
|
||||
it 'returns email from data when available' do
|
||||
handler = described_class.new(email_message, base_email_content)
|
||||
allow(handler).to receive(:email_from_data).and_return('from_data@example.com')
|
||||
allow(handler).to receive(:email_from_inbox).and_return('from_inbox@example.com')
|
||||
|
||||
result = handler.send(:inbox_email)
|
||||
|
||||
expect(result).to eq('from_data@example.com')
|
||||
end
|
||||
|
||||
it 'returns email from inbox when email data is not available' do
|
||||
handler = described_class.new(email_message, {})
|
||||
allow(handler).to receive(:email_from_data).and_return(nil)
|
||||
allow(handler).to receive(:email_from_inbox).and_return('from_inbox@example.com')
|
||||
|
||||
result = handler.send(:inbox_email)
|
||||
|
||||
expect(result).to eq('from_inbox@example.com')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#email_from_data' do
|
||||
it 'parses email address from from field' do
|
||||
handler = described_class.new(email_message, base_email_content)
|
||||
allow(Messages::ForwardedMessageFormatterService).to receive(:parse_from_field).and_return('parsed@example.com')
|
||||
|
||||
result = handler.send(:email_from_data)
|
||||
|
||||
expect(Messages::ForwardedMessageFormatterService).to have_received(:parse_from_field).with('John Doe <sender@example.com>')
|
||||
expect(result).to eq('parsed@example.com')
|
||||
end
|
||||
|
||||
it 'returns nil when from field is not available' do
|
||||
handler = described_class.new(email_message, {})
|
||||
result = handler.send(:email_from_data)
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#subject' do
|
||||
it 'returns subject from email data when available' do
|
||||
handler = described_class.new(email_message, base_email_content)
|
||||
result = handler.send(:subject)
|
||||
|
||||
expect(result).to eq('Test Subject')
|
||||
end
|
||||
|
||||
it 'returns subject from conversation when available' do
|
||||
allow(conversation).to receive(:additional_attributes).and_return({ 'subject' => 'Conversation Subject' })
|
||||
handler = described_class.new(email_message, {})
|
||||
result = handler.send(:subject)
|
||||
|
||||
expect(result).to eq('Conversation Subject')
|
||||
end
|
||||
|
||||
it 'returns "No Subject" when no subject is available' do
|
||||
handler = described_class.new(message_no_email, nil)
|
||||
result = handler.send(:subject)
|
||||
|
||||
expect(result).to eq('No Subject')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,158 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::ForwardedMessageFormatterService do
|
||||
describe '.convert_markdown_to_html' do
|
||||
it 'converts bold markdown to HTML' do
|
||||
formatted = described_class.convert_markdown_to_html('**Bold Text**')
|
||||
# Updated expectation to match CommonMarker output format
|
||||
expect(formatted).to include('<strong>Bold Text</strong>')
|
||||
end
|
||||
|
||||
it 'converts italic markdown to HTML' do
|
||||
formatted = described_class.convert_markdown_to_html('*Italic Text*')
|
||||
expect(formatted).to include('<em>Italic Text</em>')
|
||||
end
|
||||
|
||||
it 'converts underscore italic markdown to HTML' do
|
||||
formatted = described_class.convert_markdown_to_html('_Italic Text_')
|
||||
expect(formatted).to include('<em>Italic Text</em>')
|
||||
end
|
||||
|
||||
it 'handles multiple markdown elements' do
|
||||
formatted = described_class.convert_markdown_to_html('**Bold** and _italic_')
|
||||
expect(formatted).to include('<strong>Bold</strong>')
|
||||
expect(formatted).to include('<em>italic</em>')
|
||||
end
|
||||
|
||||
it 'handles empty text' do
|
||||
expect(described_class.convert_markdown_to_html('')).to eq('')
|
||||
end
|
||||
|
||||
it 'handles nil text' do
|
||||
expect(described_class.convert_markdown_to_html(nil)).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.strip_markdown' do
|
||||
it 'strips bold markdown' do
|
||||
stripped = described_class.strip_markdown('**Bold Text**')
|
||||
expect(stripped.strip).to eq('Bold Text')
|
||||
end
|
||||
|
||||
it 'strips italic markdown' do
|
||||
stripped = described_class.strip_markdown('*Italic Text*')
|
||||
expect(stripped.strip).to eq('Italic Text')
|
||||
end
|
||||
|
||||
it 'strips underscore italic markdown' do
|
||||
stripped = described_class.strip_markdown('_Italic Text_')
|
||||
expect(stripped.strip).to eq('Italic Text')
|
||||
end
|
||||
|
||||
it 'handles multiple markdown elements' do
|
||||
stripped = described_class.strip_markdown('**Bold** and _italic_')
|
||||
# Updated expectation to handle trailing newline
|
||||
expect(stripped.strip).to eq('Bold and italic')
|
||||
end
|
||||
|
||||
it 'handles empty text' do
|
||||
expect(described_class.strip_markdown('')).to eq('')
|
||||
end
|
||||
|
||||
it 'handles nil text' do
|
||||
expect(described_class.strip_markdown(nil)).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.extract_email' do
|
||||
it 'extracts email from format "Name <email@example.com>"' do
|
||||
extracted = described_class.extract_email('John Doe <john@example.com>')
|
||||
expect(extracted).to eq('john@example.com')
|
||||
end
|
||||
|
||||
it 'returns plain email as-is' do
|
||||
extracted = described_class.extract_email('john@example.com')
|
||||
expect(extracted).to eq('john@example.com')
|
||||
end
|
||||
|
||||
it 'handles empty string' do
|
||||
expect(described_class.extract_email('')).to eq('')
|
||||
end
|
||||
|
||||
it 'handles nil' do
|
||||
expect(described_class.extract_email(nil)).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.parse_from_field' do
|
||||
it 'extracts email from format "Name <email@example.com>"' do
|
||||
parsed = described_class.parse_from_field('John Doe <john@example.com>')
|
||||
expect(parsed).to eq('john@example.com')
|
||||
end
|
||||
|
||||
it 'returns plain email as-is' do
|
||||
parsed = described_class.parse_from_field('john@example.com')
|
||||
expect(parsed).to eq('john@example.com')
|
||||
end
|
||||
|
||||
it 'handles empty string' do
|
||||
expect(described_class.parse_from_field('')).to eq('')
|
||||
end
|
||||
|
||||
it 'handles nil' do
|
||||
expect(described_class.parse_from_field(nil)).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.format_date_string' do
|
||||
it 'formats the date in a readable format' do
|
||||
# NOTE: We don't test the exact formatted output since it uses DateTime.now
|
||||
# which would be different for each test run
|
||||
result = described_class.format_date_string('2025-04-29T14:29:07+05:30')
|
||||
expect(result).to match(/\w{3}, \w{3} \d+, \d{4} at \d+:\d+ [AP]M/)
|
||||
end
|
||||
|
||||
it 'handles empty string' do
|
||||
expect(described_class.format_date_string('')).to eq('')
|
||||
end
|
||||
|
||||
it 'handles nil' do
|
||||
expect(described_class.format_date_string(nil)).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.format_plain_text_to_html' do
|
||||
it 'converts newlines to <br>' do
|
||||
result = described_class.format_plain_text_to_html("Line 1\nLine 2")
|
||||
expect(result).to eq('Line 1<br>Line 2')
|
||||
end
|
||||
|
||||
it 'escapes HTML special characters' do
|
||||
result = described_class.format_plain_text_to_html('<script>alert("XSS")</script>')
|
||||
expect(result).to eq('<script>alert("XSS")</script>')
|
||||
end
|
||||
|
||||
it 'handles empty string' do
|
||||
expect(described_class.format_plain_text_to_html('')).to eq('')
|
||||
end
|
||||
|
||||
it 'handles nil' do
|
||||
expect(described_class.format_plain_text_to_html(nil)).to eq('')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.markdown_to_plain_text' do
|
||||
it 'converts markdown text to plain text' do
|
||||
plain_text = described_class.markdown_to_plain_text('**Bold** and _italic_ text')
|
||||
expect(plain_text.strip).to eq('Bold and italic text')
|
||||
end
|
||||
|
||||
it 'handles empty string' do
|
||||
expect(described_class.markdown_to_plain_text('')).to eq('')
|
||||
end
|
||||
|
||||
it 'handles nil' do
|
||||
expect(described_class.markdown_to_plain_text(nil)).to eq('')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::ForwardedMessageHtmlBuilderService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: account) }
|
||||
|
||||
let(:formatted_info) do
|
||||
{
|
||||
from: 'sender@example.com',
|
||||
date: 'Wed, Apr 29, 2025 at 2:29 PM',
|
||||
subject: 'Test Subject',
|
||||
to: 'recipient@example.com'
|
||||
}
|
||||
end
|
||||
|
||||
let(:base_email_content) do
|
||||
{
|
||||
'html_content' => {
|
||||
'full' => '<div>HTML content</div>'
|
||||
},
|
||||
'text_content' => {
|
||||
'full' => 'Text content'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
let(:forwarded_message) do
|
||||
create(:message, conversation: conversation, account: account,
|
||||
content_attributes: { email: base_email_content })
|
||||
end
|
||||
|
||||
let(:html_builder) { described_class.new(formatted_info, forwarded_message, base_email_content) }
|
||||
let(:empty_builder) { described_class.new({}, forwarded_message, {}) }
|
||||
|
||||
describe '#html_wrapper' do
|
||||
it 'wraps content with proper HTML structure' do
|
||||
result = html_builder.html_wrapper('<p>Test content</p>')
|
||||
|
||||
expect(result).to start_with('<div dir="ltr">')
|
||||
expect(result).to include('<p>Test content</p>')
|
||||
expect(result).to include('<br><br>')
|
||||
expect(result).to include('---------- Forwarded message ---------')
|
||||
expect(result).to end_with('</div>')
|
||||
end
|
||||
|
||||
it 'includes header and body even when content is blank' do
|
||||
result = html_builder.html_wrapper('')
|
||||
|
||||
expect(result).to start_with('<div dir="ltr">')
|
||||
expect(result).to include('---------- Forwarded message ---------')
|
||||
expect(result).to include('<br><br>')
|
||||
expect(result).to end_with('</div>')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#forwarded_header_html' do
|
||||
it 'formats header with all fields' do
|
||||
allow(Messages::ForwardedMessageFormatterService).to receive(:extract_email).and_return('sender@example.com')
|
||||
|
||||
result = html_builder.forwarded_header_html
|
||||
|
||||
expect(result).to include('gmail_quote_container')
|
||||
expect(result).to include('---------- Forwarded message ---------')
|
||||
expect(result).to include('From: <<a href="mailto:sender@example.com">sender@example.com</a>>')
|
||||
expect(result).to include('Date: Wed, Apr 29, 2025 at 2:29 PM')
|
||||
expect(result).to include('Subject: Test Subject')
|
||||
expect(result).to include('To: <<a href="mailto:recipient@example.com">recipient@example.com</a>>')
|
||||
|
||||
expect(Messages::ForwardedMessageFormatterService).to have_received(:extract_email).with('sender@example.com')
|
||||
end
|
||||
|
||||
it 'omits empty fields' do
|
||||
empty_info = { from: '', subject: 'Test Subject' }
|
||||
builder = described_class.new(empty_info, forwarded_message, base_email_content)
|
||||
|
||||
result = builder.forwarded_header_html
|
||||
|
||||
expect(result).to include('---------- Forwarded message ---------')
|
||||
expect(result).to include('Subject: Test Subject')
|
||||
expect(result).not_to include('From:')
|
||||
expect(result).not_to include('Date:')
|
||||
expect(result).not_to include('To:')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#forwarded_body_html' do
|
||||
it 'returns HTML content when available' do
|
||||
result = html_builder.forwarded_body_html
|
||||
|
||||
expect(result).to eq('<div>HTML content</div>')
|
||||
end
|
||||
|
||||
it 'converts text to HTML when HTML is not available' do
|
||||
text_only_content = { 'text_content' => { 'full' => 'Text only content' } }
|
||||
builder = described_class.new(formatted_info, forwarded_message, text_only_content)
|
||||
|
||||
allow(Messages::ForwardedMessageFormatterService).to receive(:convert_markdown_to_html).and_return('<p>Converted HTML</p>')
|
||||
|
||||
result = builder.forwarded_body_html
|
||||
|
||||
expect(Messages::ForwardedMessageFormatterService).to have_received(:convert_markdown_to_html).with('Text only content')
|
||||
expect(result).to eq('<p>Converted HTML</p>')
|
||||
end
|
||||
|
||||
it 'falls back to message content when no email data is available' do
|
||||
allow(forwarded_message).to receive(:content).and_return('Message content')
|
||||
|
||||
empty_builder.forwarded_body_html
|
||||
|
||||
expect(Messages::ForwardedMessageFormatterService).to receive(:convert_markdown_to_html).with('Message content')
|
||||
empty_builder.forwarded_body_html
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#build_email_html' do
|
||||
it 'formats email with HTML markup' do
|
||||
result = html_builder.send(:build_email_html, 'test@example.com')
|
||||
|
||||
expect(result).to eq('From: <<a href="mailto:test@example.com">test@example.com</a>><br>')
|
||||
end
|
||||
|
||||
it 'returns nil for blank email' do
|
||||
expect(html_builder.send(:build_email_html, '')).to be_nil
|
||||
expect(html_builder.send(:build_email_html, nil)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_field_html' do
|
||||
it 'formats field with label and value' do
|
||||
result = html_builder.send(:build_field_html, 'Test', 'Value')
|
||||
|
||||
expect(result).to eq('Test: Value<br>')
|
||||
end
|
||||
|
||||
it 'returns nil for blank value' do
|
||||
expect(html_builder.send(:build_field_html, 'Test', '')).to be_nil
|
||||
expect(html_builder.send(:build_field_html, 'Test', nil)).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_to_html' do
|
||||
it 'formats to field with HTML markup' do
|
||||
result = html_builder.send(:build_to_html, 'recipient@example.com')
|
||||
|
||||
expect(result).to eq('To: <<a href="mailto:recipient@example.com">recipient@example.com</a>><br>')
|
||||
end
|
||||
|
||||
it 'returns nil for blank email' do
|
||||
expect(html_builder.send(:build_to_html, '')).to be_nil
|
||||
expect(html_builder.send(:build_to_html, nil)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user