From 2d6578dad47764365118b52f20c813d224aa5fa0 Mon Sep 17 00:00:00 2001 From: Mitch <25337396+MitchDrage@users.noreply.github.com> Date: Tue, 10 Nov 2026 03:28:59 +0000 Subject: [PATCH] Issue a new password reset token on each request --- .../resourcedetail/UserDetailVO.java | 1 + .../user/UserPasswordResetManager.java | 6 + .../user/UserPasswordResetManagerImpl.java | 87 +++++++--- .../UserPasswordResetManagerImplTest.java | 160 +++++++++++++++--- ui/public/locales/en.json | 2 +- .../utils/mailing/SMTPMailSender.java | 13 +- .../utils/mailing/SMTPMailSenderTest.java | 17 ++ 7 files changed, 238 insertions(+), 48 deletions(-) diff --git a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java index d0cfcc3d4396..1b318f9a3fdc 100644 --- a/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java +++ b/engine/schema/src/main/java/org/apache/cloudstack/resourcedetail/UserDetailVO.java @@ -48,6 +48,7 @@ public class UserDetailVO implements ResourceDetail { public static final String Setup2FADetail = "2FASetupStatus"; public static final String PasswordResetToken = "PasswordResetToken"; public static final String PasswordResetTokenExpiryDate = "PasswordResetTokenExpiryDate"; + public static final String PasswordResetTokenIssuedDate = "PasswordResetTokenIssuedDate"; public UserDetailVO() { } diff --git a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManager.java b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManager.java index ca14f6a1654e..a0a31f53223c 100644 --- a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManager.java +++ b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManager.java @@ -33,6 +33,12 @@ public interface UserPasswordResetManager { "TTL in minutes for the token generated to reset the ACS user's password", true, ConfigKey.Scope.Global); + ConfigKey UserPasswordResetEmailCooldown = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED, Long.class, + "user.password.reset.email.cooldown", "300", + "Minimum time in seconds between password reset emails for the same user. " + + "Requests within this time send no email. Set to 0 to disable.", true, + ConfigKey.Scope.Global); + ConfigKey UserPasswordResetEmailSender = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED, String.class, "user.password.reset.email.sender", null, "Sender for emails sent to the user to reset ACS user's password ", true, diff --git a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java index c62bca8eca4b..5f77c69a89c2 100644 --- a/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/user/UserPasswordResetManagerImpl.java @@ -52,6 +52,7 @@ import static org.apache.cloudstack.config.ApiServiceConfiguration.ManagementServerAddresses; import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetToken; import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenExpiryDate; +import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenIssuedDate; public class UserPasswordResetManagerImpl extends ManagerBase implements UserPasswordResetManager, Configurable { @@ -90,6 +91,7 @@ public ConfigKey[] getConfigKeys() { return new ConfigKey[]{ UserPasswordResetEnabled, UserPasswordResetTtl, + UserPasswordResetEmailCooldown, UserPasswordResetEmailSender, UserPasswordResetSMTPHost, UserPasswordResetSMTPPort, @@ -136,24 +138,39 @@ private String getKey(String namespace, String config) { } - protected boolean validateExistingToken(UserAccount userAccount) { - - Map details = userDetailsDao.listDetailsKeyPairs(userAccount.getId()); - - String resetToken = details.get(PasswordResetToken); - String resetTokenExpiryTimeString = details.getOrDefault(PasswordResetTokenExpiryDate, "0"); + /** + * Removes every row for the detail before writing it. addDetail only replaces one row, so + * duplicates left by parallel requests would otherwise persist and break every later link. + */ + private void replaceDetail(UserAccount userAccount, String name, String value) { + userDetailsDao.removeDetail(userAccount.getId(), name); + userDetailsDao.addDetail(userAccount.getId(), name, value, false); + } + protected long getEmailCooldownSeconds() { + return UserPasswordResetEmailCooldown.value(); + } - if (StringUtils.isNotEmpty(resetToken) && StringUtils.isNotEmpty(resetTokenExpiryTimeString)) { - final Date resetTokenExpiryTime = new Date(Long.parseLong(resetTokenExpiryTimeString)); - final Date currentTime = new Date(); - if (currentTime.after(resetTokenExpiryTime)) { - return true; - } - } else if (StringUtils.isEmpty(resetToken)) { - return true; + protected boolean isWithinEmailCooldown(UserAccount userAccount) { + long cooldown = getEmailCooldownSeconds(); + if (cooldown <= 0) { + return false; + } + UserDetailVO issuedDate = userDetailsDao.findDetail(userAccount.getId(), PasswordResetTokenIssuedDate); + if (issuedDate == null || StringUtils.isEmpty(issuedDate.getValue())) { + return false; + } + long issuedAt; + try { + issuedAt = Long.parseLong(issuedDate.getValue()); + } catch (NumberFormatException e) { + logger.warn("Ignoring invalid password reset issue time [{}] for user {}", issuedDate.getValue(), userAccount); + return false; } - return false; + // A negative value means the issue time is in the future, from clock skew between + // management servers. Ignore it so the user is not blocked. + long elapsed = System.currentTimeMillis() - issuedAt; + return elapsed >= 0 && elapsed / 1000 < cooldown; } public void setResetTokenAndSend(UserAccount userAccount) { @@ -163,20 +180,27 @@ public void setResetTokenAndSend(UserAccount userAccount) { "Failed to reset token and send email. SMTP mail sender is not configured"); } - if (!validateExistingToken(userAccount)) { - logger.debug(String.format( - "Failed to reset token and send email. Password reset token is already set for user %s in " + - "domain id: %s with account %s and email %s", - userAccount.getUsername(), userAccount.getDomainId(), - userAccount.getAccountName(), userAccount.getEmail())); + if (isWithinEmailCooldown(userAccount)) { + logger.info("Password reset email not sent for user {} account id: {} domain id: {}. " + + "A reset was requested less than {} seconds ago.", + userAccount, userAccount.getAccountId(), userAccount.getDomainId(), + getEmailCooldownSeconds()); return; } - final String resetToken = UUID.randomUUID().toString(); - final Date resetTokenExpiryTime = new Date(System.currentTimeMillis() + UserPasswordResetTtl.value() * 60 * 1000); + // Claim the cooldown before sending, so a request arriving during the send is caught. + // Truly parallel requests can still both pass the check; the cooldown is best effort. + final long now = System.currentTimeMillis(); + replaceDetail(userAccount, PasswordResetTokenIssuedDate, String.valueOf(now)); + + // The claim is kept even if the send fails, so a mail outage cannot be used to + // bypass the cooldown. The previous link still works because no new token is saved. + issueTokenAndSend(userAccount, now); + } - userDetailsDao.addDetail(userAccount.getId(), PasswordResetToken, resetToken, false); - userDetailsDao.addDetail(userAccount.getId(), PasswordResetTokenExpiryDate, String.valueOf(resetTokenExpiryTime.getTime()), false); + private void issueTokenAndSend(UserAccount userAccount, long now) { + final String resetToken = UUID.randomUUID().toString(); + final Date resetTokenExpiryTime = new Date(now + UserPasswordResetTtl.value() * 60 * 1000); final String email = userAccount.getEmail(); final String username = userAccount.getUsername(); @@ -216,7 +240,17 @@ public void setResetTokenAndSend(UserAccount userAccount) { mailProperties.setRecipients(addresses); - mailSender.sendMail(mailProperties); + // Save the token only once the email is sent. If the send fails, the previous + // link keeps working and the user can retry without waiting for the cooldown. + if (!mailSender.trySendMail(mailProperties)) { + logger.error("Failed to send password reset email for user {} account id: {} domain id: {}. The existing token was kept.", + userAccount, userAccount.getAccountId(), userAccount.getDomainId()); + return; + } + + // The new token replaces any existing one, so only the latest email's link works. + replaceDetail(userAccount, PasswordResetToken, resetToken); + replaceDetail(userAccount, PasswordResetTokenExpiryDate, String.valueOf(resetTokenExpiryTime.getTime())); logger.debug("User password reset email for user {} account id: {} domain id: {} sent to {} with token expiry at {}", userAccount, userAccount.getAccountId(), userAccount.getDomainId(), email, resetTokenExpiryTime); } @@ -265,6 +299,7 @@ void resetPassword(UserAccount userAccount, String password) { userDetailsDao.removeDetail(userAccount.getId(), PasswordResetToken); userDetailsDao.removeDetail(userAccount.getId(), PasswordResetTokenExpiryDate); + userDetailsDao.removeDetail(userAccount.getId(), PasswordResetTokenIssuedDate); userDao.persist(user); } diff --git a/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java index 17092e6311dd..92f81bead7d4 100644 --- a/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/user/UserPasswordResetManagerImplTest.java @@ -16,25 +16,28 @@ // under the License. package org.apache.cloudstack.user; +import com.cloud.user.AccountManager; import com.cloud.user.UserAccount; +import com.cloud.user.UserVO; +import com.cloud.user.dao.UserDao; import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.resourcedetail.UserDetailVO; import org.apache.cloudstack.resourcedetail.dao.UserDetailsDao; +import org.apache.cloudstack.utils.mailing.SMTPMailSender; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.InOrder; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; -import java.util.Collections; -import java.util.Map; - import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetToken; import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenExpiryDate; +import static org.apache.cloudstack.resourcedetail.UserDetailVO.PasswordResetTokenIssuedDate; @RunWith(MockitoJUnitRunner.class) public class UserPasswordResetManagerImplTest { @@ -45,6 +48,15 @@ public class UserPasswordResetManagerImplTest { @Mock private UserDetailsDao userDetailsDao; + @Mock + private SMTPMailSender mailSender; + + @Mock + private UserDao userDao; + + @Mock + private AccountManager accountManager; + @Test public void testGetMessageBody() { ConfigKey passwordResetMailTemplate = Mockito.mock(ConfigKey.class); @@ -116,35 +128,145 @@ public void testValidateAndResetPassword() { Mockito.verify(passwordReset, Mockito.times(1)).resetPassword(userAccount, "new_password"); } - @Test - public void testValidateExistingTokenFirstRequest() { + private UserAccount mockUser() { UserAccount userAccount = Mockito.mock(UserAccount.class); Mockito.when(userAccount.getId()).thenReturn(1L); - Mockito.when(userDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Collections.emptyMap()); + return userAccount; + } - Assert.assertTrue(passwordReset.validateExistingToken(userAccount)); + private void mockIssuedDate(String value) { + UserDetailVO issuedDate = Mockito.mock(UserDetailVO.class); + Mockito.when(issuedDate.getValue()).thenReturn(value); + Mockito.when(userDetailsDao.findDetail(1L, PasswordResetTokenIssuedDate)).thenReturn(issuedDate); } @Test - public void testValidateExistingTokenSecondRequestExpired() { + public void testIsWithinEmailCooldownFirstRequest() { + UserAccount userAccount = mockUser(); + Mockito.doReturn(60L).when(passwordReset).getEmailCooldownSeconds(); + Mockito.when(userDetailsDao.findDetail(1L, PasswordResetTokenIssuedDate)).thenReturn(null); + + Assert.assertFalse(passwordReset.isWithinEmailCooldown(userAccount)); + } + + @Test + public void testIsWithinEmailCooldownRecentRequest() { + UserAccount userAccount = mockUser(); + Mockito.doReturn(60L).when(passwordReset).getEmailCooldownSeconds(); + mockIssuedDate(String.valueOf(System.currentTimeMillis() - 10 * 1000)); + + Assert.assertTrue(passwordReset.isWithinEmailCooldown(userAccount)); + } + + @Test + public void testIsWithinEmailCooldownOldRequest() { + UserAccount userAccount = mockUser(); + Mockito.doReturn(60L).when(passwordReset).getEmailCooldownSeconds(); + mockIssuedDate(String.valueOf(System.currentTimeMillis() - 5 * 60 * 1000)); + + Assert.assertFalse(passwordReset.isWithinEmailCooldown(userAccount)); + } + + @Test + public void testIsWithinEmailCooldownDisabled() { UserAccount userAccount = Mockito.mock(UserAccount.class); - Mockito.when(userAccount.getId()).thenReturn(1L); - Mockito.when(userDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Map.of( - PasswordResetToken, "reset_token", - PasswordResetTokenExpiryDate, String.valueOf(System.currentTimeMillis() - 5 * 60 * 1000))); + Mockito.doReturn(0L).when(passwordReset).getEmailCooldownSeconds(); - Assert.assertTrue(passwordReset.validateExistingToken(userAccount)); + Assert.assertFalse(passwordReset.isWithinEmailCooldown(userAccount)); + Mockito.verify(userDetailsDao, Mockito.never()).findDetail(Mockito.anyLong(), Mockito.anyString()); } + @Test + public void testIsWithinEmailCooldownIssueTimeInFuture() { + UserAccount userAccount = mockUser(); + Mockito.doReturn(60L).when(passwordReset).getEmailCooldownSeconds(); + mockIssuedDate(String.valueOf(System.currentTimeMillis() + 2 * 60 * 1000)); + + Assert.assertFalse(passwordReset.isWithinEmailCooldown(userAccount)); + } @Test - public void testValidateExistingTokenSecondRequestUnexpired() { + public void testIsWithinEmailCooldownLargeValue() { + UserAccount userAccount = mockUser(); + Mockito.doReturn(Long.MAX_VALUE / 100).when(passwordReset).getEmailCooldownSeconds(); + mockIssuedDate(String.valueOf(System.currentTimeMillis() - 10 * 1000)); + + Assert.assertTrue(passwordReset.isWithinEmailCooldown(userAccount)); + } + + @Test + public void testIsWithinEmailCooldownInvalidIssueTime() { + UserAccount userAccount = mockUser(); + Mockito.doReturn(60L).when(passwordReset).getEmailCooldownSeconds(); + mockIssuedDate("not-a-number"); + + Assert.assertFalse(passwordReset.isWithinEmailCooldown(userAccount)); + } + + @Test + public void testSetResetTokenAndSendWithinCooldown() { UserAccount userAccount = Mockito.mock(UserAccount.class); - Mockito.when(userAccount.getId()).thenReturn(1L); - Mockito.when(userDetailsDao.listDetailsKeyPairs(1L)).thenReturn(Map.of( - PasswordResetToken, "reset_token", - PasswordResetTokenExpiryDate, String.valueOf(System.currentTimeMillis() + 5 * 60 * 1000))); + Mockito.doReturn(true).when(passwordReset).isWithinEmailCooldown(userAccount); + + passwordReset.setResetTokenAndSend(userAccount); + + Mockito.verify(userDetailsDao, Mockito.never()).addDetail(Mockito.anyLong(), Mockito.anyString(), Mockito.anyString(), Mockito.anyBoolean()); + Mockito.verify(mailSender, Mockito.never()).trySendMail(Mockito.any()); + } + + private UserAccount mockUserForSend() { + UserAccount userAccount = mockUser(); + Mockito.when(userAccount.getUsername()).thenReturn("test_user"); + Mockito.when(userAccount.getEmail()).thenReturn("user@example.com"); + Mockito.doReturn(false).when(passwordReset).isWithinEmailCooldown(userAccount); + Mockito.doReturn("body").when(passwordReset).getMessageBody(Mockito.eq(userAccount), Mockito.anyString(), Mockito.anyString()); + return userAccount; + } + + @Test + public void testSetResetTokenAndSendIssuesNewToken() { + UserAccount userAccount = mockUserForSend(); + Mockito.when(mailSender.trySendMail(Mockito.any())).thenReturn(true); + + passwordReset.setResetTokenAndSend(userAccount); + + // Each detail has all existing rows removed before the new row is written, + // so duplicates from parallel requests do not survive the next request. + InOrder order = Mockito.inOrder(userDetailsDao); + order.verify(userDetailsDao).removeDetail(1L, PasswordResetTokenIssuedDate); + order.verify(userDetailsDao).addDetail(Mockito.eq(1L), Mockito.eq(PasswordResetTokenIssuedDate), Mockito.anyString(), Mockito.eq(false)); + order.verify(userDetailsDao).removeDetail(1L, PasswordResetToken); + order.verify(userDetailsDao).addDetail(Mockito.eq(1L), Mockito.eq(PasswordResetToken), Mockito.anyString(), Mockito.eq(false)); + order.verify(userDetailsDao).removeDetail(1L, PasswordResetTokenExpiryDate); + order.verify(userDetailsDao).addDetail(Mockito.eq(1L), Mockito.eq(PasswordResetTokenExpiryDate), Mockito.anyString(), Mockito.eq(false)); + Mockito.verify(userDetailsDao, Mockito.times(1)).removeDetail(1L, PasswordResetTokenIssuedDate); + } + + @Test + public void testSetResetTokenAndSendFailureKeepsExistingTokenAndCooldown() { + UserAccount userAccount = mockUserForSend(); + Mockito.when(mailSender.trySendMail(Mockito.any())).thenReturn(false); + + passwordReset.setResetTokenAndSend(userAccount); + + Mockito.verify(userDetailsDao, Mockito.never()).addDetail(Mockito.eq(1L), Mockito.eq(PasswordResetToken), Mockito.anyString(), Mockito.anyBoolean()); + Mockito.verify(userDetailsDao, Mockito.never()).addDetail(Mockito.eq(1L), Mockito.eq(PasswordResetTokenExpiryDate), Mockito.anyString(), Mockito.anyBoolean()); + Mockito.verify(userDetailsDao, Mockito.never()).removeDetail(1L, PasswordResetToken); + Mockito.verify(userDetailsDao).addDetail(Mockito.eq(1L), Mockito.eq(PasswordResetTokenIssuedDate), Mockito.anyString(), Mockito.eq(false)); + // Removed once while claiming the cooldown, and not released after the failure. + Mockito.verify(userDetailsDao, Mockito.times(1)).removeDetail(1L, PasswordResetTokenIssuedDate); + } + + @Test + public void testResetPasswordRemovesResetDetails() { + UserAccount userAccount = mockUser(); + UserVO user = Mockito.mock(UserVO.class); + Mockito.when(userDao.getUser(1L)).thenReturn(user); + + passwordReset.resetPassword(userAccount, "new_password"); - Assert.assertFalse(passwordReset.validateExistingToken(userAccount)); + Mockito.verify(userDetailsDao).removeDetail(1L, PasswordResetToken); + Mockito.verify(userDetailsDao).removeDetail(1L, PasswordResetTokenExpiryDate); + Mockito.verify(userDetailsDao).removeDetail(1L, PasswordResetTokenIssuedDate); } } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 4f06153320b5..f549acd5d6b5 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -3556,7 +3556,7 @@ "message.failed.to.add": "Failed to add", "message.failed.to.assign.vms": "Failed to assign Instances", "message.failed.to.remove": "Failed to remove", -"message.forgot.password.success": "An email has been sent to your email address with instructions on how to reset your password.", +"message.forgot.password.success": "If the account exists and has an email address, reset instructions have been sent. Check your spam folder, and wait a few minutes before requesting another email.", "message.generate.keys": "Please confirm that you would like to generate new API/Secret keys for this User.", "message.chart.statistic.info": "The shown charts are self-adjustable, that means, if the value gets close to the limit or overpass it, it will grow to adjust the shown value", "message.chart.statistic.info.hypervisor.additionals": "The metrics data depend on the hypervisor plugin used for each hypervisor. The behavior can vary across different hypervisors. For instance, with KVM, metrics are real-time statistics provided by libvirt. In contrast, with VMware, the metrics are averaged data for a given time interval controlled by configuration.", diff --git a/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java b/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java index b354772fde08..d30ed82796b9 100644 --- a/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java +++ b/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java @@ -156,9 +156,16 @@ protected SMTPSessionProperties configureSessionProperties() { } public void sendMail(SMTPMailProperties mailProps) { + trySendMail(mailProps); + } + + /** + * Sends the mail and reports whether the SMTP server accepted it. Failures are logged. + */ + public boolean trySendMail(SMTPMailProperties mailProps) { if (session == null) { logger.error("Unable to send mail due to null session."); - return; + return false; } try { @@ -167,7 +174,7 @@ public void sendMail(SMTPMailProperties mailProps) { if (ArrayUtils.isEmpty(message.getAllRecipients())) { logger.error(String.format("Unable to send mail [%s] due to the recipient list of the message being empty.", mailProps.getSubject())); - return; + return false; } SMTPTransport smtpTrans = createSmtpTransport(); @@ -175,9 +182,11 @@ public void sendMail(SMTPMailProperties mailProps) { smtpTrans.connect(); smtpTrans.sendMessage(message, message.getAllRecipients()); smtpTrans.close(); + return true; } catch (MessagingException | UnsupportedEncodingException ex) { logger.error(String.format("Unable to send mail [%s] to the recipcients [%s].", mailProps.getSubject(), mailProps.getRecipients().toString()), ex); + return false; } } diff --git a/utils/src/test/java/org/apache/cloudstack/utils/mailing/SMTPMailSenderTest.java b/utils/src/test/java/org/apache/cloudstack/utils/mailing/SMTPMailSenderTest.java index 7b5fbc26af4f..a7e6c4029a66 100644 --- a/utils/src/test/java/org/apache/cloudstack/utils/mailing/SMTPMailSenderTest.java +++ b/utils/src/test/java/org/apache/cloudstack/utils/mailing/SMTPMailSenderTest.java @@ -615,6 +615,23 @@ public void validateSMTPMailSenderSendMailWithNullSession() { Mockito.verify(smtpMailSenderMock.logger).error(Mockito.anyString()); } + @Test + public void validateSMTPMailSenderTrySendMailWithNullSession() { + assertFalse(smtpMailSenderMock.trySendMail(new SMTPMailProperties())); + } + + @Test + public void validateSMTPMailSenderTrySendMailWithValidSession() throws MessagingException, UnsupportedEncodingException { + smtpMailSenderMock.session = Session.getDefaultInstance(Mockito.mock(Properties.class)); + Address[] recipients = {new InternetAddress("email@acme.com")}; + + Mockito.doReturn(recipients).when(messageMock).getAllRecipients(); + Mockito.doReturn(messageMock).when(smtpMailSenderMock).createMessage(Mockito.any()); + Mockito.doReturn(Mockito.mock(SMTPTransport.class)).when(smtpMailSenderMock).createSmtpTransport(); + + assertTrue(smtpMailSenderMock.trySendMail(new SMTPMailProperties())); + } + @Test public void validateSMTPMailSenderSendMailWithValidSession() throws MessagingException, UnsupportedEncodingException { SMTPMailProperties mailProps = new SMTPMailProperties();