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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@trpc/server": "^11.1.1",
"@unsend/email-editor": "workspace:*",
"@unsend/ui": "workspace:*",
"jsx-email": "^2.7.1",
"bullmq": "^5.51.1",
"chrono-node": "^2.8.0",
"date-fns": "^4.1.0",
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/app/api/dev/email-preview/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
renderOtpEmail,
renderTeamInviteEmail,
} from "~/server/email-templates";

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") || "otp";

Comment on lines +7 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Restrict the dev preview route to development-only

This is a dev-only utility; gate it by NODE_ENV to avoid exposing it in production.

Apply this diff:

 export async function GET(request: NextRequest) {
+  // Dev-only endpoint: return 404 in non-development environments
+  if (process.env.NODE_ENV !== "development") {
+    return NextResponse.json({ error: "Not Found" }, { status: 404 });
+  }
   const { searchParams } = new URL(request.url);
   const type = searchParams.get("type") || "otp";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") || "otp";
export async function GET(request: NextRequest) {
// Dev-only endpoint: return 404 in non-development environments
if (process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const type = searchParams.get("type") || "otp";
// …rest of your logic
}
🤖 Prompt for AI Agents
In apps/web/src/app/api/dev/email-preview/route.ts around lines 4–7, the
dev-only email preview route is currently exposed unconditionally; add a
NODE_ENV check at the start of the GET handler so it only runs in development
(process.env.NODE_ENV === 'development'), and immediately return a 404/403
response (or throw) when not in development to prevent exposure in production;
keep the rest of the handler unchanged and ensure the guard executes before
creating the URL/searchParams or reading any request data.

if (process.env.NODE_ENV !== "development") {
return NextResponse.json({ error: "Not Found" }, { status: 404 });
}

try {
let html: string;

if (type === "otp") {
html = await renderOtpEmail({
otpCode: "ABC123",
loginUrl: "https://app.unsend.dev/login?token=abc123",
hostName: "Unsend",
});
} else if (type === "invite") {
html = await renderTeamInviteEmail({
teamName: "My Awesome Team",
inviteUrl: "https://app.unsend.dev/join-team?inviteId=123",
inviterName: "John Doe",
role: "admin",
});
} else {
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
}

return new NextResponse(html, {
headers: {
"Content-Type": "text/html",
},
});
} catch (error) {
console.error("Error rendering email template:", error);
return NextResponse.json(
{ error: "Failed to render email template" },
{ status: 500 }
);
}
}
99 changes: 99 additions & 0 deletions apps/web/src/server/email-templates/OtpEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import React from "react";
import { Container, Text } from "jsx-email";
import { render } from "jsx-email";
import { EmailLayout } from "./components/EmailLayout";
import { EmailHeader } from "./components/EmailHeader";
import { EmailFooter } from "./components/EmailFooter";
import { EmailButton } from "./components/EmailButton";

interface OtpEmailProps {
otpCode: string;
loginUrl: string;
hostName?: string;
logoUrl?: string;
}

export function OtpEmail({
otpCode,
loginUrl,
hostName = "Unsend",
logoUrl,
}: OtpEmailProps) {
return (
<EmailLayout preview={`Your verification code: ${otpCode}`}>
<EmailHeader logoUrl={logoUrl} title="Sign in to your account" />

<Container style={{ padding: "20px 0", textAlign: "left" as const }}>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 24px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Hi there,
</Text>

<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 32px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Use the verification code below to sign in to your Unsend account:
</Text>

<Container
style={{
backgroundColor: "#f8f9fa",
padding: "16px",
margin: "0 0 32px 0",
textAlign: "left" as const,
}}
>
<Text
style={{
fontSize: "24px",
fontWeight: "700",
color: "#000000",
letterSpacing: "4px",
margin: "0",
fontFamily: "monospace",
textAlign: "left" as const,
}}
>
{otpCode}
</Text>
</Container>

<Container style={{ margin: "0 0 32px 0", textAlign: "left" as const }}>
<EmailButton href={loginUrl}>Sign in with one click</EmailButton>
</Container>

<Text
style={{
fontSize: "14px",
color: "#6b7280",
margin: "0",
lineHeight: "1.5",
textAlign: "left" as const,
}}
>
If you didn't request this email, you can safely ignore it. The
verification code will expire automatically.
</Text>
</Container>

<EmailFooter />
</EmailLayout>
);
}

export async function renderOtpEmail(props: OtpEmailProps): Promise<string> {
return render(<OtpEmail {...props} />);
}
90 changes: 90 additions & 0 deletions apps/web/src/server/email-templates/TeamInviteEmail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from "react";
import { Container, Text } from "jsx-email";
import { render } from "jsx-email";
import { EmailLayout } from "./components/EmailLayout";
import { EmailHeader } from "./components/EmailHeader";
import { EmailFooter } from "./components/EmailFooter";
import { EmailButton } from "./components/EmailButton";

interface TeamInviteEmailProps {
teamName: string;
inviteUrl: string;
inviterName?: string;
logoUrl?: string;
role?: string;
}

export function TeamInviteEmail({
teamName,
inviteUrl,
inviterName,
logoUrl,
role = "member",
}: TeamInviteEmailProps) {
return (
<EmailLayout preview={`You've been invited to join ${teamName} on Unsend`}>
<EmailHeader logoUrl={logoUrl} title="You're invited!" />

<Container style={{ padding: "20px 0", textAlign: "left" as const }}>
<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 24px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
Hi there,
</Text>

<Text
style={{
fontSize: "16px",
color: "#374151",
margin: "0 0 32px 0",
lineHeight: "1.6",
textAlign: "left" as const,
}}
>
{inviterName
? `${inviterName} has invited you to join `
: "You have been invited to join "}
<strong style={{ color: "#000000" }}>{teamName}</strong> on Unsend
{role && role !== "member" && (
<span>
{" "}
as a <strong style={{ color: "#000000" }}>{role}</strong>
</span>
)}
.
</Text>

<Container style={{ margin: "0 0 32px 0", textAlign: "left" as const }}>
<EmailButton href={inviteUrl}>Accept invitation</EmailButton>
</Container>

<Text
style={{
fontSize: "14px",
color: "#6b7280",
margin: "0",
lineHeight: "1.5",
textAlign: "left" as const,
}}
>
If you weren't expecting this invitation or don't want to join this
team, you can safely ignore this email.
</Text>
</Container>

<EmailFooter />
</EmailLayout>
);
}

export async function renderTeamInviteEmail(
props: TeamInviteEmailProps
): Promise<string> {
return render(<TeamInviteEmail {...props} />);
}
38 changes: 38 additions & 0 deletions apps/web/src/server/email-templates/components/EmailButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from "react";
import { Link } from "jsx-email";

interface EmailButtonProps {
href: string;
children: React.ReactNode;
variant?: "primary" | "secondary";
}

export function EmailButton({
href,
children,
variant = "primary"
}: EmailButtonProps) {
return (
<a
href={href}
style={{
backgroundColor: "#000000",
color: "#ffffff",
border: "1px solid #000000",
borderRadius: "4px",
fontSize: "16px",
fontWeight: "500",
padding: "12px 24px",
textDecoration: "none",
display: "inline-block",
textAlign: "center" as const,
minWidth: "120px",
boxSizing: "border-box" as const,
float: "left" as const,
clear: "both" as const,
}}
>
{children}
</a>
);
}
43 changes: 43 additions & 0 deletions apps/web/src/server/email-templates/components/EmailFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from "react";
import { Container, Text } from "jsx-email";

interface EmailFooterProps {
companyName?: string;
supportUrl?: string;
}

export function EmailFooter({
companyName = "Unsend",
supportUrl = "https://unsend.dev"
}: EmailFooterProps) {
return (
<Container
style={{
padding: "20px 0",
backgroundColor: "#ffffff",
}}
>
<Text
style={{
fontSize: "14px",
color: "#6b7280",
textAlign: "left" as const,
margin: "0",
lineHeight: "1.5",
}}
>
This email was sent by {companyName}. If you have any questions, please{" "}
<a
href={supportUrl}
style={{
color: "#000000",
textDecoration: "underline",
}}
>
contact our support team
</a>
.
</Text>
</Container>
);
}
45 changes: 45 additions & 0 deletions apps/web/src/server/email-templates/components/EmailHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from "react";
import { Container, Heading, Img } from "jsx-email";

interface EmailHeaderProps {
logoUrl?: string;
title?: string;
}

export function EmailHeader({ logoUrl, title }: EmailHeaderProps) {
return (
<Container
style={{
padding: "20px 0",
textAlign: "left" as const,
}}
>
{logoUrl && (
<Img
src={logoUrl}
alt="Unsend"
style={{
width: "48px",
height: "48px",
margin: "0 0 16px 0",
display: "block",
}}
/>
)}
{title && (
<Heading
style={{
fontSize: "24px",
fontWeight: "600",
color: "#111827",
margin: "0",
lineHeight: "1.3",
textAlign: "left" as const,
}}
>
{title}
</Heading>
)}
</Container>
);
}
Loading