Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions src/js/addressUtils.js
Original file line number Diff line number Diff line change
@@ -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
);
}
45 changes: 23 additions & 22 deletions src/js/messageExport.js
Original file line number Diff line number Diff line change
@@ -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 '';
Expand Down Expand Up @@ -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, '')
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 || ''} &lt;${recipient.smtpAddress || recipient.email || ''}&gt;`
.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 || ''} &lt;${recipient.smtpAddress || recipient.email || ''}&gt;`
.map((recipient) =>
escapeHTML(formatContact(recipient.name || '', getContactEmail(recipient)))
)
.join(', ');

Expand Down Expand Up @@ -373,7 +374,7 @@ export function messageToHtmlDocument(message) {
<main>
<h1>${escapeHTML(message?.subject || 'Message')}</h1>
<div class="meta">
<div><strong>From:</strong> ${escapeHTML(message?.senderName || '')} &lt;${escapeHTML(message?.senderEmail || '')}&gt;</div>
<div><strong>From:</strong> ${escapeHTML(formatContact(message?.senderName || '', message?.senderEmail || ''))}</div>
${recipientsTo ? `<div><strong>To:</strong> ${recipientsTo}</div>` : ''}
${recipientsCc ? `<div><strong>CC:</strong> ${recipientsCc}</div>` : ''}
<div><strong>Date:</strong> ${escapeHTML(new Date(message?.messageDeliveryTime || Date.now()).toLocaleString())}</div>
Expand Down
9 changes: 3 additions & 6 deletions src/js/ui/AttachmentModalManager.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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') },
Expand Down Expand Up @@ -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(', ');
}
Expand Down
Loading