Skip to content
Open
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
28 changes: 25 additions & 3 deletions apps/web/src/app/(dashboard)/emails/email-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
COMPLAINT_ERROR_MESSAGES,
DELIVERY_DELAY_ERRORS,
} from "@usesend/lib/src/constants/ses-errors";
import { getEmailPreviewSrcDoc } from "~/lib/email-preview";
import CancelEmail from "./cancel-email";
import { useEffect } from "react";
import { useState } from "react";
Expand Down Expand Up @@ -90,7 +91,10 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.3 }}
>
<EmailPreview html={emailQuery.data?.html ?? ""} />
<EmailPreview
html={emailQuery.data?.html}
text={emailQuery.data?.text}
/>
</motion.div>
</div>
{emailQuery.data?.latestStatus !== "SCHEDULED" ? (
Expand Down Expand Up @@ -134,7 +138,13 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
);
}

const EmailPreview = ({ html }: { html: string }) => {
const EmailPreview = ({
html,
text,
}: {
html: string | null | undefined;
text: string | null | undefined;
}) => {
const [show, setShow] = useState(false);

useEffect(() => {
Expand All @@ -145,17 +155,29 @@ const EmailPreview = ({ html }: { html: string }) => {
return () => clearTimeout(timer);
}, []);

const srcDoc = getEmailPreviewSrcDoc(html, text);

if (!show) {
return (
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t"></div>
);
}

if (!srcDoc) {
return (
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t flex items-center justify-center">
<span className="text-sm text-muted-foreground dark:text-slate-500">
No content to preview
</span>
</div>
);
}

return (
<div className="dark:bg-slate-200 h-[350px] overflow-visible rounded border-t">
<iframe
className="w-full h-full"
srcDoc={html}
srcDoc={srcDoc}
sandbox="allow-same-origin"
/>
</div>
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/lib/email-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const escapeHtml = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");

/**
* Builds the srcDoc for the sandboxed email preview iframe. Emails sent with
* only a `text` body have no html, so the plain text is escaped and wrapped
* in a minimal document instead of rendering an empty preview.
*/
export function getEmailPreviewSrcDoc(
html: string | null | undefined,
text: string | null | undefined,
): string | null {
if (html) {
return html;
}

if (text) {
return `<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0"><pre style="margin:0;padding:16px;font-family:ui-sans-serif,system-ui,sans-serif;font-size:14px;line-height:1.5;white-space:pre-wrap;word-break:break-word;color:#0f172a">${escapeHtml(text)}</pre></body></html>`;
}

return null;
}
34 changes: 34 additions & 0 deletions apps/web/src/lib/email-preview.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";

import { getEmailPreviewSrcDoc } from "./email-preview";

describe("getEmailPreviewSrcDoc", () => {
it("returns the html body unchanged when present", () => {
const html = "<html><body><h1>Hello</h1></body></html>";
expect(getEmailPreviewSrcDoc(html, "ignored text")).toBe(html);
});

it("wraps a text-only body in a plain-text document", () => {
const srcDoc = getEmailPreviewSrcDoc(null, "Hello\n\nworld");
expect(srcDoc).toContain("<pre");
expect(srcDoc).toContain("Hello\n\nworld");
});

it("escapes html inside a text body instead of rendering it", () => {
const srcDoc = getEmailPreviewSrcDoc(null, 'Use <b> & "quotes" wisely');
expect(srcDoc).not.toContain("<b>");
expect(srcDoc).toContain("&lt;b&gt; &amp; &quot;quotes&quot; wisely");
});

it("treats an empty html string as absent and falls back to text", () => {
const srcDoc = getEmailPreviewSrcDoc("", "plain body");
expect(srcDoc).toContain("plain body");
expect(srcDoc).toContain("<pre");
});

it("returns null when neither html nor text is present", () => {
expect(getEmailPreviewSrcDoc(null, null)).toBeNull();
expect(getEmailPreviewSrcDoc("", "")).toBeNull();
expect(getEmailPreviewSrcDoc(undefined, undefined)).toBeNull();
});
});