feat: multilingual email templates with translations table
Replace column-based email template languages (subject_en/subject_de) with a normalized email_template_translations table where each language is a row. This allows adding new languages without schema changes. - Add migration 075 to create email_template_translations table, migrate existing EN/DE data, and seed NL/PT/RU for customer-facing templates - Update processTemplate() to query translations table with fallback chain (requested lang -> en -> first available), with legacy column fallback - Restructure admin email API to return/accept translations object format - Update frontend EmailConfigPage with dynamic 5-language tabs, translation count badges, and copy-from-language feature for empty translations - Add Dutch to default language dropdown in general settings - Add Dutch to clientAccessI18n and password security messages in emails - Expand email domain detection for NL/BE/BR/PT/RU domains - Add email i18n keys (copyFrom, noTranslation, etc.) across all 5 locales
This commit is contained in:
+161
-133
@@ -248,6 +248,58 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: get translations for a template, with legacy column fallback
|
||||
async function getTemplateTranslations(templateId, template) {
|
||||
const translations = {};
|
||||
try {
|
||||
const rows = await db('email_template_translations')
|
||||
.where('template_id', templateId)
|
||||
.select('language', 'subject', 'body_html', 'body_text');
|
||||
|
||||
rows.forEach(row => {
|
||||
translations[row.language] = {
|
||||
subject: row.subject || '',
|
||||
body_html: row.body_html || '',
|
||||
body_text: row.body_text || '',
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
// Translations table might not exist yet (pre-migration)
|
||||
// Fall back to legacy columns
|
||||
if (template.subject_en !== undefined) {
|
||||
translations.en = {
|
||||
subject: template.subject_en || '',
|
||||
body_html: template.body_html_en || '',
|
||||
body_text: template.body_text_en || '',
|
||||
};
|
||||
translations.de = {
|
||||
subject: template.subject_de || '',
|
||||
body_html: template.body_html_de || '',
|
||||
body_text: template.body_text_de || '',
|
||||
};
|
||||
} else {
|
||||
translations.en = {
|
||||
subject: template.subject || '',
|
||||
body_html: template.body_html || '',
|
||||
body_text: template.body_text || '',
|
||||
};
|
||||
}
|
||||
}
|
||||
return translations;
|
||||
}
|
||||
|
||||
// Get email templates
|
||||
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
@@ -255,45 +307,17 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
|
||||
.select('*')
|
||||
.orderBy('template_key');
|
||||
|
||||
// Parse variables JSON and format for multi-language support
|
||||
const formattedTemplates = templates.map(template => {
|
||||
const result = {
|
||||
const formattedTemplates = [];
|
||||
for (const template of templates) {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
formattedTemplates.push({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
// Handle both old and new schema formats
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
result.subject_en = template.subject_en;
|
||||
result.body_html_en = template.body_html_en;
|
||||
result.body_text_en = template.body_text_en;
|
||||
result.subject_de = template.subject_de;
|
||||
result.body_html_de = template.body_html_de;
|
||||
result.body_text_de = template.body_text_de;
|
||||
} else {
|
||||
// Old schema - use basic columns for both languages
|
||||
result.subject_en = template.subject;
|
||||
result.body_html_en = template.body_html;
|
||||
result.body_text_en = template.body_text;
|
||||
result.subject_de = template.subject;
|
||||
result.body_html_de = template.body_html;
|
||||
result.body_text_de = template.body_text;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(formattedTemplates);
|
||||
} catch (error) {
|
||||
@@ -313,119 +337,99 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Handle both old and new schema formats
|
||||
const response = {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
|
||||
res.json({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
if (typeof template.variables === 'object') return template.variables;
|
||||
return JSON.parse(template.variables);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse variables for template:', template.template_key, e.message);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
updated_at: template.updated_at
|
||||
};
|
||||
|
||||
// Check which columns exist and use them appropriately
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
response.subject_en = template.subject_en;
|
||||
response.body_html_en = template.body_html_en;
|
||||
response.body_text_en = template.body_text_en;
|
||||
response.subject_de = template.subject_de;
|
||||
response.body_html_de = template.body_html_de;
|
||||
response.body_text_de = template.body_text_de;
|
||||
} else {
|
||||
// Old schema - use basic columns for both languages
|
||||
response.subject_en = template.subject;
|
||||
response.body_html_en = template.body_html;
|
||||
response.body_text_en = template.body_text;
|
||||
response.subject_de = template.subject;
|
||||
response.body_html_de = template.body_html;
|
||||
response.body_text_de = template.body_text;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email template fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email template' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update email template
|
||||
// Update email template translations
|
||||
router.put('/templates/:key', [
|
||||
adminAuth,
|
||||
requirePermission('email.edit'),
|
||||
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
|
||||
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
|
||||
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
|
||||
body('body_html_de').optional().notEmpty().withMessage('German HTML body cannot be empty')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
subject_en, subject_de,
|
||||
body_html_en, body_html_de,
|
||||
body_text_en, body_text_de
|
||||
} = req.body;
|
||||
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
// Check which columns exist in the database
|
||||
const template = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.first();
|
||||
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Determine schema type and update accordingly
|
||||
if (template.subject_en !== undefined) {
|
||||
// New schema with language columns
|
||||
if (subject_en !== undefined) updateData.subject_en = subject_en;
|
||||
if (subject_de !== undefined) updateData.subject_de = subject_de;
|
||||
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
|
||||
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
|
||||
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
|
||||
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
|
||||
|
||||
// Also update basic columns if they exist
|
||||
if (template.subject !== undefined) {
|
||||
updateData.subject = subject_en || updateData.subject_en;
|
||||
updateData.body_html = body_html_en || updateData.body_html_en;
|
||||
updateData.body_text = body_text_en || updateData.body_text_en || '';
|
||||
}
|
||||
} else {
|
||||
// Old schema - only update basic columns
|
||||
if (subject_en !== undefined) {
|
||||
updateData.subject = subject_en;
|
||||
updateData.body_html = body_html_en;
|
||||
updateData.body_text = body_text_en || '';
|
||||
const { translations } = req.body;
|
||||
|
||||
if (!translations || typeof translations !== 'object') {
|
||||
return res.status(400).json({ error: 'translations object is required' });
|
||||
}
|
||||
|
||||
// Upsert each language translation
|
||||
for (const [language, data] of Object.entries(translations)) {
|
||||
if (!data || typeof data !== 'object') continue;
|
||||
|
||||
const existing = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
const row = {
|
||||
subject: data.subject || '',
|
||||
body_html: data.body_html || '',
|
||||
body_text: data.body_text || '',
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.update(row);
|
||||
} else {
|
||||
await db('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language,
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.update(updateData);
|
||||
// Update timestamp on parent template
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update({ updated_at: new Date() });
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
// Also sync legacy columns for backward compatibility
|
||||
const enData = translations.en;
|
||||
const deData = translations.de;
|
||||
const legacyUpdate = { updated_at: new Date() };
|
||||
const columnInfo = await db('email_templates').columnInfo();
|
||||
|
||||
if (enData && columnInfo.subject_en) {
|
||||
legacyUpdate.subject_en = enData.subject || '';
|
||||
legacyUpdate.body_html_en = enData.body_html || '';
|
||||
legacyUpdate.body_text_en = enData.body_text || '';
|
||||
}
|
||||
if (deData && columnInfo.subject_de) {
|
||||
legacyUpdate.subject_de = deData.subject || '';
|
||||
legacyUpdate.body_html_de = deData.body_html || '';
|
||||
legacyUpdate.body_text_de = deData.body_text || '';
|
||||
}
|
||||
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update(legacyUpdate);
|
||||
|
||||
// Log activity
|
||||
await logActivity('email_template_updated',
|
||||
{ template_key: req.params.key },
|
||||
{ template_key: req.params.key, languages: Object.keys(translations) },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
@@ -449,16 +453,40 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
}
|
||||
|
||||
const { preview_data, language = 'en' } = req.body;
|
||||
|
||||
// Get the appropriate language version
|
||||
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
|
||||
|
||||
// Handle backward compatibility
|
||||
let htmlContent = template[htmlField] || template.body_html || '';
|
||||
let textContent = template[textField] || template.body_text || '';
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
|
||||
// Get translation from translations table with fallback
|
||||
let translation = null;
|
||||
try {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
if (!translation && language !== 'en') {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language: 'en' })
|
||||
.first();
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to legacy columns
|
||||
}
|
||||
|
||||
let subject = '';
|
||||
let htmlContent = '';
|
||||
let textContent = '';
|
||||
|
||||
if (translation) {
|
||||
subject = translation.subject || '';
|
||||
htmlContent = translation.body_html || '';
|
||||
textContent = translation.body_text || '';
|
||||
} else {
|
||||
// Legacy column fallback
|
||||
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
|
||||
subject = template[subjectField] || template.subject || '';
|
||||
htmlContent = template[htmlField] || template.body_html || '';
|
||||
textContent = template[textField] || template.body_text || '';
|
||||
}
|
||||
|
||||
if (preview_data) {
|
||||
const escapeHtml = (str) => String(str)
|
||||
@@ -472,7 +500,7 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
const escapedValue = escapeHtml(preview_data[key]);
|
||||
htmlContent = htmlContent.replace(regex, escapedValue);
|
||||
textContent = textContent.replace(regex, preview_data[key]); // text doesn't need HTML escaping
|
||||
textContent = textContent.replace(regex, preview_data[key]);
|
||||
subject = subject.replace(regex, escapeHtml(preview_data[key]));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,15 +99,22 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
logger.error('Error fetching email config language:', error);
|
||||
}
|
||||
|
||||
// Fourth priority: Check if the email domain suggests German
|
||||
// Fourth priority: Check if the email domain suggests a language
|
||||
if (email) {
|
||||
const germanDomains = ['.de', '.at', '.ch', '.li'];
|
||||
const domain = email.toLowerCase();
|
||||
if (germanDomains.some(d => domain.endsWith(d))) {
|
||||
return 'de';
|
||||
const domainLanguageMap = [
|
||||
{ domains: ['.de', '.at', '.ch', '.li'], language: 'de' },
|
||||
{ domains: ['.nl', '.be'], language: 'nl' },
|
||||
{ domains: ['.br', '.pt'], language: 'pt' },
|
||||
{ domains: ['.ru', '.su'], language: 'ru' },
|
||||
];
|
||||
for (const { domains, language: lang } of domainLanguageMap) {
|
||||
if (domains.some(d => domain.endsWith(d))) {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
@@ -300,30 +307,73 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { formatWelcomeMessage } = require('../utils/formatters');
|
||||
|
||||
// Get the appropriate language fields
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
// Get translation from email_template_translations table with fallback chain
|
||||
let subject = '';
|
||||
let htmlBody = '';
|
||||
let textBody = '';
|
||||
|
||||
// Fall back to non-language-specific fields for backward compatibility
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
try {
|
||||
// Try requested language first, then English, then any available
|
||||
let translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language })
|
||||
.first();
|
||||
|
||||
if (!translation && language !== 'en') {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id, language: 'en' })
|
||||
.first();
|
||||
}
|
||||
|
||||
if (!translation) {
|
||||
translation = await db('email_template_translations')
|
||||
.where({ template_id: template.id })
|
||||
.first();
|
||||
}
|
||||
|
||||
if (translation) {
|
||||
subject = translation.subject || '';
|
||||
htmlBody = translation.body_html || '';
|
||||
textBody = translation.body_text || '';
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('email_template_translations table not available, falling back to columns:', error.message);
|
||||
}
|
||||
|
||||
// Fallback to legacy column-based fields if no translation found
|
||||
if (!subject && !htmlBody) {
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
subject = template[subjectField] || template.subject_en || template.subject || '';
|
||||
htmlBody = template[htmlField] || template.body_html_en || template.body_html || '';
|
||||
textBody = template[textField] || template.body_text_en || template.body_text || '';
|
||||
}
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
const passwordSecurityI18n = {
|
||||
en: '(Not shown for security reasons)',
|
||||
de: '(Aus Sicherheitsgründen nicht angezeigt)',
|
||||
nl: '(Om veiligheidsredenen niet weergegeven)',
|
||||
pt: '(Não exibido por motivos de segurança)',
|
||||
ru: '(Не показано в целях безопасности)',
|
||||
};
|
||||
const noPasswordI18n = {
|
||||
en: 'No password required',
|
||||
de: 'Kein Passwort erforderlich',
|
||||
nl: 'Geen wachtwoord vereist',
|
||||
pt: 'Nenhuma senha necessária',
|
||||
ru: 'Пароль не требуется',
|
||||
};
|
||||
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? 'Kein Passwort erforderlich'
|
||||
: 'No password required';
|
||||
processedVariables.gallery_password = noPasswordI18n[language] || noPasswordI18n.en;
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
@@ -371,6 +421,12 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
link: 'Открыть доступ клиента',
|
||||
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
|
||||
},
|
||||
nl: {
|
||||
label: 'Klanttoegang (Privé)',
|
||||
desc: 'Bekijk en beheer de zichtbaarheid van foto\'s voordat u deelt met gasten:',
|
||||
link: 'Klanttoegang openen',
|
||||
warning: 'Deel deze link niet — hiermee kunnen foto\'s worden verborgen in de gastengalerij.',
|
||||
},
|
||||
pt: {
|
||||
label: 'Acesso do Cliente (Privado)',
|
||||
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
|
||||
|
||||
Reference in New Issue
Block a user