From 9bf0af612ad975227e071566c149c885efbfcd4e Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Mon, 24 Feb 2025 14:31:57 +0100 Subject: [PATCH] Autodetect RTL/LTR for email texts Closes #37584 Signed-off-by: Alexander Schwartz --- .../VerifyMessageProperties.java | 4 ---- .../FreeMarkerEmailTemplateProvider.java | 7 ++++++ .../org/keycloak/theme/beans/LocaleBean.java | 12 +++++----- .../keycloak/theme/beans/LocaleBeanTest.java | 4 ++-- .../keycloak/testsuite/i18n/EmailTest.java | 17 +++++++++----- .../email/messages/messages_ar.properties | 22 +++++++++---------- .../theme/base/email/html/template.ftl | 2 +- 7 files changed, 39 insertions(+), 29 deletions(-) diff --git a/misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java b/misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java index 9368853e9bf..29e92bdc18e 100644 --- a/misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java +++ b/misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java @@ -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; } diff --git a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java index 73694ee884a..b6e4b74d2c9 100755 --- a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java +++ b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java @@ -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()); diff --git a/services/src/main/java/org/keycloak/theme/beans/LocaleBean.java b/services/src/main/java/org/keycloak/theme/beans/LocaleBean.java index 5d70922b656..f60f3a72c8f 100755 --- a/services/src/main/java/org/keycloak/theme/beans/LocaleBean.java +++ b/services/src/main/java/org/keycloak/theme/beans/LocaleBean.java @@ -37,12 +37,12 @@ public class LocaleBean { private final String currentLanguageTag; private final boolean rtl; // right-to-left language private final List supported; - private static final ConcurrentHashMap bidiMap = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap 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() { diff --git a/services/src/test/java/org/keycloak/theme/beans/LocaleBeanTest.java b/services/src/test/java/org/keycloak/theme/beans/LocaleBeanTest.java index 475b19ff142..cb7b134b764 100644 --- a/services/src/test/java/org/keycloak/theme/beans/LocaleBeanTest.java +++ b/services/src/test/java/org/keycloak/theme/beans/LocaleBeanTest.java @@ -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)); } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java index 801a05544fe..04d7c2d13e1 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java @@ -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, "", 1); changeUserLocale("de"); - verifyResetPassword(subjectDe, expectedBodyContentDe, 2); + verifyResetPassword(subjectDe, expectedBodyContentDe, "", 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 diff --git a/themes/src/main/resources-community/theme/base/email/messages/messages_ar.properties b/themes/src/main/resources-community/theme/base/email/messages/messages_ar.properties index 7d3529aea98..62e4714a48f 100644 --- a/themes/src/main/resources-community/theme/base/email/messages/messages_ar.properties +++ b/themes/src/main/resources-community/theme/base/email/messages/messages_ar.properties @@ -1,33 +1,33 @@ emailVerificationSubject=التحقق من البريد الإلكتروني emailVerificationBody=قام شخص ما بإنشاء حساب {2} بعنوان البريد الإلكتروني هذا. إذا كان هذا أنت، فانقر على الرابط أدناه للتحقق من عنوان بريدك الإلكتروني\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا لم تكن قد أنشأت هذا الحساب، فقط تجاهل هذه الرسالة. -emailVerificationBodyHtml=

قام شخص ما بإنشاء حساب {2} بعنوان البريد الإلكتروني هذا. إذا كان هذا أنت، فانقر على الرابط أدناه للتحقق من عنوان بريدك الإلكتروني

رابط التحقق من البريد الإلكتروني

ستنتهي صلاحية هذا الرابط خلال {3}.

إذا لم تكن قد أنشأت هذا الحساب، فقط تجاهل هذه الرسالة.

+emailVerificationBodyHtml=

قام شخص ما بإنشاء حساب {2} بعنوان البريد الإلكتروني هذا. إذا كان هذا أنت، فانقر على الرابط أدناه للتحقق من عنوان بريدك الإلكتروني

رابط التحقق من البريد الإلكتروني

ستنتهي صلاحية هذا الرابط خلال {3}.

إذا لم تكن قد أنشأت هذا الحساب، فقط تجاهل هذه الرسالة.

emailUpdateConfirmationSubject=التحقق من البريد الإلكتروني الجديد emailUpdateConfirmationBody=لتحديث حساب {2} الخاص بك بعنوان البريد الإلكتروني {1}، انقر على الرابط أدناه\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا كنت لا تريد القيام بهذا التعديل، فقط تجاهل هذه الرسالة. -emailUpdateConfirmationBodyHtml=

لتحديث حساب {2} الخاص بك بعنوان البريد الإلكتروني {1}, انقر على الرابط أدناه

{0}

ستنتهي صلاحية هذا الرابط خلال {3}.

إذا كنت لا تريد القيام بهذا التعديل، فقط تجاهل هذه الرسالة.

+emailUpdateConfirmationBodyHtml=

لتحديث حساب {2} الخاص بك بعنوان البريد الإلكتروني {1}, انقر على الرابط أدناه

{0}

ستنتهي صلاحية هذا الرابط خلال {3}.

إذا كنت لا تريد القيام بهذا التعديل، فقط تجاهل هذه الرسالة.

emailTestSubject=[KEYCLOAK] - رسالة تجربة emailTestBody=هذه رسالة تجربة -emailTestBodyHtml=

هذه رسالة تجربة

+emailTestBodyHtml=

هذه رسالة تجربة

identityProviderLinkSubject=ربط {0} identityProviderLinkBody=قام شخص ما بطلب ربط الحساب "{1}" بالحساب "{0}" الخاص بالمستخدم {2} . إذا كان هذا أنت، فانقر على الرابط أدناه لإتمام عملية ربط الحسابات\n\n{3}\n\nستنتهي صلاحية هذا الرابط خلال {5}.\n\nإذا كنت لا تريد ربط الحساب، فقط تجاهل هذه الرسالة. إذا قمت بربط الحسابات، فستتمكن من تسجيل الدخول إلى {1} من خلال {0}. -identityProviderLinkBodyHtml=

قام شخص ما بطلب ربط الحساب {1} بالحساب {0} الخاص بالمستخدم {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإتمام عملية ربط الحسابات

رابط لتأكيد ربط الحساب

ستنتهي صلاحية هذا الرابط خلال {5}.

إذا كنت لا تريد ربط الحساب، فقط تجاهل هذه الرسالة. إذا قمت بربط الحسابات، فستتمكن من تسجيل الدخول إلى {1} من خلال {0}.

+identityProviderLinkBodyHtml=

قام شخص ما بطلب ربط الحساب {1} بالحساب {0} الخاص بالمستخدم {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإتمام عملية ربط الحسابات

رابط لتأكيد ربط الحساب

ستنتهي صلاحية هذا الرابط خلال {5}.

إذا كنت لا تريد ربط الحساب، فقط تجاهل هذه الرسالة. إذا قمت بربط الحسابات، فستتمكن من تسجيل الدخول إلى {1} من خلال {0}.

passwordResetSubject=إعادة تعيين كلمة المرور passwordResetBody=قام شخص ما بطلب تغيير معلومات الدخول للحساب {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإعادة تعيين معلومات الدخول.\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا كنت لا تريد إعادة تعيين معلومات الدخول، فقط تجاهل هذه الرسالة. -passwordResetBodyHtml=

قام شخص ما بطلب تغيير معلومات الدخول للحساب {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإعادة تعيين معلومات الدخول.

رابط إعادة تعيين معلومات الدخول للحساب

ستنتهي صلاحية هذا الرابط خلال {3}.

إذا كنت لا تريد إعادة تعيين معلومات الدخول، فقط تجاهل هذه الرسالة.

+passwordResetBodyHtml=

قام شخص ما بطلب تغيير معلومات الدخول للحساب {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإعادة تعيين معلومات الدخول.

رابط إعادة تعيين معلومات الدخول للحساب

ستنتهي صلاحية هذا الرابط خلال {3}.

إذا كنت لا تريد إعادة تعيين معلومات الدخول، فقط تجاهل هذه الرسالة.

executeActionsSubject=تحديث بيانات حسابك executeActionsBody=تلقيت طلب من مسؤول النظام لتحديث بيانات حسابك {2} والقيام بالإجراءات المطلوبة التالية: {3}. انقر على الرابط أدناه للبدء.\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {4}.\n\nإذا لم تكن على علم بأن مسؤول النظام قد طلب ذلك، فتجاهل هذه الرسالة ولن يتم تغيير أي شيء. -executeActionsBodyHtml=

تلقيت طلب من مسؤول النظام لتحديث بيانات حسابك {2} والقيام بالإجراءات المطلوبة التالية: {3}. انقر على الرابط أدناه للبدء.

رابط تحديث بيانات الحساب

ستنتهي صلاحية هذا الرابط خلال {4}.

إذا لم تكن على علم بأن مسؤول النظام قد طلب ذلك، فتجاهل هذه الرسالة ولن يتم تغيير أي شيء.

+executeActionsBodyHtml=

تلقيت طلب من مسؤول النظام لتحديث بيانات حسابك {2} والقيام بالإجراءات المطلوبة التالية: {3}. انقر على الرابط أدناه للبدء.

رابط تحديث بيانات الحساب

ستنتهي صلاحية هذا الرابط خلال {4}.

إذا لم تكن على علم بأن مسؤول النظام قد طلب ذلك، فتجاهل هذه الرسالة ولن يتم تغيير أي شيء.

eventLoginErrorSubject=خطأ في تسجيل الدخول eventLoginErrorBody=تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام. -eventLoginErrorBodyHtml=

تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.

+eventLoginErrorBodyHtml=

تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.

eventRemoveTotpSubject=إزالة رمز التحقق eventRemoveTotpBody=تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام. -eventRemoveTotpBodyHtml=

تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.

+eventRemoveTotpBodyHtml=

تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.

eventUpdatePasswordSubject=تحديث كلمة المرور eventUpdatePasswordBody=تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام. -eventUpdatePasswordBodyHtml=

تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.

+eventUpdatePasswordBodyHtml=

تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.

eventUpdateTotpSubject=تحديث خاصية رمز التحقق eventUpdateTotpBody=تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام. -eventUpdateTotpBodyHtml=

تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.

+eventUpdateTotpBodyHtml=

تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.

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=

يرجى التحقق من عنوان بريدك الإلكتروني عن طريق إدخال الرمز التالي.

{0}

+emailVerificationBodyCodeHtml=

يرجى التحقق من عنوان بريدك الإلكتروني عن طريق إدخال الرمز التالي.

{0}

diff --git a/themes/src/main/resources/theme/base/email/html/template.ftl b/themes/src/main/resources/theme/base/email/html/template.ftl index fd2ea5df2a9..1bd56aa8529 100644 --- a/themes/src/main/resources/theme/base/email/html/template.ftl +++ b/themes/src/main/resources/theme/base/email/html/template.ftl @@ -1,5 +1,5 @@ <#macro emailLayout> - + <#nested>