method
+++`; + +const EMAIL_WITH_SIGNATURE = ` +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
+
Latest reply here.
+Thanks,
+Jane Doe
+++`; + +const EMAIL_WITH_FOLLOW_UP_CONTENT = ` +On Mon, Sep 22, Someone wrote:
+Previous reply 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 = ''; + 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('Line 1
Line 2
'); + 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