feat: email package

This commit is contained in:
pandeymangg
2025-12-16 22:59:45 +05:30
parent e9f800f017
commit 8736b4bd02
29 changed files with 2608 additions and 551 deletions

View File

@@ -1,6 +1,10 @@
import { TFunction } from "i18next";
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
import React from "react";
import {
Column,
Container,
ElementHeader,
Button as EmailButton,
Img,
Link,
@@ -8,11 +12,8 @@ import {
Section,
Tailwind,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
import { TFunction } from "i18next";
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
import React from "react";
render,
} from "@formbricks/email";
import { TSurveyCTAElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
@@ -24,7 +25,6 @@ import { isLight, mixColor } from "@/lib/utils/colors";
import { parseRecallInfo } from "@/lib/utils/recall";
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { ElementHeader } from "./email-element-header";
interface PreviewEmailTemplateProps {
survey: TSurvey;

View File

@@ -1,6 +1,17 @@
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import {
renderEmailCustomizationPreviewEmail,
renderEmbedSurveyPreviewEmail,
renderForgotPasswordEmail,
renderInviteAcceptedEmail,
renderInviteEmail,
renderLinkSurveyEmail,
renderNewEmailVerification,
renderPasswordResetNotifyEmail,
renderResponseFinishedEmail,
renderVerificationEmail,
} from "@formbricks/email";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
@@ -23,17 +34,8 @@ import {
import { getPublicDomain } from "@/lib/getPublicUrl";
import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
import { VerificationEmail } from "./emails/auth/verification-email";
import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email";
import { InviteEmail } from "./emails/invite/invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT);
@@ -89,7 +91,7 @@ export const sendVerificationNewEmail = async (id: string, email: string): Promi
const token = createEmailChangeToken(id, email);
const verifyLink = `${WEBAPP_URL}/verify-email-change?token=${encodeURIComponent(token)}`;
const html = await render(await NewEmailVerification({ verifyLink }));
const html = await renderNewEmailVerification({ verifyLink, t });
return await sendEmail({
to: email,
@@ -117,7 +119,7 @@ export const sendVerificationEmail = async ({
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?token=${encodeURIComponent(token)}`;
const html = await render(await VerificationEmail({ verificationRequestLink, verifyLink }));
const html = await renderVerificationEmail({ verificationRequestLink, verifyLink, t });
return await sendEmail({
to: email,
@@ -140,7 +142,7 @@ export const sendForgotPasswordEmail = async (user: {
expiresIn: "1d",
});
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
const html = await render(await ForgotPasswordEmail({ verifyLink }));
const html = await renderForgotPasswordEmail({ verifyLink, t });
return await sendEmail({
to: user.email,
subject: t("emails.forgot_password_email_subject"),
@@ -150,7 +152,7 @@ export const sendForgotPasswordEmail = async (user: {
export const sendPasswordResetNotifyEmail = async (user: { email: string }): Promise<boolean> => {
const t = await getTranslate();
const html = await render(await PasswordResetNotifyEmail());
const html = await renderPasswordResetNotifyEmail({ t });
return await sendEmail({
to: user.email,
subject: t("emails.password_reset_notify_email_subject"),
@@ -171,7 +173,7 @@ export const sendInviteMemberEmail = async (
const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`;
const html = await render(await InviteEmail({ inviteeName, inviterName, verifyLink }));
const html = await renderInviteEmail({ inviteeName, inviterName, verifyLink, t });
return await sendEmail({
to: email,
subject: t("emails.invite_member_email_subject"),
@@ -185,7 +187,7 @@ export const sendInviteAcceptedEmail = async (
email: string
): Promise<void> => {
const t = await getTranslate();
const html = await render(await InviteAcceptedEmail({ inviteeName, inviterName }));
const html = await renderInviteAcceptedEmail({ inviteeName, inviterName, t });
await sendEmail({
to: email,
subject: t("emails.invite_accepted_email_subject"),
@@ -208,16 +210,19 @@ export const sendResponseFinishedEmail = async (
throw new Error("Organization not found");
}
const html = await render(
await ResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
})
);
// Pre-process the element response mapping before passing to email
const elements = getElementResponseMapping(survey, response);
const html = await renderResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
elements,
t,
});
await sendEmail({
to: email,
@@ -241,7 +246,7 @@ export const sendEmbedSurveyPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const html = await render(await EmbedSurveyPreviewEmail({ html: innerHtml, environmentId, logoUrl }));
const html = await renderEmbedSurveyPreviewEmail({ html: innerHtml, environmentId, logoUrl, t });
return await sendEmail({
to,
subject: t("emails.embed_survey_preview_email_subject"),
@@ -255,7 +260,7 @@ export const sendEmailCustomizationPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const emailHtmlBody = await render(await EmailCustomizationPreviewEmail({ userName, logoUrl }));
const emailHtmlBody = await renderEmailCustomizationPreviewEmail({ userName, logoUrl, t });
return await sendEmail({
to,
@@ -280,7 +285,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
};
const surveyLink = getSurveyLink();
const html = await render(await LinkSurveyEmail({ surveyName, surveyLink, logoUrl }));
const html = await renderLinkSurveyEmail({ surveyName, surveyLink, logoUrl, t });
return await sendEmail({
to: data.email,
subject: t("emails.verified_link_survey_email_subject"),

View File

@@ -1,9 +1,17 @@
import { render } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import {
ProcessedHiddenField,
ProcessedResponseElement,
ProcessedVariable,
renderFollowUpEmail,
} from "@formbricks/email";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { sendEmail } from "@/modules/email";
import { FollowUpEmail } from "@/modules/survey/follow-ups/components/follow-up-email";
export const sendFollowUpEmail = async ({
followUp,
@@ -28,21 +36,70 @@ export const sendFollowUpEmail = async ({
}): Promise<void> => {
const {
action: {
properties: { subject },
properties: { subject, body },
},
} = followUp;
const emailHtmlBody = await render(
await FollowUpEmail({
followUp,
logoUrl,
attachResponseData,
includeVariables,
includeHiddenFields,
survey,
response,
})
);
const t = await getTranslate();
// Process body: parse recall tags and sanitize HTML
const processedBody = dompurify.sanitize(parseRecallInfo(body, response.data, response.variables), {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs
ADD_ATTR: ["target"], // Allow 'target' attribute for links
});
// Process response data
const responseData: ProcessedResponseElement[] = attachResponseData
? getElementResponseMapping(survey, response).map((e) => ({
element: e.element,
response: e.response,
type: e.type,
}))
: [];
// Process variables
const variables: ProcessedVariable[] =
attachResponseData && includeVariables
? survey.variables
.filter((variable) => {
const variableResponse = response.variables[variable.id];
return (
(typeof variableResponse === "string" || typeof variableResponse === "number") &&
variableResponse !== undefined
);
})
.map((variable) => ({
id: variable.id,
name: variable.name,
type: variable.type,
value: response.variables[variable.id] as string | number,
}))
: [];
// Process hidden fields
const hiddenFields: ProcessedHiddenField[] =
attachResponseData && includeHiddenFields
? (survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => ({
id: hiddenFieldId,
value: response.data[hiddenFieldId] as string,
})) ?? [])
: [];
const emailHtmlBody = await renderFollowUpEmail({
body: processedBody,
responseData,
variables,
hiddenFields,
logoUrl,
t,
});
await sendEmail({
to,

View File

@@ -29,6 +29,7 @@
"@dnd-kit/utilities": "3.2.2",
"@formbricks/cache": "workspace:*",
"@formbricks/database": "workspace:*",
"@formbricks/email": "workspace:*",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/js-core": "workspace:*",
"@formbricks/logger": "workspace:*",
@@ -71,7 +72,6 @@
"@radix-ui/react-toggle": "1.1.8",
"@radix-ui/react-toggle-group": "1.1.9",
"@radix-ui/react-tooltip": "1.2.6",
"@react-email/components": "0.0.38",
"@sentry/nextjs": "10.5.0",
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/forms": "0.5.10",

View File

@@ -0,0 +1,4 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-react.js"],
parser: "@typescript-eslint/parser",
};

45
packages/email/README.md Normal file
View File

@@ -0,0 +1,45 @@
# @formbricks/emails
Email templates for Formbricks with React Email preview server.
## Purpose
This package provides email templates for visual QA and preview. It includes:
- Email templates (auth, invite, survey, general)
- Shared email UI components
- Mock translation utilities for preview
- Example data for template rendering
## Development
Run the React Email preview server:
```bash
pnpm dev
```
Visit `localhost:3456` to preview all email templates with mock data.
## Usage in Production
The web app imports templates from this package:
```typescript
import { VerificationEmail } from "@formbricks/emails";
// Pass real translation function and data
const html = await render(
await VerificationEmail({
verifyLink,
verificationRequestLink,
t, // Real i18n function
})
);
```
## Architecture
- **Preview Mode**: Templates use mock `t()` function and example data
- **Production Mode**: Web app passes real `t()` function and actual data
- **No Business Logic**: SMTP, i18n, JWT, and database logic remain in web app

View File

@@ -0,0 +1,33 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailButton } from "@/src/components/email-button";
import { EmailFooter } from "@/src/components/email-footer";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface ForgotPasswordEmailProps {
verifyLink: string;
t?: TFunction;
}
export function ForgotPasswordEmail({ verifyLink, t = mockT }: ForgotPasswordEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.forgot_password_email_heading")}</Heading>
<Text className="text-sm">{t("emails.forgot_password_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.forgot_password_email_change_password")} />
<Text className="text-sm font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
<Text className="mb-0 text-sm">{t("emails.forgot_password_email_did_not_request")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function ForgotPasswordEmailPreview(): React.JSX.Element {
return <ForgotPasswordEmail {...exampleData.forgotPasswordEmail} />;
}

View File

@@ -0,0 +1,40 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import { EmailButton } from "@/src/components/email-button";
import { EmailFooter } from "@/src/components/email-footer";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface NewEmailVerificationProps {
readonly verifyLink: string;
readonly t?: TFunction;
}
export function NewEmailVerification({
verifyLink,
t = mockT,
}: NewEmailVerificationProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text className="text-sm">{t("emails.new_email_verification_text")}</Text>
<Text className="text-sm">{t("emails.verification_security_notice")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-sm text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function NewEmailVerificationPreview(): React.JSX.Element {
return <NewEmailVerification {...exampleData.newEmailVerification} />;
}

View File

@@ -0,0 +1,30 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailFooter } from "@/src/components/email-footer";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface PasswordResetNotifyEmailProps {
readonly t?: TFunction;
}
export function PasswordResetNotifyEmail({
t = mockT,
}: PasswordResetNotifyEmailProps = {}): React.JSX.Element {
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.password_changed_email_heading")}</Heading>
<Text className="text-sm">{t("emails.password_changed_email_text")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function PasswordResetNotifyEmailPreview(): React.JSX.Element {
return <PasswordResetNotifyEmail {...exampleData.passwordResetNotifyEmail} />;
}

View File

@@ -0,0 +1,47 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import { EmailButton } from "@/src/components/email-button";
import { EmailFooter } from "@/src/components/email-footer";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface VerificationEmailProps {
verifyLink: string;
verificationRequestLink: string;
t?: TFunction;
}
export function VerificationEmail({
verifyLink,
verificationRequestLink,
t = mockT,
}: VerificationEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text className="text-sm">{t("emails.verification_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-sm text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<Text className="text-sm">
{t("emails.verification_email_if_expired_request_new_token")}
<Link className="text-sm text-black underline" href={verificationRequestLink}>
{t("emails.verification_email_request_new_verification")}
</Link>
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function VerificationEmailPreview(): React.JSX.Element {
return <VerificationEmail {...exampleData.verificationEmail} />;
}

View File

@@ -0,0 +1,32 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface EmailCustomizationPreviewEmailProps {
userName: string;
logoUrl?: string;
t?: TFunction;
}
export function EmailCustomizationPreviewEmail({
userName,
logoUrl,
t = mockT,
}: EmailCustomizationPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.email_customization_preview_email_heading", { userName })}</Heading>
<Text className="text-sm">{t("emails.email_customization_preview_email_text")}</Text>
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function EmailCustomizationPreviewEmailPreview(): React.JSX.Element {
return <EmailCustomizationPreviewEmail {...exampleData.emailCustomizationPreviewEmail} />;
}

View File

@@ -0,0 +1,39 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailFooter } from "@/src/components/email-footer";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface InviteAcceptedEmailProps {
inviterName: string;
inviteeName: string;
t?: TFunction;
}
export function InviteAcceptedEmail({
inviterName,
inviteeName,
t = mockT,
}: InviteAcceptedEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<Container>
<Heading>
{t("emails.invite_accepted_email_heading", { inviterName })} {inviterName}
</Heading>
<Text className="text-sm">
{t("emails.invite_accepted_email_text_par1", { inviteeName })} {inviteeName}{" "}
{t("emails.invite_accepted_email_text_par2")}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function InviteAcceptedEmailPreview(): React.JSX.Element {
return <InviteAcceptedEmail {...exampleData.inviteAcceptedEmail} />;
}

View File

@@ -0,0 +1,43 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailButton } from "@/src/components/email-button";
import { EmailFooter } from "@/src/components/email-footer";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface InviteEmailProps {
inviteeName: string;
inviterName: string;
verifyLink: string;
t?: TFunction;
}
export function InviteEmail({
inviteeName,
inviterName,
verifyLink,
t = mockT,
}: InviteEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<Container>
<Heading>
{t("emails.invite_email_heading", { inviteeName })} {inviteeName}
</Heading>
<Text className="text-sm">
{t("emails.invite_email_text_par1", { inviterName })} {inviterName}{" "}
{t("emails.invite_email_text_par2")}
</Text>
<EmailButton href={verifyLink} label={t("emails.invite_email_button_label")} />
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function InviteEmailPreview(): React.JSX.Element {
return <InviteEmail {...exampleData.inviteEmail} />;
}

View File

@@ -0,0 +1,42 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface EmbedSurveyPreviewEmailProps {
html: string;
environmentId: string;
logoUrl?: string;
t?: TFunction;
}
export function EmbedSurveyPreviewEmail({
html,
environmentId,
logoUrl,
t = mockT,
}: EmbedSurveyPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.embed_survey_preview_email_heading")}</Heading>
<Text className="text-sm">{t("emails.embed_survey_preview_email_text")}</Text>
<Text className="text-sm">
<b>{t("emails.embed_survey_preview_email_didnt_request")}</b>{" "}
{t("emails.embed_survey_preview_email_fight_spam")}
</Text>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: html }} />
<Text className="text-center text-sm text-slate-700">
{t("emails.embed_survey_preview_email_environment_id")}: {environmentId}
</Text>
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function EmbedSurveyPreviewEmailPreview(): React.JSX.Element {
return <EmbedSurveyPreviewEmail {...exampleData.embedSurveyPreviewEmail} />;
}

View File

@@ -0,0 +1,135 @@
import { Column, Hr, Row, Text } from "@react-email/components";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { EmailTemplate } from "@/src/components/email-template";
import { renderEmailResponseValue } from "@/src/lib/email-utils";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
// Processed data types - web app does all the processing
interface ProcessedResponseElement {
element: string;
response: string | string[];
type: TSurveyElementTypeEnum;
}
interface ProcessedVariable {
id: string;
name: string;
type: "text" | "number";
value: string | number;
}
interface ProcessedHiddenField {
id: string;
value: string;
}
export interface FollowUpEmailProps {
readonly body: string; // Already processed HTML with recall tags replaced
readonly responseData?: ProcessedResponseElement[]; // Already mapped elements
readonly variables?: ProcessedVariable[]; // Already filtered variables
readonly hiddenFields?: ProcessedHiddenField[]; // Already filtered hidden fields
readonly logoUrl?: string;
readonly t?: TFunction;
}
export function FollowUpEmail({
body,
responseData = [],
variables = [],
hiddenFields = [],
logoUrl,
t = mockT,
}: FollowUpEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<>
<div dangerouslySetInnerHTML={{ __html: body }} />
{responseData.length > 0 ? (
<>
<Hr />
<Text className="mb-4 text-base font-semibold text-slate-900">{t("emails.response_data")}</Text>
</>
) : null}
{responseData.map((e) => {
if (!e.response) return null;
return (
<Row key={e.element}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">{e.element}</Text>
{renderEmailResponseValue(e.response, e.type, t, true)}
</Column>
</Row>
);
})}
{variables.map((variable) => (
<Row key={variable.id}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{variable.type === "number"
? `${t("emails.number_variable")}: ${variable.name}`
: `${t("emails.text_variable")}: ${variable.name}`}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
{variable.value}
</Text>
</Column>
</Row>
))}
{hiddenFields.map((hiddenField) => (
<Row key={hiddenField.id}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{t("emails.hidden_field")}: {hiddenField.id}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
{hiddenField.value}
</Text>
</Column>
</Row>
))}
</>
</EmailTemplate>
);
}
// Default export for preview server with example data
export default function FollowUpEmailPreview(): React.JSX.Element {
return (
<FollowUpEmail
body="<p>Thank you for your feedback! We've received your response and will review it shortly.</p><p>Here's a summary of what you submitted:</p>"
responseData={[
{
element: "What did you like most?",
response: "The customer service was excellent!",
type: TSurveyElementTypeEnum.OpenText,
},
{
element: "How would you rate your experience?",
response: "5",
type: TSurveyElementTypeEnum.Rating,
},
]}
variables={[
{
id: "var-1",
name: "Customer ID",
type: "text",
value: "CUST-456",
},
]}
hiddenFields={[
{
id: "userId",
value: "user-abc-123",
},
]}
logoUrl="https://app.formbricks.com/logo.png"
/>
);
}

View File

@@ -0,0 +1,42 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailButton } from "@/src/components/email-button";
import { EmailFooter } from "@/src/components/email-footer";
import { EmailTemplate } from "@/src/components/email-template";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
interface LinkSurveyEmailProps {
surveyName: string;
surveyLink: string;
logoUrl: string;
t?: TFunction;
}
export function LinkSurveyEmail({
surveyName,
surveyLink,
logoUrl,
t = mockT,
}: LinkSurveyEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.verification_email_hey")}</Heading>
<Text className="text-sm">{t("emails.verification_email_thanks")}</Text>
<Text className="text-sm">{t("emails.verification_email_to_fill_survey")}</Text>
<EmailButton href={surveyLink} label={t("emails.verification_email_take_survey")} />
<Text className="text-sm text-slate-400">
{t("emails.verification_email_survey_name")}: {surveyName}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function LinkSurveyEmailPreview(): React.JSX.Element {
return <LinkSurveyEmail {...exampleData.linkSurveyEmail} />;
}

View File

@@ -0,0 +1,189 @@
import { Column, Container, Heading, Hr, Link, Row, Section, Text } from "@react-email/components";
import { FileDigitIcon, FileType2Icon } from "lucide-react";
import type { TOrganization } from "@formbricks/types/organizations";
import type { TResponse } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { EmailButton } from "@/src/components/email-button";
import { EmailTemplate } from "@/src/components/email-template";
import { renderEmailResponseValue } from "@/src/lib/email-utils";
import { exampleData } from "@/src/lib/example-data";
import { t as mockT } from "@/src/lib/mock-translate";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
export interface MappedElement {
element: string;
response: string | string[];
type: TSurveyElementTypeEnum;
}
export interface ResponseFinishedEmailProps {
survey: TSurvey;
responseCount: number;
response: TResponse;
WEBAPP_URL: string;
environmentId: string;
organization: TOrganization;
elements: MappedElement[]; // Pre-processed data, not a function
t?: TFunction;
}
// Helper function to get element response mapping (simplified)
const mockGetElementResponseMapping = (survey: TSurvey, response: TResponse) => {
// For preview, just return the response data as elements
return Object.entries(response.data)
.filter(([key]) => !survey.hiddenFields.fieldIds?.includes(key))
.map(([key, value]) => ({
element: key,
response: value as string | string[],
type: TSurveyElementTypeEnum.OpenText, // Default type for preview
}));
};
export function ResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
elements,
t = mockT,
}: ResponseFinishedEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<Container>
<Row>
<Column>
<Heading> {t("emails.survey_response_finished_email_hey")}</Heading>
<Text className="mb-4 text-sm">
{t("emails.survey_response_finished_email_congrats", {
surveyName: survey.name,
})}
</Text>
<Hr />
{elements.map((e) => {
if (!e.response) return null;
return (
<Row key={e.element}>
<Column className="w-full font-medium">
<Text className="mb-2 text-sm">{e.element}</Text>
{renderEmailResponseValue(e.response, e.type, t)}
</Column>
</Row>
);
})}
{survey.variables
.filter((variable) => {
const variableResponse = response.variables[variable.id];
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
const variableResponse = response.variables[variable.id];
return (
<Row key={variable.id}>
<Column className="w-full text-sm font-medium">
<Text className="mb-1 flex items-center gap-2">
{variable.type === "number" ? (
<FileDigitIcon className="h-4 w-4" />
) : (
<FileType2Icon className="h-4 w-4" />
)}
{variable.name}
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
{variableResponse}
</Text>
</Column>
</Row>
);
})}
{survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId] as string;
return (
<Row key={hiddenFieldId}>
<Column className="w-full font-medium">
<Text className="mb-2 flex items-center gap-2 text-sm">
{hiddenFieldId} <EyeOffIcon />
</Text>
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
{hiddenFieldResponse}
</Text>
</Column>
</Row>
);
})}
<EmailButton
href={`${WEBAPP_URL}/environments/${environmentId}/surveys/${survey.id}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA`}
label={
responseCount > 1
? t("emails.survey_response_finished_email_view_more_responses", {
responseCount: String(responseCount - 1),
})
: t("emails.survey_response_finished_email_view_survey_summary")
}
/>
<Hr />
<Section className="mt-4 text-center text-sm">
<Text className="text-sm font-medium">
{t("emails.survey_response_finished_email_dont_want_notifications")}
</Text>
<Text className="mb-0">
<Link
className="text-sm text-black underline"
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}`}>
{t("emails.survey_response_finished_email_turn_off_notifications_for_this_form")}
</Link>
</Text>
<Text className="mt-0">
<Link
className="text-sm text-black underline"
href={`${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedOrganizationIds&elementId=${organization.id}`}>
{t("emails.survey_response_finished_email_turn_off_notifications_for_all_new_forms")}
</Link>
</Text>
</Section>
</Column>
</Row>
</Container>
</EmailTemplate>
);
}
function EyeOffIcon(): React.JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-eye-off h-4 w-4 rounded-lg bg-slate-200 p-1">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
<line x1="2" x2="22" y1="2" y2="22" />
</svg>
);
}
// Default export for preview server
export default function ResponseFinishedEmailPreview(): React.JSX.Element {
const { survey, response, ...rest } = exampleData.responseFinishedEmail;
const elements = mockGetElementResponseMapping(survey, response);
return <ResponseFinishedEmail {...rest} survey={survey} response={response} elements={elements} />;
}

View File

@@ -0,0 +1,27 @@
{
"name": "@formbricks/email",
"version": "1.0.0",
"private": true,
"description": "Email templates for Formbricks with React Email preview server",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"dev": "email dev --port 3456",
"build": "tsc --noEmit",
"lint": "eslint src --fix --ext .ts,.tsx",
"clean": "rimraf .turbo node_modules dist"
},
"dependencies": {
"@react-email/components": "1.0.1",
"react": "19.1.2",
"react-dom": "19.1.2",
"react-email": "5.0.8"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/types": "workspace:*",
"@react-email/preview-server": "5.0.8"
}
}

View File

@@ -0,0 +1,16 @@
import { Button } from "@react-email/components";
interface EmailButtonProps {
label: string;
href: string;
}
export function EmailButton({ label, href }: EmailButtonProps): React.JSX.Element {
return (
<Button className="rounded-md bg-black px-6 py-3 text-sm text-white" href={href}>
{label}
</Button>
);
}
export default EmailButton;

View File

@@ -0,0 +1,29 @@
import { Container } from "@react-email/components";
interface ElementHeaderProps {
headline: string;
subheader?: string;
className?: string;
}
// Simple cn utility for className merging
const cn = (...classes: (string | undefined)[]) => {
return classes.filter(Boolean).join(" ");
};
export function ElementHeader({ headline, subheader, className }: ElementHeaderProps): React.JSX.Element {
return (
<>
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
<div dangerouslySetInnerHTML={{ __html: headline }} />
</Container>
{subheader && (
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
<div dangerouslySetInnerHTML={{ __html: subheader }} />
</Container>
)}
</>
);
}
export default ElementHeader;

View File

@@ -0,0 +1,15 @@
import { Text } from "@react-email/components";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
export function EmailFooter({ t }: { t: TFunction }): React.JSX.Element {
return (
<Text className="text-sm">
{t("emails.email_footer_text_1")}
<br />
{t("emails.email_footer_text_2")}
</Text>
);
}
export default EmailFooter;

View File

@@ -0,0 +1,59 @@
import { Body, Container, Html, Img, Link, Section, Tailwind } from "@react-email/components";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
const fbLogoUrl =
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface EmailTemplateProps {
readonly children: React.ReactNode;
readonly logoUrl?: string;
readonly t: TFunction;
}
export function EmailTemplate({ children, logoUrl, t }: EmailTemplateProps): React.JSX.Element {
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
return (
<Html>
<Tailwind>
<Body
className="m-0 h-full w-full justify-center bg-slate-50 p-6 text-center text-sm text-slate-800"
style={{
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
}}>
<Section>
{isDefaultLogo ? (
<Link href={logoLink} target="_blank">
<Img data-testid="default-logo-image" alt="Logo" className="mx-auto w-60" src={fbLogoUrl} />
</Link>
) : (
<Img
data-testid="logo-image"
alt="Logo"
className="mx-auto max-h-[100px] w-80 object-contain"
src={logoUrl}
/>
)}
</Section>
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left">
{children}
</Container>
<Section className="mt-4 text-center text-sm">
<Link
className="m-0 text-sm font-normal text-slate-500"
href="https://formbricks.com/?utm_source=email_header&utm_medium=email"
target="_blank"
rel="noopener noreferrer">
{t("emails.email_template_text_1")}
</Link>
</Section>
</Body>
</Tailwind>
</Html>
);
}
export default EmailTemplate;

View File

@@ -0,0 +1,52 @@
export { VerificationEmail } from "@/emails/auth/verification-email";
export { ForgotPasswordEmail } from "@/emails/auth/forgot-password-email";
export { NewEmailVerification } from "@/emails/auth/new-email-verification";
export { PasswordResetNotifyEmail } from "@/emails/auth/password-reset-notify-email";
export { InviteEmail } from "@/emails/invite/invite-email";
export { InviteAcceptedEmail } from "@/emails/invite/invite-accepted-email";
export { LinkSurveyEmail } from "@/emails/survey/link-survey-email";
export { EmbedSurveyPreviewEmail } from "@/emails/survey/embed-survey-preview-email";
export { ResponseFinishedEmail } from "@/emails/survey/response-finished-email";
export { EmailCustomizationPreviewEmail } from "@/emails/general/email-customization-preview-email";
export { FollowUpEmail } from "@/emails/survey/follow-up-email";
export { EmailButton } from "./components/email-button";
export { EmailFooter } from "./components/email-footer";
export { EmailTemplate } from "./components/email-template";
export { ElementHeader } from "./components/email-element-header";
export {
renderVerificationEmail,
renderForgotPasswordEmail,
renderNewEmailVerification,
renderPasswordResetNotifyEmail,
renderInviteEmail,
renderInviteAcceptedEmail,
renderLinkSurveyEmail,
renderEmbedSurveyPreviewEmail,
renderResponseFinishedEmail,
renderEmailCustomizationPreviewEmail,
renderFollowUpEmail,
} from "./lib/render";
export { render } from "@react-email/render";
export {
Body,
Button,
Column,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type { ProcessedResponseElement, ProcessedVariable, ProcessedHiddenField } from "./lib/render";

View File

@@ -0,0 +1,82 @@
import { Column, Container, Img, Link, Row, Text } from "@react-email/components";
import { FileIcon } from "lucide-react";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
// Simplified version - just get the filename from URL
const getOriginalFileNameFromUrl = (url: string): string => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || "file";
return decodeURIComponent(filename);
} catch {
return url.split("/").pop() || "file";
}
};
export const renderEmailResponseValue = (
response: string | string[],
questionType: TSurveyElementTypeEnum,
t: TFunction,
overrideFileUploadResponse = false
): React.JSX.Element => {
switch (questionType) {
case TSurveyElementTypeEnum.FileUpload:
return (
<Container>
{overrideFileUploadResponse ? (
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
</Text>
) : (
Array.isArray(response) &&
response.map((responseItem) => (
<Link
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-slate-200 p-2 text-sm text-black shadow-sm"
href={responseItem}
key={responseItem}>
<FileIcon className="h-4 w-4" />
<Text className="mx-auto mb-0 truncate text-sm">
{getOriginalFileNameFromUrl(responseItem)}
</Text>
</Link>
))
)}
</Container>
);
case TSurveyElementTypeEnum.PictureSelection:
return (
<Container>
<Row>
{Array.isArray(response) &&
response.map((responseItem) => (
<Column key={responseItem}>
<Img alt={responseItem.split("/").pop()} className="m-2 h-28" src={responseItem} />
</Column>
))}
</Row>
</Container>
);
case TSurveyElementTypeEnum.Ranking:
return (
<Container>
<Row className="mb-2 text-sm text-slate-700" dir="auto">
{Array.isArray(response) &&
response.filter(Boolean).map((item, index) => (
<Row key={item} className="mb-1 flex items-center">
<Column className="w-6 text-slate-400">#{index + 1}</Column>
<Column className="rounded bg-slate-100 px-2 py-1">{item}</Column>
</Row>
))}
</Row>
</Container>
);
default:
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response as string}</Text>;
}
};

View File

@@ -0,0 +1,150 @@
// Mock data for email templates to use in React Email preview server
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
export const exampleData = {
verificationEmail: {
verifyLink: "https://app.formbricks.com/auth/verify?token=example-verification-token",
verificationRequestLink: "https://app.formbricks.com/auth/verification-requested",
},
forgotPasswordEmail: {
verifyLink: "https://app.formbricks.com/auth/forgot-password/reset?token=example-reset-token",
},
newEmailVerification: {
verifyLink: "https://app.formbricks.com/verify-email-change?token=example-email-change-token",
},
passwordResetNotifyEmail: {
// No props needed
},
inviteEmail: {
inviteeName: "Jane Smith",
inviterName: "John Doe",
verifyLink: "https://app.formbricks.com/invite?token=example-invite-token",
},
inviteAcceptedEmail: {
inviterName: "John Doe",
inviteeName: "Jane Smith",
},
linkSurveyEmail: {
surveyName: "Customer Satisfaction Survey",
surveyLink:
"https://app.formbricks.com/s/example-survey-id?verify=example-token&suId=example-single-use-id",
logoUrl: "https://app.formbricks.com/logo.png",
},
embedSurveyPreviewEmail: {
html: '<div style="padding: 20px; background-color: #f3f4f6; border-radius: 8px;"><h3 style="margin-top: 0;">Example Survey Embed</h3><p>This is a preview of how your survey will look when embedded in an email.</p></div>',
environmentId: "clxyz123456789",
logoUrl: "https://app.formbricks.com/logo.png",
},
responseFinishedEmail: {
survey: {
id: "survey-123",
name: "Customer Feedback Survey",
variables: [
{
id: "var-1",
name: "Customer ID",
type: "text" as const,
},
],
hiddenFields: {
enabled: true,
fieldIds: ["userId"],
},
welcomeCard: {
enabled: false,
},
questions: [
{
id: "q1",
type: "openText" as const,
headline: { default: "What did you like most?" },
required: true,
inputType: "text" as const,
},
{
id: "q2",
type: "rating" as const,
headline: { default: "How would you rate your experience?" },
required: true,
scale: "number" as const,
range: 5,
},
],
endings: [],
styling: {},
createdBy: null,
} as unknown as TSurvey,
responseCount: 15,
response: {
id: "response-123",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey-123",
finished: true,
data: {
q1: "The customer service was excellent!",
q2: 5,
userId: "user-abc-123",
},
variables: {
"var-1": "CUST-456",
},
contactAttributes: {
email: "customer@example.com",
},
meta: {
userAgent: {},
url: "https://example.com",
},
tags: [],
notes: [],
ttc: {},
singleUseId: null,
language: "default",
displayId: null,
} as unknown as TResponse,
WEBAPP_URL: "https://app.formbricks.com",
environmentId: "env-123",
organization: {
id: "org-123",
name: "Acme Corporation",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
subscriptionStatus: null,
features: {
inAppSurvey: { status: "active" as const, unlimited: true },
linkSurvey: { status: "active" as const, unlimited: true },
userTargeting: { status: "active" as const, unlimited: true },
},
limits: {
monthly: {
responses: 1000,
miu: 10000,
},
},
},
isAIEnabled: false,
} as unknown as TOrganization,
},
emailCustomizationPreviewEmail: {
userName: "Alex Johnson",
logoUrl: "https://app.formbricks.com/logo.png",
},
};
// Type exports for use in templates
export type ExampleDataKeys = keyof typeof exampleData;
export type ExampleData<K extends ExampleDataKeys> = (typeof exampleData)[K];

View File

@@ -0,0 +1,110 @@
// Mock translation function for React Email preview server
// Returns English strings extracted from apps/web/locales/en-US.json
type TranslationKey = string;
type TranslationValue = string;
const translations: Record<TranslationKey, TranslationValue> = {
"emails.accept": "Accept",
"emails.click_or_drag_to_upload_files": "Click or drag to upload files.",
"emails.email_customization_preview_email_heading": "Hey {userName}",
"emails.email_customization_preview_email_subject": "Formbricks Email Customization Preview",
"emails.email_customization_preview_email_text":
"This is an email preview to show you which logo will be rendered in the emails.",
"emails.email_footer_text_1": "Have a great day!",
"emails.email_footer_text_2": "The Formbricks Team",
"emails.email_template_text_1": "This email was sent via Formbricks.",
"emails.embed_survey_preview_email_didnt_request": "Didn't request this?",
"emails.embed_survey_preview_email_environment_id": "Environment ID",
"emails.embed_survey_preview_email_fight_spam":
"Help us fight spam and forward this mail to hola@formbricks.com",
"emails.embed_survey_preview_email_heading": "Preview Email Embed",
"emails.embed_survey_preview_email_subject": "Formbricks Email Survey Preview",
"emails.embed_survey_preview_email_text": "This is how the code snippet looks embedded into an email:",
"emails.forgot_password_email_change_password": "Change password",
"emails.forgot_password_email_did_not_request": "If you didn't request this, please ignore this email.",
"emails.forgot_password_email_heading": "Change password",
"emails.forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"emails.forgot_password_email_subject": "Reset your Formbricks password",
"emails.forgot_password_email_text":
"You have requested a link to change your password. You can do this by clicking the link below:",
"emails.hidden_field": "Hidden field",
"emails.imprint": "Imprint",
"emails.invite_accepted_email_heading": "Hey",
"emails.invite_accepted_email_subject": "You've got a new organization member!",
"emails.invite_accepted_email_text_par1": "Just letting you know that",
"emails.invite_accepted_email_text_par2": "accepted your invitation. Have fun collaborating!",
"emails.invite_email_button_label": "Join organization",
"emails.invite_email_heading": "Hey",
"emails.invite_email_text_par1": "Your colleague",
"emails.invite_email_text_par2":
"invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"emails.invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"emails.new_email_verification_text": "To verify your new email address, please click the button below:",
"emails.number_variable": "Number variable",
"emails.password_changed_email_heading": "Password changed",
"emails.password_changed_email_text": "Your password has been changed successfully.",
"emails.password_reset_notify_email_subject": "Your Formbricks password has been changed",
"emails.privacy_policy": "Privacy Policy",
"emails.reject": "Reject",
"emails.render_email_response_value_file_upload_response_link_not_included":
"Link to uploaded file is not included for data privacy reasons",
"emails.response_data": "Response data",
"emails.response_finished_email_subject": "A response for {surveyName} was completed ✅",
"emails.response_finished_email_subject_with_email":
"{personEmail} just completed your {surveyName} survey ✅",
"emails.schedule_your_meeting": "Schedule your meeting",
"emails.select_a_date": "Select a date",
"emails.survey_response_finished_email_congrats":
"Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}",
"emails.survey_response_finished_email_dont_want_notifications": "Don't want to get these notifications?",
"emails.survey_response_finished_email_hey": "Hey 👋",
"emails.survey_response_finished_email_turn_off_notifications_for_all_new_forms":
"Turn off notifications for all newly created forms",
"emails.survey_response_finished_email_turn_off_notifications_for_this_form":
"Turn off notifications for this form",
"emails.survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
"emails.survey_response_finished_email_view_survey_summary": "View survey summary",
"emails.text_variable": "Text variable",
"emails.verification_email_click_on_this_link": "You can also click on this link:",
"emails.verification_email_heading": "Almost there!",
"emails.verification_email_hey": "Hey 👋",
"emails.verification_email_if_expired_request_new_token":
"If it has expired please request a new token here:",
"emails.verification_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"emails.verification_email_request_new_verification": "Request new verification",
"emails.verification_email_subject": "Please verify your email to use Formbricks",
"emails.verification_email_survey_name": "Survey name",
"emails.verification_email_take_survey": "Take survey",
"emails.verification_email_text": "To start using Formbricks please verify your email below:",
"emails.verification_email_thanks": "Thanks for validating your email!",
"emails.verification_email_to_fill_survey": "To fill out the survey please click on the button below:",
"emails.verification_email_verify_email": "Verify email",
"emails.verification_new_email_subject": "Email change verification",
"emails.verification_security_notice":
"If you did not request this email change, please ignore this email or contact support immediately.",
"emails.verified_link_survey_email_subject": "Your survey is ready to be filled out.",
};
// Simple string replacement for placeholders like {userName}, {surveyName}, etc.
const replacePlaceholders = (text: string, replacements?: Record<string, string>): string => {
if (!replacements) return text;
let result = text;
Object.entries(replacements).forEach(([key, value]) => {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value);
});
return result;
};
/**
* Mock translation function for preview server
* @param key - Translation key (e.g., "emails.forgot_password_email_heading")
* @param replacements - Optional object with placeholder replacements
* @returns Translated string with placeholders replaced
*/
export const t = (key: string, replacements?: Record<string, string>): string => {
const translation = translations[key] || key;
return replacePlaceholders(translation, replacements);
};

View File

@@ -0,0 +1,118 @@
import { render } from "@react-email/render";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { FollowUpEmailProps } from "@/emails/survey/follow-up-email";
import { ResponseFinishedEmailProps } from "@/emails/survey/response-finished-email";
import {
EmailCustomizationPreviewEmail,
EmbedSurveyPreviewEmail,
FollowUpEmail,
ForgotPasswordEmail,
InviteAcceptedEmail,
InviteEmail,
LinkSurveyEmail,
NewEmailVerification,
PasswordResetNotifyEmail,
ResponseFinishedEmail,
VerificationEmail,
} from "@/src/index";
type TFunction = (key: string, replacements?: Record<string, string>) => string;
// Render helper functions that convert React email components to HTML strings
// These are used by the web app to send emails without needing to import react-email
export async function renderVerificationEmail(props: {
verifyLink: string;
verificationRequestLink: string;
t: TFunction;
}): Promise<string> {
return await render(VerificationEmail(props));
}
export async function renderForgotPasswordEmail(props: {
verifyLink: string;
t: TFunction;
}): Promise<string> {
return await render(ForgotPasswordEmail(props));
}
export async function renderNewEmailVerification(props: {
verifyLink: string;
t: TFunction;
}): Promise<string> {
return await render(NewEmailVerification(props));
}
export async function renderPasswordResetNotifyEmail(props: { t: TFunction }): Promise<string> {
return await render(PasswordResetNotifyEmail(props));
}
export async function renderInviteEmail(props: {
inviteeName: string;
inviterName: string;
verifyLink: string;
t: TFunction;
}): Promise<string> {
return await render(InviteEmail(props));
}
export async function renderInviteAcceptedEmail(props: {
inviterName: string;
inviteeName: string;
t: TFunction;
}): Promise<string> {
return await render(InviteAcceptedEmail(props));
}
export async function renderLinkSurveyEmail(props: {
surveyName: string;
surveyLink: string;
logoUrl: string;
t: TFunction;
}): Promise<string> {
return await render(LinkSurveyEmail(props));
}
export async function renderEmbedSurveyPreviewEmail(props: {
html: string;
environmentId: string;
logoUrl?: string;
t: TFunction;
}): Promise<string> {
return await render(EmbedSurveyPreviewEmail(props));
}
export async function renderResponseFinishedEmail(props: ResponseFinishedEmailProps): Promise<string> {
return await render(ResponseFinishedEmail(props));
}
export async function renderEmailCustomizationPreviewEmail(props: {
userName: string;
logoUrl?: string;
t: TFunction;
}): Promise<string> {
return await render(EmailCustomizationPreviewEmail(props));
}
// Follow-up email types - exported for web app use
export interface ProcessedResponseElement {
element: string;
response: string | string[];
type: TSurveyElementTypeEnum;
}
export interface ProcessedVariable {
id: string;
name: string;
type: "text" | "number";
value: string | number;
}
export interface ProcessedHiddenField {
id: string;
value: string;
}
export async function renderFollowUpEmail(props: FollowUpEmailProps): Promise<string> {
return await render(FollowUpEmail(props));
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"baseUrl": ".",
"outDir": "dist",
"paths": {
"@/*": ["./*"]
},
"rootDir": "."
},
"exclude": ["node_modules", "dist"],
"extends": "@formbricks/config-typescript/react-library.json",
"include": ["src/**/*", "emails/**/*"]
}

1603
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff