feat: whyrating - initial project from turbostarter boilerplate

This commit is contained in:
Alejandro Gutiérrez
2026-02-04 01:54:52 +01:00
commit 5cdc07cd39
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
import baseConfig from "@turbostarter/eslint-config/base";
export default baseConfig;

View File

@@ -0,0 +1,38 @@
{
"name": "@turbostarter/email",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./server": "./src/server.ts"
},
"scripts": {
"clean": "git clean -xdf .cache .turbo dist node_modules",
"dev": "dotenv -e ../../apps/web/.env.local -c -- email dev --port 3005 --dir src/templates",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@turbostarter/prettier-config",
"dependencies": {
"@react-email/components": "0.3.2",
"@turbostarter/i18n": "workspace:*",
"@turbostarter/ui": "workspace:*",
"nodemailer": "7.0.11",
"react-email": "4.2.4",
"zod": "catalog:"
},
"devDependencies": {
"@react-email/preview-server": "4.2.4",
"@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*",
"@turbostarter/shared": "workspace:*",
"@turbostarter/tsconfig": "workspace:*",
"@types/nodemailer": "7.0.3",
"eslint": "catalog:",
"prettier": "catalog:",
"typescript": "catalog:"
}
}

21
packages/email/src/env.ts Normal file
View 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,
});

View File

@@ -0,0 +1 @@
export * from "./types";

View File

@@ -0,0 +1 @@
export * from "./resend/env";

View File

@@ -0,0 +1 @@
export { send } from "./resend";

View 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,
});

View 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;

View 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,
});

View 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;

View 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,
});

View 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;

View 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,
});

View 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;

View 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,
});

View 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;

View File

@@ -0,0 +1,8 @@
export interface EmailProviderStrategy {
send: (args: {
to: string;
subject: string;
text: string;
html?: string;
}) => Promise<void>;
}

View 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 };

View 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}
/>
);
};

View 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>
</>
);
};

View 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}
/>
);
};

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View 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;

View 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;

View 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 };
};

View 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";

View 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;
};
};

View 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,
});

View File

@@ -0,0 +1,9 @@
{
"extends": "@turbostarter/tsconfig/internal.json",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "ES2022"],
"jsx": "preserve"
},
"include": ["*.ts", "src/**/*"],
"exclude": ["node_modules"]
}