mn create-app example.micronaut.micronautguide --build=gradle --lang=groovy
Send Emails from the Micronaut framework
Learn how to send emails with AWS SES and SendGrid from a Micronaut application and leverage @Requires annotation to load beans conditionally.
Authors: Sergio del Amo
Micronaut Version: 3.3.0
1. Getting Started
In this guide, we will create a Micronaut application written in Groovy.
2. What you will need
To complete this guide, you will need the following:
-
Some time on your hands
-
A decent text editor or IDE
-
JDK 1.8 or greater installed with
JAVA_HOME
configured appropriately
3. Solution
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
-
Download and unzip the source
4. Writing the Application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
If you don’t specify the --build argument, Gradle is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.
|
The previous command creates a Micronaut application with the default package example.micronaut
in a directory named micronautguide
.
4.1. Controller
Create MailController
which use a collaborator, emailService
to send and email.
package example.micronaut
import groovy.transform.CompileStatic
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Body
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Post
import javax.validation.Valid
@CompileStatic
@Controller("/mail") (1)
class MailController {
private final EmailService emailService
MailController(EmailService emailService) { (2)
this.emailService = emailService
}
@Post("/send") (3)
HttpResponse<?> send(@Body @Valid EmailCmd cmd) { (4)
emailService.send(cmd)
HttpResponse.ok() (5)
}
}
1 | The class is defined as a controller with the @Controller annotation mapped to the path /mail/send |
2 | Constructor injection |
3 | The @Post annotation maps the index method to all requests that use an HTTP POST |
4 | Add @Valid to any method parameter which requires validation. Use a POGO supplied as a JSON payload in the request to populate the email. |
5 | Return 200 OK as the result |
The previous controller uses a POJO supplied in the request body as a JSON Payload
@Introspected
@CompileStatic
class EmailCmd implements Email {
@NotNull
@NotBlank
String recipient
@NotNull
@NotBlank
String subject
List<String> cc = []
List<String> bcc = []
String htmlBody
String textBody
String replyTo
4.2. Email Service
Create an interface - EmailService
. Any email provider present in the application should implement it.
package example.micronaut
import io.micronaut.core.annotation.NonNull
import javax.validation.Valid
import javax.validation.constraints.NotNull
interface EmailService {
void send(@NonNull @NotNull @Valid Email email)
}
package example.micronaut;
interface Email {
String getRecipient()
List<String> getCc()
List<String> getBcc()
String getSubject()
String getHtmlBody()
String getTextBody()
String getReplyTo()
}
4.2.1. AWS SES
Amazon Simple Email Service (Amazon SES) is a cloud-based email sending service designed to help digital marketers and application developers send marketing, notification, and transactional emails. It is a reliable, cost-effective service for businesses of all sizes that use email to keep in contact with their customers.
Add a dependency to AWS SES SDK:
implementation("software.amazon.awssdk:ses:2.17.124")
Create service which uses AWS Simple Email Service client to send the email
package example.micronaut
import groovy.transform.CompileStatic
import io.micronaut.context.annotation.Requires
import io.micronaut.context.annotation.Secondary
import io.micronaut.context.annotation.Value
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.ses.SesClient
import software.amazon.awssdk.services.ses.model.Body
import software.amazon.awssdk.services.ses.model.Content
import software.amazon.awssdk.services.ses.model.Destination
import software.amazon.awssdk.services.ses.model.Message
import software.amazon.awssdk.services.ses.model.SendEmailRequest
import software.amazon.awssdk.services.ses.model.SendEmailResponse
import jakarta.inject.Singleton
import javax.validation.Valid
import javax.validation.constraints.NotNull
@CompileStatic
@Singleton (1)
@Requires(condition = AwsResourceAccessCondition) (2)
@Secondary (3)
class AwsSesMailService implements EmailService {
private static final Logger LOG = LoggerFactory.getLogger(AwsSesMailService)
protected final String sourceEmail
protected final SesClient ses
AwsSesMailService(@Nullable @Value('${AWS_REGION}') String awsRegionEnv, (4)
@Nullable @Value('${AWS_SOURCE_EMAIL}') String sourceEmailEnv,
@Nullable @Value('${aws.region}') String awsRegionProp,
@Nullable @Value('${aws.sourceemail}') String sourceEmailProp) {
this.sourceEmail = sourceEmailEnv != null ? sourceEmailEnv : sourceEmailProp
String awsRegion = awsRegionEnv != null ? awsRegionEnv : awsRegionProp
this.ses = SesClient.builder().region(Region.of(awsRegion)).build()
}
@Override void send(@NonNull @NotNull @Valid Email email) {
SendEmailRequest sendEmailRequest = SendEmailRequest.builder()
.destination(Destination.builder().toAddresses(email.recipient).build())
.source(sourceEmail)
.message(Message.builder().subject(Content.builder().data(email.subject).build())
.body(Body.builder().text(Content.builder().data(email.textBody).build()).build()).build()).build() as SendEmailRequest
SendEmailResponse response =ses.sendEmail(sendEmailRequest)
LOG.info("Sent email with id: {}", response.messageId())
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
2 | Bean will not loaded unless condition is met. |
3 | In case of multiple possible interface implementations of EmailService , @Secondary reduces the priority. |
4 | Values for region and source email are resolved from environment variables or system properties. |
We annotated the previous class with @Requires(condition = AwsResourceAccessCondition.class)
.
The AwsResourceAccessCondition
ensures the bean is not loaded unless certain conditions are fulfilled.
If your application creates an AWS client using the create method, the client searches for credentials using the default credentials provider chain, in the following order:
In the Java system properties:
aws.accessKeyId
andaws.secretAccessKey
.In system environment variables:
AWS_ACCESS_KEY_ID
andAWS_SECRET_ACCESS_KEY
.In the default credentials file (the location of this file varies by platform).
n the Amazon ECS environment variable:
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
.In the instance profile credentials, which exist within the instance metadata associated with the IAM role for the EC2 instance.
package example.micronaut
import groovy.transform.CompileStatic
import io.micronaut.context.condition.Condition
import io.micronaut.context.condition.ConditionContext
import io.micronaut.context.env.Environment
import io.micronaut.core.util.StringUtils
/**
* @see <a href="https://docs.aws.amazon.com/sdk-for-java/v2/developer-guide/java-dg-roles.html">Configure IAM Roles for Amazon EC2</a>
*/
@CompileStatic
class AwsResourceAccessCondition implements Condition {
@Override
boolean matches(ConditionContext context) {
if (StringUtils.isNotEmpty(System.getProperty("aws.accessKeyId")) && StringUtils.isNotEmpty(System.getProperty("aws.secretAccessKey"))) { (1)
return true
}
if (StringUtils.isNotEmpty(System.getenv("AWS_ACCESS_KEY_ID")) && StringUtils.isNotEmpty(System.getenv("AWS_SECRET_ACCESS_KEY"))) { (2)
return true
}
if (StringUtils.isNotEmpty(System.getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"))) { (3)
return true
}
context && context.getBean(Environment).activeNames.contains(Environment.AMAZON_EC2) (4)
}
}
Add a test to verify the service is loaded:
package example.micronaut;
import io.micronaut.context.ApplicationContext
import spock.lang.Specification
import spock.util.environment.RestoreSystemProperties
import spock.lang.IgnoreIf
@IgnoreIf({ env['AWS_SECRET_ACCESS_KEY'] })
class AwsSesMailServiceSpec extends Specification {
void "aws ses mail service is not loaded if system property is not present"() {
given:
ApplicationContext ctx = ApplicationContext.run()
expect:
!ctx.containsBean(AwsSesMailService)
cleanup:
ctx.close();
}
@RestoreSystemProperties
void "aws ses mail service is loaded if system properties are present"() {
given:
System.setProperty("aws.accessKeyId", "XXXX")
System.setProperty("aws.secretAccessKey", "YKYY")
System.setProperty("aws.region", "XXXX")
System.setProperty("aws.sourceemail", "me@micronaut.example")
ApplicationContext ctx = ApplicationContext.run()
when:
AwsSesMailService bean = ctx.getBean(AwsSesMailService)
then:
"me@micronaut.example" == bean.sourceEmail
cleanup:
ctx.close()
}
}
4.2.2. SendGrid
SendGrid is a transactional email service.
SendGrid is responsible for sending billions of emails for some of the best and brightest companies in the world.
Add a dependency to SendGrid SDK:
implementation("com.sendgrid:sendgrid-java:4.8.2")
Create a service which encapsulates the integration with SendGrid. The bean will not be loaded if the
system properties (sendgrid.apikey
, sendgrid.fromemail
) or environment variables (SENDGRID_APIKEY
, SENDGRID_FROM_EMAIL
) are not present.
package example.micronaut
import groovy.transform.CompileStatic;
import io.micronaut.context.condition.Condition;
import io.micronaut.context.condition.ConditionContext;
import io.micronaut.core.util.StringUtils;
@CompileStatic
class SendGridEmailCondition implements Condition {
@Override
boolean matches(ConditionContext context) {
return envOrSystemProperty("SENDGRID_APIKEY", "sendgrid.apikey") &&
envOrSystemProperty("SENDGRID_FROM_EMAIL", "sendgrid.fromemail");
}
private static boolean envOrSystemProperty(String env, String prop) {
return StringUtils.isNotEmpty(System.getProperty(prop)) || StringUtils.isNotEmpty(System.getenv(env));
}
}
Add a test:
package example.micronaut
import spock.lang.Specification
import spock.util.environment.RestoreSystemProperties
class SendGridEmailConditionSpec extends Specification {
@RestoreSystemProperties
void "condition is true if system properites are present"() {
given:
System.setProperty("sendgrid.apikey", "XXXX")
System.setProperty("sendgrid.fromemail", "me@micronaut.example")
SendGridEmailCondition condition = new SendGridEmailCondition()
expect:
condition.matches(null)
}
void "condition is false if system properties are not present"() {
given:
SendGridEmailCondition condition = new SendGridEmailCondition()
expect:
!condition.matches(null)
}
}
package example.micronaut
import com.sendgrid.SendGrid
import com.sendgrid.Request
import com.sendgrid.Response
import com.sendgrid.Method
import com.sendgrid.helpers.mail.Mail
import com.sendgrid.helpers.mail.objects.Content
import com.sendgrid.helpers.mail.objects.Personalization
import groovy.transform.CompileStatic
import io.micronaut.context.annotation.Requires
import io.micronaut.context.annotation.Value
import io.micronaut.core.annotation.NonNull
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import jakarta.inject.Singleton
import javax.validation.Valid
import javax.validation.constraints.NotNull
@CompileStatic
@Singleton (1)
@Requires(condition = SendGridEmailCondition) (2)
class SendGridEmailService implements EmailService {
private static final Logger LOG = LoggerFactory.getLogger(SendGridEmailService)
protected final String apiKey
protected final String fromEmail
SendGridEmailService(@Value('${SENDGRID_APIKEY:none}') String apiKeyEnv, (3)
@Value('${SENDGRID_FROM_EMAIL:none}') String fromEmailEnv, @Value('${sendgrid.apikey:none}') String apiKeyProp,
@Value('${sendgrid.fromemail:none}') String fromEmailProp) {
this.apiKey = apiKeyEnv != null && !apiKeyEnv.equals("none") ? apiKeyEnv : apiKeyProp
this.fromEmail = fromEmailEnv != null && !fromEmailEnv.equals("none") ? fromEmailEnv: fromEmailProp
}
protected static Content contentOfEmail(Email email) {
if ( email.textBody !=null ) {
return new Content("text/plain", email.textBody)
}
if ( email.htmlBody !=null ) {
return new Content("text/html", email.htmlBody)
}
return null
}
@Override
void send(@NonNull @NotNull @Valid Email email) {
Personalization personalization = new Personalization()
personalization.setSubject(email.subject)
com.sendgrid.helpers.mail.objects.Email to = new com.sendgrid.helpers.mail.objects.Email(email.recipient)
personalization.addTo(to)
if ( email.cc != null ) {
for ( String cc : email.cc ) {
com.sendgrid.helpers.mail.objects.Email ccEmail = new com.sendgrid.helpers.mail.objects.Email()
ccEmail.setEmail(cc)
personalization.addCc(ccEmail)
}
}
if ( email.bcc != null ) {
for ( String bcc : email.bcc ) {
com.sendgrid.helpers.mail.objects.Email bccEmail = new com.sendgrid.helpers.mail.objects.Email()
bccEmail.setEmail(bcc)
personalization.addBcc(bccEmail)
}
}
Mail mail = new Mail()
com.sendgrid.helpers.mail.objects.Email from = new com.sendgrid.helpers.mail.objects.Email()
from.setEmail(fromEmail)
mail.from = from
mail.addPersonalization(personalization)
Content content = contentOfEmail(email)
mail.addContent(content)
SendGrid sg = new SendGrid(apiKey)
Request request = new Request()
try {
request.setMethod(Method.POST)
request.setEndpoint("mail/send")
request.setBody(mail.build())
Response response = sg.api(request)
if (LOG.infoEnabled) {
LOG.info("Status Code: {}", response.statusCode)
LOG.info("Body: {}", response.body)
LOG.info("Headers {}", response.headers.collect { k, v -> "$k=$v" }.join(", "))
}
} catch (IOException e) {
LOG.error(e.message, e)
}
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
2 | Bean will not loaded unless condition is met. |
3 | Values will be resolved from system properties. |
Add a test:
package example.micronaut
import io.micronaut.context.ApplicationContext
import spock.lang.Specification
import spock.util.environment.RestoreSystemProperties
class SendGridEmailServiceSpec extends Specification {
void "send grid email service is not loaded if system property is not present"() {
given:
ApplicationContext ctx = ApplicationContext.run()
expect:
!ctx.containsBean(SendGridEmailService)
cleanup:
ctx.close()
}
@RestoreSystemProperties
void "send grid email service is loaded if system properties are present"() {
given:
System.setProperty("sendgrid.apikey", "XXXX")
System.setProperty("sendgrid.fromemail", "me@micronaut.example")
ApplicationContext ctx = ApplicationContext.run()
SendGridEmailService bean = ctx.getBean(SendGridEmailService)
expect:
"XXXX" == bean.apiKey
"me@micronaut.example" == bean.fromEmail
cleanup:
ctx.close()
}
}
4.3. Run the application
Add a logger to get more visibility:
<logger name="example.micronaut" level="TRACE"/>
To use SendGrid, define the required environment variables and run the application.
$ export SENDGRID_FROM_EMAIL=email@email.com
$ export SENDGRID_APIKEY=XXXXXX
./gradlew run
To use AWS SES, define the required environment variables and run the application.
$ export AWS_REGION=eu-west-1
$ export AWS_SOURCE_EMAIL=email@email.com
$ export AWS_ACCESS_KEY_ID=XXXXXXXX
$ export AWS_SECRET_KEY=XXXXXXXX
./gradlew run
If you supply both AWS SES and SendGrid system properties, the SendGrid EmailService
implementation will be used due to the @Secondary
annotation in AwsSesMailService
.
curl -X "POST" "http://localhost:8080/mail/send" \
-H 'Content-Type: application/json; charset=utf-8' \
-d $'{
"subject": "Test Email",
"recipient": "recipient@email.com",
"textBody": "Foo"
}'
4.4. Test
In our acceptance test, beans SendGridEmailService
or AwsSesMailService
will not be loaded since system properties are not present.
Instead, we set up a Mock which we can verify interactions against.
package example.micronaut
import io.micronaut.context.annotation.Primary
import io.micronaut.context.annotation.Requires
import io.micronaut.core.annotation.NonNull
import jakarta.inject.Singleton
import javax.validation.Valid
import javax.validation.constraints.NotNull
@Primary
@Requires(property = "spec.name", value = "mailcontroller")
@Singleton
class MockEmailService implements EmailService {
public List<Email> emails = new ArrayList<>()
@Override
void send(@NonNull @NotNull @Valid Email email) {
emails << email
}
}
Create the next test:
package example.micronaut
import io.micronaut.context.ApplicationContext
import io.micronaut.context.annotation.Property
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Specification
import jakarta.inject.Inject
@MicronautTest (1)
@Property(name = "spec.name", value = "mailcontroller") (2)
class MailControllerSpec extends Specification {
@Inject
ApplicationContext applicationContext (3)
@Inject
@Client("/")
HttpClient client (4)
void "mail send interacts once email service"() {
given:
EmailCmd cmd = new EmailCmd(subject: "Test", recipient: "delamos@grails.example", textBody: "Hola hola")
HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd) (5)
when:
EmailService emailService = applicationContext.getBean(EmailService)
then:
emailService instanceof MockEmailService
when:
HttpResponse<?> rsp = client.toBlocking().exchange(request)
then:
HttpStatus.OK == rsp.getStatus()
old(((MockEmailService) emailService).emails.size()) + 1 == ((MockEmailService) emailService).emails.size() (6)
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Annotate the class with @Property to supply configuration to the test. |
3 | Inject the ApplicationContext bean |
4 | Inject the HttpClient bean and point it to the embedded server. |
5 | Creating HTTP Requests is easy thanks to the Micronaut framework fluid API. |
6 | emailService.send method is invoked once. |
4.5. Validation
We want to ensure any email request contains a subject, recipient and a text body or html body.
Micronaut validation is built on the standard framework – JSR 380, also known as Bean Validation 2.0.
Hibernate Validator is a reference implementation of the validation API. Micronaut has built-in support for validation of beans that are annotated with javax.validation
annotations.
The necessary dependencies are included by default when creating a new application, so you don’t need to add anything else.
Create the next test:
package example.micronaut
import io.micronaut.context.ApplicationContext
import io.micronaut.context.annotation.Property
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import spock.lang.Specification
import jakarta.inject.Inject
@MicronautTest (1)
@Property(name = "spec.name", value = "mailcontroller") (2)
class MailControllerValidationSpec extends Specification {
@Inject
ApplicationContext applicationContext;
@Inject
@Client("/")
HttpClient client;
void "mail send cannot be invoked without subject"() {
given:
EmailCmd cmd = new EmailCmd(recipient: "delamos@micronaut.example", textBody: "Hola hola")
HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd) (3)
when:
client.toBlocking().exchange(request)
then:
HttpClientResponseException e = thrown()
HttpStatus.BAD_REQUEST == e.status
}
void "mail send cannot be invoked without recipient"() {
given:
EmailCmd cmd = new EmailCmd(subject: "Hola", textBody: "Hola hola")
HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd) (3)
when:
client.toBlocking().exchange(request)
then:
HttpClientResponseException e = thrown()
HttpStatus.BAD_REQUEST == e.status
}
void "mail send cannot be invoked without either text body or html body"() {
EmailCmd cmd = new EmailCmd(subject: "Hola", recipient: "delamos@micronaut.example")
HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd) (3)
when:
client.toBlocking().exchange(request)
then:
HttpClientResponseException e = thrown()
HttpStatus.BAD_REQUEST == e.status
}
void "mail send can be invoked without text body and not html body"() {
given:
EmailCmd cmd = new EmailCmd(subject: "Hola", recipient: "delamos@micronaut.example", textBody: "Hello")
HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd) (3)
when:
HttpResponse<?> rsp = client.toBlocking().exchange(request)
then:
HttpStatus.OK == rsp.getStatus()
}
void "mail send can be invoked with html body and not text body"() {
given:
EmailCmd cmd = new EmailCmd(subject: "Hola", recipient: "delamos@micronaut.example", htmlBody: "<h1>Hello</h1>")
HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd) (3)
when:
HttpResponse<?> rsp = client.toBlocking().exchange(request)
then:
HttpStatus.OK == rsp.getStatus()
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Define a property available for the application. |
3 | Creating HTTP Requests is easy thanks to the Micronaut framework fluid API. |
In order to satisfy the test, create an email constraints annotation
package example.micronaut
import javax.validation.Constraint
import javax.validation.Payload
import java.lang.annotation.ElementType
import java.lang.annotation.Retention
import java.lang.annotation.RetentionPolicy
import java.lang.annotation.Target
@Constraint(validatedBy = [])
@Target(value = [ElementType.TYPE])
@Retention(RetentionPolicy.RUNTIME)
@interface EmailConstraints {
String message() default "{email.invalid}"
Class<?>[] groups() default []
Class<? extends Payload>[] payload() default []
}
and a constraint validator in a @Factory
class:
package example.micronaut
import groovy.transform.CompileStatic
import io.micronaut.context.annotation.Factory
import io.micronaut.core.annotation.AnnotationValue
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable
import io.micronaut.core.util.StringUtils
import io.micronaut.validation.validator.constraints.ConstraintValidator
import io.micronaut.validation.validator.constraints.ConstraintValidatorContext
import jakarta.inject.Singleton
@Factory
@CompileStatic
class EmailConstraintsFactory {
@Singleton
ConstraintValidator<EmailConstraints, EmailCmd> emailBodyValidator() {
return new ConstraintValidator<EmailConstraints, EmailCmd>() {
@Override
boolean isValid(@Nullable EmailCmd value,
@NonNull AnnotationValue<EmailConstraints> annotationMetadata,
@NonNull ConstraintValidatorContext context) {
StringUtils.isNotEmpty(value?.textBody) || StringUtils.isNotEmpty(value?.htmlBody)
}
}
}
}
Annotate EmailCmd
with EmailConstraints
and @Introspected
(to generate the
Bean Introspection information).
@EmailConstraints
@Introspected
@CompileStatic
class EmailCmd implements Email {
@NotNull
@NotBlank
String recipient
@NotNull
@NotBlank
String subject
List<String> cc = []
List<String> bcc = []
String htmlBody
String textBody
String replyTo
5. Testing the Application
To run the tests:
./gradlew test
Then open build/reports/tests/test/index.html
in a browser to see the results.
6. Next steps
Explore more features with Micronaut Guides.
7. Help with the Micronaut Framework
Object Computing, Inc. (OCI) sponsored the creation of this Guide. A variety of consulting and support services are available.