From cc7fcdb4ef31e9dfb4f7403aa93479b6df56719d Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Thu, 5 Mar 2026 19:45:28 +0100 Subject: [PATCH] fix: Align preview and actual email for templates and campaigns --- apps/api/src/services/EmailService.ts | 376 +++++++++++++++++- apps/web/src/components/EmailPreviewModal.tsx | 20 +- 2 files changed, 385 insertions(+), 11 deletions(-) diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index bc40a38..b5f3ebc 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -650,6 +650,378 @@ 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. + */ + private static 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: string) => c.length > 0); + const allowedPrefixes = ['prose', 'variable-', 'email-image', 'ProseMirror', 'resizable-image', 'selected', 'resize-handle']; + const hasDisallowedClass = classes.some((cls: string) => !allowedPrefixes.some((prefix: string) => 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 + ); + } + + /** + * Wraps visual editor content with a full HTML document and prose styles. + * Mirrors wrapEmailWithStyles() in EmailPreviewModal.tsx so sent emails + * match the preview modal exactly. + */ + private static wrapWithEmailStyles(htmlBody: string): string { + return ` + + + + + + + +
+ ${htmlBody} +
+ +`; + } + /** * Compile HTML email with optional unsubscribe footer and badge * Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled) @@ -665,7 +1037,9 @@ export class EmailService { project: Project; includeUnsubscribe?: boolean; }): string { - let html = content; + // Wrap visual editor content with prose styles so the sent email matches the preview modal. + // Custom HTML (from the HTML editor) already carries its own styles and is used as-is. + let html = this.detectCustomHtmlPatterns(content) ? content : this.wrapWithEmailStyles(content); const unsubscribeHtml = includeUnsubscribe ? (() => { diff --git a/apps/web/src/components/EmailPreviewModal.tsx b/apps/web/src/components/EmailPreviewModal.tsx index cbcc42b..e8339aa 100644 --- a/apps/web/src/components/EmailPreviewModal.tsx +++ b/apps/web/src/components/EmailPreviewModal.tsx @@ -94,7 +94,7 @@ const wrapEmailWithStyles = (htmlBody: string): string => { /* Tailwind Typography (prose) base styles */ .prose { color: #374151; - max-width: 65ch; + max-width: 600px; } .prose [class~="lead"] { color: #4b5563; @@ -148,8 +148,8 @@ const wrapEmailWithStyles = (htmlBody: string): string => { margin-bottom: 0.75em; } .prose hr { - border-color: #e5e7eb; - border-top-width: 1px; + border: none; + border-top: 1px solid #e5e7eb; margin-top: 3em; margin-bottom: 3em; } @@ -352,43 +352,43 @@ const wrapEmailWithStyles = (htmlBody: string): string => { display: inline; } - table { + .prose table { border-collapse: collapse; width: 100%; margin: 16px 0; } - th, td { + .prose th, .prose td { border: 1px solid #e5e7eb; padding: 8px 12px; text-align: left; min-width: 100px; } - th { + .prose th { background-color: #f3f4f6; font-weight: 600; } - img { + .prose img { max-width: 100%; height: auto; display: block; margin: 16px 0; } - .resizable-image-wrapper { + .prose .resizable-image-wrapper { display: block; margin: 16px 0; } - .resizable-image-container { + .prose .resizable-image-container { display: inline-block; position: relative; max-width: 100%; } - .resizable-image-container img { + .prose .resizable-image-container img { margin: 0; }