feat: email package for client side email components (#6986)

This commit is contained in:
Anshuman Pandey
2025-12-22 19:43:06 +05:30
committed by GitHub
parent 834929e766
commit acd5cff534
46 changed files with 4107 additions and 3121 deletions

View File

@@ -474,7 +474,7 @@
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"hidden_field": "Champ caché",
"imprint": "Impressum",
"imprint": "Empreinte",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
"invite_accepted_email_text_par1": "Je te fais savoir que",

View File

@@ -474,7 +474,7 @@
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressum",
"imprint": "impressão",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
"invite_accepted_email_text_par1": "Só pra te avisar que",

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,30 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface ForgotPasswordEmailProps {
verifyLink: string;
}
export async function ForgotPasswordEmail({
verifyLink,
}: ForgotPasswordEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
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>
);
}
export default ForgotPasswordEmail;

View File

@@ -1,34 +0,0 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface VerificationEmailProps {
readonly verifyLink: string;
}
export async function NewEmailVerification({
verifyLink,
}: VerificationEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
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>
);
}
export default NewEmailVerification;

View File

@@ -1,20 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
export async function PasswordResetNotifyEmail(): Promise<React.JSX.Element> {
const t = await getTranslate();
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>
);
}
export default PasswordResetNotifyEmail;

View File

@@ -1,26 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "../../components/email-template";
interface EmailCustomizationPreviewEmailProps {
userName: string;
logoUrl?: string;
}
export async function EmailCustomizationPreviewEmail({
userName,
logoUrl,
}: EmailCustomizationPreviewEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
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>
);
}
export default EmailCustomizationPreviewEmail;

View File

@@ -1,33 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface InviteAcceptedEmailProps {
inviterName: string;
inviteeName: string;
}
export async function InviteAcceptedEmail({
inviterName,
inviteeName,
}: InviteAcceptedEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
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>
);
}
export default InviteAcceptedEmail;

View File

@@ -1,37 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface InviteEmailProps {
inviteeName: string;
inviterName: string;
verifyLink: string;
}
export async function InviteEmail({
inviteeName,
inviterName,
verifyLink,
}: InviteEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
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>
);
}
export default InviteEmail;

View File

@@ -1,36 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "../../components/email-template";
interface EmbedSurveyPreviewEmailProps {
html: string;
environmentId: string;
logoUrl?: string;
}
export async function EmbedSurveyPreviewEmail({
html,
environmentId,
logoUrl,
}: EmbedSurveyPreviewEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
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>
);
}
export default EmbedSurveyPreviewEmail;

View File

@@ -1,36 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface LinkSurveyEmailProps {
surveyName: string;
surveyLink: string;
logoUrl: string;
}
export async function LinkSurveyEmail({
surveyName,
surveyLink,
logoUrl,
}: LinkSurveyEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
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>
);
}
export default LinkSurveyEmail;

View File

@@ -1,6 +1,18 @@
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 { TEmailTemplateLegalProps } from "@formbricks/email/src/types/email";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
@@ -9,8 +21,11 @@ import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import {
DEBUG,
IMPRINT_ADDRESS,
IMPRINT_URL,
MAIL_FROM,
MAIL_FROM_NAME,
PRIVACY_URL,
SMTP_AUTHENTICATED,
SMTP_HOST,
SMTP_PASSWORD,
@@ -18,25 +33,24 @@ import {
SMTP_REJECT_UNAUTHORIZED_TLS,
SMTP_SECURE_ENABLED,
SMTP_USER,
TERMS_URL,
WEBAPP_URL,
} from "@/lib/constants";
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);
const legalProps: TEmailTemplateLegalProps = {
privacyUrl: PRIVACY_URL || undefined,
termsUrl: TERMS_URL || undefined,
imprintUrl: IMPRINT_URL || undefined,
imprintAddress: IMPRINT_ADDRESS || undefined,
};
interface SendEmailDataProps {
to: string;
replyTo?: string;
@@ -89,7 +103,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, ...legalProps });
return await sendEmail({
to: email,
@@ -117,7 +131,12 @@ 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,
...legalProps,
});
return await sendEmail({
to: email,
@@ -140,7 +159,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, ...legalProps });
return await sendEmail({
to: user.email,
subject: t("emails.forgot_password_email_subject"),
@@ -150,7 +169,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, ...legalProps });
return await sendEmail({
to: user.email,
subject: t("emails.password_reset_notify_email_subject"),
@@ -171,7 +190,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, ...legalProps });
return await sendEmail({
to: email,
subject: t("emails.invite_member_email_subject"),
@@ -185,7 +204,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, ...legalProps });
await sendEmail({
to: email,
subject: t("emails.invite_accepted_email_subject"),
@@ -208,16 +227,20 @@ 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,
...legalProps,
});
await sendEmail({
to: email,
@@ -241,7 +264,13 @@ 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,
...legalProps,
});
return await sendEmail({
to,
subject: t("emails.embed_survey_preview_email_subject"),
@@ -255,7 +284,12 @@ 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,
...legalProps,
});
return await sendEmail({
to,
@@ -280,7 +314,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, ...legalProps });
return await sendEmail({
to: data.email,
subject: t("emails.verified_link_survey_email_subject"),

View File

@@ -1,125 +0,0 @@
import { Column, Hr, Row, Text } from "@react-email/components";
import React from "react";
import sanitizeHtml from "sanitize-html";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
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 { EmailTemplate } from "@/modules/email/components/email-template";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
interface FollowUpEmailProps {
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
readonly includeVariables: boolean;
readonly includeHiddenFields: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JSX.Element> {
const { properties } = props.followUp.action;
let { body } = properties;
// Parse recall tags and replace with actual response values
body = parseRecallInfo(body, props.response.data, props.response.variables);
const elements = props.attachResponseData ? getElementResponseMapping(props.survey, props.response) : [];
const t = await getTranslate();
return (
<EmailTemplate logoUrl={props.logoUrl} t={t}>
<>
<div
dangerouslySetInnerHTML={{
__html: sanitizeHtml(body, {
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
allowedAttributes: {
a: ["href", "rel", "target"],
"*": ["dir", "class"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: {
a: ["http", "https"],
},
}),
}}
/>
{elements.length > 0 ? (
<>
<Hr />
<Text className="mb-4 text-base font-semibold text-slate-900">{t("emails.response_data")}</Text>
</>
) : null}
{elements.map((e) => {
if (!e.response) return;
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>
);
})}
{props.attachResponseData &&
props.includeVariables &&
props.survey.variables
.filter((variable) => {
const variableResponse = props.response.variables[variable.id];
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
const variableResponse = props.response.variables[variable.id];
return (
<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 text-sm break-words whitespace-pre-wrap text-slate-700">
{variableResponse}
</Text>
</Column>
</Row>
);
})}
{props.attachResponseData &&
props.includeHiddenFields &&
props.survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = props.response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => {
const hiddenFieldResponse = props.response.data[hiddenFieldId] as string;
return (
<Row key={hiddenFieldId}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{t("emails.hidden_field")}: {hiddenFieldId}
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{hiddenFieldResponse}
</Text>
</Column>
</Row>
);
})}
</>
</EmailTemplate>
);
}

View File

@@ -1,9 +1,18 @@
import { render } from "@react-email/components";
import sanitizeHtml from "sanitize-html";
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 { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/constants";
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 +37,79 @@ 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 = sanitizeHtml(parseRecallInfo(body, response.data, response.variables), {
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
allowedAttributes: {
a: ["href", "rel", "target"],
"*": ["dir", "class"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: {
a: ["http", "https"],
},
});
// 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],
}))
: [];
// 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,
privacyUrl: PRIVACY_URL || undefined,
termsUrl: TERMS_URL || undefined,
imprintUrl: IMPRINT_URL || undefined,
imprintAddress: IMPRINT_ADDRESS || undefined,
});
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:*",
@@ -70,7 +71,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",
};

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

@@ -0,0 +1,86 @@
# @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
- Tailwind CSS for styling with full intellisense support
## Development
### Preview Server
Run the React Email preview server:
```bash
pnpm dev
```
Visit `localhost:3456` to preview all email templates with mock data.
### Styling
The package uses Tailwind CSS via `@react-email/components`. Tailwind intellisense is configured and should work automatically in your IDE. The config files are:
- `tailwind.config.js` - Tailwind configuration for intellisense
- `postcss.config.js` - PostCSS configuration
### Path Aliases
Use `@/` prefix for clean imports:
```typescript
import { FollowUpEmail } from "@/emails/survey/follow-up-email";
import { EmailTemplate } from "@/src/components/email-template";
import { mockT } from "@/src/lib/mock-translate";
```
## Usage in Production
The web app imports render helper functions from this package:
```typescript
import { renderVerificationEmail } from "@formbricks/email";
// Pass real translation function and data
const html = await renderVerificationEmail({
verifyLink,
verificationRequestLink,
t, // Real i18n function from getTranslate()
});
```
For complex emails with pre-processing:
```typescript
import { renderResponseFinishedEmail } from "@formbricks/email";
import { getElementResponseMapping } from "@/lib/responses";
// Pre-process data before rendering
const elements = getElementResponseMapping(survey, response);
const html = await renderResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
elements, // Pre-processed data
t,
});
```
## Architecture
- **Preview Mode**: Templates use mock `t()` function and example data for visual QA
- **Production Mode**: Web app passes real `t()` function and pre-processed data
- **Render Functions**: Typed helper functions abstract `@react-email/render` from web app
- **No Business Logic**: SMTP, i18n, JWT, database queries, and data processing stay in web app
- **Clean Separation**: Web app processes data → Email package renders HTML

View File

@@ -0,0 +1,36 @@
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface ForgotPasswordEmailProps extends TEmailTemplateLegalProps {
readonly verifyLink: string;
readonly t?: TFunction;
}
export function ForgotPasswordEmail({
verifyLink,
t = mockT,
...legalProps
}: ForgotPasswordEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<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>
);
}
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface NewEmailVerificationProps extends TEmailTemplateLegalProps {
readonly verifyLink: string;
readonly t?: TFunction;
}
export function NewEmailVerification({
verifyLink,
t = mockT,
...legalProps
}: NewEmailVerificationProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<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="text-sm break-all 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>
);
}
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface PasswordResetNotifyEmailProps extends TEmailTemplateLegalProps {
readonly t?: TFunction;
}
export function PasswordResetNotifyEmail({
t = mockT,
...legalProps
}: PasswordResetNotifyEmailProps = {}): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<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>
);
}
export default function PasswordResetNotifyEmailPreview(): React.JSX.Element {
return <PasswordResetNotifyEmail {...exampleData.passwordResetNotifyEmail} />;
}

View File

@@ -1,28 +1,32 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface VerificationEmailProps {
verifyLink: string;
verificationRequestLink: string;
interface VerificationEmailProps extends TEmailTemplateLegalProps {
readonly verifyLink: string;
readonly verificationRequestLink: string;
readonly t?: TFunction;
}
export async function VerificationEmail({
export function VerificationEmail({
verifyLink,
verificationRequestLink,
}: VerificationEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
t = mockT,
...legalProps
}: VerificationEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<EmailTemplate t={t} {...legalProps}>
<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}>
<Link className="text-sm break-all text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
@@ -38,4 +42,6 @@ export async function VerificationEmail({
);
}
export default VerificationEmail;
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface EmailCustomizationPreviewEmailProps extends TEmailTemplateLegalProps {
readonly userName: string;
readonly logoUrl?: string;
readonly t?: TFunction;
}
export function EmailCustomizationPreviewEmail({
userName,
logoUrl,
t = mockT,
...legalProps
}: EmailCustomizationPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<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>
);
}
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface InviteAcceptedEmailProps extends TEmailTemplateLegalProps {
readonly inviterName: string;
readonly inviteeName: string;
readonly t?: TFunction;
}
export function InviteAcceptedEmail({
inviterName,
inviteeName,
t = mockT,
...legalProps
}: InviteAcceptedEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<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>
);
}
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface InviteEmailProps extends TEmailTemplateLegalProps {
readonly inviteeName: string;
readonly inviterName: string;
readonly verifyLink: string;
readonly t?: TFunction;
}
export function InviteEmail({
inviteeName,
inviterName,
verifyLink,
t = mockT,
...legalProps
}: InviteEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<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>
);
}
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface EmbedSurveyPreviewEmailProps extends TEmailTemplateLegalProps {
readonly html: string;
readonly environmentId: string;
readonly logoUrl?: string;
readonly t?: TFunction;
}
export function EmbedSurveyPreviewEmail({
html,
environmentId,
logoUrl,
t = mockT,
...legalProps
}: EmbedSurveyPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<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>
);
}
export default function EmbedSurveyPreviewEmailPreview(): React.JSX.Element {
return <EmbedSurveyPreviewEmail {...exampleData.embedSurveyPreviewEmail} />;
}

View File

@@ -0,0 +1,86 @@
import { Column, Hr, Row, Text } from "@react-email/components";
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { ProcessedHiddenField, ProcessedResponseElement, ProcessedVariable } from "../../src/types/follow-up";
import { TFunction } from "../../src/types/translations";
export interface FollowUpEmailProps extends TEmailTemplateLegalProps {
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,
...legalProps
}: FollowUpEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<>
<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 text-sm break-words whitespace-pre-wrap 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 text-sm break-words whitespace-pre-wrap text-slate-700">
{hiddenField.value}
</Text>
</Column>
</Row>
))}
</>
</EmailTemplate>
);
}
export default function FollowUpEmailPreview(): React.JSX.Element {
return <FollowUpEmail {...(exampleData.followUpEmail as unknown as FollowUpEmailProps)} />;
}

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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface LinkSurveyEmailProps extends TEmailTemplateLegalProps {
readonly surveyName: string;
readonly surveyLink: string;
readonly logoUrl?: string;
readonly t?: TFunction;
}
export function LinkSurveyEmail({
surveyName,
surveyLink,
logoUrl,
t = mockT,
...legalProps
}: LinkSurveyEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<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

@@ -2,35 +2,52 @@ import { Column, Container, Heading, Hr, Link, Row, Section, Text } from "@react
import { FileDigitIcon, FileType2Icon } from "lucide-react";
import type { TOrganization } from "@formbricks/types/organizations";
import type { TResponse } from "@formbricks/types/responses";
import { type TSurvey } from "@formbricks/types/surveys/types";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
import { EmailButton } from "../../components/email-button";
import { EmailTemplate } from "../../components/email-template";
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";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { ProcessedResponseElement } from "../../src/types/follow-up";
import { TFunction } from "../../src/types/translations";
interface ResponseFinishedEmailProps {
survey: TSurvey;
responseCount: number;
response: TResponse;
WEBAPP_URL: string;
environmentId: string;
organization: TOrganization;
export interface ResponseFinishedEmailProps extends TEmailTemplateLegalProps {
readonly survey: TSurvey;
readonly responseCount: number;
readonly response: TResponse;
readonly WEBAPP_URL: string;
readonly environmentId: string;
readonly organization: TOrganization;
readonly elements: ProcessedResponseElement[]; // Pre-processed data, not a function
readonly t?: TFunction;
}
export async function ResponseFinishedEmail({
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,
}: ResponseFinishedEmailProps): Promise<React.JSX.Element> {
const elements = getElementResponseMapping(survey, response);
const t = await getTranslate();
elements,
t = mockT,
...legalProps
}: ResponseFinishedEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<EmailTemplate t={t} {...legalProps}>
<Container>
<Row>
<Column>
@@ -42,7 +59,7 @@ export async function ResponseFinishedEmail({
</Text>
<Hr />
{elements.map((e) => {
if (!e.response) return;
if (!e.response) return null;
return (
<Row key={e.element}>
<Column className="w-full font-medium">
@@ -58,7 +75,6 @@ export async function ResponseFinishedEmail({
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
@@ -158,3 +174,11 @@ function EyeOffIcon(): React.JSX.Element {
</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,30 @@
{
"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-email": "5.0.8"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/types": "workspace:*",
"@react-email/preview-server": "5.0.8",
"autoprefixer": "10.4.21",
"clsx": "2.1.1",
"postcss": "8.5.3",
"tailwind-merge": "3.2.0",
"tailwindcss": "3.4.17"
}
}

View File

@@ -1,9 +1,8 @@
import { Button } from "@react-email/components";
import React from "react";
interface EmailButtonProps {
label: string;
href: string;
readonly label: string;
readonly href: string;
}
export function EmailButton({ label, href }: EmailButtonProps): React.JSX.Element {

View File

@@ -1,10 +1,10 @@
import { Container } from "@react-email/components";
import { cn } from "@/lib/cn";
import { cn } from "../../src/lib/cn";
interface ElementHeaderProps {
headline: string;
subheader?: string;
className?: string;
readonly headline: string;
readonly subheader?: string;
readonly className?: string;
}
export function ElementHeader({ headline, subheader, className }: ElementHeaderProps): React.JSX.Element {
@@ -21,3 +21,5 @@ export function ElementHeader({ headline, subheader, className }: ElementHeaderP
</>
);
}
export default ElementHeader;

View File

@@ -1,6 +1,5 @@
import { Text } from "@react-email/components";
import { TFunction } from "i18next";
import React from "react";
import { TFunction } from "../types/translations";
export function EmailFooter({ t }: { t: TFunction }): React.JSX.Element {
return (

View File

@@ -1,22 +1,24 @@
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { TFunction } from "i18next";
import React from "react";
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@/lib/constants";
import { TEmailTemplateLegalProps } from "../types/email";
import { TFunction } from "../types/translations";
const fbLogoUrl = FB_LOGO_URL;
const fbLogoUrl = "https://app.formbricks.com/logo-transparent.png";
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface EmailTemplateProps {
interface EmailTemplateProps extends TEmailTemplateLegalProps {
readonly children: React.ReactNode;
readonly logoUrl?: string;
readonly t: TFunction;
}
export async function EmailTemplate({
export function EmailTemplate({
children,
logoUrl,
t,
}: EmailTemplateProps): Promise<React.JSX.Element> {
privacyUrl,
imprintUrl,
imprintAddress,
}: EmailTemplateProps): React.JSX.Element {
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
return (
@@ -53,23 +55,23 @@ export async function EmailTemplate({
rel="noopener noreferrer">
{t("emails.email_template_text_1")}
</Link>
{IMPRINT_ADDRESS && (
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
{imprintAddress && (
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">{imprintAddress}</Text>
)}
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">
{IMPRINT_URL && (
{imprintUrl && (
<Link
href={IMPRINT_URL}
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.imprint")}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && " • "}
{PRIVACY_URL && (
{imprintUrl && privacyUrl && " • "}
{privacyUrl && (
<Link
href={PRIVACY_URL}
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
@@ -83,3 +85,5 @@ export async function EmailTemplate({
</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 { ProcessedHiddenField, ProcessedResponseElement, ProcessedVariable } from "./types/follow-up";

View File

@@ -0,0 +1,6 @@
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};

View File

@@ -1,15 +1,26 @@
import { Column, Container, Img, Link, Row, Text } from "@react-email/components";
import { TFunction } from "i18next";
import { FileIcon } from "lucide-react";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { TFunction } from "../types/translations";
export const renderEmailResponseValue = async (
// 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
): Promise<React.JSX.Element> => {
): React.JSX.Element => {
switch (questionType) {
case TSurveyElementTypeEnum.FileUpload:
return (
@@ -65,6 +76,6 @@ export const renderEmailResponseValue = async (
);
default:
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response}</Text>;
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response as string}</Text>;
}
};

View File

@@ -0,0 +1,184 @@
// 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 { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
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",
},
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",
},
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,
},
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",
},
],
},
emailCustomizationPreviewEmail: {
userName: "Alex Johnson",
},
legalProps: {
privacyUrl: "https://formbricks.com/privacy",
termsUrl: "https://formbricks.com/terms",
imprintUrl: "https://formbricks.com/imprint",
imprintAddress: "Formbricks GmbH, Example Street 123, 12345 Berlin, Germany",
},
};
export type ExampleDataKeys = keyof typeof exampleData;
export type ExampleData<K extends ExampleDataKeys> = (typeof exampleData)[K];

View File

@@ -0,0 +1,108 @@
// 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.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.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,116 @@
import { render } from "@react-email/render";
import { ForgotPasswordEmail } from "../../emails/auth/forgot-password-email";
import { NewEmailVerification } from "../../emails/auth/new-email-verification";
import { PasswordResetNotifyEmail } from "../../emails/auth/password-reset-notify-email";
import { VerificationEmail } from "../../emails/auth/verification-email";
import { EmailCustomizationPreviewEmail } from "../../emails/general/email-customization-preview-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 { FollowUpEmail, FollowUpEmailProps } from "../../emails/survey/follow-up-email";
import { LinkSurveyEmail } from "../../emails/survey/link-survey-email";
import {
ResponseFinishedEmail,
ResponseFinishedEmailProps,
} from "../../emails/survey/response-finished-email";
import { TEmailTemplateLegalProps } from "../types/email";
import { TFunction } from "../types/translations";
export async function renderVerificationEmail(
props: {
verifyLink: string;
verificationRequestLink: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(VerificationEmail(props));
}
export async function renderForgotPasswordEmail(
props: {
verifyLink: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(ForgotPasswordEmail(props));
}
export async function renderNewEmailVerification(
props: {
verifyLink: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(NewEmailVerification(props));
}
export async function renderPasswordResetNotifyEmail(
props: { t: TFunction } & TEmailTemplateLegalProps
): Promise<string> {
return await render(PasswordResetNotifyEmail(props));
}
export async function renderInviteEmail(
props: {
inviteeName: string;
inviterName: string;
verifyLink: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(InviteEmail(props));
}
export async function renderInviteAcceptedEmail(
props: {
inviterName: string;
inviteeName: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(InviteAcceptedEmail(props));
}
export async function renderLinkSurveyEmail(
props: {
surveyName: string;
surveyLink: string;
logoUrl: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(LinkSurveyEmail(props));
}
export async function renderEmbedSurveyPreviewEmail(
props: {
html: string;
environmentId: string;
logoUrl?: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(EmbedSurveyPreviewEmail(props));
}
export async function renderResponseFinishedEmail(
props: ResponseFinishedEmailProps & TEmailTemplateLegalProps
): Promise<string> {
return await render(ResponseFinishedEmail(props));
}
export async function renderEmailCustomizationPreviewEmail(
props: {
userName: string;
logoUrl?: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(EmailCustomizationPreviewEmail(props));
}
export async function renderFollowUpEmail(
props: FollowUpEmailProps & TEmailTemplateLegalProps
): Promise<string> {
return await render(FollowUpEmail(props));
}

View File

@@ -0,0 +1,6 @@
export interface TEmailTemplateLegalProps {
privacyUrl?: string;
termsUrl?: string;
imprintUrl?: string;
imprintAddress?: string;
}

View File

@@ -0,0 +1,19 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
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;
}

View File

@@ -0,0 +1 @@
export type TFunction = (key: string, replacements?: Record<string, string>) => string;

View File

@@ -0,0 +1,5 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{ts,tsx}", "./emails/**/*.{ts,tsx}"],
plugins: [],
};

View File

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

View File

@@ -25,6 +25,7 @@ const __dirname = dirname(__filename);
// Configuration for Web App
const WEB_APP_DIR = path.join(__dirname, "..", "..", "..", "apps", "web");
const EMAIL_PKG_DIR = path.join(__dirname, "..", "..", "..", "packages", "email");
const WEB_APP_LOCALES_DIR = path.join(WEB_APP_DIR, "locales");
const WEB_APP_DEFAULT_LOCALE = "en-US";
@@ -139,27 +140,32 @@ export function extractKeysFromContent(content: string): string[] {
/**
* Scan source files for translation keys
*/
async function scanSourceFiles(sourceDir: string, packageName: string): Promise<Set<string>> {
async function scanSourceFiles(sourceDirs: string | string[], packageName: string): Promise<Set<string>> {
console.log(`🔍 Scanning ${packageName} source files for translation keys...`);
const usedKeys = new Set<string>();
const dirs = Array.isArray(sourceDirs) ? sourceDirs : [sourceDirs];
// Find all TypeScript and TypeScript React files
const files = await glob("**/*.{ts,tsx}", {
cwd: sourceDir,
ignore: EXCLUDE_DIRS,
absolute: true,
});
for (const dir of dirs) {
// Find all TypeScript and TypeScript React files
const files = await glob("**/*.{ts,tsx}", {
cwd: dir,
ignore: EXCLUDE_DIRS,
absolute: true,
});
console.log(` Found ${files.length.toString()} files to scan`);
console.log(
` Found ${files.length.toString()} files to scan in ${path.relative(path.join(__dirname, "..", "..", ".."), dir)}`
);
for (const file of files) {
try {
const content = await fs.promises.readFile(file, "utf-8");
const keys = extractKeysFromContent(content);
keys.forEach((key) => usedKeys.add(key));
} catch (error) {
console.error(`❌ Error: Could not read file ${file}:`, error);
for (const file of files) {
try {
const content = await fs.promises.readFile(file, "utf-8");
const keys = extractKeysFromContent(content);
keys.forEach((key) => usedKeys.add(key));
} catch (error) {
console.error(`❌ Error: Could not read file ${file}:`, error);
}
}
}
@@ -423,13 +429,13 @@ function displayResults(results: ScanResults, packageName: string, defaultLocale
* Validate translations for a single package
*/
async function validatePackage(
sourceDir: string,
sourceDirs: string | string[],
localesDir: string,
defaultLocale: string,
packageName: string
): Promise<ScanResults> {
// Scan source files for used keys
const usedKeys = await scanSourceFiles(sourceDir, packageName);
const usedKeys = await scanSourceFiles(sourceDirs, packageName);
// Load translation keys from all locale files
const translationsByLocale = await loadAllTranslationKeys(localesDir, defaultLocale, packageName);
@@ -461,7 +467,7 @@ async function main(): Promise<void> {
try {
// Validate Web App
const webAppResults = await validatePackage(
WEB_APP_DIR,
[WEB_APP_DIR, EMAIL_PKG_DIR],
WEB_APP_LOCALES_DIR,
WEB_APP_DEFAULT_LOCALE,
"Web App"

5413
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff