Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions conf/guides.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2190,10 +2190,11 @@ guides:
title: Help with Grails

- name: 'grails-email'
title: 'Send Email and Spock Spring'
subtitle: 'Learn how to send emails with AWS SES and SendGrid from a Grails app and leverage Spock Spring integration to verify interaction.'
title: 'Sending Email from a Grails 8 Application (and Testing It)'
subtitle: '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'
Comment thread
jamesfredley marked this conversation as resolved.
category: 'Grails Testing'
publicationDate: '2018-06-04'
versions:
Expand Down Expand Up @@ -2257,6 +2258,46 @@ guides:
test: Test
helpWithGrails:
title: Do you need help with Grails?
'8':
sourcePath: guides/grails-email/v8
publicationDate: '2026-07-01'
tags:
- 'email'
- 'smtp'
- 'sendgrid'
- 'ses'
- 'aws'
- 'spock-spring'
- 'integration-testing'
- 'mail'
sampleRef:
repo: 'grails-guides/grails-email'
branch: 'grails8'
toc:
introduction:
title: Introduction
requirements:
title: What you will need
gettingStarted:
title: Getting Started
emailContract:
title: Email contract
smtpMail:
title: SMTP with the Mail plugin
sendGridService:
title: SendGrid
awsSesService:
title: AWS SES
mailConfiguration:
title: Wire the provider
controller:
title: Controller
testing:
title: Testing without live email
summary:
title: Summary
helpWithGrails:
title: Do you need help with Grails?

- name: 'grails-events'
title: 'Grails Events'
Expand Down
9 changes: 9 additions & 0 deletions guides/grails-email/v8/guide/awsSesService.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
AWS SES is the third provider option:

[source,groovy]
.src/main/groovy/example/AwsSesEmailService.groovy
----
include::../snippets/src/main/groovy/example/AwsSesEmailService.groovy[]
----

Set `EMAIL_PROVIDER=ses` and configure `AWS_REGION` and `AWS_SOURCE` plus standard AWS credentials for the SDK.
29 changes: 29 additions & 0 deletions guides/grails-email/v8/guide/controller.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
Accept JSON payloads with a command object that implements `Email` and validates required fields:

[source,groovy]
.grails-app/controllers/example/EmailCmd.groovy
----
include::../snippets/grails-app/controllers/example/EmailCmd.groovy[]
----

The controller delegates to `EmailService`:

[source,groovy]
.grails-app/controllers/example/MailController.groovy
----
include::../snippets/grails-app/controllers/example/MailController.groovy[]
----

[IMPORTANT]
====
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:
Comment thread
jamesfredley marked this conversation as resolved.

[source,bash]
----
curl -X POST http://localhost:8080/mail/send \
-H 'Content-Type: application/json' \
-d '{"recipient":"user@example.com","subject":"Hello","textBody":"Hi there"}'
----
25 changes: 25 additions & 0 deletions guides/grails-email/v8/guide/emailContract.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
Define a small `Email` contract and a single `EmailService` boundary that every provider implements:

[source,groovy]
.src/main/groovy/example/Email.groovy
----
include::../snippets/src/main/groovy/example/Email.groovy[]
----

[source,groovy]
.src/main/groovy/example/EmailService.groovy
----
include::../snippets/src/main/groovy/example/EmailService.groovy[]
----

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`:

[source,groovy]
.build.gradle
----
include::../snippets/build.gradle[]
----

Grails 8 uses `org.grails.plugins:grails-mail` 5.x from the Grails plugin repository, compatible with Apache Grails 8.
14 changes: 14 additions & 0 deletions guides/grails-email/v8/guide/gettingStarted.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Clone the repository and verify tests pass:

[source,bash]
----
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`).
1 change: 1 addition & 0 deletions guides/grails-email/v8/guide/helpWithGrails.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include::{commondir}/common-helpWithGrails.adoc[]
5 changes: 5 additions & 0 deletions guides/grails-email/v8/guide/introduction.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
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/`.
9 changes: 9 additions & 0 deletions guides/grails-email/v8/guide/mailConfiguration.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Wire the active provider in `grails-app/conf/spring/resources.groovy`:

[source,groovy]
.grails-app/conf/spring/resources.groovy
----
include::../snippets/grails-app/conf/spring/resources.groovy[]
----

The switch reads `email.provider` from configuration. Provider wiring is skipped in the `test` environment so integration tests can register a mock `EmailService` bean.
4 changes: 4 additions & 0 deletions guides/grails-email/v8/guide/requirements.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
* Approximately 45 minutes
* JDK 21 (Apache Grails 8 requires Java 21)
* A https://www.grails.org/[Grails] installation or the bundled Gradle wrapper in `initial/` and `complete/`
* Optional: local SMTP catcher on port **1025** for manual `bootRun` verification (for example https://github.com/mailhog/MailHog[MailHog])
9 changes: 9 additions & 0 deletions guides/grails-email/v8/guide/sendGridService.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
SendGrid implements the same `EmailService` contract:

[source,groovy]
.src/main/groovy/example/SendGridEmailService.groovy
----
include::../snippets/src/main/groovy/example/SendGridEmailService.groovy[]
----

Set `EMAIL_PROVIDER=sendgrid` and provide `SENDGRID_APIKEY` and `SENDGRID_FROM_EMAIL` (see the `sendgrid` block in `application.yml`).
17 changes: 17 additions & 0 deletions guides/grails-email/v8/guide/smtpMail.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
The default provider delegates to the Grails Mail plugin:

[source,groovy]
.grails-app/services/example/SmtpEmailService.groovy
----
include::../snippets/grails-app/services/example/SmtpEmailService.groovy[]
----

Configure SMTP settings in `application.yml`:

[source,yaml]
.grails-app/conf/application.yml
----
include::../snippets/grails-app/conf/application.yml[]
----

For local development, point `SMTP_HOST` and `SMTP_PORT` at a mail catcher on `localhost:1025`.
8 changes: 8 additions & 0 deletions guides/grails-email/v8/guide/summary.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
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.
17 changes: 17 additions & 0 deletions guides/grails-email/v8/guide/testing.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Verify the HTTP endpoint builds the message and invokes `EmailService` without sending live email. Register a Spock mock as the primary `EmailService` bean:

[source,groovy]
.src/integration-test/groovy/example/MailControllerSpec.groovy
----
include::../snippets/src/integration-test/groovy/example/MailControllerSpec.groovy[]
----

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

Run tests:

[source,bash]
----
cd ../complete
./gradlew test integrationTest
----
3 changes: 3 additions & 0 deletions guides/grails-email/v8/snippets/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
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"
28 changes: 28 additions & 0 deletions guides/grails-email/v8/snippets/grails-app/conf/application.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#tag::mailConfig[]
grails:
mail:
Comment thread
jamesfredley marked this conversation as resolved.
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[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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[]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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, view: '/application/errors', status: UNPROCESSABLE_ENTITY
return
}
log.info 'Sending mail to {} with subject {}', cmd.recipient, cmd.subject
emailService.send(cmd)
render status: 200
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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)
}
}
Loading