Files
wekan/server/lib/emailLocalization.js
Lauri Ojansivu 058677e078 E-mails in the clear HTML layout by default: notifications and account mail.
Notification e-mail now defaults to the clearly arranged layout of #5171 -
board first, board, actor and card in bold and linked, with a plain-text
alternative. One sentence (classic) is still one choice away at the
Admin Panel, board and member levels, and the tray keeps the classic line.

Account e-mails - reset password, verify e-mail, enroll account, and the
board and registration invitations - get an HTML part in the same style,
built from the same translated text, which is still sent as the text
part: no translation changes. Every piece of text is escaped first, the
link paragraph becomes a button, and only an http(s) address is linked. A
caller's own HTML and texts that are not WeKan's e-mail strings are left
as they were, and a failure to build the HTML leaves the text alone.

models/lib/accountEmailHtml.js renders it; tests/accountEmailHtml.test.cjs
lays out the real English texts and checks that markup in a name, a board
title or the site name stays text and only http(s) is linked.
notificationDelivery's default assertions now expect clear for e-mail,
and emailLocalizationPreparation's harness allows the new module, checks
the HTML part, and checks the absent-HTML case with literal text.

Thanks to Meeques and xet7 !

Fixes #4434.
2026-10-14 14:33:10 +03:00

91 lines
3.9 KiB
JavaScript

// emailLocalization.js
// Utility functions to handle email localization in Wekan
import { TAPi18n } from '/imports/i18n';
import { ReactiveCache } from '/imports/reactiveCache';
// Main object for email localization utilities
const EmailLocalization = {
/**
* Prepare transport content using the recipient's preferred language.
* Does not send; callers may persist the returned message before dispatch.
* @param {Object} options - Standard email sending options plus language options
* @param {String} options.to - Recipient email address
* @param {String} options.from - Sender email address
* @param {String} options.subject - Email subject i18n key
* @param {String} options.text - Email text i18n key
* @param {Object} options.params - Parameters for i18n translation
* @param {String} options.language - Language code to use (if not provided, will try to detect)
* @param {String} options.userId - User ID to determine language (if not provided with language)
*/
async prepareEmail(options) {
// Determine the language to use
let lang = options.language;
// If no language is specified but we have a userId, try to get the user's language
if (!lang && options.userId) {
const user = await ReactiveCache.getUser(options.userId);
if (user) {
lang = user.getLanguage();
}
}
// If no language could be determined, use the site default
if (!lang) {
lang = TAPi18n.getLanguage() || 'en';
}
// #5875: ensure the chosen language's bundle is loaded on the server before
// translating, otherwise __() falls back to English.
await TAPi18n.ensureLanguageLoaded(lang);
// Translate subject and text using the determined language
const subject = TAPi18n.__(options.subject, options.params || {}, lang);
let text = options.text;
// If text is an i18n key, translate it
if (typeof text === 'string' && text.startsWith('email-')) {
text = TAPi18n.__(text, options.params || {}, lang);
}
// Build final transport content without sending it. Durable callers can
// persist this snapshot instead of re-localizing during a later retry.
const mail = {
to: options.to,
from: options.from || Accounts.emailTemplates.from,
subject: subject,
text: text
};
if (options.attachments !== undefined) {
const { validateRuleEmailAttachments } = require('./ruleEmailAttachments');
// Only byte snapshots may cross this boundary, never paths or URLs.
mail.attachments = structuredClone(validateRuleEmailAttachments(options.attachments));
}
if (options.html !== undefined) mail.html = options.html;
// #4434: a WeKan account e-mail (an `email-...` i18n text such as a board
// invitation) also goes out in the clear HTML layout, built from the same
// translated text. A caller's own HTML, or text that is not one of
// WeKan's e-mail strings, is left exactly as it was.
else if (typeof options.text === 'string' && options.text.startsWith('email-') && typeof text === 'string') {
const { renderAccountEmailHtml } = require('/models/lib/accountEmailHtml');
mail.html = renderAccountEmailHtml({ text, siteName: Accounts.emailTemplates.siteName });
}
// #2414: reply-by-email - a Reply-To carrying an HMAC-signed card token,
// set by server/notifications/emailQueue.js, lets a reply sent through the
// inbound-email webhook (server/routes/inboundEmail.js) be matched back to
// the card it was about. Omitted entirely when the caller has none to give
// (e.g. WEKAN_INBOUND_EMAIL_DOMAIN is not configured).
if (options.replyTo) {
mail.replyTo = options.replyTo;
}
return mail;
},
async sendEmail(options) {
return await Email.sendAsync(await EmailLocalization.prepareEmail(options));
}
};
// Add module.exports to make it accessible from other files
export default EmailLocalization;