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
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ dependencies {
implementation(project(":admin"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-flyway")
implementation("org.flywaydb:flyway-core")
runtimeOnly("org.flywaydb:flyway-database-postgresql")
implementation("org.springframework.boot:spring-boot-starter-mail")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
Expand Down
2 changes: 1 addition & 1 deletion app/config
98 changes: 73 additions & 25 deletions app/src/main/kotlin/com/maggom/app/adapter/MailAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.maggom.auth.port.out.WelcomeMailPort
import com.maggom.event.domain.MarathonEvent
import com.maggom.event.port.out.MarathonEventPort
import com.maggom.event.port.out.NotificationMailPort
import com.maggom.member.port.out.UnsubscribeTokenPort
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeMessage
import org.slf4j.LoggerFactory
Expand All @@ -28,53 +29,83 @@ class MailAdapter(
private val templateEngine: TemplateEngine,
@Value("\${spring.mail.from-email}") private val fromEmail: String,
@Value("\${spring.mail.from-name}") private val fromName: String,
@Value("\${maggom.mail.base-url}") private val baseUrl: String,
@Autowired(required = false) private val fallbackMailProps: FallbackMailProperties?,
@Autowired(required = false) @Qualifier("gmailMailSender") private val fallbackMailSender: JavaMailSender?,
private val marathonEventPort: MarathonEventPort,
private val unsubscribeTokenPort: UnsubscribeTokenPort,
) : EmailSenderPort, WelcomeMailPort, NotificationMailPort, TestMailPort {

companion object {
private val DEFAULT_REGIONS = listOf("수도권")
private const val WELCOME_EVENTS_COUNT = 2
private const val UNSUBSCRIBE_PATH = "/api/v1/subscriptions/unsubscribe"
}

private val log = LoggerFactory.getLogger(javaClass)

private data class MailSpec(
val to: String,
val subject: String,
val template: String,
val context: Context,
val unsubscribeUrl: String? = null,
)

private data class MailPayload(
val to: String,
val subject: String,
val html: String,
val unsubscribeUrl: String?,
)

override fun sendAuthCode(message: AuthCodeEmailMessage) {
val context = Context(Locale.KOREAN).apply {
setVariable("code", message.code)
setVariable("expiryMinutes", message.expiryMinutes)
}
sendHtml(
to = message.to,
subject = "[마꼼] 이메일 인증 번호",
template = "mail/auth-code",
context = context,
MailSpec(
to = message.to,
subject = "[마꼼] 이메일 인증 번호",
template = "mail/auth-code",
context = context,
)
)
}

override fun sendWelcomeMail(to: String) {
val events = marathonEventPort.findOpenByRegions(DEFAULT_REGIONS).take(WELCOME_EVENTS_COUNT)
val unsubscribeUrl = unsubscribeUrl(to)
val context = Context(Locale.KOREAN).apply {
setVariable("events", events)
setVariable("unsubscribeUrl", unsubscribeUrl)
}
sendHtml(
to = to,
subject = "[마꼼] 구독을 시작했어요! 🎉",
template = "mail/welcome",
context = context,
MailSpec(
to = to,
subject = "[마꼼] 구독을 시작했어요! 🎉",
template = "mail/welcome",
context = context,
unsubscribeUrl = unsubscribeUrl,
)
)
}

override fun sendNotification(to: String, events: List<MarathonEvent>) {
val unsubscribeUrl = unsubscribeUrl(to)
val context = Context(Locale.KOREAN).apply {
setVariable("events", events)
setVariable("unsubscribeUrl", unsubscribeUrl)
}
sendHtml(
to = to,
subject = "[마꼼] 마라톤 대회 목록 🏃",
template = "mail/notification",
context = context,
MailSpec(
to = to,
subject = "[마꼼] 마라톤 대회 목록 🏃",
template = "mail/notification",
context = context,
unsubscribeUrl = unsubscribeUrl,
)
)
}

Expand All @@ -83,35 +114,52 @@ class MailAdapter(
setVariable("templateType", templateType)
}
sendHtml(
to = to,
subject = "[마꼼] 테스트 메일 발송",
template = "mail/test",
context = context,
MailSpec(
to = to,
subject = "[마꼼] 테스트 메일 발송",
template = "mail/test",
context = context,
)
)
}

private fun sendHtml(to: String, subject: String, template: String, context: Context) {
val html = templateEngine.process(template, context)
private fun unsubscribeUrl(to: String): String {
return "$baseUrl$UNSUBSCRIBE_PATH?token=${unsubscribeTokenPort.generate(to)}"
}

private fun sendHtml(spec: MailSpec) {
val payload = MailPayload(
to = spec.to,
subject = spec.subject,
html = templateEngine.process(spec.template, spec.context),
unsubscribeUrl = spec.unsubscribeUrl,
)
try {
doSend(mailSender, fromEmail, to, subject, html)
doSend(mailSender, fromEmail, payload)
} catch (e: MailException) {
if (fallbackMailSender != null && fallbackMailProps != null) {
log.warn("주 발송 실패, Gmail 폴백 시도 [to={}, subject={}]: {}", to, subject, e.message)
doSend(fallbackMailSender, fallbackMailProps.fromEmail, to, subject, html)
log.warn("주 발송 실패, Gmail 폴백 시도 [to={}, subject={}]: {}", spec.to, spec.subject, e.message)
doSend(fallbackMailSender, fallbackMailProps.fromEmail, payload)
} else {
throw e
}
}
}

private fun doSend(sender: JavaMailSender, from: String, to: String, subject: String, html: String) {
private fun doSend(sender: JavaMailSender, from: String, payload: MailPayload) {
val message: MimeMessage = sender.createMimeMessage()
val helper = MimeMessageHelper(message, true, "UTF-8")

helper.setFrom(InternetAddress(from, fromName, "UTF-8"))
helper.setTo(to)
helper.setSubject(subject)
helper.setText(html, true)
helper.setTo(payload.to)
helper.setSubject(payload.subject)
helper.setText(payload.html, true)

// RFC 8058 원클릭 수신 거부 - 메일 클라이언트가 구독 취소 버튼을 노출한다.
payload.unsubscribeUrl?.let {
message.addHeader("List-Unsubscribe", "<$it>")
message.addHeader("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
}

sender.send(message)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.maggom.app.adapter

import com.maggom.member.port.out.UnsubscribeTokenPort
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.security.MessageDigest
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

/**
* 메일 수신 거부용 서명 토큰 어댑터.
*
* 형식: `base64url(email).base64url(HMAC-SHA256(base64url(email)))`
* 메일은 발송 후 한참 뒤에 열릴 수 있으므로 만료를 두지 않는다.
*/
@Component
class UnsubscribeTokenAdapter(
@Value("\${maggom.auth.unsubscribe-secret:\${maggom.auth.jwt-secret}}") private val secret: String,
) : UnsubscribeTokenPort {

/** JWT 서명키와 같은 시크릿을 쓰더라도 키 재질이 겹치지 않도록 용도별로 파생한다. */
private val key by lazy {
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), HMAC_ALGORITHM))

SecretKeySpec(mac.doFinal(KEY_INFO.toByteArray(Charsets.UTF_8)), HMAC_ALGORITHM)
}

override fun generate(email: String): String {
val payload = encode(email.toByteArray(Charsets.UTF_8))

return "$payload.${sign(payload)}"
}

override fun extractEmail(token: String): String? {
val parts = token.split(".")
if (parts.size != 2) return null

val payload = parts[0]
val signature = parts[1]
if (!isValidSignature(payload, signature)) return null

return try {
String(Base64.getUrlDecoder().decode(payload), Charsets.UTF_8)
} catch (e: IllegalArgumentException) {
null
}
}

private fun isValidSignature(payload: String, signature: String): Boolean {
return MessageDigest.isEqual(
sign(payload).toByteArray(Charsets.UTF_8),
signature.toByteArray(Charsets.UTF_8),
)
}

private fun sign(payload: String): String {
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(key)

return encode(mac.doFinal(payload.toByteArray(Charsets.UTF_8)))
}

private fun encode(bytes: ByteArray): String {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}

companion object {
private const val HMAC_ALGORITHM = "HmacSHA256"
private const val KEY_INFO = "maggom:unsubscribe:v1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.maggom.app.adapter.`in`.web

import com.maggom.member.port.`in`.UnsubscribeUseCase
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.thymeleaf.TemplateEngine
import org.thymeleaf.context.Context
import java.util.Locale

@RestController
@RequestMapping("/api/v1/subscriptions/unsubscribe")
class UnsubscribeController(
private val unsubscribeUseCase: UnsubscribeUseCase,
private val templateEngine: TemplateEngine,
) {

/** 메일 본문의 해지 링크 (사람이 클릭) - 결과 안내 페이지를 그대로 응답한다. */
@GetMapping(produces = ["text/html;charset=UTF-8"])
@ResponseStatus(HttpStatus.OK)
fun unsubscribeByLink(@RequestParam token: String): String {
val success = unsubscribeUseCase.unsubscribe(token)
val context = Context(Locale.KOREAN).apply {
setVariable("success", success)
}

return templateEngine.process("unsubscribe/result", context)
}

/** List-Unsubscribe-Post 원클릭 해지 (RFC 8058) - 메일 클라이언트가 호출한다. */
@PostMapping
@ResponseStatus(HttpStatus.OK)
fun unsubscribeOneClick(@RequestParam token: String) {
unsubscribeUseCase.unsubscribe(token)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ class JwtAuthenticationFilter(
private val PUBLIC_PATHS = listOf(
"/api/v1/auth/",
"/api/v1/subscriptions/count",
"/api/v1/subscriptions/unsubscribe",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class NotificationScheduler(
if (!shouldSendToday(pref.receiveDays, todayCode)) continue
if (!isReceiveHour(pref.receiveTime, now)) continue

val events = findMatchingEvents(pref.prefRegions, pref.prefDistances)
val events = findMatchingEvents(pref.prefRegions, pref.prefDistances, pref.includeSmall)
if (events.isEmpty()) continue

notificationMailPort.sendNotification(member.email, events)
Expand All @@ -51,8 +51,12 @@ class NotificationScheduler(
log.info("알림 발송 완료 - 발송 수: $sentCount")
}

private fun findMatchingEvents(prefRegions: List<String>, prefDistances: List<String>): List<MarathonEvent> {
return marathonEventPort.findOpenByRegions(prefRegions)
private fun findMatchingEvents(
prefRegions: List<String>,
prefDistances: List<String>,
includeSmall: Boolean,
): List<MarathonEvent> {
return marathonEventPort.findOpenByRegions(prefRegions, includeSmall)
.filter { event -> event.distances.any { it in prefDistances } }
.take(MAX_EVENTS_PER_MAIL)
}
Expand All @@ -79,6 +83,6 @@ class NotificationScheduler(

companion object {
private const val SEND_INTERVAL_MS = 200L
private const val MAX_EVENTS_PER_MAIL = 10
private const val MAX_EVENTS_PER_MAIL = 15
}
}
Loading
Loading