diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index b5f3ebc..02a4a06 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -652,7 +652,7 @@ export class EmailService { /** * Detects if HTML contains custom patterns that indicate it was written in the HTML editor - * rather than the visual editor. Mirrors the same logic in EmailPreviewModal.tsx. + * rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts. */ private static detectCustomHtmlPatterns(html: string): boolean { if (!html || html.trim() === '') return false; @@ -692,7 +692,7 @@ export class EmailService { /** * Wraps visual editor content with a full HTML document and prose styles. - * Mirrors wrapEmailWithStyles() in EmailPreviewModal.tsx so sent emails + * Mirrors wrapEmailWithStyles() in apps/web/src/lib/emailStyles.ts so sent emails * match the preview modal exactly. */ private static wrapWithEmailStyles(htmlBody: string): string { diff --git a/apps/web/src/components/EmailEditor/EmailEditor.tsx b/apps/web/src/components/EmailEditor/EmailEditor.tsx index 4447200..2660bf2 100644 --- a/apps/web/src/components/EmailEditor/EmailEditor.tsx +++ b/apps/web/src/components/EmailEditor/EmailEditor.tsx @@ -31,6 +31,7 @@ import { } from '@plunk/ui'; import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react'; import {network} from '../../lib/network'; +import {detectCustomHtmlPatterns, wrapEmailWithStyles} from '../../lib/emailStyles'; import 'tippy.js/dist/tippy.css'; interface EmailEditorProps { @@ -51,54 +52,6 @@ const commonVariables = [ {name: 'manageUrl', description: 'Manage link'}, ]; -const detectCustomHtmlPatterns = (html: string): boolean => { - if (!html || html.trim() === '') return false; - - // Check for common patterns that indicate custom HTML - const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html); - - // Check for custom classes (exclude TipTap's generated classes) - const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi); - let hasCustomClasses = false; - for (const match of classMatches) { - const classValue = match[1]; - if (!classValue) continue; - // Split by whitespace to get individual classes - const classes = classValue.split(/\s+/).filter(c => c.length > 0); - // Check if any class is NOT in the allowed list - const allowedPrefixes = [ - 'prose', - 'variable-', - 'email-image', - 'ProseMirror', - 'resizable-image', - 'selected', - 'resize-handle', - ]; - const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix))); - if (hasDisallowedClass) { - hasCustomClasses = true; - break; - } - } - - const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html); - const hasComplexTables = /]*>[\s\S]*?]*>/i.test(html); - const hasMediaQueries = /@media/i.test(html); - const hasStyleTags = /]*>/i.test(html); - - return ( - hasInlineStyles || - hasCustomClasses || - hasCustomAttributes || - hasComplexTables || - hasCustomElements || - hasMediaQueries || - hasStyleTags - ); -}; - export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) { // Detect if initial value has custom HTML and start in appropriate mode const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual'; @@ -348,31 +301,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT if (iframeDoc) { const previewContent = getPreviewHtml(); - const fullHtml = ` - - - - - - - - - ${previewContent} - - - `; + const fullHtml = wrapEmailWithStyles(previewContent); iframeDoc.open(); iframeDoc.write(fullHtml); diff --git a/apps/web/src/components/EmailPreviewModal.tsx b/apps/web/src/components/EmailPreviewModal.tsx index e8339aa..c4d5bc2 100644 --- a/apps/web/src/components/EmailPreviewModal.tsx +++ b/apps/web/src/components/EmailPreviewModal.tsx @@ -1,6 +1,7 @@ import {Button, Dialog, DialogContent, DialogHeader, DialogTitle} from '@plunk/ui'; import {Monitor, Smartphone, Tablet} from 'lucide-react'; import {useState} from 'react'; +import {wrapEmailWithStyles} from '../lib/emailStyles'; interface EmailPreviewModalProps { open: boolean; @@ -16,391 +17,6 @@ interface EmailPreviewModalProps { type PreviewDevice = 'mobile' | 'tablet' | 'desktop'; -// Detects if HTML contains custom patterns that indicate it was written in the HTML editor -// rather than the visual editor. Custom HTML should render as-is without prose wrapper. -const detectCustomHtmlPatterns = (html: string): boolean => { - if (!html || html.trim() === '') return false; - - // Check for common patterns that indicate custom HTML - const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html); - - // Check for custom classes (exclude TipTap's generated classes) - const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi); - let hasCustomClasses = false; - for (const match of classMatches) { - const classValue = match[1]; - if (!classValue) continue; - // Split by whitespace to get individual classes - const classes = classValue.split(/\s+/).filter(c => c.length > 0); - // Check if any class is NOT in the allowed list - const allowedPrefixes = [ - 'prose', - 'variable-', - 'email-image', - 'ProseMirror', - 'resizable-image', - 'selected', - 'resize-handle', - ]; - const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix))); - if (hasDisallowedClass) { - hasCustomClasses = true; - break; - } - } - - const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html); - const hasComplexTables = /]*>[\s\S]*?
]*>/i.test(html); - const hasMediaQueries = /@media/i.test(html); - const hasStyleTags = /]*>/i.test(html); - - return ( - hasInlineStyles || - hasCustomClasses || - hasCustomAttributes || - hasComplexTables || - hasCustomElements || - hasMediaQueries || - hasStyleTags - ); -}; - -// Helper function to wrap email body with necessary styles for proper rendering -// Only wraps visual editor content - custom HTML is returned as-is -const wrapEmailWithStyles = (htmlBody: string): string => { - // If this is custom HTML (from HTML editor), return it as-is without wrapping - if (detectCustomHtmlPatterns(htmlBody)) { - return htmlBody; - } - - // For visual editor content, wrap with prose styles - return ` - - - - - - - -
- ${htmlBody} -
- -`; -}; - export function EmailPreviewModal({ open, onOpenChange, diff --git a/apps/web/src/lib/emailStyles.ts b/apps/web/src/lib/emailStyles.ts new file mode 100644 index 0000000..80f062b --- /dev/null +++ b/apps/web/src/lib/emailStyles.ts @@ -0,0 +1,377 @@ +// Detects if HTML contains custom patterns that indicate it was written in the HTML editor +// rather than the visual editor. Custom HTML should render as-is without prose wrapper. +export const detectCustomHtmlPatterns = (html: string): boolean => { + if (!html || html.trim() === '') return false; + + const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html); + + const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi); + let hasCustomClasses = false; + for (const match of classMatches) { + const classValue = match[1]; + if (!classValue) continue; + + const classes = classValue.split(/\s+/).filter(c => c.length > 0); + const allowedPrefixes = [ + 'prose', + 'variable-', + 'email-image', + 'ProseMirror', + 'resizable-image', + 'selected', + 'resize-handle', + ]; + const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix))); + if (hasDisallowedClass) { + hasCustomClasses = true; + break; + } + } + + const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html); + const hasComplexTables = /]*>[\s\S]*?
]*>/i.test(html); + const hasMediaQueries = /@media/i.test(html); + const hasStyleTags = /]*>/i.test(html); + + return ( + hasInlineStyles || + hasCustomClasses || + hasCustomAttributes || + hasComplexTables || + hasCustomElements || + hasMediaQueries || + hasStyleTags + ); +}; + +export const wrapEmailWithStyles = (htmlBody: string): string => { + if (detectCustomHtmlPatterns(htmlBody)) { + return htmlBody; + } + + return ` + + + + + + + +
+ ${htmlBody} +
+ +`; +};