v1.0.0 🎉 — Production release. i18n Espagnol + Allemand (launcher + emails).

Première release MAJEURE après ~30 itérations 0.x. Le système est complet
et stable pour la production : distribution PROSERVE multi-channels, install
launcher cross-fleet, monitoring santé VR, sécurité (signatures Ed25519,
HMAC URLs présignées, settings lock per-license, RFC1918 filter cache LAN).

i18n nouvelles langues (Espagnol + Allemand) :
 - Strings.cs : signature T() étendue avec 2 params optionnels (es?/de?)
   en plus des 5 obligatoires. Fallback automatique sur l'anglais quand
   non traduit → infrastructure prête immédiatement, traduction progressive
   sur les ~317 strings du launcher selon les besoins terrain.
 - ~30 strings critiques traduites maintenant (top bar, body, status badges,
   actions principales Launch/Install/Cancel/Yes/No, MessageBox titles).
 - Available[] inclut es/Español + de/Deutsch ; auto-détection Windows
   pour es-* / de-* funcionne au premier launch.
 - Init() whitelist mise à jour.

Emails release announce localisés par license :
 - Migration 005 : ajout `language VARCHAR(8) NULL` sur licenses (whitelist
   fr/en/es/de/zh/th/ar, NULL = fallback English).
 - getEmailStrings() PHP étendu : dictionnaire 7 langues × 14 strings.
   Traduction complète es + de (gérable : 28 nouvelles strings, vs 634
   pour traduire le launcher en intégralité).
 - notify_release boucle par license : récupère language + locale fallback
   'en' + render avec la bonne locale → chaque destinataire reçoit son
   email dans la langue de SA license, indépendamment des autres clients.
 - Subject localisé aussi (« PROSERVE v1.5.4 ya está disponible » /
   « PROSERVE v1.5.4 ist jetzt verfügbar »).
 - Release notes body reste TOUJOURS en anglais (évite d'avoir à maintenir
   N traductions du Markdown des notes).
 - Direction RTL préservée pour l'arabe (dir="rtl" sur <html> + <table>).

Admin UI : dropdown langue dans la modal Contacts d'une license + whitelist
backend (set_contact_emails accepte fr/en/es/de/zh/th/ar).

Bumps : 0.29.10 → 1.0.0 — milestone V1, premier release majeur en prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 18:01:50 +02:00
parent 500f7d12e6
commit 73d084a703
6 changed files with 353 additions and 89 deletions

View File

@@ -169,25 +169,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
elseif ($action === 'set_contact_emails') {
// Met à jour la liste des emails de contact pour les notifications
// de nouvelles versions. L'admin saisit en texte libre (1 par ligne,
// virgules, point-virgules — peu importe), on parse + valide via
// FILTER_VALIDATE_EMAIL et on stocke en CSV. Vide / aucun email
// valide → on stocke NULL (= pas de notif possible pour cette
// license, juste skip silencieux à l'envoi).
// Met à jour la liste des emails de contact + la langue préférée
// pour les notifications de nouvelles versions. L'admin saisit en
// texte libre (1 par ligne, virgules, point-virgules — peu importe),
// on parse + valide via FILTER_VALIDATE_EMAIL et on stocke en CSV.
// Vide / aucun email valide → on stocke NULL (= pas de notif possible
// pour cette license, juste skip silencieux à l'envoi).
//
// Language : whitelist stricte fr/en/zh/th/ar (= ce que le launcher
// supporte côté Strings.cs). NULL ou inconnu → fallback English.
$id = (int)($_POST['id'] ?? 0);
$raw = (string)($_POST['contact_emails'] ?? '');
$lang = trim((string)($_POST['language'] ?? ''));
if ($id <= 0) {
throw new Exception('License id invalide.');
}
$allowedLangs = ['fr', 'en', 'es', 'de', 'zh', 'th', 'ar'];
if ($lang !== '' && !in_array($lang, $allowedLangs, true)) {
throw new Exception("Code langue invalide : '{$lang}'. Attendu : " . implode(', ', $allowedLangs) . ' ou vide.');
}
$parsed = Mailer::parseEmails($raw);
$stored = Mailer::joinForStorage($parsed);
$db->prepare('UPDATE licenses SET contact_emails = ? WHERE id = ?')
->execute([$stored, $id]);
$db->prepare('UPDATE licenses SET contact_emails = ?, language = ? WHERE id = ?')
->execute([$stored, $lang !== '' ? $lang : null, $id]);
$count = count($parsed);
$langDisplay = $lang !== '' ? $lang : 'en (défaut)';
$message = $count === 0
? "License #{$id} : contacts de notification supprimés."
: "License #{$id} : {$count} contact(s) email enregistré(s) pour les notifications de release.";
? "License #{$id} : contacts de notification supprimés. Langue email : {$langDisplay}."
: "License #{$id} : {$count} contact(s) email enregistré(s), langue email : {$langDisplay}.";
}
elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0);
@@ -546,17 +555,45 @@ Layout::header('Licenses', 'licenses');
version de PROSERVE accessible à cette license (matching channel +
entitlement). Format libre : une adresse par ligne, virgules, ou
point-virgules. Adresses invalides ignorées silencieusement.
La langue choisie ci-dessous est utilisée pour le contenu de l'email
(greeting + instructions) — les release notes elles-mêmes restent
toujours en anglais.
</p>
<?php
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
// comment c'est stocké en DB (CSV).
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
$currentDisplay = implode("\n", $currentEmails);
$currentLang = trim((string)($l['language'] ?? ''));
$langOptions = [
'' => 'English (defaut)',
'fr' => 'Français',
'en' => 'English',
'es' => 'Español',
'de' => 'Deutsch',
'zh' => '中文 (Chinese)',
'th' => 'ภาษาไทย (Thai)',
'ar' => 'العربية (Arabic)',
];
?>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_contact_emails">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Langue de l'email</label>
<select name="language">
<?php foreach ($langOptions as $code => $label):
$sel = ($currentLang === $code) ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($code) ?>" <?= $sel ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<p class="hint" style="margin: 4px 0 0;">
Langue dans laquelle l'email de notification sera rédigé.
Le contenu des release notes reste toujours en anglais.
</p>
</div>
<div class="field">
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
<textarea name="contact_emails" rows="6"

View File

@@ -102,33 +102,212 @@ function find_entry_index_by_id(array $manifest, string $id): ?int
}
/**
* Construit le corps HTML de l'email de release announce. Message en anglais
* (= langue par défaut du parc client international VR PROSERVE), inclut les
* release notes brutes en Markdown (sans rendu — les MUA modernes Markdown
* basique en clair reste très lisible et évite la dépendance Markdig PHP).
* Le owner_name de la license est utilisé pour personnaliser le greeting.
* Le logo (optionnel via <c>notifications.logo_url</c>) est inséré dans le
* bandeau d'en-tête bleu — hotlinkable depuis n'importe quel MUA (pas de CID
* embed pour rester compatible avec Gmail/Outlook qui bloquent les data URIs).
* Strings localisées pour l'email de release announce. Couvre les 5 langues
* supportées par le launcher (Strings.cs côté .NET) : fr, en, zh, th, ar.
* Le release notes BODY lui-même reste TOUJOURS en anglais — seul le texte
* d'accompagnement (greeting, instructions, CTAs) est localisé. Permet de
* notifier des clients internationaux dans leur langue sans avoir à
* maintenir N traductions du Markdown des notes.
*
* Placeholders dans les chaînes :
* {version} → numéro de version (ex. "1.5.4.16")
* {owner} → nom du client (ex. "Asterion VR")
*
* Fallback : si la locale demandée n'est pas en table → 'en' (= comportement
* conservateur, le marché VR international a globalement l'anglais comme
* lingua franca tech).
*
* @return array<string, string> dictionnaire de strings prêtes à interpoler
*/
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = ''): string
function getEmailStrings(string $locale): array
{
static $cache = null;
if ($cache !== null) {
return $cache[$locale] ?? $cache['en'];
}
$cache = [
'en' => [
'subject' => 'PROSERVE v{version} is now available',
'title' => 'PROSERVE v{version} is available!',
'subtitle' => 'A new version is ready to install via your PROSERVE Launcher.',
'greeting' => 'Hi {owner},',
'intro' => 'We\'re happy to let you know that <strong>PROSERVE version {version}</strong> has just been published and is now available for download.',
'instructions' => 'If you already have the <strong>PROSERVE Launcher</strong> installed, simply open it on any of your machines, click <em>« Check for updates »</em>, and install the new version with one click.',
'firstInstallTitle' => 'First install or new workstation?',
'firstInstallBody' => 'If this is your first time, or you\'re setting up a new PC, download the PROSERVE Launcher installer below. <strong>You\'ll need to run it on EACH workstation</strong> where PROSERVE will be used.',
'downloadButton' => '⬇ Download PROSERVE Launcher (Windows)',
'directLink' => 'Direct link:',
'releaseNotesTitle' => 'Release notes',
'releaseNotesNone' => 'No release notes for this version.',
'support' => 'As always, if you run into any issue (download failure, install error, anything weird in-game), don\'t hesitate to reach out — we\'re here to help.',
'closing' => 'Happy training!',
'signature' => 'The ASTERION VR team',
'footer' => 'This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.',
],
'fr' => [
'subject' => 'PROSERVE v{version} est disponible',
'title' => 'PROSERVE v{version} est disponible !',
'subtitle' => 'Une nouvelle version est prête à installer via votre PROSERVE Launcher.',
'greeting' => 'Bonjour {owner},',
'intro' => 'Nous avons le plaisir de vous annoncer que <strong>la version {version} de PROSERVE</strong> vient d\'être publiée et est désormais disponible au téléchargement.',
'instructions' => 'Si vous avez déjà le <strong>PROSERVE Launcher</strong> installé, ouvrez-le simplement sur n\'importe lequel de vos postes, cliquez sur <em>« Vérifier les mises à jour »</em> et installez la nouvelle version en un clic.',
'firstInstallTitle' => 'Première installation ou nouveau poste ?',
'firstInstallBody' => 'S\'il s\'agit de votre première installation, ou si vous configurez un nouveau PC, téléchargez le PROSERVE Launcher ci-dessous. <strong>Il doit être lancé sur CHAQUE poste</strong> où PROSERVE sera utilisé.',
'downloadButton' => '⬇ Télécharger PROSERVE Launcher (Windows)',
'directLink' => 'Lien direct :',
'releaseNotesTitle' => 'Notes de version',
'releaseNotesNone' => 'Pas de notes de version pour cette release.',
'support' => 'Comme toujours, en cas de problème (échec de téléchargement, erreur d\'installation, comportement inattendu en VR), n\'hésitez pas à nous contacter — nous sommes là pour vous aider.',
'closing' => 'Bon entraînement !',
'signature' => 'L\'équipe ASTERION VR',
'footer' => 'Cette notification automatique a été envoyée parce que votre email est enregistré comme contact sur l\'une de vos licenses PROSERVE.<br/>Pour ne plus recevoir ces emails, demandez à votre gestionnaire de compte ASTERION VR de retirer votre adresse des contacts license.',
],
'zh' => [
'subject' => 'PROSERVE v{version} 现已发布',
'title' => 'PROSERVE v{version} 已发布!',
'subtitle' => '新版本已准备好通过您的 PROSERVE Launcher 安装。',
'greeting' => '你好 {owner}',
'intro' => '我们很高兴地通知您,<strong>PROSERVE 版本 {version}</strong> 刚刚发布,现已可供下载。',
'instructions' => '如果您已经安装了 <strong>PROSERVE Launcher</strong>,只需在任何一台机器上打开它,单击<em>「检查更新」</em>,然后单击「安装」即可完成新版本的安装。',
'firstInstallTitle' => '首次安装或新工作站?',
'firstInstallBody' => '如果这是您的第一次使用,或者您正在配置一台新 PC请在下方下载 PROSERVE Launcher 安装程序。<strong>您需要在使用 PROSERVE 的每台工作站上运行它</strong>。',
'downloadButton' => '⬇ 下载 PROSERVE Launcher (Windows)',
'directLink' => '直接链接:',
'releaseNotesTitle' => '版本说明',
'releaseNotesNone' => '此版本没有版本说明。',
'support' => '一如既往,如果您遇到任何问题(下载失败、安装错误、游戏中出现异常情况),请随时联系我们 — 我们随时为您提供帮助。',
'closing' => '训练愉快!',
'signature' => 'ASTERION VR 团队',
'footer' => '此自动通知已发送,因为您的联系电子邮件已注册到您的某个 PROSERVE 许可证中。<br/>要停止接收这些通知,请要求您的 ASTERION VR 客户经理从许可证联系人中删除您的地址。',
],
'th' => [
'subject' => 'PROSERVE v{version} พร้อมใช้งานแล้ว',
'title' => 'PROSERVE v{version} พร้อมใช้งาน!',
'subtitle' => 'เวอร์ชันใหม่พร้อมติดตั้งผ่าน PROSERVE Launcher ของคุณ',
'greeting' => 'สวัสดี {owner},',
'intro' => 'เรายินดีที่จะแจ้งให้คุณทราบว่า <strong>PROSERVE เวอร์ชัน {version}</strong> เพิ่งได้รับการเผยแพร่และพร้อมให้ดาวน์โหลดแล้ว',
'instructions' => 'หากคุณติดตั้ง <strong>PROSERVE Launcher</strong> ไว้แล้ว เพียงเปิดบนเครื่องใดก็ได้ของคุณ คลิก <em>« ตรวจสอบการอัปเดต »</em> แล้วติดตั้งเวอร์ชันใหม่ด้วยคลิกเดียว',
'firstInstallTitle' => 'ติดตั้งครั้งแรก หรือเวิร์กสเตชันใหม่?',
'firstInstallBody' => 'หากเป็นครั้งแรกของคุณ หรือคุณกำลังตั้งค่า PC ใหม่ ดาวน์โหลด PROSERVE Launcher ด้านล่าง <strong>คุณต้องเรียกใช้บนเวิร์กสเตชันทุกเครื่อง</strong>ที่จะใช้ PROSERVE',
'downloadButton' => '⬇ ดาวน์โหลด PROSERVE Launcher (Windows)',
'directLink' => 'ลิงก์โดยตรง:',
'releaseNotesTitle' => 'บันทึกการเผยแพร่',
'releaseNotesNone' => 'ไม่มีบันทึกการเผยแพร่สำหรับเวอร์ชันนี้',
'support' => 'เช่นเคย หากคุณพบปัญหาใดๆ (ดาวน์โหลดล้มเหลว ติดตั้งผิดพลาด อะไรแปลกๆ ในเกม) อย่าลังเลที่จะติดต่อ — เราพร้อมช่วยเหลือ',
'closing' => 'ฝึกซ้อมให้สนุก!',
'signature' => 'ทีม ASTERION VR',
'footer' => 'การแจ้งเตือนอัตโนมัตินี้ถูกส่งเนื่องจากอีเมลติดต่อของคุณถูกลงทะเบียนกับหนึ่งในใบอนุญาต PROSERVE ของคุณ<br/>เพื่อหยุดรับการแจ้งเตือนเหล่านี้ ขอให้ผู้จัดการบัญชี ASTERION VR ของคุณลบที่อยู่ของคุณออกจากผู้ติดต่อใบอนุญาต',
],
'ar' => [
'subject' => 'PROSERVE v{version} متوفر الآن',
'title' => 'PROSERVE v{version} متوفر!',
'subtitle' => 'إصدار جديد جاهز للتثبيت عبر PROSERVE Launcher الخاص بك.',
'greeting' => 'مرحباً {owner}،',
'intro' => 'يسعدنا أن نعلمك بأن <strong>الإصدار {version} من PROSERVE</strong> قد تم نشره للتو وأصبح متاحاً للتنزيل.',
'instructions' => 'إذا كان لديك <strong>PROSERVE Launcher</strong> مثبتاً بالفعل، فما عليك سوى فتحه على أي من أجهزتك، والنقر فوق <em>«التحقق من التحديثات»</em>، وتثبيت الإصدار الجديد بنقرة واحدة.',
'firstInstallTitle' => 'تثبيت لأول مرة أو محطة عمل جديدة؟',
'firstInstallBody' => 'إذا كانت هذه المرة الأولى لك، أو إذا كنت تعد جهاز كمبيوتر جديداً، فقم بتنزيل PROSERVE Launcher أدناه. <strong>ستحتاج إلى تشغيله على كل محطة عمل</strong> سيتم استخدام PROSERVE فيها.',
'downloadButton' => '⬇ تنزيل PROSERVE Launcher (Windows)',
'directLink' => 'رابط مباشر:',
'releaseNotesTitle' => 'ملاحظات الإصدار',
'releaseNotesNone' => 'لا توجد ملاحظات إصدار لهذه النسخة.',
'support' => 'كما هو الحال دائماً، إذا واجهت أي مشكلة (فشل التنزيل، خطأ في التثبيت، أي شيء غريب في اللعبة)، فلا تتردد في التواصل معنا — نحن هنا للمساعدة.',
'closing' => 'تدريب سعيد!',
'signature' => 'فريق ASTERION VR',
'footer' => 'تم إرسال هذا الإشعار التلقائي لأن بريدك الإلكتروني للاتصال مسجل في أحد تراخيص PROSERVE الخاصة بك.<br/>للتوقف عن تلقي هذه الإشعارات، اطلب من مدير حسابك في ASTERION VR إزالة عنوانك من جهات اتصال الترخيص.',
],
'es' => [
'subject' => 'PROSERVE v{version} ya está disponible',
'title' => '¡PROSERVE v{version} ya está disponible!',
'subtitle' => 'Una nueva versión está lista para instalar a través de tu PROSERVE Launcher.',
'greeting' => 'Hola {owner},',
'intro' => 'Nos complace informarte que <strong>la versión {version} de PROSERVE</strong> acaba de publicarse y ya está disponible para descargar.',
'instructions' => 'Si ya tienes el <strong>PROSERVE Launcher</strong> instalado, simplemente ábrelo en cualquiera de tus máquinas, haz clic en <em>«Buscar actualizaciones»</em> e instala la nueva versión con un solo clic.',
'firstInstallTitle' => '¿Primera instalación o nueva estación de trabajo?',
'firstInstallBody' => 'Si es la primera vez, o si estás configurando un PC nuevo, descarga el instalador de PROSERVE Launcher abajo. <strong>Deberás ejecutarlo en CADA estación de trabajo</strong> donde se utilice PROSERVE.',
'downloadButton' => '⬇ Descargar PROSERVE Launcher (Windows)',
'directLink' => 'Enlace directo:',
'releaseNotesTitle' => 'Notas de versión',
'releaseNotesNone' => 'No hay notas de versión para esta release.',
'support' => 'Como siempre, si encuentras algún problema (fallo de descarga, error de instalación, comportamiento inesperado en el juego), no dudes en contactarnos — estamos aquí para ayudar.',
'closing' => '¡Buen entrenamiento!',
'signature' => 'El equipo ASTERION VR',
'footer' => 'Esta notificación automática se envió porque tu dirección de correo está registrada como contacto en una de tus licencias PROSERVE.<br/>Para dejar de recibir estos correos, pide a tu gestor de cuenta ASTERION VR que elimine tu dirección de los contactos de la licencia.',
],
'de' => [
'subject' => 'PROSERVE v{version} ist jetzt verfügbar',
'title' => 'PROSERVE v{version} ist verfügbar!',
'subtitle' => 'Eine neue Version ist bereit zur Installation über deinen PROSERVE Launcher.',
'greeting' => 'Hallo {owner},',
'intro' => 'Wir freuen uns, dir mitzuteilen, dass <strong>die Version {version} von PROSERVE</strong> soeben veröffentlicht wurde und jetzt zum Download bereitsteht.',
'instructions' => 'Wenn du den <strong>PROSERVE Launcher</strong> bereits installiert hast, öffne ihn einfach auf einer beliebigen deiner Maschinen, klicke auf <em>«Updates suchen»</em> und installiere die neue Version mit einem Klick.',
'firstInstallTitle' => 'Erstinstallation oder neue Arbeitsstation?',
'firstInstallBody' => 'Wenn dies das erste Mal ist oder du einen neuen PC einrichtest, lade den PROSERVE Launcher-Installer unten herunter. <strong>Du musst ihn auf JEDER Arbeitsstation ausführen</strong>, auf der PROSERVE verwendet wird.',
'downloadButton' => '⬇ PROSERVE Launcher herunterladen (Windows)',
'directLink' => 'Direkter Link:',
'releaseNotesTitle' => 'Versionshinweise',
'releaseNotesNone' => 'Keine Versionshinweise für diese Version.',
'support' => 'Wie immer, wenn du auf Probleme stößt (Download-Fehler, Installationsfehler, irgendetwas Seltsames im Spiel), zögere nicht, uns zu kontaktieren — wir sind hier, um zu helfen.',
'closing' => 'Viel Spaß beim Training!',
'signature' => 'Das ASTERION VR Team',
'footer' => 'Diese automatische Benachrichtigung wurde gesendet, weil deine Kontakt-E-Mail bei einer deiner PROSERVE-Lizenzen registriert ist.<br/>Um diese E-Mails nicht mehr zu erhalten, bitte deinen ASTERION VR Account Manager, deine Adresse aus den Lizenz-Kontakten zu entfernen.',
],
];
return $cache[$locale] ?? $cache['en'];
}
/**
* Construit le corps HTML de l'email de release announce, dans la langue
* <c>$locale</c> demandée (fallback 'en'). Le contenu de la release note
* elle-même (Markdown) reste en anglais — seul le texte d'accompagnement
* (greeting, instructions, CTAs) est localisé via <see cref="getEmailStrings"/>.
* Le owner_name personnalise le greeting. Le logo (optionnel via CID embed
* <c>notifications.logo_path</c>) est inséré dans le bandeau d'en-tête navy.
*
* Pour l'arabe : on ajoute <c>dir="rtl"</c> sur le wrapper table → Outlook,
* Gmail et Apple Mail rendent correctement le texte droit→gauche, et les
* sous-éléments inline (HTML tags, code, URLs) restent affichés LTR comme
* attendu (mixed bidi handling natif).
*/
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = '', string $locale = 'en'): string
{
// Récupère le dictionnaire de strings localisées + interpolation simple
// des placeholders {version} et {owner}. On échappe APRÈS interpolation
// pour ne pas double-encoder les < > de balises HTML déjà dans les
// strings (cf. <strong> dans les templates de getEmailStrings).
$s = getEmailStrings($locale);
$versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
$ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', ENT_QUOTES, 'UTF-8');
$notesEsc = htmlspecialchars(trim($notesMarkdown), ENT_QUOTES, 'UTF-8');
// $logoSrc peut être une URL https://… (hotlink) OU un cid:xxx (inline embed).
// Le caller décide selon ce qu'il préfère ; on échappe juste pour mettre dans src=.
$logoEsc = htmlspecialchars($logoSrc, ENT_QUOTES, 'UTF-8');
// Direction RTL pour l'arabe. Les autres langues sont LTR par défaut.
// Note : on POSITIONNE rtl sur la table racine — les sub-blocks restent
// align="center" / align="left" selon le contexte (les tags HTML inline
// gardent leur direction naturelle via bidi-isolate du browser).
$dirAttr = ($locale === 'ar') ? ' dir="rtl"' : '';
// Helper d'interpolation : remplace {version} et {owner} dans une string
// localisée. NOTE : on ne réencode PAS — les strings dans getEmailStrings
// contiennent volontairement des <strong>, <em>, etc. à rendre comme HTML.
$tr = function (string $key) use ($s, $versionEsc, $ownerEsc): string {
return strtr($s[$key] ?? '', ['{version}' => $versionEsc, '{owner}' => $ownerEsc]);
};
// Wrap les release notes en <pre> pour préserver le Markdown formatting
// (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que
// ses notes sont en Markdown et l'opérateur peut lire le format brut.
// IMPORTANT : le titre "Release notes" est localisé MAIS le contenu reste
// toujours en anglais (= ce que l'admin a saisi dans la modal Notes).
$notesBlock = $notesEsc !== ''
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">Release notes</h3>\n"
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">" . htmlspecialchars($s['releaseNotesTitle'], ENT_QUOTES, 'UTF-8') . "</h3>\n"
. "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; "
. "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; "
. "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; "
. "direction: ltr; text-align: left; "
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
: '<p style="color: #6B7280; font-style: italic;">No release notes for this version.</p>';
: '<p style="color: #6B7280; font-style: italic;">' . htmlspecialchars($s['releaseNotesNone'], ENT_QUOTES, 'UTF-8') . '</p>';
// Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque
// les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche
@@ -158,38 +337,50 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
: 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe';
$installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8');
// Toutes les strings utilisateur sont passées par $tr() qui interpole
// {version}/{owner} dans le template localisé pour la $locale demandée.
$titleStr = $tr('title');
$subtitleStr = $tr('subtitle');
$greetingStr = $tr('greeting');
$introStr = $tr('intro');
$instructionsStr = $tr('instructions');
$fiTitleStr = $tr('firstInstallTitle');
$fiBodyStr = $tr('firstInstallBody');
$downloadBtnStr = $tr('downloadButton');
$directLinkStr = $tr('directLink');
$supportStr = $tr('support');
$closingStr = $tr('closing');
$signatureStr = $tr('signature');
$footerStr = $tr('footer');
return <<<HTML
<!DOCTYPE html>
<html>
<html{$dirAttr}>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0; padding:24px 12px; background-color:#F3F4F6; font-family:'Segoe UI',Arial,sans-serif; color:#1F2937;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640"
style="max-width:640px; margin:0 auto; border-collapse:collapse;">
style="max-width:640px; margin:0 auto; border-collapse:collapse;"{$dirAttr}>
<tr>
<td bgcolor="#0F172A" align="center"
style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;">
<div style="margin-bottom:16px;">{$logoBlock}</div>
<h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;">
PROSERVE v{$versionEsc} is available!
{$titleStr}
</h1>
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
A new version is ready to install via your PROSERVE Launcher.
{$subtitleStr}
</p>
</td>
</tr>
<tr>
<td bgcolor="#FFFFFF"
style="background-color:#FFFFFF; padding:28px 28px; border:1px solid #E5E7EB; border-top:none; border-radius:0 0 8px 8px;">
<p style="margin:0 0 14px;">Hi {$ownerEsc},</p>
<p style="margin:0 0 14px;">{$greetingStr}</p>
<p style="margin:0 0 14px;">
We're happy to let you know that
<strong>PROSERVE version {$versionEsc}</strong>
has just been published and is now available for download.
{$introStr}
</p>
<p style="margin:0 0 14px;">
If you already have the <strong>PROSERVE Launcher</strong> installed,
simply open it on any of your machines, click
<em>« Check for updates »</em>, and install the new version with one click.
{$instructionsStr}
</p>
<!-- Section « Don't have the launcher yet ? » : bouton de téléchargement
@@ -200,13 +391,10 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
<tr>
<td style="padding:18px 0;">
<p style="margin:0 0 12px; font-weight:600; color:#1F2937;">
First install or new workstation?
{$fiTitleStr}
</p>
<p style="margin:0 0 16px; color:#4B5563;">
If this is your first time, or you're setting up a new PC,
download the PROSERVE Launcher installer below.
<strong>You'll need to run it on EACH workstation</strong>
where PROSERVE will be used.
{$fiBodyStr}
</p>
<!-- Bouton stylé via table + bgcolor (vs CSS button) pour
compat Outlook qui ne supporte ni border-radius CSS sur
@@ -222,13 +410,13 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
color:#FFFFFF; text-decoration:none;
font-family:'Segoe UI',Arial,sans-serif;
font-size:14px; font-weight:600;">
⬇ Download PROSERVE Launcher (Windows)
{$downloadBtnStr}
</a>
</td>
</tr>
</table>
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
Direct link:
{$directLinkStr}
<a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a>
</p>
</td>
@@ -237,19 +425,17 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
{$notesBlock}
<p style="margin:24px 0 14px;">
As always, if you run into any issue (download failure, install error,
anything weird in-game), don't hesitate to reach out — we're here to help.
{$supportStr}
</p>
<p style="margin:0;">
Happy training!<br/>
<strong>The ASTERION VR team</strong>
{$closingStr}<br/>
<strong>{$signatureStr}</strong>
</p>
</td>
</tr>
<tr>
<td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;">
This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
{$footerStr}
</td>
</tr>
</table>
@@ -547,10 +733,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$notesFile = "$notesDir/{$entryId}.md";
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
// Récupère les licenses éligibles
// Récupère les licenses éligibles. On inclut maintenant `language`
// pour pouvoir localiser l'email — fallback 'en' si NULL ou inconnu.
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until
'SELECT id, owner_name, channel, can_see_betas, contact_emails, language, download_entitlement_until
FROM licenses
WHERE revoked_at IS NULL
AND download_entitlement_until >= ?
@@ -603,7 +790,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($emails)) continue;
$licsTouched++;
$subject = "PROSERVE v{$version} is now available";
// Locale par license : whitelist contre les langues supportées,
// fallback 'en' pour les licenses sans préférence définie.
$locale = trim((string)($lic['language'] ?? ''));
if (!in_array($locale, ['fr', 'en', 'zh', 'th', 'ar'], true)) {
$locale = 'en';
}
// Sujet localisé via le même dictionnaire que le corps.
$subjectTemplate = getEmailStrings($locale)['subject'] ?? 'PROSERVE v{version} is now available';
$subject = str_replace('{version}', $version, $subjectTemplate);
// Logo via CID inline si fichier disponible sur disque, sinon
// chaîne vide → le template tombera sur le fallback texte
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE
@@ -617,7 +812,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$lic['owner_name'] ?? '',
$notesBody,
$config['base_url'] ?? '',
$logoSrc
$logoSrc,
$locale
);
foreach ($emails as $email) {