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
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:
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()
}
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:
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:
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:
#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:
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:
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:
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:
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:
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:
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:
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
EmailServiceboundary 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.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.