diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts index afa9729..2718189 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -214,11 +214,21 @@ export class Contacts { // Fetch project to get language preference const project = await ContactService.getProjectByContactId(contactId); + // Get contact-level locale (overrides project language) + const contactLocale = + contact.data && + typeof contact.data === 'object' && + !Array.isArray(contact.data) && + 'locale' in contact.data && + typeof contact.data.locale === 'string' + ? contact.data.locale + : null; + return res.status(200).json({ id: contact.id, email: contact.email, subscribed: contact.subscribed, - language: project?.language || 'en', + language: contactLocale || project?.language || 'en', }); } diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index e821fd5..35ae603 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -1,4 +1,5 @@ import {type Contact, Prisma} from '@plunk/db'; +import {isValidLanguageCode} from '@plunk/shared'; import type {FilterCondition, FilterGroup} from '@plunk/types'; import {prisma} from '../database/prisma.js'; @@ -229,6 +230,20 @@ export class ContactService { continue; } + // Validate locale field + if (key === 'locale') { + if (typeof value === 'string') { + if (!isValidLanguageCode(value)) { + throw new HttpException( + 400, + `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`, + ); + } + } else if (value !== null && value !== undefined) { + throw new HttpException(400, 'Locale must be a string'); + } + } + // Handle non-persistent data format: { value: "...", persistent: false } if ( typeof value === 'object' && @@ -297,6 +312,12 @@ export class ContactService { Object.assign(mergedData, contact.data); } + // Explicitly expose locale as a predefined field (available in templates) + // This ensures locale is always accessible even if not in contact.data + if (mergedData.locale === undefined) { + mergedData.locale = null; + } + // Add temporary (non-persistent) data if (temporaryData) { for (const [key, value] of Object.entries(temporaryData)) { diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 771b55b..2f82a54 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -604,8 +604,18 @@ export class EmailService { const unsubscribeHtml = includeUnsubscribe ? (() => { - // Get translator for project's language - const translator = createTranslatorSync(project.language || 'en'); + // Get contact-level locale (overrides project language) + const contactLocale = + contact.data && + typeof contact.data === 'object' && + !Array.isArray(contact.data) && + 'locale' in contact.data && + typeof contact.data.locale === 'string' + ? contact.data.locale + : null; + + // Get translator for contact's locale or project's language + const translator = createTranslatorSync(contactLocale || project.language || 'en'); const unsubscribeText = translator.t('email.footer.unsubscribeText', { projectName: project.name, }); diff --git a/apps/web/src/components/EmailEditor/VariableMention.ts b/apps/web/src/components/EmailEditor/VariableMention.ts index 0a005c3..d64e523 100644 --- a/apps/web/src/components/EmailEditor/VariableMention.ts +++ b/apps/web/src/components/EmailEditor/VariableMention.ts @@ -117,7 +117,7 @@ export const VariableMention = Mention.configure({ items: ({query}) => { const safeVariables = Array.isArray(availableVariables) ? availableVariables : []; - const allVariables = ['email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', ...safeVariables]; + const allVariables = ['email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', 'locale', ...safeVariables]; const uniqueVariables = Array.from(new Set(allVariables)).filter(v => typeof v === 'string'); if (!query) {