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
2 changes: 2 additions & 0 deletions src/main/java/chaeso/zip/server/ChaesoZipApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -97,7 +98,13 @@ public String sendSignupVerificationCode(String email) {
String code = generateCode();
verificationCodeStore.saveCode(normalized, code);
try {
verificationMailSender.sendVerificationCode(normalized, code);
CompletableFuture<Void> sendFuture =
verificationMailSender.sendVerificationCode(normalized, code);
sendFuture.whenComplete((ignored, exception) -> {
if (exception != null) {
verificationCodeStore.releaseSendSlot(normalized);
}
});
} catch (RuntimeException exception) {
verificationCodeStore.releaseSendSlot(normalized);
throw exception;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,53 @@
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;

/** 회원가입 이메일 인증코드 메일을 구성하고 발송한다. */
@Component
@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<Void> 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);
}
}
}
41 changes: 41 additions & 0 deletions src/main/resources/templates/email/signup-verification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="ko">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
<title>채소.zip 이메일 인증</title>
</head>
<body dir="ltr" lang="ko" style="background-color:#F3F3F1;margin:0;padding:0">
<div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0" data-skip-in-text="true">채소.zip 이메일 인증</div>
<table border="0" width="100%" cellpadding="0" cellspacing="0" role="presentation" align="center">
<tbody>
<tr>
<td dir="ltr" lang="ko" style="margin:0;padding:40px 16px;background-color:#F3F3F1;font-family:Arial, sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width:480px;margin:0 auto;background-color:#FFFFFF;border-radius:16px">
<tbody>
<tr style="width:100%">
<td style="padding:40px 32px">
<img alt="채소.zip" src="https://chaeso-zip.com/brand-assets/logo-m.svg" style="display:block;outline:none;border:none;text-decoration:none;margin:0 0 24px" width="160" />
<p style="font-size:24px;line-height:24px;margin:0 0 16px;color:#111111;font-weight:bold">이메일 인증</p>
<p style="font-size:15px;line-height:1.7;margin:0;color:#666666">회원가입을 완료하려면 아래 인증코드를 입력해 주세요.</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="margin:28px 0;background-color:#F4F4F4;border-radius:10px;text-align:center">
<tbody>
<tr>
<td style="padding:20px">
<p style="font-size:32px;line-height:24px;margin:0;color:#111111;font-weight:bold;letter-spacing:8px">{{code}}</p>
</td>
</tr>
</tbody>
</table>
<p style="font-size:13px;line-height:1.6;margin:0;color:#666666">인증코드는 {{ttlMinutes}}분 동안 유효합니다.<br />인증을 요청하지 않으셨다면 이 메일을 삭제해 주세요.</p>
<p style="font-size:12px;line-height:1.6;margin:24px 0 0;padding-top:20px;border-top:1px solid #E8E8E8;color:#666666">본 메일은 채소.zip에서 자동 발송되었습니다.</p>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<SimpleMailMessage> captor = ArgumentCaptor.forClass(SimpleMailMessage.class);
ArgumentCaptor<MimeMessage> 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));
}
}
2 changes: 1 addition & 1 deletion src/test/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading