diff --git a/apxtri/models/Notifications.js b/apxtri/models/Notifications.js index 3b5f523..7846a1b 100644 --- a/apxtri/models/Notifications.js +++ b/apxtri/models/Notifications.js @@ -262,6 +262,98 @@ Notifications.filternoitif = (tribe, tpl, alias, channel = "push", topics = []) return ok; }; +/** + * Language of a recipient: the language of its own app (persons.lg, stamped by + * Persons.managetopic on each topic subscription) wins over the language of + * whoever triggered the event, which is only a fallback. Anything outside + * fr/en is forced to "en", the same way checkHeaders forces xlang. + */ +Notifications.recipientlang = (person, fallback) => + person && ["fr", "en"].includes(person.lg) + ? person.lg + : ["fr", "en"].includes(fallback) + ? fallback + : "en"; + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}/; +const localdate_re = /\{\{#localdate\}\}\s*([^\s}]+)\s*\{\{\/localdate\}\}/g; + +/** + * A date is never formatted by the caller: it sends the raw value (ISO) and the + * template declares how to display it. + * + * seekertemplate_fr.js : tpl.dateformat = { bookdate: "DD/MM/YYYY" } + * template : ... le {{#localdate}}bookdate{{/localdate}} ... + * + * The text of the section is the NAME of the field to display, and it is not + * "{{bookdate}}": mustache hands the raw inner text of a lambda, it does not + * render it, so the value is read in the view (closure) and not through a + * render callback. tpl.dateformat gives the dayjs format of that field. + * + * Anything that is not an ISO date (missing, empty, ...) is returned untouched, + * which also prevents an "Invalid Date" from reaching a recipient. + * + * The value MUST be a factory (() => function (text)): mustache calls a function + * found in the data with no argument during the lookup + * (mustache.js Context.lookup), so a plain (text) => ... lambda is silently + * ignored and the section renders its own content. + */ +Notifications.localdate = (tpl, view) => () => (text) => { + const field = text.trim(); + const raw = view ? view[field] : undefined; + const value = raw === undefined || raw === null ? "" : String(raw).trim(); + const fmt = (tpl.dateformat || {})[field]; + if (!fmt || !ISO_DATE_RE.test(value)) return value; + return dayjs(value).format(fmt); +}; + +/** + * Single rendering point for every notification template (email subject/html/ + * text and push notiftitle/notifdescription). + * + * Notifications.render(tpl, view, ["subject", "html", "text"]) + * + * `keys` is explicit on purpose: a template without tpl.text must keep failing + * instead of silently producing a mail without its text part. `meta` is only + * used to make the warnings actionable ({ template, lg }). + * + * Warns on both directions of a mismatch between tpl.dateformat and the + * template: a declared format never used means the raw ISO date is displayed, + * and a {{#localdate}} without declaration means the same thing silently. + */ +Notifications.render = (tpl, view, keys, meta = {}) => { + view.localdate = Notifications.localdate(tpl, view); + const rendered = {}; + try { + for (const k of keys) rendered[k] = Mustache.render(tpl[k], view); + } finally { + // view is the caller object (req.body, emailsto entry, push recipient) and + // is persisted as is in persons.notifications[]: do not leak the factory. + delete view.localdate; + } + const sources = ["subject", "html", "text", "notiftitle", "notifdescription"] + .map((k) => (typeof tpl[k] === "string" ? tpl[k] : "")) + .join("\n"); + const used = new Set([...sources.matchAll(localdate_re)].map((m) => m[1])); + const declared = Object.keys(tpl.dateformat || {}); + const where = `${meta.template || "?"}_${meta.lg || "?"}`; + for (const field of declared) { + if (!used.has(field)) + console.warn( + currentmod, + `dateformat "${field}" declared but never used in ${where}` + ); + } + for (const field of used) { + if (!declared.includes(field)) + console.warn( + currentmod, + `{{#localdate}}${field}} used without dateformat in ${where}, raw value displayed` + ); + } + return rendered; +}; + Notifications.manageemail = async ( data, templatename, @@ -360,9 +452,13 @@ Notifications.manageemail = async ( emailtosend.to = emailtosend.to.slice(0, -1); emailtosend.templatename = templatename; emailtosend.lg = lg; - emailtosend.subject = Mustache.render(tplemail.subject, email); - emailtosend.html = Mustache.render(tplemail.html, email); - emailtosend.text = Mustache.render(tplemail.text, email); + Object.assign( + emailtosend, + Notifications.render(tplemail, email, ["subject", "html", "text"], { + template: templatename, + lg, + }) + ); if (email.cc) { emailtosend.cc += emailtosend.cc && emailtosend.cc != "" ? "," : ""; emailtosend.cc += email.cc.join(","); diff --git a/apxtri/models/Pagans.js b/apxtri/models/Pagans.js index 7d39403..4f1e5b3 100644 --- a/apxtri/models/Pagans.js +++ b/apxtri/models/Pagans.js @@ -264,9 +264,10 @@ Pagans.sendmailkey = (data) => { const maildata = { from: tplemail.sender, to: person.email, - subject: Mustache.render(tplemail.subject, person), - html: Mustache.render(tplemail.html, person), - text: Mustache.render(tplemail.text, person), + ...Notifications.render(tplemail, person, ["subject", "html", "text"], { + template: "createidentity", + lg: data.lg, + }), attachments: [ { filename: `${person.alias}_privatekey.txt`, diff --git a/apxtri/models/PagansPeter.js b/apxtri/models/PagansPeter.js index 7f6b2ed..836f1e3 100644 --- a/apxtri/models/PagansPeter.js +++ b/apxtri/models/PagansPeter.js @@ -165,9 +165,10 @@ Pagans.sendmailkey = (data) => { const maildata = { from: tplemail.sender, to: person.email, - subject: Mustache.render(tplemail.subject, person), - html: Mustache.render(tplemail.html, person), - text: Mustache.render(tplemail.text, person), + ...Notifications.render(tplemail, person, ["subject", "html", "text"], { + template: "createidentity", + lg: data.lg, + }), attachments: [ { filename: `${person.alias}_privatekey.txt`, diff --git a/apxtri/routes/notifications.js b/apxtri/routes/notifications.js index a6c58a1..fdf82c1 100644 --- a/apxtri/routes/notifications.js +++ b/apxtri/routes/notifications.js @@ -284,7 +284,7 @@ router.get("/checkemail/:email/:digit6", checkHeaders, async (req, res) => { }, "checkemail", "adminapi", - req.session.header.xlang || "fr", + Notifications.recipientlang(null, req.session.header.xlang), 1, ["anonymous"] );