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.

4. Writing the Application

Create an application using the Micronaut Command Line Interface or with Micronaut Launch.

mn create-app example.micronaut.micronautguide --build=gradle --lang=groovy
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.

src/main/groovy/example/micronaut/MailController.groovy
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

src/main/groovy/example/micronaut/EmailCmd.groovy
@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.

src/main/groovy/example/micronaut/EmailService.groovy
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)
}
src/main/groovy/example/micronaut/Email.groovy
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:

build.gradle
implementation("software.amazon.awssdk:ses:2.17.124")

Create service which uses AWS Simple Email Service client to send the email

src/main/groovy/example/micronaut/AwsSesMailService.groovy
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 and aws.secretAccessKey.

  • In system environment variables: AWS_ACCESS_KEY_ID and AWS_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.

src/main/groovy/example/micronaut/AwsResourceAccessCondition.groovy
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:

src/test/groovy/example/micronaut/AwsSesMailServiceSpec.groovy
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:

build.gradle
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.

src/main/groovy/example/micronaut/SendGridEmailCondition.groovy
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:

src/test/groovy/example/micronaut/SendGridEmailConditionSpec.groovy
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)
    }
}
src/main/groovy/example/micronaut/SendGridEmailService.groovy
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:

src/test/groovy/example/micronaut/SendGridEmailServiceSpec.groovy
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:

src/main/resources/logback.xml
<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.

src/test/groovy/example/micronaut/MockEmailService.groovy
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:

src/test/groovy/example/micronaut/MailControllerSpec.groovy
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:

src/test/groovy/example/micronaut/MailControllerValidationSpec.groovy
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

src/main/groovy/example/micronaut/EmailConstraints.groovy
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:

src/main/groovy/example/micronaut/EmailConstraintsFactory.groovy
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).

src/main/groovy/example/micronaut/EmailCmd.groovy
@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.