显示导航

发送电子邮件和 Spock Spring

了解如何使用 AWS SES 和 SendGrid 从 Grails 应用程序发送电子邮件,以及利用 Spock Spring 集成验证交互。

作者:Sergio del Amo

Grails 版本 4.0.1

1 入门

1.1 需要事项

完成本指南,需要具备以下条件

  • 一些时间

  • 合适的文本编辑器或 IDE

  • 已安装 JDK 1.8 或更高版本,且相应地配置了 JAVA_HOME

1.2 完成指南的方法

如需开始操作,请执行以下操作

或者

Grails 指南存储库包含两个文件夹

  • initial 初始项目。通常是带有部分附加代码的简单 Grails 应用程序,可帮助你快速入门。

  • complete 完成的示例。这是通过执行指南中给出的步骤并将这些更改应用于 initial 文件夹而得出的结果。

如需完成指南,请转到 initial 文件夹

  • cd 进入 grails-guides/grails-email/initial

并按照下一部分中的说明操作。

如果你 cd 进入 grails-guides/grails-email/complete,则可以直接转至完成的示例

如果你想从头开始,请使用 Grails 应用程序 Forge 创建一个新的 Grails 3 应用程序。

forgeDefault

2 编写应用程序

使用 rest-api 配置文件创建一个应用程序

grails create-app example --profile=rest-api

2.1 控制器

将条目添加到 UrlMappings

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

class UrlMappings {

    static mappings = {
    ...
    ..
    .
        post "/mail/send"(controller: 'mail', action: 'send')
    }
}

创建 MailController,它使用协作者 emailService 发送电子邮件。

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

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

@Slf4j
@CompileStatic
class MailController {

    EmailService emailService

    static allowedMethods = [send: 'POST']

    def send(EmailCmd cmd) {
        if ( cmd.hasErrors() ) {
            render status: 422
            return
        }
        log.info '{}', cmd.toString()
        emailService.send(cmd)
        render status: 200
    }
}

前面的控制器使用 命令对象

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

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
    }
}
1 recipient 是必需的
2 subject 是必需的
3 你必须指定 textBodyhtmlBody

2.2 电子邮件服务

创建接口 - EmailService。应用中任何现有的电子邮件集成都应实现它。

src/groovy/main/example/grails/EmailService.groovy
package example.grails

import groovy.transform.CompileStatic

@CompileStatic
interface EmailService {
    void send(Email email)
}
src/groovy/main/example/grails/Email.groovy
package example.grails

import groovy.transform.CompileStatic

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

2.2.1 AWS SES

Amazon 简单电子邮件服务 (Amazon SES) 是一种基于云的电子邮件发送服务,旨在帮助数字营销人员和应用程序开发人员发送营销、通知和交易类电子邮件。它是一种经济实惠的可靠服务,可供使用电子邮件与客户保持联系的所有规模的企业使用。

有一个 AWS SDK SES Grails 插件。但是,在本指南中我们将直接集成 AWS SDK SES。

添加对 AWS SES SDK 的依赖

build.gradle
    compile 'software.amazon.awssdk:ses:2.10.24'

此外,添加可以通过系统属性/命令行参数传递的配置属性

grails-app/conf/application.yml
aws:
    ses:
        source: '${AWS_SOURCE}'
        region: '${AWS_REGION}'

创建一个封装与 SES 集成的服务。有几种方法可提供编程凭据。

客户端按以下顺序使用默认凭据提供者链搜索凭据

在 Java 系统属性中:aws.accessKeyId 和 aws.secretKey。

在系统环境变量中:AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY。

在默认凭据文件中(此文件的位置因平台而异)。

在 Amazon ECS 环境变量中:AWS_CONTAINER_CREDENTIALS_RELATIVE_URI。

在实例配置文件凭据中,该凭据存在于与 EC2 实例的 IAM 角色关联的实例元数据中。

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

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 AwsSesMailService implements EmailService, GrailsConfigurationAware {  (1)

    String sourceEmail

    SesClient sesClient

    @Override
    void setConfiguration(Config co) {

        String awsRegion = co.getProperty('aws.ses.region')
        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', '')
        if (!this.sourceEmail) {
            log.warn('aws.sourceEmail not set')
        }
    }

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

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

    private Message composeMessage(Email email) {
        Content subject = Content.builder().data(email.getSubject()).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.")
            log.warn("Error message: {}", ex.message)
        }
    }
}

2.2.2 SendGrid

SendGrid 是一种交易类电子邮件服务。

SendGrid 负责为全球一些最优秀、最出色的公司发送数十亿封电子邮件。

有一个 SendGrid Grails 插件。但是,在本指南中我们将直接集成 AWS SDK SES。

添加对 SendGrid SDK 的依赖

build.gradle
    compile 'com.sendgrid:sendgrid-java:4.1.2'

添加可以通过系统属性/命令行参数传递的配置属性

grails-app/conf/application.yml
sendgrid:
    api: '${SENDGRID_APIKEY}'
    from: '${SENDGRID_FROM_EMAIL}'

创建一个封装与 SendGrid 集成的服务

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

import com.sendgrid.Personalization
import com.sendgrid.Content
import com.sendgrid.Mail
import com.sendgrid.SendGrid
import com.sendgrid.Request
import com.sendgrid.Response
import com.sendgrid.Method
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.apiKey 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.getStatusCode()))
            log.info("Body: {}", response.getBody())
            if ( log.infoEnabled ) {
                response.getHeaders().each { String k, String v ->
                    log.info("Response Header {} => {}", k, v)
                }
            }

        } catch (IOException ex) {
            log.error(ex.getMessage())
        }
    }

    private Content contentOfEmail(Email email) {
        if ( email.textBody ) {
            return new Content("text/plain", email.textBody)
        }
        if ( email.htmlBody ) {
            return new Content("text/html", email.htmlBody)
        }
        return null
    }

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

        com.sendgrid.Email to = new com.sendgrid.Email(email.recipient)
        personalization.addTo(to)

        if ( email.getCc() ) {
            for ( String cc : email.getCc() ) {
                com.sendgrid.Email ccEmail = new com.sendgrid.Email()
                ccEmail.email = cc
                personalization.addCc(ccEmail)
            }
        }
        if ( email.getBcc() ) {
            for ( String bcc : email.getBcc() ) {
                com.sendgrid.Email bccEmail = new com.sendgrid.Email()
                bccEmail.email = bcc
                personalization.addBcc(bccEmail)
            }
        }
        personalization
    }

    private Mail buildEmail(Email email) {
        Personalization personalization = buildPersonalization(email)
        Mail mail = new Mail()
        com.sendgrid.Email from = new com.sendgrid.Email()
        from.email = from
        mail.from = from
        mail.addPersonalization(personalization)
        Content content = contentOfEmail(email)
        mail.addContent(content)
        mail
    }
}

2.3 资源

Grails 与 Spring 框架 集成并基于该框架构建。

你可以通过在使用 Grails Spring DSLgrails-app/conf/spring/resources.groovy 中配置它们来轻松注册新的 bean(或重写现有的 bean)。

我们将根据所需的系统属性的存在启用 SendGrid 或 AWS SES 集成。

grails-app/conf/spring/resources.groovy
import example.grails.AwsSesMailService
import example.grails.SendGridEmailService

beans = {
    if ( System.getProperty('SENDGRID_FROM_EMAIL') && System.getProperty('SENDGRID_APIKEY') ) {
        emailService(SendGridEmailService)
    } else if (System.getProperty('AWS_REGION') && System.getProperty('AWS_SOURCE')) {
        emailService(AwsSesMailService)
    }
}

添加日志记录器以获得更高的可见性

grails-app/conf/logback.groovy
...
..
.
logger('example.grails', INFO, ['STDOUT'], false)

要使用 SendGrid,请使用必要的系统属性启动应用程序

$ ./gradlew [email protected] -DSENDGRID_APIKEY=XXXXXX bootRun

要使用 AWS SES,请使用必要的系统属性启动应用程序

$ export AWS_ACCESS_KEY_ID=XXXXXXXX
$ export AWS_SECRET_ACCESS_KEY=XXXXXXXX
$ ./gradlew -DAWS_REGION=eu-west-1 [email protected]  bootRun

2.4 测试

在我们的验收测试中,我们不希望 Bean emailService 成为 SendGridEmailServiceAwsSesMailService。相反,我们希望其成为 Mock,以便针对它验证交互。

spock-spring 模块支持将 Spock 仿真和存根定义为 Spring Bean。

添加对 spock-spring 的依赖

build.gradle
    testCompile 'org.spockframework:spock-spring:1.3-groovy-2.5'

首先,你需使用 @ComponentScanApplication.groovy 进行注释。

grails-app/init/example/grails/Application.groovy
package example.grails

import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration
import groovy.transform.CompileStatic
import org.springframework.context.annotation.ComponentScan

@ComponentScan('example.grails')
@CompileStatic
class Application extends GrailsAutoConfiguration {
    static void main(String[] args) {
        GrailsApp.run(Application, args)
    }
}

在下一个测试中,我们使用带 @TestConfiguration 注释的嵌入式配置。我们使用 DetachedMockFactory 创建 EmailService 仿真。

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

import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import io.micronaut.http.HttpStatus
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import spock.lang.Shared
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.client.HttpClient
import spock.lang.Specification
import spock.mock.DetachedMockFactory

@Integration
class MailControllerSpec extends Specification {

    @Shared
    HttpClient client

    EmailService emailService

    @OnceBefore
    void init() {
        String baseUrl = "http://localhost:$serverPort"
        this.client  = HttpClient.create(baseUrl.toURL())
    }

    def "/mail/send interacts once email service"() {
        when:
        HttpRequest request = HttpRequest.POST('/mail/send', [
                subject: 'Test',
                recipient: '[email protected]',
                textBody: 'Hola hola'
        ])
        HttpResponse resp = client.toBlocking().exchange(request)

        then:
        resp.status == HttpStatus.OK
        1 * emailService.send(_) (1)
    }

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

        @Bean
        EmailService emailService() {
            factory.Mock(EmailService)
        }
    }
}
1 emailService.send 方法被调用一次。

在 Spock 文档中了解有关 Spring Spock 集成 的更多信息。

3 你需要 Grails 的帮助吗?

Object Computing, Inc. (OCI) 赞助了本指南的创建。有多种咨询和支持服务可用。

OCI 是 Grails 的家

认识团队