diff --git a/apps/web/package.json b/apps/web/package.json index 45cd005..55dad1d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/app/api/dev/email-preview/route.ts b/apps/web/src/app/api/dev/email-preview/route.ts new file mode 100644 index 0000000..bda57d8 --- /dev/null +++ b/apps/web/src/app/api/dev/email-preview/route.ts @@ -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"; + + 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 } + ); + } +} diff --git a/apps/web/src/server/email-templates/OtpEmail.tsx b/apps/web/src/server/email-templates/OtpEmail.tsx new file mode 100644 index 0000000..227eddb --- /dev/null +++ b/apps/web/src/server/email-templates/OtpEmail.tsx @@ -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 ( + + + + + + Hi there, + + + + Use the verification code below to sign in to your Unsend account: + + + + + {otpCode} + + + + + Sign in with one click + + + + If you didn't request this email, you can safely ignore it. The + verification code will expire automatically. + + + + + + ); +} + +export async function renderOtpEmail(props: OtpEmailProps): Promise { + return render(); +} diff --git a/apps/web/src/server/email-templates/TeamInviteEmail.tsx b/apps/web/src/server/email-templates/TeamInviteEmail.tsx new file mode 100644 index 0000000..fab1c64 --- /dev/null +++ b/apps/web/src/server/email-templates/TeamInviteEmail.tsx @@ -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 ( + + + + + + Hi there, + + + + {inviterName + ? `${inviterName} has invited you to join ` + : "You have been invited to join "} + {teamName} on Unsend + {role && role !== "member" && ( + + {" "} + as a {role} + + )} + . + + + + Accept invitation + + + + If you weren't expecting this invitation or don't want to join this + team, you can safely ignore this email. + + + + + + ); +} + +export async function renderTeamInviteEmail( + props: TeamInviteEmailProps +): Promise { + return render(); +} diff --git a/apps/web/src/server/email-templates/components/EmailButton.tsx b/apps/web/src/server/email-templates/components/EmailButton.tsx new file mode 100644 index 0000000..acb6950 --- /dev/null +++ b/apps/web/src/server/email-templates/components/EmailButton.tsx @@ -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 ( + + {children} + + ); +} \ No newline at end of file diff --git a/apps/web/src/server/email-templates/components/EmailFooter.tsx b/apps/web/src/server/email-templates/components/EmailFooter.tsx new file mode 100644 index 0000000..304aaaf --- /dev/null +++ b/apps/web/src/server/email-templates/components/EmailFooter.tsx @@ -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 ( + + + This email was sent by {companyName}. If you have any questions, please{" "} + + contact our support team + + . + + + ); +} \ No newline at end of file diff --git a/apps/web/src/server/email-templates/components/EmailHeader.tsx b/apps/web/src/server/email-templates/components/EmailHeader.tsx new file mode 100644 index 0000000..c9a131c --- /dev/null +++ b/apps/web/src/server/email-templates/components/EmailHeader.tsx @@ -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 ( + + {logoUrl && ( + Unsend + )} + {title && ( + + {title} + + )} + + ); +} \ No newline at end of file diff --git a/apps/web/src/server/email-templates/components/EmailLayout.tsx b/apps/web/src/server/email-templates/components/EmailLayout.tsx new file mode 100644 index 0000000..b68c8bc --- /dev/null +++ b/apps/web/src/server/email-templates/components/EmailLayout.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { + Html, + Head, + Body, + Container, + Font, + Preview, +} from "jsx-email"; + +interface EmailLayoutProps { + preview?: string; + children: React.ReactNode; +} + +export function EmailLayout({ preview, children }: EmailLayoutProps) { + return ( + + + +