diff --git a/apps/app/nuxt.config.ts b/apps/app/nuxt.config.ts index 6456d8d..8a06d72 100644 --- a/apps/app/nuxt.config.ts +++ b/apps/app/nuxt.config.ts @@ -20,6 +20,10 @@ export default defineNuxtConfig({ css: ['~/assets/css/main.css'], + colorMode: { + storageKey: 'shhh_color_mode' + }, + // Defence in depth for a design where the browser holds the only decryption key: an XSS here does // not leak a session, it leaks every key that passes through the page. routeRules: { @@ -82,7 +86,12 @@ export default defineNuxtConfig({ { code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' } ], defaultLocale: 'en', - strategy: 'no_prefix' + strategy: 'no_prefix', + detectBrowserLanguage: { + // Named rather than left on the module default: a cookie scoped to a parent domain by another + // Nuxt app would otherwise decide the language here. `server/utils/mail-locale.ts` reads it too. + cookieKey: 'shhh_i18n_locale' + } }, // Without this, icons are fetched at runtime from /api/_nuxt_icon and log `[Icon] failed to load diff --git a/apps/app/server/api/admin/invitations/index.post.ts b/apps/app/server/api/admin/invitations/index.post.ts index 09963f3..7b6cc41 100644 --- a/apps/app/server/api/admin/invitations/index.post.ts +++ b/apps/app/server/api/admin/invitations/index.post.ts @@ -59,9 +59,11 @@ export default defineEventHandler(async (event) => { // Same reasoning as paste sharing: from BETTER_AUTH_URL, not the attacker-controllable Host header, since it lands in an email. const origin = process.env.BETTER_AUTH_URL!.replace(/\/+$/, '') + // The invitee has no account yet, so the inviting admin's UI language is the only signal available. const mail = invitationTemplate({ url: `${origin}/register?token=${token}`, - expiresInDays: expiryDays + expiresInDays: expiryDays, + locale: mailLocaleFromEvent(event) }) const sent = await sendMail({ to: email, ...mail }) diff --git a/apps/app/server/api/pastes/index.post.ts b/apps/app/server/api/pastes/index.post.ts index 178939f..6167056 100644 --- a/apps/app/server/api/pastes/index.post.ts +++ b/apps/app/server/api/pastes/index.post.ts @@ -218,7 +218,9 @@ export default defineEventHandler(async (event) => { senderName: session.user.name || session.user.email, senderEmail: session.user.email, remainingReads: paste!.maxReads, - expiresAt: paste!.expiresAt + expiresAt: paste!.expiresAt, + // The recipients' own language is unknown; the sender's is the closest signal there is. + locale: mailLocaleFromEvent(event) }) shared = result.sent } diff --git a/apps/app/server/utils/auth.ts b/apps/app/server/utils/auth.ts index 1c0d3a1..3a967fb 100644 --- a/apps/app/server/utils/auth.ts +++ b/apps/app/server/utils/auth.ts @@ -8,6 +8,7 @@ import { getSetting } from './settings' import { isSetupComplete } from './setup' import { isInvitedSignup } from './invitations' import { isMailEnabled, sendMail } from './mail' +import { resolveMailLocale } from './mail-locale' import { resetPasswordTemplate, verifyEmailTemplate } from './mail-templates' if (!process.env.BETTER_AUTH_SECRET) { @@ -45,8 +46,8 @@ export const auth = betterAuth({ requireEmailVerification: isMailEnabled(), // Without a provider there is no way to deliver a reset link, so resetting becomes a manual admin action. sendResetPassword: isMailEnabled() - ? async ({ user, url }) => { - const mail = resetPasswordTemplate({ url }) + ? async ({ user, url }, request) => { + const mail = resetPasswordTemplate({ url, locale: resolveMailLocale(request?.headers) }) await sendMail({ to: user.email, ...mail }) } : undefined @@ -58,8 +59,8 @@ export const auth = betterAuth({ sendOnSignIn: isMailEnabled(), autoSignInAfterVerification: true, sendVerificationEmail: isMailEnabled() - ? async ({ user, url }) => { - const mail = verifyEmailTemplate({ url }) + ? async ({ user, url }, request) => { + const mail = verifyEmailTemplate({ url, locale: resolveMailLocale(request?.headers) }) await sendMail({ to: user.email, ...mail }) } : undefined @@ -72,8 +73,8 @@ export const auth = betterAuth({ // False even for unverified accounts: skipping confirmation would make the check avoidable by simply never verifying. updateEmailWithoutVerification: false, sendChangeEmailConfirmation: isMailEnabled() - ? async ({ user, newEmail, url }) => { - const mail = changeEmailTemplate({ url, newEmail }) + ? async ({ user, newEmail, url }, request) => { + const mail = changeEmailTemplate({ url, newEmail, locale: resolveMailLocale(request?.headers) }) await sendMail({ to: user.email, ...mail }) } : undefined diff --git a/apps/app/server/utils/mail-locale.ts b/apps/app/server/utils/mail-locale.ts new file mode 100644 index 0000000..06a83ec --- /dev/null +++ b/apps/app/server/utils/mail-locale.ts @@ -0,0 +1,73 @@ +import type { H3Event } from 'h3' + +export const MAIL_LOCALES = ['en', 'fr'] as const +export type MailLocale = typeof MAIL_LOCALES[number] +export const DEFAULT_MAIL_LOCALE: MailLocale = 'en' + +// Must stay in step with `i18n.detectBrowserLanguage.cookieKey` in nuxt.config, where the switcher writes. +const LOCALE_COOKIE = 'shhh_i18n_locale' + +function isMailLocale(value: string): value is MailLocale { + return (MAIL_LOCALES as readonly string[]).includes(value) +} + +function fromCookie(header: string | null): MailLocale | null { + if (!header) return null + + for (const part of header.split(';')) { + const separator = part.indexOf('=') + if (separator === -1) continue + if (part.slice(0, separator).trim() !== LOCALE_COOKIE) continue + + // The header is attacker-controlled and `decodeURIComponent` throws on malformed escapes: + // an unusable cookie has to read as no cookie, never as a 500 on the request that sends mail. + let value: string + try { + value = decodeURIComponent(part.slice(separator + 1).trim()).toLowerCase() + } catch { + return null + } + + return isMailLocale(value) ? value : null + } + + return null +} + +function fromAcceptLanguage(header: string | null): MailLocale | null { + if (!header) return null + + const ranked = header + .split(',') + .map((entry) => { + const [tag, ...params] = entry.trim().split(';') + const quality = params.find(param => param.trim().startsWith('q=')) + return { tag: (tag ?? '').trim().toLowerCase(), quality: quality ? Number(quality.split('=')[1]) : 1 } + }) + .filter(entry => entry.tag !== '' && entry.tag !== '*' && Number.isFinite(entry.quality) && entry.quality > 0) + .sort((a, b) => b.quality - a.quality) + + for (const { tag } of ranked) { + // 'fr-CA' and 'fr' both mean the French mail; only the primary subtag is ours to match. + const base = tag.split('-')[0]! + if (isMailLocale(base)) return base + } + + return null +} + +/** + * The cookie wins over Accept-Language: it carries a deliberate choice from the language switcher, + * where the header is only whatever the browser was installed with. + */ +export function resolveMailLocale(headers: Headers | null | undefined): MailLocale { + if (!headers) return DEFAULT_MAIL_LOCALE + + return fromCookie(headers.get('cookie')) + ?? fromAcceptLanguage(headers.get('accept-language')) + ?? DEFAULT_MAIL_LOCALE +} + +export function mailLocaleFromEvent(event: H3Event): MailLocale { + return resolveMailLocale(event.headers) +} diff --git a/apps/app/server/utils/mail-templates.ts b/apps/app/server/utils/mail-templates.ts index aabc743..3ff4df9 100644 --- a/apps/app/server/utils/mail-templates.ts +++ b/apps/app/server/utils/mail-templates.ts @@ -1,3 +1,5 @@ +import { DEFAULT_MAIL_LOCALE, type MailLocale } from './mail-locale' + /** * One function per template returns `{ subject, html, text }` — a separate text generator would drift. * One minimal wrapper is shared by all mails, no per-template design. @@ -21,9 +23,126 @@ function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, char => ESCAPES[char]!) } -function layout(title: string, bodyHtml: string): string { +// Always UTC: the recipient's timezone is unknown, and a naked local time would be read as their own. +function formatExpiry(locale: MailLocale, date: Date): string { + const formatted = new Intl.DateTimeFormat(locale, { + dateStyle: 'long', + timeStyle: 'short', + timeZone: 'UTC' + }).format(date) + return `${formatted} UTC` +} + +interface Strings { + footer: string + linkFallback: string + verifyEmail: { subject: string, intro: string, button: string, ignore: string } + resetPassword: { subject: string, intro: string, button: string, ignore: string } + changeEmail: { subject: string, intro: (newEmail: string) => string, button: string, ignore: string } + invitation: { subject: string, intro: string, button: string, expiry: (days: number | null) => string } + sharedPaste: { + subject: (senderName: string) => string + title: string + intro: (senderName: string) => string + button: string + reads: (remaining: number | null) => string + expiry: (expiresAt: Date) => string + warning: string + } +} + +const STRINGS: Record = { + en: { + footer: 'Say it once. We\'ll forget.', + linkFallback: 'Or copy this link into your browser:', + verifyEmail: { + subject: 'Confirm your email address', + intro: 'Confirm your email address to finish setting up your shhh account.', + button: 'Confirm email address', + ignore: 'If you did not create this account, you can ignore this email.' + }, + resetPassword: { + subject: 'Reset your password', + intro: 'Someone requested a password reset for your shhh account. Use the link below to choose a new password.', + button: 'Reset password', + ignore: 'If this was not you, you can ignore this email — your password stays unchanged.' + }, + changeEmail: { + subject: 'Confirm your new email address', + intro: newEmail => `A request was made to change the email address of your shhh account to ${newEmail}.`, + button: 'Confirm the change', + ignore: 'If this was not you, ignore this email and change your password — your address stays unchanged until this link is used.' + }, + invitation: { + subject: 'You have been invited to shhh', + intro: 'You have been invited to create an account on this shhh instance.', + button: 'Create your account', + expiry: days => days === null + ? 'This invitation does not expire.' + : `This invitation expires in ${days} day${days === 1 ? '' : 's'}.` + }, + sharedPaste: { + subject: senderName => `${senderName} shared a secret with you`, + title: 'A secret was shared with you', + intro: senderName => `${senderName} shared an encrypted secret with you through shhh.`, + button: 'Open the secret', + reads: remaining => remaining === null + ? 'It can be opened an unlimited number of times.' + : `It can be opened ${remaining} more time${remaining === 1 ? '' : 's'} in total — that count is shared between everyone who received this link.`, + expiry: expiresAt => `It expires on ${formatExpiry('en', expiresAt)}.`, + warning: 'The link contains the decryption key in its fragment. Anyone holding the full link can read the secret, so treat it as the secret itself.' + } + }, + fr: { + footer: 'Dites-le une fois. Nous l\'oublierons.', + linkFallback: 'Ou copiez ce lien dans votre navigateur :', + verifyEmail: { + subject: 'Confirmez votre adresse email', + intro: 'Confirmez votre adresse email pour terminer la création de votre compte shhh.', + button: 'Confirmer mon adresse', + ignore: 'Si vous n\'êtes pas à l\'origine de ce compte, vous pouvez ignorer cet email.' + }, + resetPassword: { + subject: 'Réinitialisez votre mot de passe', + intro: 'Une réinitialisation de mot de passe a été demandée pour votre compte shhh. Utilisez le lien ci-dessous pour en choisir un nouveau.', + button: 'Réinitialiser mon mot de passe', + ignore: 'Si vous n\'êtes pas à l\'origine de cette demande, ignorez cet email — votre mot de passe reste inchangé.' + }, + changeEmail: { + subject: 'Confirmez votre nouvelle adresse email', + intro: newEmail => `Une demande de changement de l'adresse email de votre compte shhh vers ${newEmail} a été effectuée.`, + button: 'Confirmer le changement', + ignore: 'Si vous n\'êtes pas à l\'origine de cette demande, ignorez cet email et changez votre mot de passe — votre adresse reste inchangée tant que ce lien n\'est pas utilisé.' + }, + invitation: { + subject: 'Vous êtes invité sur shhh', + intro: 'Vous avez été invité à créer un compte sur cette instance shhh.', + button: 'Créer mon compte', + expiry: days => days === null + ? 'Cette invitation n\'expire pas.' + : `Cette invitation expire dans ${days} jour${days === 1 ? '' : 's'}.` + }, + sharedPaste: { + subject: senderName => `${senderName} a partagé un secret avec vous`, + title: 'Un secret a été partagé avec vous', + intro: senderName => `${senderName} a partagé un secret chiffré avec vous via shhh.`, + button: 'Ouvrir le secret', + reads: remaining => remaining === null + ? 'Il peut être ouvert un nombre illimité de fois.' + : `Il peut encore être ouvert ${remaining} fois au total — ce compteur est partagé entre tous les destinataires de ce lien.`, + expiry: expiresAt => `Il expire le ${formatExpiry('fr', expiresAt)}.`, + warning: 'Le lien contient la clé de déchiffrement dans son fragment. Quiconque détient le lien complet peut lire le secret : traitez-le comme le secret lui-même.' + } + } +} + +function stringsFor(locale: MailLocale | undefined): Strings { + return STRINGS[locale ?? DEFAULT_MAIL_LOCALE] ?? STRINGS[DEFAULT_MAIL_LOCALE] +} + +function layout(locale: MailLocale, strings: Strings, title: string, bodyHtml: string): string { return ` - + @@ -34,17 +153,17 @@ function layout(title: string, bodyHtml: string): string {

shhh

${bodyHtml}
-

Say it once. We'll forget.

+

${escapeHtml(strings.footer)}

` } -function button(url: string, label: string): string { +function button(strings: Strings, url: string, label: string): string { return `

${escapeHtml(label)}

-

Or copy this link into your browser:

+

${escapeHtml(strings.linkFallback)}

${escapeHtml(url)}

` } @@ -52,67 +171,79 @@ function paragraph(text: string): string { return `

${escapeHtml(text)}

` } -export function verifyEmailTemplate(params: { url: string }): RenderedMail { - const intro = 'Confirm your email address to finish setting up your shhh account.' - const ignore = 'If you did not create this account, you can ignore this email.' +export function verifyEmailTemplate(params: { url: string, locale?: MailLocale }): RenderedMail { + const locale = params.locale ?? DEFAULT_MAIL_LOCALE + const strings = stringsFor(locale) + const { subject, intro, button: label, ignore } = strings.verifyEmail return { - subject: 'Confirm your email address', - html: layout('Confirm your email address', paragraph(intro) + button(params.url, 'Confirm email address') + paragraph(ignore)), + subject, + html: layout(locale, strings, subject, paragraph(intro) + button(strings, params.url, label) + paragraph(ignore)), text: `${intro}\n\n${params.url}\n\n${ignore}` } } -export function resetPasswordTemplate(params: { url: string }): RenderedMail { - const intro = 'Someone requested a password reset for your shhh account. Use the link below to choose a new password.' - const ignore = 'If this was not you, you can ignore this email — your password stays unchanged.' +export function resetPasswordTemplate(params: { url: string, locale?: MailLocale }): RenderedMail { + const locale = params.locale ?? DEFAULT_MAIL_LOCALE + const strings = stringsFor(locale) + const { subject, intro, button: label, ignore } = strings.resetPassword return { - subject: 'Reset your password', - html: layout('Reset your password', paragraph(intro) + button(params.url, 'Reset password') + paragraph(ignore)), + subject, + html: layout(locale, strings, subject, paragraph(intro) + button(strings, params.url, label) + paragraph(ignore)), text: `${intro}\n\n${params.url}\n\n${ignore}` } } -export function changeEmailTemplate(params: { url: string, newEmail: string }): RenderedMail { +export function changeEmailTemplate(params: { url: string, newEmail: string, locale?: MailLocale }): RenderedMail { + const locale = params.locale ?? DEFAULT_MAIL_LOCALE + const strings = stringsFor(locale) + const { subject, button: label, ignore } = strings.changeEmail // Sent to the CURRENT address, not the new one: the existing owner has to approve, or notice and refuse, the move. - const intro = `A request was made to change the email address of your shhh account to ${params.newEmail}.` - const ignore = 'If this was not you, ignore this email and change your password — your address stays unchanged until this link is used.' + const intro = strings.changeEmail.intro(params.newEmail) return { - subject: 'Confirm your new email address', - html: layout('Confirm your new email address', paragraph(intro) + button(params.url, 'Confirm the change') + paragraph(ignore)), + subject, + html: layout(locale, strings, subject, paragraph(intro) + button(strings, params.url, label) + paragraph(ignore)), text: `${intro}\n\n${params.url}\n\n${ignore}` } } -export function invitationTemplate(params: { url: string, expiresInDays: number | null }): RenderedMail { - const intro = 'You have been invited to create an account on this shhh instance.' - const expiry = params.expiresInDays === null - ? 'This invitation does not expire.' - : `This invitation expires in ${params.expiresInDays} day${params.expiresInDays === 1 ? '' : 's'}.` +export function invitationTemplate(params: { url: string, expiresInDays: number | null, locale?: MailLocale }): RenderedMail { + const locale = params.locale ?? DEFAULT_MAIL_LOCALE + const strings = stringsFor(locale) + const { subject, intro, button: label } = strings.invitation + const expiry = strings.invitation.expiry(params.expiresInDays) return { - subject: 'You have been invited to shhh', - html: layout('You have been invited to shhh', paragraph(intro) + button(params.url, 'Create your account') + paragraph(expiry)), + subject, + html: layout(locale, strings, subject, paragraph(intro) + button(strings, params.url, label) + paragraph(expiry)), text: `${intro}\n\n${params.url}\n\n${expiry}` } } -export function sharedPasteTemplate(params: { url: string, senderName: string, remainingReads: number | null, expiresAt: Date }): RenderedMail { - const intro = `${params.senderName} shared an encrypted secret with you through shhh.` - const expiry = `It expires on ${params.expiresAt.toUTCString()}.` +export function sharedPasteTemplate(params: { + url: string + senderName: string + remainingReads: number | null + expiresAt: Date + locale?: MailLocale +}): RenderedMail { + const locale = params.locale ?? DEFAULT_MAIL_LOCALE + const strings = stringsFor(locale) + const { title, button: label, warning } = strings.sharedPaste + const intro = strings.sharedPaste.intro(params.senderName) + const expiry = strings.sharedPaste.expiry(params.expiresAt) // The counter belongs to the paste, not to each recipient — several people may hold this same link. - const reads = params.remainingReads === null - ? 'It can be opened an unlimited number of times.' - : `It can be opened ${params.remainingReads} more time${params.remainingReads === 1 ? '' : 's'} in total — that count is shared between everyone who received this link.` - const warning = 'The link contains the decryption key in its fragment. Anyone holding the full link can read the secret, so treat it as the secret itself.' + const reads = strings.sharedPaste.reads(params.remainingReads) return { - subject: `${params.senderName} shared a secret with you`, + subject: strings.sharedPaste.subject(params.senderName), html: layout( - 'A secret was shared with you', - paragraph(intro) + button(params.url, 'Open the secret') + paragraph(reads) + paragraph(expiry) + paragraph(warning) + locale, + strings, + title, + paragraph(intro) + button(strings, params.url, label) + paragraph(reads) + paragraph(expiry) + paragraph(warning) ), text: `${intro}\n\n${params.url}\n\n${reads}\n${expiry}\n\n${warning}` } diff --git a/apps/app/server/utils/paste-sharing.ts b/apps/app/server/utils/paste-sharing.ts index a72b8e1..8329875 100644 --- a/apps/app/server/utils/paste-sharing.ts +++ b/apps/app/server/utils/paste-sharing.ts @@ -1,3 +1,5 @@ +import type { MailLocale } from './mail-locale' + /** * ⚠️ The one place the decryption key reaches the server: a usable link carries it, so composing the mail needs it. * Only at creation, only for authenticated users, never for an existing paste — that is what bounds the exposure. @@ -12,6 +14,7 @@ export async function sharePasteByEmail(params: { senderEmail: string remainingReads: number | null expiresAt: Date + locale: MailLocale }): Promise<{ sent: boolean }> { // From BETTER_AUTH_URL, not the request's Host header: that header is attacker-controllable, and this URL goes out in an email. const origin = process.env.BETTER_AUTH_URL!.replace(/\/+$/, '') @@ -21,7 +24,8 @@ export async function sharePasteByEmail(params: { url, senderName: params.senderName, remainingReads: params.remainingReads, - expiresAt: params.expiresAt + expiresAt: params.expiresAt, + locale: params.locale }) // `to` is the sender, Bcc everyone else: recipients see who shared the link but never one another, and no third-party mailbox gets a copy. diff --git a/apps/app/tests/mail-locale.test.ts b/apps/app/tests/mail-locale.test.ts new file mode 100644 index 0000000..6d4b364 --- /dev/null +++ b/apps/app/tests/mail-locale.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { resolveMailLocale } from '../server/utils/mail-locale' + +function headers(entries: Record) { + return new Headers(entries) +} + +describe('resolveMailLocale', () => { + it('falls back to English without headers', () => { + expect(resolveMailLocale(undefined)).toBe('en') + expect(resolveMailLocale(null)).toBe('en') + expect(resolveMailLocale(headers({}))).toBe('en') + }) + + it('reads the locale the language switcher stored', () => { + expect(resolveMailLocale(headers({ cookie: 'shhh_i18n_locale=fr' }))).toBe('fr') + }) + + it('finds the cookie among others', () => { + expect(resolveMailLocale(headers({ cookie: 'foo=bar; shhh_i18n_locale=fr; baz=qux' }))).toBe('fr') + }) + + it('is not fooled by a cookie whose name merely ends the same way', () => { + expect(resolveMailLocale(headers({ cookie: 'not_shhh_i18n_locale=fr' }))).toBe('en') + }) + + it('prefers the cookie over Accept-Language, since it is a deliberate choice', () => { + const source = headers({ 'cookie': 'shhh_i18n_locale=en', 'accept-language': 'fr-FR,fr;q=0.9' }) + expect(resolveMailLocale(source)).toBe('en') + }) + + it('falls back to Accept-Language when no cookie was set', () => { + expect(resolveMailLocale(headers({ 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.8' }))).toBe('fr') + }) + + it('honours the quality ranking rather than the written order', () => { + expect(resolveMailLocale(headers({ 'accept-language': 'fr;q=0.3,en;q=0.9' }))).toBe('en') + expect(resolveMailLocale(headers({ 'accept-language': 'en;q=0.3,fr;q=0.9' }))).toBe('fr') + }) + + it('ignores a language that is refused outright', () => { + expect(resolveMailLocale(headers({ 'accept-language': 'fr;q=0' }))).toBe('en') + }) + + it('skips unsupported languages instead of failing', () => { + expect(resolveMailLocale(headers({ 'accept-language': 'de-DE,es;q=0.8' }))).toBe('en') + expect(resolveMailLocale(headers({ 'accept-language': 'de-DE,fr;q=0.8' }))).toBe('fr') + }) + + it('ignores an unsupported or malformed cookie value and moves on', () => { + expect(resolveMailLocale(headers({ cookie: 'shhh_i18n_locale=de' }))).toBe('en') + expect(resolveMailLocale(headers({ cookie: 'shhh_i18n_locale=' }))).toBe('en') + }) + + it('survives broken percent-encoding rather than throwing on the send path', () => { + // The cookie is attacker-controlled and decodeURIComponent throws on these; a 500 on paste + // creation is not an acceptable answer to a corrupted cookie. + for (const value of ['%', '%zz', '%E0%A4%A']) { + expect(() => resolveMailLocale(headers({ cookie: `shhh_i18n_locale=${value}` }))).not.toThrow() + expect(resolveMailLocale(headers({ cookie: `shhh_i18n_locale=${value}` }))).toBe('en') + } + }) + + it('still falls back to Accept-Language when the cookie is unusable', () => { + const source = headers({ 'cookie': 'shhh_i18n_locale=%zz', 'accept-language': 'fr-FR,fr;q=0.9' }) + expect(resolveMailLocale(source)).toBe('fr') + }) +}) diff --git a/apps/app/tests/mail-templates.test.ts b/apps/app/tests/mail-templates.test.ts index 750b588..8bcee6a 100644 --- a/apps/app/tests/mail-templates.test.ts +++ b/apps/app/tests/mail-templates.test.ts @@ -116,3 +116,71 @@ describe('change email', () => { expect(mail.text).toMatch(/if this was not you/i) }) }) + +describe('locales', () => { + const FR = [ + ['verifyEmail', verifyEmailTemplate({ url: URL_, locale: 'fr' })], + ['resetPassword', resetPasswordTemplate({ url: URL_, locale: 'fr' })], + ['changeEmail', changeEmailTemplate({ url: URL_, newEmail: 'new@example.com', locale: 'fr' })], + ['invitation', invitationTemplate({ url: URL_, expiresInDays: 7, locale: 'fr' })], + ['sharedPaste', sharedPasteTemplate({ url: URL_, senderName: 'Alice', remainingReads: 2, expiresAt: new Date('2030-01-01T00:00:00Z'), locale: 'fr' })] + ] as const + + it.each(FR)('%s is rendered in French', (_name, mail) => { + expect(mail.subject.trim()).not.toBe('') + expect(mail.html).toContain('lang="fr"') + expect(mail.text.trim()).not.toBe('') + }) + + it.each(FR)('%s still carries the link in both parts', (_name, mail) => { + expect(mail.html).toContain(URL_) + expect(mail.text).toContain(URL_) + }) + + it.each(FR)('%s differs from its English counterpart', (name, mail) => { + const english = ALL.find(([other]) => other === name)![1] + expect(mail.subject).not.toBe(english.subject) + expect(mail.text).not.toBe(english.text) + }) + + it('defaults to English when no locale is given', () => { + expect(resetPasswordTemplate({ url: URL_ }).subject).toBe(resetPasswordTemplate({ url: URL_, locale: 'en' }).subject) + expect(verifyEmailTemplate({ url: URL_ }).html).toContain('lang="en"') + }) + + it('translates the invitation expiry, including the never-expires case', () => { + expect(invitationTemplate({ url: URL_, expiresInDays: 7, locale: 'fr' }).text).toContain('7 jours.') + expect(invitationTemplate({ url: URL_, expiresInDays: 1, locale: 'fr' }).text).toContain('1 jour.') + expect(invitationTemplate({ url: URL_, expiresInDays: null, locale: 'fr' }).text).toMatch(/n'expire pas/) + }) + + it('translates the shared-paste read counter', () => { + const base = { url: URL_, senderName: 'Alice', expiresAt: new Date('2030-01-01T00:00:00Z'), locale: 'fr' } as const + expect(sharedPasteTemplate({ ...base, remainingReads: null }).text).toMatch(/illimité/) + expect(sharedPasteTemplate({ ...base, remainingReads: 3 }).text).toMatch(/compteur est partagé/) + }) + + it('renders the expiry date in the mail locale, always in UTC', () => { + const base = { url: URL_, senderName: 'Alice', remainingReads: null, expiresAt: new Date('2030-03-01T14:30:00Z') } as const + expect(sharedPasteTemplate({ ...base, locale: 'en' }).text).toContain('UTC') + expect(sharedPasteTemplate({ ...base, locale: 'fr' }).text).toContain('UTC') + expect(sharedPasteTemplate({ ...base, locale: 'fr' }).text).toContain('mars') + expect(sharedPasteTemplate({ ...base, locale: 'en' }).text).toContain('March') + }) + + it('still escapes a hostile sender name in French', () => { + const mail = sharedPasteTemplate({ + url: URL_, + senderName: '', + remainingReads: null, + expiresAt: new Date('2030-01-01T00:00:00Z'), + locale: 'fr' + }) + expect(mail.html).not.toContain('