👁️

【Kotlin / Spring Boot】疎結合で汎用的なメール送信機能を作る

2023/11/12に公開

はじめに

本記事では、KotlinとSpring Bootの内容ですが、以前に執筆した記事の設計がベースになっております。

https://zenn.dev/ikefukurou777/articles/65cfd0289ac74d

https://zenn.dev/ikefukurou777/articles/6518f3facf64eb

全員共通のメールもあれば、タイトルや本文の一部分を動的に変更して、メールを送信するユースケースが多いと思います。
汎用的なメール送信機能に加えて、オニオンアーキテクチャーを意識した疎結合で実装した内容を記事にしました。

環境

  • Kotlin 1.8.22
  • Java 17
  • Spring Boot 3.1.3
  • spring-boot-starter-mail:3.0.4
  • Exposed 0.43.0
  • PostgreSQL

DB設計

ER図

テーブルの設計方針としては、以下を前提としております。

  • テーブルにはサロゲートキーとしてid列を持たせる(id列は一意の値を付与する)
  • 選定しているORMの関係上、中間テーブルにもサロゲートキーを付与している

DDL/DML

CREATE TABLE IF NOT EXISTS mail_templates (
    id SERIAL PRIMARY KEY,
    code VARCHAR(8) NOT NULL,
    "name" VARCHAR(30) NOT NULL,
    subject VARCHAR(100) NOT NULL,
    body TEXT NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

ALTER TABLE mail_templates
ADD CONSTRAINT mail_templates_code_unique UNIQUE (code);

CREATE TABLE IF NOT EXISTS mail_tags (
    id SERIAL PRIMARY KEY,
    "name" VARCHAR(50) NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

ALTER TABLE mail_tags
ADD CONSTRAINT mail_tags_name_unique UNIQUE ("name");

CREATE TABLE IF NOT EXISTS mail_template_tag_relations (
    id SERIAL PRIMARY KEY,
    mail_template_id INT NOT NULL,
    mail_tag_id INT NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    CONSTRAINT fk_mail_template_tag_relations_mail_template_id__id FOREIGN KEY (mail_template_id) REFERENCES mail_templates(id) ON DELETE RESTRICT ON UPDATE RESTRICT,
    CONSTRAINT fk_mail_template_tag_relations_mail_tag_id__id FOREIGN KEY (mail_tag_id) REFERENCES mail_tags(id) ON DELETE RESTRICT ON UPDATE RESTRICT
);
-- mail_tags
INSERT INTO mail_tags (id, "name") VALUES (1, 'eventName');
INSERT INTO mail_tags (id, "name") VALUES (2, 'plannerUserName');
INSERT INTO mail_tags (id, "name") VALUES (3, 'eventUrl');
INSERT INTO mail_tags (id, "name") VALUES (4, 'userName');
INSERT INTO mail_tags (id, "name") VALUES (5, 'userProfileUrl');

-- mail_templates
INSERT INTO mail_templates (code, "name", subject, body) VALUES (
    'EMAIL001',
    '[管理者向け] イベントへの気になる登録通知',
    '${eventName}に気になる登録がありました!',
    '<p>${plannerUserName} 様</p>
<br>
<p>あなたが登録した${eventName}に新しい気になる登録がありました。</p>
<br>
<p>対象イベント:</p>
<a href="${eventUrl}" target="_blank">${eventName}</a>
<br>
<p>ユーザー情報:</p>
<p>ユーザー名:  ${userName}</p>
<p>プロフィールはこちらです。</p>
${userProfileUrl}
<br>
<br>
<p>引き続き、イベントの成功に向けてのご活動を応援しております。</p>
<br>
<p>よろしくお願いいたします。</p>
'
);

-- mail_template_tag_relations
INSERT INTO mail_template_tag_relations (mail_template_id, mail_tag_id) VALUES (1, 1);
INSERT INTO mail_template_tag_relations (mail_template_id, mail_tag_id) VALUES (1, 2);
INSERT INTO mail_template_tag_relations (mail_template_id, mail_tag_id) VALUES (1, 3);
INSERT INTO mail_template_tag_relations (mail_template_id, mail_tag_id) VALUES (1, 4);
INSERT INTO mail_template_tag_relations (mail_template_id, mail_tag_id) VALUES (1, 5);

メールテンプレートテーブルにて、アプリケーション内で一意となるコード値を払い出します。
タグ(メールの件名や本文内のプレースホルダー)は、n個のメールテンプレートで使用されることを鑑みて、タグテーブルを用意し、両テーブルの中間テーブルで管理しています。

アーキテクチャー

src配下のディレクトリ構成抜粋です。

src
├── main
│   ├── kotlin
│   │   └── com
│   │       └── ss
│   │           └── sample
│   │               ├── SampleApplication.kt
│   │               ├── application
│   │               │   ├── email
│   │               │   │   ├── IMailProcessor.kt
│   │               │   │   ├── MailDetailsDto.kt
│   │               │   │   └── MailProcessor.kt
│   │               ├── config
│   │               │   ├── email
│   │               │   │   └── MailProperties.kt
│   │               ├── domain
│   │               │   ├── email
│   │               │   │   ├── IMailTemplateRepository.kt
│   │               │   │   ├── ISMTPMailSender.kt
│   │               │   │   ├── MailTag.kt
│   │               │   │   ├── MailTemplate.kt
│   │               │   │   └── MailTemplateCode.kt
│   │               ├── infrastructure
│   │               │   ├── entity
│   │               │   │   ├── MailTagEntity.kt
│   │               │   │   ├── MailTemplateEntity.kt
│   │               │   │   ├── MailTemplateTagRelationEntity.kt
│   │               │   ├── repository
│   │               │   │   ├── email
│   │               │   │   │   ├── MailTemplateRepository.kt
│   │               │   │   │   └── SMTPMailSender.kt
│   │               │   └── tables
│   │               │       ├── MailTagTable.kt
│   │               │       ├── MailTemplateTable.kt
│   │               │       ├── MailTemplateTagRelationTable.kt
│   │               └── presentation
│   │                   ├── admin
│   │                   │   └── AdminSampleController.kt
│   └── resources
│       ├── application-local.yml
│       ├── application.yml
│       ├── db
│       │   └── migration
│       │       ├── V0.0.1__create_tables.sql
│       │       └── V0.0.2__init_data.sql
│       ├── static
│       └── templates
└── test
    └── resources
        └── application.yml

実装

メール送信クラス

実際にメールを送信するメソッドです。
外部とのやり取りになることから、インフラストラクチャー層に配置しました。

実装クラス

@Component
class SMTPMailSender(private val javaMailSender: JavaMailSender) : ISMTPMailSender {
    override fun sendMail(mailDetailsDto: MailDetailsDto) {
        try {
            val message = javaMailSender.createMimeMessage()
            val helper = MimeMessageHelper(message, true)

            helper.setTo(mailDetailsDto.to.toTypedArray())
            helper.setFrom(mailDetailsDto.from, mailDetailsDto.fromName)
            helper.setSubject(mailDetailsDto.subject)
            helper.setText(mailDetailsDto.htmlContent, true)
            mailDetailsDto.cc?.let { helper.setCc(it.toTypedArray()) }
            mailDetailsDto.bcc?.let { helper.setBcc(it.toTypedArray()) }
            mailDetailsDto.attachmentPaths?.forEach { helper.addAttachment(it, File(it)) }
            javaMailSender.send(message)
        } catch (e: Exception) {
            throw CustomMailSendException("Failed to send mail: ${e.message}")
        }
    }
}

インターフェース

interface ISMTPMailSender {
    fun sendMail(mailDetailsDto: MailDetailsDto)
}

DTO

data class MailDetailsDto(
    val to: List<String>,
    val cc: List<String>? = null,
    val bcc: List<String>? = null,
    val from: String,
    val fromName: String,
    val subject: String,
    val htmlContent: String,
    val attachmentPaths: List<String>? = null
)

ドメイン層に定義しているインターフェースに対する実装クラスです。
メールに必要なパラメーターをDTOで受け取って、JavaMailでメールを送信しています。

ドメイン層

ビジネスルール、プロセスに関わる内容を定義しています。

メールテンプレートコード

enum class MailTemplateCodeType {
    EMAIL001 // [管理者向け] イベントへの気になる登録通知
}

data class MailTemplateCode(val value: String) {
    init {
        val validMailTemplateCodeValues = MailTemplateCodeType.values().map { it.name }
        if (!validMailTemplateCodeValues.contains(value)) {
            throw ArgumentException("メールテンプレートコードが無効です:$value")
        }
    }
}

上述しましたが、メールテンプレートコードは一意となるコード値を払い出しているため、そのコード値ではない値が渡された場合にはインスタンスを生成できないように 値オブジェクト(ValueObject) にしています。

メールテンプレートクラス

data class MailTag(val tagName: String) {
    constructor(mailTagEntity: MailTagEntity) : this(mailTagEntity.name)
}
data class MailTemplate(
    val code: MailTemplateCode,
    val name: String,
    val subject: String,
    val body: String,
    val tags: List<MailTag>
) {
    constructor(mailTemplateEntity: MailTemplateEntity) : this(
        MailTemplateCode(mailTemplateEntity.code),
        mailTemplateEntity.name,
        mailTemplateEntity.subject,
        mailTemplateEntity.body,
        mailTemplateEntity.tags.map { MailTag(it.mailTag) }
    )
}

エンティティーの定義(テーブル定義に対して、ORMに依存したクラス定義)は省略しますが、データベースから取得した値をエンティティークラスに詰めた上で、それをドメインクラスに詰め替えて利用できるようなクラスを作っています。

インフラストラクチャー層

リポジトリクラスは、データベースにORMを介して、クエリを発行する責務です。

実装クラス

@Repository
class MailTemplateRepository : IMailTemplateRepository {
    override fun fetchMailTemplateByMailTemplateCode(mailTemplateCode: MailTemplateCode): MailTemplate? {
        return MailTemplateEntity.find { MailTemplateTable.code eq mailTemplateCode.value }.firstOrNull()
            ?.let { mailTemplateEntity ->
                MailTemplate(
                    MailTemplateCode(mailTemplateEntity.code),
                    mailTemplateEntity.name,
                    mailTemplateEntity.subject,
                    mailTemplateEntity.body,
                    mailTemplateEntity.tags.map { tagEntity -> MailTag(tagEntity.mailTag) }
                )
            }
    }
}

インターフェース

interface IMailTemplateRepository {
    fun fetchMailTemplateByMailTemplateCode(mailTemplateCode: MailTemplateCode): MailTemplate?
}

ORMはExposedを使っており、その内容については省略しますが、テーブルからメールテンプレートとそのタグ情報を取得し、ドメインクラスに詰め替えてから返却しています。

設定値

SMTP情報、メールの差出人の定義は、application.ymlにて定義し、ConfigurationPropertiesを使用したクラスを定義して、設定内容を読み込むようにしています。

spring:
  mail:
    host: ${MAIL_HOST:xxxxx}
    username: ${MAIL_USERNAME:xxxxx}
    password: ${MAIL_PASSWORD:xxxxx}
    port: ${MAIL_PORT:587}

smtp:
  mail:
    from: ${SMTP_FROM:no_reply@example.com}
    from-name: サンプルアプリケーション
@Configuration
@ConfigurationProperties(prefix = "smtp.mail")
data class MailProperties(
    var from: String = "",
    var fromName: String = ""
)

SMTP認証で必要なるホストやユーザー情報のロードは不要です。application.ymlに設定しておくことでJavaMailでメールを送信時に参照されるため、明示的なセットは不要であることから読み込みはしていません。

アプリケーション層

ビジネスロジックを責務とするサービスクラスです。

インターフェース

interface IMailProcessor {
    fun buildAndSendMail(
        mailTemplateCode: MailTemplateCode,
        bindings: Map<String, String>,
        to: List<String>,
        cc: List<String>?,
        bcc: List<String>?
    )
}

実装クラス

@Service
class MailProcessor(
    private val mailTemplateRepository: IMailTemplateRepository,
    private val mailSender: ISMTPMailSender,
    private val mailProperties: MailProperties
) : IMailProcessor {
    override fun buildAndSendMail(
        mailTemplateCode: MailTemplateCode,
        bindings: Map<String, String>,
        to: List<String>,
        cc: List<String>?,
        bcc: List<String>?
    ) {
        // メールテンプレートを取得する
        val mailTemplate = mailTemplateRepository.fetchMailTemplateByMailTemplateCode(mailTemplateCode)
            ?: throw CustomNotFoundException("Mail template not found for code: ${mailTemplateCode.value}")
        // メール文面の生成する
        val mailContent = buildMailContent(mailTemplate, bindings)
        // メール送信
        val mailDetailsDto = MailDetailsDto(
            to = to,
            cc = cc,
            bcc = bcc,
            from = mailProperties.from,
            fromName = mailProperties.fromName,
            subject = mailContent.subject,
            htmlContent = mailContent.body,
            attachmentPaths = null // 添付ファイルが必要になった場合には、この値を設定する
        )
        sendMail(mailDetailsDto)
    }

    /**
     * メールテンプレートのプレースホルダーを置換する
     * @param mailTemplate メールテンプレート
     * @param bindings プレースホルダーと置換する値のマップ
     * @return プレースホルダーを置換したメール文面
     */
    private fun buildMailContent(mailTemplate: MailTemplate, bindings: Map<String, String>): MailContent {
        var subject = mailTemplate.subject
        var body = mailTemplate.body

        mailTemplate.tags.forEach { tag ->
            val placeholder = "\\$\\{${tag.tagName}\\}"
            val tagValue =
                bindings[tag.tagName] ?: throw CustomNotFoundException("Tag not found for name: ${tag.tagName}")
            subject = subject.replace(placeholder.toRegex(), tagValue)
            body = body.replace(placeholder.toRegex(), tagValue)
        }

        return MailContent(subject, body)
    }

    private fun sendMail(mailDetailsDto: MailDetailsDto) {
        mailSender.sendMail(mailDetailsDto)
    }

    data class MailContent(val subject: String, val body: String)
}
  • 別レイヤーのクラスはDIして利用します。
  • buildAndSendMailメソッドでは、メールテンプレートコード、プレースホルダーとプレースホルダーにマッピングしたい値のリスト、誰に送るかを受け取ります。
    • メールテンプレートコードに設定されている内容を基に、メールの件名と本文を生成します
    • メールを送信します

動作確認

ローカルでの開発時には、mailtrapを使用して、実際にメールをせずに期待されるメールが送信されるかを確認します。

// メール送信テスト
mailProcessor.buildAndSendMail(
    MailTemplateCode("EMAIL001"),
        mapOf(
            "eventName" to "イベントテスト",
            "plannerUserName" to "イベント太郎",
            "eventUrl" to "https://example.com/events/1",
            "userName" to "村田 太郎",
            "userProfileUrl" to "https://example.com/users/1"
            ),
            listOf("to@example.com"),
            listOf("cc@example.com"),
            listOf("bcc@example.com")
)

送信イメージ
送信されたメール

おわりに

メールテンプレートが増えた場合でも利用側でプレースホルダーに対して何をマッピングするかを決めれば汎用的に利用できるかと思います。
また、疎結合にしていますので、各レイヤーでテストがし易いです。

以上です。
本記事が何かの一助になれば幸いです。

Discussion