From a0f47bc3bebb60079925043e35b46c5e3e3afbe8 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Sun, 20 Sep 2026 06:38:04 -0400 Subject: [PATCH] fix: use translations for mail templates + fix load translations race condition --- CLAUDE.md | 66 +++++ backend/api/authentication.ts | 25 +- backend/api/mail.ts | 15 +- backend/api/schemas/user.ts | 11 + backend/api/users.ts | 11 +- backend/locales/en.json | 31 ++- backend/models/locales.ts | 87 +++++++ backend/models/mail.ts | 245 +++++++++--------- backend/models/users.ts | 56 +++- backend/types/fastify.d.ts | 1 + .../src/components/ApiKeyCreateDialog.vue | 11 +- frontend/src/components/AuthLoginPanel.vue | 16 +- frontend/src/components/GroupEditOverlay.vue | 15 +- .../components/MailTemplateEditorOverlay.vue | 170 ------------ frontend/src/components/NavEditOverlay.vue | 11 +- .../src/components/PageDataTemplateDialog.vue | 17 +- frontend/src/components/TreeLevel.vue | 3 +- frontend/src/components/TreeNav.vue | 27 +- frontend/src/components/TreeNode.vue | 2 + frontend/src/components/UserDefaultsMenu.vue | 17 +- frontend/src/components/UserEditOverlay.vue | 43 ++- frontend/src/layouts/AdminLayout.vue | 1 - frontend/src/layouts/InboxLayout.vue | 13 +- frontend/src/layouts/ProfileLayout.vue | 13 +- frontend/src/pages/AdminGeneral.vue | 25 +- frontend/src/pages/AdminGroups.vue | 11 +- frontend/src/pages/AdminInstances.vue | 13 +- frontend/src/pages/AdminMail.vue | 55 +--- frontend/src/pages/AdminScheduler.vue | 21 +- frontend/src/pages/AdminUsers.vue | 11 +- frontend/src/pages/ProfileInfo.vue | 60 ++++- frontend/src/stores/user.js | 12 +- 32 files changed, 690 insertions(+), 425 deletions(-) delete mode 100644 frontend/src/components/MailTemplateEditorOverlay.vue diff --git a/CLAUDE.md b/CLAUDE.md index f4bad04c0..415598af8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1245,6 +1245,72 @@ back as the truth. It says so in a banner instead. `selectedProvider` still answ key whose module has been dropped from the installation, which is the one case the screen cannot produce and has to describe. +### Emails + +The wiki sends three — a registration confirmation, a forgotten password, and the admin area's test +button — and `models/mail.ts` is the only place nodemailer is used. `MailTemplateData` is the +closed list, held as typed literals rather than rows in a table: nothing sends a mail this wiki did +not ask it to, so a template is part of the flow that uses it and a flow that gained one would gain +code there anyway. A wiki with no SMTP settings is the normal case, which is why `isConfigured` is +a question callers ask rather than something `send()` assumes. + +**No mail is written in English in the code.** Every string lives in `locales/en.json` under +`mail.*` and is translated by the same CrowdIn pipeline the interface uses, so a locale somebody +translates arrives in the mails without anything here changing. Adding a template therefore means +adding its keys there, and there is no second place copy is kept. + +**`locales.translator(code)` is how strings are resolved server-side** — a bound `{ locale, isRTL, +t }` rather than a `t(locale, key)` call, because the strings have to be fetched from the db and +everything that renders text does it one locale at a time and several strings at a time. Keys and +`{name}` placeholders are vue-i18n's, so a translator need not know which side of the wire a string +is rendered on. It is the first server-side translation in the codebase and is not mail-specific; +anything else the wiki writes for a person rather than for a machine belongs in it too. + +- **Two fallbacks, and they are not the same thing.** A code naming a locale that is not installed — + or is not a locale at all — is not used, which is also what stops an unvalidated value off a + request body from putting an entry in the string cache. A locale that IS installed but is missing + the key asked for falls back to `en` for that key alone, because a translation lags the release + that added a string and a half-translated locale must not emit raw keys at a reader. +- **String sets are cached; locale metadata already was.** `getLocales` holds the rows, and + `#stringsFor` holds the blobs — a few thousand entries the interface re-fetches per request and + has no reason to keep, but which a mail reads a handful of keys out of. `reloadCache` drops them, + which every install and update already calls and which the `reloadLocales` event runs on the other + instances of an HA set. + +**One description, two bodies.** A template returns a `MailContent` — subject, title, paragraphs, at +most one action, footer — and `htmlShell` and `textBody` are two renderings of it. Each template +used to write both out by hand, and a string changed in one was a string not changed in the other. +Everything in the description appears in both: the title is a heading in the HTML and a first line +in the text, and a paragraph written under a heading refers to it. + +**Which language a mail is written in is the caller's answer, not the model's.** `MailRequest.locale` +is what is known about the recipient, and what is known differs at every send site: an account's own +`prefs.locale`, the locale the browser filling the form was reading the wiki in, or nothing at all. +`localeFor` then falls back to the site's primary locale — the wiki's own language, which is the +right answer for a mail nobody has a preference on. The admin area's test button sends in the +language the admin area is being read in, so that it also shows what the templates say in it. + +**`prefs.locale` is a language preference, not an interface setting.** It is edited under Profile → +Info and per-user in the admin user editor, and **registration seeds it from the locale the sign-up +form was filled in** — which is the only thing a brand new account has to go on, and means the +preference populates itself for anybody who signed up reading the wiki in their own language. What +the INTERFACE is drawn in is a different question with a different answer: on a page it is the +page's own locale, and elsewhere the locale picker's per-browser choice. See the locale block in +`App.vue`. + +**The direction is declared three times on purpose.** Gmail and Outlook.com drop the `` and +`` elements and paste what is between them into their own document, taking any `dir` on them +with it — so an RTL mail read there comes out left-aligned unless the `` that survives carries +the direction itself. The bare URL under a button stays `ltr` either way: a URL is not written in +the language around it, and bidi reordering makes one unreadable. + +**Templates are not editable by an administrator**, and the stub that suggested they were — a +`@vue/repl` playground behind the experimental flag, wired to a Save button that did nothing and +importing a package the frontend does not have — is gone. Customization is a separate feature that +has not been built; if it is, the shape to keep is sparse overrides on top of the locale strings +rather than a replacement for them, so that a wiki that rewords one sentence keeps getting +translations and improvements for everything else. + ### Audit log Every action a **person** takes is one row in `auditLog` — `userId`, `clientIP`, `ts`, `kind` diff --git a/backend/api/authentication.ts b/backend/api/authentication.ts index e71ae4420..a8bbe3f23 100644 --- a/backend/api/authentication.ts +++ b/backend/api/authentication.ts @@ -583,7 +583,7 @@ async function routes(app: FastifyInstance) { */ app.post<{ Params: { siteId: string } - Body: { strategyId: string; name: string; email: string; password: string } + Body: { strategyId: string; name: string; email: string; password: string; locale?: string } }>( '/sites/:siteId/auth/register', { @@ -629,6 +629,12 @@ async function routes(app: FastifyInstance) { type: 'string', minLength: 8, maxLength: 255 + }, + locale: { + type: 'string', + description: + 'The language the wiki was being read in while this form was filled. Kept as the new account’s own language preference and used for the mail that follows. Omitted, or naming a locale this wiki has not installed, the site’s own language is used.', + maxLength: 255 } } }, @@ -650,7 +656,8 @@ async function routes(app: FastifyInstance) { email: req.body.email, password: req.body.password, ip: req.ip, - baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId }) + baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId }), + locale: req.body.locale }, req ) @@ -754,7 +761,10 @@ async function routes(app: FastifyInstance) { * for why. What it does report is the two things that are about this wiki rather than about a user: * a strategy that does not offer resets, and an instance with no mail server configured. */ - app.post<{ Params: { siteId: string }; Body: { strategyId: string; email: string } }>( + app.post<{ + Params: { siteId: string } + Body: { strategyId: string; email: string; locale?: string } + }>( '/sites/:siteId/auth/forgotPassword', { config: { @@ -789,6 +799,12 @@ async function routes(app: FastifyInstance) { type: 'string', format: 'email', maxLength: 255 + }, + locale: { + type: 'string', + description: + 'The language the wiki was being read in while this form was filled, used for the mail when the account has no language of its own. Omitted, or naming a locale this wiki has not installed, the site’s own language is used.', + maxLength: 255 } } }, @@ -812,7 +828,8 @@ async function routes(app: FastifyInstance) { strategyId: req.body.strategyId, email: req.body.email, ip: req.ip, - baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId }) + baseUrl: WIKI.models.mail.baseUrl({ req, siteId: req.params.siteId }), + locale: req.body.locale }) return { ok: true } } catch (err: any) { diff --git a/backend/api/mail.ts b/backend/api/mail.ts index 10cff3f33..21624f842 100644 --- a/backend/api/mail.ts +++ b/backend/api/mail.ts @@ -153,7 +153,7 @@ async function routes(app: FastifyInstance) { * password otherwise. Sent through the same transport every other mail goes through, so a failure * here is the failure they would have hit. */ - app.post<{ Body: { recipient: string } }>( + app.post<{ Body: { recipient: string; locale?: string } }>( '/test', { config: { @@ -172,6 +172,12 @@ async function routes(app: FastifyInstance) { type: 'string', format: 'email', maxLength: 255 + }, + locale: { + type: 'string', + description: + 'Which language to write the test in, which is how the localized templates are checked. Defaults to the site’s own language, as a mail nobody has a preference on does.', + maxLength: 255 } } }, @@ -205,11 +211,16 @@ async function routes(app: FastifyInstance) { siteId, to: req.body.recipient, template: 'test', + locale: req.body.locale, data: { baseUrl: WIKI.models.mail.baseUrl({ req, siteId }) } }) - await audit(req, 'admin', 'sendTestEmail', { recipient: req.body.recipient, siteId }) + await audit(req, 'admin', 'sendTestEmail', { + recipient: req.body.recipient, + siteId, + locale: req.body.locale + }) return { ok: true, diff --git a/backend/api/schemas/user.ts b/backend/api/schemas/user.ts index 5b9bcdce9..6e53d7ef1 100644 --- a/backend/api/schemas/user.ts +++ b/backend/api/schemas/user.ts @@ -152,6 +152,11 @@ export async function registerSchemas(app: FastifyInstance): Promise { pronouns: { type: 'string' }, + locale: { + type: 'string', + description: + 'The language the wiki writes to this user in — emails today. An empty string means the language of the site the mail is about.' + }, timezone: { type: 'string', description: 'IANA time zone name, or an empty string to use the client time zone.' @@ -255,6 +260,12 @@ export async function registerSchemas(app: FastifyInstance): Promise { type: 'string', maxLength: 255 }, + locale: { + type: 'string', + description: + 'The language to be written to in, as a locale code the wiki has installed. An empty string follows the site instead. A code naming no installed locale is ignored when the mail is rendered.', + maxLength: 255 + }, timezone: { type: 'string', description: 'IANA time zone name, e.g. `America/New_York`.', diff --git a/backend/api/users.ts b/backend/api/users.ts index 633c00874..b999e9787 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -63,9 +63,10 @@ const IDENTITY_PROFILE_FIELDS = ['name', 'location', 'jobTitle', 'pronouns'] as * The rest of the profile: how the wiki behaves for this one person. * * Never gated on `allowProfileEditing`, because no identity provider owns them — a time zone, a date - * format and a colour-vision setting are properties of whoever is reading, not of the account record - * an administrator is keeping authoritative. Turning profile editing off to keep names in step with - * a directory must not take somebody's accessibility settings away with it. + * format, a colour-vision setting and the language to be written to in are properties of whoever is + * reading, not of the account record an administrator is keeping authoritative. Turning profile + * editing off to keep names in step with a directory must not take somebody's accessibility settings + * away with it. */ const PERSONAL_PROFILE_FIELDS = [ /* @@ -75,6 +76,7 @@ const PERSONAL_PROFILE_FIELDS = [ one field that lets anybody be mentioned in a comment. */ 'handle', + 'locale', 'timezone', 'dateFormat', 'timeFormat', @@ -266,7 +268,7 @@ async function routes(app: FastifyInstance) { schema: { summary: "Update the logged in user's own profile", description: - 'Updates any subset of the profile fields; omitted ones are left unchanged. The name, location, job title and pronouns require profile editing to be enabled on this wiki (Administration → Authentication) and are refused otherwise; the time zone, date and time formats, appearance and colour-vision settings are the user’s own and are always accepted. The email cannot be changed here, and neither can any field an administrator owns.', + 'Updates any subset of the profile fields; omitted ones are left unchanged. The name, location, job title and pronouns require profile editing to be enabled on this wiki (Administration → Authentication) and are refused otherwise; the language, time zone, date and time formats, appearance and colour-vision settings are the user’s own and are always accepted. The email cannot be changed here, and neither can any field an administrator owns.', tags: ['Users'], body: { $ref: 'UserProfileUpdate#' @@ -337,6 +339,7 @@ async function routes(app: FastifyInstance) { req.session.user = { ...req.session.user!, name: profile.name, + locale: profile.locale, timezone: profile.timezone, dateFormat: profile.dateFormat, timeFormat: profile.timeFormat, diff --git a/backend/locales/en.json b/backend/locales/en.json index e62a0c0e5..da7888446 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -806,10 +806,6 @@ "admin.mail.smtpVerifySSL": "Verify SSL Certificate", "admin.mail.smtpVerifySSLHint": "Some hosts requires SSL certificate checking to be disabled. Leave enabled for proper security.", "admin.mail.subtitle": "Configure mail settings", - "admin.mail.templateEditor": "Mail Template Editor", - "admin.mail.templateResetPwd": "Password Reset Email", - "admin.mail.templateWelcome": "Welcome Email", - "admin.mail.templates": "Mail Templates", "admin.mail.test": "Send a test email", "admin.mail.testHint": "Send a test email to ensure your SMTP configuration is working.", "admin.mail.testRecipient": "Recipient Email Address", @@ -1327,6 +1323,9 @@ "admin.users.jobTitle": "Job Title", "admin.users.jobTitleHint": "The job title of the user.", "admin.users.joined": "Joined", + "admin.users.language": "Preferred Language for Communications", + "admin.users.languageHint": "The language to use for emails and notifications that are not locale-specific.", + "admin.users.languageSiteDefault": "Site Default", "admin.users.lastLoginAt": "Last login {date}", "admin.users.lastUpdated": "Last Updated", "admin.users.linkedAccounts": "Linked Accounts", @@ -2672,6 +2671,27 @@ "localeFetchDialog.resultUnchanged": "{count} already up to date", "localeFetchDialog.resultUpdated": "No locale updated | {count} locale updated | {count} locales updated", "localeFetchDialog.title": "Fetch Updates", + "mail.common.greeting": "Hi {name},", + "mail.resetPwd.action": "Choose a new password", + "mail.resetPwd.body": "Somebody asked to reset the password for your account on {siteName}.", + "mail.resetPwd.expiry": "This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.", + "mail.resetPwd.footer": "You are receiving this because a password reset was requested for this address on {siteName}.", + "mail.resetPwd.subject": "Reset your password \u2014 {siteName}", + "mail.resetPwd.title": "Reset your password", + "mail.test.action": "Go to the wiki", + "mail.test.body": "If you are reading it, {siteName} can send mail through the SMTP server it is configured with.", + "mail.test.footer": "You are receiving this because somebody sent a test email from the Wiki.js admin area.", + "mail.test.subject": "Test email \u2014 {siteName}", + "mail.test.title": "This is a test email", + "mail.welcome.action": "Go to the wiki", + "mail.welcome.body": "Your account on {siteName} is ready. You can sign in at any time.", + "mail.welcome.footer": "You are receiving this because an account was created for this address on {siteName}.", + "mail.welcome.subject": "Welcome to {siteName}", + "mail.welcome.verify.action": "Confirm my email address", + "mail.welcome.verify.body": "An account was created for this address on {siteName}. Confirm that it is yours to finish signing up.", + "mail.welcome.verify.expiry": "This link is valid for 24 hours. If you did not create this account, you can ignore this message.", + "mail.welcome.verify.subject": "Confirm your email address \u2014 {siteName}", + "mail.welcome.verify.title": "Confirm your email address", "navEdit.clearItems": "Clear All Items", "navEdit.editMenuItems": "Edit Menu Items", "navEdit.editingInherited": "Inherited menu — shared with every page using it", @@ -2799,6 +2819,9 @@ "profile.infoLoadingFailed": "Failed to load your profile.", "profile.jobTitle": "Job Title", "profile.jobTitleHint": "Your position in your organization; shown on your profile page.", + "profile.language": "Preferred Language for Communications", + "profile.languageHint": "The language to use for emails and notifications that are not locale-specific.", + "profile.languageSiteDefault": "Site Default", "profile.localeDefault": "Locale Default", "profile.location": "Location", "profile.locationHint": "Your city and country; shown on your profile page.", diff --git a/backend/models/locales.ts b/backend/models/locales.ts index 442c35216..fb0acdf92 100644 --- a/backend/models/locales.ts +++ b/backend/models/locales.ts @@ -21,6 +21,24 @@ const REMOTE_BASE_URL = 'https://github.com/requarks/wiki-locales/raw/main' */ const SOURCE_LOCALE = 'en' +/** + * A `{name}` placeholder in a locale string, as vue-i18n writes them. + * + * A name with nothing to put in it is left as it stands rather than emptied: the string is then + * visibly wrong in the place the mistake actually is, instead of quietly missing a word. + */ +const INTERPOLATION = /\{([A-Za-z0-9_]+)\}/g + +/** One locale's strings, bound and ready to render with. See `translator`. */ +export interface Translator { + /** The locale these strings are actually for, which need not be the one that was asked for. */ + locale: string + /** Whether it is written right to left, which is what a document rendered in it has to declare. */ + isRTL: boolean + /** One string, with its `{name}` placeholders filled in. */ + t(key: string, params?: Record): string +} + /** One entry of the remote `metadata.json`: a strings file and the hash of its contents. */ interface RemoteLocale { file: string @@ -634,9 +652,78 @@ class Locales { return results.length === 1 ? results[0].strings : [] } + /** + * One locale's string set, held in memory after the first read. + * + * Only the locale METADATA is cached by `getLocales`; the strings are a blob of a few thousand + * entries that the interface fetches from the db per request and has no reason to keep. What + * reads them here does: a mail is rendered from a handful of keys, and paying a query for each + * send — of a set that changes only when a locale is installed or updated — is the wrong trade. + * + * Dropped by `reloadCache`, which is what every install and update already calls and what the + * `reloadLocales` event runs on the other instances of an HA set. + */ + async #stringsFor(code: string): Promise> { + const cacheKey = `localeStrings:${code}` + const cached = WIKI.cache.get(cacheKey) as Record | undefined + if (cached) { + return cached + } + const strings = (await this.getStrings(code)) as Record + // -> `getStrings` answers `[]` for a locale that has no row at all + const resolved = Array.isArray(strings) ? {} : (strings ?? {}) + WIKI.cache.set(cacheKey, resolved) + return resolved + } + + /** + * Resolve strings server-side, the way the interface resolves them in the browser. + * + * Everything the wiki writes for a person to read rather than for a machine to parse belongs in + * `locales/en.json` and is translated with the rest of it — today that is the mails, which used to + * be English literals in `models/mail.ts`. The keys and the `{name}` placeholders are vue-i18n's, + * because a translator working on CrowdIn should not have to know which side of the wire a string + * is rendered on. + * + * **It is a bound translator rather than a `t(locale, key)` call** because the strings have to be + * fetched, and everything that renders text does it one locale at a time and several strings at a + * time: awaiting the locale once and then filling in a template synchronously is what keeps the + * rendering itself readable. + * + * Two fallbacks, and they are not the same thing. A locale that is not installed — or is not a + * locale this wiki has heard of — is not used at all, so that an unvalidated code from a request + * body cannot put an entry in the cache above. A locale that IS installed but is missing the key + * asked for falls back to `en` for that key alone, because a translation lags the release that + * added the string and a half-translated locale must not emit raw keys at a reader. + * + * @param code The locale wanted, if there is one + */ + async translator(code?: string | null): Promise { + const known = this.#cachedLocales().find((lc) => lc.code === code && lc.isInstalled) + const locale = known?.code ?? SOURCE_LOCALE + const strings = await this.#stringsFor(locale) + const fallback = locale === SOURCE_LOCALE ? strings : await this.#stringsFor(SOURCE_LOCALE) + return { + locale, + isRTL: known?.isRTL ?? false, + t(key, params = {}) { + // -> The key itself for a string no locale has, which is what vue-i18n shows and is the one + // form of this that says what is missing + const template = strings[key] ?? fallback[key] ?? key + return template.replace(INTERPOLATION, (match, name: string) => + name in params ? String(params[name]) : match + ) + } + } + } + async reloadCache(): Promise { WIKI.logger.info('Reloading locales cache...') const locales = await WIKI.models.locales.getLocales({ cache: false }) + // -> The string sets too, since an update run is exactly what changes them + for (const locale of locales) { + WIKI.cache.del(`localeStrings:${locale.code}`) + } WIKI.logger.info(`Loaded ${locales.length} locales into cache [ OK ]`) } } diff --git a/backend/models/mail.ts b/backend/models/mail.ts index c39f825cb..923c24860 100644 --- a/backend/models/mail.ts +++ b/backend/models/mail.ts @@ -1,13 +1,18 @@ import { createTransport } from 'nodemailer' import type { Transporter } from 'nodemailer' +import type { Translator } from './locales.ts' /** * The templates this wiki sends, and what each one needs. * - * Two of them are the ones the admin area names under Mail Templates; `test` is the button beside - * them. Held as literals rather than rows in a table because nothing sends a mail this wiki did not - * ask it to — a template is part of the flow that uses it, and a flow that gained one would have to - * gain code here anyway. + * Two of them belong to a flow — registration and a forgotten password; `test` is the admin area's + * button. Held as literals rather than rows in a table because nothing sends a mail this wiki did + * not ask it to — a template is part of the flow that uses it, and a flow that gained one would + * have to gain code here anyway. + * + * **What each one SAYS is not here**: every string lives in `locales/en.json` under `mail.*` and is + * translated with the rest of the interface, so adding a template means adding its keys there. See + * `render`. */ export interface MailTemplateData { welcome: { @@ -39,11 +44,23 @@ export interface MailTemplateData { /** A template key, i.e. one of the keys of `MailTemplateData`. */ export type MailTemplate = keyof MailTemplateData -/** What a rendered template is: a subject line and the two bodies every mail carries. */ -interface RenderedMail { +/** + * One mail as its template describes it, before either body exists. + * + * Every mail this wiki sends is the same shape — a heading, some paragraphs, at most one thing to + * press — so a template says what goes in those slots and nothing about how they are drawn. Which + * is what lets the HTML body and the text body be two renderings of one description rather than + * two hand-written copies that drift: the pair of them used to be written out per template, and a + * string changed in one was a string not changed in the other. + */ +interface MailContent { subject: string - text: string - html: string + /** The heading, which is the subject without the site's name repeated in it. */ + title: string + /** Paragraphs, as plain text: escaping is the business of whichever body they end up in. */ + body: string[] + action?: { label: string; url: string } + footer: string } /** @@ -76,6 +93,17 @@ export interface MailRequest { to: string template: K data: MailTemplateData[K] + /** + * What language to write it in, when anything is known about the recipient's. + * + * The caller's job rather than this model's, because what is known differs at every send site and + * none of it is reachable from here: an account's own `prefs.locale`, the locale the browser + * making the request was reading the wiki in, or nothing at all for a mail nobody asked for. A + * locale that is not installed is ignored, so a value straight off a request body is safe to pass. + * + * Left empty, the mail is written in the site's primary locale — see `localeFor`. + */ + locale?: string | null } /** @@ -98,33 +126,33 @@ function escapeHtml(str: string): string { * Written as a table with inline styles and no external anything, which is what a mail client will * actually render — the stylesheet, the web font and the background image a page would use are all * either stripped or blocked by the ones people read mail in. + * + * **The direction is declared three times on purpose.** Gmail and Outlook.com drop the `` and + * `` elements and paste what is between them into their own document, taking any `dir` on + * them with it — so a right-to-left mail read there would come out left-aligned, with its + * punctuation at the wrong end, unless the cell that survives carries the direction itself. */ -function htmlShell({ - title, - body, - action, - footer -}: { - title: string - /** Paragraphs, already escaped. */ - body: string[] - action?: { label: string; url: string } - footer: string -}): string { +function htmlShell({ title, body, action, footer }: MailContent, isRTL: boolean): string { + const dir = isRTL ? 'rtl' : 'ltr' + const align = isRTL ? 'right' : 'left' const paragraphs = body - .map((p) => `

${p}

`) + .map( + (p) => + `

${escapeHtml(p)}

` + ) .join('') const button = action ? `

${escapeHtml(action.label)}

` + // -> The same link in full, for the client that will not render the button and for the reader - // who wants to see where it goes before following it - `

${escapeHtml(action.url)}

` + // who wants to see where it goes before following it. Always left to right: a URL is not + // written in the language around it, and bidi reordering makes one unreadable. + `

${escapeHtml(action.url)}

` : '' return [ '', - '', + ``, '', - '
', + `
`, `

${escapeHtml(title)}

`, paragraphs, button, @@ -133,6 +161,23 @@ function htmlShell({ ].join('') } +/** + * The text body, which is the same description with nothing drawn around it. + * + * Every client that will not render HTML shows this one, and it is also what keeps a mail out of + * the spam folder a filter puts HTML-only messages in. Every field of the description appears, + * including the title: it is a heading in the HTML body and a first line here, and a paragraph + * written under one refers to it — "if you are reading it" has nothing to point at in a text body + * that opened with the sentence itself. + * + * The action's URL goes after the paragraphs rather than beside whichever sentence introduces it, + * so that the two bodies say things in the same order — the button sits after the paragraphs in + * the HTML one for the same reason. + */ +function textBody({ title, body, action, footer }: MailContent): string { + return [title, ...body, ...(action ? [action.url] : []), footer].join('\n\n') +} + /** * Mail model * @@ -149,6 +194,11 @@ function htmlShell({ * The transport is built once and kept, and rebuilt when the settings behind it change — * `configFingerprint()` is how that is noticed, rather than an event, because the settings can be * changed on another instance in an HA set and this one would never hear about it. + * + * **Nothing here is written in English.** Every string comes out of `locales/en.json` under `mail.*` + * through `locales.translator`, which is the same string set and the same CrowdIn pipeline the + * interface uses — so a locale somebody translates arrives in the mails as well, and a template + * added here is a set of keys added there. Which language one mail is written in is `localeFor`. */ class Mail { private transporter: Transporter | null = null @@ -272,118 +322,76 @@ class Mail { } /** - * Render one of the templates. + * The language a mail is written in. + * + * What the caller knows about the recipient, and the site's primary locale when it knows nothing + * — which is the wiki's own language, and the right answer for a mail about a site rather than + * one addressed to a reader with a preference. `translator` takes it from there: a code naming a + * locale that is not installed falls back to English rather than sending a mail full of keys. + */ + private localeFor(locale: string | null | undefined, siteId: string): string | null { + return locale || WIKI.sites[siteId]?.config?.locales?.primary || null + } + + /** + * Describe one of the templates in the locale it is being sent in. * - * Both bodies are built from the same values: the text one is what a client that will not render - * HTML shows, and is also what keeps the mail out of a spam folder that scores HTML-only mail. + * Strings come from `locales/en.json` under `mail.*` and are translated with the rest of the + * interface, so what is left here is which keys a template uses and what it puts in them. Both + * bodies are rendered from the one description that comes out — see `MailContent`. */ private render( + { t }: Translator, siteName: string, template: K, data: MailTemplateData[K] - ): RenderedMail { + ): MailContent { switch (template) { case 'welcome': { const d = data as MailTemplateData['welcome'] - const footer = `You are receiving this because an account was created for this address on ${siteName}.` if (d.verifyUrl) { return { - subject: `Confirm your email address — ${siteName}`, - text: [ - `Hi ${d.name},`, - '', - `An account was created for this address on ${siteName}. Confirm that it is yours to finish signing up:`, - '', - d.verifyUrl, - '', - 'This link is valid for 24 hours. If you did not create this account, you can ignore this message.', - '', - footer - ].join('\n'), - html: htmlShell({ - title: 'Confirm your email address', - body: [ - `Hi ${escapeHtml(d.name)},`, - `An account was created for this address on ${escapeHtml(siteName)}. Confirm that it is yours to finish signing up.`, - 'This link is valid for 24 hours. If you did not create this account, you can ignore this message.' - ], - action: { label: 'Confirm my email address', url: d.verifyUrl }, - footer - }) + subject: t('mail.welcome.verify.subject', { siteName }), + title: t('mail.welcome.verify.title'), + body: [ + t('mail.common.greeting', { name: d.name }), + t('mail.welcome.verify.body', { siteName }), + t('mail.welcome.verify.expiry') + ], + action: { label: t('mail.welcome.verify.action'), url: d.verifyUrl }, + footer: t('mail.welcome.footer', { siteName }) } } return { - subject: `Welcome to ${siteName}`, - text: [ - `Hi ${d.name},`, - '', - `Your account on ${siteName} is ready. You can sign in at any time:`, - '', - `${d.baseUrl}/login`, - '', - footer - ].join('\n'), - html: htmlShell({ - title: `Welcome to ${escapeHtml(siteName)}`, - body: [ - `Hi ${escapeHtml(d.name)},`, - 'Your account is ready. You can sign in at any time.' - ], - action: { label: 'Go to the wiki', url: `${d.baseUrl}/login` }, - footer - }) + subject: t('mail.welcome.subject', { siteName }), + title: t('mail.welcome.subject', { siteName }), + body: [t('mail.common.greeting', { name: d.name }), t('mail.welcome.body', { siteName })], + action: { label: t('mail.welcome.action'), url: `${d.baseUrl}/login` }, + footer: t('mail.welcome.footer', { siteName }) } } case 'resetPwd': { const d = data as MailTemplateData['resetPwd'] - const footer = `You are receiving this because a password reset was requested for this address on ${siteName}.` return { - subject: `Reset your password — ${siteName}`, - text: [ - `Hi ${d.name},`, - '', - `Somebody asked to reset the password for your account on ${siteName}. Choose a new one here:`, - '', - d.resetUrl, - '', - 'This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.', - '', - footer - ].join('\n'), - html: htmlShell({ - title: 'Reset your password', - body: [ - `Hi ${escapeHtml(d.name)},`, - `Somebody asked to reset the password for your account on ${escapeHtml(siteName)}.`, - 'This link is valid for 24 hours and can only be used once. If you did not ask for this, nothing has changed and you can ignore this message.' - ], - action: { label: 'Choose a new password', url: d.resetUrl }, - footer - }) + subject: t('mail.resetPwd.subject', { siteName }), + title: t('mail.resetPwd.title'), + body: [ + t('mail.common.greeting', { name: d.name }), + t('mail.resetPwd.body', { siteName }), + t('mail.resetPwd.expiry') + ], + action: { label: t('mail.resetPwd.action'), url: d.resetUrl }, + footer: t('mail.resetPwd.footer', { siteName }) } } default: { const d = data as MailTemplateData['test'] - const footer = - 'You are receiving this because somebody sent a test email from the Wiki.js admin area.' return { - subject: `Test email — ${siteName}`, - text: [ - 'This is a test email.', - '', - `If you are reading it, ${siteName} can send mail through the SMTP server it is configured with.`, - '', - d.baseUrl, - '', - footer - ].join('\n'), - html: htmlShell({ - title: 'This is a test email', - body: [ - `If you are reading it, ${escapeHtml(siteName)} can send mail through the SMTP server it is configured with.` - ], - footer - }) + subject: t('mail.test.subject', { siteName }), + title: t('mail.test.title'), + body: [t('mail.test.body', { siteName })], + action: { label: t('mail.test.action'), url: d.baseUrl }, + footer: t('mail.test.footer') } } } @@ -403,15 +411,20 @@ class Mail { siteId, to, template, - data + data, + locale }: MailRequest): Promise { if (!this.isConfigured) { throw new Error('ERR_MAIL_NOT_CONFIGURED') } const conf = this.config const siteName = this.siteName(siteId) - const { subject, text, html } = this.render(siteName, template, data) - WIKI.logger.debug(`Sending ${template} email to <${to}>...`) + const translator = await WIKI.models.locales.translator(this.localeFor(locale, siteId)) + const content = this.render(translator, siteName, template, data) + const { subject } = content + const text = textBody(content) + const html = htmlShell(content, translator.isRTL) + WIKI.logger.debug(`Sending ${template} email to <${to}> in ${translator.locale}...`) await this.getTransporter().sendMail({ from: { name: conf.senderName?.trim() || siteName, diff --git a/backend/models/users.ts b/backend/models/users.ts index cff6911d5..87a291c68 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -120,6 +120,11 @@ export interface UserProfile { location: string jobTitle: string pronouns: string + /** + * The language this wiki writes to this person in, or an empty string to be written to in + * whatever the site's own language is. See `prefs.locale` in `createUser`. + */ + locale: string timezone: string dateFormat: string timeFormat: string @@ -155,6 +160,7 @@ export interface UserProfilePatch { location?: string jobTitle?: string pronouns?: string + locale?: string timezone?: string dateFormat?: string timeFormat?: string @@ -164,7 +170,14 @@ export interface UserProfilePatch { /** The `meta` keys the profile owns, and the `prefs` keys it owns. */ const profileMetaKeys = ['location', 'jobTitle', 'pronouns'] as const -const profilePrefsKeys = ['timezone', 'dateFormat', 'timeFormat', 'appearance', 'cvd'] as const +const profilePrefsKeys = [ + 'locale', + 'timezone', + 'dateFormat', + 'timeFormat', + 'appearance', + 'cvd' +] as const /** * The square, in pixels, an avatar is resized to. The profile page and the account menu both display @@ -486,7 +499,8 @@ class Users { isVerified = true, isProvisioned = false, externalId, - strategyId + strategyId, + locale = '' }: { name: string email: string @@ -520,6 +534,14 @@ class Users { * it under the strategy that was registered through, rather than assuming the built-in one. */ strategyId?: string + /** + * The language to write to this person in, where the account was created somewhere that knows + * — a registration knows which language the wiki was being read in when the form was filled. + * + * Empty for an account created for somebody who is not there to say, which leaves the mails in + * the site's own language until they pick one on their profile. + */ + locale?: string }): Promise { const localStrategyId = strategyId ?? WIKI.data.systemIds.localAuthId const result = await WIKI.db @@ -550,6 +572,7 @@ class Users { pronouns: '' }, prefs: { + locale, // -> Seeded from the instance-wide user defaults, which an administrator can change timezone: WIKI.config.userDefaults?.timezone ?? 'America/New_York', dateFormat: WIKI.config.userDefaults?.dateFormat ?? 'YYYY-MM-DD', @@ -621,6 +644,9 @@ class Users { location: meta.location ?? '', jobTitle: meta.jobTitle ?? '', pronouns: meta.pronouns ?? '', + // -> An empty locale means "whatever the site is written in", which is what the mails fall + // back to and what the profile page offers as the first choice + locale: prefs.locale ?? '', // -> An empty time zone / date format means "whatever the client resolves", which is what the // profile page falls back to timezone: prefs.timezone ?? '', @@ -2062,6 +2088,10 @@ class Users { * sent a link, and `verifyEmail` is what comes back: there is nothing to log in to yet. * * @param baseUrl Where this wiki is reachable, for the link in the email + * @param locale What language the wiki was being read in while the form was filled. It is kept as + * the account's own preference as well as used for the mail that follows: somebody + * who signed up reading the wiki in French has said something about which language + * to write to them in, and there is nowhere else for a brand new account to get one. * @throws `ERR_INVALID_STRATEGY`, `ERR_REGISTRATION_DISABLED`, `ERR_EMAIL_NOT_ALLOWED`, * `ERR_ACCOUNT_ALREADY_EXISTS`, `ERR_PASSWORD_TOO_SHORT`, `ERR_MAIL_NOT_CONFIGURED` */ @@ -2073,7 +2103,8 @@ class Users { email, password, ip, - baseUrl + baseUrl, + locale }: { siteId: string strategyId: string @@ -2082,6 +2113,7 @@ class Users { password: string ip?: string baseUrl: string + locale?: string | null }, req: any ): Promise { @@ -2142,7 +2174,8 @@ class Users { password, groups: strategy.autoEnrollGroups ?? [], isVerified: !mustVerify, - strategyId: strategy.id + strategyId: strategy.id, + locale: locale ?? '' }) /* @@ -2169,6 +2202,7 @@ class Users { siteId, to: address, template: 'welcome', + locale, data: { name: name.trim(), baseUrl, @@ -2207,6 +2241,7 @@ class Users { siteId, to: address, template: 'welcome', + locale, data: { name: name.trim(), baseUrl } }) } catch (err: any) { @@ -2254,6 +2289,8 @@ class Users { siteId: targetSiteId, to: user.email, template: 'welcome', + // -> Theirs, never the administrator's: the request that triggers this is somebody else's + locale: (user.prefs as Record)?.locale, data: { name: user.name, baseUrl: WIKI.models.mail.baseUrl({ req, siteId: targetSiteId }) @@ -2301,6 +2338,8 @@ class Users { * both misconfigurations rather than answers about a user. * * @param baseUrl Where this wiki is reachable, for the link in the email + * @param locale What language the wiki was being read in when the form was filled, which is what + * the mail is written in for an account whose owner has never picked one * @throws `ERR_INVALID_STRATEGY`, `ERR_FORGOT_PASSWORD_DISABLED`, `ERR_MAIL_NOT_CONFIGURED` */ async requestPasswordReset({ @@ -2308,13 +2347,15 @@ class Users { strategyId, email, ip, - baseUrl + baseUrl, + locale }: { siteId: string strategyId: string email: string ip?: string baseUrl: string + locale?: string | null }): Promise { const strategy = await WIKI.models.authentication.getSiteStrategy(siteId, strategyId) if (!strategy || strategy.module !== 'local') { @@ -2349,6 +2390,10 @@ class Users { siteId, to: user.email, template: 'resetPwd', + // -> Their own preference where they have one, and otherwise the language the wiki was being + // read in by whoever filled the form — which is the same person often enough to be the + // better guess, and is only ever a guess either way + locale: (user.prefs as Record)?.locale || locale, data: { name: user.name, baseUrl, @@ -2435,6 +2480,7 @@ class Users { email: user.email, name: user.name, hasAvatar: user.hasAvatar, + locale: user.prefs?.locale, timezone: user.prefs?.timezone, dateFormat: user.prefs?.dateFormat, timeFormat: user.prefs?.timeFormat, diff --git a/backend/types/fastify.d.ts b/backend/types/fastify.d.ts index 71bdc0539..41a9a13bf 100644 --- a/backend/types/fastify.d.ts +++ b/backend/types/fastify.d.ts @@ -27,6 +27,7 @@ declare module 'fastify' { email: string name: string hasAvatar?: boolean + locale?: string timezone?: string dateFormat?: string timeFormat?: string diff --git a/frontend/src/components/ApiKeyCreateDialog.vue b/frontend/src/components/ApiKeyCreateDialog.vue index 1f1155d19..ae36c774a 100644 --- a/frontend/src/components/ApiKeyCreateDialog.vue +++ b/frontend/src/components/ApiKeyCreateDialog.vue @@ -153,13 +153,20 @@ const state = reactive({ */ const GUESTS_GROUP_ID = '10000000-0000-4000-8000-000000000001' -const expirations = [ +/* + A computed, not a plain array: the locale strings are fetched after the app mounts + (`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they + land and leave the expiry choices showing raw keys for the life of the page — which is what a + direct load of this screen does, as opposed to a navigation to it. Inside a computed + it re-evaluates when `setLocaleMessage` fills the strings in. +*/ +const expirations = computed(() => [ { value: '30d', text: t('admin.api.expiration30d') }, { value: '90d', text: t('admin.api.expiration90d') }, { value: '180d', text: t('admin.api.expiration180d') }, { value: '1y', text: t('admin.api.expiration1y') }, { value: '3y', text: t('admin.api.expiration3y') } -] +]) // REFS diff --git a/frontend/src/components/AuthLoginPanel.vue b/frontend/src/components/AuthLoginPanel.vue index 053f3cc69..b9041ca8c 100644 --- a/frontend/src/components/AuthLoginPanel.vue +++ b/frontend/src/components/AuthLoginPanel.vue @@ -551,6 +551,7 @@ import { copyToClipboard } from '@/helpers/clipboard' import { localizeError } from '@/helpers/localization' import { useAuthConfigStore } from '@/stores/authConfig' +import { useCommonStore } from '@/stores/common' import { useSiteStore } from '@/stores/site' import { useUserStore } from '@/stores/user' @@ -566,6 +567,7 @@ const dark = useDark() // STORES const authConfigStore = useAuthConfigStore() +const commonStore = useCommonStore() const siteStore = useSiteStore() const userStore = useUserStore() @@ -1050,7 +1052,14 @@ async function forgotPassword() { const resp = await API_CLIENT.post(`sites/${siteStore.id}/auth/forgotPassword`, { json: { strategyId: state.selectedStrategyId, - email: state.username + email: state.username, + /* + What language to write the mail in, for an account whose owner has never said. There is + nothing else to go on at this point -- the form is filled by somebody who is not signed in + -- and the language the wiki is being read in right now is a better guess than the site's + own, which is what the server falls back to. + */ + locale: commonStore.locale }, throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400 }).json() @@ -1139,7 +1148,10 @@ async function register() { strategyId: state.selectedStrategyId, name: state.newName, email: state.newEmail, - password: state.newPassword + password: state.newPassword, + // -> Kept as the new account's own language preference, as well as used for the welcome + // mail: signing up while reading the wiki in French says which language to write in + locale: commonStore.locale }, throwHttpErrors: (statusNumber) => statusNumber > 400 // Don't throw for 400 }).json() diff --git a/frontend/src/components/GroupEditOverlay.vue b/frontend/src/components/GroupEditOverlay.vue index a382f4cc2..04f063c95 100644 --- a/frontend/src/components/GroupEditOverlay.vue +++ b/frontend/src/components/GroupEditOverlay.vue @@ -834,7 +834,14 @@ const state = reactive({ usersTotal: 0 }) -const sections = [ +/* + Computed, not plain arrays: the locale strings are fetched after the app mounts (`App.vue` -> + `applyLocale`), so a `t()` called once during `setup()` can resolve before they land and leave + the tab strip and the member table showing raw keys for the life of the page — which is what a direct load of this + screen does, as opposed to a navigation to it. Inside a computed they re-evaluate when + `setLocaleMessage` fills the strings in. +*/ +const sections = computed(() => [ { key: 'overview', text: t('admin.groups.overview'), icon: 'la:users' }, { key: 'rules', text: t('admin.groups.rules'), icon: 'la:file-invoice', rulesTotal: true }, { @@ -850,9 +857,9 @@ const sections = [ usersTotal: true, excludeGuests: true } -] +]) -const usersHeaders = [ +const usersHeaders = computed(() => [ { align: 'center', field: 'id', @@ -888,7 +895,7 @@ const usersHeaders = [ sortable: false, style: 'width: 250px' } -] +]) /** * The group-wide permissions, as cards, in the order the screen offers them. diff --git a/frontend/src/components/MailTemplateEditorOverlay.vue b/frontend/src/components/MailTemplateEditorOverlay.vue deleted file mode 100644 index 95691d602..000000000 --- a/frontend/src/components/MailTemplateEditorOverlay.vue +++ /dev/null @@ -1,170 +0,0 @@ - - - - - diff --git a/frontend/src/components/NavEditOverlay.vue b/frontend/src/components/NavEditOverlay.vue index bbd60d35e..6bc2b54be 100644 --- a/frontend/src/components/NavEditOverlay.vue +++ b/frontend/src/components/NavEditOverlay.vue @@ -579,10 +579,17 @@ const sortableOptions = { animation: 150 } -const visibilityOptions = [ +/* + A computed, not a plain array: the locale strings are fetched after the app mounts + (`App.vue` -> `applyLocale`), so a `t()` called once during `setup()` can resolve before they + land and leave the visibility choices showing raw keys for the life of the page — which is what a + direct load of this screen does, as opposed to a navigation to it. Inside a computed + it re-evaluates when `setLocaleMessage` fills the strings in. +*/ +const visibilityOptions = computed(() => [ { value: false, label: t('navEdit.visibilityAll') }, { value: true, label: t('navEdit.visibilityLimited') } -] +]) // COMPUTED diff --git a/frontend/src/components/PageDataTemplateDialog.vue b/frontend/src/components/PageDataTemplateDialog.vue index a9f627b21..62c0b8a3b 100644 --- a/frontend/src/components/PageDataTemplateDialog.vue +++ b/frontend/src/components/PageDataTemplateDialog.vue @@ -182,7 +182,7 @@