keycloak-uncached

Details

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 b62381b..c55a585 100755
--- a/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java
+++ b/services/src/main/java/org/keycloak/email/freemarker/FreeMarkerEmailTemplateProvider.java
@@ -17,6 +17,16 @@
 
 package org.keycloak.email.freemarker;
 
+import java.io.IOException;
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Properties;
+
 import org.keycloak.broker.provider.BrokeredIdentityContext;
 import org.keycloak.common.util.ObjectUtil;
 import org.keycloak.email.EmailException;
@@ -34,25 +44,19 @@ import org.keycloak.theme.FreeMarkerException;
 import org.keycloak.theme.FreeMarkerUtil;
 import org.keycloak.theme.Theme;
 import org.keycloak.theme.ThemeProvider;
+import org.keycloak.theme.beans.LinkExpirationFormatterMethod;
 import org.keycloak.theme.beans.MessageFormatterMethod;
 
-import java.io.IOException;
-import java.text.MessageFormat;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Properties;
-
 /**
  * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
  */
 public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
 
     protected KeycloakSession session;
-    /** authenticationSession can be null for some email sendings, it is filled only for email sendings performed as part of the authentication session (email verification, password reset, broker link etc.)! */
+    /**
+     * authenticationSession can be null for some email sendings, it is filled only for email sendings performed as part of the authentication session (email verification, password reset, broker link
+     * etc.)!
+     */
     protected AuthenticationSessionModel authenticationSession;
     protected FreeMarkerUtil freeMarker;
     protected RealmModel realm;
@@ -81,7 +85,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
         attributes.put(name, value);
         return this;
     }
-    
+
     @Override
     public EmailTemplateProvider setAuthenticationSession(AuthenticationSessionModel authenticationSession) {
         this.authenticationSession = authenticationSession;
@@ -109,8 +113,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
     public void sendPasswordReset(String link, long expirationInMinutes) throws EmailException {
         Map<String, Object> attributes = new HashMap<String, Object>(this.attributes);
         attributes.put("user", new ProfileBean(user));
-        attributes.put("link", link);
-        attributes.put("linkExpiration", expirationInMinutes);
+        addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
 
         attributes.put("realmName", getRealmName());
 
@@ -134,8 +137,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
     public void sendConfirmIdentityBrokerLink(String link, long expirationInMinutes) throws EmailException {
         Map<String, Object> attributes = new HashMap<String, Object>(this.attributes);
         attributes.put("user", new ProfileBean(user));
-        attributes.put("link", link);
-        attributes.put("linkExpiration", expirationInMinutes);
+        addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
 
         attributes.put("realmName", getRealmName());
 
@@ -146,7 +148,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
         attributes.put("identityProviderContext", brokerContext);
         attributes.put("identityProviderAlias", idpAlias);
 
-        List<Object> subjectAttrs = Arrays.<Object>asList(idpAlias);
+        List<Object> subjectAttrs = Arrays.<Object> asList(idpAlias);
         send("identityProviderLinkSubject", subjectAttrs, "identity-provider-link.ftl", attributes);
     }
 
@@ -154,8 +156,7 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
     public void sendExecuteActions(String link, long expirationInMinutes) throws EmailException {
         Map<String, Object> attributes = new HashMap<String, Object>(this.attributes);
         attributes.put("user", new ProfileBean(user));
-        attributes.put("link", link);
-        attributes.put("linkExpiration", expirationInMinutes);
+        addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
 
         attributes.put("realmName", getRealmName());
 
@@ -166,14 +167,31 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
     public void sendVerifyEmail(String link, long expirationInMinutes) throws EmailException {
         Map<String, Object> attributes = new HashMap<String, Object>(this.attributes);
         attributes.put("user", new ProfileBean(user));
-        attributes.put("link", link);
-        attributes.put("linkExpiration", expirationInMinutes);
+        addLinkInfoIntoAttributes(link, expirationInMinutes, attributes);
 
         attributes.put("realmName", getRealmName());
 
         send("emailVerificationSubject", "email-verification.ftl", attributes);
     }
 
+    /**
+     * Add link info into template attributes.
+     * 
+     * @param link to add
+     * @param expirationInMinutes to add
+     * @param attributes to add link info into
+     */
+    protected void addLinkInfoIntoAttributes(String link, long expirationInMinutes, Map<String, Object> attributes) throws EmailException {
+        attributes.put("link", link);
+        attributes.put("linkExpiration", expirationInMinutes);
+        try {
+            Locale locale = session.getContext().resolveLocale(user);
+            attributes.put("linkExpirationFormatter", new LinkExpirationFormatterMethod(getTheme().getMessages(locale), locale));
+        } catch (IOException e) {
+            throw new EmailException("Failed to template email", e);
+        }
+    }
+
     protected void send(String subjectKey, String template, Map<String, Object> attributes) throws EmailException {
         send(subjectKey, Collections.emptyList(), template, attributes);
     }
@@ -185,19 +203,19 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
             attributes.put("locale", locale);
             Properties rb = theme.getMessages(locale);
             attributes.put("msg", new MessageFormatterMethod(locale, rb));
-            String subject = new MessageFormat(rb.getProperty(subjectKey,subjectKey),locale).format(subjectAttributes.toArray());
+            String subject = new MessageFormat(rb.getProperty(subjectKey, subjectKey), locale).format(subjectAttributes.toArray());
             String textTemplate = String.format("text/%s", template);
             String textBody;
             try {
                 textBody = freeMarker.processTemplate(attributes, textTemplate, theme);
-            } catch (final FreeMarkerException e ) {
+            } catch (final FreeMarkerException e) {
                 textBody = null;
             }
             String htmlTemplate = String.format("html/%s", template);
             String htmlBody;
             try {
                 htmlBody = freeMarker.processTemplate(attributes, htmlTemplate, theme);
-            } catch (final FreeMarkerException e ) {
+            } catch (final FreeMarkerException e) {
                 htmlBody = null;
             }
 
@@ -210,12 +228,12 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
     protected Theme getTheme() throws IOException {
         return session.theme().getTheme(Theme.Type.EMAIL);
     }
-    
+
     protected void send(String subjectKey, List<Object> subjectAttributes, String template, Map<String, Object> attributes) throws EmailException {
         try {
             EmailTemplate email = processTemplate(subjectKey, subjectAttributes, template, attributes);
             send(email.getSubject(), email.getTextBody(), email.getHtmlBody());
-        } catch (EmailException e){
+        } catch (EmailException e) {
             throw e;
         } catch (Exception e) {
             throw new EmailException("Failed to template email", e);
@@ -235,9 +253,9 @@ public class FreeMarkerEmailTemplateProvider implements EmailTemplateProvider {
     public void close() {
     }
 
-    protected String toCamelCase(EventType event){
+    protected String toCamelCase(EventType event) {
         StringBuilder sb = new StringBuilder("event");
-        for(String s : event.name().toLowerCase().split("_")){
+        for (String s : event.name().toLowerCase().split("_")) {
             sb.append(ObjectUtil.capitalize(s));
         }
         return sb.toString();
diff --git a/services/src/main/java/org/keycloak/theme/beans/LinkExpirationFormatterMethod.java b/services/src/main/java/org/keycloak/theme/beans/LinkExpirationFormatterMethod.java
new file mode 100644
index 0000000..c62ac44
--- /dev/null
+++ b/services/src/main/java/org/keycloak/theme/beans/LinkExpirationFormatterMethod.java
@@ -0,0 +1,75 @@
+/*
+ * JBoss, Home of Professional Open Source
+ * Copyright 2018 Red Hat Inc. and/or its affiliates and other contributors
+ * as indicated by the @authors tag. All rights reserved.
+ */
+package org.keycloak.theme.beans;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+
+import freemarker.template.TemplateMethodModelEx;
+import freemarker.template.TemplateModelException;
+
+/**
+ * Method used to format link expiration time period in emails.
+ *
+ * @author Vlastimil Elias (velias at redhat dot com)
+ */
+public class LinkExpirationFormatterMethod implements TemplateMethodModelEx {
+
+    protected final Properties messages;
+    protected final Locale locale;
+
+    public LinkExpirationFormatterMethod(Properties messages, Locale locale) {
+        this.messages = messages;
+        this.locale = locale;
+    }
+
+    @SuppressWarnings("rawtypes")
+    @Override
+    public Object exec(List arguments) throws TemplateModelException {
+        Object val = arguments.isEmpty() ? null : arguments.get(0);
+        if (val == null)
+            return "";
+
+        try {
+            //input value is in minutes, as defined in EmailTemplateProvider!
+            return format(Long.parseLong(val.toString().trim()) * 60);
+        } catch (NumberFormatException e) {
+            // not a number, return it as is
+            return val.toString();
+        }
+
+    }
+
+    protected String format(long valueInSeconds) {
+
+        String unitKey = "seconds";
+        long value = valueInSeconds;
+
+        if (value > 0 && value % 60 == 0) {
+            unitKey = "minutes";
+            value = value / 60;
+            if (value % 60 == 0) {
+                unitKey = "hours";
+                value = value / 60;
+                if (value % 24 == 0) {
+                    unitKey = "days";
+                    value = value / 24;
+                }
+            }
+        }
+
+        return value + " " + getUnitTextFromMessages(unitKey, value);
+    }
+
+    protected String getUnitTextFromMessages(String unitKey, long value) {
+        String msg = messages.getProperty("linkExpirationFormatter.timePeriodUnit." + unitKey + "." + value);
+        if (msg != null)
+            return msg;
+        return messages.getProperty("linkExpirationFormatter.timePeriodUnit." + unitKey);
+    }
+
+}
diff --git a/services/src/test/java/org/keycloak/theme/beans/LinkExpirationFormatterMethodTest.java b/services/src/test/java/org/keycloak/theme/beans/LinkExpirationFormatterMethodTest.java
new file mode 100644
index 0000000..6f52694
--- /dev/null
+++ b/services/src/test/java/org/keycloak/theme/beans/LinkExpirationFormatterMethodTest.java
@@ -0,0 +1,128 @@
+/*
+ * JBoss, Home of Professional Open Source
+ * Copyright 2018 Red Hat Inc. and/or its affiliates and other contributors
+ * as indicated by the @authors tag. All rights reserved.
+ */
+package org.keycloak.theme.beans;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Properties;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import freemarker.template.TemplateModelException;
+
+/**
+ * @author Vlastimil Elias (velias at redhat dot com)
+ */
+public class LinkExpirationFormatterMethodTest {
+
+    protected static final Locale locale = Locale.ENGLISH;
+    protected static final Properties messages = new Properties();
+    static {
+        messages.put("linkExpirationFormatter.timePeriodUnit.seconds.1", "second");
+        messages.put("linkExpirationFormatter.timePeriodUnit.seconds", "seconds");
+        messages.put("linkExpirationFormatter.timePeriodUnit.minutes.1", "minute");
+        messages.put("linkExpirationFormatter.timePeriodUnit.minutes.3", "minutes-3");
+        messages.put("linkExpirationFormatter.timePeriodUnit.minutes", "minutes");
+        messages.put("linkExpirationFormatter.timePeriodUnit.hours.1", "hour");
+        messages.put("linkExpirationFormatter.timePeriodUnit.hours", "hours");
+        messages.put("linkExpirationFormatter.timePeriodUnit.days.1", "day");
+        messages.put("linkExpirationFormatter.timePeriodUnit.days", "days");
+    }
+
+    protected List<Object> toList(Object... objects) {
+        return Arrays.asList(objects);
+    }
+    
+    @Test
+    public void inputtypes_null() throws TemplateModelException{
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("", tested.exec(Collections.emptyList()));
+    }
+    
+    @Test
+    public void inputtypes_string_empty() throws TemplateModelException{
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("", tested.exec(toList("")));
+        Assert.assertEquals(" ", tested.exec(toList(" ")));
+    }
+
+    @Test
+    public void inputtypes_string_number() throws TemplateModelException{
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("2 minutes", tested.exec(toList("2")));
+        Assert.assertEquals("2 minutes", tested.exec(toList(" 2 ")));
+    }
+
+    @Test
+    public void inputtypes_string_notanumber() throws TemplateModelException{
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("ahoj", tested.exec(toList("ahoj")));
+    }
+    
+    @Test
+    public void inputtypes_number() throws TemplateModelException{
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("5 minutes", tested.exec(toList(new Integer(5))));
+        Assert.assertEquals("5 minutes", tested.exec(toList(new Long(5))));
+    }
+
+    @Test
+    public void format_second_zero() throws TemplateModelException {
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("0 seconds", tested.exec(toList(0)));
+    }
+
+    @Test
+    public void format_minute_one() throws TemplateModelException {
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("1 minute", tested.exec(toList(1)));
+    }
+
+    @Test
+    public void format_minute_more() throws TemplateModelException {
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("2 minutes", tested.exec(toList(2)));
+        //test support for languages with more plurals depending on the value
+        Assert.assertEquals("3 minutes-3", tested.exec(toList(3)));
+        Assert.assertEquals("5 minutes", tested.exec(toList(5)));
+        Assert.assertEquals("24 minutes", tested.exec(toList(24)));
+        Assert.assertEquals("59 minutes", tested.exec(toList(59)));
+        Assert.assertEquals("61 minutes", tested.exec(toList(61)));
+    }
+
+    @Test
+    public void format_hour_one() throws TemplateModelException {
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("1 hour", tested.exec(toList(60)));
+    }
+
+    @Test
+    public void format_hour_more() throws TemplateModelException {
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("2 hours", tested.exec(toList(2 * 60)));
+        Assert.assertEquals("5 hours", tested.exec(toList(5 * 60)));
+        Assert.assertEquals("23 hours", tested.exec(toList(23 * 60)));
+        Assert.assertEquals("25 hours", tested.exec(toList(25 * 60)));
+    }
+
+    @Test
+    public void format_day_one() throws TemplateModelException {
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("1 day", tested.exec(toList(60 * 24)));
+    }
+
+    @Test
+    public void format_day_more() throws TemplateModelException {
+        LinkExpirationFormatterMethod tested = new LinkExpirationFormatterMethod(messages, locale);
+        Assert.assertEquals("2 days", tested.exec(toList(2 * 24 * 60)));
+        Assert.assertEquals("5 days", tested.exec(toList(5 * 24 * 60)));
+    }
+
+
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java
index fafc4ad..a3d4df1 100755
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java
@@ -685,11 +685,11 @@ public class UserTest extends AbstractAdminTest {
 
         assertTrue(body.getText().contains("Update Password"));
         assertTrue(body.getText().contains("your Admin-client-test account"));
-        assertTrue(body.getText().contains("This link will expire within 720 minutes"));
+        assertTrue(body.getText().contains("This link will expire within 12 hours"));
 
         assertTrue(body.getHtml().contains("Update Password"));
         assertTrue(body.getHtml().contains("your Admin-client-test account"));
-        assertTrue(body.getHtml().contains("This link will expire within 720 minutes"));
+        assertTrue(body.getHtml().contains("This link will expire within 12 hours"));
 
         String link = MailUtils.getPasswordResetEmailLink(body);
 
diff --git a/themes/src/main/resources/theme/base/email/html/email-verification.ftl b/themes/src/main/resources/theme/base/email/html/email-verification.ftl
index b2142ef..bd371d9 100644
--- a/themes/src/main/resources/theme/base/email/html/email-verification.ftl
+++ b/themes/src/main/resources/theme/base/email/html/email-verification.ftl
@@ -1,5 +1,5 @@
 <html>
 <body>
-${msg("emailVerificationBodyHtml",link, linkExpiration, realmName)?no_esc}
+${msg("emailVerificationBodyHtml",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))?no_esc}
 </body>
 </html>
diff --git a/themes/src/main/resources/theme/base/email/html/executeActions.ftl b/themes/src/main/resources/theme/base/email/html/executeActions.ftl
index 5999feb..6510dfc 100755
--- a/themes/src/main/resources/theme/base/email/html/executeActions.ftl
+++ b/themes/src/main/resources/theme/base/email/html/executeActions.ftl
@@ -4,6 +4,6 @@
 
 <html>
 <body>
-${msg("executeActionsBodyHtml",link, linkExpiration, realmName, requiredActionsText)?no_esc}
+${msg("executeActionsBodyHtml",link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration))?no_esc}
 </body>
 </html>
diff --git a/themes/src/main/resources/theme/base/email/html/identity-provider-link.ftl b/themes/src/main/resources/theme/base/email/html/identity-provider-link.ftl
index 31bddbe..fff38fc 100644
--- a/themes/src/main/resources/theme/base/email/html/identity-provider-link.ftl
+++ b/themes/src/main/resources/theme/base/email/html/identity-provider-link.ftl
@@ -1,5 +1,5 @@
 <html>
 <body>
-${msg("identityProviderLinkBodyHtml", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration)?no_esc}
+${msg("identityProviderLinkBodyHtml", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration, linkExpirationFormatter(linkExpiration))?no_esc}
 </body>
 </html>
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/email/html/password-reset.ftl b/themes/src/main/resources/theme/base/email/html/password-reset.ftl
index edbc888..e56ae1e 100755
--- a/themes/src/main/resources/theme/base/email/html/password-reset.ftl
+++ b/themes/src/main/resources/theme/base/email/html/password-reset.ftl
@@ -1,5 +1,5 @@
 <html>
 <body>
-${msg("passwordResetBodyHtml",link, linkExpiration, realmName)?no_esc}
+${msg("passwordResetBodyHtml",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))?no_esc}
 </body>
 </html>
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/email/messages/messages_en.properties b/themes/src/main/resources/theme/base/email/messages/messages_en.properties
index 5824d0a..e04e947 100755
--- a/themes/src/main/resources/theme/base/email/messages/messages_en.properties
+++ b/themes/src/main/resources/theme/base/email/messages/messages_en.properties
@@ -1,18 +1,18 @@
 emailVerificationSubject=Verify email
-emailVerificationBody=Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you didn''t create this account, just ignore this message.
-emailVerificationBodyHtml=<p>Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address</p><p><a href="{0}">Link to e-mail address verification</a></p><p>This link will expire within {1} minutes.</p><p>If you didn''t create this account, just ignore this message.</p>
+emailVerificationBody=Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address\n\n{0}\n\nThis link will expire within {3}.\n\nIf you didn''t create this account, just ignore this message.
+emailVerificationBodyHtml=<p>Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address</p><p><a href="{0}">Link to e-mail address verification</a></p><p>This link will expire within {3}.</p><p>If you didn''t create this account, just ignore this message.</p>
 emailTestSubject=[KEYCLOAK] - SMTP test message
 emailTestBody=This is a test message
 emailTestBodyHtml=<p>This is a test message</p>
 identityProviderLinkSubject=Link {0}
-identityProviderLinkBody=Someone wants to link your "{1}" account with "{0}" account of user {2} . If this was you, click the link below to link accounts\n\n{3}\n\nThis link will expire within {4} minutes.\n\nIf you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.
-identityProviderLinkBodyHtml=<p>Someone wants to link your <b>{1}</b> account with <b>{0}</b> account of user {2} . If this was you, click the link below to link accounts</p><p><a href="{3}">Link to confirm account linking</a></p><p>This link will expire within {4} minutes.</p><p>If you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.</p>
+identityProviderLinkBody=Someone wants to link your "{1}" account with "{0}" account of user {2} . If this was you, click the link below to link accounts\n\n{3}\n\nThis link will expire within {5}.\n\nIf you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.
+identityProviderLinkBodyHtml=<p>Someone wants to link your <b>{1}</b> account with <b>{0}</b> account of user {2} . If this was you, click the link below to link accounts</p><p><a href="{3}">Link to confirm account linking</a></p><p>This link will expire within {5}.</p><p>If you don''t want to link account, just ignore this message. If you link accounts, you will be able to login to {1} through {0}.</p>
 passwordResetSubject=Reset password
-passwordResetBody=Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.\n\n{0}\n\nThis link and code will expire within {1} minutes.\n\nIf you don''t want to reset your credentials, just ignore this message and nothing will be changed.
-passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.</p><p><a href="{0}">Link to reset credentials</a></p><p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your credentials, just ignore this message and nothing will be changed.</p>
+passwordResetBody=Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.\n\n{0}\n\nThis link and code will expire within {3}.\n\nIf you don''t want to reset your credentials, just ignore this message and nothing will be changed.
+passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s credentials. If this was you, click on the link below to reset them.</p><p><a href="{0}">Link to reset credentials</a></p><p>This link will expire within {3}.</p><p>If you don''t want to reset your credentials, just ignore this message and nothing will be changed.</p>
 executeActionsSubject=Update Your Account
-executeActionsBody=Your administrator has just requested that you update your {2} account by performing the following action(s): {3}. Click on the link below to start this process.\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you are unaware that your admin has requested this, just ignore this message and nothing will be changed.
-executeActionsBodyHtml=<p>Your administrator has just requested that you update your {2} account by performing the following action(s): {3}. Click on the link below to start this process.</p><p><a href="{0}">Link to account update</a></p><p>This link will expire within {1} minutes.</p><p>If you are unaware that your admin has requested this, just ignore this message and nothing will be changed.</p>
+executeActionsBody=Your administrator has just requested that you update your {2} account by performing the following action(s): {3}. Click on the link below to start this process.\n\n{0}\n\nThis link will expire within {4}.\n\nIf you are unaware that your admin has requested this, just ignore this message and nothing will be changed.
+executeActionsBodyHtml=<p>Your administrator has just requested that you update your {2} account by performing the following action(s): {3}. Click on the link below to start this process.</p><p><a href="{0}">Link to account update</a></p><p>This link will expire within {4}.</p><p>If you are unaware that your admin has requested this, just ignore this message and nothing will be changed.</p>
 eventLoginErrorSubject=Login error
 eventLoginErrorBody=A failed login attempt was detected to your account on {0} from {1}. If this was not you, please contact an admin.
 eventLoginErrorBodyHtml=<p>A failed login attempt was detected to your account on {0} from {1}. If this was not you, please contact an admin.</p>
@@ -31,3 +31,17 @@ requiredAction.terms_and_conditions=Terms and Conditions
 requiredAction.UPDATE_PASSWORD=Update Password
 requiredAction.UPDATE_PROFILE=Update Profile
 requiredAction.VERIFY_EMAIL=Verify Email
+
+# units for link expiration timeout formatting
+linkExpirationFormatter.timePeriodUnit.seconds=seconds
+linkExpirationFormatter.timePeriodUnit.seconds.1=second
+linkExpirationFormatter.timePeriodUnit.minutes=minutes
+linkExpirationFormatter.timePeriodUnit.minutes.1=minute
+#for language which have more unit plural forms depending on the value (eg. Czech and other Slavic langs) you can override unit text for some other values like this: 
+#linkExpirationFormatter.timePeriodUnit.minutes.2=minuty
+#linkExpirationFormatter.timePeriodUnit.minutes.3=minuty
+#linkExpirationFormatter.timePeriodUnit.minutes.4=minuty
+linkExpirationFormatter.timePeriodUnit.hours=hours
+linkExpirationFormatter.timePeriodUnit.hours.1=hour
+linkExpirationFormatter.timePeriodUnit.days=days
+linkExpirationFormatter.timePeriodUnit.days.1=day
diff --git a/themes/src/main/resources/theme/base/email/text/email-verification.ftl b/themes/src/main/resources/theme/base/email/text/email-verification.ftl
index 152de22..9e39696 100644
--- a/themes/src/main/resources/theme/base/email/text/email-verification.ftl
+++ b/themes/src/main/resources/theme/base/email/text/email-verification.ftl
@@ -1,2 +1,2 @@
 <#ftl output_format="plainText">
-${msg("emailVerificationBody",link, linkExpiration, realmName)}
\ No newline at end of file
+${msg("emailVerificationBody",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))}
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/email/text/executeActions.ftl b/themes/src/main/resources/theme/base/email/text/executeActions.ftl
index af9d5c4..6610c7a 100755
--- a/themes/src/main/resources/theme/base/email/text/executeActions.ftl
+++ b/themes/src/main/resources/theme/base/email/text/executeActions.ftl
@@ -1,4 +1,4 @@
 <#ftl output_format="plainText">
 <#assign requiredActionsText><#if requiredActions??><#list requiredActions><#items as reqActionItem>${msg("requiredAction.${reqActionItem}")}<#sep>, </#items></#list><#else></#if></#assign>
 
-${msg("executeActionsBody",link, linkExpiration, realmName, requiredActionsText)}
\ No newline at end of file
+${msg("executeActionsBody",link, linkExpiration, realmName, requiredActionsText, linkExpirationFormatter(linkExpiration))}
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/email/text/identity-provider-link.ftl b/themes/src/main/resources/theme/base/email/text/identity-provider-link.ftl
index 0232e12..ed9d246 100644
--- a/themes/src/main/resources/theme/base/email/text/identity-provider-link.ftl
+++ b/themes/src/main/resources/theme/base/email/text/identity-provider-link.ftl
@@ -1,2 +1,2 @@
 <#ftl output_format="plainText">
-${msg("identityProviderLinkBody", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration)}
\ No newline at end of file
+${msg("identityProviderLinkBody", identityProviderAlias, realmName, identityProviderContext.username, link, linkExpiration, linkExpirationFormatter(linkExpiration))}
\ No newline at end of file
diff --git a/themes/src/main/resources/theme/base/email/text/password-reset.ftl b/themes/src/main/resources/theme/base/email/text/password-reset.ftl
index e22649d..27405c9 100755
--- a/themes/src/main/resources/theme/base/email/text/password-reset.ftl
+++ b/themes/src/main/resources/theme/base/email/text/password-reset.ftl
@@ -1,2 +1,2 @@
 <#ftl output_format="plainText">
-${msg("passwordResetBody",link, linkExpiration, realmName)}
\ No newline at end of file
+${msg("passwordResetBody",link, linkExpiration, realmName, linkExpirationFormatter(linkExpiration))}
\ No newline at end of file