Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package com.maggom.admin.adapter.`in`.web

import com.maggom.admin.adapter.`in`.web.dto.MailTemplateType
import com.maggom.admin.adapter.`in`.web.dto.TestMailRequest
import com.maggom.admin.adapter.`in`.web.dto.TestMailResponse
import com.maggom.auth.port.out.WelcomeMailPort
import com.maggom.event.port.out.NotificationMailPort
import com.maggom.auth.port.out.TestMailPort
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
Expand All @@ -16,17 +14,13 @@ import java.util.UUID
@RestController
@RequestMapping("/api/v1/admin")
class AdminController(
private val welcomeMailPort: WelcomeMailPort,
private val notificationMailPort: NotificationMailPort,
private val testMailPort: TestMailPort,
) {

@PostMapping("/mails/test-send")
@ResponseStatus(HttpStatus.OK)
fun sendTestMail(@RequestBody request: TestMailRequest): TestMailResponse {
when (request.templateType) {
MailTemplateType.WELCOME -> welcomeMailPort.sendWelcomeMail(request.toEmail)
MailTemplateType.NOTIFICATION -> notificationMailPort.sendNotification(request.toEmail, emptyList())
}
testMailPort.sendTestMail(request.toEmail, request.templateType.name)

return TestMailResponse(
message = "테스트 메일 발송 요청이 완료되었습니다.",
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.openfeign.querydsl:querydsl-jpa:7.0")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
Expand Down
2 changes: 1 addition & 1 deletion app/config
158 changes: 70 additions & 88 deletions app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,123 +2,105 @@ package com.maggom.app.adapter

import com.maggom.auth.port.out.AuthCodeEmailMessage
import com.maggom.auth.port.out.EmailSenderPort
import com.maggom.auth.port.out.TestMailPort
import com.maggom.auth.port.out.WelcomeMailPort
import com.maggom.event.port.out.NotificationMailPort
import com.maggom.event.domain.MarathonEvent
import com.maggom.event.port.out.NotificationMailPort
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeMessage
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.MailException
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.MimeMessageHelper
import org.springframework.stereotype.Component
import java.time.format.DateTimeFormatter
import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context
import java.util.Locale

@Component
class MailAdapter(
private val mailSender: JavaMailSender,
private val templateEngine: TemplateEngine,
@Value("\${spring.mail.from-email}") private val fromEmail: String,
@Value("\${spring.mail.from-name}") private val fromName: String,
) : EmailSenderPort, WelcomeMailPort, NotificationMailPort {
@Value("\${maggom.mail.fallback.from-email:#{null}}") private val fallbackFromEmail: String?,
@Autowired(required = false) @Qualifier("gmailMailSender") private val fallbackMailSender: JavaMailSender?,
) : EmailSenderPort, WelcomeMailPort, NotificationMailPort, TestMailPort {

override fun sendAuthCode(message: AuthCodeEmailMessage) {
val mailMessage = SimpleMailMessage().apply {
setFrom(InternetAddress(fromEmail, fromName, "UTF-8").toString())
setTo(message.to)
subject = "[마꼼] 이메일 인증 번호"
text = """
${message.code}
private val log = LoggerFactory.getLogger(javaClass)

위 인증 번호를 입력하여 인증을 완료해 주세요.
""".trimIndent()
override fun sendAuthCode(message: AuthCodeEmailMessage) {
val context = Context(Locale.KOREAN).apply {
setVariable("code", message.code)
setVariable("expiryMinutes", message.expiryMinutes)
}

mailSender.send(mailMessage)
sendHtml(
to = message.to,
subject = "[마꼼] 이메일 인증 번호",
template = "mail/auth-code",
context = context,
)
}

override fun sendWelcomeMail(to: String) {
val message: MimeMessage = mailSender.createMimeMessage()
val helper = MimeMessageHelper(message, true, "UTF-8")

helper.setFrom(InternetAddress(fromEmail, fromName, "UTF-8"))
helper.setTo(to)
helper.setSubject("[마꼼] 구독을 시작했어요! 이런 정보를 보내드려요 🏃")
helper.setText(buildWelcomeHtmlBody(), true)

mailSender.send(message)
sendHtml(
to = to,
subject = "[마꼼] 구독을 시작했어요! 🏃",
template = "mail/welcome",
context = Context(Locale.KOREAN),
)
}

override fun sendNotification(to: String, events: List<MarathonEvent>) {
val message: MimeMessage = mailSender.createMimeMessage()
val helper = MimeMessageHelper(message, true, "UTF-8")

helper.setFrom(InternetAddress(fromEmail, fromName, "UTF-8"))
helper.setTo(to)
helper.setSubject("[마꼼] 이번 주 마라톤 대회 알림")
helper.setText(buildNotificationHtmlBody(events), true)

mailSender.send(message)
val context = Context(Locale.KOREAN).apply {
setVariable("events", events)
}
sendHtml(
to = to,
subject = "[마꼼] 이번 주 마라톤 대회 알림",
template = "mail/notification",
context = context,
)
}

private fun buildWelcomeHtmlBody(): String {
val sampleEventRows = """
<div style="border:1px solid #e0e0e0; border-radius:8px; padding:16px; margin-bottom:16px;">
<h3 style="margin:0 0 8px 0; color:#333;">2025 서울 봄 마라톤</h3>
<p style="margin:4px 0; color:#666;">📅 대회일: 2025.04.20</p>
<p style="margin:4px 0; color:#666;">📍 지역: 서울</p>
<p style="margin:4px 0; color:#666;">🏅 코스: 10K, 하프, 풀</p>
<p style="margin:4px 0; color:#666;">📋 접수: 2025.03.01 09:00 ~ 2025.04.01 23:59</p>
<span style="display:inline-block; margin-top:8px; padding:8px 16px; background:#ccc; color:white; border-radius:4px;">신청하기</span>
</div>
<div style="border:1px solid #e0e0e0; border-radius:8px; padding:16px; margin-bottom:16px;">
<h3 style="margin:0 0 8px 0; color:#333;">2025 한강 하프마라톤</h3>
<p style="margin:4px 0; color:#666;">📅 대회일: 2025.05.11</p>
<p style="margin:4px 0; color:#666;">📍 지역: 경기</p>
<p style="margin:4px 0; color:#666;">🏅 코스: 하프</p>
<p style="margin:4px 0; color:#666;">📋 접수: 2025.03.15 10:00 ~ 2025.04.20 18:00</p>
<span style="display:inline-block; margin-top:8px; padding:8px 16px; background:#ccc; color:white; border-radius:4px;">신청하기</span>
</div>
""".trimIndent()
override fun sendTestMail(to: String, templateType: String) {
val context = Context(Locale.KOREAN).apply {
setVariable("templateType", templateType)
}
sendHtml(
to = to,
subject = "[마꼼] 테스트 메일 발송",
template = "mail/test",
context = context,
)
}

return """
<div style="font-family: sans-serif; max-width:600px; margin:0 auto; padding:20px;">
<h2 style="color:#333; border-bottom:2px solid #4CAF50; padding-bottom:8px;">🎉 마꼼 구독을 시작했어요!</h2>
<p style="color:#666;">설정하신 조건에 맞는 마라톤 대회 정보를 정기적으로 이메일로 보내드려요.</p>
<p style="color:#666; margin-bottom:20px;">아래는 알림이 오면 이런 형식으로 받아보실 수 있어요 (샘플입니다).</p>
<h3 style="color:#333; border-bottom:1px solid #e0e0e0; padding-bottom:8px;">🏃 마라톤 대회 알림</h3>
$sampleEventRows
<hr style="border:none; border-top:1px solid #e0e0e0; margin:20px 0;">
<p style="color:#999; font-size:12px;">수신 설정 변경 또는 구독 해지는 마꼼 서비스에서 가능합니다.</p>
</div>
""".trimIndent()
private fun sendHtml(to: String, subject: String, template: String, context: Context) {
val html = templateEngine.process(template, context)
try {
doSend(mailSender, fromEmail, to, subject, html)
} catch (e: MailException) {
if (fallbackMailSender != null && fallbackFromEmail != null) {
log.warn("주 발송 실패, Gmail 폴백 시도 [to={}, subject={}]: {}", to, subject, e.message)
doSend(fallbackMailSender, fallbackFromEmail, to, subject, html)
} else {
throw e
}
}
}

private fun buildNotificationHtmlBody(events: List<MarathonEvent>): String {
val dateFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd")
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm")
private fun doSend(sender: JavaMailSender, from: String, to: String, subject: String, html: String) {
val message: MimeMessage = sender.createMimeMessage()
val helper = MimeMessageHelper(message, true, "UTF-8")

val eventRows = events.joinToString("") { event ->
val regEnd = event.regEndDate?.format(dateTimeFormatter) ?: "미정"
"""
<div style="border:1px solid #e0e0e0; border-radius:8px; padding:16px; margin-bottom:16px;">
<h3 style="margin:0 0 8px 0; color:#333;">${event.title}</h3>
<p style="margin:4px 0; color:#666;">📅 대회일: ${event.eventDate.format(dateFormatter)}</p>
<p style="margin:4px 0; color:#666;">📍 지역: ${event.region}</p>
<p style="margin:4px 0; color:#666;">🏅 코스: ${event.distances.joinToString(", ")}</p>
<p style="margin:4px 0; color:#666;">📋 접수: ${event.regStartDate.format(dateTimeFormatter)} ~ $regEnd</p>
<a href="${event.linkUrl}" style="display:inline-block; margin-top:8px; padding:8px 16px; background:#4CAF50; color:white; text-decoration:none; border-radius:4px;">신청하기</a>
</div>
""".trimIndent()
}
helper.setFrom(InternetAddress(from, fromName, "UTF-8"))
helper.setTo(to)
helper.setSubject(subject)
helper.setText(html, true)

return """
<div style="font-family: sans-serif; max-width:600px; margin:0 auto; padding:20px;">
<h2 style="color:#333; border-bottom:2px solid #4CAF50; padding-bottom:8px;">🏃 마라톤 대회 알림</h2>
<p style="color:#666;">안녕하세요! 구독하신 조건에 맞는 대회 정보를 알려드립니다.</p>
$eventRows
<hr style="border:none; border-top:1px solid #e0e0e0; margin:20px 0;">
<p style="color:#999; font-size:12px;">수신 설정 변경 또는 구독 해지는 마꼼 서비스에서 가능합니다.</p>
</div>
""".trimIndent()
sender.send(message)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.maggom.app.config

import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.mail.javamail.JavaMailSenderImpl

@Configuration
@Profile("prod")
class GmailFallbackMailConfig(
@Value("\${maggom.mail.fallback.username}") private val username: String,
@Value("\${maggom.mail.fallback.password}") private val password: String,
) {

@Bean("gmailMailSender")
fun gmailMailSender(): JavaMailSender {
return JavaMailSenderImpl().apply {
host = "smtp.gmail.com"
port = 587
this.username = username
this.password = password
javaMailProperties.apply {
put("mail.smtp.auth", "true")
put("mail.smtp.starttls.enable", "true")
put("mail.smtp.starttls.required", "true")
put("mail.smtp.connectiontimeout", "5000")
put("mail.smtp.timeout", "5000")
put("mail.smtp.writetimeout", "5000")
}
}
}
}
54 changes: 54 additions & 0 deletions app/src/main/resources/templates/mail/auth-code.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이메일 인증 번호</title>
</head>
<body style="margin:0; padding:0; background:#f5f5f5; font-family:'Apple SD Gothic Neo', 'Malgun Gothic', sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f5; padding:40px 0;">
<tr>
<td align="center">
<table width="500" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 2px 8px rgba(0,0,0,0.08);">

<!-- 헤더 -->
<tr>
<td style="background:#6366f1; padding:0;">
<a href="https://maggom.com" target="_blank" rel="noopener noreferrer" style="display:block; padding:20px 40px; text-decoration:none; cursor:pointer;">
<p style="margin:0; color:#ffffff; font-size:20px; font-weight:700; letter-spacing:-0.5px;">마꼼</p>
</a>
</td>
</tr>

<!-- 본문 -->
<tr>
<td style="padding:30px;">
<p style="margin:0 0 8px; color:#333; font-size:20px; font-weight:700;">이메일 인증 번호</p>
<p style="margin:0 0 12px; color:#666; font-size:14px; line-height:1.6;">아래 인증 번호를 입력하여 인증을 완료해 주세요.</p>

<!-- 인증 코드 박스 -->
<div style="background:#eef2ff; border:2px #6366f1; border-radius:8px; padding:24px; text-align:center; margin-bottom:24px;">
<p style="margin:0; color:#6366f1; font-size:40px; font-weight:800; letter-spacing:12px;" th:text="${code}">123456</p>
</div>

<p style="margin:0; color:#999; font-size:13px; line-height:1.6;">
인증 번호는 <strong th:text="${expiryMinutes}">3</strong>분 후 만료됩니다.<br>
</p>
</td>
</tr>

<!-- 푸터 -->
<tr>
<td style="background:#f9f9f9; padding:20px 40px; border-top:1px solid #eee;">
<p style="margin:0; color:#aaa; font-size:12px; line-height:1.6;">
본 메일은 발신 전용입니다. 문의는 <a href="https://maggom.com" target="_blank" rel="noopener noreferrer" style="color:#aaa; text-decoration:underline;">마꼼</a>을 이용해 주세요.
</p>
</td>
</tr>

</table>
</td>
</tr>
</table>
</body>
</html>
Loading
Loading