feat: allow quoted email thread in reply (#12545)
This PR adds the ability to include the thread history as a quoted text ## Preview https://github.com/user-attachments/assets/c96a85e5-8ac8-4021-86ca-57509b4eea9f
This commit is contained in:
156
app/javascript/dashboard/helper/emailQuoteExtractor.js
Normal file
156
app/javascript/dashboard/helper/emailQuoteExtractor.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Quote detection strategies
|
||||
const QUOTE_INDICATORS = [
|
||||
'.gmail_quote_container',
|
||||
'.gmail_quote',
|
||||
'.OutlookQuote',
|
||||
'.email-quote',
|
||||
'.quoted-text',
|
||||
'.quote',
|
||||
'[class*="quote"]',
|
||||
'[class*="Quote"]',
|
||||
];
|
||||
|
||||
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
|
||||
|
||||
// Regex patterns for quote identification
|
||||
const QUOTE_PATTERNS = [
|
||||
/On .* wrote:/i,
|
||||
/-----Original Message-----/i,
|
||||
/Sent: /i,
|
||||
/From: /i,
|
||||
];
|
||||
|
||||
export class EmailQuoteExtractor {
|
||||
/**
|
||||
* Remove quotes from email HTML and return cleaned HTML
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {string} HTML content with quotes removed
|
||||
*/
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
tempDiv.querySelectorAll(selector).forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
|
||||
this.removeTrailingBlockquote(tempDiv);
|
||||
|
||||
// Remove text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
textNodeQuotes.forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
return tempDiv.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if HTML content contains any quotes
|
||||
* @param {string} htmlContent - Full HTML content of the email
|
||||
* @returns {boolean} True if quotes are detected, false otherwise
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const selector of QUOTE_INDICATORS) {
|
||||
if (tempDiv.querySelector(selector)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.findTrailingBlockquote(tempDiv)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for text-based quotes
|
||||
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
|
||||
return textNodeQuotes.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find text nodes that match quote patterns
|
||||
* @param {Element} rootElement - Root element to search
|
||||
* @returns {Element[]} Array of parent block elements containing quote-like text
|
||||
*/
|
||||
static findTextNodeQuotes(rootElement) {
|
||||
const quoteBlocks = [];
|
||||
const treeWalker = document.createTreeWalker(
|
||||
rootElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
for (
|
||||
let currentNode = treeWalker.nextNode();
|
||||
currentNode !== null;
|
||||
currentNode = treeWalker.nextNode()
|
||||
) {
|
||||
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
|
||||
pattern.test(currentNode.textContent)
|
||||
);
|
||||
|
||||
if (isQuoteLike) {
|
||||
const parentBlock = this.findParentBlock(currentNode);
|
||||
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
|
||||
quoteBlocks.push(parentBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return quoteBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the closest block-level parent element by recursively traversing up the DOM tree.
|
||||
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION
|
||||
* that contain the text node. It's used to identify and remove entire block-level elements
|
||||
* that contain quote-like text, rather than just removing the text node itself. This ensures
|
||||
* proper structural removal of quoted content while maintaining HTML integrity.
|
||||
* @param {Node} node - Starting node to find parent
|
||||
* @returns {Element|null} Block-level parent element
|
||||
*/
|
||||
static findParentBlock(node) {
|
||||
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
|
||||
let current = node.parentElement;
|
||||
|
||||
while (current) {
|
||||
if (blockElements.includes(current.tagName)) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
332
app/javascript/dashboard/helper/quotedEmailHelper.js
Normal file
332
app/javascript/dashboard/helper/quotedEmailHelper.js
Normal file
@@ -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}`;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
|
||||
|
||||
const SAMPLE_EMAIL_HTML = `
|
||||
<p>method</p>
|
||||
<blockquote>
|
||||
<p>On Mon, Sep 29, 2025 at 5:18 PM John <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
|
||||
<p>Hi</p>
|
||||
<blockquote>
|
||||
<p>On Mon, Sep 29, 2025 at 5:17 PM Shivam Mishra <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
|
||||
<p>Yes, it is.</p>
|
||||
<p>On Mon, Sep 29, 2025 at 5:16 PM John from Shaneforwoot < shaneforwoot@gmail.com> wrote:</p>
|
||||
<blockquote>
|
||||
<p>Hey</p>
|
||||
<p>On Mon, Sep 29, 2025 at 4:59 PM John shivam@chatwoot.com wrote:</p>
|
||||
<p>This is another quoted quoted text reply</p>
|
||||
<p>This is nice</p>
|
||||
<p>On Mon, Sep 29, 2025 at 4:21 PM John from Shaneforwoot < > shaneforwoot@gmail.com> wrote:</p>
|
||||
<p>Hey there, this is a reply from Chatwoot, notice the quoted text</p>
|
||||
<p>Hey there</p>
|
||||
<p>This is an email text, enjoy reading this</p>
|
||||
<p>-- Shivam Mishra, Chatwoot</p>
|
||||
</blockquote>
|
||||
</blockquote>
|
||||
</blockquote>
|
||||
`;
|
||||
|
||||
const EMAIL_WITH_SIGNATURE = `
|
||||
<p>Latest reply here.</p>
|
||||
<p>Thanks,</p>
|
||||
<p>Jane Doe</p>
|
||||
<blockquote>
|
||||
<p>On Mon, Sep 22, Someone wrote:</p>
|
||||
<p>Previous reply content</p>
|
||||
</blockquote>
|
||||
`;
|
||||
|
||||
const EMAIL_WITH_FOLLOW_UP_CONTENT = `
|
||||
<blockquote>
|
||||
<p>Inline quote that should stay</p>
|
||||
</blockquote>
|
||||
<p>Internal note follows</p>
|
||||
<p>Regards,</p>
|
||||
`;
|
||||
|
||||
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 = '<p>Plain content</p>';
|
||||
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
|
||||
});
|
||||
|
||||
it('removes trailing blockquotes while preserving trailing signatures', () => {
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
|
||||
|
||||
expect(cleanedHtml).toContain('<p>Thanks,</p>');
|
||||
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
|
||||
expect(cleanedHtml).not.toContain('<blockquote');
|
||||
});
|
||||
|
||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
|
||||
});
|
||||
});
|
||||
326
app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js
Normal file
326
app/javascript/dashboard/helper/specs/quotedEmailHelper.spec.js
Normal file
@@ -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 = '<p>Hello <strong>world</strong></p>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('handles complex HTML structure', () => {
|
||||
const html = '<div><p>Line 1</p><p>Line 2</p></div>';
|
||||
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('<john@example.com>');
|
||||
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: '<p>HTML reply</p>' } },
|
||||
},
|
||||
};
|
||||
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>');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user