feat: whyrating - initial project from turbostarter boilerplate
This commit is contained in:
21
packages/email/src/env.ts
Normal file
21
packages/email/src/env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import { preset as providerPreset } from "./providers/env";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "email",
|
||||
server: {
|
||||
PRODUCT_NAME: z.string().optional(),
|
||||
},
|
||||
extends: [providerPreset],
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
});
|
||||
1
packages/email/src/index.ts
Normal file
1
packages/email/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
1
packages/email/src/providers/env.ts
Normal file
1
packages/email/src/providers/env.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./resend/env";
|
||||
1
packages/email/src/providers/index.ts
Normal file
1
packages/email/src/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { send } from "./resend";
|
||||
24
packages/email/src/providers/nodemailer/env.ts
Normal file
24
packages/email/src/providers/nodemailer/env.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import { sharedPreset } from "../../utils/env";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "nodemailer",
|
||||
server: {
|
||||
NODEMAILER_HOST: z.string(),
|
||||
NODEMAILER_PORT: z.coerce.number(),
|
||||
NODEMAILER_USER: z.string(),
|
||||
NODEMAILER_PASSWORD: z.string(),
|
||||
},
|
||||
extends: [sharedPreset],
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
});
|
||||
28
packages/email/src/providers/nodemailer/index.ts
Normal file
28
packages/email/src/providers/nodemailer/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { EmailProviderStrategy } from "../types";
|
||||
|
||||
const from = env.EMAIL_FROM;
|
||||
|
||||
export const { send } = {
|
||||
send: async ({ to, subject, html, text }) => {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: env.NODEMAILER_HOST,
|
||||
port: env.NODEMAILER_PORT,
|
||||
auth: {
|
||||
user: env.NODEMAILER_USER,
|
||||
pass: env.NODEMAILER_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
});
|
||||
},
|
||||
} satisfies EmailProviderStrategy;
|
||||
21
packages/email/src/providers/plunk/env.ts
Normal file
21
packages/email/src/providers/plunk/env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import { sharedPreset } from "../../utils/env";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "plunk",
|
||||
server: {
|
||||
PLUNK_API_KEY: z.string(),
|
||||
},
|
||||
extends: [sharedPreset],
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
});
|
||||
31
packages/email/src/providers/plunk/index.ts
Normal file
31
packages/email/src/providers/plunk/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { EmailProviderStrategy } from "../types";
|
||||
|
||||
const from = env.EMAIL_FROM;
|
||||
|
||||
export const { send } = {
|
||||
send: async ({ to, subject, html, text }) => {
|
||||
const response = await fetch("https://api.useplunk.com/v1/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${env.PLUNK_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to,
|
||||
from,
|
||||
subject,
|
||||
body: html,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(await response.json());
|
||||
throw new Error("Could not send email!");
|
||||
}
|
||||
},
|
||||
} satisfies EmailProviderStrategy;
|
||||
21
packages/email/src/providers/postmark/env.ts
Normal file
21
packages/email/src/providers/postmark/env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import { sharedPreset } from "../../utils/env";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "postmark",
|
||||
server: {
|
||||
POSTMARK_API_KEY: z.string(),
|
||||
},
|
||||
extends: [sharedPreset],
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
});
|
||||
31
packages/email/src/providers/postmark/index.ts
Normal file
31
packages/email/src/providers/postmark/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { EmailProviderStrategy } from "../types";
|
||||
|
||||
const from = env.EMAIL_FROM;
|
||||
|
||||
export const { send } = {
|
||||
send: async ({ to, subject, html, text }) => {
|
||||
const response = await fetch("https://api.postmarkapp.com/email", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Postmark-Server-Token": env.POSTMARK_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
From: from,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
HtmlBody: html,
|
||||
TextBody: text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(await response.json());
|
||||
throw new Error("Could not send email!");
|
||||
}
|
||||
},
|
||||
} satisfies EmailProviderStrategy;
|
||||
21
packages/email/src/providers/resend/env.ts
Normal file
21
packages/email/src/providers/resend/env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import { sharedPreset } from "../../utils/env";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "resend",
|
||||
server: {
|
||||
RESEND_API_KEY: z.string().optional(),
|
||||
},
|
||||
extends: [sharedPreset],
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
});
|
||||
31
packages/email/src/providers/resend/index.ts
Normal file
31
packages/email/src/providers/resend/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { EmailProviderStrategy } from "../types";
|
||||
|
||||
const from = env.EMAIL_FROM;
|
||||
|
||||
export const { send } = {
|
||||
send: async ({ to, subject, html, text }) => {
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(await response.json());
|
||||
throw new Error("Could not send email!");
|
||||
}
|
||||
},
|
||||
} satisfies EmailProviderStrategy;
|
||||
21
packages/email/src/providers/sendgrid/env.ts
Normal file
21
packages/email/src/providers/sendgrid/env.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import { sharedPreset } from "../../utils/env";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const preset = {
|
||||
id: "sendgrid",
|
||||
server: {
|
||||
SENDGRID_API_KEY: z.string(),
|
||||
},
|
||||
extends: [sharedPreset],
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const env = defineEnv({
|
||||
...envConfig,
|
||||
...preset,
|
||||
});
|
||||
43
packages/email/src/providers/sendgrid/index.ts
Normal file
43
packages/email/src/providers/sendgrid/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { env } from "./env";
|
||||
|
||||
import type { EmailProviderStrategy } from "../types";
|
||||
|
||||
const from = env.EMAIL_FROM;
|
||||
|
||||
export const { send } = {
|
||||
send: async ({ to, subject, html, text }) => {
|
||||
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${env.SENDGRID_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: { email: from },
|
||||
personalizations: [
|
||||
{
|
||||
to: [{ email: to }],
|
||||
},
|
||||
],
|
||||
subject,
|
||||
content: [
|
||||
{
|
||||
type: "text/plain",
|
||||
value: text,
|
||||
},
|
||||
{
|
||||
type: "text/html",
|
||||
value: html,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(await response.json());
|
||||
throw new Error("Could not send email!");
|
||||
}
|
||||
},
|
||||
} satisfies EmailProviderStrategy;
|
||||
8
packages/email/src/providers/types.ts
Normal file
8
packages/email/src/providers/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface EmailProviderStrategy {
|
||||
send: (args: {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
38
packages/email/src/server.ts
Normal file
38
packages/email/src/server.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { logger } from "@turbostarter/shared/logger";
|
||||
|
||||
import { send } from "./providers";
|
||||
import { getTemplate } from "./templates";
|
||||
|
||||
import type { EmailTemplate, EmailVariables } from "./types";
|
||||
|
||||
const sendEmail = async <T extends EmailTemplate>({
|
||||
to,
|
||||
template,
|
||||
locale,
|
||||
variables,
|
||||
}: {
|
||||
to: string;
|
||||
template: T;
|
||||
variables: EmailVariables[T];
|
||||
locale?: string;
|
||||
}) => {
|
||||
logger.info(`Sending email ${template} to ${to}`);
|
||||
|
||||
try {
|
||||
const { html, text, subject } = await getTemplate({
|
||||
id: template,
|
||||
variables,
|
||||
locale,
|
||||
});
|
||||
|
||||
const result = await send({ to, subject, html, text });
|
||||
logger.info(`Email sent successfully to ${to}`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to send email ${template} to ${to}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export { sendEmail };
|
||||
43
packages/email/src/templates/_components/button.tsx
Normal file
43
packages/email/src/templates/_components/button.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Button as ReactEmailButton } from "@react-email/components";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import type { ButtonProps as ReactEmailButtonProps } from "@react-email/components";
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"block rounded-md text-center text-sm font-medium whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground",
|
||||
outline: "border-input bg-background border",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
link: "text-primary underline-offset-4",
|
||||
},
|
||||
size: {
|
||||
default: "px-4 py-2.5",
|
||||
sm: "rounded-md px-3",
|
||||
lg: "rounded-md px-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonProps = ReactEmailButtonProps &
|
||||
VariantProps<typeof buttonVariants>;
|
||||
|
||||
export const Button = ({ variant, size, className, ...props }: ButtonProps) => {
|
||||
return (
|
||||
<ReactEmailButton
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
29
packages/email/src/templates/_components/layout/footer.tsx
Normal file
29
packages/email/src/templates/_components/layout/footer.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Img, Link, Text } from "@react-email/components";
|
||||
|
||||
import { env } from "../../../env";
|
||||
|
||||
interface FooterProps {
|
||||
readonly origin: string;
|
||||
}
|
||||
|
||||
export const Footer = ({ origin }: FooterProps) => {
|
||||
return (
|
||||
<>
|
||||
<Img
|
||||
src={`${origin}/images/logo.png`}
|
||||
alt="Turbostarter Logo"
|
||||
height={45}
|
||||
className="mt-12"
|
||||
/>
|
||||
<Text className="text-muted-foreground max-w-[250px] leading-normal">
|
||||
<Link
|
||||
href="https://turbostarter.dev"
|
||||
className="text-muted-foreground"
|
||||
style={{ textDecoration: "underline" }}
|
||||
>
|
||||
{env.PRODUCT_NAME}
|
||||
</Link>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
16
packages/email/src/templates/_components/layout/header.tsx
Normal file
16
packages/email/src/templates/_components/layout/header.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Img } from "@react-email/components";
|
||||
|
||||
interface HeaderProps {
|
||||
readonly origin: string;
|
||||
}
|
||||
|
||||
export const Header = ({ origin }: HeaderProps) => {
|
||||
return (
|
||||
<Img
|
||||
src={`${origin}/images/logo-text.png`}
|
||||
alt="Turbostarter Logo"
|
||||
className="mb-10"
|
||||
height={45}
|
||||
/>
|
||||
);
|
||||
};
|
||||
91
packages/email/src/templates/_components/layout/layout.tsx
Normal file
91
packages/email/src/templates/_components/layout/layout.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Container,
|
||||
Font,
|
||||
Head,
|
||||
Html,
|
||||
Section,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
|
||||
import { Footer } from "./footer";
|
||||
import { Header } from "./header";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export const Layout = ({
|
||||
children,
|
||||
origin,
|
||||
locale,
|
||||
}: PropsWithChildren<{ origin?: string | null; locale?: string }>) => {
|
||||
return (
|
||||
<Html lang={locale}>
|
||||
<Head>
|
||||
<Font
|
||||
fontFamily="Geist"
|
||||
fallbackFontFamily="Arial"
|
||||
fontWeight={400}
|
||||
fontStyle="normal"
|
||||
webFont={{
|
||||
url: "https://fonts.gstatic.com/s/geist/v3/gyByhwUxId8gMEwYGFWNOITddY4.woff2",
|
||||
format: "woff2",
|
||||
}}
|
||||
/>
|
||||
</Head>
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "#ffffff",
|
||||
foreground: "#09090b",
|
||||
card: {
|
||||
DEFAULT: "#ffffff",
|
||||
foreground: "#09090b",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "#ffffff",
|
||||
foreground: "#09090b",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "#f14704",
|
||||
foreground: "#fff7ed",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "#f4f4f5",
|
||||
foreground: "#18181b",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "#f4f4f5",
|
||||
foreground: "#71717b",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "#f4f4f5",
|
||||
foreground: "#18181b",
|
||||
},
|
||||
success: {
|
||||
DEFAULT: "#4ade80",
|
||||
foreground: "#09090b",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "#e7000b",
|
||||
foreground: "#fff7ed",
|
||||
},
|
||||
border: "#e4e4e7",
|
||||
input: "#e4e4e7",
|
||||
ring: "#f14704",
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Section className="p-1">
|
||||
<Container className="bg-card text-card-foreground rounded-lg p-6">
|
||||
{origin && <Header origin={origin} />}
|
||||
{children}
|
||||
{origin && <Footer origin={origin} />}
|
||||
</Container>
|
||||
</Section>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
67
packages/email/src/templates/auth/change-email.tsx
Normal file
67
packages/email/src/templates/auth/change-email.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Heading, Preview, Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
import { Trans } from "@turbostarter/i18n";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { getOrigin } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Button } from "../_components/button";
|
||||
import { Layout } from "../_components/layout/layout";
|
||||
|
||||
import type {
|
||||
EmailVariables,
|
||||
EmailTemplate,
|
||||
CommonEmailProps,
|
||||
} from "../../types";
|
||||
|
||||
type Props = EmailVariables[typeof EmailTemplate.CHANGE_EMAIL] &
|
||||
CommonEmailProps;
|
||||
|
||||
export const ChangeEmail = async ({ url, locale, newEmail }: Props) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
const origin = getOrigin(url);
|
||||
|
||||
return (
|
||||
<Layout origin={origin} locale={locale}>
|
||||
<Preview>{t("account.email.change.email.preview", { newEmail })}</Preview>
|
||||
<Heading className="leading-tight tracking-tight">
|
||||
{t("account.email.change.email.subject")}
|
||||
</Heading>
|
||||
|
||||
<Text>
|
||||
<Trans
|
||||
ns="auth"
|
||||
i18nKey="account.email.change.email.body"
|
||||
values={{ newEmail }}
|
||||
components={{
|
||||
bold: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Button href={url}>{t("account.email.change.email.cta")}</Button>
|
||||
|
||||
<Text>{t("account.email.change.email.or")}</Text>
|
||||
|
||||
<code className="border-border bg-muted inline-block rounded-md border border-solid px-5 py-3.5 font-mono text-xs">
|
||||
{url}
|
||||
</code>
|
||||
|
||||
<Text className="text-muted-foreground">
|
||||
{t("account.email.change.email.disclaimer")}
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
ChangeEmail.subject = async ({ locale }: CommonEmailProps) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
return t("account.email.change.email.subject");
|
||||
};
|
||||
|
||||
ChangeEmail.PreviewProps = {
|
||||
url: "http://localhost:3000/api/auth/verify-email?token=123&callbackURL=/dashboard/settings",
|
||||
locale: "en",
|
||||
newEmail: "john@doe.com",
|
||||
};
|
||||
|
||||
export default ChangeEmail;
|
||||
57
packages/email/src/templates/auth/confirm-email.tsx
Normal file
57
packages/email/src/templates/auth/confirm-email.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Heading, Preview, Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { getOrigin } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Button } from "../_components/button";
|
||||
import { Layout } from "../_components/layout/layout";
|
||||
|
||||
import type {
|
||||
EmailVariables,
|
||||
EmailTemplate,
|
||||
CommonEmailProps,
|
||||
} from "../../types";
|
||||
|
||||
type Props = EmailVariables[typeof EmailTemplate.CONFIRM_EMAIL] &
|
||||
CommonEmailProps;
|
||||
|
||||
export const ConfirmEmail = async ({ url, locale }: Props) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
const origin = getOrigin(url);
|
||||
|
||||
return (
|
||||
<Layout origin={origin} locale={locale}>
|
||||
<Preview>{t("account.email.confirm.email.preview")}</Preview>
|
||||
<Heading className="leading-tight tracking-tight">
|
||||
{t("account.email.confirm.email.subject")}
|
||||
</Heading>
|
||||
|
||||
<Text>{t("account.email.confirm.email.body")}</Text>
|
||||
|
||||
<Button href={url}>{t("account.email.confirm.email.cta")}</Button>
|
||||
|
||||
<Text>{t("account.email.confirm.email.or")}</Text>
|
||||
|
||||
<code className="border-border bg-muted inline-block rounded-md border border-solid px-5 py-3.5 font-mono text-xs">
|
||||
{url}
|
||||
</code>
|
||||
|
||||
<Text className="text-muted-foreground">
|
||||
{t("account.email.confirm.email.disclaimer")}
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
ConfirmEmail.subject = async ({ locale }: CommonEmailProps) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
return t("account.email.confirm.email.subject");
|
||||
};
|
||||
|
||||
ConfirmEmail.PreviewProps = {
|
||||
url: "http://localhost:3000/api/auth/verify-email?token=123&callbackURL=/dashboard",
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
export default ConfirmEmail;
|
||||
57
packages/email/src/templates/auth/delete-account.tsx
Normal file
57
packages/email/src/templates/auth/delete-account.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Heading, Preview, Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { getOrigin } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Button } from "../_components/button";
|
||||
import { Layout } from "../_components/layout/layout";
|
||||
|
||||
import type {
|
||||
EmailTemplate,
|
||||
EmailVariables,
|
||||
CommonEmailProps,
|
||||
} from "../../types";
|
||||
|
||||
type Props = EmailVariables[typeof EmailTemplate.DELETE_ACCOUNT] &
|
||||
CommonEmailProps;
|
||||
|
||||
export const DeleteAccount = async ({ url, locale }: Props) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
const origin = getOrigin(url);
|
||||
|
||||
return (
|
||||
<Layout origin={origin} locale={locale}>
|
||||
<Preview>{t("account.delete.email.preview")}</Preview>
|
||||
<Heading className="leading-tight tracking-tight">
|
||||
{t("account.delete.email.subject")}
|
||||
</Heading>
|
||||
|
||||
<Text>{t("account.delete.email.body")}</Text>
|
||||
|
||||
<Button href={url}>{t("account.delete.email.cta")}</Button>
|
||||
|
||||
<Text>{t("account.delete.email.or")}</Text>
|
||||
|
||||
<code className="border-border bg-muted inline-block rounded-md border border-solid px-5 py-3.5 font-mono text-xs">
|
||||
{url}
|
||||
</code>
|
||||
|
||||
<Text className="text-muted-foreground">
|
||||
{t("account.delete.email.disclaimer")}
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteAccount.subject = async ({ locale }: CommonEmailProps) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
return t("account.delete.email.subject");
|
||||
};
|
||||
|
||||
DeleteAccount.PreviewProps = {
|
||||
url: "http://localhost:3000/api/auth/delete-user/callback?token=123&callbackURL=/",
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
export default DeleteAccount;
|
||||
56
packages/email/src/templates/auth/magic-link.tsx
Normal file
56
packages/email/src/templates/auth/magic-link.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Heading, Preview, Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { getOrigin } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Button } from "../_components/button";
|
||||
import { Layout } from "../_components/layout/layout";
|
||||
|
||||
import type {
|
||||
EmailVariables,
|
||||
EmailTemplate,
|
||||
CommonEmailProps,
|
||||
} from "../../types";
|
||||
|
||||
type Props = EmailVariables[typeof EmailTemplate.MAGIC_LINK] & CommonEmailProps;
|
||||
|
||||
export const MagicLink = async ({ url, locale }: Props) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
const origin = getOrigin(url);
|
||||
|
||||
return (
|
||||
<Layout origin={origin} locale={locale}>
|
||||
<Preview>{t("login.magicLink.email.preview")}</Preview>
|
||||
<Heading className="leading-tight tracking-tight">
|
||||
{t("login.magicLink.email.subject")}
|
||||
</Heading>
|
||||
|
||||
<Text>{t("login.magicLink.email.body")}</Text>
|
||||
|
||||
<Button href={url}>{t("login.magicLink.email.cta")}</Button>
|
||||
|
||||
<Text>{t("login.magicLink.email.or")}</Text>
|
||||
|
||||
<code className="border-border bg-muted inline-block rounded-md border border-solid px-5 py-3.5 font-mono text-xs">
|
||||
{url}
|
||||
</code>
|
||||
|
||||
<Text className="text-muted-foreground">
|
||||
{t("login.magicLink.email.disclaimer")}
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
MagicLink.subject = async ({ locale }: CommonEmailProps) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
return t("login.magicLink.email.subject");
|
||||
};
|
||||
|
||||
MagicLink.PreviewProps = {
|
||||
url: "http://localhost:3000/api/auth/magic-link/verify?token=123&callbackURL=/dashboard",
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
export default MagicLink;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Heading, Preview, Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
import { Trans } from "@turbostarter/i18n";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { getOrigin } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Button } from "../_components/button";
|
||||
import { Layout } from "../_components/layout/layout";
|
||||
|
||||
import type {
|
||||
EmailVariables,
|
||||
EmailTemplate,
|
||||
CommonEmailProps,
|
||||
} from "../../types";
|
||||
|
||||
type Props = EmailVariables[typeof EmailTemplate.ORGANIZATION_INVITATION] &
|
||||
CommonEmailProps;
|
||||
|
||||
export const OrganizationInvitation = async ({
|
||||
url,
|
||||
inviter,
|
||||
organization,
|
||||
locale,
|
||||
}: Props) => {
|
||||
const { t } = await getTranslation({ locale, ns: "organization" });
|
||||
const origin = getOrigin(url);
|
||||
|
||||
return (
|
||||
<Layout origin={origin} locale={locale}>
|
||||
<Preview>{t("members.invite.email.preview", { inviter })}</Preview>
|
||||
<Heading className="leading-tight tracking-tight">
|
||||
{t("members.invite.email.subject")}
|
||||
</Heading>
|
||||
|
||||
<Text>
|
||||
<Trans
|
||||
i18nKey="members.invite.email.body"
|
||||
ns="organization"
|
||||
values={{ inviter, organization }}
|
||||
components={{
|
||||
bold: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Button href={url}>
|
||||
{t("members.invite.email.cta", { organization })}
|
||||
</Button>
|
||||
|
||||
<Text>{t("members.invite.email.or")}</Text>
|
||||
|
||||
<code className="border-border bg-muted inline-block rounded-md border border-solid px-5 py-3.5 font-mono text-xs">
|
||||
{url}
|
||||
</code>
|
||||
|
||||
<Text className="text-muted-foreground">
|
||||
{t("members.invite.email.disclaimer")}
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
OrganizationInvitation.subject = async ({ locale }: CommonEmailProps) => {
|
||||
const { t } = await getTranslation({ locale, ns: "organization" });
|
||||
return t("members.invite.email.subject");
|
||||
};
|
||||
|
||||
OrganizationInvitation.PreviewProps = {
|
||||
url: "http://localhost:3000/auth/join?invitationId=h4zI2pKJrQkP5NQljdA8W57wG0V8LHrv",
|
||||
locale: "en",
|
||||
inviter: "John Doe",
|
||||
organization: "Acme Inc",
|
||||
};
|
||||
|
||||
export default OrganizationInvitation;
|
||||
57
packages/email/src/templates/auth/reset-password.tsx
Normal file
57
packages/email/src/templates/auth/reset-password.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Heading, Preview, Text } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { getOrigin } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Button } from "../_components/button";
|
||||
import { Layout } from "../_components/layout/layout";
|
||||
|
||||
import type {
|
||||
EmailVariables,
|
||||
EmailTemplate,
|
||||
CommonEmailProps,
|
||||
} from "../../types";
|
||||
|
||||
type Props = EmailVariables[typeof EmailTemplate.CONFIRM_EMAIL] &
|
||||
CommonEmailProps;
|
||||
|
||||
export const ResetPassword = async ({ url, locale }: Props) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
const origin = getOrigin(url);
|
||||
|
||||
return (
|
||||
<Layout origin={origin}>
|
||||
<Preview>{t("account.password.update.email.preview")}</Preview>
|
||||
<Heading className="leading-tight tracking-tight">
|
||||
{t("account.password.update.email.subject")}
|
||||
</Heading>
|
||||
|
||||
<Text>{t("account.password.update.email.body")}</Text>
|
||||
|
||||
<Button href={url}>{t("account.password.update.email.cta")}</Button>
|
||||
|
||||
<Text>{t("account.password.update.email.or")}</Text>
|
||||
|
||||
<code className="border-border bg-muted inline-block rounded-md border border-solid px-5 py-3.5 font-mono text-xs">
|
||||
{url}
|
||||
</code>
|
||||
|
||||
<Text className="text-muted-foreground">
|
||||
{t("account.password.update.email.disclaimer")}
|
||||
</Text>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
ResetPassword.subject = async ({ locale }: CommonEmailProps) => {
|
||||
const { t } = await getTranslation({ locale, ns: "auth" });
|
||||
return t("account.password.update.email.subject");
|
||||
};
|
||||
|
||||
ResetPassword.PreviewProps = {
|
||||
url: "http://localhost:3000/api/auth/reset-password/KwiyWf9xsTrfndZY5a0stg4p?callbackURL=/auth/password/update",
|
||||
locale: "en",
|
||||
};
|
||||
|
||||
export default ResetPassword;
|
||||
45
packages/email/src/templates/contact-form.tsx
Normal file
45
packages/email/src/templates/contact-form.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Heading, Preview, Row, Column } from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
|
||||
import { Layout } from "./_components/layout/layout";
|
||||
|
||||
import type { EmailTemplate } from "../types";
|
||||
import type { EmailVariables } from "../types";
|
||||
|
||||
type Props = EmailVariables[typeof EmailTemplate.CONTACT_FORM];
|
||||
|
||||
export const ContactForm = async (props: Props) => {
|
||||
const { t } = await getTranslation({ ns: "marketing" });
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Preview>{t("contact.email.subject")}</Preview>
|
||||
<Heading className="leading-tight tracking-tight">
|
||||
{t("contact.email.body")}
|
||||
</Heading>
|
||||
|
||||
{Object.entries(props).map(([key, value]) => (
|
||||
<Row key={key}>
|
||||
<Column>
|
||||
<strong>{key}</strong>: {value}
|
||||
</Column>
|
||||
</Row>
|
||||
))}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
ContactForm.subject = async () => {
|
||||
const { t } = await getTranslation({ ns: "marketing" });
|
||||
return t("contact.email.subject");
|
||||
};
|
||||
|
||||
ContactForm.PreviewProps = {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
message: "Hello, I'm interested in your services.",
|
||||
};
|
||||
|
||||
export default ContactForm;
|
||||
54
packages/email/src/templates/index.ts
Normal file
54
packages/email/src/templates/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
import { EmailTemplate } from "../types";
|
||||
|
||||
import ChangeEmail from "./auth/change-email";
|
||||
import { ConfirmEmail } from "./auth/confirm-email";
|
||||
import DeleteAccount from "./auth/delete-account";
|
||||
import { MagicLink } from "./auth/magic-link";
|
||||
import { OrganizationInvitation } from "./auth/organization-invitation";
|
||||
import { ResetPassword } from "./auth/reset-password";
|
||||
import ContactForm from "./contact-form";
|
||||
|
||||
import type { CommonEmailProps, EmailVariables } from "../types";
|
||||
|
||||
interface EmailTemplateComponent<T extends EmailTemplate> {
|
||||
(
|
||||
props: EmailVariables[T] & CommonEmailProps,
|
||||
): Promise<React.ReactElement> | React.ReactElement;
|
||||
subject: ((props: CommonEmailProps) => Promise<string> | string) | string;
|
||||
}
|
||||
|
||||
export const templates: {
|
||||
[K in EmailTemplate]: EmailTemplateComponent<K>;
|
||||
} = {
|
||||
[EmailTemplate.RESET_PASSWORD]: ResetPassword,
|
||||
[EmailTemplate.MAGIC_LINK]: MagicLink,
|
||||
[EmailTemplate.CONFIRM_EMAIL]: ConfirmEmail,
|
||||
[EmailTemplate.DELETE_ACCOUNT]: DeleteAccount,
|
||||
[EmailTemplate.CHANGE_EMAIL]: ChangeEmail,
|
||||
[EmailTemplate.ORGANIZATION_INVITATION]: OrganizationInvitation,
|
||||
[EmailTemplate.CONTACT_FORM]: ContactForm,
|
||||
} as const;
|
||||
|
||||
export const getTemplate = async <T extends EmailTemplate>({
|
||||
id,
|
||||
locale,
|
||||
variables,
|
||||
}: {
|
||||
id: T;
|
||||
variables: EmailVariables[T];
|
||||
locale?: string;
|
||||
}) => {
|
||||
const template = templates[id];
|
||||
const subject =
|
||||
typeof template.subject === "function"
|
||||
? await template.subject({ locale })
|
||||
: template.subject;
|
||||
const email = await template({ ...variables, locale });
|
||||
|
||||
const html = await render(email);
|
||||
const text = await render(email, { plainText: true });
|
||||
|
||||
return { html, text, subject };
|
||||
};
|
||||
11
packages/email/src/types/index.ts
Normal file
11
packages/email/src/types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const EmailProvider = {
|
||||
RESEND: "resend",
|
||||
PLUNK: "plunk",
|
||||
POSTMARK: "postmark",
|
||||
NODEMAILER: "nodemailer",
|
||||
SENDGRID: "sendgrid",
|
||||
} as const;
|
||||
|
||||
export type EmailProvider = (typeof EmailProvider)[keyof typeof EmailProvider];
|
||||
|
||||
export * from "./templates";
|
||||
46
packages/email/src/types/templates.ts
Normal file
46
packages/email/src/types/templates.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface CommonEmailProps {
|
||||
readonly locale?: string;
|
||||
}
|
||||
|
||||
const AuthEmailTemplate = {
|
||||
RESET_PASSWORD: "reset-password",
|
||||
MAGIC_LINK: "magic-link",
|
||||
CONFIRM_EMAIL: "confirm-email",
|
||||
DELETE_ACCOUNT: "delete-account",
|
||||
CHANGE_EMAIL: "change-email",
|
||||
ORGANIZATION_INVITATION: "organization-invitation",
|
||||
} as const;
|
||||
|
||||
type AuthEmailTemplate =
|
||||
(typeof AuthEmailTemplate)[keyof typeof AuthEmailTemplate];
|
||||
|
||||
type AuthEmailVariables = Record<
|
||||
Exclude<AuthEmailTemplate, typeof EmailTemplate.ORGANIZATION_INVITATION>,
|
||||
{
|
||||
url: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export const EmailTemplate = {
|
||||
...AuthEmailTemplate,
|
||||
CONTACT_FORM: "contact-form",
|
||||
} as const;
|
||||
|
||||
export type EmailTemplate = (typeof EmailTemplate)[keyof typeof EmailTemplate];
|
||||
|
||||
export type EmailVariables = AuthEmailVariables & {
|
||||
[EmailTemplate.CHANGE_EMAIL]: {
|
||||
url: string;
|
||||
newEmail: string;
|
||||
};
|
||||
[EmailTemplate.ORGANIZATION_INVITATION]: {
|
||||
url: string;
|
||||
inviter: string;
|
||||
organization: string;
|
||||
};
|
||||
[EmailTemplate.CONTACT_FORM]: {
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
19
packages/email/src/utils/env.ts
Normal file
19
packages/email/src/utils/env.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineEnv } from "envin";
|
||||
import * as z from "zod";
|
||||
|
||||
import { envConfig } from "@turbostarter/shared/constants";
|
||||
|
||||
import type { Preset } from "envin/types";
|
||||
|
||||
export const sharedPreset = {
|
||||
id: "shared",
|
||||
server: {
|
||||
EMAIL_FROM: z.string().optional().default("noreply@example.com"),
|
||||
// Default kept intentionally — runtime validation warns if not overridden
|
||||
},
|
||||
} as const satisfies Preset;
|
||||
|
||||
export const sharedEnv = defineEnv({
|
||||
...envConfig,
|
||||
...sharedPreset,
|
||||
});
|
||||
Reference in New Issue
Block a user