From 7f1671c083c7e21c6f439256149b86cf358ae95b Mon Sep 17 00:00:00 2001 From: Shivam Kumar Date: Mon, 29 Sep 2025 11:12:04 +0530 Subject: [PATCH 01/12] feat: Form validation message for password input (#11705) Fixes https://github.com/chatwoot/chatwoot/issues/10914 # Pull Request Template ## Description Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. Fixes # (issue) ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. ## Checklist: - [x ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../dashboard/i18n/locale/en/login.json | 2 +- .../dashboard/i18n/locale/en/signup.json | 11 +- .../auth/signup/components/Signup/Form.vue | 114 +++++++++++++++--- 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/en/login.json b/app/javascript/dashboard/i18n/locale/en/login.json index f347f2435..061284247 100644 --- a/app/javascript/dashboard/i18n/locale/en/login.json +++ b/app/javascript/dashboard/i18n/locale/en/login.json @@ -24,7 +24,7 @@ "CREATE_NEW_ACCOUNT": "Create a new account", "SUBMIT": "Login", "SAML": { - "LABEL": "Log in via SSO", + "LABEL": "Login via SSO", "TITLE": "Initiate Single Sign-on (SSO)", "SUBTITLE": "Enter your work email to access your organization", "BACK_TO_LOGIN": "Login via Password", diff --git a/app/javascript/dashboard/i18n/locale/en/signup.json b/app/javascript/dashboard/i18n/locale/en/signup.json index 501d9b87e..b0e5f5d27 100644 --- a/app/javascript/dashboard/i18n/locale/en/signup.json +++ b/app/javascript/dashboard/i18n/locale/en/signup.json @@ -27,15 +27,20 @@ "LABEL": "Password", "PLACEHOLDER": "Password", "ERROR": "Password is too short.", - "IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character." + "IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character.", + "REQUIREMENTS_LENGTH": "At least 6 characters long", + "REQUIREMENTS_UPPERCASE": "At least one uppercase letter", + "REQUIREMENTS_LOWERCASE": "At least one lowercase letter", + "REQUIREMENTS_NUMBER": "At least one number", + "REQUIREMENTS_SPECIAL": "At least one special character" }, "CONFIRM_PASSWORD": { "LABEL": "Confirm password", "PLACEHOLDER": "Confirm password", - "ERROR": "Password doesnot match." + "ERROR": "Passwords do not match." }, "API": { - "SUCCESS_MESSAGE": "Registration Successfull", + "SUCCESS_MESSAGE": "Registration Successful", "ERROR_MESSAGE": "Could not connect to Woot server. Please try again." }, "SUBMIT": "Create account", diff --git a/app/javascript/v3/views/auth/signup/components/Signup/Form.vue b/app/javascript/v3/views/auth/signup/components/Signup/Form.vue index de7b8a458..b22f621fc 100644 --- a/app/javascript/v3/views/auth/signup/components/Signup/Form.vue +++ b/app/javascript/v3/views/auth/signup/components/Signup/Form.vue @@ -1,6 +1,6 @@ + + diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 4e3124a3c..80551eaf8 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; import { useTrack } from 'dashboard/composables'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import CannedResponse from './CannedResponse.vue'; import ReplyToMessage from './ReplyToMessage.vue'; @@ -16,6 +17,7 @@ import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBotto import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue'; import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue'; import ReplyBoxBanner from './ReplyBoxBanner.vue'; +import QuotedEmailPreview from './QuotedEmailPreview.vue'; import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants'; import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue'; @@ -32,6 +34,12 @@ import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; import { trimContent, debounce, getRecipients } from '@chatwoot/utils'; import wootConstants from 'dashboard/constants/globals'; +import { + extractQuotedEmailText, + buildQuotedEmailHeader, + truncatePreviewText, + appendQuotedTextToMessage, +} from 'dashboard/helper/quotedEmailHelper'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import { @@ -65,6 +73,7 @@ export default { ContentTemplates, WhatsappTemplates, WootMessageEditor, + QuotedEmailPreview, }, mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins], props: { @@ -80,6 +89,8 @@ export default { updateUISettings, isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings, + setQuotedReplyFlagForInbox, + fetchQuotedReplyFlagFromUISettings, } = useUISettings(); const replyEditor = useTemplateRef('replyEditor'); @@ -89,6 +100,8 @@ export default { updateUISettings, isEditorHotKeyEnabled, fetchSignatureFlagFromUISettings, + setQuotedReplyFlagForInbox, + fetchQuotedReplyFlagFromUISettings, replyEditor, }; }, @@ -130,6 +143,8 @@ export default { currentUser: 'getCurrentUser', lastEmail: 'getLastEmailInSelectedChat', globalConfig: 'globalConfig/get', + accountId: 'getCurrentAccountId', + isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', }), currentContact() { return this.$store.getters['contacts/getContact']( @@ -367,6 +382,51 @@ export default { const { slug = '' } = portal; return slug; }, + isQuotedEmailReplyEnabled() { + return this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.QUOTED_EMAIL_REPLY + ); + }, + quotedReplyPreference() { + if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) { + return false; + } + + return !!this.fetchQuotedReplyFlagFromUISettings(this.channelType); + }, + lastEmailWithQuotedContent() { + if (!this.isAnEmailChannel) { + return null; + } + + const lastEmail = this.lastEmail; + if (!lastEmail || lastEmail.private) { + return null; + } + + return lastEmail; + }, + quotedEmailText() { + return extractQuotedEmailText(this.lastEmailWithQuotedContent); + }, + quotedEmailPreviewText() { + return truncatePreviewText(this.quotedEmailText, 80); + }, + shouldShowQuotedReplyToggle() { + return ( + this.isAnEmailChannel && + !this.isOnPrivateNote && + this.isQuotedEmailReplyEnabled + ); + }, + shouldShowQuotedPreview() { + return ( + this.shouldShowQuotedReplyToggle && + this.quotedReplyPreference && + !!this.quotedEmailText + ); + }, }, watch: { currentChat(conversation, oldConversation) { @@ -516,6 +576,36 @@ export default { ); } }, + toggleQuotedReply() { + if (!this.isAnEmailChannel) { + return; + } + + const nextValue = !this.quotedReplyPreference; + this.setQuotedReplyFlagForInbox(this.channelType, nextValue); + }, + shouldIncludeQuotedEmail() { + return ( + this.isQuotedEmailReplyEnabled && + this.quotedReplyPreference && + this.shouldShowQuotedReplyToggle && + !!this.quotedEmailText + ); + }, + getMessageWithQuotedEmailText(message) { + if (!this.shouldIncludeQuotedEmail()) { + return message; + } + + const quotedText = this.quotedEmailText || ''; + const header = buildQuotedEmailHeader( + this.lastEmailWithQuotedContent, + this.currentContact, + this.inbox + ); + + return appendQuotedTextToMessage(message, quotedText, header); + }, resetRecorderAndClearAttachments() { // Reset audio recorder UI state this.resetAudioRecorderInput(); @@ -965,9 +1055,11 @@ export default { return multipleMessagePayload; }, getMessagePayload(message) { + const messageWithQuote = this.getMessageWithQuotedEmailText(message); + let messagePayload = { conversationId: this.currentChat.id, - message, + message: messageWithQuote, private: this.isPrivate, sender: this.sender, }; @@ -995,7 +1087,6 @@ export default { if (this.toEmails && !this.isOnPrivateNote) { messagePayload.toEmails = this.toEmails; } - return messagePayload; }, setCcEmails(value) { @@ -1160,6 +1251,12 @@ export default { @toggle-variables-menu="toggleVariablesMenu" @clear-selection="clearEditorSelection" /> +
({ @@ -37,6 +38,7 @@ describe('useUISettings', () => { DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER, contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER, editor_message_key: 'enter', + channel_email_quoted_reply_enabled: true, }); }); @@ -51,6 +53,7 @@ describe('useUISettings', () => { DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER, contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER, editor_message_key: 'enter', + channel_email_quoted_reply_enabled: true, }, }); }); @@ -65,6 +68,7 @@ describe('useUISettings', () => { DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER, contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER, editor_message_key: 'enter', + channel_email_quoted_reply_enabled: true, }, }); }); @@ -100,6 +104,7 @@ describe('useUISettings', () => { contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER, email_signature_enabled: true, editor_message_key: 'enter', + channel_email_quoted_reply_enabled: true, }, }); }); @@ -109,6 +114,26 @@ describe('useUISettings', () => { expect(fetchSignatureFlagFromUISettings('email')).toBe(undefined); }); + it('sets quoted reply flag for inbox correctly', () => { + const { setQuotedReplyFlagForInbox } = useUISettings(); + setQuotedReplyFlagForInbox('Channel::Email', false); + expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', { + uiSettings: { + is_ct_labels_open: true, + conversation_sidebar_items_order: + DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER, + contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER, + editor_message_key: 'enter', + channel_email_quoted_reply_enabled: false, + }, + }); + }); + + it('fetches quoted reply flag from UI settings correctly', () => { + const { fetchQuotedReplyFlagFromUISettings } = useUISettings(); + expect(fetchQuotedReplyFlagFromUISettings('Channel::Email')).toBe(true); + }); + it('returns correct value for isEditorHotKeyEnabled when editor_message_key is configured', () => { getUISettingsMock.value.enter_to_send_enabled = false; const { isEditorHotKeyEnabled } = useUISettings(); diff --git a/app/javascript/dashboard/composables/useUISettings.js b/app/javascript/dashboard/composables/useUISettings.js index 964c615ad..9f1943414 100644 --- a/app/javascript/dashboard/composables/useUISettings.js +++ b/app/javascript/dashboard/composables/useUISettings.js @@ -87,6 +87,13 @@ const setSignatureFlagForInbox = (channelType, value, updateUISettings) => { updateUISettings({ [`${slugifiedChannel}_signature_enabled`]: value }); }; +const setQuotedReplyFlagForInbox = (channelType, value, updateUISettings) => { + if (!channelType) return; + + const slugifiedChannel = slugifyChannel(channelType); + updateUISettings({ [`${slugifiedChannel}_quoted_reply_enabled`]: value }); +}; + /** * Fetches the signature flag for a specific channel type from UI settings. * @param {string} channelType - The type of the channel. @@ -100,6 +107,13 @@ const fetchSignatureFlagFromUISettings = (channelType, uiSettings) => { return uiSettings.value[`${slugifiedChannel}_signature_enabled`]; }; +const fetchQuotedReplyFlagFromUISettings = (channelType, uiSettings) => { + if (!channelType) return false; + + const slugifiedChannel = slugifyChannel(channelType); + return uiSettings.value[`${slugifiedChannel}_quoted_reply_enabled`]; +}; + /** * Checks if a specific editor hotkey is enabled. * @param {string} key - The key to check. @@ -147,6 +161,10 @@ export function useUISettings() { setSignatureFlagForInbox(channelType, value, updateUISettings), fetchSignatureFlagFromUISettings: channelType => fetchSignatureFlagFromUISettings(channelType, uiSettings), + setQuotedReplyFlagForInbox: (channelType, value) => + setQuotedReplyFlagForInbox(channelType, value, updateUISettings), + fetchQuotedReplyFlagFromUISettings: channelType => + fetchQuotedReplyFlagFromUISettings(channelType, uiSettings), isEditorHotKeyEnabled: key => isEditorHotKeyEnabled(key, uiSettings), }; } diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 0766e1e24..0fb2322d2 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -40,6 +40,7 @@ export const FEATURE_FLAGS = { CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CAPTAIN_V2: 'captain_integration_v2', SAML: 'saml', + QUOTED_EMAIL_REPLY: 'quoted_email_reply', }; export const PREMIUM_FEATURES = [ diff --git a/app/javascript/dashboard/components-next/message/bubbles/Email/removeReply.js b/app/javascript/dashboard/helper/emailQuoteExtractor.js similarity index 79% rename from app/javascript/dashboard/components-next/message/bubbles/Email/removeReply.js rename to app/javascript/dashboard/helper/emailQuoteExtractor.js index 50b0c9df9..775dc4db4 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Email/removeReply.js +++ b/app/javascript/dashboard/helper/emailQuoteExtractor.js @@ -10,6 +10,8 @@ const QUOTE_INDICATORS = [ '[class*="Quote"]', ]; +const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote'; + // Regex patterns for quote identification const QUOTE_PATTERNS = [ /On .* wrote:/i, @@ -36,6 +38,8 @@ export class EmailQuoteExtractor { }); }); + this.removeTrailingBlockquote(tempDiv); + // Remove text-based quotes const textNodeQuotes = this.findTextNodeQuotes(tempDiv); textNodeQuotes.forEach(el => { @@ -62,6 +66,10 @@ export class EmailQuoteExtractor { } } + if (this.findTrailingBlockquote(tempDiv)) { + return true; + } + // Check for text-based quotes const textNodeQuotes = this.findTextNodeQuotes(tempDiv); return textNodeQuotes.length > 0; @@ -123,4 +131,26 @@ export class EmailQuoteExtractor { return null; } + + /** + * Remove fallback blockquote if it is the last top-level element. + * @param {Element} rootElement - Root element containing the HTML + */ + static removeTrailingBlockquote(rootElement) { + const trailingBlockquote = this.findTrailingBlockquote(rootElement); + trailingBlockquote?.remove(); + } + + /** + * Locate a fallback blockquote that is the last top-level element. + * @param {Element} rootElement - Root element containing the HTML + * @returns {Element|null} The trailing blockquote element if present + */ + static findTrailingBlockquote(rootElement) { + const lastElement = rootElement.lastElementChild; + if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) { + return lastElement; + } + return null; + } } diff --git a/app/javascript/dashboard/helper/quotedEmailHelper.js b/app/javascript/dashboard/helper/quotedEmailHelper.js new file mode 100644 index 000000000..b72fe8f50 --- /dev/null +++ b/app/javascript/dashboard/helper/quotedEmailHelper.js @@ -0,0 +1,332 @@ +import { format, parseISO, isValid as isValidDate } from 'date-fns'; + +/** + * Extracts plain text from HTML content + * @param {string} html - HTML content to convert + * @returns {string} Plain text content + */ +export const extractPlainTextFromHtml = html => { + if (!html) { + return ''; + } + if (typeof document === 'undefined') { + return html.replace(/<[^>]*>/g, ' '); + } + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + return tempDiv.textContent || tempDiv.innerText || ''; +}; + +/** + * Extracts sender name from email message + * @param {Object} lastEmail - Last email message object + * @param {Object} contact - Contact object + * @returns {string} Sender name + */ +export const getEmailSenderName = (lastEmail, contact) => { + const senderName = lastEmail?.sender?.name; + if (senderName && senderName.trim()) { + return senderName.trim(); + } + + const contactName = contact?.name; + return contactName && contactName.trim() ? contactName.trim() : ''; +}; + +/** + * Extracts sender email from email message + * @param {Object} lastEmail - Last email message object + * @param {Object} contact - Contact object + * @returns {string} Sender email address + */ +export const getEmailSenderEmail = (lastEmail, contact) => { + const senderEmail = lastEmail?.sender?.email; + if (senderEmail && senderEmail.trim()) { + return senderEmail.trim(); + } + + const contentAttributes = + lastEmail?.contentAttributes || lastEmail?.content_attributes || {}; + const emailMeta = contentAttributes.email || {}; + + if (Array.isArray(emailMeta.from) && emailMeta.from.length > 0) { + const fromAddress = emailMeta.from[0]; + if (fromAddress && fromAddress.trim()) { + return fromAddress.trim(); + } + } + + const contactEmail = contact?.email; + return contactEmail && contactEmail.trim() ? contactEmail.trim() : ''; +}; + +/** + * Extracts date from email message + * @param {Object} lastEmail - Last email message object + * @returns {Date|null} Email date + */ +export const getEmailDate = lastEmail => { + const contentAttributes = + lastEmail?.contentAttributes || lastEmail?.content_attributes || {}; + const emailMeta = contentAttributes.email || {}; + + if (emailMeta.date) { + const parsedDate = parseISO(emailMeta.date); + if (isValidDate(parsedDate)) { + return parsedDate; + } + } + + const createdAt = lastEmail?.created_at; + if (createdAt) { + const timestamp = Number(createdAt); + if (!Number.isNaN(timestamp)) { + const milliseconds = timestamp > 1e12 ? timestamp : timestamp * 1000; + const derivedDate = new Date(milliseconds); + if (!Number.isNaN(derivedDate.getTime())) { + return derivedDate; + } + } + } + + return null; +}; + +/** + * Formats date for quoted email header + * @param {Date} date - Date to format + * @returns {string} Formatted date string + */ +export const formatQuotedEmailDate = date => { + try { + return format(date, "EEE, MMM d, yyyy 'at' p"); + } catch (error) { + const fallbackDate = new Date(date); + if (!Number.isNaN(fallbackDate.getTime())) { + return format(fallbackDate, "EEE, MMM d, yyyy 'at' p"); + } + } + + return ''; +}; + +/** + * Extracts inbox email address from last email message + * @param {Object} lastEmail - Last email message object + * @param {Object} inbox - Inbox object + * @returns {string} Inbox email address + */ +export const getInboxEmail = (lastEmail, inbox) => { + const contentAttributes = + lastEmail?.contentAttributes || lastEmail?.content_attributes || {}; + const emailMeta = contentAttributes.email || {}; + + if (Array.isArray(emailMeta.to) && emailMeta.to.length > 0) { + const toAddress = emailMeta.to[0]; + if (toAddress && toAddress.trim()) { + return toAddress.trim(); + } + } + + const inboxEmail = inbox?.email; + return inboxEmail && inboxEmail.trim() ? inboxEmail.trim() : ''; +}; + +/** + * Builds quoted email header from contact (for incoming messages) + * @param {Object} lastEmail - Last email message object + * @param {Object} contact - Contact object + * @returns {string} Formatted header string + */ +export const buildQuotedEmailHeaderFromContact = (lastEmail, contact) => { + if (!lastEmail) { + return ''; + } + + const quotedDate = getEmailDate(lastEmail); + const senderEmail = getEmailSenderEmail(lastEmail, contact); + + if (!quotedDate || !senderEmail) { + return ''; + } + + const formattedDate = formatQuotedEmailDate(quotedDate); + if (!formattedDate) { + return ''; + } + + const senderName = getEmailSenderName(lastEmail, contact); + const hasName = !!senderName; + const contactLabel = hasName + ? `${senderName} <${senderEmail}>` + : `<${senderEmail}>`; + + return `On ${formattedDate} ${contactLabel} wrote:`; +}; + +/** + * Builds quoted email header from inbox (for outgoing messages) + * @param {Object} lastEmail - Last email message object + * @param {Object} inbox - Inbox object + * @returns {string} Formatted header string + */ +export const buildQuotedEmailHeaderFromInbox = (lastEmail, inbox) => { + if (!lastEmail) { + return ''; + } + + const quotedDate = getEmailDate(lastEmail); + const inboxEmail = getInboxEmail(lastEmail, inbox); + + if (!quotedDate || !inboxEmail) { + return ''; + } + + const formattedDate = formatQuotedEmailDate(quotedDate); + if (!formattedDate) { + return ''; + } + + const inboxName = inbox?.name; + const hasName = !!inboxName; + const inboxLabel = hasName + ? `${inboxName} <${inboxEmail}>` + : `<${inboxEmail}>`; + + return `On ${formattedDate} ${inboxLabel} wrote:`; +}; + +/** + * Builds quoted email header based on message type + * @param {Object} lastEmail - Last email message object + * @param {Object} contact - Contact object + * @param {Object} inbox - Inbox object + * @returns {string} Formatted header string + */ +export const buildQuotedEmailHeader = (lastEmail, contact, inbox) => { + if (!lastEmail) { + return ''; + } + + // MESSAGE_TYPE.OUTGOING = 1, MESSAGE_TYPE.INCOMING = 0 + const isOutgoing = lastEmail.message_type === 1; + + if (isOutgoing) { + return buildQuotedEmailHeaderFromInbox(lastEmail, inbox); + } + + return buildQuotedEmailHeaderFromContact(lastEmail, contact); +}; + +/** + * Formats text as markdown blockquote + * @param {string} text - Text to format + * @param {string} header - Optional header to prepend + * @returns {string} Formatted blockquote + */ +export const formatQuotedTextAsBlockquote = (text, header = '') => { + const normalizedLines = text + ? String(text).replace(/\r\n/g, '\n').split('\n') + : []; + + if (!header && !normalizedLines.length) { + return ''; + } + + const quotedLines = []; + + if (header) { + quotedLines.push(`> ${header}`); + quotedLines.push('>'); + } + + normalizedLines.forEach(line => { + const trimmedLine = line.trimEnd(); + quotedLines.push(trimmedLine ? `> ${trimmedLine}` : '>'); + }); + + return quotedLines.join('\n'); +}; + +/** + * Extracts quoted email text from last email message + * @param {Object} lastEmail - Last email message object + * @returns {string} Quoted email text + */ +export const extractQuotedEmailText = lastEmail => { + if (!lastEmail) { + return ''; + } + + const contentAttributes = + lastEmail.contentAttributes || lastEmail.content_attributes || {}; + const emailContent = contentAttributes.email || {}; + const textContent = emailContent.textContent || emailContent.text_content; + + if (textContent?.reply) { + return textContent.reply; + } + if (textContent?.full) { + return textContent.full; + } + + const htmlContent = emailContent.htmlContent || emailContent.html_content; + if (htmlContent?.reply) { + return extractPlainTextFromHtml(htmlContent.reply); + } + if (htmlContent?.full) { + return extractPlainTextFromHtml(htmlContent.full); + } + + const fallbackContent = + lastEmail.content || lastEmail.processed_message_content || ''; + + return fallbackContent; +}; + +/** + * Truncates text for preview display + * @param {string} text - Text to truncate + * @param {number} maxLength - Maximum length (default: 80) + * @returns {string} Truncated text + */ +export const truncatePreviewText = (text, maxLength = 80) => { + const preview = text.trim().replace(/\s+/g, ' '); + if (!preview) { + return ''; + } + + if (preview.length <= maxLength) { + return preview; + } + return `${preview.slice(0, maxLength - 3)}...`; +}; + +/** + * Appends quoted text to message + * @param {string} message - Original message + * @param {string} quotedText - Text to quote + * @param {string} header - Quote header + * @returns {string} Message with quoted text appended + */ +export const appendQuotedTextToMessage = (message, quotedText, header) => { + const baseMessage = message ? String(message) : ''; + const quotedBlock = formatQuotedTextAsBlockquote(quotedText, header); + + if (!quotedBlock) { + return baseMessage; + } + + if (!baseMessage) { + return quotedBlock; + } + + let separator = '\n\n'; + if (baseMessage.endsWith('\n\n')) { + separator = ''; + } else if (baseMessage.endsWith('\n')) { + separator = '\n'; + } + + return `${baseMessage}${separator}${quotedBlock}`; +}; diff --git a/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js b/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js new file mode 100644 index 000000000..7bd2aaa51 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/emailQuoteExtractor.spec.js @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { EmailQuoteExtractor } from '../emailQuoteExtractor.js'; + +const SAMPLE_EMAIL_HTML = ` +

method

+
+

On Mon, Sep 29, 2025 at 5:18 PM John shivam@chatwoot.com wrote:

+

Hi

+
+

On Mon, Sep 29, 2025 at 5:17 PM Shivam Mishra shivam@chatwoot.com wrote:

+

Yes, it is.

+

On Mon, Sep 29, 2025 at 5:16 PM John from Shaneforwoot < shaneforwoot@gmail.com> wrote:

+
+

Hey

+

On Mon, Sep 29, 2025 at 4:59 PM John shivam@chatwoot.com wrote:

+

This is another quoted quoted text reply

+

This is nice

+

On Mon, Sep 29, 2025 at 4:21 PM John from Shaneforwoot < > shaneforwoot@gmail.com> wrote:

+

Hey there, this is a reply from Chatwoot, notice the quoted text

+

Hey there

+

This is an email text, enjoy reading this

+

-- Shivam Mishra, Chatwoot

+
+
+
+`; + +const EMAIL_WITH_SIGNATURE = ` +

Latest reply here.

+

Thanks,

+

Jane Doe

+
+

On Mon, Sep 22, Someone wrote:

+

Previous reply content

+
+`; + +const EMAIL_WITH_FOLLOW_UP_CONTENT = ` +
+

Inline quote that should stay

+
+

Internal note follows

+

Regards,

+`; + +describe('EmailQuoteExtractor', () => { + it('removes blockquote-based quotes from the email body', () => { + const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML); + + const container = document.createElement('div'); + container.innerHTML = cleanedHtml; + + expect(container.querySelectorAll('blockquote').length).toBe(0); + expect(container.textContent?.trim()).toBe('method'); + expect(container.textContent).not.toContain( + 'On Mon, Sep 29, 2025 at 5:18 PM' + ); + }); + + it('keeps blockquote fallback when it is not the last top-level element', () => { + const cleanedHtml = EmailQuoteExtractor.extractQuotes( + EMAIL_WITH_FOLLOW_UP_CONTENT + ); + + const container = document.createElement('div'); + container.innerHTML = cleanedHtml; + + expect(container.querySelector('blockquote')).not.toBeNull(); + expect(container.lastElementChild?.tagName).toBe('P'); + }); + + it('detects quote indicators in nested blockquotes', () => { + const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML); + expect(result).toBe(true); + }); + + it('does not flag blockquotes that are followed by other elements', () => { + expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_FOLLOW_UP_CONTENT)).toBe( + false + ); + }); + + it('returns false when no quote indicators are present', () => { + const html = '

Plain content

'; + expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false); + }); + + it('removes trailing blockquotes while preserving trailing signatures', () => { + const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE); + + expect(cleanedHtml).toContain('

Thanks,

'); + expect(cleanedHtml).toContain('

Jane Doe

'); + expect(cleanedHtml).not.toContain(' { + expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true); + }); +}); diff --git a/app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js b/app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js new file mode 100644 index 000000000..2c75f9dee --- /dev/null +++ b/app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js @@ -0,0 +1,326 @@ +import { + extractPlainTextFromHtml, + getEmailSenderName, + getEmailSenderEmail, + getEmailDate, + formatQuotedEmailDate, + buildQuotedEmailHeader, + formatQuotedTextAsBlockquote, + extractQuotedEmailText, + truncatePreviewText, + appendQuotedTextToMessage, +} from '../quotedEmailHelper'; + +describe('quotedEmailHelper', () => { + describe('extractPlainTextFromHtml', () => { + it('returns empty string for null or undefined', () => { + expect(extractPlainTextFromHtml(null)).toBe(''); + expect(extractPlainTextFromHtml(undefined)).toBe(''); + }); + + it('strips HTML tags and returns plain text', () => { + const html = '

Hello world

'; + const result = extractPlainTextFromHtml(html); + expect(result).toBe('Hello world'); + }); + + it('handles complex HTML structure', () => { + const html = '

Line 1

Line 2

'; + const result = extractPlainTextFromHtml(html); + expect(result).toContain('Line 1'); + expect(result).toContain('Line 2'); + }); + }); + + describe('getEmailSenderName', () => { + it('returns sender name from lastEmail', () => { + const lastEmail = { sender: { name: 'John Doe' } }; + const result = getEmailSenderName(lastEmail, {}); + expect(result).toBe('John Doe'); + }); + + it('returns contact name if sender name not available', () => { + const lastEmail = { sender: {} }; + const contact = { name: 'Jane Smith' }; + const result = getEmailSenderName(lastEmail, contact); + expect(result).toBe('Jane Smith'); + }); + + it('returns empty string if neither available', () => { + const result = getEmailSenderName({}, {}); + expect(result).toBe(''); + }); + + it('trims whitespace from names', () => { + const lastEmail = { sender: { name: ' John Doe ' } }; + const result = getEmailSenderName(lastEmail, {}); + expect(result).toBe('John Doe'); + }); + }); + + describe('getEmailSenderEmail', () => { + it('returns sender email from lastEmail', () => { + const lastEmail = { sender: { email: 'john@example.com' } }; + const result = getEmailSenderEmail(lastEmail, {}); + expect(result).toBe('john@example.com'); + }); + + it('returns email from contentAttributes if sender email not available', () => { + const lastEmail = { + contentAttributes: { + email: { from: ['jane@example.com'] }, + }, + }; + const result = getEmailSenderEmail(lastEmail, {}); + expect(result).toBe('jane@example.com'); + }); + + it('returns contact email as fallback', () => { + const lastEmail = {}; + const contact = { email: 'contact@example.com' }; + const result = getEmailSenderEmail(lastEmail, contact); + expect(result).toBe('contact@example.com'); + }); + + it('trims whitespace from emails', () => { + const lastEmail = { sender: { email: ' john@example.com ' } }; + const result = getEmailSenderEmail(lastEmail, {}); + expect(result).toBe('john@example.com'); + }); + }); + + describe('getEmailDate', () => { + it('returns parsed date from email metadata', () => { + const lastEmail = { + contentAttributes: { + email: { date: '2024-01-15T10:30:00Z' }, + }, + }; + const result = getEmailDate(lastEmail); + expect(result).toBeInstanceOf(Date); + }); + + it('returns date from created_at timestamp', () => { + const lastEmail = { created_at: 1705318200 }; + const result = getEmailDate(lastEmail); + expect(result).toBeInstanceOf(Date); + }); + + it('handles millisecond timestamps', () => { + const lastEmail = { created_at: 1705318200000 }; + const result = getEmailDate(lastEmail); + expect(result).toBeInstanceOf(Date); + }); + + it('returns null if no valid date found', () => { + const result = getEmailDate({}); + expect(result).toBeNull(); + }); + }); + + describe('formatQuotedEmailDate', () => { + it('formats date correctly', () => { + const date = new Date('2024-01-15T10:30:00Z'); + const result = formatQuotedEmailDate(date); + expect(result).toMatch(/Mon, Jan 15, 2024 at/); + }); + + it('returns empty string for invalid date', () => { + const result = formatQuotedEmailDate('invalid'); + expect(result).toBe(''); + }); + }); + + describe('buildQuotedEmailHeader', () => { + it('builds complete header with name and email', () => { + const lastEmail = { + sender: { name: 'John Doe', email: 'john@example.com' }, + contentAttributes: { + email: { date: '2024-01-15T10:30:00Z' }, + }, + }; + const result = buildQuotedEmailHeader(lastEmail, {}); + expect(result).toContain('John Doe'); + expect(result).toContain('john@example.com'); + expect(result).toContain('wrote:'); + }); + + it('builds header without name if not available', () => { + const lastEmail = { + sender: { email: 'john@example.com' }, + contentAttributes: { + email: { date: '2024-01-15T10:30:00Z' }, + }, + }; + const result = buildQuotedEmailHeader(lastEmail, {}); + expect(result).toContain(''); + expect(result).not.toContain('undefined'); + }); + + it('returns empty string if missing required data', () => { + expect(buildQuotedEmailHeader(null, {})).toBe(''); + expect(buildQuotedEmailHeader({}, {})).toBe(''); + }); + }); + + describe('formatQuotedTextAsBlockquote', () => { + it('formats single line text', () => { + const result = formatQuotedTextAsBlockquote('Hello world'); + expect(result).toBe('> Hello world'); + }); + + it('formats multi-line text', () => { + const text = 'Line 1\nLine 2\nLine 3'; + const result = formatQuotedTextAsBlockquote(text); + expect(result).toBe('> Line 1\n> Line 2\n> Line 3'); + }); + + it('includes header if provided', () => { + const result = formatQuotedTextAsBlockquote('Hello', 'Header text'); + expect(result).toContain('> Header text'); + expect(result).toContain('>\n> Hello'); + }); + + it('handles empty lines correctly', () => { + const text = 'Line 1\n\nLine 3'; + const result = formatQuotedTextAsBlockquote(text); + expect(result).toBe('> Line 1\n>\n> Line 3'); + }); + + it('returns empty string for empty input', () => { + expect(formatQuotedTextAsBlockquote('')).toBe(''); + expect(formatQuotedTextAsBlockquote('', '')).toBe(''); + }); + + it('handles Windows line endings', () => { + const text = 'Line 1\r\nLine 2'; + const result = formatQuotedTextAsBlockquote(text); + expect(result).toBe('> Line 1\n> Line 2'); + }); + }); + + describe('extractQuotedEmailText', () => { + it('extracts text from textContent.reply', () => { + const lastEmail = { + contentAttributes: { + email: { textContent: { reply: 'Reply text' } }, + }, + }; + const result = extractQuotedEmailText(lastEmail); + expect(result).toBe('Reply text'); + }); + + it('falls back to textContent.full', () => { + const lastEmail = { + contentAttributes: { + email: { textContent: { full: 'Full text' } }, + }, + }; + const result = extractQuotedEmailText(lastEmail); + expect(result).toBe('Full text'); + }); + + it('extracts from htmlContent and converts to plain text', () => { + const lastEmail = { + contentAttributes: { + email: { htmlContent: { reply: '

HTML reply

' } }, + }, + }; + const result = extractQuotedEmailText(lastEmail); + expect(result).toBe('HTML reply'); + }); + + it('uses fallback content if structured content not available', () => { + const lastEmail = { content: 'Fallback content' }; + const result = extractQuotedEmailText(lastEmail); + expect(result).toBe('Fallback content'); + }); + + it('returns empty string for null or missing email', () => { + expect(extractQuotedEmailText(null)).toBe(''); + expect(extractQuotedEmailText({})).toBe(''); + }); + }); + + describe('truncatePreviewText', () => { + it('returns full text if under max length', () => { + const text = 'Short text'; + const result = truncatePreviewText(text, 80); + expect(result).toBe('Short text'); + }); + + it('truncates text exceeding max length', () => { + const text = 'A'.repeat(100); + const result = truncatePreviewText(text, 80); + expect(result).toHaveLength(80); + expect(result).toContain('...'); + }); + + it('collapses multiple spaces', () => { + const text = 'Text with spaces'; + const result = truncatePreviewText(text); + expect(result).toBe('Text with spaces'); + }); + + it('trims whitespace', () => { + const text = ' Text with spaces '; + const result = truncatePreviewText(text); + expect(result).toBe('Text with spaces'); + }); + + it('returns empty string for empty input', () => { + expect(truncatePreviewText('')).toBe(''); + expect(truncatePreviewText(' ')).toBe(''); + }); + + it('uses default max length of 80', () => { + const text = 'A'.repeat(100); + const result = truncatePreviewText(text); + expect(result).toHaveLength(80); + }); + }); + + describe('appendQuotedTextToMessage', () => { + it('appends quoted text to message', () => { + const message = 'My reply'; + const quotedText = 'Original message'; + const header = 'On date sender wrote:'; + const result = appendQuotedTextToMessage(message, quotedText, header); + + expect(result).toContain('My reply'); + expect(result).toContain('> On date sender wrote:'); + expect(result).toContain('> Original message'); + }); + + it('returns only quoted text if message is empty', () => { + const result = appendQuotedTextToMessage('', 'Quoted', 'Header'); + expect(result).toContain('> Header'); + expect(result).toContain('> Quoted'); + expect(result).not.toContain('\n\n\n'); + }); + + it('returns message if no quoted text', () => { + const result = appendQuotedTextToMessage('Message', '', ''); + expect(result).toBe('Message'); + }); + + it('handles proper spacing with double newline', () => { + const result = appendQuotedTextToMessage('Message', 'Quoted', 'Header'); + expect(result).toContain('Message\n\n>'); + }); + + it('does not add extra newlines if message already ends with newlines', () => { + const result = appendQuotedTextToMessage( + 'Message\n\n', + 'Quoted', + 'Header' + ); + expect(result).not.toContain('\n\n\n'); + }); + + it('adds single newline if message ends with one newline', () => { + const result = appendQuotedTextToMessage('Message\n', 'Quoted', 'Header'); + expect(result).toContain('Message\n\n>'); + }); + }); +}); diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 9fd39b70f..79d5ebc66 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -227,6 +227,13 @@ "YES": "Send", "CANCEL": "Cancel" } + }, + "QUOTED_REPLY": { + "ENABLE_TOOLTIP": "Include quoted email thread", + "DISABLE_TOOLTIP": "Don't include quoted email thread", + "REMOVE_PREVIEW": "Remove quoted email thread", + "COLLAPSE": "Collapse preview", + "EXPAND": "Expand preview" } }, "VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team", diff --git a/config/features.yml b/config/features.yml index 3d896b483..e1f3b20d9 100644 --- a/config/features.yml +++ b/config/features.yml @@ -220,3 +220,6 @@ display_name: Reply Mailer Migration enabled: false chatwoot_internal: true +- name: quoted_email_reply + display_name: Quoted Email Reply + enabled: false From ecff66146aefe9d71f52ef4101fa7756a346bbb7 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 1 Oct 2025 13:43:05 +0530 Subject: [PATCH 06/12] fix: Rendering on email without html content (#12561) image --- app/builders/messages/message_builder.rb | 5 +- .../message/bubbles/Email/Index.vue | 70 ++++++---- .../conversation/QuotedEmailPreview.vue | 2 +- .../helper/specs/quotedEmailHelper.spec.js | 125 +++++++++++++++++- 4 files changed, 172 insertions(+), 30 deletions(-) diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 2f95615e9..86bcee54e 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -7,6 +7,7 @@ class Messages::MessageBuilder @private = params[:private] || false @conversation = conversation @user = user + @account = conversation.account @message_type = params[:message_type] || 'outgoing' @attachments = params[:attachments] @automation_rule = content_attributes&.dig(:automation_rule_id) @@ -20,7 +21,9 @@ class Messages::MessageBuilder @message = @conversation.messages.build(message_params) process_attachments process_emails - process_email_content + # When the message has no quoted content, it will just be rendered as a regular message + # The frontend is equipped to handle this case + process_email_content if @account.feature_enabled?(:quoted_email_reply) @message.save! @message end diff --git a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue index 1eb3cda10..7a4164ded 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Email/Index.vue @@ -6,6 +6,7 @@ import { allowedCssProperties } from 'lettersanitizer'; import Icon from 'next/icon/Icon.vue'; import { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.js'; +import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue'; import BaseBubble from 'next/message/bubbles/Base.vue'; import AttachmentChips from 'next/message/chips/AttachmentChips.vue'; import EmailMeta from './EmailMeta.vue'; @@ -46,6 +47,22 @@ const originalEmailHtml = computed( originalEmailText.value ); +const hasEmailContent = computed(() => { + return ( + contentAttributes?.value?.email?.textContent?.full || + contentAttributes?.value?.email?.htmlContent?.full + ); +}); + +const messageContent = computed(() => { + // If translations exist and we're showing translations (not original) + if (hasTranslations.value && !renderOriginal.value) { + return translationContent.value; + } + // Otherwise show original content + return content.value; +}); + const textToShow = computed(() => { // If translations exist and we're showing translations (not original) if (hasTranslations.value && !renderOriginal.value) { @@ -126,30 +143,37 @@ const handleSeeOriginal = () => { {{ $t('EMAIL_HEADER.EXPAND') }}
- - +