diff --git a/src/main/java/chaeso/zip/server/ChaesoZipApplication.java b/src/main/java/chaeso/zip/server/ChaesoZipApplication.java index 1fd0ebbf..78fc2bed 100644 --- a/src/main/java/chaeso/zip/server/ChaesoZipApplication.java +++ b/src/main/java/chaeso/zip/server/ChaesoZipApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication @ConfigurationPropertiesScan +@EnableAsync public class ChaesoZipApplication { public static void main(String[] args) { diff --git a/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java b/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java index 09c3f565..99e3a910 100644 --- a/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java +++ b/src/main/java/chaeso/zip/server/auth/application/AuthServiceImpl.java @@ -36,6 +36,7 @@ import java.util.Locale; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; @@ -97,7 +98,13 @@ public String sendSignupVerificationCode(String email) { String code = generateCode(); verificationCodeStore.saveCode(normalized, code); try { - verificationMailSender.sendVerificationCode(normalized, code); + CompletableFuture sendFuture = + verificationMailSender.sendVerificationCode(normalized, code); + sendFuture.whenComplete((ignored, exception) -> { + if (exception != null) { + verificationCodeStore.releaseSendSlot(normalized); + } + }); } catch (RuntimeException exception) { verificationCodeStore.releaseSendSlot(normalized); throw exception; diff --git a/src/main/java/chaeso/zip/server/auth/infrastructure/mail/MailTemplateException.java b/src/main/java/chaeso/zip/server/auth/infrastructure/mail/MailTemplateException.java new file mode 100644 index 00000000..bf91616e --- /dev/null +++ b/src/main/java/chaeso/zip/server/auth/infrastructure/mail/MailTemplateException.java @@ -0,0 +1,9 @@ +package chaeso.zip.server.auth.infrastructure.mail; + +/** 메일 템플릿을 읽거나 구성하지 못했을 때 발생하는 인프라 예외. */ +public class MailTemplateException extends RuntimeException { + + public MailTemplateException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/chaeso/zip/server/auth/infrastructure/mail/VerificationMailSender.java b/src/main/java/chaeso/zip/server/auth/infrastructure/mail/VerificationMailSender.java index 0161bd60..a84dbc52 100644 --- a/src/main/java/chaeso/zip/server/auth/infrastructure/mail/VerificationMailSender.java +++ b/src/main/java/chaeso/zip/server/auth/infrastructure/mail/VerificationMailSender.java @@ -1,9 +1,16 @@ package chaeso.zip.server.auth.infrastructure.mail; import chaeso.zip.server.auth.infrastructure.verification.EmailVerificationProperties; +import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; -import org.springframework.mail.SimpleMailMessage; +import org.springframework.core.io.ClassPathResource; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; /** 회원가입 이메일 인증코드 메일을 구성하고 발송한다. */ @@ -11,17 +18,36 @@ @RequiredArgsConstructor public class VerificationMailSender { - private static final String SUBJECT = "[채소.zip] 이메일 인증 코드"; + private static final String SUBJECT = "[채소.zip] 회원가입을 위한 이메일 인증"; + private static final String TEMPLATE = "templates/email/signup-verification.html"; private final JavaMailSender mailSender; private final EmailVerificationProperties properties; - public void sendVerificationCode(String to, String code) { - SimpleMailMessage message = new SimpleMailMessage(); - message.setFrom(properties.from()); - message.setTo(to); - message.setSubject(SUBJECT); - message.setText("인증 코드: " + code + " (" + properties.codeTtl().toMinutes() + "분 이내 입력해주세요)"); - mailSender.send(message); + @Async + public CompletableFuture sendVerificationCode(String to, String code) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, StandardCharsets.UTF_8.name()); + helper.setValidateAddresses(true); + helper.setFrom(properties.from(), "채소.zip"); + helper.setTo(to); + helper.setSubject(SUBJECT); + helper.setText(render(code), true); + mailSender.send(message); + return CompletableFuture.completedFuture(null); + } catch (MessagingException | UnsupportedEncodingException exception) { + throw new MailTemplateException("인증 이메일을 구성할 수 없습니다", exception); + } + } + + private String render(String code) { + try { + return new ClassPathResource(TEMPLATE).getContentAsString(StandardCharsets.UTF_8) + .replace("{{code}}", code) + .replace("{{ttlMinutes}}", String.valueOf(properties.codeTtl().toMinutes())); + } catch (java.io.IOException exception) { + throw new MailTemplateException("인증 이메일 템플릿을 읽을 수 없습니다", exception); + } } } diff --git a/src/main/resources/templates/email/signup-verification.html b/src/main/resources/templates/email/signup-verification.html new file mode 100644 index 00000000..da6d27bc --- /dev/null +++ b/src/main/resources/templates/email/signup-verification.html @@ -0,0 +1,41 @@ + + + + + + 채소.zip 이메일 인증 + + +
채소.zip 이메일 인증
+ + + + + + +
+ + + + + + +
+ 채소.zip +

이메일 인증

+

회원가입을 완료하려면 아래 인증코드를 입력해 주세요.

+ + + + + + +
+

{{code}}

+
+

인증코드는 {{ttlMinutes}}분 동안 유효합니다.
인증을 요청하지 않으셨다면 이 메일을 삭제해 주세요.

+

본 메일은 채소.zip에서 자동 발송되었습니다.

+
+
+ + diff --git a/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java b/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java index 8e3367e6..48622584 100644 --- a/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java +++ b/src/test/java/chaeso/zip/server/auth/application/AuthServiceTest.java @@ -51,6 +51,7 @@ import java.util.List; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -138,6 +139,8 @@ void setUp() { userRepository.findByEmailAndDeletedAtIsNull(invocation.getArgument(0))); lenient().when(userRepository.findByIdAndDeletedAtIsNull(any())) .thenReturn(Optional.of(UserFixture.user())); + lenient().when(verificationMailSender.sendVerificationCode(anyString(), anyString())) + .thenReturn(CompletableFuture.completedFuture(null)); } private static LoginCommand loginCommand() { @@ -206,11 +209,10 @@ void success() { void mailFailureReleasesCooldown() { given(userRepository.findByEmailAndDeletedAtIsNull("user@chaeso.zip")).willReturn(Optional.empty()); given(verificationCodeStore.tryAcquireSendSlot("user@chaeso.zip")).willReturn(true); - willThrow(new MailSendException("smtp down")) - .given(verificationMailSender).sendVerificationCode(eq("user@chaeso.zip"), anyString()); + given(verificationMailSender.sendVerificationCode(eq("user@chaeso.zip"), anyString())) + .willReturn(CompletableFuture.failedFuture(new MailSendException("smtp down"))); - assertThatThrownBy(() -> authService.sendSignupVerificationCode("user@chaeso.zip")) - .isInstanceOf(MailSendException.class); + assertThat(authService.sendSignupVerificationCode("user@chaeso.zip")).isNull(); verify(verificationCodeStore).releaseSendSlot("user@chaeso.zip"); } diff --git a/src/test/java/chaeso/zip/server/auth/infrastructure/mail/VerificationMailSenderTest.java b/src/test/java/chaeso/zip/server/auth/infrastructure/mail/VerificationMailSenderTest.java index 31105c03..c0e3159f 100644 --- a/src/test/java/chaeso/zip/server/auth/infrastructure/mail/VerificationMailSenderTest.java +++ b/src/test/java/chaeso/zip/server/auth/infrastructure/mail/VerificationMailSenderTest.java @@ -1,37 +1,85 @@ package chaeso.zip.server.auth.infrastructure.mail; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import chaeso.zip.server.auth.infrastructure.verification.EmailVerificationProperties; +import jakarta.mail.MessagingException; +import jakarta.mail.Session; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; import java.time.Duration; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Async; class VerificationMailSenderTest { + @Test + @DisplayName("인증 메일 발송은 비동기로 실행된다") + void sendsVerificationCodeAsynchronously() throws NoSuchMethodException { + assertThat(VerificationMailSender.class + .getMethod("sendVerificationCode", String.class, String.class) + .isAnnotationPresent(Async.class)) + .isTrue(); + } + @Test @DisplayName("수신자/발신자/코드가 담긴 인증 메일을 발송한다") - void sendsVerificationCode() { + void sendsVerificationCode() throws Exception { JavaMailSender mailSender = mock(JavaMailSender.class); + MimeMessage mimeMessage = new MimeMessage((Session) null); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); EmailVerificationProperties properties = new EmailVerificationProperties( - "no-reply@chaeso.zip", Duration.ofMinutes(5), Duration.ofMinutes(30), 5, Duration.ofMinutes(1)); + "no-reply@chaeso-zip.com", + Duration.ofMinutes(5), + Duration.ofMinutes(30), + 5, + Duration.ofMinutes(1)); VerificationMailSender sender = new VerificationMailSender(mailSender, properties); sender.sendVerificationCode("user@chaeso.zip", "123456"); - ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + ArgumentCaptor captor = ArgumentCaptor.forClass(MimeMessage.class); verify(mailSender).send(captor.capture()); - SimpleMailMessage message = captor.getValue(); - assertThat(message.getTo()).containsExactly("user@chaeso.zip"); - assertThat(message.getFrom()).isEqualTo("no-reply@chaeso.zip"); - assertThat(message.getText()) + MimeMessage message = captor.getValue(); + assertThat(message.getRecipients(MimeMessage.RecipientType.TO)[0].toString()) + .hasToString("user@chaeso.zip"); + InternetAddress from = (InternetAddress) message.getFrom()[0]; + assertThat(from.getAddress()).isEqualTo("no-reply@chaeso-zip.com"); + assertThat(from.getPersonal()).isEqualTo("채소.zip"); + assertThat(message.getContent().toString()) .contains("123456") .contains("5분"); } + + @Test + @DisplayName("잘못된 수신자 주소면 메일 템플릿 예외로 변환한다") + void throwsMailTemplateExceptionWhenRecipientIsInvalid() { + JavaMailSender mailSender = mock(JavaMailSender.class); + MimeMessage mimeMessage = new MimeMessage((Session) null); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + EmailVerificationProperties properties = + new EmailVerificationProperties( + "no-reply@chaeso-zip.com", + Duration.ofMinutes(5), + Duration.ofMinutes(30), + 5, + Duration.ofMinutes(1)); + VerificationMailSender sender = new VerificationMailSender(mailSender, properties); + + assertThatThrownBy(() -> sender.sendVerificationCode("invalid-address", "123456")) + .isInstanceOf(MailTemplateException.class) + .hasMessage("인증 이메일을 구성할 수 없습니다") + .hasCauseInstanceOf(MessagingException.class); + verify(mailSender, never()).send(any(MimeMessage.class)); + } } diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index dc1e67f2..477f0911 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -51,7 +51,7 @@ app: consent: terms-version: v1.0 email-verification: - from: no-reply@test.com + from: no-reply@chaeso-zip.com code-ttl: PT5M verified-ttl: PT30M max-verify-attempts: 5