diff --git a/blaze-postoffice/pom.xml b/blaze-postoffice/pom.xml index 9583094d..9f3604c8 100644 --- a/blaze-postoffice/pom.xml +++ b/blaze-postoffice/pom.xml @@ -30,6 +30,12 @@ + + com.icegreen + greenmail-standalone + 2.0.1 + + com.fizzed crux-util diff --git a/blaze-postoffice/src/main/java/com/fizzed/blaze/PostOffices.java b/blaze-postoffice/src/main/java/com/fizzed/blaze/PostOffices.java index 796d597b..46a9842f 100644 --- a/blaze-postoffice/src/main/java/com/fizzed/blaze/PostOffices.java +++ b/blaze-postoffice/src/main/java/com/fizzed/blaze/PostOffices.java @@ -1,5 +1,11 @@ package com.fizzed.blaze; +import com.fizzed.blaze.postoffice.Mail; + public class PostOffices { + static public Mail mail() { + return new Mail(Contexts.currentContext()); + } + } \ No newline at end of file diff --git a/blaze-postoffice/src/main/java/com/fizzed/blaze/postoffice/Mail.java b/blaze-postoffice/src/main/java/com/fizzed/blaze/postoffice/Mail.java index 2906539a..674ed876 100644 --- a/blaze-postoffice/src/main/java/com/fizzed/blaze/postoffice/Mail.java +++ b/blaze-postoffice/src/main/java/com/fizzed/blaze/postoffice/Mail.java @@ -1,28 +1,30 @@ package com.fizzed.blaze.postoffice; +import com.fizzed.blaze.Config; import com.fizzed.blaze.Context; import com.fizzed.blaze.core.Action; import com.fizzed.blaze.core.BlazeException; import com.fizzed.blaze.core.VerbosityMixin; -import com.fizzed.blaze.internal.IntRangeHelper; import com.fizzed.blaze.util.*; -import okhttp3.*; -import org.apache.commons.io.IOUtils; -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.nio.file.Path; -import java.nio.file.Paths; +import jakarta.mail.*; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeBodyPart; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; + import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import static java.util.Arrays.asList; public class Mail extends Action implements VerbosityMixin { + static private final Map SESSIONS = new ConcurrentHashMap<>(); + static public class Result extends com.fizzed.blaze.core.Result { Result(Mail action, Integer value) { @@ -32,21 +34,307 @@ static public class Result extends com.fizzed.blaze.core.Result to; + private List cc; + private List bcc; + private String subject; + private String textBody; + private String htmlBody; public Mail(Context context) { super(context); this.log = new VerboseLogger(this); + + // initialize the host, port, etc. from config + final Config config = context.config(); + this.smtpHost = config.value("postoffice.smtp.host").orNull(); + this.smtpPort = config.value("postoffice.smtp.port", Integer.class).orNull(); + this.smtpAuth = config.value("postoffice.smtp.auth", Boolean.class).orNull(); + this.smtpStartTls = config.value("postoffice.smtp.start_tls", Boolean.class).orNull(); + this.smtpSsl = config.value("postoffice.smtp.ssl", Boolean.class).orNull(); + this.smtpSslInsecure = config.value("postoffice.smtp.ssl_insecure", Boolean.class).orNull(); + this.smtpUsername = config.value("postoffice.smtp.username").orNull(); + this.smtpPassword = config.value("postoffice.smtp.password").orNull(); } public VerboseLogger getVerboseLogger() { return this.log; } + public Mail smtpHost(String smtpHost) { + this.smtpHost = smtpHost; + return this; + } + + public Mail smtpPort(Integer smtpPort) { + this.smtpPort = smtpPort; + return this; + } + + public Mail smtpAuth(Boolean smtpAuth) { + this.smtpAuth = smtpAuth; + return this; + } + + public Mail smtpStartTls(Boolean smtpStartTls) { + this.smtpStartTls = smtpStartTls; + return this; + } + + public Mail smtpSsl(Boolean smtpSsl) { + this.smtpSsl = smtpSsl; + return this; + } + + public Mail smtpSslInsecure(Boolean smtpSslInsecure) { + this.smtpSslInsecure = smtpSslInsecure; + return this; + } + + public Mail smtpUsername(String smtpUsername) { + this.smtpUsername = smtpUsername; + return this; + } + + public Mail smtpPassword(String smtpPassword) { + this.smtpPassword = smtpPassword; + return this; + } + + public Mail from(String from) { + this.from = from; + return this; + } + + public Mail to(String... to) { + return this.to(asList(to)); + } + + public Mail to(List to) { + this.to = to; + return this; + } + + public Mail addTo(String to) { + if (this.to == null) { + this.to = new ArrayList<>(); + } + this.to.add(to); + return this; + } + + public Mail cc(String... cc) { + return this.cc(asList(cc)); + } + + public Mail cc(List cc) { + this.cc = cc; + return this; + } + + public Mail addCc(String cc) { + if (this.cc == null) { + this.cc = new ArrayList<>(); + } + this.cc.add(cc); + return this; + } + + public Mail bcc(String... bcc) { + return this.bcc(asList(bcc)); + } + + public Mail bcc(List bcc) { + this.bcc = bcc; + return this; + } + + public Mail addBcc(String bcc) { + if (this.bcc == null) { + this.bcc = new ArrayList<>(); + } + this.bcc.add(bcc); + return this; + } + + public Mail subject(String subject) { + this.subject = subject; + return this; + } + + public Mail textBody(String textBody) { + this.textBody = textBody; + return this; + } + + public Mail htmlBody(String htmlBody) { + this.htmlBody = htmlBody; + return this; + } + @Override protected Result doRun() throws BlazeException { + final Properties properties = new Properties(); + if (this.smtpHost != null) { + properties.put("mail.smtp.host", this.smtpHost); + } + if (this.smtpPort != null) { + properties.put("mail.smtp.port", this.smtpPort); + } + if (this.smtpAuth != null) { + properties.put("mail.smtp.auth", this.smtpAuth); + } + if (this.smtpStartTls != null) { + properties.put("mail.smtp.starttls.enable", this.smtpStartTls); + } + if (this.smtpSsl != null) { + properties.put("mail.smtp.ssl.enable", this.smtpSsl); + } + if (this.smtpSslInsecure != null) { + // if insecure set we do NOT want to check the server identity + properties.put("mail.smtp.ssl.checkserveridentity", !this.smtpSslInsecure); + } + + // why on earth are these "infinite" by default? + properties.put("mail.smtp.connectiontimeout", 60000); + properties.put("mail.smtp.timeout", 60000); + properties.put("mail.smtp.writetimeout", 60000); + + final Timer timer = new Timer(); + log.verbose("Sending mail: to={}, subject={}", this.to, this.subject); + + // get or create new session + final String sessionIdentifier = this.buildSessionIdentifier(properties, this.smtpUsername, this.smtpPassword); + Session session = SESSIONS.get(sessionIdentifier); + if (session == null) { + if (this.smtpUsername != null && this.smtpPassword != null) { + session = Session.getInstance(properties, new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(smtpUsername, smtpPassword); + } + }); + } else { + session = Session.getInstance(properties); + } + SESSIONS.put(sessionIdentifier, session); + log.verbose("Using new mail session: {}", session); + } else { + log.verbose("Using cached mail session: {}", session); + } + + if (log.isDebug()) { + session.setDebug(true); + } + + final Message message = new MimeMessage(session); + + if (this.from != null) { + try { + message.setFrom(new InternetAddress(this.from)); + } catch (MessagingException e) { + throw new BlazeException("Failed setting FROM address from value '" + this.from + "': " + e.getMessage(), e); + } + } + + this.setRecipients(this.to, message, Message.RecipientType.TO); + this.setRecipients(this.cc, message, Message.RecipientType.CC); + this.setRecipients(this.bcc, message, Message.RecipientType.BCC); + + try { + message.setSubject(this.subject); + } catch (MessagingException e) { + throw new BlazeException("Failed setting subject from value '" + this.subject + "': " + e.getMessage(), e); + } + + // you need text or html or both + if (this.textBody == null && this.htmlBody == null) { + throw new BlazeException("A text and/or html body are required"); + } + + final Multipart multipart = new MimeMultipart(); + + if (this.textBody != null) { + try { + MimeBodyPart mimeBodyPart = new MimeBodyPart(); + mimeBodyPart.setContent(this.textBody, "text/plain; charset=utf-8"); + multipart.addBodyPart(mimeBodyPart); + } catch (MessagingException e) { + throw new BlazeException("Failed setting text body: " + e.getMessage(), e); + } + } + + if (this.htmlBody != null) { + try { + MimeBodyPart mimeBodyPart = new MimeBodyPart(); + mimeBodyPart.setContent(this.htmlBody, "text/html; charset=utf-8"); + multipart.addBodyPart(mimeBodyPart); + } catch (MessagingException e) { + throw new BlazeException("Failed setting html body: " + e.getMessage(), e); + } + } + + try { + message.setContent(multipart); + } catch (MessagingException e) { + throw new BlazeException("Failed setting multipart message: " + e.getMessage(), e); + } + + try { + Transport.send(message); + log.verbose("Successfully sent mail: to={}, subject={} (in {})", this.to, this.subject, timer); + } catch (MessagingException e) { + throw new BlazeException("Failed to send mail: to=" + + this.to + ", subject=" + this.subject + ", error=" + e.getMessage () + " (in " + timer + ")", e); + } + + return new Result(this, 0); + } + + private void setRecipients(List addresses, Message message, Message.RecipientType recipientType) { + if (addresses != null && !addresses.isEmpty()) { + for (final String a : addresses) { + try { + message.addRecipient(recipientType, new InternetAddress(a)); + } catch (MessagingException e) { + throw new BlazeException("Failed setting " + recipientType + " address from value '" + a + "': " + e.getMessage(), e); + } + } + } + } + + private String buildSessionIdentifier(Properties props, String username, String password) { + StringBuilder identifier = new StringBuilder(); + List sortedKeys = props.keySet().stream() + .sorted() + .collect(Collectors.toList()); + + for (Object sortedKey : sortedKeys) { + Object value = props.get(sortedKey); + if (identifier.length() > 0) { + identifier.append(";"); + } + identifier.append(sortedKey).append("=").append(value); + } + + if (username != null && password != null) { + if (identifier.length() > 0) { + identifier.append(";"); + } + identifier.append("username=").append(username).append(";").append("password_hash=").append(password.hashCode()); + } - return null; + return identifier.toString(); } -} +} \ No newline at end of file diff --git a/blaze-postoffice/src/test/java/com/fizzed/blaze/postoffice/MailTest.java b/blaze-postoffice/src/test/java/com/fizzed/blaze/postoffice/MailTest.java index 5834c9a5..cd189048 100644 --- a/blaze-postoffice/src/test/java/com/fizzed/blaze/postoffice/MailTest.java +++ b/blaze-postoffice/src/test/java/com/fizzed/blaze/postoffice/MailTest.java @@ -1,23 +1,119 @@ package com.fizzed.blaze.postoffice; import com.fizzed.blaze.Config; +import com.fizzed.blaze.core.BlazeException; import com.fizzed.blaze.internal.ConfigHelper; import com.fizzed.blaze.internal.ContextImpl; +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.ServerSetup; +import jakarta.mail.Message; +import jakarta.mail.internet.MimeMessage; +import jakarta.mail.internet.MimeMultipart; +import org.junit.After; import org.junit.Before; +import org.junit.Test; import java.nio.file.Paths; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.fail; import static org.mockito.Mockito.spy; public class MailTest { + private GreenMail greenMail; private Config config; private ContextImpl context; + @Before - public void setup() throws Exception { + public void before() throws Exception { + this.greenMail = new GreenMail(ServerSetup.SMTP + .dynamicPort()); + this.greenMail.withConfiguration(new GreenMailConfiguration() + ); + this.greenMail.start(); this.config = ConfigHelper.create(null); this.context = spy(new ContextImpl(null, null, Paths.get("blaze.java"), config)); } + @After + public void after() throws Exception { + this.greenMail.stop(); + } + + @Test + public void noTextOrHtmlBody() throws Exception { + try { + new Mail(this.context) + .smtpHost("localhost") + .smtpPort(this.greenMail.getSmtp().getPort()) + .from("from@localhost") + .to("henry@example.com") + .subject("Test Email") + .run(); + fail(); + } catch (BlazeException e) { + // expected + } + } + + @Test + public void textMail() throws Exception { + new Mail(this.context) + .smtpHost("localhost") + .smtpPort(this.greenMail.getSmtp().getPort()) + .from("from@localhost") + .to("henry@example.com") + .subject("Test Email") + .textBody("Hello World!") + .run(); + + MimeMessage[] receivedMessages = this.greenMail.getReceivedMessages(); + + assertThat(receivedMessages.length, is(1)); + + MimeMessage receivedMessage = receivedMessages[0]; + assertThat(receivedMessage.getFrom()[0].toString(), is("from@localhost")); + assertThat(receivedMessage.getRecipients(Message.RecipientType.TO)[0].toString(), is("henry@example.com")); + assertThat(receivedMessage.getSubject(), is("Test Email")); + assertThat(receivedMessage.getContent(), instanceOf(MimeMultipart.class)); + + MimeMultipart multipart = (MimeMultipart) receivedMessage.getContent(); + assertThat(multipart.getCount(), is(1)); + assertThat(multipart.getBodyPart(0).getContentType(), is("text/plain; charset=utf-8")); + assertThat(multipart.getBodyPart(0).getContent(), is("Hello World!")); + } + + @Test + public void htmlMail() throws Exception { + new Mail(this.context) + .smtpHost("localhost") + .smtpPort(this.greenMail.getSmtp().getPort()) + .from("from@localhost") + .to("henry@example.com") + .subject("Test Email") + .htmlBody("Hello World!") + .run(); + + MimeMessage[] receivedMessages = this.greenMail.getReceivedMessages(); + + assertThat(receivedMessages.length, is(1)); + + MimeMessage receivedMessage = receivedMessages[0]; + assertThat(receivedMessage.getFrom()[0].toString(), is("from@localhost")); + assertThat(receivedMessage.getRecipients(Message.RecipientType.TO)[0].toString(), is("henry@example.com")); + assertThat(receivedMessage.getSubject(), is("Test Email")); + assertThat(receivedMessage.getContent(), instanceOf(MimeMultipart.class)); + + MimeMultipart multipart = (MimeMultipart) receivedMessage.getContent(); + assertThat(multipart.getCount(), is(1)); + assertThat(multipart.getBodyPart(0).getContentType(), is("text/html; charset=utf-8")); + assertThat(multipart.getBodyPart(0).getContent(), is("Hello World!")); + } + } \ No newline at end of file