diff --git a/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailAttachment.java b/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailAttachment.java new file mode 100644 index 00000000000..29227030826 --- /dev/null +++ b/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailAttachment.java @@ -0,0 +1,94 @@ +package com.enonic.xp.mail; + +import java.util.Map; +import java.util.Objects; + +import com.google.common.collect.ImmutableMap; +import com.google.common.io.ByteSource; + +import com.enonic.xp.annotation.PublicApi; + +@PublicApi +public final class MailAttachment +{ + private final String fileName; + + private final ByteSource data; + + private final String mimeType; + + private final Map headers; + + private MailAttachment( final Builder builder ) + { + this.fileName = Objects.requireNonNull( builder.fileName ); + this.data = Objects.requireNonNull( builder.data ); + this.mimeType = builder.mimeType; + this.headers = builder.headers != null ? ImmutableMap.copyOf( builder.headers ) : ImmutableMap.of(); + } + + public static Builder create() + { + return new Builder(); + } + + public String getFileName() + { + return fileName; + } + + public ByteSource getData() + { + return data; + } + + public String getMimeType() + { + return mimeType; + } + + public Map getHeaders() + { + return headers; + } + + public static class Builder + { + private String fileName; + + private ByteSource data; + + private String mimeType; + + private Map headers; + + public Builder fileName( final String fileName ) + { + this.fileName = fileName; + return this; + } + + public Builder data( final ByteSource data ) + { + this.data = data; + return this; + } + + public Builder mimeType( final String mimeType ) + { + this.mimeType = mimeType; + return this; + } + + public Builder headers( final Map headers ) + { + this.headers = headers; + return this; + } + + public MailAttachment build() + { + return new MailAttachment( this ); + } + } +} diff --git a/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailHeader.java b/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailHeader.java new file mode 100644 index 00000000000..89216595198 --- /dev/null +++ b/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailHeader.java @@ -0,0 +1,32 @@ +package com.enonic.xp.mail; + +import com.enonic.xp.annotation.PublicApi; + +@PublicApi +public final class MailHeader +{ + private final String key; + + private final String value; + + public MailHeader( final String key, final String value ) + { + this.key = key; + this.value = value; + } + + public static MailHeader from( final String key, final String value ) + { + return new MailHeader( key, value ); + } + + public String getKey() + { + return key; + } + + public String getValue() + { + return value; + } +} diff --git a/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailMessage.java b/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailMessage.java index d8cb92e3130..22d5eb69f3f 100644 --- a/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailMessage.java +++ b/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailMessage.java @@ -2,6 +2,7 @@ import javax.mail.internet.MimeMessage; +@Deprecated public interface MailMessage { void compose( MimeMessage message ) diff --git a/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailService.java b/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailService.java index f19691f5b45..09272618eed 100644 --- a/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailService.java +++ b/modules/core/core-api/src/main/java/com/enonic/xp/mail/MailService.java @@ -1,6 +1,12 @@ package com.enonic.xp.mail; +import com.enonic.xp.annotation.PublicApi; + +@PublicApi public interface MailService { + @Deprecated void send( MailMessage message ); + + void send( SendMailParams message ); } diff --git a/modules/core/core-api/src/main/java/com/enonic/xp/mail/SendMailParams.java b/modules/core/core-api/src/main/java/com/enonic/xp/mail/SendMailParams.java new file mode 100644 index 00000000000..b26bf287e8d --- /dev/null +++ b/modules/core/core-api/src/main/java/com/enonic/xp/mail/SendMailParams.java @@ -0,0 +1,230 @@ +package com.enonic.xp.mail; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import com.enonic.xp.annotation.PublicApi; + +@PublicApi +public final class SendMailParams +{ + private final List to; + + private final List from; + + private final List cc; + + private final List bcc; + + private final List replyTo; + + private final List headers; + + private final List attachments; + + private final String subject; + + private final String contentType; + + private final String body; + + private SendMailParams( Builder builder ) + { + this.to = List.copyOf( builder.to ); + this.from = List.copyOf( builder.from ); + this.cc = List.copyOf( builder.cc ); + this.bcc = List.copyOf( builder.bcc ); + this.replyTo = List.copyOf( builder.replyTo ); + this.headers = List.copyOf( builder.headers ); + this.attachments = List.copyOf( builder.attachments ); + this.subject = builder.subject; + this.contentType = builder.contentType; + this.body = builder.body; + } + + public static Builder create() + { + return new Builder(); + } + + public List getTo() + { + return to; + } + + public List getFrom() + { + return from; + } + + public List getCc() + { + return cc; + } + + public List getBcc() + { + return bcc; + } + + public List getReplyTo() + { + return replyTo; + } + + public String getSubject() + { + return subject; + } + + public String getContentType() + { + return contentType; + } + + public String getBody() + { + return body; + } + + public List getHeaders() + { + return headers; + } + + public List getAttachments() + { + return attachments; + } + + public static class Builder + { + private final List to = new ArrayList<>(); + + private final List from = new ArrayList<>(); + + private final List cc = new ArrayList<>(); + + private final List bcc = new ArrayList<>(); + + private final List replyTo = new ArrayList<>(); + + private final List headers = new ArrayList<>(); + + private final List attachments = new ArrayList<>(); + + private String subject; + + private String contentType; + + private String body; + + public Builder to( final String... to ) + { + this.to.addAll( List.of( to ) ); + return this; + } + + public Builder to( final Collection to ) + { + this.to.addAll( to ); + return this; + } + + public Builder from( final String... from ) + { + this.from.addAll( List.of( from ) ); + return this; + } + + public Builder from( final Collection from ) + { + this.from.addAll( from ); + return this; + } + + public Builder cc( final String... cc ) + { + this.cc.addAll( List.of( cc ) ); + return this; + } + + public Builder cc( final Collection cc ) + { + this.cc.addAll( cc ); + return this; + } + + public Builder bcc( final String... bcc ) + { + this.bcc.addAll( List.of( bcc ) ); + return this; + } + + public Builder bcc( final Collection bcc ) + { + this.bcc.addAll( bcc ); + return this; + } + + public Builder replyTo( final String... replyTo ) + { + this.replyTo.addAll( List.of( replyTo ) ); + return this; + } + + public Builder replyTo( final Collection replyTo ) + { + this.replyTo.addAll( replyTo ); + return this; + } + + public Builder subject( final String subject ) + { + this.subject = subject; + return this; + } + + public Builder contentType( final String contentType ) + { + this.contentType = contentType; + return this; + } + + public Builder body( final String body ) + { + this.body = body; + return this; + } + + public Builder addHeader( final String key, final String value ) + { + this.headers.add( MailHeader.from( key, value ) ); + return this; + } + + public void addHeaders( final Map headers ) + { + headers.forEach( this::addHeader ); + } + + public Builder addAttachment( final MailAttachment attachment ) + { + this.attachments.add( attachment ); + return this; + } + + public Builder addAttachments( final Collection attachments ) + { + this.attachments.addAll( attachments ); + return this; + } + + public SendMailParams build() + { + return new SendMailParams( this ); + } + } +} diff --git a/modules/core/core-mail/build.gradle b/modules/core/core-mail/build.gradle index d1ea653ccfa..5c7c82cd14a 100644 --- a/modules/core/core-mail/build.gradle +++ b/modules/core/core-mail/build.gradle @@ -1,5 +1,6 @@ dependencies { implementation project( ':core:core-api' ) + implementation project( ':core:core-internal' ) testImplementation ( libs.mockjavamail ) { exclude group: 'javax.mail' diff --git a/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MailConfig.java b/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MailConfig.java index 133758f804c..67c3d2ce822 100644 --- a/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MailConfig.java +++ b/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MailConfig.java @@ -13,4 +13,6 @@ String smtpPassword() default ""; boolean smtpTLS() default false; + + String defaultFromEmail(); } diff --git a/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MailServiceImpl.java b/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MailServiceImpl.java index ffb3aff8940..88730dbd47b 100644 --- a/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MailServiceImpl.java +++ b/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MailServiceImpl.java @@ -1,6 +1,8 @@ package com.enonic.xp.mail.impl; +import java.time.Duration; import java.util.Properties; +import java.util.concurrent.Executors; import javax.mail.Address; import javax.mail.Authenticator; @@ -11,20 +13,41 @@ import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Modified; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.enonic.xp.core.internal.concurrent.SimpleExecutor; import com.enonic.xp.mail.MailException; import com.enonic.xp.mail.MailMessage; import com.enonic.xp.mail.MailService; +import com.enonic.xp.mail.SendMailParams; @Component(immediate = true, configurationPid = "com.enonic.xp.mail") public final class MailServiceImpl implements MailService { + private static final Logger LOG = LoggerFactory.getLogger( MailServiceImpl.class ); + + private final SimpleExecutor simpleExecutor; + private Session session; + private volatile String defaultFromEmail; + + public MailServiceImpl() + { + this.simpleExecutor = new SimpleExecutor( Executors::newCachedThreadPool, "mail-service-executor-thread-%d", + e -> LOG.error( "Message sending failed", e ) ); + } + @Activate + @Modified public void activate( final MailConfig config ) { + this.defaultFromEmail = config.defaultFromEmail(); + final Properties properties = new Properties(); properties.put( "mail.transport.protocol", "smtp" ); @@ -47,6 +70,12 @@ public void activate( final MailConfig config ) } } + @Deactivate + public void deactivate() + { + simpleExecutor.shutdownAndAwaitTermination( Duration.ofSeconds( 5 ), neverCommenced -> LOG.warn( "Not all messages were sent" ) ); + } + @Override public void send( final MailMessage message ) { @@ -62,6 +91,29 @@ public void send( final MailMessage message ) } } + @Override + public void send( final SendMailParams params ) + { + try + { + MimeMessage message = new MimeMessageConverter( defaultFromEmail, session ).convert( params ); + simpleExecutor.execute( () -> { + try + { + doSend( message ); + } + catch ( Exception e ) + { + throw new RuntimeException( e ); + } + } ); + } + catch ( final Exception e ) + { + throw handleException( e ); + } + } + private MimeMessage newMessage() { return new MimeMessage( this.session ); diff --git a/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MimeMessageConverter.java b/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MimeMessageConverter.java new file mode 100644 index 00000000000..2a43643b159 --- /dev/null +++ b/modules/core/core-mail/src/main/java/com/enonic/xp/mail/impl/MimeMessageConverter.java @@ -0,0 +1,227 @@ +package com.enonic.xp.mail.impl; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +import javax.activation.DataHandler; +import javax.activation.DataSource; +import javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.Multipart; +import javax.mail.Session; +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeBodyPart; +import javax.mail.internet.MimeMessage; +import javax.mail.internet.MimeMultipart; + +import com.google.common.io.ByteSource; + +import com.enonic.xp.mail.MailAttachment; +import com.enonic.xp.mail.MailException; +import com.enonic.xp.mail.MailHeader; +import com.enonic.xp.mail.SendMailParams; +import com.enonic.xp.util.MediaTypes; + +import static com.google.common.base.Strings.nullToEmpty; + +class MimeMessageConverter +{ + private static final String DEFAULT_FROM_PATTERN = "<>"; + + private final String defaultFromEmail; + + private final Session session; + + MimeMessageConverter( String defaultFromEmail, Session session ) + { + this.defaultFromEmail = defaultFromEmail; + this.session = session; + } + + MimeMessage convert( SendMailParams params ) + throws Exception + { + MimeMessage message = new MimeMessage( session ); + + message.setSubject( params.getSubject() ); + + message.addFrom( toAddresses( resolveFrom( params.getFrom() ) ) ); + message.addRecipients( Message.RecipientType.TO, toAddresses( params.getTo() ) ); + message.addRecipients( Message.RecipientType.CC, toAddresses( params.getCc() ) ); + message.addRecipients( Message.RecipientType.BCC, toAddresses( params.getBcc() ) ); + message.setReplyTo( toAddresses( params.getReplyTo() ) ); + + for ( MailHeader header : params.getHeaders() ) + { + message.addHeader( header.getKey(), header.getValue() ); + } + + final List attachmentList = params.getAttachments(); + if ( attachmentList.isEmpty() ) + { + message.setText( nullToEmpty( params.getBody() ), "UTF-8" ); + if ( params.getContentType() != null ) + { + message.addHeader( "Content-Type", params.getContentType() ); + } + } + else + { + message.setContent( createMultiPart( params ) ); + } + + return message; + } + + private Multipart createMultiPart( SendMailParams params ) + throws MessagingException + { + final Multipart result = new MimeMultipart(); + + result.addBodyPart( createTextPart( params.getBody(), params.getContentType() ) ); + for ( MailAttachment attachment : params.getAttachments() ) + { + result.addBodyPart( createMimeBodyPart( attachment ) ); + } + + return result; + } + + private MimeBodyPart createTextPart( String body, String contentType ) + throws MessagingException + { + final MimeBodyPart result = new MimeBodyPart(); + result.setText( nullToEmpty( body ), "UTF-8" ); + if ( contentType != null ) + { + result.addHeader( "Content-Type", contentType ); + } + return result; + } + + private MimeBodyPart createMimeBodyPart( MailAttachment attachment ) + throws MessagingException + { + final MimeBodyPart result = new MimeBodyPart(); + + String mimeType = + Objects.requireNonNullElse( attachment.getMimeType(), MediaTypes.instance().fromFile( attachment.getFileName() ).toString() ); + + DataSource source = new ByteSourceDataSource( attachment.getData(), attachment.getFileName(), mimeType ); + result.setDataHandler( new DataHandler( source ) ); + result.setFileName( attachment.getFileName() ); + + for ( Map.Entry header : attachment.getHeaders().entrySet() ) + { + result.addHeader( header.getKey(), header.getValue() ); + } + + return result; + } + + private List resolveFrom( final List from ) + { + final List result = from.stream().filter( string -> !nullToEmpty( string ).isBlank() ).map( sender -> { + if ( sender.contains( DEFAULT_FROM_PATTERN ) ) + { + if ( nullToEmpty( defaultFromEmail ).isBlank() ) + { + throw new IllegalArgumentException( + String.format( "To use \"%s\" the \"defaultFromEmail\" configuration must be set in \"com.enonic.xp.mail.cfg\"", + DEFAULT_FROM_PATTERN ) ); + } + return sender.equals( DEFAULT_FROM_PATTERN ) + ? defaultFromEmail + : sender.replace( DEFAULT_FROM_PATTERN, String.format( "<%s>", defaultFromEmail ) ); + } + else + { + return sender; + } + } ).collect( Collectors.toList() ); + + if ( result.isEmpty() ) + { + if ( !nullToEmpty( defaultFromEmail ).isBlank() ) + { + result.add( defaultFromEmail ); + } + else + { + throw new IllegalArgumentException( "Parameter 'from' is required" ); + } + } + + return result; + } + + private InternetAddress[] toAddresses( final List addressList ) + { + return addressList.stream() + .filter( string -> !nullToEmpty( string ).isBlank() ) + .map( this::toAddress ) + .toArray( InternetAddress[]::new ); + } + + private InternetAddress toAddress( final String address ) + throws MailException + { + try + { + return new InternetAddress( address ); + } + catch ( AddressException e ) + { + throw new MailException( e.getMessage(), e ); + } + } + + private static class ByteSourceDataSource + implements DataSource + { + private final ByteSource source; + + private final String name; + + private final String mimeType; + + ByteSourceDataSource( final ByteSource source, final String name, final String mimeType ) + { + this.source = source; + this.name = name; + this.mimeType = mimeType; + } + + @Override + public InputStream getInputStream() + throws IOException + { + return this.source.openStream(); + } + + @Override + public OutputStream getOutputStream() + throws IOException + { + throw new UnsupportedOperationException( "Not implemented" ); + } + + @Override + public String getContentType() + { + return this.mimeType; + } + + @Override + public String getName() + { + return this.name; + } + } +} diff --git a/modules/core/core-mail/src/test/java/com/enonic/xp/mail/impl/MailServiceImplTest.java b/modules/core/core-mail/src/test/java/com/enonic/xp/mail/impl/MailServiceImplTest.java index 006261aad71..1f360cb092b 100644 --- a/modules/core/core-mail/src/test/java/com/enonic/xp/mail/impl/MailServiceImplTest.java +++ b/modules/core/core-mail/src/test/java/com/enonic/xp/mail/impl/MailServiceImplTest.java @@ -13,7 +13,9 @@ import com.enonic.xp.mail.MailException; import com.enonic.xp.mail.MailMessage; +import com.enonic.xp.mail.SendMailParams; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -25,9 +27,7 @@ public class MailServiceImplTest public void setUp() throws Exception { - final MailConfig config = Mockito.mock( MailConfig.class ); - Mockito.when( config.smtpHost() ).thenReturn( "localhost" ); - Mockito.when( config.smtpPort() ).thenReturn( 25 ); + final MailConfig config = Mockito.mock( MailConfig.class, invocation -> invocation.getMethod().getDefaultValue() ); this.mailService = new MailServiceImpl(); this.mailService.activate( config ); @@ -61,6 +61,13 @@ public void sessionNotActivatedTest() assertThrows( MailException.class, () -> mailService.send( mockMessage ) ); } + @Test + public void testSend() + { + assertDoesNotThrow( () -> this.mailService.send( + SendMailParams.create().subject( "test subject" ).body( "test body" ).to( "to@bar.com" ).from( "from@bar.com" ).build() ) ); + } + private void createMockMessage( MimeMessage msg ) throws Exception { diff --git a/modules/core/core-mail/src/test/java/com/enonic/xp/mail/impl/MimeMessageConverterTest.java b/modules/core/core-mail/src/test/java/com/enonic/xp/mail/impl/MimeMessageConverterTest.java new file mode 100644 index 00000000000..077221627c1 --- /dev/null +++ b/modules/core/core-mail/src/test/java/com/enonic/xp/mail/impl/MimeMessageConverterTest.java @@ -0,0 +1,233 @@ +package com.enonic.xp.mail.impl; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Properties; +import java.util.stream.Stream; + +import javax.mail.BodyPart; +import javax.mail.Message; +import javax.mail.Session; +import javax.mail.internet.AddressException; +import javax.mail.internet.InternetAddress; +import javax.mail.internet.MimeMultipart; + +import org.junit.jupiter.api.Test; + +import com.google.common.io.ByteSource; +import com.google.common.io.CharStreams; + +import com.enonic.xp.mail.MailAttachment; +import com.enonic.xp.mail.SendMailParams; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class MimeMessageConverterTest +{ + @Test + public void testSimpleMail() + throws Exception + { + SendMailParams params = SendMailParams.create() + .subject( "test subject" ) + .body( "test body" ) + .to( "to@bar.com" ) + .from( "from@bar.com" ) + .cc( "cc@bar.com" ) + .bcc( "bcc@bar.com" ) + .replyTo( "replyTo@bar.com" ) + .addHeader( "X-Custom", "Value" ) + .addHeader( "X-Other", "2" ) + .build(); + + Message message = new MimeMessageConverter( null, Session.getDefaultInstance( new Properties() ) ).convert( params ); + + assertEquals( "test subject", message.getSubject() ); + assertEquals( "test body", message.getContent() ); + assertArrayEquals( toAddresses( "from@bar.com" ), message.getFrom() ); + assertArrayEquals( toAddresses( "to@bar.com" ), message.getRecipients( Message.RecipientType.TO ) ); + assertArrayEquals( toAddresses( "cc@bar.com" ), message.getRecipients( Message.RecipientType.CC ) ); + assertArrayEquals( toAddresses( "bcc@bar.com" ), message.getRecipients( Message.RecipientType.BCC ) ); + assertArrayEquals( toAddresses( "replyTo@bar.com" ), message.getReplyTo() ); + assertEquals( "Value", message.getHeader( "X-Custom" )[0] ); + assertEquals( "2", message.getHeader( "X-Other" )[0] ); + } + + @Test + public void testMultiRecipientsMail() + throws Exception + { + SendMailParams params = SendMailParams.create() + .subject( "test subject" ) + .body( "test body" ) + .to( "to@bar.com", "to@foo.com" ) + .from( "from@bar.com", "from@foo.com" ) + .cc( "cc@bar.com", "cc@foo.com" ) + .bcc( "bcc@bar.com", "bcc@foo.com" ) + .replyTo( "replyTo@bar.com", "replyTo@foo.com" ) + .build(); + + Message message = new MimeMessageConverter( null, Session.getDefaultInstance( new Properties() ) ).convert( params ); + + assertEquals( "test subject", message.getSubject() ); + assertEquals( "test body", message.getContent() ); + assertArrayEquals( toAddresses( "from@bar.com", "from@foo.com" ), message.getFrom() ); + assertArrayEquals( toAddresses( "to@bar.com", "to@foo.com" ), message.getRecipients( Message.RecipientType.TO ) ); + assertArrayEquals( toAddresses( "cc@bar.com", "cc@foo.com" ), message.getRecipients( Message.RecipientType.CC ) ); + assertArrayEquals( toAddresses( "bcc@bar.com", "bcc@foo.com" ), message.getRecipients( Message.RecipientType.BCC ) ); + assertArrayEquals( toAddresses( "replyTo@bar.com", "replyTo@foo.com" ), message.getReplyTo() ); + } + + @Test + public void testRfc822AddressMail() + throws Exception + { + SendMailParams params = SendMailParams.create() + .subject( "test subject" ) + .body( "test body" ) + .to( "To Bar ", "To Foo " ) + .from( "From Bar ", "From Foo ", "<>", "Some User <>", "username@domain.com" ) + .build(); + + Message message = + new MimeMessageConverter( "noreply@domain.com", Session.getDefaultInstance( new Properties() ) ).convert( params ); + + assertEquals( "test subject", message.getSubject() ); + assertEquals( "test body", message.getContent() ); + assertArrayEquals( + toAddresses( "From Bar ", "From Foo ", "noreply@domain.com", "Some User ", + "username@domain.com" ), message.getFrom() ); + assertArrayEquals( toAddresses( "To Bar ", "To Foo " ), message.getRecipients( Message.RecipientType.TO ) ); + } + + @Test + public void testDefaultFromMail() + throws Exception + { + SendMailParams params = SendMailParams.create() + .subject( "test subject" ) + .body( "test body" ) + .to( "To Bar ", "To Foo " ) + .from( "Username ", "<>", "Some User <>", "username2@domain.com" ) + .build(); + + Message message = + new MimeMessageConverter( "noreply@domain.com", Session.getDefaultInstance( new Properties() ) ).convert( params ); + + assertEquals( "test subject", message.getSubject() ); + assertEquals( "test body", message.getContent() ); + assertArrayEquals( + toAddresses( "Username ", "noreply@domain.com", "Some User ", "username2@domain.com" ), + message.getFrom() ); + assertArrayEquals( toAddresses( "To Bar ", "To Foo " ), message.getRecipients( Message.RecipientType.TO ) ); + } + + @Test + public void testInvalidDefaultFromMail() + throws Exception + { + SendMailParams params = SendMailParams.create() + .subject( "test subject" ) + .body( "test body" ) + .to( "To Bar ", "To Foo " ) + .from( "Username ", "<>", "Some User <>", "username2@domain.com" ) + .build(); + + IllegalArgumentException ex = assertThrows( IllegalArgumentException.class, () -> new MimeMessageConverter( null, + Session.getDefaultInstance( + new Properties() ) ).convert( + params ) ); + + assertEquals( "To use \"<>\" the \"defaultFromEmail\" configuration must be set in \"com.enonic.xp.mail.cfg\"", ex.getMessage() ); + } + + @Test + public void testSendMailWithContentType() + throws Exception + { + SendMailParams params = SendMailParams.create() + .subject( "test subject" ) + .body( "test body" ) + .to( "to@bar.com" ) + .from( "from@bar.com" ) + .contentType( "text/html" ) + .build(); + + Message message = new MimeMessageConverter( null, Session.getDefaultInstance( new Properties() ) ).convert( params ); + + assertEquals( "test subject", message.getSubject() ); + assertEquals( "test body", message.getContent() ); + assertArrayEquals( toAddresses( "from@bar.com" ), message.getFrom() ); + assertArrayEquals( toAddresses( "to@bar.com" ), message.getRecipients( Message.RecipientType.TO ) ); + assertEquals( "text/html", message.getContentType() ); + } + + @Test + public void testSendWithAttachments() + throws Exception + { + SendMailParams params = SendMailParams.create() + .subject( "test subject" ) + .body( "test body" ) + .to( "to@bar.com" ) + .from( "from@bar.com" ) + .addAttachment( MailAttachment.create() + .fileName( "image.png" ) + .mimeType( "image/png" ) + .data( ByteSource.wrap( "image data".getBytes() ) ) + .headers( Map.of( "Content-ID", "" ) ) + .build() ) + .addAttachment( MailAttachment.create().fileName( "text.txt" ).data( ByteSource.wrap( "Some text".getBytes() ) ).build() ) + .build(); + + Message message = new MimeMessageConverter( null, Session.getDefaultInstance( new Properties() ) ).convert( params ); + message.saveChanges(); // required to updated headers (mimeType) + + MimeMultipart content = (MimeMultipart) message.getContent(); + assertEquals( 3, content.getCount() ); + final BodyPart first = content.getBodyPart( 0 ); + final BodyPart second = content.getBodyPart( 1 ); + final BodyPart third = content.getBodyPart( 2 ); + final String secondContent = + CharStreams.toString( new InputStreamReader( (InputStream) second.getContent(), StandardCharsets.UTF_8 ) ); + + assertEquals( "test body", first.getContent() ); + assertEquals( "image data", secondContent ); + assertEquals( "Some text", third.getContent() ); + + assertNull( first.getFileName() ); + assertEquals( "image.png", second.getFileName() ); + assertEquals( "text.txt", third.getFileName() ); + + assertEquals( "text/plain; charset=UTF-8", first.getContentType() ); + assertTrue( second.getContentType().startsWith( "image/png" ) ); + assertTrue( third.getContentType().startsWith( "text/plain" ) ); + + assertEquals( "", second.getHeader( "Content-ID" )[0] ); + } + + + private InternetAddress[] toAddresses( final String... addresses ) + { + return Stream.of( addresses ).map( this::toAddress ).toArray( InternetAddress[]::new ); + } + + private InternetAddress toAddress( final String address ) + throws RuntimeException + { + try + { + return new InternetAddress( address ); + } + catch ( AddressException e ) + { + throw new RuntimeException( e ); + } + } +} diff --git a/modules/lib/lib-mail/src/main/java/com/enonic/xp/lib/mail/SendMailHandler.java b/modules/lib/lib-mail/src/main/java/com/enonic/xp/lib/mail/SendMailHandler.java index 91d59594646..78a80af5a48 100644 --- a/modules/lib/lib-mail/src/main/java/com/enonic/xp/lib/mail/SendMailHandler.java +++ b/modules/lib/lib-mail/src/main/java/com/enonic/xp/lib/mail/SendMailHandler.java @@ -1,51 +1,35 @@ package com.enonic.xp.lib.mail; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Supplier; -import java.util.stream.Stream; - -import javax.activation.DataHandler; -import javax.activation.DataSource; -import javax.mail.Message; -import javax.mail.Multipart; -import javax.mail.internet.AddressException; -import javax.mail.internet.InternetAddress; -import javax.mail.internet.MimeBodyPart; -import javax.mail.internet.MimeMessage; -import javax.mail.internet.MimeMultipart; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.google.common.io.ByteSource; -import com.google.common.net.MediaType; -import com.enonic.xp.mail.MailException; -import com.enonic.xp.mail.MailMessage; +import com.enonic.xp.mail.MailAttachment; import com.enonic.xp.mail.MailService; +import com.enonic.xp.mail.SendMailParams; import com.enonic.xp.script.bean.BeanContext; import com.enonic.xp.script.bean.ScriptBean; -import com.enonic.xp.util.MediaTypes; - -import static com.google.common.base.Strings.nullToEmpty; public final class SendMailHandler - implements MailMessage, ScriptBean + implements ScriptBean { private static final Logger LOG = LoggerFactory.getLogger( SendMailHandler.class ); - private String[] to; + private List to; - private String[] from; + private List from; - private String[] cc; + private List cc; - private String[] bcc; + private List bcc; - private String[] replyTo; + private List replyTo; private String subject; @@ -53,35 +37,35 @@ public final class SendMailHandler private String body; - private Supplier mailService; - private Map headers; private List> attachments; + private Supplier mailService; + public void setTo( final String[] to ) { - this.to = to; + this.to = arrayToList( to ); } public void setFrom( final String[] from ) { - this.from = from; + this.from = arrayToList( from ); } public void setCc( final String[] cc ) { - this.cc = cc; + this.cc = arrayToList( cc ); } public void setBcc( final String[] bcc ) { - this.bcc = bcc; + this.bcc = arrayToList( bcc ); } public void setReplyTo( final String[] replyTo ) { - this.replyTo = replyTo; + this.replyTo = arrayToList( replyTo ); } public void setContentType( final String contentType ) @@ -109,11 +93,18 @@ public void setAttachments( final List> attachments ) this.attachments = attachments; } + @Override + public void initialize( final BeanContext context ) + { + this.mailService = context.getService( MailService.class ); + } + public boolean send() { try { - this.mailService.get().send( this ); + final SendMailParams params = createParams(); + this.mailService.get().send( params ); return true; } catch ( final Exception e ) @@ -123,150 +114,56 @@ public boolean send() } } - @Override - public void compose( final MimeMessage message ) - throws Exception + private List arrayToList( final String[] value ) { - message.setSubject( this.subject ); + return value == null ? List.of() : List.of( value ); + } - message.addFrom( toAddresses( this.from ) ); - message.addRecipients( Message.RecipientType.TO, toAddresses( this.to ) ); - message.addRecipients( Message.RecipientType.CC, toAddresses( this.cc ) ); - message.addRecipients( Message.RecipientType.BCC, toAddresses( this.bcc ) ); - message.setReplyTo( toAddresses( this.replyTo ) ); + private SendMailParams createParams() + { + final SendMailParams.Builder result = SendMailParams.create() + .to( this.to ) + .from( this.from ) + .cc( this.cc ) + .bcc( this.bcc ) + .replyTo( this.replyTo ) + .subject( this.subject ) + .contentType( this.contentType ) + .body( this.body ); if ( this.headers != null ) { - for ( Map.Entry header : this.headers.entrySet() ) - { - message.addHeader( header.getKey(), header.getValue() ); - } + this.headers.forEach( result::addHeader ); } - final List attachmentList = getAttachments(); - if ( attachmentList.isEmpty() ) + if ( this.attachments != null ) { - message.setText( nullToEmpty( this.body ), "UTF-8" ); - if ( this.contentType != null ) - { - message.addHeader( "Content-Type", this.contentType ); - } + resolveAttachments( this.attachments ).forEach( result::addAttachment ); } - else - { - final Multipart multipart = new MimeMultipart(); - - final MimeBodyPart textPart = new MimeBodyPart(); - textPart.setText( nullToEmpty( this.body ), "UTF-8" ); - if ( this.contentType != null ) - { - textPart.addHeader( "Content-Type", this.contentType ); - } - multipart.addBodyPart( textPart ); - - for ( Attachment attachment : attachmentList ) - { - final MimeBodyPart messageBodyPart = new MimeBodyPart(); - DataSource source = new ByteSourceDataSource( attachment.data, attachment.name, attachment.mimeType ); - messageBodyPart.setDataHandler( new DataHandler( source ) ); - messageBodyPart.setFileName( attachment.name ); - for ( String headerName : attachment.headers.keySet() ) - { - messageBodyPart.addHeader( headerName, attachment.headers.get( headerName ) ); - } - multipart.addBodyPart( messageBodyPart ); - } - message.setContent( multipart ); - } - - } - private InternetAddress toAddress( final String address ) - throws MailException - { - try - { - return new InternetAddress( address ); - } - catch ( AddressException e ) - { - throw new MailException( e.getMessage(), e ); - } - } - - private InternetAddress[] toAddresses( final String[] addressList ) - throws Exception - { - return Stream.of( addressList ).filter( string -> !nullToEmpty( string ).isBlank() ).map( this::toAddress ).toArray( - InternetAddress[]::new ); + return result.build(); } - private List getAttachments() + @SuppressWarnings("unchecked") + private List resolveAttachments( final List> attachments ) { - if ( this.attachments == null ) - { - return Collections.emptyList(); - } - final List attachments = new ArrayList<>(); - for ( Map attachmentObject : this.attachments ) + final List result = new ArrayList<>(); + for ( Map attachmentObject : attachments ) { final String name = getValue( attachmentObject, "fileName", String.class ); final ByteSource data = getValue( attachmentObject, "data", ByteSource.class ); - String mimeType = getValue( attachmentObject, "mimeType", String.class ); - final Map headers = getValue( attachmentObject, "headers", Map.class ); - if ( name != null && data != null ) - { - mimeType = mimeType == null ? getMimeType( name ) : mimeType; - attachments.add( new Attachment( name, data, mimeType, headers ) ); - } + final String mimeType = getValue( attachmentObject, "mimeType", String.class ); + final Map headers = getValue( attachmentObject, "headers", Map.class ); + + result.add( MailAttachment.create().data( data ).mimeType( mimeType ).fileName( name ).headers( headers ).build() ); } - return attachments; + return result; } + @SuppressWarnings("unchecked") private T getValue( final Map object, final String key, final Class type ) { final Object value = object.get( key ); - if ( type.isInstance( value ) ) - { - //noinspection unchecked - return (T) value; - } - return null; - } - - private String getMimeType( final String fileName ) - { - if ( fileName == null ) - { - return MediaType.OCTET_STREAM.toString(); - } - - final MediaType type = MediaTypes.instance().fromFile( fileName ); - return type.toString(); - } - - @Override - public void initialize( final BeanContext context ) - { - this.mailService = context.getService( MailService.class ); - } - - private static class Attachment - { - public final String name; - - public final ByteSource data; - - public final String mimeType; - - public final Map headers; - - Attachment( final String name, final ByteSource data, final String mimeType, final Map headers ) - { - this.name = name; - this.data = data; - this.mimeType = mimeType; - this.headers = headers == null ? Collections.emptyMap() : headers; - } + return type.isInstance( value ) ? (T) value : null; } } diff --git a/modules/lib/lib-mail/src/test/java/com/enonic/xp/lib/mail/SendMailScriptTest.java b/modules/lib/lib-mail/src/test/java/com/enonic/xp/lib/mail/SendMailScriptTest.java index 43b9eba1c3f..2cfe9d50233 100644 --- a/modules/lib/lib-mail/src/test/java/com/enonic/xp/lib/mail/SendMailScriptTest.java +++ b/modules/lib/lib-mail/src/test/java/com/enonic/xp/lib/mail/SendMailScriptTest.java @@ -1,46 +1,51 @@ package com.enonic.xp.lib.mail; -import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.util.stream.Stream; - -import javax.mail.BodyPart; -import javax.mail.Message; -import javax.mail.Session; -import javax.mail.internet.AddressException; -import javax.mail.internet.InternetAddress; -import javax.mail.internet.MimeMessage; -import javax.mail.internet.MimeMultipart; +import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import com.google.common.io.ByteSource; import com.google.common.io.CharStreams; +import com.enonic.xp.mail.MailAttachment; +import com.enonic.xp.mail.MailHeader; import com.enonic.xp.mail.MailMessage; import com.enonic.xp.mail.MailService; +import com.enonic.xp.mail.SendMailParams; import com.enonic.xp.resource.ResourceProblemException; import com.enonic.xp.testing.ScriptTestSupport; -import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; public class SendMailScriptTest extends ScriptTestSupport { - private MailMessage actualMessage; + private SendMailParams actualMessage; @Override public void initialize() throws Exception { super.initialize(); - final MailService mailService = message -> this.actualMessage = message; - addService( MailService.class, mailService ); + addService( MailService.class, new MailService() + { + @Override + public void send( final MailMessage message ) + { + // do nothing + } + + @Override + public void send( final SendMailParams message ) + { + SendMailScriptTest.this.actualMessage = message; + } + } ); } @Test @@ -51,38 +56,36 @@ public void testExample() @Test public void testSimpleMail() - throws Exception { runFunction( "/test/send-test.js", "simpleMail" ); - final MimeMessage message = mockCompose( this.actualMessage ); + final SendMailParams message = this.actualMessage; assertEquals( "test subject", message.getSubject() ); - assertEquals( "test body", message.getContent() ); - assertArrayEquals( toAddresses( "from@bar.com" ), message.getFrom() ); - assertArrayEquals( toAddresses( "to@bar.com" ), message.getRecipients( Message.RecipientType.TO ) ); - assertArrayEquals( toAddresses( "cc@bar.com" ), message.getRecipients( Message.RecipientType.CC ) ); - assertArrayEquals( toAddresses( "bcc@bar.com" ), message.getRecipients( Message.RecipientType.BCC ) ); - assertArrayEquals( toAddresses( "replyTo@bar.com" ), message.getReplyTo() ); - assertEquals( "Value", message.getHeader( "X-Custom" )[0] ); - assertEquals( "2", message.getHeader( "X-Other" )[0] ); + assertEquals( "test body", message.getBody() ); + assertEquals( List.of( "from@bar.com" ), message.getFrom() ); + assertEquals( List.of( "to@bar.com" ), message.getTo() ); + assertEquals( List.of( "cc@bar.com" ), message.getCc() ); + assertEquals( List.of( "bcc@bar.com" ), message.getBcc() ); + assertEquals( List.of( "replyTo@bar.com" ), message.getReplyTo() ); + assertEquals( "Value", getHeader( message.getHeaders(), "X-Custom" ) ); + assertEquals( "2", getHeader( message.getHeaders(), "X-Other" ) ); } @Test public void testMultiRecipientsMail() - throws Exception { runFunction( "/test/send-test.js", "multiRecipientsMail" ); - final MimeMessage message = mockCompose( this.actualMessage ); + final SendMailParams message = this.actualMessage; assertEquals( "test subject", message.getSubject() ); - assertEquals( "test body", message.getContent() ); - assertArrayEquals( toAddresses( "from@bar.com", "from@foo.com" ), message.getFrom() ); - assertArrayEquals( toAddresses( "to@bar.com", "to@foo.com" ), message.getRecipients( Message.RecipientType.TO ) ); - assertArrayEquals( toAddresses( "cc@bar.com", "cc@foo.com" ), message.getRecipients( Message.RecipientType.CC ) ); - assertArrayEquals( toAddresses( "bcc@bar.com", "bcc@foo.com" ), message.getRecipients( Message.RecipientType.BCC ) ); - assertArrayEquals( toAddresses( "replyTo@bar.com", "replyTo@foo.com" ), message.getReplyTo() ); + assertEquals( "test body", message.getBody() ); + assertEquals( List.of( "from@bar.com", "from@foo.com" ), message.getFrom() ); + assertEquals( List.of( "to@bar.com", "to@foo.com" ), message.getTo() ); + assertEquals( List.of( "cc@bar.com", "cc@foo.com" ), message.getCc() ); + assertEquals( List.of( "bcc@bar.com", "bcc@foo.com" ), message.getBcc() ); + assertEquals( List.of( "replyTo@bar.com", "replyTo@foo.com" ), message.getReplyTo() ); } @Test @@ -91,21 +94,31 @@ public void testRFC822AddressMail() { runFunction( "/test/send-test.js", "rfc822AddressMail" ); - final MimeMessage message = mockCompose( this.actualMessage ); + final SendMailParams message = this.actualMessage; assertEquals( "test subject", message.getSubject() ); - assertEquals( "test body", message.getContent() ); - assertArrayEquals( toAddresses( "From Bar ", "From Foo " ), message.getFrom() ); - assertArrayEquals( toAddresses( "To Bar ", "To Foo " ), message.getRecipients( Message.RecipientType.TO ) ); + assertEquals( "test body", message.getBody() ); + assertEquals( List.of( "From Bar ", "From Foo " ), message.getFrom() ); + assertEquals( List.of( "To Bar ", "To Foo " ), message.getTo() ); } @Test public void testFailSendMail() throws Exception { - final MailService mailService = message -> + final MailService mailService = new MailService() { - throw new RuntimeException( "Error sending mail" ); + @Override + public void send( final MailMessage message ) + { + throw new RuntimeException( "Error sending mail" ); + } + + @Override + public void send( final SendMailParams message ) + { + throw new RuntimeException( "Error sending mail" ); + } }; addService( MailService.class, mailService ); @@ -120,12 +133,12 @@ public void testMailWithContentType() { runFunction( "/test/send-test.js", "sendMailWithContentType" ); - final MimeMessage message = mockCompose( this.actualMessage ); + final SendMailParams message = this.actualMessage; assertEquals( "test subject", message.getSubject() ); - assertEquals( "test body", message.getContent() ); - assertArrayEquals( toAddresses( "from@bar.com" ), message.getFrom() ); - assertArrayEquals( toAddresses( "to@bar.com" ), message.getRecipients( Message.RecipientType.TO ) ); + assertEquals( "test body", message.getBody() ); + assertEquals( List.of( "from@bar.com" ), message.getFrom() ); + assertEquals( List.of( "to@bar.com" ), message.getTo() ); assertEquals( "text/html", message.getContentType() ); } @@ -133,9 +146,19 @@ public void testMailWithContentType() public void testFailMissingFrom() throws Exception { - final MailService mailService = message -> + final MailService mailService = new MailService() { - throw new RuntimeException( "Error sending mail" ); + @Override + public void send( final MailMessage message ) + { + throw new RuntimeException( "Error sending mail" ); + } + + @Override + public void send( final SendMailParams message ) + { + throw new RuntimeException( "Error sending mail" ); + } }; addService( MailService.class, mailService ); @@ -156,11 +179,20 @@ public void testFailMissingFrom() public void testFailMissingTo() throws Exception { - final MailService mailService = message -> + addService( MailService.class, new MailService() { - throw new RuntimeException( "Error sending mail" ); - }; - addService( MailService.class, mailService ); + @Override + public void send( final MailMessage message ) + { + throw new RuntimeException( "Error sending mail" ); + } + + @Override + public void send( final SendMailParams message ) + { + throw new RuntimeException( "Error sending mail" ); + } + } ); try { @@ -181,60 +213,30 @@ public void testMailWithAttachments() { runFunction( "/test/send-test.js", "sendWithAttachments" ); - final MimeMessage message = mockCompose( this.actualMessage ); - message.saveChanges(); // required to updated headers (mimeType) - - MimeMultipart content = (MimeMultipart) message.getContent(); - assertEquals( 3, content.getCount() ); - final BodyPart first = content.getBodyPart( 0 ); - final BodyPart second = content.getBodyPart( 1 ); - final BodyPart third = content.getBodyPart( 2 ); - final String secondContent = - CharStreams.toString( new InputStreamReader( (InputStream) second.getContent(), StandardCharsets.UTF_8 ) ); - - assertEquals( "test body", first.getContent() ); - assertEquals( "image data", secondContent ); - assertEquals( "Some text", third.getContent() ); + final SendMailParams message = this.actualMessage; - assertNull( first.getFileName() ); - assertEquals( "image.png", second.getFileName() ); - assertEquals( "text.txt", third.getFileName() ); + assertEquals( 2, message.getAttachments().size() ); - assertEquals( "text/plain; charset=UTF-8", first.getContentType() ); - assertTrue( second.getContentType().startsWith( "image/png" ) ); - assertTrue( third.getContentType().startsWith( "text/plain" ) ); + final MailAttachment attachment1 = message.getAttachments().get( 0 ); + assertEquals( "image.png", attachment1.getFileName() ); + assertEquals( "image/png", attachment1.getMimeType() ); - assertEquals( "", second.getHeader( "Content-ID" )[0] ); - } - - private InternetAddress[] toAddresses( final String... addresses ) - { - return Stream.of( addresses ).map( this::toAddress ).toArray( InternetAddress[]::new ); - } + assertEquals( "image data", CharStreams.toString( + new InputStreamReader( ( attachment1.getData() ).openBufferedStream(), StandardCharsets.UTF_8 ) ) ); + Map headersAttachment1 = attachment1.getHeaders(); + assertEquals( "", headersAttachment1.get( "Content-ID" ) ); - private InternetAddress toAddress( final String address ) - throws RuntimeException - { - try - { - return new InternetAddress( address ); - } - catch ( AddressException e ) - { - throw new RuntimeException( e ); - } + final MailAttachment attachment2 = message.getAttachments().get( 1 ); + assertEquals( "text.txt", attachment2.getFileName() ); } - private MimeMessage mockCompose( final MailMessage message ) - throws Exception + public ByteSource createByteSource( final String value ) { - final MimeMessage mimeMessage = new MimeMessage( (Session) null ); - message.compose( mimeMessage ); - return mimeMessage; + return ByteSource.wrap( value.getBytes() ); } - public ByteSource createByteSource( final String value ) + private String getHeader( List headers, String header ) { - return ByteSource.wrap( value.getBytes() ); + return headers.stream().filter( h -> h.getKey().equalsIgnoreCase( header ) ).map( MailHeader::getValue ).findFirst().orElse( null ); } } diff --git a/modules/runtime/src/home/config/com.enonic.xp.mail.cfg b/modules/runtime/src/home/config/com.enonic.xp.mail.cfg index 721adf644d6..270ae53335c 100644 --- a/modules/runtime/src/home/config/com.enonic.xp.mail.cfg +++ b/modules/runtime/src/home/config/com.enonic.xp.mail.cfg @@ -3,4 +3,5 @@ # smtpAuth=false # smtpUser=user # smtpPassword=password -# smtpTLS=false \ No newline at end of file +# smtpTLS=false +# defaultFromEmail=