diff --git a/.gitignore b/.gitignore index 90143f669..e20008c97 100644 --- a/.gitignore +++ b/.gitignore @@ -224,7 +224,7 @@ pom.xml.releaseBackup pom.xml.versionsBackup pom.xml.next release.properties - +dependency-reduced-pom.xml ### Gradle ### .gradle diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/mail/MovingMessage.java b/greenmail-core/src/main/java/com/icegreen/greenmail/mail/MovingMessage.java index 87f375ac5..dca7e200e 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/mail/MovingMessage.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/mail/MovingMessage.java @@ -10,39 +10,110 @@ import java.util.LinkedList; import java.util.List; +import com.icegreen.greenmail.smtp.auth.AuthenticationState; +import com.icegreen.greenmail.smtp.auth.LoginAuthenticationState; +import com.icegreen.greenmail.smtp.auth.PlainAuthenticationState; + /** * Contains information for delivering a mime email. */ public class MovingMessage { + private AuthenticationState authenticationState; private MailAddress returnPath; private final List toAddresses = new LinkedList<>(); private MimeMessage message; + + /** + * Retrieves the state object with the data used for authentication. Currently + * {@link PlainAuthenticationState PLAIN} and {@link LoginAuthenticationState LOGIN} + * authentication is supported. You can use this, for example, to retrieve the username + * or password that was sent by the client. + * + * Note that this will return {@code null} when no authentication was performed or needed. + * + * @return The state used by the AUTH command, if any. + */ + public AuthenticationState getAuthenticationState() { + return authenticationState; + } + + /** + * Retrieves the state object with the data used for authentication. Currently + * {@link PlainAuthenticationState PLAIN} and {@link LoginAuthenticationState LOGIN} + * authentication is supported. You can use this, for example, to retrieve the username + * or password that was sent by the client. + * + * Note that this will return {@code null} when no authentication was performed or needed. + * + * @return The state used by the AUTH command, if any. + */ + public void setAuthenticationState(AuthenticationState authenticationState) { + this.authenticationState = authenticationState; + } + /** + * Retrieves the addresses from which the email was sent. Note that these are + * the {@code RCPT TO} addresses from the SMTP envelope, not the {@code TO} + * addresses from the mail header. + * @return The address to which the mail is directed. + */ public List getToAddresses() { return toAddresses; } + /** + * Retrieves the contents of the mail message, including all mail headers and the body. + * @return The message that was sent. + */ public MimeMessage getMessage() { return message; } + /** + * Retrieves the address from which the email was sent. Note that this is the + * {@code MAIL FROM} address from the SMTP envelope, not the {@code FROM} + * address(es) from the mail header. + * @return The address from which the email was sent. + */ public MailAddress getReturnPath() { return returnPath; } + /** + * Sets or overwrites the address from which the email was sent. Note that this is + * the {@code MAIL FROM} address from the SMTP envelope, not the {@code FROM} + * address(es) from the mail header. + * @param fromAddress The address from which the email was sent. + */ public void setReturnPath(MailAddress fromAddress) { this.returnPath = fromAddress; } + /** + * Adds an address from which the email was sent. Note that these are the {@code RCPT TO} + * addresses from the SMTP envelope, not the {@code TO} addresses from the mail header. + * @return The address to which the mail is directed. + */ public void addRecipient(MailAddress s) { toAddresses.add(s); } + /** + * Removes an address from the list of addresses from which the email was sent. Note + * that these are the {@code RCPT TO} addresses from the SMTP envelope, not the {@code TO} + * addresses from the mail header. + * @return An address to remove form the addresses to which the mail is directed. + */ public void removeRecipient(MailAddress s) { toAddresses.remove(s); } + /** + * Sets or overwrites the contents of the mail message, including all mail headers + * and the body. + * @param message The message that was sent. + */ public void setMimeMessage(MimeMessage message) { this.message = message; } diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/SmtpState.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/SmtpState.java index d0f9a06a6..6a6c63da9 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/SmtpState.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/SmtpState.java @@ -7,6 +7,7 @@ package com.icegreen.greenmail.smtp; import com.icegreen.greenmail.mail.MovingMessage; +import com.icegreen.greenmail.smtp.auth.AuthenticationState; public class SmtpState { MovingMessage currentMessage; @@ -25,4 +26,14 @@ public MovingMessage getMessage() { public void clearMessage() { currentMessage = new MovingMessage(); } + + /** + * To destroy a half-constructed message, but preserves the + * {@link MovingMessage#getAuthenticationState()}. + */ + public void clearMessagePreservingAuthenticationState() { + AuthenticationState authState = currentMessage != null ? currentMessage.getAuthenticationState() : null; + currentMessage = new MovingMessage(); + currentMessage.setAuthenticationState(authState); + } } \ No newline at end of file diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/AuthenticationState.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/AuthenticationState.java new file mode 100644 index 000000000..1557a6d22 --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/AuthenticationState.java @@ -0,0 +1,14 @@ +package com.icegreen.greenmail.smtp.auth; + +/** + * Base interface for the state used by various authentication methods. The data contained + * in the state depends on the authentication method that was used. The state can be + * mutable, such as when the authentication method requires multiple exchanges between the + * client and the server. + */ +public interface AuthenticationState { + /** + * @return The type of the used authentication mechanism, e.g. {@code PLAIN} or {@code LOGIN}. + */ + String getType(); +} diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/LoginAuthenticationState.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/LoginAuthenticationState.java new file mode 100644 index 000000000..06b4e7ba9 --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/LoginAuthenticationState.java @@ -0,0 +1,45 @@ +package com.icegreen.greenmail.smtp.auth; + +import com.icegreen.greenmail.smtp.commands.AuthCommand.AuthMechanism; + +/** + * Details from the {@link AuthMechanism#LOGIN} authorization mechanism, when + * that mechanism was used for authentication. + */ +public class LoginAuthenticationState implements AuthenticationState, UsernameAuthentication { + private final String username; + + private final String password; + + /** + * @param username The username from the AUTH command. + * @param password The password from the AUTH command. + */ + public LoginAuthenticationState(String username, String password) { + this.username = username; + this.password = password; + } + + @Override + public String getType() { + return AuthMechanism.LOGIN.name(); + } + + /** + * Retrieves the username that was used for {@code PLAIN} or {@code LOGIN} authentication. + * Note that this will return {@code null} when no authentication was performed or needed. + * @return The username from the AUTH command. + */ + public String getUsername() { + return username; + } + + /** + * Retrieves the password that was used for {@code PLAIN} or {@code LOGIN} authentication. + * Note that this will return {@code null} when no authentication was performed or needed. + * @return The password from the AUTH command. + */ + public String getPassword() { + return password; + } +} diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/PlainAuthenticationState.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/PlainAuthenticationState.java new file mode 100644 index 000000000..d971e1c1a --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/PlainAuthenticationState.java @@ -0,0 +1,63 @@ +package com.icegreen.greenmail.smtp.auth; + +import com.icegreen.greenmail.smtp.commands.AuthCommand.AuthMechanism; +import com.icegreen.greenmail.util.SaslMessage; + +/** + * Details from the {@link AuthMechanism#PLAIN} authorization mechanism, when + * that mechanism was used for authentication. + */ +public class PlainAuthenticationState implements AuthenticationState, UsernameAuthentication { + private final String authorizationId; + private final String authenticationId; + private final String password; + + /** + * @param saslMessage The parsed message sent by the client with the {@code AUTH} command. + */ + public PlainAuthenticationState(SaslMessage saslMessage) { + this(saslMessage.getAuthzid(), saslMessage.getAuthcid(), saslMessage.getPasswd()); + } + + @Override + public String getType() { + return AuthMechanism.PLAIN.name(); + } + + /** + * @param authorizationId The authorization ID sent by the client with the {@code AUTH} command. + * @param authenticationId The authentication ID sent by the client with the {@code AUTH} command. + * @param password The password sent by the client with the {@code AUTH} command. + */ + public PlainAuthenticationState(String authorizationId, String authenticationId, String password) { + this.authorizationId = authorizationId; + this.authenticationId = authenticationId; + this.password = password; + } + + /** + * @return The authorization ID sent by the client with the {@code AUTH} command. + */ + public String getAuthorizationId() { + return authorizationId; + } + + /** + * @return The authentication ID sent by the client with the {@code AUTH} command. + */ + public String getAuthenticationId() { + return authenticationId; + } + + /** + * @return password The password sent by the client with the {@code AUTH} command. + */ + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return authenticationId; + } +} diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/UsernameAuthentication.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/UsernameAuthentication.java new file mode 100644 index 000000000..182ff16c1 --- /dev/null +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/auth/UsernameAuthentication.java @@ -0,0 +1,10 @@ +package com.icegreen.greenmail.smtp.auth; + +/** + * Base interface for authentication methods that provide or make use of + * a plain text username to identify a user, such as the {@code PLAIN} or + * {@code LOGIN} authentication mechanisms. + */ +public interface UsernameAuthentication { + String getUsername(); +} diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/AuthCommand.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/AuthCommand.java index 4a067b793..043002787 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/AuthCommand.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/AuthCommand.java @@ -9,6 +9,9 @@ import com.icegreen.greenmail.smtp.SmtpConnection; import com.icegreen.greenmail.smtp.SmtpManager; import com.icegreen.greenmail.smtp.SmtpState; +import com.icegreen.greenmail.smtp.auth.AuthenticationState; +import com.icegreen.greenmail.smtp.auth.LoginAuthenticationState; +import com.icegreen.greenmail.smtp.auth.PlainAuthenticationState; import com.icegreen.greenmail.user.UserManager; import com.icegreen.greenmail.util.EncodingUtil; import com.icegreen.greenmail.util.SaslMessage; @@ -62,16 +65,16 @@ public void execute(SmtpConnection conn, SmtpState state, // Check auth mechanism final String authMechanismValue = commandParts[1]; if (AuthMechanism.LOGIN.name().equalsIgnoreCase(authMechanismValue)) { - authLogin(conn, manager, commandLine, commandParts, authMechanismValue); + authLogin(conn, state, manager, commandLine, commandParts, authMechanismValue); } else if (AuthMechanism.PLAIN.name().equalsIgnoreCase(authMechanismValue)) { - authPlain(conn, manager, commandParts); + authPlain(conn, state, manager, commandParts); } else { conn.send(SMTP_SYNTAX_ERROR + " : Unsupported auth mechanism " + authMechanismValue + ". Only auth mechanism <" + Arrays.toString(AuthMechanism.values()) + "> supported."); } } - private void authPlain(SmtpConnection conn, SmtpManager manager, String[] commandParts) throws IOException { + private void authPlain(SmtpConnection conn, SmtpState state, SmtpManager manager, String[] commandParts) throws IOException { // Continuation? String initialResponse; if (commandParts.length == 2) { @@ -80,8 +83,12 @@ private void authPlain(SmtpConnection conn, SmtpManager manager, String[] comman } else { initialResponse = commandParts[2]; } + + SaslMessage saslMessage = parseInitialResponse(initialResponse); + AuthenticationState authenticationContext = new PlainAuthenticationState(saslMessage); + state.getMessage().setAuthenticationState(authenticationContext); - if (authenticate(manager.getUserManager(), EncodingUtil.decodeBase64(initialResponse))) { + if (authenticate(manager.getUserManager(), saslMessage)) { conn.setAuthenticated(true); conn.send(AUTH_SUCCEDED); } else { @@ -89,8 +96,8 @@ private void authPlain(SmtpConnection conn, SmtpManager manager, String[] comman } } - private void authLogin(SmtpConnection conn, SmtpManager manager, String commandLine, String[] commandParts, - String authMechanismValue) throws IOException { + private void authLogin(SmtpConnection conn, SmtpState state, SmtpManager manager, String commandLine, + String[] commandParts, String authMechanismValue) throws IOException { // https://www.samlogic.net/articles/smtp-commands-reference-auth.htm if (commandParts.length != 2) { conn.send(SMTP_SYNTAX_ERROR + " : Unsupported auth mechanism " + authMechanismValue + @@ -100,8 +107,12 @@ private void authLogin(SmtpConnection conn, SmtpManager manager, String commandL String username = conn.readLine(); conn.send(SMTP_SERVER_CONTINUATION + "UGFzc3dvcmQ6"); // "Password:" String pwd = conn.readLine(); + String plainUsername = EncodingUtil.decodeBase64(username); + String plainPwd = EncodingUtil.decodeBase64(pwd); + AuthenticationState authenticationContext = new LoginAuthenticationState(plainUsername, plainPwd); + state.getMessage().setAuthenticationState(authenticationContext); - if (manager.getUserManager().test(EncodingUtil.decodeBase64(username), EncodingUtil.decodeBase64(pwd))) { + if (manager.getUserManager().test(plainUsername, plainPwd)) { conn.setAuthenticated(true); conn.send(AUTH_SUCCEDED); } else { @@ -110,11 +121,15 @@ private void authLogin(SmtpConnection conn, SmtpManager manager, String commandL } } - private boolean authenticate(UserManager userManager, String value) { - // authorization-id\0authentication-id\0passwd - final SaslMessage saslMessage = SaslMessage.parse(value); + private boolean authenticate(UserManager userManager, SaslMessage saslMessage) { return userManager.test(saslMessage.getAuthcid(), saslMessage.getPasswd()); } + + private SaslMessage parseInitialResponse(String initialReponse) { + String value = EncodingUtil.decodeBase64(initialReponse); + // authorization-id\0authentication-id\0passwd + return SaslMessage.parse(value); + } private static String getValuesWsSeparated() { StringBuilder buf = new StringBuilder(); diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/MailCommand.java b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/MailCommand.java index 519149cc1..f26a04246 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/MailCommand.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/smtp/commands/MailCommand.java @@ -45,11 +45,11 @@ public void execute(SmtpConnection conn, SmtpState state, conn.send(err); return; } - state.clearMessage(); + state.clearMessagePreservingAuthenticationState(); state.getMessage().setReturnPath(fromAddr); conn.send("250 OK"); } else { - state.clearMessage(); + state.clearMessagePreservingAuthenticationState(); state.getMessage(); conn.send("250 OK"); } diff --git a/greenmail-core/src/main/java/com/icegreen/greenmail/util/GreenMailUtil.java b/greenmail-core/src/main/java/com/icegreen/greenmail/util/GreenMailUtil.java index dd5fa8426..a3ef9e340 100644 --- a/greenmail-core/src/main/java/com/icegreen/greenmail/util/GreenMailUtil.java +++ b/greenmail-core/src/main/java/com/icegreen/greenmail/util/GreenMailUtil.java @@ -266,6 +266,21 @@ public static void sendMimeMessage(MimeMessage mimeMessage) { } } + /** + * Send the message using the JavaMail session defined in the message + * + * @param mimeMessage Message to send + * @param username Username for authentication. + * @param password Password for authentication. + */ + public static void sendMimeMessage(MimeMessage mimeMessage, String username, String password) { + try { + Transport.send(mimeMessage, username, password); + } catch (MessagingException e) { + throw new IllegalStateException("Can not send message " + mimeMessage, e); + } + } + /** * Send the message with the given attributes and the given body using the specified SMTP settings * diff --git a/greenmail-core/src/test/java/com/icegreen/greenmail/examples/ExampleFindUserByAuthLoginTest.java b/greenmail-core/src/test/java/com/icegreen/greenmail/examples/ExampleFindUserByAuthLoginTest.java new file mode 100644 index 000000000..5d1d2c1ba --- /dev/null +++ b/greenmail-core/src/test/java/com/icegreen/greenmail/examples/ExampleFindUserByAuthLoginTest.java @@ -0,0 +1,87 @@ +package com.icegreen.greenmail.examples; + +import com.icegreen.greenmail.junit.GreenMailRule; +import com.icegreen.greenmail.mail.MailAddress; +import com.icegreen.greenmail.mail.MovingMessage; +import com.icegreen.greenmail.smtp.auth.AuthenticationState; +import com.icegreen.greenmail.smtp.auth.UsernameAuthentication; +import com.icegreen.greenmail.store.FolderException; +import com.icegreen.greenmail.store.MailFolder; +import com.icegreen.greenmail.user.GreenMailUser; +import com.icegreen.greenmail.user.MessageDeliveryHandler; +import com.icegreen.greenmail.user.NoSuchUserException; +import com.icegreen.greenmail.user.UserException; +import com.icegreen.greenmail.user.UserManager; +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.GreenMailUtil; +import com.icegreen.greenmail.util.ServerSetupTest; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +import jakarta.mail.Message.RecipientType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; + +/** + * By default, GreenMail delivers messages to the user based on the email address + * to which the mail was sent. + *

+ * This example illustrates how you can use a custom message delivery handler to deliver + * messages based on the login that was used to authenticate against the mail server. + */ +public class ExampleFindUserByAuthLoginTest { + public GreenMail greenMail; + + @Test + public void testSend() throws MessagingException, UserException, FolderException { + final UserManager userManager = greenMail.getUserManager(); + // Create a new user + userManager.createUser("from@localhost", "login", "pass"); + + // Set a message delivery handler that find the user and inbox by + // the login that was used. + userManager.setMessageDeliveryHandler(new MessageDeliveryHandler() { + @Override + public GreenMailUser handle(MovingMessage msg, MailAddress mailAddress) + throws MessagingException, UserException { + AuthenticationState authState = msg.getAuthenticationState(); + if (!(authState instanceof UsernameAuthentication)) { + throw new MessagingException("Authentication is required"); + } + String login = ((UsernameAuthentication)authState).getUsername(); + GreenMailUser user = userManager.getUser(login); + if (user == null) { + throw new NoSuchUserException("No user found for login " + login + ", make sure to create the user first"); + } + return user; + } + }); + + // Send a mail with an arbitrary FROM / TO address + MimeMessage message = GreenMailUtil.createTextEmail("john@example.com", "mary@example.com", + "some subject", "some body", ServerSetupTest.SMTP); // --- Place your sending code here instead + GreenMailUtil.sendMimeMessage(message, "login", "pass"); + + // Check that the mail was still sent to the user we created. + GreenMailUser user = greenMail.getUserManager().getUser("login"); + MailFolder inbox = greenMail.getManagers().getImapHostManager().getInbox(user); + assertThat(inbox.getMessages().get(0).getMimeMessage().getSubject()).isEqualTo("some subject"); + assertThat(inbox.getMessages().get(0).getMimeMessage().getRecipients(RecipientType.TO)[0].toString()).isEqualTo("john@example.com"); + assertThat(inbox.getMessages().get(0).getMimeMessage().getFrom()[0].toString()).isEqualTo("mary@example.com"); + } + + @Before + public void setupMail() { + greenMail = new GreenMail(ServerSetupTest.SMTP); + greenMail.start(); + } + + @After + public void tearDownMail() { + greenMail.stop(); + } +}