Sending Email from a Grails 8 Application (and Testing It)

Send mail over SMTP or through SendGrid / AWS SES, externalize configuration, and verify send interactions with Spock Spring integration tests, without live email.

Authors: Sergio del Amo, Sanjana

Grails Version: 8

1 Introduction

Learn how to send mail from a Grails 8 application over SMTP (Grails Mail plugin) and through a provider (SendGrid / AWS SES), externalize configuration in application.yml, and verify send interactions with Spock Spring integration tests, without dispatching real email in CI.

This guide parallels the HTTP client guide in shape: an external integration behind a mockable service boundary, with tests that assert the contract.

This guide follows the standard Grails guides layout: work in the initial/ project and compare your progress with complete/.

2 What you will need

  • Approximately 45 minutes

  • JDK 21 (Apache Grails 8 requires Java 21)

  • A Grails installation or the bundled Gradle wrapper in initial/ and complete/

  • Optional: local SMTP catcher on port 1025 for manual bootRun verification (for example MailHog)

3 Getting Started

Clone the repository and verify tests pass:

git clone -b grails8 https://github.com/grails-guides/grails-email.git
cd grails-email/initial
./gradlew test

The initial/ project is a Grails 8 REST API starter with no mail wiring yet.

To skip ahead, cd ../complete and run the same commands; that tree contains the finished mail services, controller, and integration spec.

Both initial and complete must pass in CI (see .github/workflows/grails8.yml).

4 Email contract

Define a small Email contract and a single EmailService boundary that every provider implements:

src/main/groovy/example/Email.groovy
package example

import groovy.transform.CompileStatic

@CompileStatic
interface Email {
    String getRecipient()
    List<String> getCc()
    List<String> getBcc()
    String getSubject()
    String getHtmlBody()
    String getTextBody()
    String getReplyTo()
}
src/main/groovy/example/EmailService.groovy
package example

import groovy.transform.CompileStatic

@CompileStatic
interface EmailService {
    void send(Email email)
}

Controllers and tests depend on EmailService, not on SMTP, SendGrid, or SES directly. Swap providers by changing Spring wiring and configuration.

Add the mail dependencies in build.gradle:

build.gradle
    implementation "org.grails.plugins:grails-mail:5.0.3"
    implementation "com.sendgrid:sendgrid-java:4.9.3"
    implementation "software.amazon.awssdk:ses:2.29.45"

Grails 8 uses org.grails.plugins:grails-mail 5.x from the Grails plugin repository, compatible with Apache Grails 8.

5 SMTP with the Mail plugin

The default provider delegates to the Grails Mail plugin:

grails-app/services/example/SmtpEmailService.groovy
package example

import grails.plugins.mail.MailService
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@CompileStatic
class SmtpEmailService implements EmailService {  (1)

    MailService mailService

    @Override
    void send(Email email) {
        mailService.sendMail {
            to email.recipient
            subject email.subject
            if (email.textBody) {
                text email.textBody
            }
            if (email.htmlBody) {
                html email.htmlBody
            }
            if (email.replyTo) {
                replyTo email.replyTo
            }
            if (email.cc) {
                cc email.cc as String[]
            }
            if (email.bcc) {
                bcc email.bcc as String[]
            }
        }
        log.debug('SMTP message handed off to MailService for {}', email.recipient)
    }
}

Configure SMTP settings in application.yml:

grails-app/conf/application.yml
#tag::mailConfig[]
grails:
  mail:
    host: ${SMTP_HOST:localhost}
    port: ${SMTP_PORT:1025}
    username: ${SMTP_USERNAME:}
    password: ${SMTP_PASSWORD:}
    default:
      from: ${SMTP_FROM:no-reply@example.com}
    props:
      mail.smtp.auth: ${SMTP_AUTH:false}
      mail.smtp.starttls.enable: ${SMTP_STARTTLS:false}
#end::mailConfig[]
#tag::emailProvider[]
email:
  provider: ${EMAIL_PROVIDER:smtp}
#end::emailProvider[]
#tag::sendgrid[]
sendgrid:
  api: ${SENDGRID_APIKEY:}
  from: ${SENDGRID_FROM_EMAIL:}
#end::sendgrid[]
#tag::awsses[]
aws:
  ses:
    source: ${AWS_SOURCE:}
    region: ${AWS_REGION:}
#end::awsses[]

For local development, point SMTP_HOST and SMTP_PORT at a mail catcher on localhost:1025.

6 SendGrid

SendGrid implements the same EmailService contract:

src/main/groovy/example/SendGridEmailService.groovy
package example

import com.sendgrid.Method
import com.sendgrid.Request
import com.sendgrid.Response
import com.sendgrid.SendGrid
import com.sendgrid.helpers.mail.Mail
import com.sendgrid.helpers.mail.objects.Content
import com.sendgrid.helpers.mail.objects.Email as SendGridEmail
import com.sendgrid.helpers.mail.objects.Personalization
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@CompileStatic
class SendGridEmailService implements EmailService, GrailsConfigurationAware {  (1)

    String api
    String from

    @Override
    void setConfiguration(Config co) {
        this.api = co.getProperty('sendgrid.api', String)
        if (!this.api) {
            throw new IllegalStateException('sendgrid.api not set')
        }
        this.from = co.getProperty('sendgrid.from', String)
        if (!this.from) {
            throw new IllegalStateException('sendgrid.from not set')
        }
    }

    @Override
    void send(Email email) {
        Mail mail = buildEmail(email)
        SendGrid sg = new SendGrid(api)
        Request request = new Request()
        try {
            request.with {
                method = Method.POST
                endpoint = 'mail/send'
                body = mail.build()
            }
            Response response = sg.api(request)
            log.info('Status Code: {}', String.valueOf(response.statusCode))
            log.debug('Body: {}', response.body)
            if (response.statusCode < 200 || response.statusCode >= 300) {
                throw new IllegalStateException(
                        "SendGrid returned status ${response.statusCode}: ${response.body}")
            }
        } catch (IOException ex) {
            log.error(ex.message, ex)
            throw ex
        }
    }

    private Personalization buildPersonalization(Email email) {
        Personalization personalization = new Personalization()
        personalization.subject = email.subject

        SendGridEmail to = new SendGridEmail(email.recipient)
        personalization.addTo(to)

        if (email.cc) {
            for (String cc : email.cc) {
                personalization.addCc(new SendGridEmail(cc))
            }
        }
        if (email.bcc) {
            for (String bcc : email.bcc) {
                personalization.addBcc(new SendGridEmail(bcc))
            }
        }
        personalization
    }

    private Mail buildEmail(Email email) {
        Personalization personalization = buildPersonalization(email)
        Mail mail = new Mail()
        SendGridEmail fromEmail = new SendGridEmail(this.from)
        mail.from = fromEmail
        mail.addPersonalization(personalization)
        if (email.textBody) {
            mail.addContent(new Content('text/plain', email.textBody))
        }
        if (email.htmlBody) {
            mail.addContent(new Content('text/html', email.htmlBody))
        }
        mail
    }
}

Set EMAIL_PROVIDER=sendgrid and provide SENDGRID_APIKEY and SENDGRID_FROM_EMAIL (see the sendgrid block in application.yml).

7 AWS SES

AWS SES is the third provider option:

src/main/groovy/example/AwsSesEmailService.groovy
package example

import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
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

@Slf4j
@CompileStatic
class AwsSesEmailService implements EmailService, GrailsConfigurationAware {  (1)

    String sourceEmail
    SesClient sesClient

    @Override
    void setConfiguration(Config co) {
        String awsRegion = co.getProperty('aws.ses.region', String)
        if (!awsRegion) {
            throw new IllegalStateException('aws.ses.region not set')
        }
        this.sesClient = SesClient.builder().region(Region.of(awsRegion)).build()

        this.sourceEmail = co.getProperty('aws.ses.source', String)
        if (!this.sourceEmail) {
            throw new IllegalStateException('aws.ses.source not set')
        }
    }

    private Body bodyOfEmail(Email email) {
        Body.Builder bodyBuilder = Body.builder()
        if (email.htmlBody) {
            bodyBuilder.html(Content.builder().data(email.htmlBody).build())
        }
        if (email.textBody) {
            bodyBuilder.text(Content.builder().data(email.textBody).build())
        }
        bodyBuilder.build()
    }

    private Destination destination(Email email) {
        Destination.Builder destinationBuilder = Destination.builder().toAddresses(email.recipient)
        if (email.cc) {
            destinationBuilder = destinationBuilder.ccAddresses(email.cc)
        }
        if (email.bcc) {
            destinationBuilder = destinationBuilder.bccAddresses(email.bcc)
        }
        destinationBuilder.build()
    }

    private Message composeMessage(Email email) {
        Content subject = Content.builder().data(email.subject).build()
        Body body = bodyOfEmail(email)
        Message.builder().subject(subject).body(body).build()
    }

    @Override
    void send(Email email) {
        try {
            Destination destination = destination(email)
            Message message = composeMessage(email)
            SendEmailRequest sendEmailRequest = SendEmailRequest.builder()
                    .source(sourceEmail)
                    .destination(destination)
                    .message(message)
                    .build()
            SendEmailResponse response = sesClient.sendEmail(sendEmailRequest)
            log.info('Email sent! {}', response.messageId())

        } catch (Exception ex) {
            log.warn('The email was not sent.', ex)
            throw ex
        }
    }
}

Set EMAIL_PROVIDER=ses and configure AWS_REGION and AWS_SOURCE plus standard AWS credentials for the SDK.

8 Wire the provider

Wire the active provider in grails-app/conf/spring/resources.groovy:

grails-app/conf/spring/resources.groovy
import example.AwsSesEmailService
import example.SendGridEmailService
import example.SmtpEmailService
import grails.util.Environment

beans = {
    if (Environment.current == Environment.TEST) {
        return
    }
    //tag::emailServiceBeans[]
    def provider = application.config.getProperty('email.provider', String, 'smtp')
    switch (provider) {
        case 'sendgrid':
            emailService(SendGridEmailService)
            break
        case 'ses':
            emailService(AwsSesEmailService)
            break
        case 'smtp':
            emailService(SmtpEmailService)
            break
        default:
            throw new IllegalArgumentException("Unsupported email.provider: ${provider}")
    }
    //end::emailServiceBeans[]
}

The switch reads email.provider from configuration. Provider wiring is skipped in the test environment so integration tests can register a mock EmailService bean.

9 Controller

Accept JSON payloads with a command object that implements Email and validates required fields:

grails-app/controllers/example/EmailCmd.groovy
package example

import grails.compiler.GrailsCompileStatic
import grails.validation.Validateable
import groovy.transform.ToString

@ToString
@GrailsCompileStatic
class EmailCmd implements Validateable, Email {
    String recipient
    List<String> cc = []
    List<String> bcc = []
    String subject
    String htmlBody
    String textBody
    String replyTo

    static constraints = {
        recipient nullable: false (1)
        subject nullable: false  (2)
        htmlBody nullable: true
        textBody nullable: true, validator: { String val, EmailCmd obj ->  (3)
            !(!obj.htmlBody && !val)
        }
        replyTo nullable: true
    }
}

The controller delegates to EmailService:

grails-app/controllers/example/MailController.groovy
package example

import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY

@Slf4j
@CompileStatic
class MailController {

    EmailService emailService

    static allowedMethods = [send: 'POST']

    def send(EmailCmd cmd) {
        if (cmd.hasErrors()) {
            respond cmd.errors, status: UNPROCESSABLE_ENTITY
            return
        }
        log.info 'Sending mail'
        emailService.send(cmd)
        render status: 200
    }
}

When the command object fails validation, respond renders the errors through the JSON views error template. Grails resolves that template by convention, first at grails-app/views/<controllerName>/_errors.gson and then at grails-app/views/errors/_errors.gson. A view: argument is ignored on this path, so the template has to live at one of those two locations. Update the generated template to report the request path and an absolute self link:

grails-app/views/errors/_errors.gson
import org.springframework.validation.*

/**
 * Renders validation errors according to vnd.error: https://github.com/blongden/vnd.error
 */
model {
    Errors errors
}

response.status UNPROCESSABLE_ENTITY

json {
    Errors errorsObject = (Errors)this.errors
    def allErrors = errorsObject.allErrors
    int errorCount = allErrors.size()
    String contextPath = request.contextPath ?: ''
    String resourcePath = request.uri
    String contextRelativePath = resourcePath.startsWith(contextPath) ? resourcePath.substring(contextPath.length()) : resourcePath
    String resourceLink = g.link(uri: contextRelativePath, absolute: true)
    if(errorCount == 1) {
        def error = allErrors.iterator().next()
        message messageSource.getMessage(error, locale)
        path resourcePath
        _links {
            self {
                href resourceLink
            }
        }
    }
    else {
        total errorCount
        _embedded {
            errors(allErrors) { ObjectError error ->
                message messageSource.getMessage(error, locale)
                path resourcePath
                _links {
                    self {
                        href resourceLink
                    }
                }
            }
        }
    }
}

The template the application generator creates uses g.link(resource: request.uri), which treats the URI as a resource name and reports /send instead of /mail/send. This version reports request.uri directly as path, and passes the same URI to g.link as uri: with absolute: true to build the self link. The context path is stripped before linking because g.link adds it back for absolute links, which would otherwise repeat it when the application runs under a servlet context path.

This endpoint is demonstration-only. It accepts arbitrary recipients and message content with no authentication, so a deployed copy can become an outbound mail relay through the configured provider. Before any public or production exposure, add authentication, authorization, recipient controls, and abuse/rate-limit protection.

Example request:

curl -X POST http://localhost:8080/mail/send \
  -H 'Content-Type: application/json' \
  -d '{"recipient":"user@example.com","subject":"Hello","textBody":"Hi there"}'

10 Testing without live email

Verify the HTTP endpoint builds the message and invokes EmailService without sending live email. Register a Spock mock as the primary EmailService bean:

src/integration-test/groovy/example/MailControllerSpec.groovy
package example

import grails.testing.mixin.integration.Integration
import groovy.json.JsonSlurper
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.client.HttpClientErrorException
import org.springframework.web.client.RestTemplate
import spock.lang.Specification
import spock.mock.DetachedMockFactory

@Integration
class MailControllerSpec extends Specification {

    @Autowired
    EmailService emailService

    void '/mail/send invokes email service with expected message'() {
        when:
        RestTemplate client = new RestTemplate()
        ResponseEntity<Map> resp = client.postForEntity(
                "http://localhost:$serverPort/mail/send",
                [
                        subject  : 'Test',
                        recipient: 'delamos@grails.example',
                        textBody : 'Hola hola'
                ],
                Map
        )

        then:
        resp.statusCode == HttpStatus.OK
        1 * emailService.send({ Email email ->
            email.recipient == 'delamos@grails.example' &&
                    email.subject == 'Test' &&
                    email.textBody == 'Hola hola'
        }) (1)
    }

    void 'invalid request without recipient returns 422 with validation errors'() {
        when:
        RestTemplate client = new RestTemplate()
        HttpClientErrorException ex = null
        try {
            client.postForEntity(
                    "http://localhost:$serverPort/mail/send",
                    [
                            subject : 'Test',
                            textBody: 'Hola hola'
                    ],
                    Map
            )
        } catch (HttpClientErrorException e) {
            ex = e
        }

        then:
        ex != null
        ex.statusCode == HttpStatus.UNPROCESSABLE_CONTENT
        def body = new JsonSlurper().parseText(ex.responseBodyAsString)
        body.message
        body.path == '/mail/send'
        body._links.self.href == "http://localhost:$serverPort/mail/send"
        0 * emailService.send(_)
    }

    void 'invalid request without subject returns 422 with validation errors'() {
        when:
        RestTemplate client = new RestTemplate()
        HttpClientErrorException ex = null
        try {
            client.postForEntity(
                    "http://localhost:$serverPort/mail/send",
                    [
                            recipient: 'delamos@grails.example',
                            textBody : 'Hola hola'
                    ],
                    Map
            )
        } catch (HttpClientErrorException e) {
            ex = e
        }

        then:
        ex != null
        ex.statusCode == HttpStatus.UNPROCESSABLE_CONTENT
        def body = new JsonSlurper().parseText(ex.responseBodyAsString)
        body.message
        body.path == '/mail/send'
        body._links.self.href == "http://localhost:$serverPort/mail/send"
        0 * emailService.send(_)
    }

    @TestConfiguration
    static class EmailServiceConfiguration {
        private DetachedMockFactory factory = new DetachedMockFactory()

        @Bean
        @Primary
        EmailService emailService() {
            factory.Mock(EmailService)
        }
    }
}

The spec posts to /mail/send and asserts emailService.send is called with the expected recipient, subject, and body.

Run tests:

cd ../complete
./gradlew test integrationTest

11 Summary

You added outbound email to a Grails 8 application:

  • An EmailService boundary with SMTP, SendGrid, and AWS SES implementations

  • Externalized provider and mail settings in application.yml

  • A REST controller with command-object validation

  • Spock Spring integration tests that mock the sender, with no live email in CI

Next steps: add HTML templates, queue outbound mail asynchronously, or wire email into domain events.

12 Do you need help with Grails?

Help with Apache Grails

Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.