From 2b526a87cadc745669bf40fa44e209cb787cd9fc Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Mon, 19 Jan 2026 19:02:01 +0530 Subject: [PATCH] fix: email locale in invite accepted email (#7124) --- .../settings/(account)/profile/actions.ts | 2 +- .../[surveyId]/(analysis)/summary/actions.ts | 1 + apps/web/app/api/(internal)/pipeline/route.ts | 9 +- apps/web/lingodotdev/server.test.ts | 18 +++- apps/web/lingodotdev/server.ts | 9 +- .../auth/forgot-password/reset/actions.ts | 2 +- .../modules/auth/invite/lib/invite.test.ts | 2 + apps/web/modules/auth/invite/lib/invite.ts | 1 + apps/web/modules/auth/invite/page.tsx | 7 +- apps/web/modules/auth/invite/types/invites.ts | 2 + apps/web/modules/auth/signup/actions.ts | 9 +- .../verification-requested/actions.test.ts | 3 +- .../auth/verification-requested/actions.ts | 2 +- .../whitelabel/email-customization/actions.ts | 1 + apps/web/modules/email/index.tsx | 59 ++++++++----- .../survey/link/components/verify-email.tsx | 21 ++++- .../web/modules/survey/link/lib/utils.test.ts | 85 +++++++++++++++++++ apps/web/modules/survey/link/lib/utils.ts | 56 +++++++++++- packages/types/email.ts | 3 +- 19 files changed, 248 insertions(+), 44 deletions(-) create mode 100644 apps/web/modules/survey/link/lib/utils.test.ts diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts index 60a18b0db3..5949d1638c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts @@ -58,7 +58,7 @@ async function handleEmailUpdate({ payload.email = inputEmail; await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail }); } else { - await sendVerificationNewEmail(ctx.user.id, inputEmail); + await sendVerificationNewEmail(ctx.user.id, inputEmail, ctx.user.locale); } return payload; } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts index eacb6e70b5..815cdd2c9f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions.ts @@ -58,6 +58,7 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient ctx.user.email, emailHtml, survey.environmentId, + ctx.user.locale, organizationLogoUrl || "" ); }); diff --git a/apps/web/app/api/(internal)/pipeline/route.ts b/apps/web/app/api/(internal)/pipeline/route.ts index 92fa9a0346..7eaaeb1bcd 100644 --- a/apps/web/app/api/(internal)/pipeline/route.ts +++ b/apps/web/app/api/(internal)/pipeline/route.ts @@ -215,7 +215,14 @@ export const POST = async (request: Request) => { } const emailPromises = usersWithNotifications.map((user) => - sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => { + sendResponseFinishedEmail( + user.email, + user.locale, + environmentId, + survey, + response, + responseCount + ).catch((error) => { logger.error( { error, url: request.url, userEmail: user.email }, `Failed to send email to ${user.email}` diff --git a/apps/web/lingodotdev/server.test.ts b/apps/web/lingodotdev/server.test.ts index e5a5df27fc..449ba89686 100644 --- a/apps/web/lingodotdev/server.test.ts +++ b/apps/web/lingodotdev/server.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { getLocale } from "@/lingodotdev/language"; import { getTranslate } from "./server"; @@ -11,6 +11,10 @@ vi.mock("@/lingodotdev/shared", () => ({ })); describe("lingodotdev server", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test("should get translate", async () => { vi.mocked(getLocale).mockResolvedValue("en-US"); const translate = await getTranslate(); @@ -22,4 +26,16 @@ describe("lingodotdev server", () => { const translate = await getTranslate(); expect(translate).toBeDefined(); }); + + test("should use provided locale instead of calling getLocale", async () => { + const translate = await getTranslate("de-DE"); + expect(getLocale).not.toHaveBeenCalled(); + expect(translate).toBeDefined(); + }); + + test("should call getLocale when locale is not provided", async () => { + vi.mocked(getLocale).mockResolvedValue("fr-FR"); + await getTranslate(); + expect(getLocale).toHaveBeenCalled(); + }); }); diff --git a/apps/web/lingodotdev/server.ts b/apps/web/lingodotdev/server.ts index 1fa3b291b8..263895ae80 100644 --- a/apps/web/lingodotdev/server.ts +++ b/apps/web/lingodotdev/server.ts @@ -3,6 +3,7 @@ import ICU from "i18next-icu"; import resourcesToBackend from "i18next-resources-to-backend"; import { initReactI18next } from "react-i18next/initReactI18next"; import { DEFAULT_LOCALE } from "@/lib/constants"; +import { TUserLocale } from "@formbricks/types/user"; import { getLocale } from "@/lingodotdev/language"; const initI18next = async (lng: string) => { @@ -21,9 +22,9 @@ const initI18next = async (lng: string) => { return i18nInstance; }; -export async function getTranslate() { - const locale = await getLocale(); +export async function getTranslate(locale?: TUserLocale) { + const resolvedLocale = locale ?? (await getLocale()); - const i18nextInstance = await initI18next(locale); - return i18nextInstance.getFixedT(locale); + const i18nextInstance = await initI18next(resolvedLocale); + return i18nextInstance.getFixedT(resolvedLocale); } diff --git a/apps/web/modules/auth/forgot-password/reset/actions.ts b/apps/web/modules/auth/forgot-password/reset/actions.ts index 71303a2e18..f8deafcb08 100644 --- a/apps/web/modules/auth/forgot-password/reset/actions.ts +++ b/apps/web/modules/auth/forgot-password/reset/actions.ts @@ -33,7 +33,7 @@ export const resetPasswordAction = actionClient.schema(ZResetPasswordAction).act ctx.auditLoggingCtx.oldObject = oldObject; ctx.auditLoggingCtx.newObject = updatedUser; - await sendPasswordResetNotifyEmail(updatedUser); + await sendPasswordResetNotifyEmail({ email: updatedUser.email, locale: updatedUser.locale }); return { success: true }; } ) diff --git a/apps/web/modules/auth/invite/lib/invite.test.ts b/apps/web/modules/auth/invite/lib/invite.test.ts index 80b4c05111..5dba58b434 100644 --- a/apps/web/modules/auth/invite/lib/invite.test.ts +++ b/apps/web/modules/auth/invite/lib/invite.test.ts @@ -69,6 +69,7 @@ describe("invite", () => { creator: { name: "Test User", email: "test@example.com", + locale: "en-US", }, }; @@ -89,6 +90,7 @@ describe("invite", () => { select: { name: true, email: true, + locale: true, }, }, }, diff --git a/apps/web/modules/auth/invite/lib/invite.ts b/apps/web/modules/auth/invite/lib/invite.ts index 95144f0b78..4ac4bbadc9 100644 --- a/apps/web/modules/auth/invite/lib/invite.ts +++ b/apps/web/modules/auth/invite/lib/invite.ts @@ -46,6 +46,7 @@ export const getInvite = reactCache(async (inviteId: string): Promise { ); } await deleteInvite(inviteId); - await sendInviteAcceptedEmail(invite.creator.name ?? "", user?.name ?? "", invite.creator.email); + await sendInviteAcceptedEmail( + invite.creator.name ?? "", + user?.name ?? "", + invite.creator.email, + invite.creator.locale + ); await updateUser(session.user.id, { notificationSettings: { ...user.notificationSettings, diff --git a/apps/web/modules/auth/invite/types/invites.ts b/apps/web/modules/auth/invite/types/invites.ts index 224799d4df..367413cbd3 100644 --- a/apps/web/modules/auth/invite/types/invites.ts +++ b/apps/web/modules/auth/invite/types/invites.ts @@ -1,10 +1,12 @@ import { Invite } from "@prisma/client"; +import { TUserLocale } from "@formbricks/types/user"; export interface InviteWithCreator extends Pick { creator: { name: string | null; email: string; + locale: TUserLocale; }; } diff --git a/apps/web/modules/auth/signup/actions.ts b/apps/web/modules/auth/signup/actions.ts index c3c4f3745a..3b38af2f1d 100644 --- a/apps/web/modules/auth/signup/actions.ts +++ b/apps/web/modules/auth/signup/actions.ts @@ -127,7 +127,12 @@ async function handleInviteAcceptance( }, }); - await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email); + await sendInviteAcceptedEmail( + invite.creator.name ?? "", + user.name, + invite.creator.email, + invite.creator.locale + ); await deleteInvite(invite.id); } @@ -168,7 +173,7 @@ async function handlePostUserCreation( } if (!emailVerificationDisabled) { - await sendVerificationEmail(user); + await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale }); } } diff --git a/apps/web/modules/auth/verification-requested/actions.test.ts b/apps/web/modules/auth/verification-requested/actions.test.ts index 02f7eec478..9f664b7cb2 100644 --- a/apps/web/modules/auth/verification-requested/actions.test.ts +++ b/apps/web/modules/auth/verification-requested/actions.test.ts @@ -48,8 +48,7 @@ describe("resendVerificationEmailAction", () => { const mockUser = { id: "user123", email: "test@example.com", - emailVerified: null, // Not verified - name: "Test User", + locale: "en-US", }; const mockVerifiedUser = { diff --git a/apps/web/modules/auth/verification-requested/actions.ts b/apps/web/modules/auth/verification-requested/actions.ts index 313b6eb15b..89d80f62b6 100644 --- a/apps/web/modules/auth/verification-requested/actions.ts +++ b/apps/web/modules/auth/verification-requested/actions.ts @@ -32,7 +32,7 @@ export const resendVerificationEmailAction = actionClient.schema(ZResendVerifica }; } ctx.auditLoggingCtx.userId = user.id; - await sendVerificationEmail(user); + await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale }); return { success: true, }; diff --git a/apps/web/modules/ee/whitelabel/email-customization/actions.ts b/apps/web/modules/ee/whitelabel/email-customization/actions.ts index 6433b09613..ed6e99d3b9 100644 --- a/apps/web/modules/ee/whitelabel/email-customization/actions.ts +++ b/apps/web/modules/ee/whitelabel/email-customization/actions.ts @@ -121,6 +121,7 @@ export const sendTestEmailAction = authenticatedActionClient await sendEmailCustomizationPreviewEmail( ctx.user.email, ctx.user.name, + ctx.user.locale, organization?.whitelabel?.logoUrl || "" ); diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx index 3539847c95..332496a121 100644 --- a/apps/web/modules/email/index.tsx +++ b/apps/web/modules/email/index.tsx @@ -71,12 +71,12 @@ export const sendEmail = async (emailData: SendEmailDataProps): Promise secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports ...(SMTP_AUTHENTICATED ? { - auth: { - type: "LOGIN", - user: SMTP_USER, - pass: SMTP_PASSWORD, - }, - } + auth: { + type: "LOGIN", + user: SMTP_USER, + pass: SMTP_PASSWORD, + }, + } : {}), tls: { rejectUnauthorized: SMTP_REJECT_UNAUTHORIZED_TLS, @@ -97,9 +97,13 @@ export const sendEmail = async (emailData: SendEmailDataProps): Promise } }; -export const sendVerificationNewEmail = async (id: string, email: string): Promise => { +export const sendVerificationNewEmail = async ( + id: string, + email: string, + locale: TUserLocale +): Promise => { try { - const t = await getTranslate(); + const t = await getTranslate(locale); const token = createEmailChangeToken(id, email); const verifyLink = `${WEBAPP_URL}/verify-email-change?token=${encodeURIComponent(token)}`; @@ -119,12 +123,14 @@ export const sendVerificationNewEmail = async (id: string, email: string): Promi export const sendVerificationEmail = async ({ id, email, + locale, }: { id: string; email: TUserEmail; + locale: TUserLocale; }): Promise => { try { - const t = await getTranslate(); + const t = await getTranslate(locale); const token = createToken(id, { expiresIn: "1d", }); @@ -154,7 +160,7 @@ export const sendForgotPasswordEmail = async (user: { email: TUserEmail; locale: TUserLocale; }): Promise => { - const t = await getTranslate(); + const t = await getTranslate(user.locale); const token = createToken(user.id, { expiresIn: "1d", }); @@ -167,8 +173,11 @@ export const sendForgotPasswordEmail = async (user: { }); }; -export const sendPasswordResetNotifyEmail = async (user: { email: string }): Promise => { - const t = await getTranslate(); +export const sendPasswordResetNotifyEmail = async (user: { + email: string; + locale: TUserLocale; +}): Promise => { + const t = await getTranslate(user.locale); const html = await renderPasswordResetNotifyEmail({ t, ...legalProps }); return await sendEmail({ to: user.email, @@ -201,9 +210,10 @@ export const sendInviteMemberEmail = async ( export const sendInviteAcceptedEmail = async ( inviterName: string, inviteeName: string, - email: string + email: string, + inviterLocale?: TUserLocale ): Promise => { - const t = await getTranslate(); + const t = await getTranslate(inviterLocale); const html = await renderInviteAcceptedEmail({ inviteeName, inviterName, t, ...legalProps }); await sendEmail({ to: email, @@ -214,12 +224,13 @@ export const sendInviteAcceptedEmail = async ( export const sendResponseFinishedEmail = async ( email: string, + locale: TUserLocale, environmentId: string, survey: TSurvey, response: TResponse, responseCount: number ): Promise => { - const t = await getTranslate(); + const t = await getTranslate(locale); const personEmail = response.contactAttributes?.email; const organization = await getOrganizationByEnvironmentId(environmentId); @@ -246,12 +257,12 @@ export const sendResponseFinishedEmail = async ( to: email, subject: personEmail ? t("emails.response_finished_email_subject_with_email", { - personEmail, - surveyName: survey.name, - }) + personEmail, + surveyName: survey.name, + }) : t("emails.response_finished_email_subject", { - surveyName: survey.name, - }), + surveyName: survey.name, + }), replyTo: personEmail?.toString() ?? MAIL_FROM, html, }); @@ -261,9 +272,10 @@ export const sendEmbedSurveyPreviewEmail = async ( to: string, innerHtml: string, environmentId: string, + locale: TUserLocale, logoUrl?: string ): Promise => { - const t = await getTranslate(); + const t = await getTranslate(locale); const html = await renderEmbedSurveyPreviewEmail({ html: innerHtml, environmentId, @@ -281,9 +293,10 @@ export const sendEmbedSurveyPreviewEmail = async ( export const sendEmailCustomizationPreviewEmail = async ( to: string, userName: string, + locale: TUserLocale, logoUrl?: string ): Promise => { - const t = await getTranslate(); + const t = await getTranslate(locale); const emailHtmlBody = await renderEmailCustomizationPreviewEmail({ userName, logoUrl, @@ -305,7 +318,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData): const singleUseId = data.suId; const logoUrl = data.logoUrl || ""; const token = createTokenForLinkSurvey(surveyId, email); - const t = await getTranslate(); + const t = await getTranslate(data.locale); const getSurveyLink = (): string => { if (singleUseId) { return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`; diff --git a/apps/web/modules/survey/link/components/verify-email.tsx b/apps/web/modules/survey/link/components/verify-email.tsx index 5c9e84f4d5..63dc953e7b 100644 --- a/apps/web/modules/survey/link/components/verify-email.tsx +++ b/apps/web/modules/survey/link/components/verify-email.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowLeft, MailIcon } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { Toaster, toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; @@ -10,11 +10,13 @@ import { z } from "zod"; import { TProjectStyling } from "@formbricks/types/project"; import { TSurvey } from "@formbricks/types/surveys/types"; import { getTextContent } from "@formbricks/types/surveys/validation"; +import { TUserLocale } from "@formbricks/types/user"; import { getLocalizedValue } from "@/lib/i18n/utils"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { isSurveyResponsePresentAction, sendLinkSurveyEmailAction } from "@/modules/survey/link/actions"; +import { getWebAppLocale } from "@/modules/survey/link/lib/utils"; import { Button } from "@/modules/ui/components/button"; import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; @@ -26,7 +28,7 @@ interface VerifyEmailProps { singleUseId?: string; languageCode: string; styling: TProjectStyling; - locale: string; + locale: TUserLocale; } const ZVerifyEmailInput = z.object({ @@ -42,7 +44,18 @@ export const VerifyEmail = ({ styling, locale, }: VerifyEmailProps) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); + + // Set i18n language based on survey language + useEffect(() => { + const webAppLocale = getWebAppLocale(languageCode, survey); + if (i18n.language !== webAppLocale) { + i18n.changeLanguage(webAppLocale).catch(() => { + // If changeLanguage fails, fallback to default locale + i18n.changeLanguage("en-US"); + }); + } + }, [languageCode, survey, i18n]); const form = useForm({ defaultValues: { email: "", @@ -175,7 +188,7 @@ export const VerifyEmail = ({ {!emailSent && showPreviewQuestions && (

{t("s.question_preview")}

-
+
{questions.map((question, index) => (

{ + const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey => { + return { + id: "survey-1", + createdAt: new Date(), + updatedAt: new Date(), + name: "Test Survey", + type: "link", + environmentId: "env-1", + createdBy: null, + status: "draft", + displayOption: "displayOnce", + autoClose: null, + triggers: [], + recontactDays: null, + displayLimit: null, + welcomeCard: { + enabled: false, + headline: { default: "Welcome" }, + timeToFinish: false, + showResponseCount: false, + }, + questions: [], + blocks: [], + endings: [], + hiddenFields: { enabled: false, fieldIds: [] }, + variables: [], + styling: null, + segment: null, + languages, + displayPercentage: null, + isVerifyEmailEnabled: false, + isSingleResponsePerEmailEnabled: false, + singleUse: null, + pin: null, + projectOverwrites: null, + surveyClosedMessage: null, + followUps: [], + delay: 0, + autoComplete: null, + showLanguageSwitch: null, + recaptcha: null, + isBackButtonHidden: false, + isCaptureIpEnabled: false, + slug: null, + metadata: {}, + } as TSurvey; + }; + + test("maps language codes to web app locales", () => { + const survey = createMockSurvey(); + expect(getWebAppLocale("en", survey)).toBe("en-US"); + expect(getWebAppLocale("de", survey)).toBe("de-DE"); + expect(getWebAppLocale("pt-BR", survey)).toBe("pt-BR"); + }); + + test("handles 'default' languageCode by finding default language in survey", () => { + const survey = createMockSurvey([ + { + language: { + id: "lang1", + code: "de", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + projectId: "proj1", + }, + default: true, + enabled: true, + }, + ]); + + expect(getWebAppLocale("default", survey)).toBe("de-DE"); + }); + + test("falls back to en-US when language is not supported", () => { + const survey = createMockSurvey(); + expect(getWebAppLocale("default", survey)).toBe("en-US"); + expect(getWebAppLocale("xx", survey)).toBe("en-US"); + }); +}); diff --git a/apps/web/modules/survey/link/lib/utils.ts b/apps/web/modules/survey/link/lib/utils.ts index 75135c344c..daf9d11e53 100644 --- a/apps/web/modules/survey/link/lib/utils.ts +++ b/apps/web/modules/survey/link/lib/utils.ts @@ -1,2 +1,54 @@ -// Prefilling logic has been moved to @/modules/survey/link/lib/prefill -// This file is kept for any future utility functions +import { TSurvey } from "@formbricks/types/surveys/types"; + +/** + * Maps survey language codes to web app locale codes. + * Falls back to "en-US" if the language is not available in web app locales. + */ +export const getWebAppLocale = (languageCode: string, survey: TSurvey): string => { + // Map of common 2-letter language codes to web app locale codes + const languageToLocaleMap: Record = { + en: "en-US", + de: "de-DE", + pt: "pt-BR", // Default to Brazilian Portuguese + "pt-BR": "pt-BR", + "pt-PT": "pt-PT", + fr: "fr-FR", + nl: "nl-NL", + zh: "zh-Hans-CN", // Default to Simplified Chinese + "zh-Hans": "zh-Hans-CN", + "zh-Hans-CN": "zh-Hans-CN", + "zh-Hant": "zh-Hant-TW", + "zh-Hant-TW": "zh-Hant-TW", + ro: "ro-RO", + ja: "ja-JP", + es: "es-ES", + sv: "sv-SE", + ru: "ru-RU", + }; + + let codeToMap = languageCode; + + // If languageCode is "default", get the default language from survey + if (languageCode === "default") { + const defaultLanguage = survey.languages?.find((lang) => lang.default); + if (defaultLanguage) { + codeToMap = defaultLanguage.language.code; + } else { + return "en-US"; + } + } + + // Check if it's already a web app locale code + if (languageToLocaleMap[codeToMap]) { + return languageToLocaleMap[codeToMap]; + } + + // Try to find a match by base language code (e.g., "pt-BR" -> "pt") + const baseCode = codeToMap.split("-")[0].toLowerCase(); + if (languageToLocaleMap[baseCode]) { + return languageToLocaleMap[baseCode]; + } + + // Fallback to English if language is not supported + return "en-US"; +}; diff --git a/packages/types/email.ts b/packages/types/email.ts index cc05516f22..cad555297e 100644 --- a/packages/types/email.ts +++ b/packages/types/email.ts @@ -1,11 +1,12 @@ import { z } from "zod"; +import { ZUserLocale } from "./user"; export const ZLinkSurveyEmailData = z.object({ surveyId: z.string(), email: z.string(), suId: z.string().optional(), surveyName: z.string(), - locale: z.string(), + locale: ZUserLocale, logoUrl: z.string().optional(), });