stack/packages/stack-shared/src/helpers/emails.ts
BilalG1 4899632ea4
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Emulator Test / docker (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Email sending sdk function, freestyle mock, small fixes (#813)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Enhances email sending API with template support, improved error
handling, and new interfaces, while adding comprehensive tests and
updating rendering logic.
> 
>   - **Behavior**:
> - Adds support for sending emails using templates with variables and
optional theming in `send-email/route.tsx`.
> - Introduces per-user notification category checks before sending
emails.
>     - Adds optional unsubscribe link in email themes.
>   - **Error Handling**:
> - Refines error handling in `send-email/route.tsx` for missing
content, non-existent user IDs, and shared email server configurations.
>     - Uses `KnownErrors` for specific error cases.
>   - **API Changes**:
> - Adds new interfaces and methods for email sending in
`server-interface.ts` and `admin-interface.ts`.
>     - Removes deprecated email sending methods from admin interfaces.
>   - **Testing**:
> - Adds e2e tests in `email.test.ts` for various email sending
scenarios, including HTML content, templates, and error cases.
>   - **Misc**:
> - Updates email rendering logic in `email-rendering.tsx` to handle new
template and theme options.
> - Simplifies import statements and cleans up code structure across
multiple files.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for 1d5a056699. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>

----


<!-- ELLIPSIS_HIDDEN -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced email sending to support both raw HTML and template-based
emails with variables and optional theming.
  * Added per-user notification category checks before sending emails.
  * Email themes now support an optional unsubscribe link in the footer.

* **Improvements**
* Updated email rendering to pass the unsubscribe link as a prop to
themes.
  * Refined error handling for email sending.
  * Improved flexibility of email sending options and result reporting.

* **API Changes**
* Introduced new interfaces and methods for sending emails on the server
side, including detailed result reporting.
  * Removed deprecated admin-side email sending methods and interfaces.
  * Added new types for email sending options and results.

* **Bug Fixes**
* Fixed navigation and property naming inconsistencies in dashboard
email template editing and sending flows.

* **Chores**
  * Simplified import statements and cleaned up internal code structure.
* Updated Docker environment for freestyle mock service to use Bun
runtime and adjusted port mappings.

* **Tests**
* Added comprehensive tests covering email sending scenarios, including
error handling and multi-user support.
* Updated existing tests to reflect refined email subjects, template
rendering, and unsubscribe link features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
2025-08-01 18:40:28 -07:00

159 lines
15 KiB
TypeScript

import { deindent } from "../utils/strings";
export const previewTemplateSource = deindent`
import { Heading, Section, Row, Button, Link, Column } from "@react-email/components";
export const variablesSchema = v => v;
export function EmailTemplate() {
return <>
<Heading as="h2" className="mb-4 text-2xl font-bold">
Header text
</Heading>
<Section className="mb-4">
Body text content with some additional information.
</Section>
<Row className="mb-4">
<Column>
<Button href="https://example.com">
A button
</Button>
</Column>
<Column>
<Link href="https://example.com">
A link
</Link>
</Column>
</Row>
</>;
}
`;
export const emptyEmailTheme = deindent`
import { Html, Tailwind, Body } from '@react-email/components';
export function EmailTheme({ children }: { children: React.ReactNode }) {
return (
<Html>
<Tailwind>
<Body>
{children}
</Body>
</Tailwind>
</Html>
);
}
`;
export const LightEmailTheme = `import { Html, Head, Tailwind, Body, Container, Link } from '@react-email/components';
import { ThemeProps } from "@stackframe/emails"
export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
return (
<Html>
<Head />
<Tailwind>
<Body className="bg-[#fafbfb] font-sans text-base">
<Container className="bg-white p-[45px] rounded-lg">
{children}
</Container>
{unsubscribeLink && (
<div className="p-4">
<Link href={unsubscribeLink}>Click here{" "}</Link>
to unsubscribe from these emails
</div>
)}
</Body>
</Tailwind>
</Html>
);
}
EmailTheme.PreviewProps = {
unsubscribeLink: "https://example.com"
} satisfies Partial<ThemeProps>
`;
const DarkEmailTheme = `import { Html, Head, Tailwind, Body, Container, Link } from '@react-email/components';
import { ThemeProps } from "@stackframe/emails"
export function EmailTheme({ children, unsubscribeLink }: ThemeProps) {
return (
<Html>
<Head />
<Tailwind>
<Body className="bg-[#323232] font-sans text-white">
<Container className="bg-black p-[45px] rounded-lg">
{children}
</Container>
{unsubscribeLink && (
<div className="p-4">
<Link href={unsubscribeLink}>Click here{" "}</Link>
to unsubscribe from these emails
</div>
)}
</Body>
</Tailwind>
</Html>
);
}
EmailTheme.PreviewProps = {
unsubscribeLink: "https://example.com"
} satisfies Partial<ThemeProps>
`;
export const DEFAULT_EMAIL_THEME_ID = "1df07ae6-abf3-4a40-83a5-a1a2cbe336ac";
export const DEFAULT_EMAIL_THEMES = {
[DEFAULT_EMAIL_THEME_ID]: {
displayName: 'Default Light',
tsxSource: LightEmailTheme,
},
"a0172b5d-cff0-463b-83bb-85124697373a": {
displayName: 'Default Dark',
tsxSource: DarkEmailTheme,
},
};
const EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID = "e7d009ce-8d47-4528-b245-5bf119f2ffa3";
const EMAIL_TEMPLATE_PASSWORD_RESET_ID = "a70fb3a4-56c1-4e42-af25-49d25603abd0";
const EMAIL_TEMPLATE_MAGIC_LINK_ID = "822687fe-8d0a-4467-a0d1-416b6e639478";
const EMAIL_TEMPLATE_TEAM_INVITATION_ID = "e84de395-2076-4831-9c19-8e9a96a868e4";
const EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID = "066dd73c-36da-4fd0-b6d6-ebf87683f8bc";
export const DEFAULT_EMAIL_TEMPLATES = {
[EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID]: {
"displayName": "Email Verification",
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n emailVerificationLink: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={`Verify your email at ${project.displayName}`} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"bg-white\">\n <h3 className=\"text-black font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n Verify your email at {project.displayName}\n </h3>\n <p className=\"text-[#474849] font-sans font-normal text-[14px] text-center pt-2 px-6 pb-4 m-0\">\n Hi{user.displayName ? (\", \" + user.displayName) : ''}! Please click on the following button to verify your email.\n </p>\n <div className=\"text-center py-3 px-6\">\n <Button\n href={variables.emailVerificationLink}\n target=\"_blank\"\n className=\"text-black font-sans font-bold text-[14px] inline-block bg-[#f0f0f0] rounded-[4px] py-3 px-5 no-underline border-0\"\n >\n Verify my email\n </Button>\n </div>\n <div className=\"py-4 px-6\">\n <Hr />\n </div>\n <p className=\"text-[#474849] font-sans font-normal text-[12px] text-center pt-1 px-6 pb-6 m-0\">\n If you were not expecting this email, you can safely ignore it. \n </p>\n </Section>\n </div>\n </>\n )\n}\n\nEmailTemplate.PreviewVariables = {\n emailVerificationLink: \"<email verification link>\"\n} satisfies typeof variablesSchema.infer",
"themeId": undefined,
},
[EMAIL_TEMPLATE_PASSWORD_RESET_ID]: {
"displayName": "Password Reset",
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\"\nimport { Subject, NotificationCategory, Props} from \"@stackframe/emails\"\n\nexport const variablesSchema = type({\n passwordResetLink: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"Reset your password at \" + project.displayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-tight leading-relaxed py-8 w-full min-h-full\">\n <Section>\n <h3 className=\"text-black bg-transparent font-sans font-bold text-[20px] text-center py-4 px-6 m-0\">\n Reset your password at {project.displayName}\n </h3>\n\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center pt-2 pb-4 px-6 m-0\">\n Hi{user.displayName ? (\", \" + user.displayName) : \"\"}! Please click on the following button to start the password reset process.\n </p>\n\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={variables.passwordResetLink}\n className=\"text-black text-sm font-sans font-bold bg-[#f0f0f0] rounded-[4px] inline-block py-3 px-5 no-underline border-none\"\n target=\"_blank\"\n >\n Reset my password\n </Button>\n </div>\n\n <div className=\"px-6 py-4\">\n <Hr />\n </div>\n\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center pt-1 pb-6 px-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n )\n}\n\nEmailTemplate.PreviewVariables = {\n passwordResetLink: \"<password reset link>\"\n} satisfies typeof variablesSchema.infer",
"themeId": undefined,
},
[EMAIL_TEMPLATE_MAGIC_LINK_ID]: {
"displayName": "Magic Link/OTP",
"tsxSource": "import { type } from 'arktype';\nimport { Section, Hr } from '@react-email/components';\nimport { Subject, NotificationCategory, Props } from '@stackframe/emails';\n\nexport const variablesSchema = type({\n magicLink: 'string',\n otp: 'string',\n});\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"Sign in to \" + project.displayName + \": Your code is \" + variables.otp} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-6 m-0 py-8 w-full min-h-full\">\n <Section className=\"mx-auto bg-white\">\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center px-6 py-4 m-0\">\n Sign in to {project.displayName}\n </h3>\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center px-6 py-4 m-0\">\n Hi{user.displayName ? \", \" + user.displayName : \"\"}! This is your one-time-password for signing in:\n </p>\n <p className=\"text-black bg-transparent text-2xl font-mono font-bold text-center px-6 py-4 m-0\">\n {variables.otp}\n </p>\n <p className=\"text-black bg-transparent text-sm font-sans font-normal text-center px-6 py-4 m-0\">\n Or you can click on{' '}\n <a\n key={20}\n href={variables.magicLink}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-blue-600 underline\"\n >\n this link\n </a>{' '}\n to sign in\n </p>\n <Hr className=\"px-6 py-4 bg-transparent\" />\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center px-6 pt-1 pb-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n );\n}\n\nEmailTemplate.PreviewVariables = {\n magicLink: \"<magic link>\",\n otp: \"3SLSWZ\"\n} satisfies typeof variablesSchema.infer",
"themeId": undefined,
},
[EMAIL_TEMPLATE_TEAM_INVITATION_ID]: {
"displayName": "Team Invitation",
"tsxSource": "import { type } from \"arktype\";\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\n\nexport const variablesSchema = type({\n teamDisplayName: \"string\",\n teamInvitationLink: \"string\"\n});\n\nexport function EmailTemplate({ user, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject value={\"You have been invited to join \" + variables.teamDisplayName} />\n <NotificationCategory value=\"Transactional\" />\n <div className=\"bg-white text-[#242424] font-sans text-base font-normal tracking-[0.15008px] leading-[1.5] m-0 py-8 w-full min-h-full\">\n <Section className=\"mx-auto max-w-lg bg-white\">\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center px-6 pt-8 m-0\">\n You are invited to {variables.teamDisplayName}\n </h3>\n <p className=\"text-[#474849] bg-transparent text-sm font-sans font-normal text-center px-6 pt-2 pb-4 m-0\">\n Hi{user.displayName ? \", \" + user.displayName : \"\"}! Please click the button below to join the team {variables.teamDisplayName}\n </p>\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={variables.teamInvitationLink}\n target=\"_blank\"\n className=\"text-black text-sm font-sans font-bold bg-[#f0f0f0] rounded-md inline-block px-5 py-3 no-underline border-0\"\n >\n Join team\n </Button>\n </div>\n <div className=\"px-6 py-4 bg-transparent\">\n <Hr />\n </div>\n <p className=\"text-[#474849] bg-transparent text-xs font-sans font-normal text-center px-6 pb-6 pt-1 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n );\n}\n\nEmailTemplate.PreviewVariables = {\n teamDisplayName: \"My Team\",\n teamInvitationLink: \"<team invitation link>\"\n} satisfies typeof variablesSchema.infer ",
"themeId": undefined,
},
[EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID]: {
"displayName": "Sign In Invitation",
"tsxSource": "import { type } from \"arktype\"\nimport { Button, Section, Hr } from \"@react-email/components\";\nimport { Subject, NotificationCategory, Props } from \"@stackframe/emails\";\n\nexport const variablesSchema = type({\n signInInvitationLink: \"string\",\n teamDisplayName: \"string\"\n})\n\nexport function EmailTemplate({ user, project, variables }: Props<typeof variablesSchema.infer>) {\n return (\n <>\n <Subject\n value={\"You have been invited to sign in to \" + project.displayName}\n />\n <NotificationCategory value=\"Transactional\" />\n\n <div className=\"bg-white text-gray-900 font-sans text-base font-normal leading-normal w-full min-h-full m-0 py-8\">\n <Section>\n <h3 className=\"text-black bg-transparent font-sans font-bold text-xl text-center pt-8 px-6 m-0\">\n You are invited to sign in to {variables.teamDisplayName}\n </h3>\n\n <p className=\"text-gray-700 bg-transparent text-sm font-sans font-normal text-center pt-2 pb-4 px-6 m-0\">\n Hi\n {user.displayName ? \", \" + user.displayName : \"\"}! Please click on the following\n link to sign in to your account\n </p>\n\n <div className=\"bg-transparent text-center px-6 py-3\">\n <Button\n href={variables.signInInvitationLink}\n className=\"text-black text-sm font-sans font-bold bg-gray-200 rounded-md inline-block py-3 px-5 no-underline border-none\"\n target=\"_blank\"\n >\n Sign in\n </Button>\n </div>\n\n <div className=\"px-6 py-4 bg-transparent\">\n <Hr />\n </div>\n\n <p className=\"text-gray-700 bg-transparent text-xs font-sans font-normal text-center pt-1 pb-6 px-6 m-0\">\n If you were not expecting this email, you can safely ignore it.\n </p>\n </Section>\n </div>\n </>\n )\n}\n\nEmailTemplate.PreviewVariables = {\n signInInvitationLink: \"<sign in invitation link>\",\n teamDisplayName: \"My Team\"\n} satisfies typeof variablesSchema.infer",
"themeId": undefined,
}
};
export const DEFAULT_TEMPLATE_IDS = {
email_verification: EMAIL_TEMPLATE_EMAIL_VERIFICATION_ID,
password_reset: EMAIL_TEMPLATE_PASSWORD_RESET_ID,
magic_link: EMAIL_TEMPLATE_MAGIC_LINK_ID,
team_invitation: EMAIL_TEMPLATE_TEAM_INVITATION_ID,
sign_in_invitation: EMAIL_TEMPLATE_SIGN_IN_INVITATION_ID,
} as const;