diff --git a/src/js/addressUtils.js b/src/js/addressUtils.js new file mode 100644 index 00000000..b0686640 --- /dev/null +++ b/src/js/addressUtils.js @@ -0,0 +1,111 @@ +/** + * Address helpers for RFC 5322-like mailbox headers plus Exchange internal addresses. + */ + +export function isSmtpAddress(address) { + return /^[^\s@<>]+@[^\s@<>]+$/.test((address || '').trim()); +} + +export function isExchangeLegacyDn(address) { + const normalized = (address || '').trim(); + return normalized.startsWith('/') && /\/(?:O|OU|CN)=/i.test(normalized); +} + +function stripDisplayQuotes(value) { + const trimmed = (value || '').trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replace(/\\"/g, '"').trim(); + } + return trimmed; +} + +function normalizeParsedAddress(displayName, rawAddress, decodeMimeWord) { + const decodedName = stripDisplayQuotes(decodeMimeWord(displayName || '')); + const address = (rawAddress || '').trim(); + const hasSmtpAddress = isSmtpAddress(address); + const hasExchangeLegacyDn = isExchangeLegacyDn(address); + const name = decodedName || (hasSmtpAddress ? address : ''); + + const result = { + name, + address: hasSmtpAddress ? address : '', + email: hasSmtpAddress ? address : '' + }; + + if (hasExchangeLegacyDn) { + result.exchangeLegacyDn = address; + } else if (address && !hasSmtpAddress) { + result.originalAddress = address; + } + + return result; +} + +export function parseAddressHeader(headerValue, decodeMimeWord = (value) => value) { + if (!headerValue) return []; + + const value = String(headerValue); + const addresses = []; + const angleAddressPattern = /<([^>]*)>/g; + let match; + let previousEnd = 0; + + while ((match = angleAddressPattern.exec(value)) !== null) { + const displayName = value + .slice(previousEnd, match.index) + .replace(/^\s*,\s*/, '') + .trim(); + addresses.push(normalizeParsedAddress(displayName, match[1], decodeMimeWord)); + previousEnd = match.index + match[0].length; + } + + if (addresses.length > 0) { + return addresses.filter( + (address) => address.name || address.address || address.exchangeLegacyDn + ); + } + + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => normalizeParsedAddress('', part, decodeMimeWord)) + .filter((address) => address.name || address.address || address.exchangeLegacyDn); +} + +export function getContactEmail(contact) { + const email = contact?.smtpAddress || contact?.email || contact?.address || ''; + return isSmtpAddress(email) ? email : ''; +} + +export function formatContact(name, email) { + const displayName = (name || '').trim(); + const smtpAddress = isSmtpAddress(email) ? email.trim() : ''; + + if (!smtpAddress) { + return displayName; + } + + return displayName && displayName !== smtpAddress + ? `${displayName} <${smtpAddress}>` + : smtpAddress; +} + +export function formatAddressHeader(name, email) { + const displayName = (name || '').trim(); + const smtpAddress = isSmtpAddress(email) ? email.trim() : ''; + + if (!smtpAddress) { + return displayName; + } + + return displayName && displayName !== smtpAddress + ? `"${displayName.replace(/"/g, '\\"')}" <${smtpAddress}>` + : `<${smtpAddress}>`; +} + +export function isUnsafeRawAddressHeader(headerValue) { + return parseAddressHeader(headerValue).some( + (address) => address.exchangeLegacyDn || address.originalAddress + ); +} diff --git a/src/js/messageExport.js b/src/js/messageExport.js index 0117db10..df1d57bd 100644 --- a/src/js/messageExport.js +++ b/src/js/messageExport.js @@ -1,5 +1,11 @@ import { escapeHTML } from './sanitizer.js'; import { textToBase64 } from './encoding.js'; +import { + formatAddressHeader, + formatContact, + getContactEmail, + isUnsafeRawAddressHeader +} from './addressUtils.js'; function stripExtension(fileName) { if (!fileName) return ''; @@ -37,14 +43,6 @@ function formatTimestamp(value) { return parsed.toUTCString(); } -function formatAddress(name, email) { - if (!email) return name || ''; - if (name && name !== email) { - return `"${name.replace(/"/g, '\\"')}" <${email}>`; - } - return `<${email}>`; -} - function wrapBase64(base64) { return (base64 || '') .replace(/\s+/g, '') @@ -140,12 +138,17 @@ function getRawHeader(message, name) { return headerMap[name.toLowerCase()] || ''; } +function getSafeRawAddressHeader(message, name) { + const rawHeader = getRawHeader(message, name); + return rawHeader && !isUnsafeRawAddressHeader(rawHeader) ? rawHeader : ''; +} + function formatAddressList(recipients = []) { return recipients .map((recipient) => - formatAddress( + formatAddressHeader( recipient.name || recipient.smtpAddress || recipient.email || '', - recipient.smtpAddress || recipient.email || '' + getContactEmail(recipient) ) ) .filter(Boolean) @@ -178,12 +181,12 @@ function buildTopLevelHeaders(message) { return [ [ 'From', - getRawHeader(message, 'from') || - formatAddress(message?.senderName || '', message?.senderEmail || '') + getSafeRawAddressHeader(message, 'from') || + formatAddressHeader(message?.senderName || '', message?.senderEmail || '') ], - ['To', getRawHeader(message, 'to') || formatAddressList(toRecipients)], - ['Cc', getRawHeader(message, 'cc') || formatAddressList(ccRecipients)], - ['Bcc', getRawHeader(message, 'bcc') || formatAddressList(bccRecipients)], + ['To', getSafeRawAddressHeader(message, 'to') || formatAddressList(toRecipients)], + ['Cc', getSafeRawAddressHeader(message, 'cc') || formatAddressList(ccRecipients)], + ['Bcc', getSafeRawAddressHeader(message, 'bcc') || formatAddressList(bccRecipients)], ['Subject', getRawHeader(message, 'subject') || message?.subject || ''], ['Date', getRawHeader(message, 'date') || formatTimestamp(message?.messageDeliveryTime)], ...optionalHeaders @@ -311,16 +314,14 @@ export function messageToEml(message) { export function messageToHtmlDocument(message) { const recipientsTo = (message?.recipients || []) .filter((recipient) => recipient.recipType === 'to') - .map( - (recipient) => - `${recipient.name || recipient.smtpAddress || recipient.email || ''} <${recipient.smtpAddress || recipient.email || ''}>` + .map((recipient) => + escapeHTML(formatContact(recipient.name || '', getContactEmail(recipient))) ) .join(', '); const recipientsCc = (message?.recipients || []) .filter((recipient) => recipient.recipType === 'cc') - .map( - (recipient) => - `${recipient.name || recipient.smtpAddress || recipient.email || ''} <${recipient.smtpAddress || recipient.email || ''}>` + .map((recipient) => + escapeHTML(formatContact(recipient.name || '', getContactEmail(recipient))) ) .join(', '); @@ -373,7 +374,7 @@ export function messageToHtmlDocument(message) {

${escapeHTML(message?.subject || 'Message')}

-
From: ${escapeHTML(message?.senderName || '')} <${escapeHTML(message?.senderEmail || '')}>
+
From: ${escapeHTML(formatContact(message?.senderName || '', message?.senderEmail || ''))}
${recipientsTo ? `
To: ${recipientsTo}
` : ''} ${recipientsCc ? `
CC: ${recipientsCc}
` : ''}
Date: ${escapeHTML(new Date(message?.messageDeliveryTime || Date.now()).toLocaleString())}
diff --git a/src/js/ui/AttachmentModalManager.js b/src/js/ui/AttachmentModalManager.js index d48ed1f6..4b0a42d2 100644 --- a/src/js/ui/AttachmentModalManager.js +++ b/src/js/ui/AttachmentModalManager.js @@ -1,6 +1,7 @@ import { isTauri, openWithSystemViewer, saveFileWithDialog } from '../tauri-bridge.js'; import { extractEml } from '../utils.js'; import { dataUrlToArrayBuffer, decodeDataUrlText, getDataUrlBase64 } from '../encoding.js'; +import { formatContact, getContactEmail } from '../addressUtils.js'; /** * Manages the attachment preview modal @@ -836,9 +837,7 @@ export class AttachmentModalManager { const headerFields = [ { label: 'From', - value: emailData.senderName - ? `${emailData.senderName} <${emailData.senderEmail}>` - : emailData.senderEmail + value: formatContact(emailData.senderName || '', emailData.senderEmail || '') }, { label: 'To', value: this.formatRecipients(emailData.recipients, 'to') }, { label: 'CC', value: this.formatRecipients(emailData.recipients, 'cc') }, @@ -1026,9 +1025,7 @@ export class AttachmentModalManager { .filter((recipient) => recipient.recipType === type) .map((recipient) => { const name = recipient.name || ''; - // Prefer smtpAddress over email (email may contain Exchange X.500 DN) - const email = recipient.smtpAddress || recipient.email || recipient.address || ''; - return name && name !== email ? `${name} <${email}>` : email; + return formatContact(name, getContactEmail(recipient)); }) .join(', '); } diff --git a/src/js/ui/MessageContentRenderer.js b/src/js/ui/MessageContentRenderer.js index c4b5d9b1..04276ac8 100644 --- a/src/js/ui/MessageContentRenderer.js +++ b/src/js/ui/MessageContentRenderer.js @@ -1,4 +1,5 @@ -import { sanitizeHTML } from '../sanitizer.js'; +import { escapeHTML, sanitizeHTML } from '../sanitizer.js'; +import { formatContact, getContactEmail } from '../addressUtils.js'; import { parseColor, getContrastRatio, adjustColorForContrast } from '../colorUtils.js'; import { isInlineImageAttachment } from '../helpers.js'; import { @@ -40,6 +41,7 @@ export class MessageContentRenderer { const toRecipients = this.formatRecipients(msgInfo.recipients, 'to'); const ccRecipients = this.formatRecipients(msgInfo.recipients, 'cc'); + const from = escapeHTML(formatContact(msgInfo.senderName || '', msgInfo.senderEmail || '')); const emailContent = this.processEmailContent(msgInfo); const messageIndex = this.messageHandler.getMessages().indexOf(msgInfo); const isPinned = this.messageHandler.isPinned(msgInfo); @@ -75,7 +77,7 @@ export class MessageContentRenderer {
-
From: ${msgInfo.senderName} <${msgInfo.senderEmail}>
+
From: ${from}
${toRecipients ? `
To: ${toRecipients}
` : ''} ${ccRecipients ? `
CC: ${ccRecipients}
` : ''}
${msgInfo.timestamp.toLocaleString()}
@@ -160,13 +162,18 @@ export class MessageContentRenderer { const source = image.getAttribute('src'); if (!source) return; - const matchingAttachment = attachments.find(attachment => - attachment.attachMimeTag?.toLowerCase().startsWith('image/') && - attachment.contentBase64 === source + const matchingAttachment = attachments.find( + (attachment) => + attachment.attachMimeTag?.toLowerCase().startsWith('image/') && + attachment.contentBase64 === source ); const linkHref = image.closest('a[href]')?.getAttribute('href') || ''; - const fileName = matchingAttachment?.fileName || image.getAttribute('alt') || `inline-image-${index + 1}`; - const contentId = matchingAttachment?.pidContentId || matchingAttachment?.contentId || ''; + const fileName = + matchingAttachment?.fileName || + image.getAttribute('alt') || + `inline-image-${index + 1}`; + const contentId = + matchingAttachment?.pidContentId || matchingAttachment?.contentId || ''; if (linkHref && this.attachmentModal?.setInlineImageMetadata) { this.attachmentModal.setInlineImageMetadata(source, { linkHref }); @@ -196,10 +203,12 @@ export class MessageContentRenderer { * @param {HTMLImageElement} image - Image element to preview */ openInlineImage(image) { - const source = image.dataset.inlineImageSource || image.currentSrc || image.getAttribute('src'); + const source = + image.dataset.inlineImageSource || image.currentSrc || image.getAttribute('src'); if (!source || !this.attachmentModal?.openInlineImage) return; - const fileName = image.dataset.inlineImageFilename || image.getAttribute('alt') || 'inline-image'; + const fileName = + image.dataset.inlineImageFilename || image.getAttribute('alt') || 'inline-image'; const linkHref = image.dataset.inlineImageLinkHref || ''; this.attachmentModal.openInlineImage({ source, fileName, linkHref }); } @@ -219,13 +228,15 @@ export class MessageContentRenderer { ); this.applyInlineImageSectionState(nextExpanded); - document.dispatchEvent(new CustomEvent('inline-image-attachment-visibility-change', { - detail: { - visibility: nextExpanded - ? INLINE_IMAGE_ATTACHMENT_VISIBILITY.EXPANDED - : INLINE_IMAGE_ATTACHMENT_VISIBILITY.COLLAPSED - } - })); + document.dispatchEvent( + new CustomEvent('inline-image-attachment-visibility-change', { + detail: { + visibility: nextExpanded + ? INLINE_IMAGE_ATTACHMENT_VISIBILITY.EXPANDED + : INLINE_IMAGE_ATTACHMENT_VISIBILITY.COLLAPSED + } + }) + ); } /** @@ -263,13 +274,16 @@ export class MessageContentRenderer { if (!isEmailDark) return; // Only fix in dark mode // Get background color for contrast calculation - const bgColor = parseColor(getComputedStyle(emailContent).backgroundColor) || - { r: 30, g: 41, b: 59 }; // fallback to slate-800 + const bgColor = parseColor(getComputedStyle(emailContent).backgroundColor) || { + r: 30, + g: 41, + b: 59 + }; // fallback to slate-800 // Find all elements with inline color styles const elements = emailContent.querySelectorAll('[style*="color"]'); - elements.forEach(el => { + elements.forEach((el) => { const style = el.getAttribute('style'); if (!style) return; @@ -305,11 +319,9 @@ export class MessageContentRenderer { */ formatRecipients(recipients, type) { return recipients - .filter(recipient => recipient.recipType === type) - .map(recipient => { - // Prefer smtpAddress over email (email may contain Exchange X.500 DN) - const email = recipient.smtpAddress || recipient.email || ''; - return `${recipient.name} <${email}>`; + .filter((recipient) => recipient.recipType === type) + .map((recipient) => { + return escapeHTML(formatContact(recipient.name || '', getContactEmail(recipient))); }) .join(', '); } @@ -327,7 +339,7 @@ export class MessageContentRenderer { // Find all style tags and scope them to .email-content const styleTags = tempDiv.getElementsByTagName('style'); - Array.from(styleTags).forEach(styleTag => { + Array.from(styleTags).forEach((styleTag) => { const cssText = styleTag.textContent; // Scope all CSS rules to .email-content const scopedCss = cssText.replace(/([^{}]+){/g, '.email-content $1{'); @@ -350,7 +362,7 @@ export class MessageContentRenderer { // Normalize line endings const text = emailContent.replace(/\r\n/g, '\n'); // Split into paragraphs by double line breaks - const paragraphs = text.split(/\n{2,}/).map(p => { + const paragraphs = text.split(/\n{2,}/).map((p) => { // Replace single line breaks in paragraph with
return p.replace(/\n/g, '
'); }); @@ -370,42 +382,54 @@ export class MessageContentRenderer { renderAttachments(msgInfo) { if (!msgInfo.attachments?.length) return ''; - this.realAttachments = msgInfo.attachments.filter(attachment => !isInlineImageAttachment(attachment)); - this.inlineImageAttachments = msgInfo.attachments.filter(attachment => isInlineImageAttachment(attachment)); + this.realAttachments = msgInfo.attachments.filter( + (attachment) => !isInlineImageAttachment(attachment) + ); + this.inlineImageAttachments = msgInfo.attachments.filter((attachment) => + isInlineImageAttachment(attachment) + ); - if (this.realAttachments.length === 0 && this.inlineImageAttachments.length === 0) return ''; + if (this.realAttachments.length === 0 && this.inlineImageAttachments.length === 0) + return ''; const inlineExpandedByDefault = inlineImageAttachmentsExpandedByDefault(); this.updateAttachmentModalAttachments(inlineExpandedByDefault); - const visibleAttachments = this.realAttachments.map((attachment, index) => ({ attachment, index })); + const visibleAttachments = this.realAttachments.map((attachment, index) => ({ + attachment, + index + })); const inlineImageAttachments = this.inlineImageAttachments.map((attachment, index) => ({ attachment, index: this.realAttachments.length + index })); + const visibleAttachmentsHtml = + visibleAttachments.length > 0 + ? this.renderAttachmentSection({ + items: visibleAttachments, + label: `${visibleAttachments.length} ${visibleAttachments.length === 1 ? 'Attachment' : 'Attachments'}`, + icon: this.getAttachmentSectionIcon(), + sectionClassName: 'attachment-section' + }) + : ''; + const inlineImageAttachmentsHtml = + inlineImageAttachments.length > 0 + ? this.renderAttachmentSection({ + items: inlineImageAttachments, + label: `${inlineImageAttachments.length} ${inlineImageAttachments.length === 1 ? 'Inline image' : 'Inline images'}`, + icon: this.getInlineImageSectionIcon(), + sectionClassName: 'attachment-section attachment-section-inline', + collapsible: true, + collapsed: !inlineImageAttachmentsExpandedByDefault() + }) + : ''; return `

- ${visibleAttachments.length > 0 - ? this.renderAttachmentSection({ - items: visibleAttachments, - label: `${visibleAttachments.length} ${visibleAttachments.length === 1 ? 'Attachment' : 'Attachments'}`, - icon: this.getAttachmentSectionIcon(), - sectionClassName: 'attachment-section' - }) - : ''} - ${inlineImageAttachments.length > 0 - ? this.renderAttachmentSection({ - items: inlineImageAttachments, - label: `${inlineImageAttachments.length} ${inlineImageAttachments.length === 1 ? 'Inline image' : 'Inline images'}`, - icon: this.getInlineImageSectionIcon(), - sectionClassName: 'attachment-section attachment-section-inline', - collapsible: true, - collapsed: !inlineImageAttachmentsExpandedByDefault() - }) - : ''} + ${visibleAttachmentsHtml} + ${inlineImageAttachmentsHtml}
`; @@ -436,8 +460,23 @@ export class MessageContentRenderer { * @param {boolean} [options.collapsed=false] - Whether the section starts collapsed * @returns {string} Rendered HTML */ - renderAttachmentSection({ items, label, icon, sectionClassName, collapsible = false, collapsed = false }) { + renderAttachmentSection({ + items, + label, + icon, + sectionClassName, + collapsible = false, + collapsed = false + }) { const expanded = !collapsed; + const toggleButton = collapsible + ? `` + : ''; return `
@@ -446,14 +485,7 @@ export class MessageContentRenderer { ${icon} ${label}
- ${collapsible - ? `` - : ''} + ${toggleButton}
${this.renderAttachmentItems(items)} @@ -468,11 +500,12 @@ export class MessageContentRenderer { * @returns {string} Card markup */ renderAttachmentItems(items) { - return items.map(({ attachment, index }) => { - const isPreviewable = this.attachmentModal?.isPreviewable(attachment.attachMimeTag); + return items + .map(({ attachment, index }) => { + const isPreviewable = this.attachmentModal?.isPreviewable(attachment.attachMimeTag); - if (isPreviewable) { - return ` + if (isPreviewable) { + return `
`; - } + } - return ` + return `
`; - }).join(''); + }) + .join(''); } /** diff --git a/src/js/utils.js b/src/js/utils.js index bc017630..37da931a 100644 --- a/src/js/utils.js +++ b/src/js/utils.js @@ -12,6 +12,7 @@ import { } from './helpers.js'; import { BASE64_SIZE_FACTOR, DEFAULT_CHARSET } from './constants.js'; import { replaceCidReferences } from './cidReplacer.js'; +import { parseAddressHeader } from './addressUtils.js'; import { binaryStringToBase64, decodeBase64Text, @@ -330,15 +331,7 @@ function decodeTransferEncoding(content, encoding, charset, isText = true) { * @returns {Array<{name: string, address: string}>} Array of email objects */ function extractEmailAddresses(str) { - if (!str) return []; - - const matches = str.match(/(?:"([^"]*)")?\s*(?:<([^>]+)>|([^\s,]+@[^\s,]+))/g) || []; - return matches.map((match) => { - const parts = match.match(/(?:"([^"]*)")?\s*(?:<([^>]+)>|([^\s,]+@[^\s,]+))/); - const email = parts[2] || parts[3]; - const name = parts[1] || email; - return { name: decodeMIMEWord(name), address: email }; - }); + return parseAddressHeader(str, decodeMIMEWord); } /** @@ -954,6 +947,7 @@ export function extractEml(fileBuffer, options = {}) { subject: decodeMIMEWord(headers.subject) || '', senderName: from.name || from.address, senderEmail: from.address, + senderExchangeLegacyDn: from.exchangeLegacyDn || '', recipients: [ ...to.map((r) => ({ ...r, recipType: 'to' })), ...cc.map((r) => ({ ...r, recipType: 'cc' })) diff --git a/tests/UIManager.test.js b/tests/UIManager.test.js index c93ea44b..30052623 100644 --- a/tests/UIManager.test.js +++ b/tests/UIManager.test.js @@ -1058,6 +1058,20 @@ describe('MessageContentRenderer', () => { expect(container.innerHTML).toContain('john@test.com'); }); + test('renders Exchange legacy DN sender without empty address brackets', () => { + renderer.render( + createMockMessage({ + senderName: 'Kliucininkaite, Lina (ENBW AG)', + senderEmail: '', + senderExchangeLegacyDn: '/O=ENBW-KK/CN=RECIPIENTS/CN=KLIUCININKAITE' + }) + ); + + expect(container.textContent).toContain('Kliucininkaite, Lina (ENBW AG)'); + expect(container.innerHTML).not.toContain('<>'); + expect(container.innerHTML).not.toContain('/O=ENBW-KK'); + }); + test('renders To recipients', () => { renderer.render(createMockMessage()); expect(container.innerHTML).toContain('Jane Doe'); @@ -1241,6 +1255,18 @@ describe('MessageContentRenderer', () => { ]; expect(renderer.formatRecipients(recipients, 'to')).toContain(', '); }); + + test('formats Exchange legacy DN recipients without address brackets', () => { + const recipients = [ + { + name: 'Internal User', + email: '', + exchangeLegacyDn: '/O=ORG/CN=RECIPIENTS/CN=USER', + recipType: 'to' + } + ]; + expect(renderer.formatRecipients(recipients, 'to')).toBe('Internal User'); + }); }); describe('processEmailContent', () => { diff --git a/tests/addressUtils.test.js b/tests/addressUtils.test.js new file mode 100644 index 00000000..24dec6d8 --- /dev/null +++ b/tests/addressUtils.test.js @@ -0,0 +1,55 @@ +import { + formatAddressHeader, + formatContact, + isExchangeLegacyDn, + isSmtpAddress, + isUnsafeRawAddressHeader, + parseAddressHeader +} from '../src/js/addressUtils.js'; + +describe('addressUtils', () => { + const legacyDn = + '/O=ENBW-KK/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=KLIUCININKAITE, LINA7E2'; + + test('detects SMTP addresses', () => { + expect(isSmtpAddress('lina@example.com')).toBe(true); + expect(isSmtpAddress(legacyDn)).toBe(false); + }); + + test('detects Exchange legacy distinguished names', () => { + expect(isExchangeLegacyDn(legacyDn)).toBe(true); + expect(isExchangeLegacyDn('lina@example.com')).toBe(false); + }); + + test('parses unquoted display names with commas before Exchange legacy DNs', () => { + const [address] = parseAddressHeader(`Kliucininkaite, Lina (ENBW AG) <${legacyDn}>`); + + expect(address.name).toBe('Kliucininkaite, Lina (ENBW AG)'); + expect(address.address).toBe(''); + expect(address.email).toBe(''); + expect(address.exchangeLegacyDn).toBe(legacyDn); + }); + + test('parses normal SMTP mailbox headers', () => { + const [address] = parseAddressHeader('"Kliucininkaite, Lina" '); + + expect(address.name).toBe('Kliucininkaite, Lina'); + expect(address.address).toBe('lina@example.com'); + expect(address.email).toBe('lina@example.com'); + expect(address.exchangeLegacyDn).toBeUndefined(); + }); + + test('formats contacts without pretending legacy DNs are SMTP addresses', () => { + expect(formatContact('Kliucininkaite, Lina (ENBW AG)', legacyDn)).toBe( + 'Kliucininkaite, Lina (ENBW AG)' + ); + expect(formatAddressHeader('Kliucininkaite, Lina (ENBW AG)', legacyDn)).toBe( + 'Kliucininkaite, Lina (ENBW AG)' + ); + }); + + test('marks raw headers with legacy DNs as unsafe for export reuse', () => { + expect(isUnsafeRawAddressHeader(`Kliucininkaite, Lina (ENBW AG) <${legacyDn}>`)).toBe(true); + expect(isUnsafeRawAddressHeader('"Lina" ')).toBe(false); + }); +}); diff --git a/tests/messageExport.test.js b/tests/messageExport.test.js index b9d7717c..b5fdf58d 100644 --- a/tests/messageExport.test.js +++ b/tests/messageExport.test.js @@ -79,6 +79,44 @@ describe('message export helpers', () => { expect(eml).toContain('X-Mailer: Microsoft Outlook 16.0'); }); + test('does not reuse raw address headers containing Exchange legacy DNs', () => { + const legacyDn = + '/O=ENBW-KK/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=KLIUCININKAITE, LINA7E2'; + const eml = messageToEml({ + ...message, + senderName: 'Kliucininkaite, Lina (ENBW AG)', + senderEmail: '', + _exportMeta: { + headerMap: { + from: `Kliucininkaite, Lina (ENBW AG) <${legacyDn}>` + } + } + }); + + expect(eml).toContain('From: Kliucininkaite, Lina (ENBW AG)'); + expect(eml).not.toContain(legacyDn); + }); + + test('renders contacts without empty angle brackets in standalone html', () => { + const html = messageToHtmlDocument({ + ...message, + senderName: 'Kliucininkaite, Lina (ENBW AG)', + senderEmail: '', + recipients: [ + { + name: 'Internal User', + exchangeLegacyDn: '/O=ORG/CN=RECIPIENTS/CN=USER', + recipType: 'to' + } + ] + }); + + expect(html).toContain('From: Kliucininkaite, Lina (ENBW AG)'); + expect(html).toContain('To: Internal User'); + expect(html).not.toContain('</O=ORG'); + expect(html).not.toContain('<>'); + }); + test('exports inline images as related cid parts instead of duplicate attachments', () => { const inlineMessage = { ...message, diff --git a/tests/utils.test.js b/tests/utils.test.js index 5b2ce65c..953d70db 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -130,6 +130,9 @@ describe('extractMsg', () => { }); describe('extractEml', () => { + const legacyDn = + '/O=ENBW-KK/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=KLIUCININKAITE, LINA7E2'; + test('decodes 8bit body content using declared Windows-1252 charset', () => { const eml = [ 'From: Brigitte Korn-Hoffmann ', @@ -146,6 +149,22 @@ describe('extractEml', () => { expect(result.bodyContent).toBe('Freundliche Grüße'); }); + test('parses Exchange legacy DN senders without treating them as SMTP addresses', () => { + const eml = [ + `From: Kliucininkaite, Lina (ENBW AG) <${legacyDn}>`, + 'Subject: Exchange legacy DN', + 'Content-Type: text/plain; charset=utf-8', + '', + 'Body' + ].join('\r\n'); + + const result = extractEml(binaryArrayBuffer(eml)); + + expect(result.senderName).toBe('Kliucininkaite, Lina (ENBW AG)'); + expect(result.senderEmail).toBe(''); + expect(result.senderExchangeLegacyDn).toBe(legacyDn); + }); + test('falls back to Windows-1252 when unlabelled 8bit text is invalid UTF-8', () => { const eml = [ 'From: test@example.com',