diff --git a/packages/twenty-emails/package.json b/packages/twenty-emails/package.json
index 557ed1f7ad..5a89e25c8b 100644
--- a/packages/twenty-emails/package.json
+++ b/packages/twenty-emails/package.json
@@ -11,7 +11,6 @@
"dependencies": {
"@lingui/core": "^5.9.5",
"@lingui/react": "^5.9.5",
- "@react-email/components": "^0.5.3",
"twenty-shared": "workspace:*"
},
"peerDependencies": {
@@ -22,13 +21,13 @@
"@lingui/cli": "^5.9.5",
"@lingui/swc-plugin": "^5.11.0",
"@lingui/vite-plugin": "^5.9.5",
- "@react-email/ui": "6.5.0",
+ "@react-email/ui": "^6.9.0",
"@tiptap/core": "^3.4.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "^4.3.1",
- "react-email": "6.5.0",
+ "react-email": "^6.9.0",
"tsc-alias": "^1.8.16",
"vite-plugin-dts": "^4.5.4"
},
diff --git a/packages/twenty-emails/src/components/BaseEmail.tsx b/packages/twenty-emails/src/components/BaseEmail.tsx
index ec8133ca89..63d24b422a 100644
--- a/packages/twenty-emails/src/components/BaseEmail.tsx
+++ b/packages/twenty-emails/src/components/BaseEmail.tsx
@@ -1,6 +1,6 @@
import { type JSX } from 'react';
import { I18nProvider } from '@lingui/react';
-import { Container, Html } from '@react-email/components';
+import { Container, Html } from 'react-email';
import { BaseHead } from 'src/components/BaseHead';
import { Footer } from 'src/components/Footer';
diff --git a/packages/twenty-emails/src/components/BaseHead.tsx b/packages/twenty-emails/src/components/BaseHead.tsx
index 389369b261..a911533f6e 100644
--- a/packages/twenty-emails/src/components/BaseHead.tsx
+++ b/packages/twenty-emails/src/components/BaseHead.tsx
@@ -1,4 +1,4 @@
-import { Font, Head } from '@react-email/components';
+import { Font, Head } from 'react-email';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/components/CallToAction.tsx b/packages/twenty-emails/src/components/CallToAction.tsx
index b284a56be2..ff384d243a 100644
--- a/packages/twenty-emails/src/components/CallToAction.tsx
+++ b/packages/twenty-emails/src/components/CallToAction.tsx
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
-import { Button } from '@react-email/components';
+import { Button } from 'react-email';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/components/Footer.tsx b/packages/twenty-emails/src/components/Footer.tsx
index b96b5bf641..6683712317 100644
--- a/packages/twenty-emails/src/components/Footer.tsx
+++ b/packages/twenty-emails/src/components/Footer.tsx
@@ -1,5 +1,5 @@
import { type I18n } from '@lingui/core';
-import { Column, Container, Row } from '@react-email/components';
+import { Column, Container, Row } from 'react-email';
import { Link } from 'src/components/Link';
import { ShadowText } from 'src/components/ShadowText';
diff --git a/packages/twenty-emails/src/components/HighlightedContainer.tsx b/packages/twenty-emails/src/components/HighlightedContainer.tsx
index c4b72d6dab..599cd18211 100644
--- a/packages/twenty-emails/src/components/HighlightedContainer.tsx
+++ b/packages/twenty-emails/src/components/HighlightedContainer.tsx
@@ -1,4 +1,4 @@
-import { Column, Container, Row } from '@react-email/components';
+import { Column, Container, Row } from 'react-email';
import React, { type JSX } from 'react';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/components/HighlightedText.tsx b/packages/twenty-emails/src/components/HighlightedText.tsx
index b742aa5290..9ca8c04a9f 100644
--- a/packages/twenty-emails/src/components/HighlightedText.tsx
+++ b/packages/twenty-emails/src/components/HighlightedText.tsx
@@ -1,4 +1,4 @@
-import { Text } from '@react-email/components';
+import { Text } from 'react-email';
import { type JSX } from 'react';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/components/Link.tsx b/packages/twenty-emails/src/components/Link.tsx
index 1137ad3140..d7ea615593 100644
--- a/packages/twenty-emails/src/components/Link.tsx
+++ b/packages/twenty-emails/src/components/Link.tsx
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
-import { Link as EmailLink } from '@react-email/components';
+import { Link as EmailLink } from 'react-email';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/components/Logo.tsx b/packages/twenty-emails/src/components/Logo.tsx
index 798db615f2..784aeb169b 100644
--- a/packages/twenty-emails/src/components/Logo.tsx
+++ b/packages/twenty-emails/src/components/Logo.tsx
@@ -1,4 +1,4 @@
-import { Img } from '@react-email/components';
+import { Img } from 'react-email';
const logoStyle = {
marginBottom: '40px',
diff --git a/packages/twenty-emails/src/components/MainText.tsx b/packages/twenty-emails/src/components/MainText.tsx
index 9eba9072bb..fe8f09a46f 100644
--- a/packages/twenty-emails/src/components/MainText.tsx
+++ b/packages/twenty-emails/src/components/MainText.tsx
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
-import { Text } from '@react-email/components';
+import { Text } from 'react-email';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/components/ShadowText.tsx b/packages/twenty-emails/src/components/ShadowText.tsx
index 80aced0a5d..138e23e6c7 100644
--- a/packages/twenty-emails/src/components/ShadowText.tsx
+++ b/packages/twenty-emails/src/components/ShadowText.tsx
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
-import { Text } from '@react-email/components';
+import { Text } from 'react-email';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/components/SubTitle.tsx b/packages/twenty-emails/src/components/SubTitle.tsx
index 1c1b31932e..fdfd4b2b58 100644
--- a/packages/twenty-emails/src/components/SubTitle.tsx
+++ b/packages/twenty-emails/src/components/SubTitle.tsx
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
-import { Heading } from '@react-email/components';
+import { Heading } from 'react-email';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/components/Title.tsx b/packages/twenty-emails/src/components/Title.tsx
index e2e5af01d0..fc8336b8c6 100644
--- a/packages/twenty-emails/src/components/Title.tsx
+++ b/packages/twenty-emails/src/components/Title.tsx
@@ -1,5 +1,5 @@
import { type JSX } from 'react';
-import { Heading } from '@react-email/components';
+import { Heading } from 'react-email';
import { emailTheme } from 'src/common-style';
diff --git a/packages/twenty-emails/src/emails/send-invite-link.email.tsx b/packages/twenty-emails/src/emails/send-invite-link.email.tsx
index 90af809d46..a4c8e58a67 100644
--- a/packages/twenty-emails/src/emails/send-invite-link.email.tsx
+++ b/packages/twenty-emails/src/emails/send-invite-link.email.tsx
@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react';
-import { Img } from '@react-email/components';
+import { Img } from 'react-email';
import { emailTheme } from 'src/common-style';
import { BaseEmail } from 'src/components/BaseEmail';
diff --git a/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx b/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx
index 7db05c5739..95d3480d20 100644
--- a/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx
+++ b/packages/twenty-emails/src/emails/validate-approved-access-domain.email.tsx
@@ -1,5 +1,5 @@
import { Trans } from '@lingui/react';
-import { Img } from '@react-email/components';
+import { Img } from 'react-email';
import { emailTheme } from 'src/common-style';
import { BaseEmail } from 'src/components/BaseEmail';
diff --git a/packages/twenty-emails/src/index.ts b/packages/twenty-emails/src/index.ts
index 404beef6e8..4796109b6d 100644
--- a/packages/twenty-emails/src/index.ts
+++ b/packages/twenty-emails/src/index.ts
@@ -1,4 +1,9 @@
export type { JSONContent } from '@tiptap/core';
+// Consumers must render through this package so there is a single resolved
+// react-email version: a second, independently pinned render can silently drift
+// onto an incompatible major and ship empty email bodies.
+export { render, toPlainText } from 'react-email';
+export * from './utils/render-email';
export * from './emails/billing-subscription-renewing.email';
export * from './emails/billing-trial-converting.email';
export * from './emails/billing-trial-ending.email';
diff --git a/packages/twenty-emails/src/utils/email-renderer/email-renderer.tsx b/packages/twenty-emails/src/utils/email-renderer/email-renderer.tsx
index 1a5af86e27..4ae8fda576 100644
--- a/packages/twenty-emails/src/utils/email-renderer/email-renderer.tsx
+++ b/packages/twenty-emails/src/utils/email-renderer/email-renderer.tsx
@@ -1,4 +1,4 @@
-import { Body, Head, Html } from '@react-email/components';
+import { Body, Head, Html } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/heading.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/heading.tsx
index 5d1197b048..cf18c7df7f 100644
--- a/packages/twenty-emails/src/utils/email-renderer/nodes/heading.tsx
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/heading.tsx
@@ -1,4 +1,4 @@
-import { Heading } from '@react-email/components';
+import { Heading } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/image.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/image.tsx
index 505a416e34..dc27ac0a31 100644
--- a/packages/twenty-emails/src/utils/email-renderer/nodes/image.tsx
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/image.tsx
@@ -1,4 +1,4 @@
-import { Column, Row } from '@react-email/components';
+import { Column, Row } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
diff --git a/packages/twenty-emails/src/utils/email-renderer/nodes/paragraph.tsx b/packages/twenty-emails/src/utils/email-renderer/nodes/paragraph.tsx
index 87dd8d9884..2a4e63b7ff 100644
--- a/packages/twenty-emails/src/utils/email-renderer/nodes/paragraph.tsx
+++ b/packages/twenty-emails/src/utils/email-renderer/nodes/paragraph.tsx
@@ -1,4 +1,4 @@
-import { Text } from '@react-email/components';
+import { Text } from 'react-email';
import { type JSONContent } from '@tiptap/core';
import { type ReactNode } from 'react';
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
diff --git a/packages/twenty-emails/src/utils/render-email.ts b/packages/twenty-emails/src/utils/render-email.ts
new file mode 100644
index 0000000000..ecedff6284
--- /dev/null
+++ b/packages/twenty-emails/src/utils/render-email.ts
@@ -0,0 +1,34 @@
+import { render } from 'react-email';
+
+// React Email renders the tree inside its own Suspense boundary without an
+// onError, so anything that throws while rendering a template is swallowed and
+// the boundary ships as ``. The email is
+// then delivered with a blank body and nothing is logged (#23307). Failing the
+// send is the lesser evil: a blank email is useless to the recipient and the
+// thrown error surfaces in the job logs.
+const ERRORED_SUSPENSE_BOUNDARY_MARKER = '';
+
+export class EmailRenderError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'EmailRenderError';
+ }
+}
+
+export const renderEmail = async (
+ ...renderArgs: Parameters
+): Promise => {
+ const output = await render(...renderArgs);
+
+ if (output.trim().length === 0) {
+ throw new EmailRenderError('Email template rendered an empty body');
+ }
+
+ if (output.includes(ERRORED_SUSPENSE_BOUNDARY_MARKER)) {
+ throw new EmailRenderError(
+ 'Email template threw while rendering, its body would have been empty',
+ );
+ }
+
+ return output;
+};
diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json
index 61af16bfbe..26fc7b6f55 100644
--- a/packages/twenty-front/package.json
+++ b/packages/twenty-front/package.json
@@ -58,7 +58,6 @@
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@nivo/radial-bar": "^0.99.0",
- "@react-email/components": "^0.5.3",
"@react-pdf/renderer": "^4.1.6",
"@scalar/api-reference-react": "^0.9.42",
"@sentry/react": "^10.51.0",
diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelDetails.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelDetails.tsx
index a5d0fd051a..ae3e3ac812 100644
--- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelDetails.tsx
+++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelDetails.tsx
@@ -5,7 +5,7 @@ import { SettingsAccountsEventVisibilitySettingsCard } from '@/settings/accounts
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
-import { Section } from '@react-email/components';
+import { Section } from 'twenty-ui/layout';
import { IconUserPlus } from 'twenty-ui/icon';
import { H2Title } from 'twenty-ui/typography';
import { Card } from 'twenty-ui/surfaces';
diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx
index 62d56b3826..90ab57ea81 100644
--- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx
+++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx
@@ -5,7 +5,7 @@ import { DATE_TIME_SETTINGS_PREVIEW_DATE } from '@/localization/constants/DateTi
import { SettingsAccountsCalendarDisplaySettings } from '@/settings/accounts/components/SettingsAccountsCalendarDisplaySettings';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
-import { Section } from '@react-email/components';
+import { Section } from 'twenty-ui/layout';
import { addMinutes, endOfDay, min, startOfDay } from 'date-fns';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { H2Title } from 'twenty-ui/typography';
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsObjectNewFieldSelector.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsObjectNewFieldSelector.tsx
index e2d61792d7..8cf1bdf56a 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsObjectNewFieldSelector.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsObjectNewFieldSelector.tsx
@@ -12,7 +12,7 @@ import { type SettingsFieldType } from '@/settings/data-model/types/SettingsFiel
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
-import { Section } from '@react-email/components';
+import { Section } from 'twenty-ui/layout';
import { useContext, useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { SettingsPath } from 'twenty-shared/types';
diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json
index 3754f28895..6f8df049bb 100644
--- a/packages/twenty-server/package.json
+++ b/packages/twenty-server/package.json
@@ -71,7 +71,6 @@
"@ptc-org/nestjs-query-core": "^9.4.0",
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-graphql-npm-9.4.0-8e6f7894e1.patch",
"@ptc-org/nestjs-query-typeorm": "patch:@ptc-org/nestjs-query-typeorm@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-typeorm-npm-9.4.0-ca3414967e.patch",
- "@react-email/render": "^1.2.3",
"@react-pdf/renderer": "^4.1.6",
"@sentry/nestjs": "^10.59.0",
"@sentry/node": "^10.59.0",
diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service.ts
index f9cbbdd240..a62039e975 100644
--- a/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/admin-panel/services/admin-panel-server-admin.service.ts
@@ -2,9 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
-import { render } from '@react-email/render';
import { isNonEmptyString } from '@sniptt/guards';
-import { ServerAdminAccessChangedEmail } from 'twenty-emails';
+import { ServerAdminAccessChangedEmail, renderEmail } from 'twenty-emails';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -249,8 +248,8 @@ export class AdminPanelServerAdminService {
canImpersonate: targetUser.canImpersonate,
locale,
});
- const html = await render(emailTemplate, { pretty: true });
- const text = await render(emailTemplate, { plainText: true });
+ const html = await renderEmail(emailTemplate, { pretty: true });
+ const text = await renderEmail(emailTemplate, { plainText: true });
const i18n = this.i18nService.getI18nInstance(locale);
const subject = i18n._(msg`Server administrator access changed`);
diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
index 94b0f35068..fa445d885a 100644
--- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.service.ts
@@ -2,8 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
-import { render } from '@react-email/render';
-import { SendApprovedAccessDomainValidation } from 'twenty-emails';
+import { SendApprovedAccessDomainValidation, renderEmail } from 'twenty-emails';
import { FileFolder, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -116,8 +115,8 @@ export class ApprovedAccessDomainService {
serverUrl: this.twentyConfigService.get('SERVER_URL'),
locale: sender.locale,
});
- const html = await render(emailTemplate);
- const text = await render(emailTemplate, {
+ const html = await renderEmail(emailTemplate);
+ const text = await renderEmail(emailTemplate, {
plainText: true,
});
diff --git a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
index 359227144c..0459c9f10d 100644
--- a/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/approved-access-domain/services/approved-access-domain.spec.ts
@@ -23,9 +23,12 @@ import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-membe
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
import { ApprovedAccessDomainService } from './approved-access-domain.service';
-// To avoid dynamic import issues in Jest
-jest.mock('@react-email/render', () => ({
- render: jest.fn().mockImplementation(async (template, options) => {
+// render() resolves through a streaming scheduler that never advances under the
+// globally enabled fake timers; the real render path is covered by
+// email-templates-rendering.spec.ts.
+jest.mock('twenty-emails', () => ({
+ ...jest.requireActual('twenty-emails'),
+ renderEmail: jest.fn().mockImplementation(async (_template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
index 2ce6cff71a..7bc3e43806 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/auth.service.ts
@@ -4,10 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto, { randomUUID } from 'node:crypto';
import { msg } from '@lingui/core/macro';
-import { render } from '@react-email/render';
import { addMilliseconds } from 'date-fns';
import ms from 'ms';
-import { PasswordUpdateNotifyEmail } from 'twenty-emails';
+import { PasswordUpdateNotifyEmail, renderEmail } from 'twenty-emails';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AppPath, ConnectedAccountProvider } from 'twenty-shared/types';
import { isNonEmptyString } from '@sniptt/guards';
@@ -721,8 +720,8 @@ export class AuthService {
locale: firstUserWorkspace.locale,
});
- const html = await render(emailTemplate, { pretty: true });
- const text = await render(emailTemplate, { plainText: true });
+ const html = await renderEmail(emailTemplate, { pretty: true });
+ const text = await renderEmail(emailTemplate, { plainText: true });
const passwordChangedMsg = msg`Your Password Has Been Successfully Changed`;
const i18n = this.i18nService.getI18nInstance(firstUserWorkspace.locale);
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts
index 7ba3a0528f..5b7045c4c4 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.spec.ts
@@ -22,8 +22,12 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
import { ResetPasswordService } from './reset-password.service';
-jest.mock('@react-email/render', () => ({
- render: jest.fn().mockImplementation(async (_, options) => {
+// render() resolves through a streaming scheduler that never advances under the
+// globally enabled fake timers; the real render path is covered by
+// email-templates-rendering.spec.ts.
+jest.mock('twenty-emails', () => ({
+ ...jest.requireActual('twenty-emails'),
+ renderEmail: jest.fn().mockImplementation(async (_template, options) => {
if (options?.plainText) {
return 'Plain Text Email';
}
diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts
index 33e781f0f8..68a0fbc7c5 100644
--- a/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/auth/services/reset-password.service.ts
@@ -4,10 +4,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { msg } from '@lingui/core/macro';
-import { render } from '@react-email/render';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
-import { PasswordResetLinkEmail } from 'twenty-emails';
+import { PasswordResetLinkEmail, renderEmail } from 'twenty-emails';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { AppPath } from 'twenty-shared/types';
import {
@@ -197,8 +196,8 @@ export class ResetPasswordService {
const emailTemplate = PasswordResetLinkEmail(emailData);
- const html = await render(emailTemplate, { pretty: true });
- const text = await render(emailTemplate, { plainText: true });
+ const html = await renderEmail(emailTemplate, { pretty: true });
+ const text = await renderEmail(emailTemplate, { plainText: true });
const i18n = this.i18nService.getI18nInstance(locale);
const subjectTemplate = hasPassword
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/services/__tests__/billing-reminder.service.spec.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/services/__tests__/billing-reminder.service.spec.ts
index 15a25c745e..d9b96ac9ac 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/reminders/services/__tests__/billing-reminder.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/services/__tests__/billing-reminder.service.spec.ts
@@ -13,13 +13,14 @@ import {
} from 'src/engine/core-modules/billing/reminders/constants/billing-reminder-sent-keys.constant';
import { BillingReminderService } from 'src/engine/core-modules/billing/reminders/services/billing-reminder.service';
-jest.mock('@react-email/render', () => ({
- render: jest.fn().mockResolvedValue(''),
-}));
+// render() resolves through a streaming scheduler that never advances under the
+// globally enabled fake timers; the real render path is covered by
+// email-templates-rendering.spec.ts.
jest.mock('twenty-emails', () => ({
BillingTrialEndingEmail: jest.fn(),
BillingTrialConvertingEmail: jest.fn(),
BillingSubscriptionRenewingEmail: jest.fn(),
+ renderEmail: jest.fn().mockResolvedValue(''),
}));
const CONFIG: Record = {
diff --git a/packages/twenty-server/src/engine/core-modules/billing/reminders/services/billing-reminder.service.ts b/packages/twenty-server/src/engine/core-modules/billing/reminders/services/billing-reminder.service.ts
index 8fc040b6ed..76aaf0d7ed 100644
--- a/packages/twenty-server/src/engine/core-modules/billing/reminders/services/billing-reminder.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/billing/reminders/services/billing-reminder.service.ts
@@ -4,12 +4,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
-import { render } from '@react-email/render';
import { addDays, differenceInCalendarDays } from 'date-fns';
import {
BillingSubscriptionRenewingEmail,
BillingTrialConvertingEmail,
BillingTrialEndingEmail,
+ renderEmail,
} from 'twenty-emails';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
@@ -293,8 +293,8 @@ export class BillingReminderService {
locale,
});
- const html = await render(emailTemplate, { pretty: true });
- const text = await render(emailTemplate, { plainText: true });
+ const html = await renderEmail(emailTemplate, { pretty: true });
+ const text = await renderEmail(emailTemplate, { plainText: true });
await this.emailService.send({
to: workspaceMember.userEmail,
diff --git a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
index 8defe934c3..81ed73fbcf 100644
--- a/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/email-verification/services/email-verification.service.ts
@@ -1,12 +1,10 @@
-import { Injectable, Logger } from '@nestjs/common';
+import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
-import { render } from '@react-email/render';
-import { renderToStaticMarkup } from 'react-dom/server';
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import ms from 'ms';
-import { SendEmailVerificationLinkEmail } from 'twenty-emails';
+import { SendEmailVerificationLinkEmail, renderEmail } from 'twenty-emails';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { AppPath } from 'twenty-shared/types';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
@@ -32,8 +30,6 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@Injectable()
export class EmailVerificationService {
- private readonly logger = new Logger(EmailVerificationService.name);
-
constructor(
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository,
@@ -97,37 +93,9 @@ export class EmailVerificationService {
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
- const html = await render(emailTemplate);
+ const html = await renderEmail(emailTemplate);
- // TEMPORARY DIAGNOSTIC (remove once the empty verification email body is root-caused):
- // render() wraps the tree in and uses renderToReadableStream WITHOUT an
- // onError, so any SSR throw is swallowed into an errored boundary and the body ships
- // as ``. Detect that and re-render
- // synchronously to surface the real error + stack (production strips it from the markup).
- if (html.length === 0 || html.includes('')) {
- this.logger.error(
- `[EMAIL_VERIFICATION_RENDER_DEBUG] verification email rendered empty/errored (locale=${locale}, trigger=${verificationTrigger}, htmlLength=${html.length})`,
- );
- this.logger.error(
- `[EMAIL_VERIFICATION_RENDER_DEBUG] html start: ${html.slice(0, 400)}`,
- );
- try {
- renderToStaticMarkup(emailTemplate);
- this.logger.error(
- '[EMAIL_VERIFICATION_RENDER_DEBUG] renderToStaticMarkup did not throw — likely an async/Suspense rejection rather than a synchronous throw',
- );
- } catch (renderError) {
- this.logger.error(
- `[EMAIL_VERIFICATION_RENDER_DEBUG] underlying render error: ${
- renderError instanceof Error
- ? `${renderError.message}\n${renderError.stack}`
- : String(renderError)
- }`,
- );
- }
- }
-
- const text = await render(emailTemplate, {
+ const text = await renderEmail(emailTemplate, {
plainText: true,
});
diff --git a/packages/twenty-server/src/engine/core-modules/email/__tests__/email-templates-rendering.spec.ts b/packages/twenty-server/src/engine/core-modules/email/__tests__/email-templates-rendering.spec.ts
new file mode 100644
index 0000000000..081c6fbb2e
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/email/__tests__/email-templates-rendering.spec.ts
@@ -0,0 +1,228 @@
+import { createElement } from 'react';
+
+import {
+ BillingSubscriptionRenewingEmail,
+ BillingTrialConvertingEmail,
+ BillingTrialEndingEmail,
+ CleanSuspendedWorkspaceEmail,
+ PasswordResetLinkEmail,
+ PasswordUpdateNotifyEmail,
+ SendApprovedAccessDomainValidation,
+ SendEmailVerificationLinkEmail,
+ SendInviteLinkEmail,
+ ServerAdminAccessChangedEmail,
+ WarnSuspendedWorkspaceEmail,
+ EmailRenderError,
+ renderEmail,
+} from 'twenty-emails';
+
+const WORKSPACE = { name: 'Acme Inc', logo: undefined };
+const SENDER = {
+ email: 'tim@twenty.com',
+ firstName: 'Tim',
+ lastName: 'Apple',
+};
+
+const TEMPLATES = [
+ {
+ name: 'BillingSubscriptionRenewingEmail',
+ element: BillingSubscriptionRenewingEmail({
+ userName: 'Tim',
+ workspaceDisplayName: 'Acme Inc',
+ renewsAt: new Date('2026-01-01'),
+ link: 'https://app.twenty.com/settings/billing',
+ locale: 'en',
+ }),
+ expectedContent: 'https://app.twenty.com/settings/billing',
+ },
+ {
+ name: 'BillingTrialConvertingEmail',
+ element: BillingTrialConvertingEmail({
+ userName: 'Tim',
+ workspaceDisplayName: 'Acme Inc',
+ trialEndsAt: new Date('2026-01-01'),
+ interval: 'month',
+ link: 'https://app.twenty.com/settings/billing',
+ locale: 'en',
+ }),
+ expectedContent: 'https://app.twenty.com/settings/billing',
+ },
+ {
+ name: 'BillingTrialEndingEmail',
+ element: BillingTrialEndingEmail({
+ userName: 'Tim',
+ workspaceDisplayName: 'Acme Inc',
+ trialEndsAt: new Date('2026-01-01'),
+ dataRetentionDays: 30,
+ link: 'https://app.twenty.com/settings/billing',
+ locale: 'en',
+ }),
+ expectedContent: 'https://app.twenty.com/settings/billing',
+ },
+ {
+ name: 'CleanSuspendedWorkspaceEmail',
+ element: CleanSuspendedWorkspaceEmail({
+ daysSinceInactive: 30,
+ userName: 'Tim',
+ workspaceDisplayName: 'Acme Inc',
+ locale: 'en',
+ }),
+ expectedContent: 'Acme Inc',
+ },
+ {
+ name: 'PasswordResetLinkEmail',
+ element: PasswordResetLinkEmail({
+ duration: '5 minutes',
+ hasPassword: true,
+ link: 'https://app.twenty.com/reset-password',
+ locale: 'en',
+ }),
+ expectedContent: 'https://app.twenty.com/reset-password',
+ },
+ {
+ name: 'PasswordUpdateNotifyEmail',
+ element: PasswordUpdateNotifyEmail({
+ userName: 'Tim',
+ email: 'tim@twenty.com',
+ link: 'https://app.twenty.com',
+ locale: 'en',
+ }),
+ expectedContent: 'tim@twenty.com',
+ },
+ {
+ name: 'SendApprovedAccessDomainValidation',
+ element: SendApprovedAccessDomainValidation({
+ link: 'https://app.twenty.com/validate-approved-access-domain',
+ domain: 'acme.com',
+ workspace: WORKSPACE,
+ sender: SENDER,
+ serverUrl: 'https://app.twenty.com',
+ locale: 'en',
+ }),
+ expectedContent: 'https://app.twenty.com/validate-approved-access-domain',
+ },
+ {
+ name: 'SendEmailVerificationLinkEmail',
+ element: SendEmailVerificationLinkEmail({
+ link: 'https://app.twenty.com/verify-email',
+ locale: 'en',
+ }),
+ expectedContent: 'https://app.twenty.com/verify-email',
+ },
+ {
+ name: 'SendInviteLinkEmail',
+ element: SendInviteLinkEmail({
+ link: 'https://app.twenty.com/invite/token',
+ workspace: WORKSPACE,
+ sender: SENDER,
+ serverUrl: 'https://app.twenty.com',
+ locale: 'en',
+ }),
+ expectedContent: 'https://app.twenty.com/invite/token',
+ },
+ {
+ name: 'ServerAdminAccessChangedEmail',
+ element: ServerAdminAccessChangedEmail({
+ actorName: 'Tim',
+ targetName: 'Jony',
+ targetEmail: 'jony@twenty.com',
+ canAccessFullAdminPanel: true,
+ canImpersonate: false,
+ locale: 'en',
+ }),
+ expectedContent: 'jony@twenty.com',
+ },
+ {
+ name: 'WarnSuspendedWorkspaceEmail',
+ element: WarnSuspendedWorkspaceEmail({
+ daysSinceInactive: 30,
+ inactiveDaysBeforeDelete: 60,
+ userName: 'Tim',
+ workspaceDisplayName: 'Acme Inc',
+ link: 'https://app.twenty.com/settings/billing',
+ locale: 'en',
+ }),
+ expectedContent: 'https://app.twenty.com/settings/billing',
+ },
+];
+
+// Transactional emails went out with an empty body for a month without a single
+// test noticing, because every email spec mocks the renderer (#23307). These
+// specs run the real render path instead.
+describe('email templates rendering', () => {
+ // render() resolves through a streaming scheduler that never advances under
+ // the globally enabled fake timers.
+ beforeAll(() => {
+ jest.useRealTimers();
+ });
+
+ afterAll(() => {
+ jest.useFakeTimers();
+ });
+
+ it.each(TEMPLATES)(
+ 'should render $name to a complete HTML body',
+ async ({ element, expectedContent }) => {
+ const html = await renderEmail(element);
+
+ expect(html).not.toContain('');
+ expect(html).not.toContain('');
+ expect(html).toContain('