Skip to content

Commit 9bf0af6

Browse files
authored
Autodetect RTL/LTR for email texts
Closes #37584 Signed-off-by: Alexander Schwartz <[email protected]>
1 parent 8ca5513 commit 9bf0af6

File tree

7 files changed

+39
-29
lines changed

7 files changed

+39
-29
lines changed

misc/theme-verifier/src/main/java/org/keycloak/themeverifier/VerifyMessageProperties.java

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,6 @@ private String normalizeValue(String key, String value) {
129129
// Unescape HTML entities, as we later also unescape HTML entities in the sanitized value
130130
value = org.apache.commons.text.StringEscapeUtils.unescapeHtml4(value);
131131

132-
if (file.getAbsolutePath().contains("email")) {
133-
// TODO: move the RTL information for emails
134-
value = value.replaceAll(Pattern.quote(" style=\"direction: rtl;\""), "");
135-
}
136132
return value;
137133
}
138134

services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
package org.keycloak.email.freemarker;
1919

2020
import java.io.IOException;
21+
import java.text.Bidi;
2122
import java.text.MessageFormat;
2223
import java.util.Collections;
2324
import java.util.HashMap;
@@ -218,6 +219,12 @@ protected EmailTemplate processTemplate(String subjectKey, List<Object> subjectA
218219
attributes.put("locale", locale);
219220

220221
Properties messages = theme.getEnhancedMessages(realm, locale);
222+
223+
String currentLanguageTag = locale.getLanguage();
224+
String currentLanguage = messages.getProperty("locale_" + currentLanguageTag, currentLanguageTag);
225+
boolean isLtr = new Bidi(currentLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isLeftToRight();
226+
attributes.put("ltr", isLtr);
227+
221228
attributes.put("msg", new MessageFormatterMethod(locale, messages));
222229

223230
attributes.put("properties", theme.getProperties());

services/src/main/java/org/keycloak/theme/beans/LocaleBean.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ public class LocaleBean {
3737
private final String currentLanguageTag;
3838
private final boolean rtl; // right-to-left language
3939
private final List<Locale> supported;
40-
private static final ConcurrentHashMap<java.util.Locale, Boolean> bidiMap = new ConcurrentHashMap<>();
40+
private static final ConcurrentHashMap<String, Boolean> bidiMap = new ConcurrentHashMap<>();
4141

4242
public LocaleBean(RealmModel realm, java.util.Locale current, UriBuilder uriBuilder, Properties messages) {
4343
this.currentLanguageTag = current.toLanguageTag();
4444
this.current = messages.getProperty("locale_" + this.currentLanguageTag, this.currentLanguageTag);
45-
this.rtl = isLeftToRight(current);
45+
this.rtl = isLeftToRight(this.current);
4646

4747
Collator collator = Collator.getInstance(current);
4848
collator.setStrength(Collator.PRIMARY); // ignore case and accents
@@ -57,16 +57,18 @@ public LocaleBean(RealmModel realm, java.util.Locale current, UriBuilder uriBuil
5757
.collect(Collectors.toList());
5858
}
5959

60-
protected static boolean isLeftToRight(java.util.Locale current) {
60+
protected static boolean isLeftToRight(String current) {
6161
// Some languages that are RTL have an English name in Java locales, like 'dv' aka Divehi as stated in
6262
// https://github.com/keycloak/keycloak/issues/33833#issuecomment-2446965307.
63-
// Still, this solution seems to be good enough for now. Any exceptions would be added when those translations arise.
63+
// Still, this solution seems to be good enough for now. Any exceptions would be added when those translations arise,
64+
// as each localization file can contain a `locale_xx' property with the wanted translation.
65+
//
6466
// Adding the ICU library was discarded at the time to avoid an additional dependency and due to its special license.
6567
// This might be reconsidered in the future if there are more scenarios.
6668
//
6769
// As the most likely alternative, a translation could in the future define RTL, its language name, and then this can be used instead.
6870

69-
return bidiMap.computeIfAbsent(current, l -> new Bidi(l.getLanguage(), Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isLeftToRight());
71+
return bidiMap.computeIfAbsent(current, l -> new Bidi(l, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isLeftToRight());
7072
}
7173

7274
public String getCurrent() {

services/src/test/java/org/keycloak/theme/beans/LocaleBeanTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,14 @@ public class LocaleBeanTest {
3838
@Test
3939
public void verifyRtl() {
4040
for (String rtlLanguageCode : RTL_LANGUAGE_CODES) {
41-
MatcherAssert.assertThat(LocaleBean.isLeftToRight(Locale.forLanguageTag(rtlLanguageCode)), Matchers.is(true));
41+
MatcherAssert.assertThat(LocaleBean.isLeftToRight(Locale.forLanguageTag(rtlLanguageCode).getLanguage()), Matchers.is(true));
4242
}
4343
}
4444

4545
@Test
4646
public void verifyLtr() {
4747
for (String rtlLanguageCode : LTR_LANGUAGE_CODES) {
48-
MatcherAssert.assertThat(LocaleBean.isLeftToRight(Locale.forLanguageTag(rtlLanguageCode)), Matchers.is(true));
48+
MatcherAssert.assertThat(LocaleBean.isLeftToRight(Locale.forLanguageTag(rtlLanguageCode).getLanguage()), Matchers.is(true));
4949
}
5050
}
5151

testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,11 @@ private void changeUserLocale(String locale) {
8181
@Test
8282
public void restPasswordEmail() throws MessagingException, IOException {
8383
String expectedBodyContent = "Someone just requested to change";
84-
verifyResetPassword("Reset password", expectedBodyContent, 1);
84+
verifyResetPassword("Reset password", expectedBodyContent, null, 1);
8585

8686
changeUserLocale("en");
8787

88-
verifyResetPassword("Reset password", expectedBodyContent, 2);
88+
verifyResetPassword("Reset password", expectedBodyContent, null, 2);
8989
}
9090

9191
@Test
@@ -109,11 +109,11 @@ public void realmLocalizationMessagesAreApplied() throws MessagingException, IOE
109109
getCleanup().addLocalization(Locale.GERMAN.toLanguageTag());
110110

111111
try {
112-
verifyResetPassword(subjectEn, expectedBodyContentEn, 1);
112+
verifyResetPassword(subjectEn, expectedBodyContentEn, "<html lang=\"en\" dir=\"ltr\">", 1);
113113

114114
changeUserLocale("de");
115115

116-
verifyResetPassword(subjectDe, expectedBodyContentDe, 2);
116+
verifyResetPassword(subjectDe, expectedBodyContentDe, "<html lang=\"de\" dir=\"ltr\">", 2);
117117
} finally {
118118
// Revert
119119
changeUserLocale("en");
@@ -124,7 +124,7 @@ public void realmLocalizationMessagesAreApplied() throws MessagingException, IOE
124124
public void restPasswordEmailGerman() throws MessagingException, IOException {
125125
changeUserLocale("de");
126126
try {
127-
verifyResetPassword("Passwort zurücksetzen", "Es wurde eine Änderung", 1);
127+
verifyResetPassword("Passwort zurücksetzen", "Es wurde eine Änderung", null, 1);
128128
} finally {
129129
// Revert
130130
changeUserLocale("en");
@@ -158,7 +158,7 @@ public void updatePasswordFromAdmin() throws MessagingException, IOException {
158158
}
159159
}
160160

161-
private void verifyResetPassword(String expectedSubject, String expectedTextBodyContent, int expectedMsgCount)
161+
private void verifyResetPassword(String expectedSubject, String expectedTextBodyContent, String expectedHtmlBodyContent, int expectedMsgCount)
162162
throws MessagingException, IOException {
163163
loginPage.open();
164164
loginPage.resetPassword();
@@ -175,6 +175,11 @@ private void verifyResetPassword(String expectedSubject, String expectedTextBody
175175
// make sure all placeholders have been replaced
176176
assertThat(textBody, not(containsString("{")));
177177
assertThat(textBody, not(containsString("}")));
178+
179+
if (expectedHtmlBodyContent != null) {
180+
String htmlBody = MailUtils.getBody(message).getHtml();
181+
assertThat(htmlBody, containsString(expectedHtmlBodyContent));
182+
}
178183
}
179184

180185
//KEYCLOAK-7478
Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,33 @@
11
emailVerificationSubject=التحقق من البريد الإلكتروني
22
emailVerificationBody=قام شخص ما بإنشاء حساب {2} بعنوان البريد الإلكتروني هذا. إذا كان هذا أنت، فانقر على الرابط أدناه للتحقق من عنوان بريدك الإلكتروني\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا لم تكن قد أنشأت هذا الحساب، فقط تجاهل هذه الرسالة.
3-
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>
3+
emailVerificationBodyHtml=<p>قام شخص ما بإنشاء حساب {2} بعنوان البريد الإلكتروني هذا. إذا كان هذا أنت، فانقر على الرابط أدناه للتحقق من عنوان بريدك الإلكتروني</p><p><a href="{0}">رابط التحقق من البريد الإلكتروني</a></p><p>ستنتهي صلاحية هذا الرابط خلال {3}.</p><p>إذا لم تكن قد أنشأت هذا الحساب، فقط تجاهل هذه الرسالة.</p>
44
emailUpdateConfirmationSubject=التحقق من البريد الإلكتروني الجديد
55
emailUpdateConfirmationBody=لتحديث حساب {2} الخاص بك بعنوان البريد الإلكتروني {1}، انقر على الرابط أدناه\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا كنت لا تريد القيام بهذا التعديل، فقط تجاهل هذه الرسالة.
6-
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>
6+
emailUpdateConfirmationBodyHtml=<p>لتحديث حساب {2} الخاص بك بعنوان البريد الإلكتروني {1}, انقر على الرابط أدناه</p><p><a href="{0}">{0}</a></p><p>ستنتهي صلاحية هذا الرابط خلال {3}.</p><p>إذا كنت لا تريد القيام بهذا التعديل، فقط تجاهل هذه الرسالة.</p>
77
emailTestSubject=[KEYCLOAK] - رسالة تجربة
88
emailTestBody=هذه رسالة تجربة
9-
emailTestBodyHtml=<p style="direction: rtl;">هذه رسالة تجربة</p>
9+
emailTestBodyHtml=<p>هذه رسالة تجربة</p>
1010
identityProviderLinkSubject=ربط {0}
1111
identityProviderLinkBody=قام شخص ما بطلب ربط الحساب "{1}" بالحساب "{0}" الخاص بالمستخدم {2} . إذا كان هذا أنت، فانقر على الرابط أدناه لإتمام عملية ربط الحسابات\n\n{3}\n\nستنتهي صلاحية هذا الرابط خلال {5}.\n\nإذا كنت لا تريد ربط الحساب، فقط تجاهل هذه الرسالة. إذا قمت بربط الحسابات، فستتمكن من تسجيل الدخول إلى {1} من خلال {0}.
12-
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>
12+
identityProviderLinkBodyHtml=<p>قام شخص ما بطلب ربط الحساب <b>{1}</b> بالحساب <b>{0}</b> الخاص بالمستخدم {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإتمام عملية ربط الحسابات</p><p><a href="{3}">رابط لتأكيد ربط الحساب</a></p><p>ستنتهي صلاحية هذا الرابط خلال {5}.</p><p>إذا كنت لا تريد ربط الحساب، فقط تجاهل هذه الرسالة. إذا قمت بربط الحسابات، فستتمكن من تسجيل الدخول إلى {1} من خلال {0}.</p>
1313
passwordResetSubject=إعادة تعيين كلمة المرور
1414
passwordResetBody=قام شخص ما بطلب تغيير معلومات الدخول للحساب {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإعادة تعيين معلومات الدخول.\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {3}.\n\nإذا كنت لا تريد إعادة تعيين معلومات الدخول، فقط تجاهل هذه الرسالة.
15-
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>
15+
passwordResetBodyHtml=<p>قام شخص ما بطلب تغيير معلومات الدخول للحساب {2}. إذا كان هذا أنت، فانقر على الرابط أدناه لإعادة تعيين معلومات الدخول.</p><p><a href="{0}">رابط إعادة تعيين معلومات الدخول للحساب</a></p><p>ستنتهي صلاحية هذا الرابط خلال {3}.</p><p>إذا كنت لا تريد إعادة تعيين معلومات الدخول، فقط تجاهل هذه الرسالة.</p>
1616
executeActionsSubject=تحديث بيانات حسابك
1717
executeActionsBody=تلقيت طلب من مسؤول النظام لتحديث بيانات حسابك {2} والقيام بالإجراءات المطلوبة التالية: {3}. انقر على الرابط أدناه للبدء.\n\n{0}\n\nستنتهي صلاحية هذا الرابط خلال {4}.\n\nإذا لم تكن على علم بأن مسؤول النظام قد طلب ذلك، فتجاهل هذه الرسالة ولن يتم تغيير أي شيء.
18-
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>
18+
executeActionsBodyHtml=<p>تلقيت طلب من مسؤول النظام لتحديث بيانات حسابك {2} والقيام بالإجراءات المطلوبة التالية: {3}. انقر على الرابط أدناه للبدء.</p><p><a href="{0}">رابط تحديث بيانات الحساب</a></p><p>ستنتهي صلاحية هذا الرابط خلال {4}.</p><p>إذا لم تكن على علم بأن مسؤول النظام قد طلب ذلك، فتجاهل هذه الرسالة ولن يتم تغيير أي شيء.</p>
1919
eventLoginErrorSubject=خطأ في تسجيل الدخول
2020
eventLoginErrorBody=تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.
21-
eventLoginErrorBodyHtml=<p style="direction: rtl;">تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
21+
eventLoginErrorBodyHtml=<p>تم رصد محاولة دخول فاشلة على حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
2222
eventRemoveTotpSubject=إزالة رمز التحقق
2323
eventRemoveTotpBody=تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.
24-
eventRemoveTotpBodyHtml=<p style="direction: rtl;">تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
24+
eventRemoveTotpBodyHtml=<p>تم إزالة خاصية رمز التحقق من حسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
2525
eventUpdatePasswordSubject=تحديث كلمة المرور
2626
eventUpdatePasswordBody=تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.
27-
eventUpdatePasswordBodyHtml=<p style="direction: rtl;">تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
27+
eventUpdatePasswordBodyHtml=<p>تم تغيير كلمة المرور الخاصة بك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
2828
eventUpdateTotpSubject=تحديث خاصية رمز التحقق
2929
eventUpdateTotpBody=تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.
30-
eventUpdateTotpBodyHtml=<p style="direction: rtl;">تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
30+
eventUpdateTotpBodyHtml=<p>تم تحديث حاصية رمز التحقق لحسابك في {0} ومن {1}. إذا لم تكن أنت، يرجى التواصل مع مسؤول النظام.</p>
3131

3232
requiredAction.CONFIGURE_TOTP=إعداد خاصية رمز التحقق
3333
requiredAction.TERMS_AND_CONDITIONS=الأحكام والشروط
@@ -43,5 +43,5 @@ linkExpirationFormatter.timePeriodUnit.hours={0,choice,0#ساعة|3#ساعات|9
4343
linkExpirationFormatter.timePeriodUnit.days={0,choice,0#يوم|3#أيام|9<يوم}
4444

4545
emailVerificationBodyCode=يرجى التحقق من عنوان بريدك الإلكتروني عن طريق إدخال الرمز التالي.\n\n{0}\n\n.
46-
emailVerificationBodyCodeHtml=<p style="direction: rtl;">يرجى التحقق من عنوان بريدك الإلكتروني عن طريق إدخال الرمز التالي.</p><p style="direction: rtl;"><b>{0}</b></p>
46+
emailVerificationBodyCodeHtml=<p>يرجى التحقق من عنوان بريدك الإلكتروني عن طريق إدخال الرمز التالي.</p><p><b>{0}</b></p>
4747

themes/src/main/resources/theme/base/email/html/template.ftl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<#macro emailLayout>
2-
<html>
2+
<html lang="${locale.language}" dir="${(ltr)?then('ltr','rtl')}">
33
<body>
44
<#nested>
55
</body>

0 commit comments

Comments
 (0)