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.2.7

1. Getting Started

In this guide, we will create a Micronaut application written in Java.

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=maven --lang=java
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. Enable annotation Processing

If you use Java or Kotlin and IntelliJ IDEA, make sure to enable annotation processing.

annotationprocessorsintellij

4.2. Controller

Create MailController which use a collaborator, emailService to send and email.

src/main/java/example/micronaut/MailController.java
package example.micronaut;

import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Post;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.validation.Valid;

@Controller("/mail") (1)
public class MailController {

    private final EmailService emailService;

    public MailController(EmailService emailService) { (2)
        this.emailService = emailService;
    }

    @Post("/send") (3)
    public HttpResponse<?> send(@Body @Valid EmailCmd cmd) { (4)
        emailService.send(cmd);
        return 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/java/example/micronaut/EmailCmd.java
@Introspected
public class EmailCmd implements Email {

    @NotNull
    @NotBlank
    private String recipient;

    @NotNull
    @NotBlank
    private String subject;

    private List<String> cc = new ArrayList<>();
    private List<String> bcc = new ArrayList<>();

    private String htmlBody;
    private String textBody;
    private String replyTo;

    @Override
    public String getRecipient() {
        return recipient;
    }

    public void setRecipient(String recipient) {
        this.recipient = recipient;
    }

    @Override
    public String getSubject() {
        return subject;
    }

    public void setSubject(String subject) {
        this.subject = subject;
    }

    @Override
    public List<String> getCc() {
        return cc;
    }

    public void setCc(List<String> cc) {
        this.cc = cc;
    }

    @Override
    public List<String> getBcc() {
        return bcc;
    }

    public void setBcc(List<String> bcc) {
        this.bcc = bcc;
    }

    @Override
    public String getHtmlBody() {
        return htmlBody;
    }

    public void setHtmlBody(String htmlBody) {
        this.htmlBody = htmlBody;
    }

    @Override
    public String getTextBody() {
        return textBody;
    }

    public void setTextBody(String textBody) {
        this.textBody = textBody;
    }

    @Override
    public String getReplyTo() {
        return replyTo;
    }

    public void setReplyTo(String replyTo) {
        this.replyTo = replyTo;
    }
}

4.3. Email Service

Create an interface - EmailService. Any email provider present in the application should implement it.

src/main/java/example/micronaut/EmailService.java
package example.micronaut;

import io.micronaut.core.annotation.NonNull;

import javax.validation.Valid;
import javax.validation.constraints.NotNull;

public interface EmailService {
    void send(@NonNull @NotNull @Valid Email email);
}
src/main/java/example/micronaut/Email.java
package example.micronaut;

import java.util.List;

public interface Email {
    String getRecipient();
    List<String> getCc();
    List<String> getBcc();
    String getSubject();
    String getHtmlBody();
    String getTextBody();
    String getReplyTo();
}

4.3.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:

pom.xml
<dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>ses</artifactId>
    <version>2.17.99</version>
    <scope>compile</scope>
</dependency>

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

src/main/java/example/micronaut/AwsSesMailService.java
package example.micronaut;

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;


@Singleton (1)
@Requires(condition = AwsResourceAccessCondition.class)  (2)
@Secondary (3)
public class AwsSesMailService implements EmailService {
    private static final Logger LOG = LoggerFactory.getLogger(AwsSesMailService.class);
    protected final String sourceEmail;
    protected final SesClient ses;

    public 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
    public void send(@NonNull @NotNull @Valid Email email) {
        SendEmailRequest sendEmailRequest = SendEmailRequest.builder()
                .destination(Destination.builder().toAddresses(email.getRecipient()).build())
                .source(sourceEmail)
                .message(Message.builder().subject(Content.builder().data(email.getSubject()).build())
                        .body(Body.builder().text(Content.builder().data(email.getTextBody()).build()).build()).build())
                .build();
        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/java/example/micronaut/AwsResourceAccessCondition.java
package example.micronaut;

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>
 */
public class AwsResourceAccessCondition implements Condition {

    @Override
    public 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;
        }

        return context != null && context.getBean(Environment.class).getActiveNames().contains(Environment.AMAZON_EC2); (4)
    }
}

Add a test to verify the service is loaded:

src/test/java/example/micronaut/AwsSesMailServiceTest.java
package example.micronaut;

import io.micronaut.context.ApplicationContext;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable;

@DisabledIfEnvironmentVariable(named = "AWS_SECRET_ACCESS_KEY", matches = ".*")
class AwsSesMailServiceTest {

    @Test
    public void awsSesMailServiceIsNotLoadedIfSystemPropertyIsNotPresent() {
        ApplicationContext ctx = ApplicationContext.run();
        assertFalse(ctx.containsBean(AwsSesMailService.class));
        ctx.close();
    }

    @Test
    public void awsSesMailServiceIsLoadedIfSystemPropertiesArePresent() {
        String accesskeyid = System.getProperty("aws.accessKeyId");
        String awssecretkey = System.getProperty("aws.secretAccessKey");
        String awsregion = System.getProperty("aws.region");
        String sourceemail = System.getProperty("aws.sourceemail");

        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();
        AwsSesMailService bean = ctx.getBean(AwsSesMailService.class);
        assertEquals("me@micronaut.example", bean.sourceEmail);
        ctx.close();

        if (awsregion == null) {
            System.clearProperty("aws.region");
        } else {
            System.setProperty("aws.region", awsregion);
        }
        if (sourceemail == null) {
            System.clearProperty("aws.sourceemail");
        } else {
            System.setProperty("aws.sourceemail", sourceemail);
        }
        if (accesskeyid == null) {
            System.clearProperty("aws.accessKeyId");
        } else {
            System.setProperty("aws.accessKeyId", accesskeyid);
        }
        if (awssecretkey == null) {
            System.clearProperty("aws.secretAccessKey");
        } else {
            System.setProperty("aws.secretAccessKey", awssecretkey);
        }
    }
}

4.3.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:

pom.xml
<dependency>
    <groupId>com.sendgrid</groupId>
    <artifactId>sendgrid-java</artifactId>
    <version>4.8.1</version>
    <scope>compile</scope>
</dependency>

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/java/example/micronaut/SendGridEmailCondition.java
package example.micronaut;

import io.micronaut.context.condition.Condition;
import io.micronaut.context.condition.ConditionContext;
import io.micronaut.core.util.StringUtils;

public class SendGridEmailCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context) {
        return envOrSystemProperty("SENDGRID_APIKEY", "sendgrid.apikey") &&
                envOrSystemProperty("SENDGRID_FROM_EMAIL", "sendgrid.fromemail");
    }

    private boolean envOrSystemProperty(String env, String prop) {
        return StringUtils.isNotEmpty(System.getProperty(prop)) || StringUtils.isNotEmpty(System.getenv(env));
    }
}

Add a test:

src/test/java/example/micronaut/SendGridEmailConditionTest.java
package example.micronaut;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class SendGridEmailConditionTest {

    @Test
    public void conditionIsTrueIfSystemPropertiesArePresent() {

        String sendGridApiKey = System.getProperty("sendgrid.apikey");
        String sendGrindFromEmail = System.getProperty("sendgrid.fromemail");
        System.setProperty("sendgrid.apikey", "XXXX");
        System.setProperty("sendgrid.fromemail", "me@micronaut.example");

        SendGridEmailCondition condition = new SendGridEmailCondition();
        assertTrue(condition.matches(null));

        if (sendGridApiKey == null) {
            System.clearProperty("sendgrid.apikey");
        } else {
            System.setProperty("sendgrid.apikey", sendGridApiKey);
        }
        if (sendGrindFromEmail == null) {
            System.clearProperty("sendgrid.fromemail");
        } else {
            System.setProperty("sendgrid.fromemail", sendGrindFromEmail);
        }
    }

    @Test
    public void conditionIsFalseIfSystemPropertiesAreNotPresent() {
        SendGridEmailCondition condition = new SendGridEmailCondition();
        assertFalse(condition.matches(null));
    }
}
src/main/java/example/micronaut/SendGridEmailService.java
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 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 java.util.stream.Collectors;
import jakarta.inject.Singleton;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.io.IOException;

@Singleton (1)
@Requires(condition = SendGridEmailCondition.class) (2)
class SendGridEmailService implements EmailService {

    private static final Logger LOG = LoggerFactory.getLogger(SendGridEmailService.class);

    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 Content contentOfEmail(Email email) {
        if ( email.getTextBody() !=null ) {
            return new Content("text/plain", email.getTextBody());
        }
        if ( email.getHtmlBody() !=null ) {
            return new Content("text/html", email.getHtmlBody());
        }
        return null;
    }

    @Override
    public void send(@NonNull @NotNull @Valid Email email) {

        Personalization personalization = new Personalization();
        personalization.setSubject(email.getSubject());

        com.sendgrid.helpers.mail.objects.Email to = new com.sendgrid.helpers.mail.objects.Email(email.getRecipient());
        personalization.addTo(to);

        if ( email.getCc() != null ) {
            for ( String cc : email.getCc() ) {
                com.sendgrid.helpers.mail.objects.Email ccEmail = new com.sendgrid.helpers.mail.objects.Email();
                ccEmail.setEmail(cc);
                personalization.addCc(ccEmail);
            }
        }

        if ( email.getBcc()  != null ) {
            for ( String bcc : email.getBcc() ) {
                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.isInfoEnabled()) {
                LOG.info("Status Code: {}", String.valueOf(response.getStatusCode()));
                LOG.info("Body: {}", response.getBody());
                LOG.info("Headers {}", response.getHeaders()
                        .keySet()
                        .stream()
                        .map(key -> key.toString() + "=" + response.getHeaders().get(key))
                        .collect(Collectors.joining(", ", "{", "}")));
            }
        } catch (IOException ex) {
            LOG.error(ex.getMessage());
        }
    }
}
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/java/example/micronaut/SendGridEmailServiceTest.java
package example.micronaut;

import io.micronaut.context.ApplicationContext;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

class SendGridEmailServiceTest {

    @Test
    public void sendGridEmailServiceIsNotLoadedIfSystemPropertyIsNotPresent() {
        ApplicationContext ctx = ApplicationContext.run();
        assertFalse(ctx.containsBean(SendGridEmailService.class));
        ctx.close();
    }

    @Test
    public void sendGridEmailServiceIsLoadedIfSystemPropertiesArePresent() {
        String sendGridApiKey = System.getProperty("sendgrid.apikey");
        String sendGrindFromEmail = System.getProperty("sendgrid.fromemail");
        System.setProperty("sendgrid.apikey", "XXXX");
        System.setProperty("sendgrid.fromemail", "me@micronaut.example");
        ApplicationContext ctx = ApplicationContext.run();
        SendGridEmailService bean = ctx.getBean(SendGridEmailService.class);
        assertEquals("XXXX", bean.apiKey);
        assertEquals("me@micronaut.example", bean.fromEmail);
        ctx.close();
        if (sendGridApiKey == null) {
            System.clearProperty("sendgrid.apikey");
        } else {
            System.setProperty("sendgrid.apikey", sendGridApiKey);
        }
        if (sendGrindFromEmail == null) {
            System.clearProperty("sendgrid.fromemail");
        } else {
            System.setProperty("sendgrid.fromemail", sendGrindFromEmail);
        }

    }
}

4.4. 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
./mvnw mn: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
./mvnw mn: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.5. 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/java/example/micronaut/MockEmailService.java
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;
import java.util.ArrayList;
import java.util.List;

@Primary
@Requires(property = "spec.name", value = "mailcontroller")
@Singleton
public class MockEmailService implements EmailService {

    public List<Email> emails = new ArrayList<>();

    @Override
    public void send(@NonNull @NotNull @Valid Email email) {
        emails.add(email);
    }
}

Create the next test:

src/test/java/example/micronaut/MailControllerTest.java
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.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;

import jakarta.inject.Inject;
import java.util.Collection;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@MicronautTest (1)
@Property(name = "spec.name", value = "mailcontroller") (2)
class MailControllerTest {

    @Inject
    ApplicationContext applicationContext; (3)

    @Inject
    @Client("/")
    HttpClient client; (4)

    @Test
    public void mailsendInteractsOnceEmailService() {
        EmailCmd cmd = new EmailCmd();
        cmd.setSubject("Test");
        cmd.setRecipient("delamos@grails.example");
        cmd.setTextBody("Hola hola");

        HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd); (5)
        EmailService emailService = applicationContext.getBean(EmailService.class);
        assertTrue(emailService instanceof MockEmailService);

        int oldEmailsSize = ((MockEmailService) emailService).emails.size();
        HttpResponse<?> rsp = client.toBlocking().exchange(request);

        assertEquals(HttpStatus.OK, rsp.getStatus());
        assertEquals(oldEmailsSize + 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.6. 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/java/example/micronaut/MailControllerValidationTest.java
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.junit5.annotation.MicronautTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;

import jakarta.inject.Inject;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

@MicronautTest (1)
@Property(name = "spec.name", value = "mailcontroller") (2)
class MailControllerValidationTest {

    @Inject
    ApplicationContext applicationContext;

    @Inject
    @Client("/")
    HttpClient client;

    @Test
    public void mailSendCannotBeInvokedWithoubSubject() {
        EmailCmd cmd = new EmailCmd();
        cmd.setRecipient("delamos@micronaut.example");
        cmd.setTextBody("Hola hola");
        HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd); (3)

        Executable e = () -> client.toBlocking().exchange(request);
        HttpClientResponseException thrown = assertThrows(HttpClientResponseException.class, e);
        assertEquals(HttpStatus.BAD_REQUEST, thrown.getStatus());
    }

    @Test
    public void mailSendCannotBeInvokedWithoutRecipient() {
        EmailCmd cmd = new EmailCmd();
        cmd.setSubject("Hola");
        cmd.setTextBody("Hola hola");
        HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd); (3)

        Executable e = () -> client.toBlocking().exchange(request);
        HttpClientResponseException thrown = assertThrows(HttpClientResponseException.class, e);
        assertEquals(HttpStatus.BAD_REQUEST, thrown.getStatus());
    }

    @Test
    public void mailSendCannotBeInvokedWithoutEitherTextBodyOrHtmlBody() {
        EmailCmd cmd = new EmailCmd();
        cmd.setSubject("Hola");
        cmd.setRecipient("delamos@micronaut.example");
        HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd); (3)

        Executable e = () -> client.toBlocking().exchange(request);
        HttpClientResponseException thrown = assertThrows(HttpClientResponseException.class, e);
        assertEquals(HttpStatus.BAD_REQUEST, thrown.getStatus());
    }

    @Test
    public void mailSendCanBeInvokedWithoutTextBodyAndNotHtmlBody() {
        EmailCmd cmd = new EmailCmd();
        cmd.setSubject("Hola");
        cmd.setRecipient("delamos@micronaut.example");
        cmd.setTextBody("Hello");

        HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd); (3)
        HttpResponse<?> rsp = client.toBlocking().exchange(request);
        assertEquals(HttpStatus.OK, rsp.getStatus());
    }

    @Test
    public void mailSendCanBeInvokedWithoutHtmlBodyAndNotTextBody() {
        EmailCmd cmd = new EmailCmd();
        cmd.setSubject("Hola");
        cmd.setRecipient("delamos@micronaut.example");
        cmd.setHtmlBody("<h1>Hello</h1>");

        HttpRequest<EmailCmd> request = HttpRequest.POST("/mail/send", cmd); (3)
        HttpResponse<?> rsp = client.toBlocking().exchange(request);
        assertEquals(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/java/example/micronaut/EmailConstraints.java
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({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @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/java/example/micronaut/EmailConstraintsFactory.java
package example.micronaut;

import io.micronaut.context.annotation.Factory;
import io.micronaut.core.util.StringUtils;
import io.micronaut.validation.validator.constraints.ConstraintValidator;

import jakarta.inject.Singleton;

@Factory
public class EmailConstraintsFactory {

    @Singleton
    ConstraintValidator<EmailConstraints, EmailCmd> emailBodyValidator() {
        return (value, annotationMetadata, context) ->
                value != null &&
                        (StringUtils.isNotEmpty(value.getTextBody()) || StringUtils.isNotEmpty(value.getHtmlBody()));
    }
}

Annotate EmailCmd with EmailConstraints and @Introspected (to generate the Bean Introspection information).

src/main/java/example/micronaut/EmailCmd.java
@EmailConstraints

@Introspected
public class EmailCmd implements Email {

    @NotNull
    @NotBlank
    private String recipient;

    @NotNull
    @NotBlank
    private String subject;

    private List<String> cc = new ArrayList<>();
    private List<String> bcc = new ArrayList<>();

    private String htmlBody;
    private String textBody;
    private String replyTo;

    // getters & setters
}

5. Testing the Application

To run the tests:

./mvnw test

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.