This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

3
packages/mail/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.vercel
.react-email
out

3
packages/mail/biome.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": ["../../biome.json"]
}

View file

@ -0,0 +1,48 @@
import { Link, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import PrimaryButton from "../src/components/PrimaryButton";
import Wrapper from "../src/components/Wrapper";
import { defaultLocale, defaultTranslations } from "../src/util/translations";
import type { BaseMailProps } from "../types";
export function EmailVerification({
url,
name,
locale,
translations,
}: {
url: string;
name: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<Wrapper>
<Text>{t("mail.emailVerification.body", { name })}</Text>
<PrimaryButton href={url}>
{t("mail.emailVerification.confirmEmail")} &rarr;
</PrimaryButton>
<Text className="text-muted-foreground text-sm">
{t("mail.common.openLinkInBrowser")}
<Link href={url} className="break-all">
{url}
</Link>
</Text>
</Wrapper>
);
}
EmailVerification.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
url: "#",
name: "John Doe",
};
export default EmailVerification;

View file

@ -0,0 +1,46 @@
import { Link, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import PrimaryButton from "../src/components/PrimaryButton";
import Wrapper from "../src/components/Wrapper";
import { defaultLocale } from "../src/util/translations";
import { defaultTranslations } from "../src/util/translations";
import type { BaseMailProps } from "../types";
export function ForgotPassword({
url,
locale,
translations,
}: {
url: string;
name: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<Wrapper>
<Text>{t("mail.forgotPassword.body")}</Text>
<PrimaryButton href={url}>
{t("mail.forgotPassword.resetPassword")} &rarr;
</PrimaryButton>
<Text className="text-muted-foreground text-sm">
{t("mail.common.openLinkInBrowser")}
<Link href={url}>{url}</Link>
</Text>
</Wrapper>
);
}
ForgotPassword.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
url: "#",
name: "John Doe",
};
export default ForgotPassword;

View file

@ -0,0 +1,46 @@
import { Link, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import PrimaryButton from "../src/components/PrimaryButton";
import Wrapper from "../src/components/Wrapper";
import { defaultTranslations } from "../src/util/translations";
import { defaultLocale } from "../src/util/translations";
import type { BaseMailProps } from "../types";
export function MagicLink({
url,
locale,
translations,
}: {
url: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<Wrapper>
<Text>{t("mail.magicLink.body")}</Text>
<Text>{t("mail.common.useLink")}</Text>
<PrimaryButton href={url}>
{t("mail.magicLink.login")} &rarr;
</PrimaryButton>
<Text className="text-muted-foreground text-sm">
{t("mail.common.openLinkInBrowser")}
<Link href={url}>{url}</Link>
</Text>
</Wrapper>
);
}
MagicLink.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
url: "#",
};
export default MagicLink;

View file

@ -0,0 +1,58 @@
import { Link, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import PrimaryButton from "../src/components/PrimaryButton";
import Wrapper from "../src/components/Wrapper";
import { defaultTranslations } from "../src/util/translations";
import { defaultLocale } from "../src/util/translations";
import type { BaseMailProps } from "../types";
export function NewUser({
url,
name,
otp,
locale,
translations,
}: {
url: string;
name: string;
otp: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<Wrapper>
<Text>{t("mail.newUser.body", { name })}</Text>
<Text>
{t("mail.common.otp")}
<br />
<strong className="font-bold text-2xl">{otp}</strong>
</Text>
<Text>{t("mail.common.useLink")}</Text>
<PrimaryButton href={url}>
{t("mail.newUser.confirmEmail")} &rarr;
</PrimaryButton>
<Text className="text-muted-foreground text-sm">
{t("mail.common.openLinkInBrowser")}
<Link href={url}>{url}</Link>
</Text>
</Wrapper>
);
}
NewUser.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
url: "#",
name: "John Doe",
otp: "123456",
};
export default NewUser;

View file

@ -0,0 +1,30 @@
import { Heading, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import Wrapper from "../src/components/Wrapper";
import { defaultTranslations } from "../src/util/translations";
import { defaultLocale } from "../src/util/translations";
import type { BaseMailProps } from "../types";
export function NewsletterSignup({ locale, translations }: BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<Wrapper>
<Heading className="text-xl">
{t("mail.newsletterSignup.subject")}
</Heading>
<Text>{t("mail.newsletterSignup.body")}</Text>
</Wrapper>
);
}
NewsletterSignup.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
};
export default NewsletterSignup;

View file

@ -0,0 +1,55 @@
import { Heading, Link, Text } from "@react-email/components";
import React from "react";
import { createTranslator } from "use-intl/core";
import PrimaryButton from "../src/components/PrimaryButton";
import Wrapper from "../src/components/Wrapper";
import { defaultLocale } from "../src/util/translations";
import { defaultTranslations } from "../src/util/translations";
import type { BaseMailProps } from "../types";
export function OrganizationInvitation({
url,
organizationName,
locale,
translations,
}: {
url: string;
organizationName: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages: translations,
});
return (
<Wrapper>
<Heading className="text-xl">
{t.markup("mail.organizationInvitation.headline", {
organizationName,
strong: (chunks) => `<strong>${chunks}</strong>`,
})}
</Heading>
<Text>
{t("mail.organizationInvitation.body", { organizationName })}
</Text>
<PrimaryButton href={url}>
{t("mail.organizationInvitation.join")}
</PrimaryButton>
<Text className="mt-4 text-muted-foreground text-sm">
{t("mail.common.openLinkInBrowser")}
<Link href={url}>{url}</Link>
</Text>
</Wrapper>
);
}
OrganizationInvitation.PreviewProps = {
locale: defaultLocale,
translations: defaultTranslations,
url: "#",
organizationName: "Acme",
};
export default OrganizationInvitation;

View file

@ -0,0 +1,15 @@
import { EmailVerification } from "../emails/EmailVerification";
import { ForgotPassword } from "../emails/ForgotPassword";
import { MagicLink } from "../emails/MagicLink";
import { NewUser } from "../emails/NewUser";
import { NewsletterSignup } from "../emails/NewsletterSignup";
import { OrganizationInvitation } from "../emails/OrganizationInvitation";
export const mailTemplates = {
magicLink: MagicLink,
forgotPassword: ForgotPassword,
newUser: NewUser,
newsletterSignup: NewsletterSignup,
organizationInvitation: OrganizationInvitation,
emailVerification: EmailVerification,
} as const;

5
packages/mail/global.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
import type { Messages } from "@repo/i18n/types";
declare global {
interface IntlMessages extends Messages {}
}

1
packages/mail/index.ts Normal file
View file

@ -0,0 +1 @@
export { sendEmail } from "./src/util/send";

View file

@ -0,0 +1,32 @@
{
"dependencies": {
"@react-email/components": "^0.0.41",
"@react-email/render": "^1.1.2",
"@repo/config": "workspace:*",
"@repo/i18n": "workspace:*",
"@repo/logs": "workspace:*",
"next-intl": "4.1.0",
"nodemailer": "^7.0.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-email": "^4.0.16",
"use-intl": "^4.1.0"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@repo/tsconfig": "workspace:*",
"@tailwindcss/line-clamp": "^0.4.4",
"@types/node": "22.15.30",
"@types/nodemailer": "^6.4.17",
"@types/react": "19.1.6"
},
"main": "./index.ts",
"name": "@repo/mail",
"scripts": {
"export": "email export",
"preview": "email dev --port 3005",
"type-check": "tsc --noEmit"
},
"types": "./index.ts",
"version": "0.0.0"
}

View file

@ -0,0 +1,32 @@
import React from "react";
export function Logo({
withLabel = true,
}: {
withLabel?: boolean;
}) {
return (
<span className="flex items-center font-semibold text-primary leading-none">
<svg className="h-12 w-12" viewBox="0 0 734 635">
<title>acme</title>
<path
opacity="0.2"
d="M282.102 232.435C328.904 205.42 404.785 205.42 451.588 232.435L697.946 374.634C744.748 401.648 744.748 445.447 697.946 472.462L451.588 614.661C404.785 641.676 328.904 641.676 282.102 614.661L35.7432 472.462C-11.059 445.447 -11.0589 401.648 35.7432 374.634L282.102 232.435Z"
fill="currentColor"
/>
<path
opacity="0.4"
d="M282.102 126.674C328.904 99.66 404.785 99.66 451.588 126.674L697.946 268.874C744.748 295.888 744.748 339.687 697.946 366.702L451.588 508.901C404.785 535.915 328.904 535.915 282.102 508.901L35.7432 366.702C-11.059 339.687 -11.0589 295.888 35.7432 268.874L282.102 126.674Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M451.588 20.9141C404.785 -6.10027 328.904 -6.1003 282.102 20.9141L35.7432 163.113C-11.0589 190.128 -11.059 233.927 35.7432 260.941L282.102 403.141C328.904 430.155 404.785 430.155 451.588 403.141L697.946 260.941C744.748 233.927 744.748 190.128 697.946 163.113L451.588 20.9141ZM497.704 114.921C499.134 115.855 500.121 117.04 500.545 118.332C505.138 132.238 505.138 143.12 505.072 154.003C505.072 198.349 468.453 225.167 420.48 245.161V290.25C420.485 294.097 418.849 297.868 415.755 301.141C412.662 304.413 408.233 307.058 402.967 308.777L337.739 330.105C335.32 330.893 332.634 331.263 329.935 331.181C327.236 331.1 324.613 330.569 322.316 329.64C320.019 328.71 318.124 327.412 316.809 325.87C315.495 324.327 314.806 322.591 314.806 320.825V275.982L299.957 285.686C297.993 286.969 295.661 287.987 293.095 288.682C290.529 289.377 287.779 289.734 285.001 289.734C282.223 289.734 279.473 289.377 276.907 288.682C274.341 287.987 272.009 286.969 270.045 285.686L236.407 263.7C232.442 261.109 230.214 257.594 230.214 253.93C230.214 250.265 232.442 246.751 236.407 244.159L251.257 234.456H182.678C179.975 234.457 177.316 234.006 174.955 233.147C172.593 232.288 170.607 231.049 169.184 229.547C167.761 228.046 166.949 226.331 166.825 224.567C166.701 222.803 167.269 221.047 168.475 219.466L201.136 176.8C203.771 173.364 207.817 170.475 212.82 168.455C217.823 166.435 223.587 165.364 229.468 165.36H298.331C328.857 133.922 369.765 110.084 437.967 110.084C454.555 110.084 471.202 110.084 492.483 113.064C494.46 113.341 496.273 113.986 497.704 114.921ZM405.86 179.723C410.207 181.621 415.318 182.634 420.546 182.634C427.557 182.634 434.281 180.814 439.239 177.575C444.196 174.335 446.981 169.942 446.981 165.36C446.981 161.944 445.431 158.604 442.526 155.763C439.622 152.923 435.493 150.709 430.663 149.401C425.832 148.094 420.517 147.752 415.389 148.418C410.261 149.085 405.551 150.73 401.854 153.146C398.157 155.562 395.639 158.64 394.619 161.99C393.599 165.341 394.123 168.814 396.124 171.971C398.124 175.127 401.513 177.825 405.86 179.723Z"
fill="currentColor"
/>
</svg>
{withLabel && <span className="ml-3 text-xl">acme</span>}
</span>
);
}

View file

@ -0,0 +1,18 @@
import { Button } from "@react-email/components";
import React, { type PropsWithChildren } from "react";
export default function PrimaryButton({
href,
children,
}: PropsWithChildren<{
href: string;
}>) {
return (
<Button
href={href}
className="rounded-full bg-primary px-4 py-2 text-lg text-primary-foreground"
>
{children}
</Button>
);
}

View file

@ -0,0 +1,57 @@
import {
Container,
Font,
Head,
Html,
Section,
Tailwind,
} from "@react-email/components";
import React, { type PropsWithChildren } from "react";
import { Logo } from "./Logo";
export default function Wrapper({ children }: PropsWithChildren) {
return (
<Html lang="en">
<Head>
<Font
fontFamily="Inter"
fallbackFontFamily="Arial"
fontWeight={400}
fontStyle="normal"
/>
</Head>
<Tailwind
config={{
theme: {
extend: {
colors: {
border: "#e3ebf6",
background: "#fafafe",
foreground: "#292b35",
primary: {
DEFAULT: "#4e6df5",
foreground: "#f6f7f9",
},
secondary: {
DEFAULT: "#292b35",
foreground: "#ffffff",
},
card: {
DEFAULT: "#ffffff",
foreground: "#292b35",
},
},
},
},
}}
>
<Section className="bg-background p-4">
<Container className="rounded-lg bg-card p-6 text-card-foreground">
<Logo />
{children}
</Container>
</Section>
</Tailwind>
</Html>
);
}

View file

@ -0,0 +1,10 @@
import { logger } from "@repo/logs";
import type { SendEmailHandler } from "../../types";
export const send: SendEmailHandler = async ({ to, subject, text }) => {
let formattedOutput = `Sending email to ${to} with subject ${subject}\n\n`;
formattedOutput += `Text: ${text}\n\n`;
logger.log(formattedOutput);
};

View file

@ -0,0 +1,9 @@
import { config } from "@repo/config";
import type { SendEmailHandler } from "../../types";
const { from } = config.mails;
// biome-ignore lint/correctness/noUnusedFunctionParameters: This is to understand the available parameters
export const send: SendEmailHandler = async ({ to, subject, text, html }) => {
// handle your custom email sending logic here
};

View file

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

View file

@ -0,0 +1,42 @@
import { config } from "@repo/config";
import { logger } from "@repo/logs";
import type { SendEmailHandler } from "../../types";
const { from } = config.mails;
const mailgunDomain = process.env.MAILGUN_DOMAIN as string;
const mailgunApiKey = process.env.MAILGUN_API_KEY as string;
export const send: SendEmailHandler = async ({ to, subject, html, text }) => {
if (!mailgunDomain || !mailgunApiKey) {
throw new Error("MAILGUN_DOMAIN and MAILGUN_API_KEY must be set");
}
const body = new FormData();
body.append("from", from);
body.append("to", to);
body.append("subject", subject);
body.append("text", text);
if (html) {
body.append("html", html);
}
const response = await fetch(
`https://api.mailgun.net/v3/${mailgunDomain}/messages`,
{
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(
`api:${mailgunApiKey}`,
).toString("base64")}`,
},
body,
},
);
if (!response.ok) {
logger.error(await response.text());
throw new Error("Could not send email");
}
};

View file

@ -0,0 +1,24 @@
import { config } from "@repo/config";
import nodemailer from "nodemailer";
import type { SendEmailHandler } from "../../types";
const { from } = config.mails;
export const send: SendEmailHandler = async ({ to, subject, text, html }) => {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST as string,
port: Number.parseInt(process.env.MAIL_PORT as string, 10),
auth: {
user: process.env.MAIL_USER as string,
pass: process.env.MAIL_PASS as string,
},
});
await transporter.sendMail({
to,
from,
subject,
text,
html,
});
};

View file

@ -0,0 +1,24 @@
import { logger } from "@repo/logs";
import type { SendEmailHandler } from "../../types";
export const send: SendEmailHandler = async ({ to, subject, html, text }) => {
const response = await fetch("https://api.useplunk.com/v1/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.PLUNK_API_KEY}`,
},
body: JSON.stringify({
to,
subject,
body: html,
text,
}),
});
if (!response.ok) {
logger.error(await response.json());
throw new Error("Could not send email");
}
};

View file

@ -0,0 +1,29 @@
import { config } from "@repo/config";
import { logger } from "@repo/logs";
import type { SendEmailHandler } from "../../types";
const { from } = config.mails;
export const send: SendEmailHandler = async ({ to, subject, html }) => {
const response = await fetch("https://api.postmarkapp.com/email", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Postmark-Server-Token": process.env
.POSTMARK_SERVER_TOKEN as string,
},
body: JSON.stringify({
From: from,
To: to,
Subject: subject,
HtmlBody: html,
MessageStream: "outbound",
}),
});
if (!response.ok) {
logger.error(await response.json());
throw new Error("Could not send email");
}
};

View file

@ -0,0 +1,26 @@
import { config } from "@repo/config";
import { logger } from "@repo/logs";
import type { SendEmailHandler } from "../../types";
const { from } = config.mails;
export const send: SendEmailHandler = async ({ to, subject, html }) => {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
},
body: JSON.stringify({
from,
to,
subject,
html,
}),
});
if (!response.ok) {
logger.error(await response.json());
throw new Error("Could not send email");
}
};

View file

@ -0,0 +1,61 @@
import { config } from "@repo/config";
import { logger } from "@repo/logs";
import type { mailTemplates } from "../../emails";
import { send } from "../provider";
import type { TemplateId } from "./templates";
import { getTemplate } from "./templates";
export async function sendEmail<T extends TemplateId>(
params: {
to: string;
locale?: keyof typeof config.i18n.locales;
} & (
| {
templateId: T;
context: Omit<
Parameters<(typeof mailTemplates)[T]>[0],
"locale" | "translations"
>;
}
| {
subject: string;
text?: string;
html?: string;
}
),
) {
const { to, locale = config.i18n.defaultLocale } = params;
let html: string;
let text: string;
let subject: string;
if ("templateId" in params) {
const { templateId, context } = params;
const template = await getTemplate({
templateId,
context,
locale,
});
subject = template.subject;
text = template.text;
html = template.html;
} else {
subject = params.subject;
text = params.text ?? "";
html = params.html ?? "";
}
try {
await send({
to,
subject,
text,
html,
});
return true;
} catch (e) {
logger.error(e);
return false;
}
}

View file

@ -0,0 +1,37 @@
import { render } from "@react-email/render";
import { getMessagesForLocale } from "@repo/i18n";
import type { Locale, Messages } from "@repo/i18n";
import { mailTemplates } from "../../emails";
export async function getTemplate<T extends TemplateId>({
templateId,
context,
locale,
}: {
templateId: T;
context: Omit<
Parameters<(typeof mailTemplates)[T]>[0],
"locale" | "translations"
>;
locale: Locale;
}) {
const template = mailTemplates[templateId];
const translations = await getMessagesForLocale(locale);
const email = template({
...(context as any),
locale,
translations,
});
const subject =
"subject" in translations.mail[templateId as keyof Messages["mail"]]
? translations.mail[templateId].subject
: "";
const html = await render(email);
const text = await render(email, { plainText: true });
return { html, text, subject };
}
export type TemplateId = keyof typeof mailTemplates;

View file

@ -0,0 +1,6 @@
import { config } from "@repo/config";
const { defaultLocale } = config.i18n;
export { default as defaultTranslations } from "@repo/i18n/translations/en.json";
export { defaultLocale };

View file

@ -0,0 +1,5 @@
{
"extends": "@repo/tsconfig/react-library.json",
"include": ["**/*.ts"],
"exclude": ["dist", "build", "node_modules"]
}

19
packages/mail/types.ts Normal file
View file

@ -0,0 +1,19 @@
import type { Locale } from "@repo/i18n";
export interface SendEmailParams {
to: string;
subject: string;
text: string;
html?: string;
}
export type SendEmailHandler = (params: SendEmailParams) => Promise<void>;
export interface MailProvider {
send: SendEmailHandler;
}
export type BaseMailProps = {
locale: Locale;
translations: any;
};