Files
Zero/apps/server/src/lib/email-processor.ts
Adam 5b2d76a5d4 prefetch email html (#1719)
# Email Content Prefetching and Processing Optimization

## Description

This PR improves email loading performance by implementing prefetching and caching of processed email HTML content. It splits the email processing logic into two parts:

1. Server-side preprocessing that handles sanitization and structure
2. Client-side processing that applies theme-specific styling and image loading preferences

The changes also add prefetching of the latest message in a thread to improve perceived loading speed when users open emails.

---

## Type of Change

- [x]  Performance improvement
- [x] 🎨 UI/UX improvement

## Areas Affected

- [x] Email Integration (Gmail, IMAP, etc.)
- [x] User Interface/Experience
- [x] Performance Optimization

## Testing Done

- [x] Manual testing performed
- [x] Cross-browser testing (if UI changes)

## Checklist

- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] My code follows the project's style guidelines

## Additional Notes

The email processing logic has been refactored to:
1. Separate heavy sanitization work (which can be done once) from theme/preference application
2. Cache processed content with a 30-minute stale time and 1-hour garbage collection time
3. Respect user preferences for external image loading and trusted senders
4. Apply theme-specific styling based on user settings or system preference

This should significantly improve the perceived performance when opening emails, especially for threads with complex HTML content.

---

_By submitting this pull request, I confirm that my contribution is made under the terms of the project's license._

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

* **New Features**
  * Improved email thread view by displaying the latest non-draft message.
  * Enhanced email content processing to apply user settings and theme preferences, including external image loading and dark/light mode support.

* **Bug Fixes**
  * More accurate handling of external images and theme styling in emails based on user preferences.

* **Chores**
  * Updated internal configuration for local development environment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-11 15:57:19 -07:00

209 lines
5.3 KiB
TypeScript

import sanitizeHtml from 'sanitize-html';
import * as cheerio from 'cheerio';
interface ProcessEmailOptions {
html: string;
shouldLoadImages: boolean;
theme: 'light' | 'dark';
}
// Server-side: Heavy lifting, preference-independent processing
export function preprocessEmailHtml(html: string): string {
const sanitizeConfig: sanitizeHtml.IOptions = {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'title', 'details', 'summary']),
allowedAttributes: {
'*': [
'class',
'style',
'align',
'valign',
'width',
'height',
'cellpadding',
'cellspacing',
'border',
'bgcolor',
'colspan',
'rowspan',
],
a: ['href', 'name', 'target', 'rel', 'class', 'style'],
img: ['src', 'alt', 'width', 'height', 'class', 'style'],
},
// Allow only safe schemes - no blob for security
allowedSchemes: ['http', 'https', 'mailto', 'tel', 'data', 'cid'],
allowedSchemesByTag: {
img: ['http', 'https', 'data', 'cid'],
},
transformTags: {
a: (tagName, attribs) => {
return {
tagName,
attribs: {
...attribs,
target: attribs.target || '_blank',
rel: 'noopener noreferrer',
},
};
},
},
};
const sanitized = sanitizeHtml(html, sanitizeConfig);
const $ = cheerio.load(sanitized);
// Collapse quoted text (structure only, no theme colors)
const collapseQuoted = (selector: string) => {
$(selector).each((_, el) => {
const $el = $(el);
if ($el.parents('details.quoted-toggle').length) return;
const innerHtml = $el.html();
if (typeof innerHtml !== 'string') return;
const detailsHtml = `<details class="quoted-toggle" style="margin-top:1em;">
<summary style="cursor:pointer;" data-theme-color="muted">
Show quoted text
</summary>
${innerHtml}
</details>`;
$el.replaceWith(detailsHtml);
});
};
collapseQuoted('blockquote');
collapseQuoted('.gmail_quote');
// Remove unwanted elements
$('title').remove();
$('img[width="1"][height="1"]').remove();
$('img[width="0"][height="0"]').remove();
// Remove preheader content
$('.preheader, .preheaderText, [class*="preheader"]').each((_, el) => {
const $el = $(el);
const style = $el.attr('style') || '';
if (
style.includes('display:none') ||
style.includes('display: none') ||
style.includes('font-size:0') ||
style.includes('font-size: 0') ||
style.includes('line-height:0') ||
style.includes('line-height: 0') ||
style.includes('max-height:0') ||
style.includes('max-height: 0') ||
style.includes('mso-hide:all') ||
style.includes('opacity:0') ||
style.includes('opacity: 0')
) {
$el.remove();
}
});
return $.html();
}
// Client-side: Light styling + image preferences
export function applyEmailPreferences(
preprocessedHtml: string,
theme: 'light' | 'dark',
shouldLoadImages: boolean
): { processedHtml: string; hasBlockedImages: boolean } {
let hasBlockedImages = false;
const isDarkTheme = theme === 'dark';
const $ = cheerio.load(preprocessedHtml);
// Handle image blocking if needed
if (!shouldLoadImages) {
$('img').each((_, el) => {
const $img = $(el);
const src = $img.attr('src');
// Allow CID images (inline attachments)
if (src && !src.startsWith('cid:')) {
hasBlockedImages = true;
$img.replaceWith(`<span style="display:none;"><!-- blocked image: ${src} --></span>`);
}
});
}
const html = $.html();
// Apply theme-specific styles
const themeStyles = `
<style type="text/css">
:host {
display: block;
line-height: 1.5;
background-color: ${isDarkTheme ? '#1A1A1A' : '#ffffff'};
color: ${isDarkTheme ? '#ffffff' : '#000000'};
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
}
a {
cursor: pointer;
color: ${isDarkTheme ? '#60a5fa' : '#2563eb'};
text-decoration: underline;
}
table {
border-collapse: collapse;
}
::selection {
background: #b3d4fc;
text-shadow: none;
}
/* Styling for collapsed quoted text */
details.quoted-toggle {
border-left: 2px solid ${isDarkTheme ? '#374151' : '#d1d5db'};
padding-left: 8px;
margin-top: 0.75rem;
}
details.quoted-toggle summary {
cursor: pointer;
color: ${isDarkTheme ? '#9CA3AF' : '#6B7280'};
list-style: none;
user-select: none;
}
details.quoted-toggle summary::-webkit-details-marker {
display: none;
}
[data-theme-color="muted"] {
color: ${isDarkTheme ? '#9CA3AF' : '#6B7280'};
}
</style>
`;
const finalHtml = `${themeStyles}${html}`;
return {
processedHtml: finalHtml,
hasBlockedImages,
};
}
// Original function for backward compatibility
export function processEmailHtml({ html, shouldLoadImages, theme }: ProcessEmailOptions): {
processedHtml: string;
hasBlockedImages: boolean;
} {
const preprocessed = preprocessEmailHtml(html);
return applyEmailPreferences(preprocessed, theme, shouldLoadImages);
}