diff --git a/backend/migrations/core/075_email_template_translations.js b/backend/migrations/core/075_email_template_translations.js
new file mode 100644
index 00000000..0c30d926
--- /dev/null
+++ b/backend/migrations/core/075_email_template_translations.js
@@ -0,0 +1,281 @@
+/**
+ * Migration to create email_template_translations table
+ * Moves from per-column language support (subject_en, subject_de) to a
+ * normalized translations table where each language is a row.
+ * This allows adding new languages without schema changes.
+ */
+exports.up = async function(knex) {
+ // 1. Create the email_template_translations table
+ await knex.schema.createTable('email_template_translations', (table) => {
+ table.increments('id').primary();
+ table.integer('template_id').unsigned().notNullable()
+ .references('id').inTable('email_templates').onDelete('CASCADE');
+ table.string('language', 10).notNullable();
+ table.text('subject');
+ table.text('body_html');
+ table.text('body_text');
+ table.datetime('created_at').defaultTo(knex.fn.now());
+ table.datetime('updated_at').defaultTo(knex.fn.now());
+ table.unique(['template_id', 'language']);
+ });
+
+ console.log('Created email_template_translations table');
+
+ // 2. Migrate existing data from email_templates columns into rows
+ const templates = await knex('email_templates').select('*');
+ const columnInfo = await knex('email_templates').columnInfo();
+ const hasLangColumns = !!columnInfo.subject_en;
+
+ for (const template of templates) {
+ // Extract EN translation
+ const enSubject = hasLangColumns
+ ? (template.subject_en || template.subject || '')
+ : (template.subject || '');
+ const enHtml = hasLangColumns
+ ? (template.body_html_en || template.body_html || '')
+ : (template.body_html || '');
+ const enText = hasLangColumns
+ ? (template.body_text_en || template.body_text || '')
+ : (template.body_text || '');
+
+ // Insert EN translation
+ if (enSubject || enHtml) {
+ await knex('email_template_translations').insert({
+ template_id: template.id,
+ language: 'en',
+ subject: enSubject,
+ body_html: enHtml,
+ body_text: enText,
+ created_at: new Date(),
+ updated_at: new Date(),
+ });
+ }
+
+ // Extract DE translation (only if lang columns exist)
+ if (hasLangColumns) {
+ const deSubject = template.subject_de || '';
+ const deHtml = template.body_html_de || '';
+ const deText = template.body_text_de || '';
+
+ // Only insert if DE content differs from EN or has content
+ if (deSubject || deHtml) {
+ await knex('email_template_translations').insert({
+ template_id: template.id,
+ language: 'de',
+ subject: deSubject,
+ body_html: deHtml,
+ body_text: deText,
+ created_at: new Date(),
+ updated_at: new Date(),
+ });
+ }
+ }
+ }
+
+ console.log(`Migrated ${templates.length} templates to translations table`);
+
+ // 3. Seed NL, PT, RU translations for customer-facing templates
+ // Look up template IDs
+ const customerTemplates = await knex('email_templates')
+ .whereIn('template_key', [
+ 'gallery_created', 'expiration_warning', 'gallery_expired', 'archive_complete'
+ ])
+ .select('id', 'template_key');
+
+ const templateMap = {};
+ customerTemplates.forEach(t => { templateMap[t.template_key] = t.id; });
+
+ const seedTranslations = [];
+
+ // --- gallery_created ---
+ if (templateMap.gallery_created) {
+ const id = templateMap.gallery_created;
+ seedTranslations.push(
+ {
+ template_id: id, language: 'nl',
+ subject: 'Uw fotogalerij is klaar!',
+ body_html: `
Galerij succesvol aangemaakt
+Beste {{host_name}},
+Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!
+Galerij details:
+
+ - Evenementdatum: {{event_date}}
+ - Galerij link: {{gallery_link}}
+ - Wachtwoord: {{gallery_password}}
+ - Verloopt op: {{expiry_date}}
+
+Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.
+{{#if welcome_message}}{{welcome_message}}
{{/if}}`,
+ body_text: `Galerij succesvol aangemaakt\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is succesvol aangemaakt!\n\nGalerij link: {{gallery_link}}\nWachtwoord: {{gallery_password}}\nVerloopt op: {{expiry_date}}`,
+ },
+ {
+ template_id: id, language: 'pt',
+ subject: 'Sua galeria de fotos está pronta!',
+ body_html: `Galeria criada com sucesso
+Prezado(a) {{host_name}},
+Sua galeria de fotos "{{event_name}}" foi criada com sucesso!
+Detalhes da galeria:
+
+ - Data do evento: {{event_date}}
+ - Link da galeria: {{gallery_link}}
+ - Senha: {{gallery_password}}
+ - Expira em: {{expiry_date}}
+
+Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.
+{{#if welcome_message}}{{welcome_message}}
{{/if}}`,
+ body_text: `Galeria criada com sucesso\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" foi criada com sucesso!\n\nLink da galeria: {{gallery_link}}\nSenha: {{gallery_password}}\nExpira em: {{expiry_date}}`,
+ },
+ {
+ template_id: id, language: 'ru',
+ subject: 'Ваша фотогалерея готова!',
+ body_html: `Галерея успешно создана
+Уважаемый(ая) {{host_name}},
+Ваша фотогалерея "{{event_name}}" была успешно создана!
+Детали галереи:
+
+ - Дата события: {{event_date}}
+ - Ссылка на галерею: {{gallery_link}}
+ - Пароль: {{gallery_password}}
+ - Срок действия: {{expiry_date}}
+
+Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.
+{{#if welcome_message}}{{welcome_message}}
{{/if}}`,
+ body_text: `Галерея успешно создана\n\nУважаемый(ая) {{host_name}},\n\nВаша фотогалерея "{{event_name}}" была успешно создана!\n\nСсылка: {{gallery_link}}\nПароль: {{gallery_password}}\nСрок действия: {{expiry_date}}`,
+ },
+ );
+ }
+
+ // --- expiration_warning ---
+ if (templateMap.expiration_warning) {
+ const id = templateMap.expiration_warning;
+ seedTranslations.push(
+ {
+ template_id: id, language: 'nl',
+ subject: 'Uw fotogalerij verloopt binnenkort',
+ body_html: `Galerij verloopt binnenkort
+Beste {{host_name}},
+Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.
+Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.
+Galerij bezoeken
`,
+ body_text: `Galerij verloopt binnenkort\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.\n\nGalerij: {{gallery_link}}`,
+ },
+ {
+ template_id: id, language: 'pt',
+ subject: 'Sua galeria de fotos expira em breve',
+ body_html: `Galeria expirando em breve
+Prezado(a) {{host_name}},
+Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.
+Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.
+Visitar galeria
`,
+ body_text: `Galeria expirando em breve\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.\n\nGaleria: {{gallery_link}}`,
+ },
+ {
+ template_id: id, language: 'ru',
+ subject: 'Срок действия вашей фотогалереи скоро истекает',
+ body_html: `Срок действия галереи истекает
+Уважаемый(ая) {{host_name}},
+Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.
+После истечения срока галерея будет архивирована и станет недоступна для гостей.
+Перейти в галерею
`,
+ body_text: `Срок действия галереи истекает\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.\n\nГалерея: {{gallery_link}}`,
+ },
+ );
+ }
+
+ // --- gallery_expired ---
+ if (templateMap.gallery_expired) {
+ const id = templateMap.gallery_expired;
+ seedTranslations.push(
+ {
+ template_id: id, language: 'nl',
+ subject: 'Uw fotogalerij {{event_name}} is verlopen',
+ body_html: `Galerij verlopen
+Beste {{host_name}},
+Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.
+De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.
`,
+ body_text: `Galerij verlopen\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.\n\nNeem contact op met: {{admin_email}}`,
+ },
+ {
+ template_id: id, language: 'pt',
+ subject: 'Sua galeria de fotos {{event_name}} expirou',
+ body_html: `Galeria expirada
+Prezado(a) {{host_name}},
+Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.
+As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.
`,
+ body_text: `Galeria expirada\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirou e não está mais acessível.\n\nContato: {{admin_email}}`,
+ },
+ {
+ template_id: id, language: 'ru',
+ subject: 'Срок действия фотогалереи {{event_name}} истёк',
+ body_html: `Срок действия галереи истёк
+Уважаемый(ая) {{host_name}},
+Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.
+Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.
`,
+ body_text: `Срок действия галереи истёк\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истёк.\n\nКонтакт: {{admin_email}}`,
+ },
+ );
+ }
+
+ // --- archive_complete ---
+ if (templateMap.archive_complete) {
+ const id = templateMap.archive_complete;
+ seedTranslations.push(
+ {
+ template_id: id, language: 'nl',
+ subject: 'Archivering voltooid: {{event_name}}',
+ body_html: `Archivering voltooid
+Beste {{host_name}},
+De fotogalerij "{{event_name}}" is succesvol gearchiveerd.
+Archief details:
+
+ - Aantal foto's: {{photo_count}}
+ - Archiefgrootte: {{archive_size}}
+ - Archiefdatum: {{archive_date}}
+
`,
+ body_text: `Archivering voltooid\n\nBeste {{host_name}},\n\nDe fotogalerij "{{event_name}}" is succesvol gearchiveerd.\n\nAantal foto's: {{photo_count}}\nGrootte: {{archive_size}}`,
+ },
+ {
+ template_id: id, language: 'pt',
+ subject: 'Arquivamento concluído: {{event_name}}',
+ body_html: `Arquivamento concluído
+Prezado(a) {{host_name}},
+A galeria de fotos "{{event_name}}" foi arquivada com sucesso.
+Detalhes do arquivo:
+
+ - Número de fotos: {{photo_count}}
+ - Tamanho do arquivo: {{archive_size}}
+ - Data do arquivamento: {{archive_date}}
+
`,
+ body_text: `Arquivamento concluído\n\nPrezado(a) {{host_name}},\n\nA galeria de fotos "{{event_name}}" foi arquivada com sucesso.\n\nFotos: {{photo_count}}\nTamanho: {{archive_size}}`,
+ },
+ {
+ template_id: id, language: 'ru',
+ subject: 'Архивация завершена: {{event_name}}',
+ body_html: `Архивация завершена
+Уважаемый(ая) {{host_name}},
+Фотогалерея "{{event_name}}" была успешно архивирована.
+Детали архива:
+
+ - Количество фото: {{photo_count}}
+ - Размер архива: {{archive_size}}
+ - Дата архивации: {{archive_date}}
+
`,
+ body_text: `Архивация завершена\n\nУважаемый(ая) {{host_name}},\n\nФотогалерея "{{event_name}}" была успешно архивирована.\n\nФото: {{photo_count}}\nРазмер: {{archive_size}}`,
+ },
+ );
+ }
+
+ // Insert all seed translations
+ const now = new Date();
+ for (const trans of seedTranslations) {
+ trans.created_at = now;
+ trans.updated_at = now;
+ await knex('email_template_translations').insert(trans);
+ }
+
+ console.log(`Seeded ${seedTranslations.length} translations for customer-facing templates`);
+};
+
+exports.down = async function(knex) {
+ await knex.schema.dropTableIfExists('email_template_translations');
+};
diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js
index 6bb29c97..19b236c5 100644
--- a/backend/src/routes/adminEmail.js
+++ b/backend/src/routes/adminEmail.js
@@ -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]));
});
}
diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js
index 2ea88d0a..ed32365e 100644
--- a/backend/src/services/emailProcessor.js
+++ b/backend/src/services/emailProcessor.js
@@ -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:',
diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx
index 1873d532..2760fe5f 100644
--- a/frontend/src/features/settings/tabs/GeneralTab.tsx
+++ b/frontend/src/features/settings/tabs/GeneralTab.tsx
@@ -246,6 +246,7 @@ export const GeneralTab: React.FC = ({
>
+
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index 8011a34c..21252d5c 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -2149,7 +2149,11 @@
"enterUrl": "URL eingeben...",
"addLink": "Hinzufügen",
"cancel": "Abbrechen"
- }
+ },
+ "copiedFromLanguage": "Inhalt von {{language}} kopiert",
+ "noTranslation": "Noch keine Übersetzung",
+ "noTranslationYet": "Für diese Sprache existiert noch keine Übersetzung. Kopieren Sie von einer vorhandenen Sprache:",
+ "copyFrom": "Kopieren von"
},
"cms": {
"title": "CMS-Seiten",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 4d596f13..a0dcae8f 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -1698,7 +1698,11 @@
"enterUrl": "Enter URL...",
"addLink": "Add",
"cancel": "Cancel"
- }
+ },
+ "copiedFromLanguage": "Copied content from {{language}}",
+ "noTranslation": "No translation yet",
+ "noTranslationYet": "No translation exists for this language yet. Copy from an existing language to get started:",
+ "copyFrom": "Copy from"
},
"cms": {
"title": "CMS Pages",
diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json
index d7a925be..4a369ebc 100644
--- a/frontend/src/i18n/locales/nl.json
+++ b/frontend/src/i18n/locales/nl.json
@@ -433,7 +433,7 @@
"loadingPhotos": "Foto's laden...",
"photoCategories": "Fotocategorieen",
"organizeCategoriesInfo": "Organiseer uw foto's in categorieen. Categorieen helpen gasten om specifieke soorten foto's te vinden.",
- "categoriesTip": "Tip: Categorieen zijn specifiek per evenement. U kunt ook globale categorieen aanmaken in Instellingen.",
+ "categoriesTip": "Tip: Categorieen zijn specifiek per evenement. U kunt aangepaste categorieen aanmaken zoals \"Ceremonie\", \"Receptie\", \"Portretten\", etc.",
"contactInformation": "Contactgegevens",
"hostEmailHelp": "Klant ontvangt meldingen over aanmaak en verloop van de galerij",
"adminEmailHelp": "Ontvangt systeemmeldingen en archiefbevestigingen",
@@ -569,19 +569,13 @@
"guestsCannotAccessGallery": "Gasten hebben geen toegang meer tot de galerij. Overweeg dit evenement te archiveren.",
"warningEmailsHaveBeenSent": "Waarschuwingsmails zijn naar de klant verzonden.",
"extendSevenDays": "7 dagen verlengen",
- "eventInformation": "Evenementinformatie",
"welcomeMessageLabel": "Welkomstbericht",
"noWelcomeMessageSet": "Geen welkomstbericht ingesteld",
- "hostEmail": "E-mail klant",
- "adminEmail": "E-mail beheerder",
"createdOn": "Aangemaakt",
- "shareLink": "Deellink",
"copy": "Kopieren",
"copied": "Gekopieerd!",
"organizingPhotosInfo": "Organiseer uw foto's in categorieen. Categorieen helpen gasten om specifieke soorten foto's te vinden.",
- "categoriesTip": "Tip: Categorieen zijn specifiek per evenement. U kunt aangepaste categorieen aanmaken zoals \"Ceremonie\", \"Receptie\", \"Portretten\", etc.",
"archiveStatusTitle": "Archiefstatus",
- "archivedOn": "Gearchiveerd op",
"downloadingArchive": "Archief {{name}} downloaden...",
"downloadStarted": "Download gestart",
"failedToDownloadArchive": "Kan archief niet downloaden",
@@ -1704,7 +1698,11 @@
"enterUrl": "Voer URL in...",
"addLink": "Toevoegen",
"cancel": "Annuleren"
- }
+ },
+ "copiedFromLanguage": "Inhoud gekopieerd van {{language}}",
+ "noTranslation": "Nog geen vertaling",
+ "noTranslationYet": "Er bestaat nog geen vertaling voor deze taal. Kopieer van een bestaande taal om te beginnen:",
+ "copyFrom": "Kopiëren van"
},
"cms": {
"title": "CMS-pagina's",
@@ -2003,7 +2001,15 @@
"duration": "Duur",
"actions": "Acties"
},
- "details": "Details",
+ "details": {
+ "backupDetails": "Back-updetails",
+ "destination": "Bestemming",
+ "started": "Gestart",
+ "completed": "Voltooid",
+ "contentBackedUp": "Geback-upte inhoud",
+ "errorDetails": "Foutdetails",
+ "manifest": "Manifest"
+ },
"statistics": "Statistieken",
"errors": "Fouten",
"backupDetails": {
@@ -2033,15 +2039,6 @@
"backupsWillAppear": "Back-ups verschijnen hier zodra ze zijn aangemaakt",
"messages": {
"deleteSuccess": "Back-up succesvol verwijderd"
- },
- "details": {
- "backupDetails": "Back-updetails",
- "destination": "Bestemming",
- "started": "Gestart",
- "completed": "Voltooid",
- "contentBackedUp": "Geback-upte inhoud",
- "errorDetails": "Foutdetails",
- "manifest": "Manifest"
}
},
"restore": {
diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json
index 3c9b9b21..345db31b 100644
--- a/frontend/src/i18n/locales/pt.json
+++ b/frontend/src/i18n/locales/pt.json
@@ -1698,7 +1698,11 @@
"enterUrl": "Digite a URL...",
"addLink": "Adicionar",
"cancel": "Cancelar"
- }
+ },
+ "copiedFromLanguage": "Conteúdo copiado de {{language}}",
+ "noTranslation": "Ainda sem tradução",
+ "noTranslationYet": "Ainda não existe tradução para este idioma. Copie de um idioma existente para começar:",
+ "copyFrom": "Copiar de"
},
"cms": {
"title": "Páginas CMS",
diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json
index 2d252858..6fe4f821 100644
--- a/frontend/src/i18n/locales/ru.json
+++ b/frontend/src/i18n/locales/ru.json
@@ -1698,7 +1698,11 @@
"enterUrl": "Введите URL...",
"addLink": "Добавить",
"cancel": "Отмена"
- }
+ },
+ "copiedFromLanguage": "Содержимое скопировано из {{language}}",
+ "noTranslation": "Перевод отсутствует",
+ "noTranslationYet": "Перевод для этого языка ещё не существует. Скопируйте из существующего языка:",
+ "copyFrom": "Копировать из"
},
"cms": {
"title": "Страницы CMS",
diff --git a/frontend/src/pages/admin/EmailConfigPage.tsx b/frontend/src/pages/admin/EmailConfigPage.tsx
index b1c879cd..e1d08698 100644
--- a/frontend/src/pages/admin/EmailConfigPage.tsx
+++ b/frontend/src/pages/admin/EmailConfigPage.tsx
@@ -11,6 +11,7 @@ import {
Eye,
EyeOff,
ShieldAlert,
+ Copy,
} from 'lucide-react';
import { toast } from 'react-toastify';
@@ -19,10 +20,18 @@ import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { Palette } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
+import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
+const SUPPORTED_LANGUAGES = [
+ { code: 'en', name: 'English', flag: '🇬🇧' },
+ { code: 'de', name: 'Deutsch', flag: '🇩🇪' },
+ { code: 'nl', name: 'Nederlands', flag: '🇳🇱' },
+ { code: 'pt', name: 'Português', flag: '🇧🇷' },
+ { code: 'ru', name: 'Русский', flag: '🇷🇺' },
+];
+
const defaultTemplateKeys = [
{
key: 'gallery_created',
@@ -92,7 +101,7 @@ export const EmailConfigPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp');
const [selectedTemplateKey, setSelectedTemplateKey] = useState('gallery_created');
const [editedTemplate, setEditedTemplate] = useState>({});
- const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
+ const [editingLang, setEditingLang] = useState('en');
const [showPassword, setShowPassword] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [showPreview, setShowPreview] = useState(false);
@@ -104,7 +113,7 @@ export const EmailConfigPage: React.FC = () => {
const [emailPrimaryColor, setEmailPrimaryColor] = useState('#5C8762');
const [emailSecondaryColor, setEmailSecondaryColor] = useState('#f9f9f9');
const queryClient = useQueryClient();
-
+
// SMTP Configuration state
const [smtpConfig, setSmtpConfig] = useState({
smtp_host: '',
@@ -191,8 +200,8 @@ export const EmailConfigPage: React.FC = () => {
});
const saveTemplateMutation = useMutation({
- mutationFn: ({ key, template }: { key: string; template: Partial }) =>
- emailService.updateTemplate(key, template),
+ mutationFn: ({ key, translations }: { key: string; translations: Record }) =>
+ emailService.updateTemplate(key, { translations }),
onSuccess: () => {
toast.success(t('toast.saveSuccess'));
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
@@ -241,21 +250,41 @@ export const EmailConfigPage: React.FC = () => {
testEmailMutation.mutate(testEmail);
};
+ // Get current translation for the editing language
+ const currentTranslation = editedTemplate.translations?.[editingLang] || { subject: '', body_html: '', body_text: '' };
+
+ const handleTranslationChange = (field: keyof EmailTemplateTranslation, value: string) => {
+ setEditedTemplate(prev => ({
+ ...prev,
+ translations: {
+ ...prev.translations,
+ [editingLang]: {
+ ...prev.translations?.[editingLang],
+ [field]: value,
+ },
+ },
+ }));
+ };
+
+ const handleCopyFromLanguage = (sourceLang: string) => {
+ const sourceTranslation = editedTemplate.translations?.[sourceLang];
+ if (!sourceTranslation) return;
+
+ setEditedTemplate(prev => ({
+ ...prev,
+ translations: {
+ ...prev.translations,
+ [editingLang]: { ...sourceTranslation },
+ },
+ }));
+ toast.info(t('email.copiedFromLanguage', { language: SUPPORTED_LANGUAGES.find(l => l.code === sourceLang)?.name || sourceLang }));
+ };
+
const handleSaveTemplate = () => {
- if (selectedTemplateKey && editedTemplate) {
- const templateData: Partial = {};
-
- // Include both language versions
- if (editedTemplate.subject_en !== undefined) templateData.subject_en = editedTemplate.subject_en;
- if (editedTemplate.subject_de !== undefined) templateData.subject_de = editedTemplate.subject_de;
- if (editedTemplate.body_html_en !== undefined) templateData.body_html_en = editedTemplate.body_html_en;
- if (editedTemplate.body_html_de !== undefined) templateData.body_html_de = editedTemplate.body_html_de;
- if (editedTemplate.body_text_en !== undefined) templateData.body_text_en = editedTemplate.body_text_en;
- if (editedTemplate.body_text_de !== undefined) templateData.body_text_de = editedTemplate.body_text_de;
-
- saveTemplateMutation.mutate({
- key: selectedTemplateKey,
- template: templateData
+ if (selectedTemplateKey && editedTemplate.translations) {
+ saveTemplateMutation.mutate({
+ key: selectedTemplateKey,
+ translations: editedTemplate.translations,
});
}
};
@@ -264,11 +293,10 @@ export const EmailConfigPage: React.FC = () => {
if (!selectedTemplateKey || !editedTemplate) return;
// Generate sample data based on the template
- // Note: These are clearly marked placeholder values for template preview only
const sampleData: Record = {
event_name: 'John & Jane Wedding',
event_date: 'December 25, 2024',
- password: '••••••••', // Masked placeholder for preview
+ password: '••••••••',
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
expiration_date: 'January 25, 2025',
welcome_message: 'Thank you for celebrating our special day with us!',
@@ -290,6 +318,19 @@ export const EmailConfigPage: React.FC = () => {
}
};
+ // Count how many languages have translations for a template
+ const getTranslationCount = (template: EmailTemplate) => {
+ if (!template.translations) return 0;
+ return Object.keys(template.translations).filter(
+ lang => template.translations[lang]?.subject || template.translations[lang]?.body_html
+ ).length;
+ };
+
+ // Languages that have content and can be copied from
+ const copySourceLanguages = SUPPORTED_LANGUAGES.filter(
+ lang => lang.code !== editingLang && editedTemplate.translations?.[lang.code]?.body_html
+ );
+
if (configLoading || templatesLoading) {
return (
@@ -612,6 +653,8 @@ export const EmailConfigPage: React.FC = () => {
{templates.map(template => {
const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key);
+ const translationCount = getTranslationCount(template);
+ const enTranslation = template.translations?.en;
return (
);
@@ -642,28 +690,6 @@ export const EmailConfigPage: React.FC = () => {
{t('email.editTemplate')}
-
-
-
-
+ {/* Language tabs */}
+
+ {SUPPORTED_LANGUAGES.map(lang => {
+ const hasContent = editedTemplate.translations?.[lang.code]?.body_html;
+ return (
+
+ );
+ })}
+
+
+ {/* Copy from language */}
+ {!currentTranslation.body_html && copySourceLanguages.length > 0 && (
+
+
{t('email.noTranslationYet')}
+
+ {copySourceLanguages.map(lang => (
+
+ ))}
+
+
+ )}
+
);
-};
\ No newline at end of file
+};
diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts
index e8441f59..dc88b47d 100644
--- a/frontend/src/services/email.service.ts
+++ b/frontend/src/services/email.service.ts
@@ -11,19 +11,17 @@ export interface EmailConfig {
tls_reject_unauthorized: boolean;
}
+export interface EmailTemplateTranslation {
+ subject: string;
+ body_html: string;
+ body_text?: string;
+}
+
export interface EmailTemplate {
id: number;
template_key: string;
- subject: string; // For backward compatibility
- body_html: string; // For backward compatibility
- body_text?: string; // For backward compatibility
- subject_en: string;
- subject_de: string;
- body_html_en: string;
- body_html_de: string;
- body_text_en?: string;
- body_text_de?: string;
variables: string[];
+ translations: Record
;
updated_at: string;
}
@@ -63,16 +61,16 @@ export const emailService = {
},
// Update email template
- async updateTemplate(key: string, template: Partial): Promise {
- await api.put(`/admin/email/templates/${key}`, template);
+ async updateTemplate(key: string, data: { translations: Record }): Promise {
+ await api.put(`/admin/email/templates/${key}`, data);
},
// Preview email template
- async previewTemplate(key: string, previewData: Record, language: 'en' | 'de' = 'en'): Promise {
+ async previewTemplate(key: string, previewData: Record, language: string = 'en'): Promise {
const response = await api.post(
`/admin/email/templates/${key}/preview`,
{ preview_data: previewData, language }
);
return response.data;
}
-};
\ No newline at end of file
+};