diff --git a/.github/labeler.yml b/.github/labeler.yml index 565313449..e242aa680 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -32,6 +32,8 @@ - any-glob-to-any-file: - spring-cloud-aws-autoconfigure/src/*/java/io/awspring/cloud/autoconfigure/ses/* - spring-cloud-aws-ses/**/* + - spring-cloud-aws-autoconfigure/src/*/java/io/awspring/cloud/autoconfigure/sesv2/* + - spring-cloud-aws-sesv2/**/* "component: sns": - changed-files: - any-glob-to-any-file: diff --git a/docs/src/main/asciidoc/_configprops.adoc b/docs/src/main/asciidoc/_configprops.adoc index 40095040c..c3f748ab0 100644 --- a/docs/src/main/asciidoc/_configprops.adoc +++ b/docs/src/main/asciidoc/_configprops.adoc @@ -85,6 +85,12 @@ |spring.cloud.aws.ses.from-arn | | Configures from ARN. Only applies to SendRawEmail operation. |spring.cloud.aws.ses.region | | Overrides the default region. |spring.cloud.aws.ses.source-arn | | Configures source ARN. Used only for sending authorization. +|spring.cloud.aws.sesv2.configuration-set-name | | Configures configuration set name. +|spring.cloud.aws.sesv2.dualstack-enabled | | Configure whether the AWS client should use the AWS dualstack endpoint. Note that not each AWS service supports dual-stack. For complete list check AWS services that support IPv6 +|spring.cloud.aws.sesv2.enabled | `+++true+++` | Enables Simple Email Service V2 integration. +|spring.cloud.aws.sesv2.endpoint | | Overrides the default endpoint. +|spring.cloud.aws.sesv2.region | | Overrides the default region. +|spring.cloud.aws.sesv2.identity-arn | | Configures identity ARN. Used only for sending authorization. |spring.cloud.aws.sns.dualstack-enabled | | Configure whether the AWS client should use the AWS dualstack endpoint. Note that not each AWS service supports dual-stack. For complete list check AWS services that support IPv6 |spring.cloud.aws.sns.enabled | `+++true+++` | Enables SNS integration. |spring.cloud.aws.sns.endpoint | | Overrides the default endpoint. @@ -98,4 +104,4 @@ |spring.cloud.aws.sqs.queue-not-found-strategy | | |spring.cloud.aws.sqs.region | | Overrides the default region. -|=== \ No newline at end of file +|=== diff --git a/docs/src/main/asciidoc/ses.adoc b/docs/src/main/asciidoc/ses.adoc index 404f325e7..8979a3a45 100644 --- a/docs/src/main/asciidoc/ses.adoc +++ b/docs/src/main/asciidoc/ses.adoc @@ -221,3 +221,32 @@ Sample IAM policy granting access to SES: ] } ---- + +=== AWS SES API v2 + +To use AWS SES API v2 instead of v1, use the following dependency instead: + +[source,xml] +---- + + io.awspring.cloud + spring-cloud-aws-starter-sesv2 + +---- + +The associated configuration options are: + +[cols="3,3,1,1"] +|=== +| Name | Description | Required | Default value +| `spring.cloud.aws.ses2.enabled` | Enables the SES integration. | No | `true` +| `spring.cloud.aws.ses2.endpoint` | Configures endpoint used by `SesClient`. | No | +| `spring.cloud.aws.ses2.region` | Configures region used by `SesClient`. | No | +| `spring.cloud.aws.ses2.identity-arn` | Configures identity ARN, used only for sending authorization. | No | +| `spring.cloud.aws.ses2.configuration-set-name` | The configuration set name used for every message | No | +|=== + +`identityArn` is the ARN of the identity that is associated with the sending authorization policy that permits you to use the email address specified as `from` when sending emails. +For more information about sending authorization, see the https://docs.aws.amazon.com/ses/latest/dg/sending-authorization.html[Amazon SES Developer Guide]. + +The `SesV2ClientCustomizer` can be used instead of the `SesClientCustomizer`. diff --git a/pom.xml b/pom.xml index a2fb96b73..5e8574596 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,7 @@ spring-cloud-aws-parameter-store spring-cloud-aws-secrets-manager spring-cloud-aws-ses + spring-cloud-aws-sesv2 spring-cloud-aws-sns spring-cloud-aws-sqs spring-cloud-aws-dynamodb @@ -52,13 +53,14 @@ spring-cloud-aws-starters/spring-cloud-aws-starter-s3 spring-cloud-aws-starters/spring-cloud-aws-starter-secrets-manager spring-cloud-aws-starters/spring-cloud-aws-starter-ses + spring-cloud-aws-starters/spring-cloud-aws-starter-sesv2 spring-cloud-aws-starters/spring-cloud-aws-starter-sns spring-cloud-aws-starters/spring-cloud-aws-starter-sqs spring-cloud-aws-samples spring-cloud-aws-test spring-cloud-aws-modulith docs - + diff --git a/spring-cloud-aws-autoconfigure/pom.xml b/spring-cloud-aws-autoconfigure/pom.xml index 47334255e..bf128aa91 100644 --- a/spring-cloud-aws-autoconfigure/pom.xml +++ b/spring-cloud-aws-autoconfigure/pom.xml @@ -66,6 +66,11 @@ spring-cloud-aws-ses true + + io.awspring.cloud + spring-cloud-aws-sesv2 + true + io.awspring.cloud spring-cloud-aws-sns diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesAutoConfiguration.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesAutoConfiguration.java new file mode 100644 index 000000000..4f9e31953 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesAutoConfiguration.java @@ -0,0 +1,77 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.sesv2; + +import io.awspring.cloud.autoconfigure.AwsSyncClientCustomizer; +import io.awspring.cloud.autoconfigure.core.*; +import io.awspring.cloud.sesv2.SimpleEmailServiceJavaMailSender; +import io.awspring.cloud.sesv2.SimpleEmailServiceMailSender; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.*; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.mail.MailSender; +import org.springframework.mail.javamail.JavaMailSender; +import software.amazon.awssdk.services.sesv2.SesV2Client; +import software.amazon.awssdk.services.sesv2.SesV2ClientBuilder; + +/** + * {@link EnableAutoConfiguration} for {@link SimpleEmailServiceMailSender} and + * {@link SimpleEmailServiceJavaMailSender}. + * + * @author Agim Emruli + * @author Eddú Meléndez + * @author Arun Patra + */ +@AutoConfiguration +@EnableConfigurationProperties(SesProperties.class) +@ConditionalOnClass({ SesV2Client.class, MailSender.class, SimpleEmailServiceJavaMailSender.class }) +@AutoConfigureAfter({ CredentialsProviderAutoConfiguration.class, RegionProviderAutoConfiguration.class }) +@ConditionalOnProperty(name = "spring.cloud.aws.sesv2.enabled", havingValue = "true", matchIfMissing = true) +public class SesAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public SesV2Client sesV2Client(SesProperties properties, AwsClientBuilderConfigurer awsClientBuilderConfigurer, + ObjectProvider> configurer, + ObjectProvider connectionDetails, + ObjectProvider sesClientCustomizers, + ObjectProvider awsSyncClientCustomizers) { + return awsClientBuilderConfigurer.configureSyncClient(SesV2Client.builder(), properties, + connectionDetails.getIfAvailable(), configurer.getIfAvailable(), sesClientCustomizers.orderedStream(), + awsSyncClientCustomizers.orderedStream()).build(); + } + + @Bean + @ConditionalOnMissingClass("jakarta.mail.Session") + @ConditionalOnMissingBean + public MailSender simpleMailSender(SesV2Client sesClient, SesProperties properties) { + return new SimpleEmailServiceMailSender(sesClient, properties.getIdentityArn(), + properties.getConfigurationSetName()); + } + + @Bean + @ConditionalOnClass(name = "jakarta.mail.Session") + @ConditionalOnMissingBean + public JavaMailSender javaMailSender(SesV2Client sesClient, SesProperties properties) { + return new SimpleEmailServiceJavaMailSender(sesClient, properties.getIdentityArn(), + properties.getConfigurationSetName()); + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesProperties.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesProperties.java new file mode 100644 index 000000000..46e1ca971 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesProperties.java @@ -0,0 +1,65 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.sesv2; + +import io.awspring.cloud.autoconfigure.AwsClientProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.lang.Nullable; + +/** + * Properties related to AWS Simple Email Service. + * + * @author Eddú Meléndez + * @author Arun Patra + */ +@ConfigurationProperties(prefix = SesProperties.PREFIX) +public class SesProperties extends AwsClientProperties { + + /** + * The prefix used for AWS credentials related properties. + */ + public static final String PREFIX = "spring.cloud.aws.sesv2"; + + /** + * Configures identity ARN. Used only for sending authorization. + */ + @Nullable + private String identityArn; + + /** + * Configures configuration set name. + */ + @Nullable + private String configurationSetName; + + @Nullable + public String getIdentityArn() { + return identityArn; + } + + @Nullable + public String getConfigurationSetName() { + return configurationSetName; + } + + public void setIdentityArn(@Nullable String identityArn) { + this.identityArn = identityArn; + } + + public void setConfigurationSetName(@Nullable String configurationSetName) { + this.configurationSetName = configurationSetName; + } +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesV2ClientCustomizer.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesV2ClientCustomizer.java new file mode 100644 index 000000000..10899ea87 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/SesV2ClientCustomizer.java @@ -0,0 +1,29 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.sesv2; + +import io.awspring.cloud.autoconfigure.AwsClientCustomizer; +import software.amazon.awssdk.services.sesv2.SesV2ClientBuilder; + +/** + * Callback interface that can be used to customize a {@link SesV2ClientBuilder}. + * + * @author Maciej Walkowiak + * @since 3.3.0 + */ +@FunctionalInterface +public interface SesV2ClientCustomizer extends AwsClientCustomizer { +} diff --git a/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/package-info.java b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/package-info.java new file mode 100644 index 000000000..6526e155b --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/main/java/io/awspring/cloud/autoconfigure/sesv2/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for Amazon SES (Simple Email Service) integrations. + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.autoconfigure.sesv2; diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 13c7a0de5..749936bad 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -12,6 +12,12 @@ "description": "Enables Simple Email Service integration.", "type": "java.lang.Boolean" }, + { + "defaultValue": true, + "name": "spring.cloud.aws.sesv2.enabled", + "description": "Enables Simple Email Service integration.", + "type": "java.lang.Boolean" + }, { "defaultValue": true, "name": "spring.cloud.aws.s3.enabled", diff --git a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 657cbf245..fb978ed25 100644 --- a/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-aws-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -4,6 +4,7 @@ io.awspring.cloud.autoconfigure.core.RegionProviderAutoConfiguration io.awspring.cloud.autoconfigure.imds.ImdsAutoConfiguration io.awspring.cloud.autoconfigure.metrics.CloudWatchExportAutoConfiguration io.awspring.cloud.autoconfigure.ses.SesAutoConfiguration +io.awspring.cloud.autoconfigure.sesv2.SesAutoConfiguration io.awspring.cloud.autoconfigure.s3.S3TransferManagerAutoConfiguration io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration io.awspring.cloud.autoconfigure.s3.S3CrtAsyncClientAutoConfiguration diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sesv2/SesAutoConfigurationTest.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sesv2/SesAutoConfigurationTest.java new file mode 100644 index 000000000..6eb1707da --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sesv2/SesAutoConfigurationTest.java @@ -0,0 +1,147 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.sesv2; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.awspring.cloud.autoconfigure.ConfiguredAwsClient; +import io.awspring.cloud.autoconfigure.core.AwsAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.AwsClientCustomizer; +import io.awspring.cloud.autoconfigure.core.CredentialsProviderAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.RegionProviderAutoConfiguration; +import java.net.URI; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.MailSender; +import org.springframework.mail.javamail.JavaMailSender; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.http.SdkHttpClient; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.services.sesv2.SesV2Client; +import software.amazon.awssdk.services.sesv2.SesV2ClientBuilder; + +/** + * Tests for class {@link io.awspring.cloud.autoconfigure.ses.SesAutoConfiguration}. + * + * @author Eddú Meléndez + * @author Maciej Walkowiak + * @author Arun Patra + */ +class SesAutoConfigurationTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.cloud.aws.region.static:eu-west-1") + .withConfiguration(AutoConfigurations.of(AwsAutoConfiguration.class, RegionProviderAutoConfiguration.class, + CredentialsProviderAutoConfiguration.class, SesAutoConfiguration.class)); + + @Test + void mailSenderWithJavaMail() { + this.contextRunner.run(context -> { + assertThat(context).hasSingleBean(MailSender.class); + assertThat(context).hasSingleBean(JavaMailSender.class); + assertThat(context).getBean(JavaMailSender.class).isSameAs(context.getBean(MailSender.class)); + }); + } + + @Test + void mailSenderWithoutSesV2ClientInTheClasspath() { + this.contextRunner.withClassLoader(new FilteredClassLoader(SesV2Client.class)).run(context -> { + assertThat(context).doesNotHaveBean(MailSender.class); + assertThat(context).doesNotHaveBean(JavaMailSender.class); + }); + } + + @Test + void mailSenderWithSimpleEmail() { + this.contextRunner.withClassLoader(new FilteredClassLoader(jakarta.mail.Session.class)).run(context -> { + assertThat(context).hasSingleBean(MailSender.class); + assertThat(context).getBean("simpleMailSender").isNotNull().isSameAs(context.getBean(MailSender.class)); + }); + } + + @Test + void sesAutoConfigurationIsDisabled() { + this.contextRunner.withPropertyValues("spring.cloud.aws.sesv2.enabled:false").run(context -> { + assertThat(context).doesNotHaveBean(MailSender.class); + assertThat(context).doesNotHaveBean(JavaMailSender.class); + }); + } + + @Test + void withCustomEndpoint() { + this.contextRunner.withPropertyValues("spring.cloud.aws.sesv2.endpoint:http://localhost:8090").run(context -> { + ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SesV2Client.class)); + assertThat(client.getEndpoint()).isEqualTo(URI.create("http://localhost:8090")); + assertThat(client.isEndpointOverridden()).isTrue(); + }); + } + + @Test + void withCustomGlobalEndpoint() { + this.contextRunner.withPropertyValues("spring.cloud.aws.endpoint:http://localhost:8090").run(context -> { + ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SesV2Client.class)); + assertThat(client.getEndpoint()).isEqualTo(URI.create("http://localhost:8090")); + assertThat(client.isEndpointOverridden()).isTrue(); + }); + } + + @Test + void withCustomGlobalEndpointAndSesEndpoint() { + this.contextRunner.withPropertyValues("spring.cloud.aws.endpoint:http://localhost:8090", + "spring.cloud.aws.sesv2.endpoint:http://localhost:9999").run(context -> { + ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SesV2Client.class)); + assertThat(client.getEndpoint()).isEqualTo(URI.create("http://localhost:9999")); + assertThat(client.isEndpointOverridden()).isTrue(); + }); + } + + @Test + void customSesV2ClientConfigurer() { + this.contextRunner.withUserConfiguration(CustomAwsClientConfig.class).run(context -> { + ConfiguredAwsClient sesClient = new ConfiguredAwsClient(context.getBean(SesV2Client.class)); + assertThat(sesClient.getApiCallTimeout()).isEqualTo(Duration.ofMillis(2000)); + assertThat(sesClient.getSyncHttpClient()).isNotNull(); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomAwsClientConfig { + + @Bean + AwsClientCustomizer snsClientBuilderAwsClientConfigurer() { + return new SesAwsClientConfigurer(); + } + + static class SesAwsClientConfigurer implements AwsClientCustomizer { + @Override + public ClientOverrideConfiguration overrideConfiguration() { + return ClientOverrideConfiguration.builder().apiCallTimeout(Duration.ofMillis(2000)).build(); + } + + @Override + public SdkHttpClient httpClient() { + return ApacheHttpClient.builder().connectionTimeout(Duration.ofMillis(1542)).build(); + } + } + + } + +} diff --git a/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sesv2/SesV2ClientCustomizerTests.java b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sesv2/SesV2ClientCustomizerTests.java new file mode 100644 index 000000000..4f8a00851 --- /dev/null +++ b/spring-cloud-aws-autoconfigure/src/test/java/io/awspring/cloud/autoconfigure/sesv2/SesV2ClientCustomizerTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2013-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.autoconfigure.sesv2; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.awspring.cloud.autoconfigure.AwsSyncClientCustomizer; +import io.awspring.cloud.autoconfigure.ConfiguredAwsClient; +import io.awspring.cloud.autoconfigure.core.AwsAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.CredentialsProviderAutoConfiguration; +import io.awspring.cloud.autoconfigure.core.RegionProviderAutoConfiguration; +import io.awspring.cloud.autoconfigure.ses.SesAutoConfiguration; +import io.awspring.cloud.autoconfigure.ses.SesClientCustomizer; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import software.amazon.awssdk.http.apache.ApacheHttpClient; +import software.amazon.awssdk.services.ses.SesClient; + +/** + * Tests for {@link SesClientCustomizer}. + * + * @author Maciej Walkowiak + */ +class SesV2ClientCustomizerTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withPropertyValues("spring.cloud.aws.region.static:eu-west-1", + "spring.cloud.aws.credentials.access-key:noop", "spring.cloud.aws.credentials.secret-key:noop") + .withConfiguration(AutoConfigurations.of(AwsAutoConfiguration.class, RegionProviderAutoConfiguration.class, + CredentialsProviderAutoConfiguration.class, SesAutoConfiguration.class)); + + @Test + void customClientCustomizer() { + contextRunner.withUserConfiguration(CustomizerConfig.class).run(context -> { + ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SesClient.class)); + assertThat(client.getApiCallTimeout()).describedAs("sets property from first customizer") + .isEqualTo(Duration.ofMillis(2001)); + assertThat(client.getApiCallAttemptTimeout()).describedAs("sets property from second customizer") + .isEqualTo(Duration.ofMillis(2002)); + assertThat(client.getSyncHttpClient()).describedAs("sets property from common client customizer") + .isNotNull(); + }); + } + + @Test + void customClientCustomizerWithOrder() { + contextRunner.withUserConfiguration(CustomizerConfigWithOrder.class).run(context -> { + ConfiguredAwsClient client = new ConfiguredAwsClient(context.getBean(SesClient.class)); + assertThat(client.getApiCallTimeout()) + .describedAs("property from the customizer with higher order takes precedence") + .isEqualTo(Duration.ofMillis(2001)); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomizerConfig { + + @Bean + SesClientCustomizer customizer() { + return builder -> { + builder.overrideConfiguration(builder.overrideConfiguration().copy(c -> { + c.apiCallTimeout(Duration.ofMillis(2001)); + })); + }; + } + + @Bean + SesClientCustomizer customizer2() { + return builder -> { + builder.overrideConfiguration(builder.overrideConfiguration().copy(c -> { + c.apiCallAttemptTimeout(Duration.ofMillis(2002)); + })); + }; + } + + @Bean + AwsSyncClientCustomizer awsSyncClientCustomizer() { + return builder -> { + builder.httpClient(ApacheHttpClient.builder().connectionTimeout(Duration.ofMillis(1542)).build()); + }; + } + } + + @Configuration(proxyBeanMethods = false) + static class CustomizerConfigWithOrder { + + @Bean + @Order(2) + SesClientCustomizer customizer() { + return builder -> { + builder.overrideConfiguration(builder.overrideConfiguration().copy(c -> { + c.apiCallTimeout(Duration.ofMillis(2001)); + })); + }; + } + + @Bean + @Order(1) + SesClientCustomizer customizer2() { + return builder -> { + builder.overrideConfiguration(builder.overrideConfiguration().copy(c -> { + c.apiCallTimeout(Duration.ofMillis(2000)); + })); + }; + } + } + +} diff --git a/spring-cloud-aws-dependencies/pom.xml b/spring-cloud-aws-dependencies/pom.xml index 4ef833ef7..acd13f368 100644 --- a/spring-cloud-aws-dependencies/pom.xml +++ b/spring-cloud-aws-dependencies/pom.xml @@ -132,6 +132,12 @@ ${project.version} + + io.awspring.cloud + spring-cloud-aws-sesv2 + ${project.version} + + io.awspring.cloud spring-cloud-aws-s3 @@ -192,6 +198,12 @@ ${project.version} + + io.awspring.cloud + spring-cloud-aws-starter-sesv2 + ${project.version} + + io.awspring.cloud spring-cloud-aws-starter-s3 diff --git a/spring-cloud-aws-samples/pom.xml b/spring-cloud-aws-samples/pom.xml index d6a00fe4f..2814ada85 100644 --- a/spring-cloud-aws-samples/pom.xml +++ b/spring-cloud-aws-samples/pom.xml @@ -21,6 +21,7 @@ spring-cloud-aws-s3-sample spring-cloud-aws-secrets-manager-sample spring-cloud-aws-ses-sample + spring-cloud-aws-sesv2-sample spring-cloud-aws-sns-sample spring-cloud-aws-sqs-sample diff --git a/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/docker-compose.yml b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/docker-compose.yml new file mode 100644 index 000000000..5fb0086c0 --- /dev/null +++ b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + sesv2-sample-localstack: + container_name: localstack + environment: + - DEBUG=1 + - LOCALSTACK_HOSTNAME=localhost + - TEST_AWS_ACCOUNT_ID=000000000000 + - AWS_DEFAULT_REGION=us-east-1 + - SERVICES=sesv2 + - S3_MOUNT=/tmp + - LOCALSTACK_AUTH_TOKEN= + # SES V2 is only available in the pro version. See https://docs.localstack.cloud/references/coverage/coverage_sesv2/ + image: localstack/localstack-pro:latest + ports: + - "4566:4566" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + diff --git a/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/pom.xml b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/pom.xml new file mode 100644 index 000000000..52a4b1b5e --- /dev/null +++ b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/pom.xml @@ -0,0 +1,42 @@ + + + + io.awspring.cloud + spring-cloud-aws-samples + 3.4.0-SNAPSHOT + + 4.0.0 + + spring-cloud-aws-sesv2-sample + Spring Cloud AWS SES V2 Sample + + + + io.awspring.cloud + spring-cloud-aws-starter-sesv2 + + + + jakarta.mail + jakarta.mail-api + + + + org.eclipse.angus + jakarta.mail + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/java/io/awspring/cloud/samples/sesv2/MailSendingApplication.java b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/java/io/awspring/cloud/samples/sesv2/MailSendingApplication.java new file mode 100644 index 000000000..fc3802f0a --- /dev/null +++ b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/java/io/awspring/cloud/samples/sesv2/MailSendingApplication.java @@ -0,0 +1,150 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.samples.sesv2; + +import java.io.File; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mail.MailSender; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import software.amazon.awssdk.services.sesv2.SesV2Client; + +/** + * A sample application to demonstrate sending simple emails and emails with attachments. + *

+ * To run this sample application, you need to do either of the following. + *

    + *
  • If you wish to send emails to a real email ID, you need to verify identities as mentioned in + * the Amazon Simple Email Service + * docs. After you do that, simply update this sample app with email IDs that you have verified with AWS.
  • + *
  • If you wish to just test out email sending capability in a test environment, you can do so by running localstack. + * Just issue the following command from the root of the `spring-cloud-aws-ses-sample`: + * + *
    + * docker-compose -f docker-compose.yml up -d
    + * 
    + * + * See more information on localstack see here and + * here.
  • + *
+ *

+ * + */ +@SpringBootApplication +public class MailSendingApplication { + private static final String SENDER = "something@foo.bar"; + private static final String RECIPIENT = "someMail@foo.bar"; + + public static void main(String[] args) { + SpringApplication.run(MailSendingApplication.class, args); + } + + @Bean + ApplicationRunner applicationRunner(MailSender mailSender, SesV2Client sesClient) { + return args -> { + sendAnEmail(mailSender, sesClient); + sendAnEmailWithAttachment(mailSender, sesClient); + sendHtmlEmail(mailSender, sesClient); + // check localstack logs for sent email, if you use localstack for running this sample + }; + } + + public static void sendAnEmail(MailSender mailSender, SesV2Client sesClient) { + // e-mail address has to verified before we email it. If it is not verified SES will return error. + sesClient.createEmailIdentity(builder -> builder.emailIdentity(RECIPIENT).build()); + sesClient.createEmailIdentity(builder -> builder.emailIdentity(SENDER).build()); + + // SimpleMailMessage is created, and we use MailSender bean which is autoconfigured to send an email through + // SES. + SimpleMailMessage simpleMailMessage = new SimpleMailMessage(); + simpleMailMessage.setFrom(SENDER); + simpleMailMessage.setTo(RECIPIENT); + simpleMailMessage.setSubject("test subject"); + simpleMailMessage.setText("test content"); + mailSender.send(simpleMailMessage); + } + + /** + * To send emails with attachments, you must provide the Java Mail API and an implementation of the API in the + * classpath. See the dependencies provided in this sample app. If you don't provider an implementation of the Java + * Mail API, you would get the following exception at runtime. + * + *
+	 * java.lang.IllegalStateException: Not provider of jakarta.mail.util.StreamProvider was found
+	 * 
+ * + * @param mailSender A {@link JavaMailSender}. + * @param sesClient An {@link SesV2Client}. + */ + public static void sendAnEmailWithAttachment(MailSender mailSender, SesV2Client sesClient) { + // e-mail address has to verified before we email it. If it is not verified SES will return error. + sesClient.createEmailIdentity(builder -> builder.emailIdentity(RECIPIENT).build()); + sesClient.createEmailIdentity(builder -> builder.emailIdentity(SENDER).build()); + + // A JavaMailSender is needed. Spring Cloud AWS SES automatically configures a JavaMailSender when it finds + // the Java Mail API in the classpath. At runtime, an implementation of teh Java Mail API must also be + // available. + JavaMailSender javaMailSender = (JavaMailSender) mailSender; + javaMailSender.send(mimeMessage -> { + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + helper.addTo(RECIPIENT); + helper.setFrom(SENDER); + File resource = new ClassPathResource("answer.txt").getFile(); + helper.addAttachment("answer.txt", resource.getAbsoluteFile()); + helper.setSubject("What is the meaning of life, the universe, and everything?"); + helper.setText("Open the attached file for the answer you are seeking", false); + }); + } + + /** + * To send HTML emails, you must provide the Java Mail API and an implementation of the API in the classpath. See + * the dependencies provided in this sample app. If you don't provider an implementation of the Java Mail API, you + * would get the following exception at runtime. + * + *
+	 * java.lang.IllegalStateException: Not provider of jakarta.mail.util.StreamProvider was found
+	 * 
+ * + * @param mailSender A {@link JavaMailSender}. + * @param sesClient An {@link SesV2Client}. + */ + public static void sendHtmlEmail(MailSender mailSender, SesV2Client sesClient) { + // e-mail address has to verified before we email it. If it is not verified SES will return error. + sesClient.createEmailIdentity(builder -> builder.emailIdentity(RECIPIENT).build()); + sesClient.createEmailIdentity(builder -> builder.emailIdentity(SENDER).build()); + + // A JavaMailSender is needed. Spring Cloud AWS SES automatically configures a JavaMailSender when it finds + // the Java Mail API in the classpath. At runtime, an implementation of the Java Mail API must also be + // available. + JavaMailSender javaMailSender = (JavaMailSender) mailSender; + javaMailSender.send(mimeMessage -> { + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + helper.addTo(RECIPIENT); + helper.setFrom(SENDER); + helper.setSubject("What is the meaning of life, the universe, and everything?"); + String htmlMessage = """ +

What is the meaning of life, the universe, and everything?

+

42

+ """; + helper.setText(htmlMessage, true); + }); + } +} diff --git a/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/resources/answer.txt b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/resources/answer.txt new file mode 100644 index 000000000..2980103fe --- /dev/null +++ b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/resources/answer.txt @@ -0,0 +1,2 @@ +Question to Spring Cloud AWS: What is the meaning of life, the universe, and everything? +Answer from Spring Cloud AWS: 42 diff --git a/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/resources/application.properties b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/resources/application.properties new file mode 100644 index 000000000..f5935aa2b --- /dev/null +++ b/spring-cloud-aws-samples/spring-cloud-aws-sesv2-sample/src/main/resources/application.properties @@ -0,0 +1,7 @@ +# LocalStack configuration +spring.cloud.aws.endpoint=http://localhost:4566 +spring.cloud.aws.region.static=us-east-1 +spring.cloud.aws.credentials.access-key=noop +spring.cloud.aws.credentials.secret-key=noop +spring.cloud.aws.sesv2.enabled=true +spring.cloud.aws.ses.enabled=false diff --git a/spring-cloud-aws-sesv2/pom.xml b/spring-cloud-aws-sesv2/pom.xml new file mode 100644 index 000000000..32287919e --- /dev/null +++ b/spring-cloud-aws-sesv2/pom.xml @@ -0,0 +1,44 @@ + + + + io.awspring.cloud + spring-cloud-aws + 3.4.0-SNAPSHOT + + 4.0.0 + + spring-cloud-aws-sesv2 + Spring Cloud AWS SES V2 Integration + + + + org.springframework + spring-context + + + org.springframework + spring-context-support + + + software.amazon.awssdk + sesv2 + + + jakarta.mail + jakarta.mail-api + true + + + jakarta.activation + jakarta.activation-api + true + + + org.eclipse.angus + jakarta.mail + test + + + diff --git a/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/SimpleEmailServiceJavaMailSender.java b/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/SimpleEmailServiceJavaMailSender.java new file mode 100644 index 000000000..ae7114f38 --- /dev/null +++ b/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/SimpleEmailServiceJavaMailSender.java @@ -0,0 +1,278 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.sesv2; + +import jakarta.activation.FileTypeMap; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.lang.Nullable; +import org.springframework.mail.MailException; +import org.springframework.mail.MailParseException; +import org.springframework.mail.MailPreparationException; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.ConfigurableMimeFileTypeMap; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.mail.javamail.MimeMessagePreparator; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.sesv2.SesV2Client; +import software.amazon.awssdk.services.sesv2.model.RawMessage; +import software.amazon.awssdk.services.sesv2.model.SendEmailRequest; +import software.amazon.awssdk.services.sesv2.model.SendEmailResponse; + +/** + * {@link JavaMailSender} implementation that allows to send {@link MimeMessage} using the Simple E-Mail Service. In + * contrast to {@link SimpleEmailServiceMailSender} this class also allows the use of attachment and other mime parts + * inside mail messages. + * + * @author Agim Emruli + * @author Eddú Meléndez + * @author Arun Patra + * @since 1.0 + */ +public class SimpleEmailServiceJavaMailSender extends SimpleEmailServiceMailSender implements JavaMailSender { + + private static final Logger LOGGER = LoggerFactory.getLogger(SimpleEmailServiceJavaMailSender.class); + + private static final String SMART_MIME_MESSAGE_CLASS_NAME = "org.springframework.mail.javamail.SmartMimeMessage"; + + private Properties javaMailProperties = new Properties(); + + @Nullable + private volatile Session session; + + @Nullable + private String defaultEncoding; + + @Nullable + private FileTypeMap defaultFileTypeMap; + + public SimpleEmailServiceJavaMailSender(SesV2Client sesClient) { + this(sesClient, null); + } + + public SimpleEmailServiceJavaMailSender(SesV2Client sesClient, @Nullable String sourceArn) { + this(sesClient, sourceArn, null); + } + + public SimpleEmailServiceJavaMailSender(SesV2Client sesClient, @Nullable String sourceArn, + @Nullable String configurationSetName) { + super(sesClient, sourceArn, configurationSetName); + } + + /** + * Allow Map access to the JavaMail properties of this sender, with the option to add or override specific entries. + *

+ * Useful for specifying entries directly, for example via "javaMailProperties[mail.from]". + * + * @return java mail properties + */ + protected Properties getJavaMailProperties() { + return this.javaMailProperties; + } + + /** + * Set JavaMail properties for the {@code Session}. + *

+ * A new {@code Session} will be created with those properties. + *

+ * Non-default properties in this instance will override given JavaMail properties. + * + * @param javaMailProperties java mail props + */ + public void setJavaMailProperties(Properties javaMailProperties) { + Assert.notNull(javaMailProperties, "javaMailProperties are required"); + this.javaMailProperties = javaMailProperties; + this.session = null; + } + + /** + * Return the JavaMail {@code Session}, lazily initializing it if hasn't been specified explicitly. + * + * @return cached session or a new one from java mail properties + */ + @Nullable + protected Session getSession() { + if (this.session == null) { + this.session = Session.getInstance(getJavaMailProperties()); + } + return this.session; + } + + /** + * Set the JavaMail {@code Session}, possibly pulled from JNDI. + *

+ * Default is a new {@code Session} without defaults, that is completely configured via this instance's properties. + *

+ * If using a pre-configured {@code Session}, non-default properties in this instance will override the settings in + * the {@code Session}. + * + * @param session JavaMail session + * @see #setJavaMailProperties + */ + public void setSession(Session session) { + Assert.notNull(session, "Session must not be null"); + this.session = session; + } + + /** + * Set the default encoding to use for {@link MimeMessage MimeMessages} created by this instance. + *

+ * Such an encoding will be auto-detected by {@link MimeMessageHelper}. + * + * @param defaultEncoding default encoding for mime messages + */ + public void setDefaultEncoding(String defaultEncoding) { + this.defaultEncoding = defaultEncoding; + } + + /** + * Set the default Java Activation {@link FileTypeMap} to use for {@link MimeMessage MimeMessages} created by this + * instance. + *

+ * A {@code FileTypeMap} specified here will be autodetected by {@link MimeMessageHelper}, avoiding the need to + * specify the {@code FileTypeMap} for each {@code MimeMessageHelper} instance. + *

+ * For example, you can specify a custom instance of Spring's {@link ConfigurableMimeFileTypeMap} here. If not + * explicitly specified, a default {@code ConfigurableMimeFileTypeMap} will be used, containing an extended set of + * MIME type mappings (as defined by the {@code mime.types} file contained in the Spring jar). + * + * @param defaultFileTypeMap Java Activation file type map + * @see MimeMessageHelper#setFileTypeMap + */ + public void setDefaultFileTypeMap(FileTypeMap defaultFileTypeMap) { + this.defaultFileTypeMap = defaultFileTypeMap; + } + + @Override + public MimeMessage createMimeMessage() { + + // We have to use reflection as SmartMimeMessage is not package-private + if (ClassUtils.isPresent(SMART_MIME_MESSAGE_CLASS_NAME, ClassUtils.getDefaultClassLoader())) { + Class smartMimeMessage = ClassUtils.resolveClassName(SMART_MIME_MESSAGE_CLASS_NAME, + ClassUtils.getDefaultClassLoader()); + Constructor constructor = ClassUtils.getConstructorIfAvailable(smartMimeMessage, Session.class, + String.class, FileTypeMap.class); + if (constructor != null) { + Object mimeMessage = BeanUtils.instantiateClass(constructor, getSession(), this.defaultEncoding, + this.defaultFileTypeMap); + return (MimeMessage) mimeMessage; + } + } + + return new MimeMessage(getSession()); + } + + @Override + public MimeMessage createMimeMessage(InputStream contentStream) throws MailException { + Assert.notNull(contentStream, "contentStream are required"); + try { + return new MimeMessage(getSession(), contentStream); + } + catch (MessagingException e) { + throw new MailParseException("Could not parse raw MIME content", e); + } + } + + @Override + public void send(MimeMessage mimeMessage) throws MailException { + Assert.notNull(mimeMessage, "mimeMessage are required"); + this.send(new MimeMessage[] { mimeMessage }); + } + + @SuppressWarnings("OverloadedVarargsMethod") + @Override + public void send(MimeMessage... mimeMessages) throws MailException { + Assert.notNull(mimeMessages, "mimeMessages are required"); + Map failedMessages = new HashMap<>(); + + for (MimeMessage mimeMessage : mimeMessages) { + try { + SendEmailRequest request = SendEmailRequest.builder().fromEmailAddressIdentityArn(getIdentityArn()) + .configurationSetName(getConfigurationSetName()) + .content(content -> content.raw(createRawMessage(mimeMessage))).build(); + + SendEmailResponse sendEmailResponse = getEmailService().sendEmail(request); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Message with id: {} successfully sent", sendEmailResponse.messageId()); + } + mimeMessage.setHeader("Message-ID", sendEmailResponse.messageId()); + } + catch (Exception e) { + // Ignore Exception because we are collecting and throwing all if any + // noinspection ThrowableResultOfMethodCallIgnored + failedMessages.put(mimeMessage, e); + } + } + + if (!failedMessages.isEmpty()) { + throw new MailSendException(failedMessages); + } + } + + @Override + public void send(MimeMessagePreparator mimeMessagePreparator) throws MailException { + Assert.notNull(mimeMessagePreparator, "mimeMessagePreparator are required"); + send(new MimeMessagePreparator[] { mimeMessagePreparator }); + } + + @SuppressWarnings("OverloadedVarargsMethod") + @Override + public void send(MimeMessagePreparator... mimeMessagePreparators) throws MailException { + Assert.notNull(mimeMessagePreparators, "mimeMessagePreparator are required"); + MimeMessage mimeMessage = createMimeMessage(); + for (MimeMessagePreparator mimeMessagePreparator : mimeMessagePreparators) { + try { + mimeMessagePreparator.prepare(mimeMessage); + } + catch (Exception e) { + throw new MailPreparationException(e); + } + } + send(mimeMessage); + } + + private RawMessage createRawMessage(MimeMessage mimeMessage) { + ByteArrayOutputStream out; + try { + out = new ByteArrayOutputStream(); + mimeMessage.writeTo(out); + } + catch (IOException e) { + throw new MailPreparationException(e); + } + catch (MessagingException e) { + throw new MailParseException(e); + } + return RawMessage.builder().data(SdkBytes.fromByteBuffer(ByteBuffer.wrap(out.toByteArray()))).build(); + } + +} diff --git a/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/SimpleEmailServiceMailSender.java b/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/SimpleEmailServiceMailSender.java new file mode 100644 index 000000000..af55f7590 --- /dev/null +++ b/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/SimpleEmailServiceMailSender.java @@ -0,0 +1,146 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.sesv2; + +import java.util.HashMap; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.lang.Nullable; +import org.springframework.mail.MailException; +import org.springframework.mail.MailSendException; +import org.springframework.mail.MailSender; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import software.amazon.awssdk.services.sesv2.SesV2Client; +import software.amazon.awssdk.services.sesv2.model.SendEmailRequest; +import software.amazon.awssdk.services.sesv2.model.SendEmailResponse; +import software.amazon.awssdk.services.sesv2.model.SesV2Exception; + +/** + * Simple MailSender implementation to send E-Mails with the Amazon Simple Email Service. This implementation has no + * dependencies to the Java Mail API. It can be used to send simple mail messages that doesn't have any attachment and + * therefore only consist of a text body and a subject line. + * + * @author Agim Emruli + * @author Eddú Meléndez + * @author Arun Patra + */ +public class SimpleEmailServiceMailSender implements MailSender, DisposableBean { + + private static final Logger LOGGER = LoggerFactory.getLogger(SimpleEmailServiceMailSender.class); + + private final SesV2Client sesClient; + + @Nullable + private final String identityArn; + + @Nullable + private final String configurationSetName; + + public SimpleEmailServiceMailSender(SesV2Client sesClient) { + this(sesClient, null); + } + + public SimpleEmailServiceMailSender(SesV2Client sesClient, @Nullable String identityArn) { + this(sesClient, identityArn, null); + } + + public SimpleEmailServiceMailSender(SesV2Client sesClient, @Nullable String identityArn, + @Nullable String configurationSetName) { + this.sesClient = sesClient; + this.identityArn = identityArn; + this.configurationSetName = configurationSetName; + } + + @Override + public void destroy() { + sesClient.close(); + } + + @Override + public void send(SimpleMailMessage simpleMessage) throws MailException { + Assert.notNull(simpleMessage, "simpleMessage are required"); + send(new SimpleMailMessage[] { simpleMessage }); + } + + @Override + public void send(SimpleMailMessage... simpleMessages) throws MailException { + Assert.notNull(simpleMessages, "simpleMessages are required"); + Map failedMessages = new HashMap<>(); + + for (SimpleMailMessage simpleMessage : simpleMessages) { + try { + SendEmailResponse sendEmailResult = getEmailService().sendEmail(prepareMessage(simpleMessage)); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Message with id: {} successfully send", sendEmailResult.messageId()); + } + } + catch (SesV2Exception e) { + // Ignore Exception because we are collecting and throwing all if any + // noinspection ThrowableResultOfMethodCallIgnored + failedMessages.put(simpleMessage, e); + } + } + + if (!failedMessages.isEmpty()) { + throw new MailSendException(failedMessages); + } + + } + + protected SesV2Client getEmailService() { + return this.sesClient; + } + + @Nullable + protected String getIdentityArn() { + return identityArn; + } + + @Nullable + protected String getConfigurationSetName() { + return configurationSetName; + } + + private SendEmailRequest prepareMessage(SimpleMailMessage simpleMailMessage) { + Assert.notNull(simpleMailMessage, "simpleMailMessage are required"); + SendEmailRequest.Builder emailRequestBuilder = SendEmailRequest.builder().destination(destination -> { + if (simpleMailMessage.getTo() != null) { + destination.toAddresses(simpleMailMessage.getTo()); + } + if (simpleMailMessage.getCc() != null) { + destination.ccAddresses(simpleMailMessage.getCc()); + } + if (simpleMailMessage.getBcc() != null) { + destination.bccAddresses(simpleMailMessage.getBcc()); + } + }).fromEmailAddress(simpleMailMessage.getFrom()).configurationSetName(getConfigurationSetName()) + .fromEmailAddressIdentityArn(getIdentityArn()) + .content(content -> content + .simple(message -> message.subject(subject -> subject.data(simpleMailMessage.getSubject())) + .body(body -> body.text(text -> text.data(simpleMailMessage.getText()))))); + + if (StringUtils.hasText(simpleMailMessage.getReplyTo())) { + emailRequestBuilder.replyToAddresses(simpleMailMessage.getReplyTo()); + } + + return emailRequestBuilder.build(); + } + +} diff --git a/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/package-info.java b/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/package-info.java new file mode 100644 index 000000000..a07141fdd --- /dev/null +++ b/spring-cloud-aws-sesv2/src/main/java/io/awspring/cloud/sesv2/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Integration with AWS SES V2 (Simple Email Service). + */ +@org.springframework.lang.NonNullApi +@org.springframework.lang.NonNullFields +package io.awspring.cloud.sesv2; diff --git a/spring-cloud-aws-sesv2/src/test/java/io/awspring/cloud/sesv2/SimpleEmailServiceJavaMailSenderTest.java b/spring-cloud-aws-sesv2/src/test/java/io/awspring/cloud/sesv2/SimpleEmailServiceJavaMailSenderTest.java new file mode 100644 index 000000000..305ebb0fb --- /dev/null +++ b/spring-cloud-aws-sesv2/src/test/java/io/awspring/cloud/sesv2/SimpleEmailServiceJavaMailSenderTest.java @@ -0,0 +1,349 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.sesv2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.Mockito.*; + +import jakarta.activation.FileTypeMap; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.io.*; +import java.util.Objects; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.springframework.mail.MailParseException; +import org.springframework.mail.MailPreparationException; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.mail.javamail.MimeMessagePreparator; +import software.amazon.awssdk.services.sesv2.SesV2Client; +import software.amazon.awssdk.services.sesv2.model.SendEmailRequest; +import software.amazon.awssdk.services.sesv2.model.SendEmailResponse; +import software.amazon.awssdk.services.sesv2.model.SesV2Exception; + +/** + * Tests for class {@link SimpleEmailServiceJavaMailSender}. + * + * @author Eddú Meléndez + * @author Maciej Walkowiak + * @author Arun Patra + */ +class SimpleEmailServiceJavaMailSenderTest { + + @Test + void createMimeMessage_withDefaultPropertiesAndNoEncodingAndFileTypeMap_returnsSessionWithEmptyProperties() { + // Arrange + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + + // Assert + assertThat(mimeMessage).isNotNull(); + assertThat(mimeMessage.getSession().getProperties()).isEmpty(); + } + + @Test + void createMimeMessage_withCustomProperties_sessionMaintainsCustomProperties() { + // Arrange + Properties mailProperties = new Properties(); + mailProperties.setProperty("mail.from", "agim.emruli@maildomain.com"); + + SimpleEmailServiceJavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + mailSender.setJavaMailProperties(mailProperties); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + + // Assert + assertThat(mimeMessage).isNotNull(); + assertThat(mimeMessage.getSession().getProperty("mail.from")).isEqualTo("agim.emruli@maildomain.com"); + } + + @Test + void createMimeMessage_withCustomSession_sessionUsedInMailIsCustomSession() { + // Arrange + Session customSession = Session.getInstance(new Properties()); + + SimpleEmailServiceJavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + mailSender.setSession(customSession); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + + // Assert + assertThat(mimeMessage.getSession()).isSameAs(customSession); + } + + @Test + void createMimeMessage_withCustomEncoding_encodingIsDetectedInMimeMessageHelper() { + // Arrange + SimpleEmailServiceJavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + mailSender.setDefaultEncoding("ISO-8859-1"); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + + // Assert + assertThat(mimeMessageHelper.getEncoding()).isEqualTo("ISO-8859-1"); + } + + @Test + void createMimeMessage_withCustomFileTypeMap_fileTypeMapIsAvailableInMailSender() { + // Arrange + SimpleEmailServiceJavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + mailSender.setDefaultFileTypeMap(FileTypeMap.getDefaultFileTypeMap()); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + + // Assert + assertThat(mimeMessageHelper.getFileTypeMap()).as("ISO-8859-1").isNotNull(); + } + + @Test + void testCreateMimeMessageFromPreDefinedMessage() throws Exception { + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + + MimeMessage original = createMimeMessage(); + + MimeMessage mimeMessage = mailSender + .createMimeMessage(new ByteArrayInputStream(getMimeMessageAsByteArray(original))); + assertThat(mimeMessage).isNotNull(); + assertThat(mimeMessage.getSubject()).isEqualTo(original.getSubject()); + assertThat(mimeMessage.getContent()).isEqualTo(original.getContent()); + assertThat(mimeMessage.getRecipients(Message.RecipientType.TO)[0]) + .isEqualTo(original.getRecipients(Message.RecipientType.TO)[0]); + } + + @Test + void testSendMimeMessage() throws MessagingException { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService, + "arn:aws:ses:us-east-1:00000001:identity/domain.com"); + + ArgumentCaptor request = ArgumentCaptor.forClass(SendEmailRequest.class); + when(emailService.sendEmail(request.capture())) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + + MimeMessage mimeMessage = createMimeMessage(); + mailSender.send(mimeMessage); + assertThat(mimeMessage.getMessageID()).isEqualTo("123"); + assertThat(request.getValue().fromEmailAddressIdentityArn()) + .isEqualTo("arn:aws:ses:us-east-1:00000001:identity/domain.com"); + } + + @Test + void testSendMimeMessageWithConfigurationSetNameSet() throws MessagingException { + SesV2Client emailService = mock(SesV2Client.class); + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService, null, "Configuration Set"); + ArgumentCaptor request = ArgumentCaptor.forClass(SendEmailRequest.class); + when(emailService.sendEmail(request.capture())) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + MimeMessage mimeMessage = createMimeMessage(); + + mailSender.send(mimeMessage); + + assertThat(request.getValue().configurationSetName()).isEqualTo("Configuration Set"); + } + + @Test + void testSendMultipleMimeMessages() throws Exception { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + when(emailService.sendEmail(ArgumentMatchers.isA(SendEmailRequest.class))) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + + mailSender.send(createMimeMessage(), createMimeMessage()); + verify(emailService, times(2)).sendEmail(ArgumentMatchers.isA(SendEmailRequest.class)); + } + + @Test + void testSendMailWithMimeMessagePreparator() throws Exception { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + MimeMessagePreparator preparator = mimeMessage -> { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + mimeMessageHelper.setTo("to@domain.com"); + mimeMessageHelper.setSubject("subject"); + mimeMessageHelper.setText("body"); + }; + + ArgumentCaptor request = ArgumentCaptor.forClass(SendEmailRequest.class); + when(emailService.sendEmail(request.capture())) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + + mailSender.send(preparator); + + MimeMessage mimeMessage = new MimeMessage(Session.getInstance(new Properties()), + new ByteArrayInputStream(request.getValue().content().raw().data().asByteArray())); + assertThat(mimeMessage.getRecipients(Message.RecipientType.TO)[0]).hasToString("to@domain.com"); + assertThat(mimeMessage.getSubject()).isEqualTo("subject"); + assertThat(mimeMessage.getContent()).isEqualTo("body"); + } + + @Test + void testSendMailWithMultipleMimeMessagePreparators() throws Exception { + + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + MimeMessagePreparator[] preparators = new MimeMessagePreparator[3]; + preparators[0] = mimeMessage -> { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + mimeMessageHelper.setTo("to@domain.com"); + }; + + preparators[1] = mimeMessage -> { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + mimeMessageHelper.setSubject("subject"); + }; + + preparators[2] = mimeMessage -> { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + mimeMessageHelper.setText("body"); + }; + + ArgumentCaptor request = ArgumentCaptor.forClass(SendEmailRequest.class); + when(emailService.sendEmail(request.capture())) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + + mailSender.send(preparators); + + MimeMessage mimeMessage = new MimeMessage(Session.getInstance(new Properties()), + new ByteArrayInputStream(request.getValue().content().raw().data().asByteArray())); + assertThat(mimeMessage.getRecipients(Message.RecipientType.TO)[0]).hasToString("to@domain.com"); + assertThat(mimeMessage.getSubject()).isEqualTo("subject"); + assertThat(mimeMessage.getContent()).isEqualTo("body"); + + } + + @Test + void testCreateMimeMessageWithExceptionInInputStream() throws Exception { + InputStream inputStream = mock(InputStream.class); + + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + IOException ioException = new IOException("error"); + when(inputStream.read(ArgumentMatchers.any(byte[].class), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenThrow(ioException); + + try { + mailSender.createMimeMessage(inputStream); + fail("MailPreparationException expected due to error while creating mail"); + } + catch (MailParseException e) { + assertThat(Objects.requireNonNull(e.getMessage()).startsWith("Could not parse raw MIME content")).isTrue(); + assertThat(e.getCause().getCause()).isSameAs(ioException); + } + } + + @Test + void testSendMultipleMailsWithException() throws Exception { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + MimeMessage failureMail = createMimeMessage(); + + when(emailService.sendEmail(ArgumentMatchers.isA(SendEmailRequest.class))) + .thenReturn(SendEmailResponse.builder().build()) + .thenThrow(SesV2Exception.builder().message("error").build()) + .thenReturn(SendEmailResponse.builder().build()); + + try { + mailSender.send(createMimeMessage(), failureMail, createMimeMessage()); + fail("Exception expected due to error while sending mail"); + } + catch (MailSendException e) { + assertThat(e.getFailedMessages()).hasSize(1); + assertThat(e.getFailedMessages()).containsKey(failureMail); + } + } + + @Test + void testSendMailsWithExceptionWhilePreparing() { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + MimeMessage mimeMessage = null; + try { + mimeMessage = new MimeMessage(Session.getInstance(new Properties())); + mailSender.send(new MimeMessage[] { mimeMessage }); + fail("Exception expected due to error while sending mail"); + } + catch (MailSendException e) { + // expected due to empty mail message + assertThat(e.getFailedMessages()).hasSize(1); + // noinspection ThrowableResultOfMethodCallIgnored + assertThat(e.getFailedMessages().get(mimeMessage)).isInstanceOf(MailPreparationException.class); + } + + MimeMessage failureMessage = null; + try { + failureMessage = new MimeMessage(Session.getInstance(new Properties())) { + + @Override + public void writeTo(OutputStream os) throws MessagingException { + throw new MessagingException("exception"); + } + }; + mailSender.send(new MimeMessage[] { failureMessage }); + fail("Exception expected due to error while sending mail"); + } + catch (MailSendException e) { + // expected due to exception writing message + assertThat(e.getFailedMessages()).hasSize(1); + // noinspection ThrowableResultOfMethodCallIgnored + assertThat(e.getFailedMessages().get(failureMessage)).isInstanceOf(MailParseException.class); + } + } + + private MimeMessage createMimeMessage() throws MessagingException { + MimeMessage message = new MimeMessage(Session.getInstance(new Properties())); + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(message); + mimeMessageHelper.addTo("to@domain.com"); + mimeMessageHelper.setText("body text"); + mimeMessageHelper.setSubject("subject"); + mimeMessageHelper.getMimeMessage().saveChanges(); + return message; + } + + private byte[] getMimeMessageAsByteArray(MimeMessage mimeMessage) throws IOException, MessagingException { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + mimeMessage.writeTo(os); + return os.toByteArray(); + } + +} diff --git a/spring-cloud-aws-sesv2/src/test/java/io/awspring/cloud/sesv2/SimpleEmailServiceMailSenderTest.java b/spring-cloud-aws-sesv2/src/test/java/io/awspring/cloud/sesv2/SimpleEmailServiceMailSenderTest.java new file mode 100644 index 000000000..b74453be2 --- /dev/null +++ b/spring-cloud-aws-sesv2/src/test/java/io/awspring/cloud/sesv2/SimpleEmailServiceMailSenderTest.java @@ -0,0 +1,348 @@ +/* + * Copyright 2013-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.awspring.cloud.sesv2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.Mockito.*; + +import jakarta.activation.FileTypeMap; +import jakarta.mail.Message; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.MimeMessage; +import java.io.*; +import java.util.Objects; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.springframework.mail.MailParseException; +import org.springframework.mail.MailPreparationException; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.mail.javamail.MimeMessagePreparator; +import software.amazon.awssdk.services.sesv2.SesV2Client; +import software.amazon.awssdk.services.sesv2.model.SendEmailRequest; +import software.amazon.awssdk.services.sesv2.model.SendEmailResponse; +import software.amazon.awssdk.services.sesv2.model.SesV2Exception; + +/** + * Tests for {@link SimpleEmailServiceMailSender}. + * + * @author Eddú Meléndez + * @author Maciej Walkowiak + * @author Arun Patra + */ +class SimpleEmailServiceMailSenderTest { + + @Test + void createMimeMessage_withDefaultPropertiesAndNoEncodingAndFileTypeMap_returnsSessionWithEmptyProperties() { + // Arrange + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + + // Assert + assertThat(mimeMessage).isNotNull(); + assertThat(mimeMessage.getSession().getProperties()).isEmpty(); + } + + @Test + void createMimeMessage_withCustomProperties_sessionMaintainsCustomProperties() { + // Arrange + Properties mailProperties = new Properties(); + mailProperties.setProperty("mail.from", "agim.emruli@maildomain.com"); + + SimpleEmailServiceJavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + mailSender.setJavaMailProperties(mailProperties); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + + // Assert + assertThat(mimeMessage).isNotNull(); + assertThat(mimeMessage.getSession().getProperty("mail.from")).isEqualTo("agim.emruli@maildomain.com"); + } + + @Test + void createMimeMessage_withCustomSession_sessionUsedInMailIsCustomSession() { + // Arrange + Session customSession = Session.getInstance(new Properties()); + + SimpleEmailServiceJavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + mailSender.setSession(customSession); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + + // Assert + assertThat(mimeMessage.getSession()).isSameAs(customSession); + } + + @Test + void createMimeMessage_withCustomEncoding_encodingIsDetectedInMimeMessageHelper() { + // Arrange + SimpleEmailServiceJavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + mailSender.setDefaultEncoding("ISO-8859-1"); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + + // Assert + assertThat(mimeMessageHelper.getEncoding()).isEqualTo("ISO-8859-1"); + } + + @Test + void createMimeMessage_withCustomFileTypeMap_fileTypeMapIsAvailableInMailSender() { + // Arrange + SimpleEmailServiceJavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + mailSender.setDefaultFileTypeMap(FileTypeMap.getDefaultFileTypeMap()); + + // Act + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + + // Assert + assertThat(mimeMessageHelper.getFileTypeMap()).as("ISO-8859-1").isNotNull(); + } + + @Test + void testCreateMimeMessageFromPreDefinedMessage() throws Exception { + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(null); + + MimeMessage original = createMimeMessage(); + + MimeMessage mimeMessage = mailSender + .createMimeMessage(new ByteArrayInputStream(getMimeMessageAsByteArray(original))); + assertThat(mimeMessage).isNotNull(); + assertThat(mimeMessage.getSubject()).isEqualTo(original.getSubject()); + assertThat(mimeMessage.getContent()).isEqualTo(original.getContent()); + assertThat(mimeMessage.getRecipients(Message.RecipientType.TO)[0]) + .isEqualTo(original.getRecipients(Message.RecipientType.TO)[0]); + } + + @Test + void testSendMimeMessage() throws MessagingException { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService, + "arn:aws:ses:us-east-1:00000001:identity/domain.com"); + + ArgumentCaptor request = ArgumentCaptor.forClass(SendEmailRequest.class); + when(emailService.sendEmail(request.capture())) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + + MimeMessage mimeMessage = createMimeMessage(); + mailSender.send(mimeMessage); + assertThat(mimeMessage.getMessageID()).isEqualTo("123"); + assertThat(request.getValue().fromEmailAddressIdentityArn()) + .isEqualTo("arn:aws:ses:us-east-1:00000001:identity/domain.com"); + } + + @Test + void testSendMimeMessageWithConfigurationSetNameSet() throws MessagingException { + SesV2Client emailService = mock(SesV2Client.class); + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService, null, "Configuration Set"); + ArgumentCaptor request = ArgumentCaptor.forClass(SendEmailRequest.class); + when(emailService.sendEmail(request.capture())) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + MimeMessage mimeMessage = createMimeMessage(); + + mailSender.send(mimeMessage); + + assertThat(request.getValue().configurationSetName()).isEqualTo("Configuration Set"); + } + + @Test + void testSendMultipleMimeMessages() throws Exception { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + when(emailService.sendEmail(ArgumentMatchers.isA(SendEmailRequest.class))) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + + mailSender.send(createMimeMessage(), createMimeMessage()); + verify(emailService, times(2)).sendEmail(ArgumentMatchers.isA(SendEmailRequest.class)); + } + + @Test + void testSendMailWithMimeMessagePreparator() throws Exception { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + MimeMessagePreparator preparator = mimeMessage -> { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + mimeMessageHelper.setTo("to@domain.com"); + mimeMessageHelper.setSubject("subject"); + mimeMessageHelper.setText("body"); + }; + + ArgumentCaptor request = ArgumentCaptor.forClass(SendEmailRequest.class); + when(emailService.sendEmail(request.capture())) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + + mailSender.send(preparator); + + MimeMessage mimeMessage = new MimeMessage(Session.getInstance(new Properties()), + new ByteArrayInputStream(request.getValue().content().raw().data().asByteArray())); + assertThat(mimeMessage.getRecipients(Message.RecipientType.TO)[0]).hasToString("to@domain.com"); + assertThat(mimeMessage.getSubject()).isEqualTo("subject"); + assertThat(mimeMessage.getContent()).isEqualTo("body"); + } + + @Test + void testSendMailWithMultipleMimeMessagePreparators() throws Exception { + + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + MimeMessagePreparator[] preparators = new MimeMessagePreparator[3]; + preparators[0] = mimeMessage -> { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + mimeMessageHelper.setTo("to@domain.com"); + }; + + preparators[1] = mimeMessage -> { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + mimeMessageHelper.setSubject("subject"); + }; + + preparators[2] = mimeMessage -> { + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(mimeMessage); + mimeMessageHelper.setText("body"); + }; + + ArgumentCaptor request = ArgumentCaptor.forClass(SendEmailRequest.class); + when(emailService.sendEmail(request.capture())) + .thenReturn(SendEmailResponse.builder().messageId("123").build()); + + mailSender.send(preparators); + + MimeMessage mimeMessage = new MimeMessage(Session.getInstance(new Properties()), + new ByteArrayInputStream(request.getValue().content().raw().data().asByteArray())); + assertThat(mimeMessage.getRecipients(Message.RecipientType.TO)[0]).hasToString("to@domain.com"); + assertThat(mimeMessage.getSubject()).isEqualTo("subject"); + assertThat(mimeMessage.getContent()).isEqualTo("body"); + + } + + @Test + void testCreateMimeMessageWithExceptionInInputStream() throws Exception { + InputStream inputStream = mock(InputStream.class); + + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + IOException ioException = new IOException("error"); + when(inputStream.read(ArgumentMatchers.any(byte[].class), ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt())) + .thenThrow(ioException); + + try { + mailSender.createMimeMessage(inputStream); + fail("MailPreparationException expected due to error while creating mail"); + } + catch (MailParseException e) { + assertThat(Objects.requireNonNull(e.getMessage()).startsWith("Could not parse raw MIME content")).isTrue(); + assertThat(e.getCause().getCause()).isSameAs(ioException); + } + } + + @Test + void testSendMultipleMailsWithException() throws Exception { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + MimeMessage failureMail = createMimeMessage(); + + when(emailService.sendEmail(ArgumentMatchers.isA(SendEmailRequest.class))) + .thenReturn(SendEmailResponse.builder().build()) + .thenThrow(SesV2Exception.builder().message("error").build()) + .thenReturn(SendEmailResponse.builder().build()); + + try { + mailSender.send(createMimeMessage(), failureMail, createMimeMessage()); + fail("Exception expected due to error while sending mail"); + } + catch (MailSendException e) { + assertThat(e.getFailedMessages()).hasSize(1); + assertThat(e.getFailedMessages()).containsKey(failureMail); + } + } + + @Test + void testSendMailsWithExceptionWhilePreparing() { + SesV2Client emailService = mock(SesV2Client.class); + + JavaMailSender mailSender = new SimpleEmailServiceJavaMailSender(emailService); + + MimeMessage mimeMessage = null; + try { + mimeMessage = new MimeMessage(Session.getInstance(new Properties())); + mailSender.send(new MimeMessage[] { mimeMessage }); + fail("Exception expected due to error while sending mail"); + } + catch (MailSendException e) { + // expected due to empty mail message + assertThat(e.getFailedMessages()).hasSize(1); + // noinspection ThrowableResultOfMethodCallIgnored + assertThat(e.getFailedMessages().get(mimeMessage)).isInstanceOf(MailPreparationException.class); + } + + MimeMessage failureMessage = null; + try { + failureMessage = new MimeMessage(Session.getInstance(new Properties())) { + + @Override + public void writeTo(OutputStream os) throws MessagingException { + throw new MessagingException("exception"); + } + }; + mailSender.send(new MimeMessage[] { failureMessage }); + fail("Exception expected due to error while sending mail"); + } + catch (MailSendException e) { + // expected due to exception writing message + assertThat(e.getFailedMessages()).hasSize(1); + // noinspection ThrowableResultOfMethodCallIgnored + assertThat(e.getFailedMessages().get(failureMessage)).isInstanceOf(MailParseException.class); + } + } + + private MimeMessage createMimeMessage() throws MessagingException { + MimeMessage message = new MimeMessage(Session.getInstance(new Properties())); + MimeMessageHelper mimeMessageHelper = new MimeMessageHelper(message); + mimeMessageHelper.addTo("to@domain.com"); + mimeMessageHelper.setText("body text"); + mimeMessageHelper.setSubject("subject"); + mimeMessageHelper.getMimeMessage().saveChanges(); + return message; + } + + private byte[] getMimeMessageAsByteArray(MimeMessage mimeMessage) throws IOException, MessagingException { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + mimeMessage.writeTo(os); + return os.toByteArray(); + } +} diff --git a/spring-cloud-aws-starters/spring-cloud-aws-starter-sesv2/pom.xml b/spring-cloud-aws-starters/spring-cloud-aws-starter-sesv2/pom.xml new file mode 100644 index 000000000..cb522d8fa --- /dev/null +++ b/spring-cloud-aws-starters/spring-cloud-aws-starter-sesv2/pom.xml @@ -0,0 +1,27 @@ + + + + spring-cloud-aws + io.awspring.cloud + 3.4.0-SNAPSHOT + ../../pom.xml + + 4.0.0 + + spring-cloud-aws-starter-sesv2 + Spring Cloud AWS SES V2 Starter + Spring Cloud AWS SES V2 Starter + + + + io.awspring.cloud + spring-cloud-aws-sesv2 + + + io.awspring.cloud + spring-cloud-aws-starter + + + diff --git a/spring-cloud-aws-testcontainers/pom.xml b/spring-cloud-aws-testcontainers/pom.xml index c41206ed1..3e0bcfb36 100644 --- a/spring-cloud-aws-testcontainers/pom.xml +++ b/spring-cloud-aws-testcontainers/pom.xml @@ -61,6 +61,11 @@ spring-cloud-aws-starter-ses test + + io.awspring.cloud + spring-cloud-aws-starter-sesv2 + test + io.awspring.cloud spring-cloud-aws-starter-s3