diff --git a/admin/src/main/kotlin/com/maggom/admin/adapter/in/web/AdminController.kt b/admin/src/main/kotlin/com/maggom/admin/adapter/in/web/AdminController.kt index 5ef6dd0..9ecbe4c 100644 --- a/admin/src/main/kotlin/com/maggom/admin/adapter/in/web/AdminController.kt +++ b/admin/src/main/kotlin/com/maggom/admin/adapter/in/web/AdminController.kt @@ -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 @@ -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 = "테스트 메일 발송 요청이 완료되었습니다.", diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e0a7d8..c45436d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/config b/app/config index b9ce5fb..b932dce 160000 --- a/app/config +++ b/app/config @@ -1 +1 @@ -Subproject commit b9ce5fbca9fb0f5c6b212b8464058a882f1ea64a +Subproject commit b932dce24aa802d5872b80a147d6c161f961698e diff --git a/app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt b/app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt index 09022fd..28d36a7 100644 --- a/app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt +++ b/app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt @@ -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) { - 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 = """ -
-

2025 서울 봄 마라톤

-

📅 대회일: 2025.04.20

-

📍 지역: 서울

-

🏅 코스: 10K, 하프, 풀

-

📋 접수: 2025.03.01 09:00 ~ 2025.04.01 23:59

- 신청하기 -
-
-

2025 한강 하프마라톤

-

📅 대회일: 2025.05.11

-

📍 지역: 경기

-

🏅 코스: 하프

-

📋 접수: 2025.03.15 10:00 ~ 2025.04.20 18:00

- 신청하기 -
- """.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 """ -
-

🎉 마꼼 구독을 시작했어요!

-

설정하신 조건에 맞는 마라톤 대회 정보를 정기적으로 이메일로 보내드려요.

-

아래는 알림이 오면 이런 형식으로 받아보실 수 있어요 (샘플입니다).

-

🏃 마라톤 대회 알림

- $sampleEventRows -
-

수신 설정 변경 또는 구독 해지는 마꼼 서비스에서 가능합니다.

-
- """.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): 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) ?: "미정" - """ -
-

${event.title}

-

📅 대회일: ${event.eventDate.format(dateFormatter)}

-

📍 지역: ${event.region}

-

🏅 코스: ${event.distances.joinToString(", ")}

-

📋 접수: ${event.regStartDate.format(dateTimeFormatter)} ~ $regEnd

- 신청하기 -
- """.trimIndent() - } + helper.setFrom(InternetAddress(from, fromName, "UTF-8")) + helper.setTo(to) + helper.setSubject(subject) + helper.setText(html, true) - return """ -
-

🏃 마라톤 대회 알림

-

안녕하세요! 구독하신 조건에 맞는 대회 정보를 알려드립니다.

- $eventRows -
-

수신 설정 변경 또는 구독 해지는 마꼼 서비스에서 가능합니다.

-
- """.trimIndent() + sender.send(message) } } diff --git a/app/src/main/kotlin/com/maggom/app/config/GmailFallbackMailConfig.kt b/app/src/main/kotlin/com/maggom/app/config/GmailFallbackMailConfig.kt new file mode 100644 index 0000000..e604573 --- /dev/null +++ b/app/src/main/kotlin/com/maggom/app/config/GmailFallbackMailConfig.kt @@ -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") + } + } + } +} diff --git a/app/src/main/resources/templates/mail/auth-code.html b/app/src/main/resources/templates/mail/auth-code.html new file mode 100644 index 0000000..90f465d --- /dev/null +++ b/app/src/main/resources/templates/mail/auth-code.html @@ -0,0 +1,54 @@ + + + + + + 이메일 인증 번호 + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +

마꼼

+
+
+

이메일 인증 번호

+

아래 인증 번호를 입력하여 인증을 완료해 주세요.

+ + +
+

123456

+
+ +

+ 인증 번호는 3분 후 만료됩니다.
+

+
+

+ 본 메일은 발신 전용입니다. 문의는 마꼼을 이용해 주세요. +

+
+
+ + diff --git a/app/src/main/resources/templates/mail/notification.html b/app/src/main/resources/templates/mail/notification.html new file mode 100644 index 0000000..98340b1 --- /dev/null +++ b/app/src/main/resources/templates/mail/notification.html @@ -0,0 +1,74 @@ + + + + + + 마라톤 대회 알림 + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +

마꼼

+
+
+

🏃 마라톤 대회 목록

+

구독하신 조건에 맞는 대회 정보를 알려드립니다.

+ + +
+
+ + + + + +
대회명 + 대회 사이트 +
+

+ 대회일: 2026.04.20 + | + 지역: 수도권 + | + 코스: 10K, 하프 +

+

+ 신청기간: + 2026.03.01 + ~ + 2026.04.01 +

+
+
+ +
+

+ 수신 설정 변경 또는 구독 해지는 마꼼에서 가능합니다.
+ 본 메일은 발신 전용입니다. +

+
+
+ + diff --git a/app/src/main/resources/templates/mail/test.html b/app/src/main/resources/templates/mail/test.html new file mode 100644 index 0000000..e437513 --- /dev/null +++ b/app/src/main/resources/templates/mail/test.html @@ -0,0 +1,57 @@ + + + + + + 테스트 메일 + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +

마꼼

+

관리자 테스트 발송 메일

+
+
+

테스트 메일 발송 확인

+

+ 관리자 패널에서 요청한 테스트 메일입니다. +

+ +
+

템플릿 타입

+

NOTIFICATION

+
+ +

+ 이메일 발송 설정(SES, SMTP)이 정상적으로 동작하고 있습니다. +

+
+

+ 본 메일은 마꼼 관리자 패널에서 발송된 테스트 메일입니다. +

+
+
+ + diff --git a/app/src/main/resources/templates/mail/welcome.html b/app/src/main/resources/templates/mail/welcome.html new file mode 100644 index 0000000..cd6193b --- /dev/null +++ b/app/src/main/resources/templates/mail/welcome.html @@ -0,0 +1,80 @@ + + + + + + 마꼼 구독 시작 + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +

마꼼

+
+
+

🎉 구독을 시작했어요!

+

+ 설정하신 조건에 맞는 마라톤 대회 정보를 정기적으로 보내드릴게요.
+ 이런 형식으로 알림을 받아보실 수 있어요. +

+ + +
+ + + + + +
2026 서울 봄 마라톤 + 대회 사이트 +
+

대회일: 2026.04.19 | 지역: 서울시 어쩌구 | 코스: 10K, 하프, 풀

+

신청기간: 2026.03.01 ~ 2026.04.01

+
+ +
+ + + + + +
2026 한강 하프마라톤 + 대회 사이트 +
+

대회일: 2026.05.10 | 지역: 서울시 어쩌구 | 코스: 하프

+

신청기간: 2026.03.15 ~ 2026.04.20

+
+ +

+ 수신 조건(요일, 시간, 지역, 거리)은 마꼼 서비스에서 언제든지 변경하실 수 있어요. +

+
+

+ 수신 설정 변경 또는 구독 해지는 마꼼에서 가능합니다.
+ 본 메일은 발신 전용입니다. +

+
+
+ + diff --git a/auth/src/main/kotlin/com/maggom/auth/port/out/TestMailPort.kt b/auth/src/main/kotlin/com/maggom/auth/port/out/TestMailPort.kt new file mode 100644 index 0000000..24feae7 --- /dev/null +++ b/auth/src/main/kotlin/com/maggom/auth/port/out/TestMailPort.kt @@ -0,0 +1,5 @@ +package com.maggom.auth.port.out + +interface TestMailPort { + fun sendTestMail(to: String, templateType: String) +} diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 70ebb07..084cddd 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -19,14 +19,19 @@ services: container_name: maggom-app environment: SPRING_PROFILES_ACTIVE: prod + TZ: Asia/Seoul DB_URL: jdbc:postgresql://postgres:5432/${DB_NAME} DB_USERNAME: ${DB_USERNAME} DB_PASSWORD: ${DB_PASSWORD} JWT_SECRET: ${JWT_SECRET} SES_SMTP_USERNAME: ${SES_SMTP_USERNAME} SES_SMTP_PASSWORD: ${SES_SMTP_PASSWORD} + GMAIL_USERNAME: ${GMAIL_USERNAME} + GMAIL_PASSWORD: ${GMAIL_PASSWORD} ports: - "8080:8080" + volumes: + - ../logs/spring:/logs depends_on: - postgres restart: always