From eb839415508b57b9db2e4c213bfe4913ea4a6d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B8=8C=EB=A6=AC?= Date: Wed, 13 Sep 2023 16:05:29 +0900 Subject: [PATCH] feat(mail): support the use of markdown in the email body --- build.gradle.kts | 1 + .../apply/application/mail/MailService.kt | 23 ++++--- src/main/kotlin/support/Markdown.kt | 26 ++++++++ .../resources/application-test.properties | 1 + src/main/resources/templates/mail/common.html | 2 +- .../apply/application/mail/FakeMailSender.kt | 23 +++++++ .../mail/MailServiceIntegrationTest.kt | 34 ++++++++++ .../apply/config/TestMailConfiguration.kt | 16 +++++ src/test/kotlin/support/MarkdownTest.kt | 64 +++++++++++++++++++ 9 files changed, 180 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/support/Markdown.kt create mode 100644 src/test/kotlin/apply/application/mail/FakeMailSender.kt create mode 100644 src/test/kotlin/apply/application/mail/MailServiceIntegrationTest.kt create mode 100644 src/test/kotlin/apply/config/TestMailConfiguration.kt create mode 100644 src/test/kotlin/support/MarkdownTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 40f0ffd43..5def3e937 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation("com.amazonaws:aws-java-sdk-ses:1.11.880") implementation("com.amazonaws:aws-java-sdk-sqs:1.12.+") implementation("io.github.microutils:kotlin-logging-jvm:3.0.0") + implementation("org.jetbrains:markdown:0.5.0") compileOnly("io.jsonwebtoken:jjwt-api:0.11.2") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2") diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index f3d9e8fc2..9819ec7cc 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -16,6 +16,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.event.TransactionalEventListener import org.thymeleaf.context.Context import org.thymeleaf.spring5.ISpringTemplateEngine +import support.markdownToEmbeddedHtml private const val MAIL_SENDING_UNIT: Int = 50 @@ -89,15 +90,7 @@ class MailService( @Async fun sendMailsByBcc(request: MailData, files: Map) { - val context = Context().apply { - setVariables( - mapOf( - "content" to request.body, - "url" to applicationProperties.url - ) - ) - } - val body = templateEngine.process("mail/common", context) + val body = generateMailBody(request) val recipients = request.recipients + mailProperties.username // TODO: 성공과 실패를 분리하여 히스토리 관리 @@ -119,4 +112,16 @@ class MailService( ) ) } + + fun generateMailBody(mailData: MailData): String { + val context = Context().apply { + setVariables( + mapOf( + "content" to markdownToEmbeddedHtml(mailData.body), + "url" to applicationProperties.url + ) + ) + } + return templateEngine.process("mail/common", context) + } } diff --git a/src/main/kotlin/support/Markdown.kt b/src/main/kotlin/support/Markdown.kt new file mode 100644 index 000000000..46a054de1 --- /dev/null +++ b/src/main/kotlin/support/Markdown.kt @@ -0,0 +1,26 @@ +package support + +import org.intellij.markdown.IElementType +import org.intellij.markdown.ast.ASTNode +import org.intellij.markdown.flavours.MarkdownFlavourDescriptor +import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor +import org.intellij.markdown.html.HtmlGenerator +import org.intellij.markdown.parser.MarkdownParser + +private val flavour: MarkdownFlavourDescriptor = CommonMarkFlavourDescriptor() +private val parser: MarkdownParser = MarkdownParser(flavour) + +fun markdownToHtml(markdownText: String): String { + val parsedTree = parser.buildMarkdownTreeFromString(markdownText) + return generateHtml(markdownText, parsedTree) +} + +// https://github.com/JetBrains/markdown/issues/72 +fun markdownToEmbeddedHtml(markdownText: String): String { + val parsedTree = parser.parse(IElementType("ROOT"), markdownText) + return generateHtml(markdownText, parsedTree) +} + +private fun generateHtml(markdownText: String, parsedTree: ASTNode): String { + return HtmlGenerator(markdownText, parsedTree, flavour).generateHtml() +} diff --git a/src/main/resources/application-test.properties b/src/main/resources/application-test.properties index 61d01e840..82e1d43d1 100644 --- a/src/main/resources/application-test.properties +++ b/src/main/resources/application-test.properties @@ -7,6 +7,7 @@ spring.jpa.show-sql=true spring.flyway.enabled=false +logging.level.apply.application.mail=DEBUG logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE logging.level.org.springframework.web.client.RestTemplate=DEBUG diff --git a/src/main/resources/templates/mail/common.html b/src/main/resources/templates/mail/common.html index 759ac3eef..8f17e078f 100644 --- a/src/main/resources/templates/mail/common.html +++ b/src/main/resources/templates/mail/common.html @@ -63,7 +63,7 @@ padding-right: 8px; " > - + [(${content})] diff --git a/src/test/kotlin/apply/application/mail/FakeMailSender.kt b/src/test/kotlin/apply/application/mail/FakeMailSender.kt new file mode 100644 index 000000000..d9c22462d --- /dev/null +++ b/src/test/kotlin/apply/application/mail/FakeMailSender.kt @@ -0,0 +1,23 @@ +package apply.application.mail + +import mu.KotlinLogging +import org.springframework.core.io.ByteArrayResource + +private val logger = KotlinLogging.logger {} + +class FakeMailSender : MailSender { + override fun send(toAddress: String, subject: String, body: String) { + logger.debug { "toAddress: $toAddress, subject: $subject" } + logger.debug { "body: $body" } + } + + override fun sendBcc( + toAddresses: List, + subject: String, + body: String, + attachments: Map + ) { + logger.debug { "toAddresses: $toAddresses, subject: $subject" } + logger.debug { "body: $body" } + } +} diff --git a/src/test/kotlin/apply/application/mail/MailServiceIntegrationTest.kt b/src/test/kotlin/apply/application/mail/MailServiceIntegrationTest.kt new file mode 100644 index 000000000..c17230b80 --- /dev/null +++ b/src/test/kotlin/apply/application/mail/MailServiceIntegrationTest.kt @@ -0,0 +1,34 @@ +package apply.application.mail + +import apply.config.TestMailConfiguration +import apply.createMailData +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.string.shouldContain +import org.springframework.context.annotation.Import +import support.test.IntegrationTest + +@Import(TestMailConfiguration::class) +@IntegrationTest +class MailServiceIntegrationTest( + private val mailService: MailService +) : BehaviorSpec({ + Given("마크다운으로 본문을 작성한 이메일이 있는 경우") { + val body = """ + |안녕하세요. 우아한테크코스입니다. + |오늘은 미션 안내와 더불어 2주 차부터 시작되는 커뮤니티에 대한 가이드도 함께 공유드리니 **반드시 이메일 내용을 꼼꼼하게 정독**해 주세요. + """.trimMargin() + val mailData = createMailData(body = body) + + When("이메일 본문을 생성하면") { + val actual = mailService.generateMailBody(mailData) + + Then("본문이 HTML로 변환된 이메일이 생성된다") { + actual shouldContain "email" + actual shouldContain """ + |

안녕하세요. 우아한테크코스입니다.
+ |오늘은 미션 안내와 더불어 2주 차부터 시작되는 커뮤니티에 대한 가이드도 함께 공유드리니 반드시 이메일 내용을 꼼꼼하게 정독해 주세요.

+ """.trimMargin() + } + } + } +}) diff --git a/src/test/kotlin/apply/config/TestMailConfiguration.kt b/src/test/kotlin/apply/config/TestMailConfiguration.kt new file mode 100644 index 000000000..10fd2bce1 --- /dev/null +++ b/src/test/kotlin/apply/config/TestMailConfiguration.kt @@ -0,0 +1,16 @@ +package apply.config + +import apply.application.mail.FakeMailSender +import apply.application.mail.MailSender +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary + +@TestConfiguration +class TestMailConfiguration { + @Primary + @Bean + fun fakeMailSender(): MailSender { + return FakeMailSender() + } +} diff --git a/src/test/kotlin/support/MarkdownTest.kt b/src/test/kotlin/support/MarkdownTest.kt new file mode 100644 index 000000000..63e75e3a8 --- /dev/null +++ b/src/test/kotlin/support/MarkdownTest.kt @@ -0,0 +1,64 @@ +package support + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldNotContain + +class MarkdownTest : StringSpec({ + "마크다운을 HTML로 변환한다" { + val markdownText = """ + |# 마크다운 + | + |## 마크다운(Markdown)이란? + | + |2004년에 존 그루버(John Gruber)와 애런 스워츠(Aaron Swartz)가 만든 마크업 언어의 하나로 읽기 쉽고 쓰기 쉬운 텍스트 포맷입니다. + | + |이 페이지의 왼쪽은 마크다운 편집기입니다. **자유롭게 연습해 보세요**. 여러분이 연습한 내용은 다른 사람에게 보이지 않고, 저장되지 않습니다. + | + |아래 링크를 클릭하여 해당 도움말(연습장)로 바로 이동할 수도 있습니다. + | + |* [문단](#paragraph) + |* [제목](#heading) + |* [인용](#blockquote) + |* [강조](#emphasis) + |* [취소선](#strike) + |* [목록](#list) + |* [할일 목록](#tasklist) + |* [링크](#link) + |* [표](#table) + |* [코드 포맷](#code) + |* [수식](#math) + |* [UML](#uml) + """.trimMargin() + val actual = markdownToHtml(markdownText) + actual shouldBe """ + | + |

마크다운

+ |

마크다운(Markdown)이란?

+ |

2004년에 존 그루버(John Gruber)와 애런 스워츠(Aaron Swartz)가 만든 마크업 언어의 하나로 읽기 쉽고 쓰기 쉬운 텍스트 포맷입니다.

+ |

이 페이지의 왼쪽은 마크다운 편집기입니다. 자유롭게 연습해 보세요. 여러분이 연습한 내용은 다른 사람에게 보이지 않고, 저장되지 않습니다.

+ |

아래 링크를 클릭하여 해당 도움말(연습장)로 바로 이동할 수도 있습니다.

+ | + | + """.trimMargin().replace(System.lineSeparator(), "") + } + + "마크다운을 태그를 제거한 HTML로 변환한다" { + val markdownText = "2004년에 존 그루버(John Gruber)와 애런 스워츠(Aaron Swartz)가 만든 마크업 언어의 하나로 읽기 쉽고 쓰기 쉬운 텍스트 포맷입니다." + val actual = markdownToEmbeddedHtml(markdownText) + actual shouldNotContain "" + } +})