Skip to content

Commit f954d05

Browse files
authored
Merge pull request #107 from prgrms-aibe-devcourse/main: main to publish
main to publish
2 parents 7b34c40 + 5518d57 commit f954d05

5 files changed

Lines changed: 172 additions & 22 deletions

File tree

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
package com.bootsignal.domain.auth.service;
22

33
import com.bootsignal.domain.user.entity.User;
4+
import com.bootsignal.global.config.Utf8DotenvLoader;
45
import com.bootsignal.global.config.properties.PasswordResetProperties;
6+
import jakarta.mail.MessagingException;
7+
import jakarta.mail.internet.MimeMessage;
8+
import java.nio.charset.StandardCharsets;
59
import java.time.Instant;
610
import lombok.RequiredArgsConstructor;
711
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
812
import org.springframework.context.annotation.Primary;
9-
import org.springframework.mail.SimpleMailMessage;
1013
import org.springframework.mail.javamail.JavaMailSender;
14+
import org.springframework.mail.javamail.MimeMessageHelper;
1115
import org.springframework.stereotype.Component;
1216

1317
/**
@@ -19,25 +23,39 @@
1923
@ConditionalOnProperty(prefix = "app.auth.password-reset.mail", name = "enabled", havingValue = "true")
2024
public class MailPasswordResetTokenNotifier implements PasswordResetTokenNotifier {
2125

26+
private static final String MAIL_SUBJECT_ENV = "PASSWORD_RESET_MAIL_SUBJECT";
27+
2228
private final JavaMailSender javaMailSender;
2329
private final PasswordResetProperties passwordResetProperties;
2430

2531
@Override
2632
public void send(User user, String rawToken, String resetUrl, Instant expiresAt) {
27-
SimpleMailMessage message = new SimpleMailMessage();
28-
message.setFrom(passwordResetProperties.mail().from());
29-
message.setTo(user.getEmail());
30-
message.setSubject(passwordResetProperties.mail().subject());
31-
message.setText("""
32-
BootSignal 비밀번호 재설정을 요청하셨습니다.
33+
try {
34+
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
35+
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, StandardCharsets.UTF_8.name());
36+
helper.setFrom(passwordResetProperties.mail().from());
37+
helper.setTo(user.getEmail());
38+
helper.setSubject(resolveSubject());
39+
helper.setText("""
40+
BootSignal 비밀번호 재설정을 요청하셨습니다.
3341
34-
아래 링크에서 새 비밀번호를 설정해 주세요.
35-
%s
42+
아래 링크에서 새 비밀번호를 설정해 주세요.
43+
%s
3644
37-
이 링크는 %s까지 사용할 수 있습니다.
38-
본인이 요청하지 않았다면 이 메일을 무시해 주세요.
39-
""".formatted(resetUrl, expiresAt));
45+
이 링크는 %s까지 사용할 수 있습니다.
46+
본인이 요청하지 않았다면 이 메일을 무시해 주세요.
47+
""".formatted(resetUrl, expiresAt));
48+
javaMailSender.send(mimeMessage);
49+
} catch (MessagingException exception) {
50+
throw new IllegalStateException("비밀번호 재설정 메일 발송에 실패했습니다.", exception);
51+
}
52+
}
4053

41-
javaMailSender.send(message);
54+
private String resolveSubject() {
55+
String fromDotenv = Utf8DotenvLoader.get(MAIL_SUBJECT_ENV);
56+
if (fromDotenv != null && !fromDotenv.isBlank()) {
57+
return fromDotenv;
58+
}
59+
return Utf8DotenvLoader.fixUtf8MisreadAsIso8859(passwordResetProperties.mail().subject());
4260
}
4361
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.bootsignal.global.config;
2+
3+
import java.io.IOException;
4+
import java.nio.charset.StandardCharsets;
5+
import java.nio.file.Files;
6+
import java.nio.file.Path;
7+
import java.util.HashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
/**
12+
* .env 파일을 UTF-8로 직접 파싱합니다. Spring config import의 ISO-8859-1 한계를 우회합니다.
13+
*/
14+
public final class Utf8DotenvLoader {
15+
16+
private static final List<Path> DOTENV_CANDIDATES = List.of(
17+
Path.of(".env"),
18+
Path.of(System.getProperty("user.dir", "."), ".env")
19+
);
20+
21+
private Utf8DotenvLoader() {
22+
}
23+
24+
public static Map<String, String> load() {
25+
for (Path candidate : DOTENV_CANDIDATES) {
26+
Path normalized = candidate.toAbsolutePath().normalize();
27+
if (!Files.isRegularFile(normalized)) {
28+
continue;
29+
}
30+
try {
31+
return parse(normalized);
32+
} catch (IOException ignored) {
33+
// try next candidate
34+
}
35+
}
36+
return Map.of();
37+
}
38+
39+
public static String get(String key) {
40+
return load().get(key);
41+
}
42+
43+
static Map<String, String> parse(Path path) throws IOException {
44+
Map<String, String> values = new HashMap<>();
45+
for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
46+
String trimmed = stripBom(line.strip());
47+
if (trimmed.isEmpty() || trimmed.startsWith("#")) {
48+
continue;
49+
}
50+
int separator = trimmed.indexOf('=');
51+
if (separator <= 0) {
52+
continue;
53+
}
54+
String key = trimmed.substring(0, separator).strip();
55+
String value = trimmed.substring(separator + 1).strip();
56+
if (!key.isEmpty()) {
57+
values.put(key, value);
58+
}
59+
}
60+
return values;
61+
}
62+
63+
public static String fixUtf8MisreadAsIso8859(String value) {
64+
if (value == null || value.isBlank() || containsHangul(value)) {
65+
return value;
66+
}
67+
if (value.chars().noneMatch(ch -> ch > 127)) {
68+
return value;
69+
}
70+
String fixed = new String(value.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);
71+
return containsHangul(fixed) ? fixed : value;
72+
}
73+
74+
private static boolean containsHangul(String value) {
75+
return value.chars().anyMatch(ch -> Character.UnicodeScript.of(ch) == Character.UnicodeScript.HANGUL);
76+
}
77+
78+
private static String stripBom(String line) {
79+
if (!line.isEmpty() && line.charAt(0) == '\uFEFF') {
80+
return line.substring(1);
81+
}
82+
return line;
83+
}
84+
}

src/main/resources/application.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ spring:
2222
max-file-size: ${MULTIPART_MAX_FILE_SIZE:100MB}
2323
max-request-size: ${MULTIPART_MAX_REQUEST_SIZE:120MB}
2424
mail:
25+
default-encoding: UTF-8
2526
host: ${SMTP_HOST:}
2627
port: ${SMTP_PORT:587}
2728
username: ${SMTP_USERNAME:}

src/test/java/com/bootsignal/domain/auth/service/MailPasswordResetTokenNotifierTest.java

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22

33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.mockito.Mockito.verify;
5+
import static org.mockito.Mockito.when;
56

67
import com.bootsignal.domain.user.entity.User;
8+
import com.bootsignal.global.config.Utf8DotenvLoader;
79
import com.bootsignal.global.config.properties.PasswordResetProperties;
10+
import jakarta.mail.internet.MimeMessage;
811
import java.time.Instant;
912
import org.junit.jupiter.api.Test;
1013
import org.mockito.ArgumentCaptor;
11-
import org.springframework.mail.SimpleMailMessage;
1214
import org.springframework.mail.javamail.JavaMailSender;
1315
import org.springframework.test.util.ReflectionTestUtils;
1416

@@ -18,13 +20,22 @@
1820
class MailPasswordResetTokenNotifierTest {
1921

2022
@Test
21-
void sendBuildsPasswordResetMailWithResetUrl() {
23+
void sendBuildsPasswordResetMailWithResetUrl() throws Exception {
2224
JavaMailSender javaMailSender = org.mockito.Mockito.mock(JavaMailSender.class);
25+
MimeMessage mimeMessage = new org.springframework.mail.javamail.JavaMailSenderImpl().createMimeMessage();
26+
when(javaMailSender.createMimeMessage()).thenReturn(mimeMessage);
27+
28+
String fallbackSubject = "비밀번호 재설정";
29+
String expectedSubject = Utf8DotenvLoader.get("PASSWORD_RESET_MAIL_SUBJECT");
30+
if (expectedSubject == null || expectedSubject.isBlank()) {
31+
expectedSubject = fallbackSubject;
32+
}
33+
2334
PasswordResetProperties properties = new PasswordResetProperties(
2435
1800L,
2536
false,
2637
"http://localhost:5173/reset-password?token={token}",
27-
new PasswordResetProperties.Mail(true, "support@bootsignal.com", "비밀번호 재설정")
38+
new PasswordResetProperties.Mail(true, "support@bootsignal.com", fallbackSubject)
2839
);
2940
MailPasswordResetTokenNotifier notifier = new MailPasswordResetTokenNotifier(javaMailSender, properties);
3041
User user = User.signupLocal("user@example.com", "encoded-password", "홍길동", "tester");
@@ -37,13 +48,13 @@ void sendBuildsPasswordResetMailWithResetUrl() {
3748
Instant.parse("2026-06-16T05:00:00Z")
3849
);
3950

40-
ArgumentCaptor<SimpleMailMessage> captor = ArgumentCaptor.forClass(SimpleMailMessage.class);
51+
ArgumentCaptor<MimeMessage> captor = ArgumentCaptor.forClass(MimeMessage.class);
4152
verify(javaMailSender).send(captor.capture());
42-
SimpleMailMessage message = captor.getValue();
43-
assertThat(message.getFrom()).isEqualTo("support@bootsignal.com");
44-
assertThat(message.getTo()).containsExactly("user@example.com");
45-
assertThat(message.getSubject()).isEqualTo("비밀번호 재설정");
46-
assertThat(message.getText())
53+
MimeMessage message = captor.getValue();
54+
assertThat(message.getFrom()[0].toString()).isEqualTo("support@bootsignal.com");
55+
assertThat(message.getAllRecipients()[0].toString()).isEqualTo("user@example.com");
56+
assertThat(message.getSubject()).isEqualTo(expectedSubject);
57+
assertThat(message.getContent().toString())
4758
.contains("http://localhost:5173/reset-password?token=raw-reset-token")
4859
.contains("2026-06-16T05:00:00Z");
4960
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.bootsignal.global.config;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.nio.charset.StandardCharsets;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import org.junit.jupiter.api.Test;
9+
10+
class Utf8DotenvLoaderTest {
11+
12+
@Test
13+
void loadsPasswordResetMailSubjectWithHangulFromProjectEnv() {
14+
Path envPath = Path.of(".env");
15+
if (!Files.isRegularFile(envPath)) {
16+
return;
17+
}
18+
19+
String subject = Utf8DotenvLoader.get("PASSWORD_RESET_MAIL_SUBJECT");
20+
assertThat(subject).isNotNull();
21+
assertThat(subject).contains("BootSignal");
22+
assertThat(subject.chars().anyMatch(c -> Character.UnicodeScript.of(c) == Character.UnicodeScript.HANGUL))
23+
.as("subject should contain Hangul when .env is UTF-8: %s", subject)
24+
.isTrue();
25+
}
26+
27+
@Test
28+
void simulatesIso8859MisreadAndFixesMojibake() {
29+
String original = "[BootSignal] 비밀번호 재설정 안내";
30+
String corrupted = new String(original.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
31+
32+
String fixed = Utf8DotenvLoader.fixUtf8MisreadAsIso8859(corrupted);
33+
34+
assertThat(fixed).isEqualTo(original);
35+
}
36+
}

0 commit comments

Comments
 (0)