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(''); + expect(html).toContain(expectedContent); + }, + ); + + it.each(TEMPLATES)( + 'should render $name to a non-empty plain text body', + async ({ element }) => { + const text = await renderEmail(element, { plainText: true }); + + expect(text.trim().length).toBeGreaterThan(0); + expect(text).not.toContain('<'); + }, + ); + + it('should render translated content for a non-english locale', async () => { + const html = await renderEmail( + PasswordResetLinkEmail({ + duration: '5 minutes', + hasPassword: true, + link: 'https://app.twenty.com/reset-password', + locale: 'fr-FR', + }), + ); + + expect(html).toContain('mot de passe'); + }); +}); + +describe('renderEmail guard', () => { + beforeAll(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + jest.useFakeTimers(); + }); + + // @react-email/render 1.x turned a throw into `` + // and resolved, so the send went out blank. Whatever the renderer does, a + // failed render must never reach the recipient as an empty email. + it('should reject rather than resolve a blank body when a component throws', async () => { + const ThrowingComponent = () => { + throw new Error('boom'); + }; + + await expect(renderEmail(createElement(ThrowingComponent))).rejects.toThrow( + /boom|Email template/, + ); + }); + + it('should throw EmailRenderError when a template produces no text body', async () => { + const EmptyComponent = () => null; + + await expect( + renderEmail(createElement(EmptyComponent), { plainText: true }), + ).rejects.toThrow(EmailRenderError); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts index 0cd4fc571f..8293a26262 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/email-composer.service.ts @@ -1,9 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { toPlainText } from '@react-email/render'; import { isNonEmptyString } from '@sniptt/guards'; import DOMPurify from 'dompurify'; +import { toPlainText } from 'twenty-emails'; import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants'; import { ConnectedAccountProvider, diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util.ts index 50cfb8f2ba..8a074702f2 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/email-tool/utils/render-rich-text-to-html.util.ts @@ -1,5 +1,4 @@ -import { render } from '@react-email/render'; -import { type JSONContent, reactMarkupFromJSON } from 'twenty-emails'; +import { type JSONContent, reactMarkupFromJSON, render } from 'twenty-emails'; export const renderRichTextToHtml = async ( jsonContent: JSONContent, diff --git a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts index 92427214e0..f214662fd3 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.spec.ts @@ -26,9 +26,12 @@ import { WorkspaceInvitationService } from './workspace-invitation.service'; // To fix a circular dependency issue jest.mock('src/engine/core-modules/workspace/services/workspace.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/workspace-invitation/services/workspace-invitation.service.ts b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts index c964f6c0fd..398a8da829 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace-invitation/services/workspace-invitation.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 } from 'date-fns'; import ms from 'ms'; -import { SendInviteLinkEmail } from 'twenty-emails'; +import { SendInviteLinkEmail, renderEmail } from 'twenty-emails'; import { AppPath, FileFolder } from 'twenty-shared/types'; import { getAppPath, isDefined } from 'twenty-shared/utils'; import { In, IsNull, Repository } from 'typeorm'; @@ -361,8 +360,8 @@ export class WorkspaceInvitationService { }; const emailTemplate = SendInviteLinkEmail(emailData); - 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/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts index 7233cb8881..4fc5babff3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-cleaner/services/cleaner.workspace-service.ts @@ -2,11 +2,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; -import { render } from '@react-email/render'; import { differenceInDays } from 'date-fns'; import { CleanSuspendedWorkspaceEmail, WarnSuspendedWorkspaceEmail, + renderEmail, } from 'twenty-emails'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath, isDefined } from 'twenty-shared/utils'; @@ -130,8 +130,8 @@ export class CleanerWorkspaceService { locale: workspaceMember.locale, }; const emailTemplate = WarnSuspendedWorkspaceEmail(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 workspaceDeletionMsg = msg`Your workspace is paused — reactivate to keep your data`; const i18n = this.i18nService.getI18nInstance(workspaceMember.locale); @@ -220,8 +220,8 @@ export class CleanerWorkspaceService { locale: workspaceMember.locale, }; const emailTemplate = CleanSuspendedWorkspaceEmail(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 }); if (!isDefined(workspaceMember.userEmail)) { throw new Error('Workspace member email is missing'); diff --git a/yarn.lock b/yarn.lock index ef2c6597dc..70743dcc00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2499,19 +2499,6 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.27.0, @babel/generator@npm:^7.29.7": - version: 7.29.7 - resolution: "@babel/generator@npm:7.29.7" - dependencies: - "@babel/parser": "npm:^7.29.7" - "@babel/types": "npm:^7.29.7" - "@jridgewell/gen-mapping": "npm:^0.3.12" - "@jridgewell/trace-mapping": "npm:^0.3.28" - jsesc: "npm:^3.0.2" - checksum: 10c0/9bf72b01b5bd0ea5b1288a0e37dbd360bff2f2b1ce73342c0d40fb3db2ec3dc004ada5ffa925c5e12939a416eed59e600d562b8ecd938ce0d27dfd0eb6c6c2b7 - languageName: node - linkType: hard - "@babel/generator@npm:^7.28.5": version: 7.28.5 resolution: "@babel/generator@npm:7.28.5" @@ -2538,6 +2525,19 @@ __metadata: languageName: node linkType: hard +"@babel/generator@npm:^7.29.7": + version: 7.29.7 + resolution: "@babel/generator@npm:7.29.7" + dependencies: + "@babel/parser": "npm:^7.29.7" + "@babel/types": "npm:^7.29.7" + "@jridgewell/gen-mapping": "npm:^0.3.12" + "@jridgewell/trace-mapping": "npm:^0.3.28" + jsesc: "npm:^3.0.2" + checksum: 10c0/9bf72b01b5bd0ea5b1288a0e37dbd360bff2f2b1ce73342c0d40fb3db2ec3dc004ada5ffa925c5e12939a416eed59e600d562b8ecd938ce0d27dfd0eb6c6c2b7 + languageName: node + linkType: hard + "@babel/helper-annotate-as-pure@npm:^7.25.9": version: 7.25.9 resolution: "@babel/helper-annotate-as-pure@npm:7.25.9" @@ -2983,14 +2983,14 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:7.27.0": - version: 7.27.0 - resolution: "@babel/parser@npm:7.27.0" +"@babel/parser@npm:7.29.2, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": + version: 7.29.2 + resolution: "@babel/parser@npm:7.29.2" dependencies: - "@babel/types": "npm:^7.27.0" + "@babel/types": "npm:^7.29.0" bin: parser: ./bin/babel-parser.js - checksum: 10c0/ba2ed3f41735826546a3ef2a7634a8d10351df221891906e59b29b0a0cd748f9b0e7a6f07576858a9de8e77785aad925c8389ddef146de04ea2842047c9d2859 + checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317 languageName: node linkType: hard @@ -3016,7 +3016,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.27.0, @babel/parser@npm:^7.28.5": +"@babel/parser@npm:^7.28.5": version: 7.28.5 resolution: "@babel/parser@npm:7.28.5" dependencies: @@ -3027,17 +3027,6 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": - version: 7.29.2 - resolution: "@babel/parser@npm:7.29.2" - dependencies: - "@babel/types": "npm:^7.29.0" - bin: - parser: ./bin/babel-parser.js - checksum: 10c0/e5a4e69e3ac7acdde995f37cf299a68458cfe7009dff66bd0962fd04920bef287201169006af365af479c08ff216bfefbb595e331f87f6ae7283858aebbc3317 - languageName: node - linkType: hard - "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:^7.25.9": version: 7.25.9 resolution: "@babel/plugin-bugfix-firefox-class-in-computed-class-key@npm:7.25.9" @@ -4454,7 +4443,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.20.7, @babel/template@npm:^7.27.0, @babel/template@npm:^7.29.7": +"@babel/template@npm:^7.20.7, @babel/template@npm:^7.29.7": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" dependencies: @@ -4476,18 +4465,18 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:7.27.0": - version: 7.27.0 - resolution: "@babel/traverse@npm:7.27.0" +"@babel/traverse@npm:7.29.0, @babel/traverse@npm:^7.28.6": + version: 7.29.0 + resolution: "@babel/traverse@npm:7.29.0" dependencies: - "@babel/code-frame": "npm:^7.26.2" - "@babel/generator": "npm:^7.27.0" - "@babel/parser": "npm:^7.27.0" - "@babel/template": "npm:^7.27.0" - "@babel/types": "npm:^7.27.0" + "@babel/code-frame": "npm:^7.29.0" + "@babel/generator": "npm:^7.29.0" + "@babel/helper-globals": "npm:^7.28.0" + "@babel/parser": "npm:^7.29.0" + "@babel/template": "npm:^7.28.6" + "@babel/types": "npm:^7.29.0" debug: "npm:^4.3.1" - globals: "npm:^11.1.0" - checksum: 10c0/c7af29781960dacaae51762e8bc6c4b13d6ab4b17312990fbca9fc38e19c4ad7fecaae24b1cf52fb844e8e6cdc76c70ad597f90e496bcb3cc0a1d66b41a0aa5b + checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb languageName: node linkType: hard @@ -4536,21 +4525,6 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.28.6": - version: 7.29.0 - resolution: "@babel/traverse@npm:7.29.0" - dependencies: - "@babel/code-frame": "npm:^7.29.0" - "@babel/generator": "npm:^7.29.0" - "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.29.0" - "@babel/template": "npm:^7.28.6" - "@babel/types": "npm:^7.29.0" - debug: "npm:^4.3.1" - checksum: 10c0/f63ef6e58d02a9fbf3c0e2e5f1c877da3e0bc57f91a19d2223d53e356a76859cbaf51171c9211c71816d94a0e69efa2732fd27ffc0e1bbc84b636e60932333eb - languageName: node - linkType: hard - "@babel/types@npm:^7.0.0, @babel/types@npm:^7.18.13, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.2, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.5, @babel/types@npm:^7.25.9, @babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.0, @babel/types@npm:^7.28.2, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.28.2 resolution: "@babel/types@npm:7.28.2" @@ -4561,7 +4535,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.26.0, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0, @babel/types@npm:^7.29.7": +"@babel/types@npm:^7.26.0, @babel/types@npm:^7.26.10, @babel/types@npm:^7.29.7": version: 7.29.7 resolution: "@babel/types@npm:7.29.7" dependencies: @@ -15208,245 +15182,28 @@ __metadata: languageName: node linkType: hard -"@react-email/body@npm:0.1.0": - version: 0.1.0 - resolution: "@react-email/body@npm:0.1.0" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/deeee52e0326eed20b3267dfe970649705298efae9ff580009ee7f48c201cbca6cd200be163865178ccdab04fc8e44384c6dd7ab130557b93f421d3b263b33c8 - languageName: node - linkType: hard - -"@react-email/button@npm:0.2.0": - version: 0.2.0 - resolution: "@react-email/button@npm:0.2.0" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/1145d096ace8a459271f550faa941668122bd816adc6547390ede5becab5b0dc99a6f19c838e539fe53a73953bd4a4de790b1a7ad3d5981c44f5d34149045bfc - languageName: node - linkType: hard - -"@react-email/code-block@npm:0.1.0": - version: 0.1.0 - resolution: "@react-email/code-block@npm:0.1.0" - dependencies: - prismjs: "npm:^1.30.0" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/55d84654cbf593e49102a11d1615e3beefb6943a82842ab1e51f435c6130a62f4ea2ebc034b127ebcc96822789b58cf352df340823278c9759bb3f031d8a796e - languageName: node - linkType: hard - -"@react-email/code-inline@npm:0.0.5": - version: 0.0.5 - resolution: "@react-email/code-inline@npm:0.0.5" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/4081233686dd09575c580303f2822c415e1acc3c21847e64536afee60d37108810f5325e50a417ca39ab796fb21aae488108b4dcb55b0bb5e5b750f1019f32d7 - languageName: node - linkType: hard - -"@react-email/column@npm:0.0.13": - version: 0.0.13 - resolution: "@react-email/column@npm:0.0.13" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/56ebc6af1c546daca2c32b0700538242ab7d90ffff4be37bf9dcf929311b6996c66b39f506dfd37063bacd4382abdeab93baf8c1e3266aa62fe3b282b16a023f - languageName: node - linkType: hard - -"@react-email/components@npm:^0.5.3": - version: 0.5.3 - resolution: "@react-email/components@npm:0.5.3" - dependencies: - "@react-email/body": "npm:0.1.0" - "@react-email/button": "npm:0.2.0" - "@react-email/code-block": "npm:0.1.0" - "@react-email/code-inline": "npm:0.0.5" - "@react-email/column": "npm:0.0.13" - "@react-email/container": "npm:0.0.15" - "@react-email/font": "npm:0.0.9" - "@react-email/head": "npm:0.0.12" - "@react-email/heading": "npm:0.0.15" - "@react-email/hr": "npm:0.0.11" - "@react-email/html": "npm:0.0.11" - "@react-email/img": "npm:0.0.11" - "@react-email/link": "npm:0.0.12" - "@react-email/markdown": "npm:0.0.15" - "@react-email/preview": "npm:0.0.13" - "@react-email/render": "npm:1.2.3" - "@react-email/row": "npm:0.0.12" - "@react-email/section": "npm:0.0.16" - "@react-email/tailwind": "npm:1.2.2" - "@react-email/text": "npm:0.1.5" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/a0839c635cde48d45d49d3ea19aa17a8e5616225e516d76de1c4d0915d88b903241033a55f4aa047d212672e48ff809840e0d05afd80bbaacb330cef7804971a - languageName: node - linkType: hard - -"@react-email/container@npm:0.0.15": - version: 0.0.15 - resolution: "@react-email/container@npm:0.0.15" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/5098468b5336682f0f833a57cea1182bfe4a46bb03d5aa9fc85f96ab1e79845e30a9487c4c56094a9482465e94aa4395f10978c99b1772ca4bd0823d2d9071f6 - languageName: node - linkType: hard - -"@react-email/font@npm:0.0.9": - version: 0.0.9 - resolution: "@react-email/font@npm:0.0.9" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/b51c9bc22f3ea6e26c34fd5e4186be0b42a0414558f318b5784079145b267f7316729d54c8ba8c955ee5100e4b9ca9f5048c123a66ad2e277d49dd07c718287c - languageName: node - linkType: hard - -"@react-email/head@npm:0.0.12": - version: 0.0.12 - resolution: "@react-email/head@npm:0.0.12" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/511147ea52330f5752e2d226a3417192adb5c0f301e35077a07fd66b99504eda6c3bfa49713a10400cf8542d75dfb317e5050b5383b285129db9854d5760d97d - languageName: node - linkType: hard - -"@react-email/heading@npm:0.0.15": - version: 0.0.15 - resolution: "@react-email/heading@npm:0.0.15" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/74e58d2933eb6d37f4f3a6f82c63af6c9ffe2ce8cd14c7f6e27f8b76d39800cbf1a215d86e17a55d69eca4c73d5e9ea747ae1cd0a7df4a08a861a13afb9989dc - languageName: node - linkType: hard - -"@react-email/hr@npm:0.0.11": - version: 0.0.11 - resolution: "@react-email/hr@npm:0.0.11" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/9d8199bdcfdc7e180636cdc11b7af5c44d185598fedd97dd0734103d662074ed8b7204e362c31f0dfd3925c37181e2fe22dbf1f76908c862d489f34e7efea7a9 - languageName: node - linkType: hard - -"@react-email/html@npm:0.0.11": - version: 0.0.11 - resolution: "@react-email/html@npm:0.0.11" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/04be96e135677cd507636877c2f9bf1a507d40f6d4442ff3fac58bc70d478e7f23dd03d94ecc1605d3e368d50129281e71f4b7afb242f1b32d285bfc37236a09 - languageName: node - linkType: hard - -"@react-email/img@npm:0.0.11": - version: 0.0.11 - resolution: "@react-email/img@npm:0.0.11" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/082e9f7d7290de4d340b06bc0ab0e4ffec08604885fdf06d93594e3684726279a3a65c0be2c83c9397bb832289078d533f0499b423ed3558f7736ad7d333d457 - languageName: node - linkType: hard - -"@react-email/link@npm:0.0.12": - version: 0.0.12 - resolution: "@react-email/link@npm:0.0.12" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/d831666bd52af9ba3a50bd4853e2b70387a041264e3781d3dab4708b755e8b2cb6df339f71eecd889b35221c205bd9997d47e950d1e3b226b4be50a22405ca87 - languageName: node - linkType: hard - -"@react-email/markdown@npm:0.0.15": - version: 0.0.15 - resolution: "@react-email/markdown@npm:0.0.15" - dependencies: - md-to-react-email: "npm:^5.0.5" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/68dc46d929ad0daf86f7d25e159455bdfbf4c0f0ca8ad152caf407f2eb154f4edb88912728cd5bba0c01f4806217e533e66baa02138c8ddcb5f2049719da7137 - languageName: node - linkType: hard - -"@react-email/preview@npm:0.0.13": - version: 0.0.13 - resolution: "@react-email/preview@npm:0.0.13" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/223cff5238f0bfc4f1c3cdd1ae4f8b92cde16a60594d4d5073607b7af0b3cfaf5ae10234ca531d9403c7143afcba2e71bc12c1aba75e51f0ae58fd9c0b8bf542 - languageName: node - linkType: hard - -"@react-email/render@npm:1.2.3, @react-email/render@npm:^1.2.3": - version: 1.2.3 - resolution: "@react-email/render@npm:1.2.3" - dependencies: - html-to-text: "npm:^9.0.5" - prettier: "npm:^3.5.3" - react-promise-suspense: "npm:^0.3.4" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/7ddd786e808fae6dc9aa456965ec5a67dff5d143b027c70538158e182a99a59b784a092c44f61ace20368ab5e31c31875ae1513593efe153d029c9870928d07b - languageName: node - linkType: hard - -"@react-email/render@npm:>=2.0.8": - version: 2.0.8 - resolution: "@react-email/render@npm:2.0.8" +"@react-email/render@npm:>=2.1.0": + version: 2.1.0 + resolution: "@react-email/render@npm:2.1.0" dependencies: + entities: "npm:^4.5.0" html-to-text: "npm:^9.0.5" + html5parser: "npm:^3.0.0" prettier: "npm:^3.5.3" peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/183793beaab413523e1ffbf3e07b352e33c0b7b0912e6bee0da2aceb495f327694f98b55957255419cbcaf771c538f2b084580d4d3a78c92ccf7a5cad527f73c + checksum: 10c0/d117fb48711fb491f8f958806278c23f5d816e5baf999faef9d93922581d5c9128c7b320418317c486f9a68760deb32a1aa97efde946f4027a7aadda18aab7fc languageName: node linkType: hard -"@react-email/row@npm:0.0.12": - version: 0.0.12 - resolution: "@react-email/row@npm:0.0.12" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/440c54071543700ce7a0db4749c93c56fa0cfae1057464231b25d9d7598f81a2f395816b922376acd5445c29163ae2858e4fc840d8cf30f3d4520104f7f5e3ed - languageName: node - linkType: hard - -"@react-email/section@npm:0.0.16": - version: 0.0.16 - resolution: "@react-email/section@npm:0.0.16" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/48bfacdd78d403b50c4c1098d100a96f3afe6159d910374a0306d6186daa1bf3803b4a2b3163fb38d9137835041b9f70145c7bb2f652ba347a82a0d396fe2ef2 - languageName: node - linkType: hard - -"@react-email/tailwind@npm:1.2.2": - version: 1.2.2 - resolution: "@react-email/tailwind@npm:1.2.2" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/fa7d0a4d518a77cf0ec26c57aeac8a24509344225a19d6f3c1320a4357d88b2d48e592f312286ec59ef17333d875e3f3437cd09102d793896229e3f5502d079e - languageName: node - linkType: hard - -"@react-email/text@npm:0.1.5": - version: 0.1.5 - resolution: "@react-email/text@npm:0.1.5" - peerDependencies: - react: ^18.0 || ^19.0 || ^19.0.0-rc - checksum: 10c0/ad825332903917d1185ce7c5e4d19846496565996f82b71a873648dffc6342d382606a96a5a00a15a16abef5322540d56b481ad3ca21c8b34de9b598715ec149 - languageName: node - linkType: hard - -"@react-email/ui@npm:6.5.0": - version: 6.5.0 - resolution: "@react-email/ui@npm:6.5.0" +"@react-email/ui@npm:^6.9.0": + version: 6.9.0 + resolution: "@react-email/ui@npm:6.9.0" dependencies: - esbuild: "npm:0.28.0" + esbuild: "npm:0.28.1" next: "npm:16.2.6" - checksum: 10c0/af9b7449f3b66a461e296ccc93a0b779898a262973f698729a67243fec4eef49d6e395d393c01399217e4c0f87186ebf90bb173b2b0802e2bfac04d02d9692d9 + checksum: 10c0/e031a5e96e19adbb51cb2c6c2425d4738ee034fcccc3741b4683e829abb94caa62d4f18b8d571f2d7b6e98eba64c46be6e2d5f17aa895f545e1e7f4a4e7870f2 languageName: node linkType: hard @@ -30761,13 +30518,6 @@ __metadata: languageName: node linkType: hard -"fast-deep-equal@npm:^2.0.1": - version: 2.0.1 - resolution: "fast-deep-equal@npm:2.0.1" - checksum: 10c0/1602e0d6ed63493c865cc6b03f9070d6d3926e8cd086a123060b58f80a295f3f08b1ecfb479ae7c45b7fd45535202aea7cf5b49bc31bffb81c20b1502300be84 - languageName: node - linkType: hard - "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -33328,6 +33078,13 @@ __metadata: languageName: node linkType: hard +"html5parser@npm:^3.0.0": + version: 3.0.0 + resolution: "html5parser@npm:3.0.0" + checksum: 10c0/1f64db31ce882504ec73c36bc87e5225ad02e472f4953215cbda7a602ad5f5f85ea12d1b490bcf5713ae9ecbdc1b2b0dc8643075fd4cc3816624746762b5c0b3 + languageName: node + linkType: hard + "htmlparser2@npm:^8.0.2": version: 8.0.2 resolution: "htmlparser2@npm:8.0.2" @@ -36075,6 +35832,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:2.6.1": + version: 2.6.1 + resolution: "jiti@npm:2.6.1" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/79b2e96a8e623f66c1b703b98ec1b8be4500e1d217e09b09e343471bbb9c105381b83edbb979d01cef18318cc45ce6e153571b6c83122170eefa531c64b6789b + languageName: node + linkType: hard + "jiti@npm:^1.17.1": version: 1.21.6 resolution: "jiti@npm:1.21.6" @@ -37856,15 +37622,6 @@ __metadata: languageName: node linkType: hard -"marked@npm:7.0.4": - version: 7.0.4 - resolution: "marked@npm:7.0.4" - bin: - marked: bin/marked.js - checksum: 10c0/7f5993bfb2d260806ffada81051c45952857117cba0fd82790779dc696c7ebd35a96be47409b4bdabd75e7ede286f5f5142a75a47200e3fa54eaf8b0cd6f74f6 - languageName: node - linkType: hard - "matchmediaquery@npm:^0.3.0": version: 0.3.1 resolution: "matchmediaquery@npm:0.3.1" @@ -37881,17 +37638,6 @@ __metadata: languageName: node linkType: hard -"md-to-react-email@npm:^5.0.5": - version: 5.0.5 - resolution: "md-to-react-email@npm:5.0.5" - dependencies: - marked: "npm:7.0.4" - peerDependencies: - react: ^18.0 || ^19.0 - checksum: 10c0/0d6eedb905562d88025fbf45c79117d9d668c86427eac9a3ecc2f5082a7fac9cf55c271f8892083e994d794322e8f0ede928ee2380465c9f4f878cbd2a392999 - languageName: node - linkType: hard - "md5.js@npm:^1.3.4": version: 1.3.5 resolution: "md5.js@npm:1.3.5" @@ -43739,13 +43485,13 @@ __metadata: languageName: node linkType: hard -"react-email@npm:6.5.0": - version: 6.5.0 - resolution: "react-email@npm:6.5.0" +"react-email@npm:^6.9.0": + version: 6.9.0 + resolution: "react-email@npm:6.9.0" dependencies: - "@babel/parser": "npm:7.27.0" - "@babel/traverse": "npm:7.27.0" - "@react-email/render": "npm:>=2.0.8" + "@babel/parser": "npm:7.29.2" + "@babel/traverse": "npm:7.29.0" + "@react-email/render": "npm:>=2.1.0" chokidar: "npm:^4.0.3" commander: "npm:^13.0.0" conf: "npm:^15.0.2" @@ -43753,7 +43499,7 @@ __metadata: debounce: "npm:^2.0.0" esbuild: "npm:^0.28.0" glob: "npm:^13.0.6" - jiti: "npm:2.4.2" + jiti: "npm:2.6.1" log-symbols: "npm:^7.0.0" marked: "npm:^15.0.12" mime-types: "npm:^3.0.0" @@ -43770,7 +43516,7 @@ __metadata: react-dom: ^18.0 || ^19.0 || ^19.0.0-rc bin: email: ./dist/cli/index.mjs - checksum: 10c0/5d49c7771db71f57358ca33f260db709b55ff6844de06a468a44aa2c36c8b5ab9b023289a9607a6a56b87f6b01bd9992cfa1ce2da9b82f76ab99ea3f79bf759c + checksum: 10c0/7c2c5fbd2b1fe1d5a42e02aa9f9e9cbe1b08b6d2b9d2c4e74aeeccc15f192dcc3e2c4b7a9fb9995cd3e6bddaa864730864fdf6d671d53df3fb6e093821794536 languageName: node linkType: hard @@ -43988,15 +43734,6 @@ __metadata: languageName: node linkType: hard -"react-promise-suspense@npm:^0.3.4": - version: 0.3.4 - resolution: "react-promise-suspense@npm:0.3.4" - dependencies: - fast-deep-equal: "npm:^2.0.1" - checksum: 10c0/ab7a22f5400f9e9933995537bf6430a4c79e33a121aedb51864968e7604e5c40421fd539ead62554f32300b7d49755c79636de06caa36fe52973b626b4ddfebf - languageName: node - linkType: hard - "react-qr-code@npm:^2.0.18": version: 2.0.18 resolution: "react-qr-code@npm:2.0.18" @@ -49166,14 +48903,13 @@ __metadata: "@lingui/react": "npm:^5.9.5" "@lingui/swc-plugin": "npm:^5.11.0" "@lingui/vite-plugin": "npm:^5.9.5" - "@react-email/components": "npm:^0.5.3" - "@react-email/ui": "npm:6.5.0" + "@react-email/ui": "npm:^6.9.0" "@tiptap/core": "npm:^3.4.2" "@types/react": "npm:^19.2.0" "@types/react-dom": "npm:^19.2.0" "@typescript/native-preview": "npm:^7.0.0-dev.20260116.1" "@vitejs/plugin-react-swc": "npm:^4.3.1" - react-email: "npm:6.5.0" + react-email: "npm:^6.9.0" tsc-alias: "npm:^1.8.16" twenty-shared: "workspace:*" vite-plugin-dts: "npm:^4.5.4" @@ -49264,7 +49000,6 @@ __metadata: "@nivo/pie": "npm:^0.99.0" "@nivo/radial-bar": "npm:^0.99.0" "@playwright/test": "npm:^1.60.0" - "@react-email/components": "npm:^0.5.3" "@react-pdf/renderer": "npm:^4.1.6" "@scalar/api-reference-react": "npm:^0.9.42" "@sentry/react": "npm:^10.51.0" @@ -49520,7 +49255,6 @@ __metadata: "@ptc-org/nestjs-query-core": "npm:^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": "npm:^1.2.3" "@react-pdf/renderer": "npm:^4.1.6" "@sentry/nestjs": "npm:^10.59.0" "@sentry/node": "npm:^10.59.0"