Autodetect RTL/LTR for email texts

Closes #37584

Signed-off-by: Alexander Schwartz <alexander.schwartz@gmx.net>
This commit is contained in:
Alexander Schwartz 2025-02-24 14:31:57 +01:00 committed by GitHub
parent 8ca551333d
commit 9bf0af612a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 39 additions and 29 deletions

View File

@ -129,10 +129,6 @@ public class VerifyMessageProperties {
// Unescape HTML entities, as we later also unescape HTML entities in the sanitized value
value = org.apache.commons.text.StringEscapeUtils.unescapeHtml4(value);
if (file.getAbsolutePath().contains("email")) {
// TODO: move the RTL information for emails
value = value.replaceAll(Pattern.quote(" style=\"direction: rtl;\""), "");
}
return value;
}

View File

@ -18,6 +18,7 @@
package org.keycloak.email.freemarker;
import java.io.IOException;
import java.text.Bidi;
import java.text.MessageFormat;
import java.util.Collections;
import java.util.HashMap;
@ -218,6 +219,12 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
attributes.put("locale", locale);
Properties messages = theme.getEnhancedMessages(realm, locale);
String currentLanguageTag = locale.getLanguage();
String currentLanguage = messages.getProperty("locale_" + currentLanguageTag, currentLanguageTag);
boolean isLtr = new Bidi(currentLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isLeftToRight();
attributes.put("ltr", isLtr);
attributes.put("msg", new MessageFormatterMethod(locale, messages));
attributes.put("properties", theme.getProperties());

View File

@ -37,12 +37,12 @@ public class LocaleBean {
private final String currentLanguageTag;
private final boolean rtl; // right-to-left language
private final List<Locale> supported;
private static final ConcurrentHashMap<java.util.Locale, Boolean> bidiMap = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, Boolean> bidiMap = new ConcurrentHashMap<>();
public LocaleBean(RealmModel realm, java.util.Locale current, UriBuilder uriBuilder, Properties messages) {
this.currentLanguageTag = current.toLanguageTag();
this.current = messages.getProperty("locale_" + this.currentLanguageTag, this.currentLanguageTag);
this.rtl = isLeftToRight(current);
this.rtl = isLeftToRight(this.current);
Collator collator = Collator.getInstance(current);
collator.setStrength(Collator.PRIMARY); // ignore case and accents
@ -57,16 +57,18 @@ public class LocaleBean {
.collect(Collectors.toList());
}
protected static boolean isLeftToRight(java.util.Locale current) {
protected static boolean isLeftToRight(String current) {
// Some languages that are RTL have an English name in Java locales, like 'dv' aka Divehi as stated in
// https://github.com/keycloak/keycloak/issues/33833#issuecomment-2446965307.
// Still, this solution seems to be good enough for now. Any exceptions would be added when those translations arise.
// Still, this solution seems to be good enough for now. Any exceptions would be added when those translations arise,
// as each localization file can contain a `locale_xx' property with the wanted translation.
//
// Adding the ICU library was discarded at the time to avoid an additional dependency and due to its special license.
// This might be reconsidered in the future if there are more scenarios.
//
// As the most likely alternative, a translation could in the future define RTL, its language name, and then this can be used instead.
return bidiMap.computeIfAbsent(current, l -> new Bidi(l.getLanguage(), Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isLeftToRight());
return bidiMap.computeIfAbsent(current, l -> new Bidi(l, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isLeftToRight());
}
public String getCurrent() {

View File

@ -38,14 +38,14 @@ public class LocaleBeanTest {
@Test
public void verifyRtl() {
for (String rtlLanguageCode : RTL_LANGUAGE_CODES) {
MatcherAssert.assertThat(LocaleBean.isLeftToRight(Locale.forLanguageTag(rtlLanguageCode)), Matchers.is(true));
MatcherAssert.assertThat(LocaleBean.isLeftToRight(Locale.forLanguageTag(rtlLanguageCode).getLanguage()), Matchers.is(true));
}
}
@Test
public void verifyLtr() {
for (String rtlLanguageCode : LTR_LANGUAGE_CODES) {
MatcherAssert.assertThat(LocaleBean.isLeftToRight(Locale.forLanguageTag(rtlLanguageCode)), Matchers.is(true));
MatcherAssert.assertThat(LocaleBean.isLeftToRight(Locale.forLanguageTag(rtlLanguageCode).getLanguage()), Matchers.is(true));
}
}

View File

@ -81,11 +81,11 @@ public class EmailTest extends AbstractI18NTest {
@Test
public void restPasswordEmail() throws MessagingException, IOException {
String expectedBodyContent = "Someone just requested to change";
verifyResetPassword("Reset password", expectedBodyContent, 1);
verifyResetPassword("Reset password", expectedBodyContent, null, 1);
changeUserLocale("en");
verifyResetPassword("Reset password", expectedBodyContent, 2);
verifyResetPassword("Reset password", expectedBodyContent, null, 2);
}
@Test
@ -109,11 +109,11 @@ public class EmailTest extends AbstractI18NTest {
getCleanup().addLocalization(Locale.GERMAN.toLanguageTag());
try {
verifyResetPassword(subjectEn, expectedBodyContentEn, 1);
verifyResetPassword(subjectEn, expectedBodyContentEn, "<html lang=\"en\" dir=\"ltr\">", 1);
changeUserLocale("de");
verifyResetPassword(subjectDe, expectedBodyContentDe, 2);
verifyResetPassword(subjectDe, expectedBodyContentDe, "<html lang=\"de\" dir=\"ltr\">", 2);
} finally {
// Revert
changeUserLocale("en");
@ -124,7 +124,7 @@ public class EmailTest extends AbstractI18NTest {
public void restPasswordEmailGerman() throws MessagingException, IOException {
changeUserLocale("de");
try {
verifyResetPassword("Passwort zurücksetzen", "Es wurde eine Änderung", 1);
verifyResetPassword("Passwort zurücksetzen", "Es wurde eine Änderung", null, 1);
} finally {
// Revert
changeUserLocale("en");
@ -158,7 +158,7 @@ public class EmailTest extends AbstractI18NTest {
}
}
private void verifyResetPassword(String expectedSubject, String expectedTextBodyContent, int expectedMsgCount)
private void verifyResetPassword(String expectedSubject, String expectedTextBodyContent, String expectedHtmlBodyContent, int expectedMsgCount)
throws MessagingException, IOException {
loginPage.open();
loginPage.resetPassword();
@ -175,6 +175,11 @@ public class EmailTest extends AbstractI18NTest {
// make sure all placeholders have been replaced
assertThat(textBody, not(containsString("{")));
assertThat(textBody, not(containsString("}")));
if (expectedHtmlBodyContent != null) {
String htmlBody = MailUtils.getBody(message).getHtml();
assertThat(htmlBody, containsString(expectedHtmlBodyContent));
}
}
//KEYCLOAK-7478

View File

@ -1,33 +1,33 @@
emailVerificationSubject=التحقق من البريد الإلكتروني
emailVerificationBody=قام شخص ما بإنشاء حساب {2} بعنوان البريد الإلكتروني هذا. إذا كان هذا أنت، فانقر على الرابط أدناه للتحقق من عنوان بريدك الإلكتروني\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا لم تكن قد أنشأت هذا الحساب، فقط تجاهل هذه الرسالة.
emailVerificationBodyHtml=<p style="direction: rtl;">قام شخص ما بإنشاء حساب {2} بعنوان البريد الإلكتروني هذا. إذا كان هذا أنت، فانقر على الرابط أدناه للتحقق من عنوان بريدك الإلكتروني</p><p style="direction: rtl;"><a href="{0}">رابط التحقق من البريد الإلكتروني</a></p><p style="direction: rtl;">ستنتهي صلاحية هذا الرابط خلال {3}.</p><p style="direction: rtl;">إذا لم تكن قد أنشأت هذا الحساب، فقط تجاهل هذه الرسالة.</p>
emailVerificationBodyHtml=<p>قام شخص ما بإنشاء حساب {2} بعنوان البريد الإلكتروني هذا. إذا كان هذا أنت، فانقر على الرابط أدناه للتحقق من عنوان بريدك الإلكتروني</p><p><a href="{0}">رابط التحقق من البريد الإلكتروني</a></p><p>ستنتهي صلاحية هذا الرابط خلال {3}.</p><p>إذا لم تكن قد أنشأت هذا الحساب، فقط تجاهل هذه الرسالة.</p>
emailUpdateConfirmationSubject=التحقق من البريد الإلكتروني الجديد
emailUpdateConfirmationBody=لتحديث حساب {2} الخاص بك بعنوان البريد الإلكتروني {1}، انقر على الرابط أدناه\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا كنت لا تريد القيام بهذا التعديل، فقط تجاهل هذه الرسالة.
emailUpdateConfirmationBodyHtml=<p style="direction: rtl;">لتحديث حساب {2} الخاص بك بعنوان البريد الإلكتروني {1}, انقر على الرابط أدناه</p><p style="direction: rtl;"><a href="{0}">{0}</a></p><p style="direction: rtl;">ستنتهي صلاحية هذا الرابط خلال {3}.</p><p style="direction: rtl;">إذا كنت لا تريد القيام بهذا التعديل، فقط تجاهل هذه الرسالة.</p>
emailUpdateConfirmationBodyHtml=<p>لتحديث حساب {2} الخاص بك بعنوان البريد الإلكتروني {1}, انقر على الرابط أدناه</p><p><a href="{0}">{0}</a></p><p>ستنتهي صلاحية هذا الرابط خلال {3}.</p><p>إذا كنت لا تريد القيام بهذا التعديل، فقط تجاهل هذه الرسالة.</p>
emailTestSubject=[KEYCLOAK] - رسالة تجربة
emailTestBody=هذه رسالة تجربة
emailTestBodyHtml=<p style="direction: rtl;">هذه رسالة تجربة</p>
emailTestBodyHtml=<p>هذه رسالة تجربة</p>
identityProviderLinkSubject=ربط {0}
identityProviderLinkBody=قام شخص ما بطلب ربط الحساب "{1}" بالحساب "{0}" الخاص بالمستخدم {2} . إذا كان هذا أنت، فانقر على الرابط أدناه لإتمام عملية ربط الحسابات\n\n{3}\n\nستنتهي صلاحية هذا الرابط خلال {5}.\n\nإذا كنت لا تريد ربط الحساب، فقط تجاهل هذه الرسالة. إذا قمت بربط الحسابات، فستتمكن من تسجيل الدخول إلى {1} من خلال {0}.
identityProviderLinkBodyHtml=<p style="direction: rtl;">قام شخص ما بطلب ربط الحساب <b>{1}</b> بالحساب <b>{0}</b> الخاص بالمستخدم {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإتمام عملية ربط الحسابات</p><p style="direction: rtl;"><a href="{3}">رابط لتأكيد ربط الحساب</a></p><p style="direction: rtl;">ستنتهي صلاحية هذا الرابط خلال {5}.</p><p style="direction: rtl;">إذا كنت لا تريد ربط الحساب، فقط تجاهل هذه الرسالة. إذا قمت بربط الحسابات، فستتمكن من تسجيل الدخول إلى {1} من خلال {0}.</p>
identityProviderLinkBodyHtml=<p>قام شخص ما بطلب ربط الحساب <b>{1}</b> بالحساب <b>{0}</b> الخاص بالمستخدم {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإتمام عملية ربط الحسابات</p><p><a href="{3}">رابط لتأكيد ربط الحساب</a></p><p>ستنتهي صلاحية هذا الرابط خلال {5}.</p><p>إذا كنت لا تريد ربط الحساب، فقط تجاهل هذه الرسالة. إذا قمت بربط الحسابات، فستتمكن من تسجيل الدخول إلى {1} من خلال {0}.</p>
passwordResetSubject=إعادة تعيين كلمة المرور
passwordResetBody=قام شخص ما بطلب تغيير معلومات الدخول للحساب {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإعادة تعيين معلومات الدخول.\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا كنت لا تريد إعادة تعيين معلومات الدخول، فقط تجاهل هذه الرسالة.
passwordResetBodyHtml=<p style="direction: rtl;">قام شخص ما بطلب تغيير معلومات الدخول للحساب {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإعادة تعيين معلومات الدخول.</p><p style="direction: rtl;"><a href="{0}">رابط إعادة تعيين معلومات الدخول للحساب</a></p><p style="direction: rtl;">ستنتهي صلاحية هذا الرابط خلال {3}.</p><p style="direction: rtl;">إذا كنت لا تريد إعادة تعيين معلومات الدخول، فقط تجاهل هذه الرسالة.</p>
passwordResetBodyHtml=<p>قام شخص ما بطلب تغيير معلومات الدخول للحساب {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإعادة تعيين معلومات الدخول.</p><p><a href="{0}">رابط إعادة تعيين معلومات الدخول للحساب</a></p><p>ستنتهي صلاحية هذا الرابط خلال {3}.</p><p>إذا كنت لا تريد إعادة تعيين معلومات الدخول، فقط تجاهل هذه الرسالة.</p>
executeActionsSubject=تحديث بيانات حسابك
executeActionsBody=تلقيت طلب من مسؤول النظام لتحديث بيانات حسابك {2} والقيام بالإجراءات المطلوبة التالية: {3}. انقر على الرابط أدناه للبدء.\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {4}.\n\nإذا لم تكن على علم بأن مسؤول النظام قد طلب ذلك، فتجاهل هذه الرسالة ولن يتم تغيير أي شيء.
executeActionsBodyHtml=<p style="direction: rtl;">تلقيت طلب من مسؤول النظام لتحديث بيانات حسابك {2} والقيام بالإجراءات المطلوبة التالية: {3}. انقر على الرابط أدناه للبدء.</p><p style="direction: rtl;"><a href="{0}">رابط تحديث بيانات الحساب</a></p><p style="direction: rtl;">ستنتهي صلاحية هذا الرابط خلال {4}.</p><p style="direction: rtl;">إذا لم تكن على علم بأن مسؤول النظام قد طلب ذلك، فتجاهل هذه الرسالة ولن يتم تغيير أي شيء.</p>
executeActionsBodyHtml=<p>تلقيت طلب من مسؤول النظام لتحديث بيانات حسابك {2} والقيام بالإجراءات المطلوبة التالية: {3}. انقر على الرابط أدناه للبدء.</p><p><a href="{0}">رابط تحديث بيانات الحساب</a></p><p>ستنتهي صلاحية هذا الرابط خلال {4}.</p><p>إذا لم تكن على علم بأن مسؤول النظام قد طلب ذلك، فتجاهل هذه الرسالة ولن يتم تغيير أي شيء.</p>
eventLoginErrorSubject=خطأ في تسجيل الدخول
eventLoginErrorBody=تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.
eventLoginErrorBodyHtml=<p style="direction: rtl;">تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
eventLoginErrorBodyHtml=<p>تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
eventRemoveTotpSubject=إزالة رمز التحقق
eventRemoveTotpBody=تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.
eventRemoveTotpBodyHtml=<p style="direction: rtl;">تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
eventRemoveTotpBodyHtml=<p>تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
eventUpdatePasswordSubject=تحديث كلمة المرور
eventUpdatePasswordBody=تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.
eventUpdatePasswordBodyHtml=<p style="direction: rtl;">تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
eventUpdatePasswordBodyHtml=<p>تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
eventUpdateTotpSubject=تحديث خاصية رمز التحقق
eventUpdateTotpBody=تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.
eventUpdateTotpBodyHtml=<p style="direction: rtl;">تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
eventUpdateTotpBodyHtml=<p>تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
requiredAction.CONFIGURE_TOTP=إعداد خاصية رمز التحقق
requiredAction.TERMS_AND_CONDITIONS=الأحكام والشروط
@ -43,5 +43,5 @@ linkExpirationFormatter.timePeriodUnit.hours={0,choice,0#ساعة|3#ساعات|9
linkExpirationFormatter.timePeriodUnit.days={0,choice,0#يوم|3#أيام|9<يوم}
emailVerificationBodyCode=يرجى التحقق من عنوان بريدك الإلكتروني عن طريق إدخال الرمز التالي.\n\n{0}\n\n.
emailVerificationBodyCodeHtml=<p style="direction: rtl;">يرجى التحقق من عنوان بريدك الإلكتروني عن طريق إدخال الرمز التالي.</p><p style="direction: rtl;"><b>{0}</b></p>
emailVerificationBodyCodeHtml=<p>يرجى التحقق من عنوان بريدك الإلكتروني عن طريق إدخال الرمز التالي.</p><p><b>{0}</b></p>

View File

@ -1,5 +1,5 @@
<#macro emailLayout>
<html>
<html lang="${locale.language}" dir="${(ltr)?then('ltr','rtl')}">
<body>
<#nested>
</body>