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
+1 -1
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",
+1 -1
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",
@@ -1,17 +0,0 @@
import { Button } from "@react-email/components";
import React from "react";
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;
@@ -1,23 +0,0 @@
import { Container } from "@react-email/components";
import { cn } from "@/lib/cn";
interface ElementHeaderProps {
headline: string;
subheader?: string;
className?: string;
}
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>
)}
</>
);
}
@@ -1,15 +0,0 @@
import { Text } from "@react-email/components";
import { TFunction } from "i18next";
import React from "react";
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;
@@ -1,85 +0,0 @@
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";
const fbLogoUrl = FB_LOGO_URL;
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 async function EmailTemplate({
children,
logoUrl,
t,
}: EmailTemplateProps): Promise<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>
{IMPRINT_ADDRESS && (
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
)}
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">
{IMPRINT_URL && (
<Link
href={IMPRINT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.imprint")}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && " • "}
{PRIVACY_URL && (
<Link
href={PRIVACY_URL}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.privacy_policy")}
</Link>
)}
</Text>
</Section>
</Body>
</Tailwind>
</Html>
);
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -1,41 +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 {
verifyLink: string;
verificationRequestLink: string;
}
export async function VerificationEmail({
verifyLink,
verificationRequestLink,
}: 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.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>
);
}
export default VerificationEmail;
@@ -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;
@@ -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;
@@ -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;
@@ -1,70 +0,0 @@
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";
export const renderEmailResponseValue = async (
response: string | string[],
questionType: TSurveyElementTypeEnum,
t: TFunction,
overrideFileUploadResponse = false
): Promise<React.JSX.Element> => {
switch (questionType) {
case TSurveyElementTypeEnum.FileUpload:
return (
<Container>
{overrideFileUploadResponse ? (
<Text className="mt-0 text-sm break-words whitespace-pre-wrap 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 text-sm break-words whitespace-pre-wrap">{response}</Text>;
}
};
@@ -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;
@@ -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;
@@ -1,160 +0,0 @@
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 { 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";
interface ResponseFinishedEmailProps {
survey: TSurvey;
responseCount: number;
response: TResponse;
WEBAPP_URL: string;
environmentId: string;
organization: TOrganization;
}
export async function ResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
}: ResponseFinishedEmailProps): Promise<React.JSX.Element> {
const elements = getElementResponseMapping(survey, response);
const t = await getTranslate();
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;
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 font-medium break-words whitespace-pre-wrap">
{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 text-sm break-words whitespace-pre-wrap">
{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>
);
}
+64 -30
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"),
@@ -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>
);
}
+81 -14
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,
+1 -1
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",