Skip to content

Commit b0c1f93

Browse files
authored
[feat] 메일 발송 기능 구현 (#39)
* feat: 메일 발송 기능 구현 * fix: 이메일 전송 관련 ErrorCode 수정 * feat: 서류 지원결과와 최종 합격 결과 API 나누기 * fix: 서류 지원결과와 최종 합격 결과 API 하나의 메소드에서 지원되게 수정 MailType 열거형 추가 * fix: ApplicantServiceImpl의 sendEmailsToApplicants 메소드 ApplicantStatus 수정 완료 * test: ApplicantServiceImpl의 sendEmailsToApplicants 메소드 테스트 케이스 작성 및 EmailServiceTest 작성 * fix: EmailServiceTest에 들어가는 from의 value가 외부 환경변수를 인식하지 못하는 문제 발생 추후 테스트용 환경변수 파일 작성 예정 * feat: EmailServiceTest 추가
1 parent 1917f71 commit b0c1f93

File tree

12 files changed

+407
-0
lines changed

12 files changed

+407
-0
lines changed

build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ dependencies {
3939
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
4040
implementation 'org.springframework.boot:spring-boot-starter-security'
4141
implementation 'org.springframework.boot:spring-boot-starter-validation'
42+
implementation 'org.springframework.boot:spring-boot-starter-mail'
4243
implementation 'org.springframework.boot:spring-boot-starter-web'
44+
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
4345
compileOnly 'org.projectlombok:lombok'
4446
developmentOnly 'org.springframework.boot:spring-boot-devtools'
4547
runtimeOnly 'com.oracle.database.jdbc:ojdbc11'
@@ -53,6 +55,9 @@ dependencies {
5355
implementation 'com.google.auth:google-auth-library-oauth2-http:0.20.0'
5456

5557

58+
59+
60+
5661
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
5762
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
5863
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
package dmu.dasom.api.domain.applicant.repository;
22

33
import dmu.dasom.api.domain.applicant.entity.Applicant;
4+
import dmu.dasom.api.domain.applicant.enums.ApplicantStatus;
45
import org.springframework.data.domain.Page;
56
import org.springframework.data.domain.Pageable;
67
import org.springframework.data.jpa.repository.JpaRepository;
78
import org.springframework.data.jpa.repository.Query;
89

10+
import java.util.List;
11+
912
import java.util.Optional;
1013

1114
public interface ApplicantRepository extends JpaRepository<Applicant, Long> {
1215

1316
@Query("SELECT a FROM Applicant a ORDER BY a.id DESC")
1417
Page<Applicant> findAllWithPageRequest(final Pageable pageable);
1518

19+
// 상태별 지원자 조회
20+
List<Applicant> findByStatus(ApplicantStatus status);
21+
List<Applicant> findByStatusIn(List<ApplicantStatus> statuses);
22+
1623
Optional<Applicant> findByStudentNo(final String studentNo);
1724

1825
}

src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import dmu.dasom.api.domain.applicant.dto.ApplicantDetailsResponseDto;
55
import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto;
66
import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto;
7+
import dmu.dasom.api.domain.email.enums.MailType;
78
import dmu.dasom.api.global.dto.PageResponse;
89

910
public interface ApplicantService {
@@ -16,4 +17,6 @@ public interface ApplicantService {
1617

1718
ApplicantDetailsResponseDto updateApplicantStatus(final Long id, final ApplicantStatusUpdateRequestDto request);
1819

20+
void sendEmailsToApplicants(MailType mailType);
21+
1922
}

src/main/java/dmu/dasom/api/domain/applicant/service/ApplicantServiceImpl.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,26 @@
55
import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto;
66
import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto;
77
import dmu.dasom.api.domain.applicant.entity.Applicant;
8+
import dmu.dasom.api.domain.applicant.enums.ApplicantStatus;
89
import dmu.dasom.api.domain.applicant.repository.ApplicantRepository;
910
import dmu.dasom.api.domain.common.exception.CustomException;
1011
import dmu.dasom.api.domain.common.exception.ErrorCode;
12+
import dmu.dasom.api.domain.email.enums.MailType;
13+
import dmu.dasom.api.domain.email.service.EmailService;
1114
import dmu.dasom.api.global.dto.PageResponse;
15+
import jakarta.mail.MessagingException;
1216
import lombok.RequiredArgsConstructor;
17+
import lombok.extern.slf4j.Slf4j;
1318
import org.springframework.data.domain.Page;
1419
import org.springframework.data.domain.PageRequest;
1520
import org.springframework.stereotype.Service;
1621
import org.springframework.transaction.annotation.Transactional;
1722

1823
import java.util.Optional;
1924

25+
import java.util.List;
26+
27+
@Slf4j
2028
@RequiredArgsConstructor
2129
@Service
2230
@Transactional
@@ -25,6 +33,7 @@ public class ApplicantServiceImpl implements ApplicantService {
2533
private final static int DEFAULT_PAGE_SIZE = 20;
2634

2735
private final ApplicantRepository applicantRepository;
36+
private final EmailService emailService;
2837

2938
// 지원자 저장
3039
@Override
@@ -73,6 +82,35 @@ public ApplicantDetailsResponseDto updateApplicantStatus(final Long id, final Ap
7382
return applicant.toApplicantDetailsResponse();
7483
}
7584

85+
@Override
86+
public void sendEmailsToApplicants(MailType mailType) {
87+
List<Applicant> applicants;
88+
89+
// MailType에 따라 지원자 조회
90+
switch (mailType) {
91+
case DOCUMENT_RESULT:
92+
applicants = applicantRepository.findAll();
93+
break;
94+
case FINAL_RESULT:
95+
applicants = applicantRepository.findByStatusIn(
96+
List.of(ApplicantStatus.INTERVIEW_FAILED,
97+
ApplicantStatus.INTERVIEW_PASSED)
98+
);
99+
break;
100+
default:
101+
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);
102+
}
103+
104+
for (Applicant applicant : applicants) {
105+
try {
106+
emailService.sendEmail(applicant.getEmail(), applicant.getName(), mailType);
107+
} catch (MessagingException e) {
108+
System.err.println("Failed to send email to: " + applicant.getEmail());
109+
}
110+
}
111+
}
112+
113+
76114
// Repository에서 ID로 지원자 조회
77115
private Applicant findById(final Long id) {
78116
return applicantRepository.findById(id)

src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public enum ErrorCode {
2020
WRITE_FAIL(400, "C011", "데이터를 쓰는데 실패하였습니다."),
2121
EMPTY_RESULT(400, "C012", "조회 결과가 없습니다."),
2222
DUPLICATED_STUDENT_NO(400, "C013", "이미 등록된 학번입니다."),
23+
SEND_EMAIL_FAIL(400, "C014", "이메일 전송에 실패하였습니다."),
24+
MAIL_TYPE_NOT_VALID(400, "C015", "메일 타입이 올바르지 않습니다.")
2325
;
2426

2527
private final int status;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package dmu.dasom.api.domain.email.enums;
2+
3+
public enum MailType {
4+
DOCUMENT_RESULT, // 서류 합격
5+
FINAL_RESULT // 최종 합격
6+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package dmu.dasom.api.domain.email.service;
2+
3+
import dmu.dasom.api.domain.common.exception.CustomException;
4+
import dmu.dasom.api.domain.common.exception.ErrorCode;
5+
import dmu.dasom.api.domain.email.enums.MailType;
6+
import jakarta.mail.MessagingException;
7+
import jakarta.mail.internet.MimeMessage;
8+
import lombok.RequiredArgsConstructor;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.mail.javamail.JavaMailSender;
11+
import org.springframework.mail.javamail.MimeMessageHelper;
12+
import org.springframework.stereotype.Service;
13+
import org.thymeleaf.TemplateEngine;
14+
import org.thymeleaf.context.Context;
15+
16+
@RequiredArgsConstructor
17+
@Service
18+
public class EmailService {
19+
20+
private JavaMailSender javaMailSender;
21+
private TemplateEngine templateEngine;
22+
@Value("${spring.mail.username}")
23+
private String from;
24+
25+
public void sendEmail(String to, String name, MailType mailType) throws MessagingException {
26+
if (mailType == null){
27+
throw new CustomException(ErrorCode.MAIL_TYPE_NOT_VALID);
28+
}
29+
// 메일 제목 및 템플릿 설정
30+
String subject;
31+
String templateName = switch (mailType) {
32+
case DOCUMENT_RESULT -> {
33+
subject = "서류 합격 안내";
34+
yield "document-pass-template";
35+
}
36+
case FINAL_RESULT -> {
37+
subject = "최종 합격 안내";
38+
yield "final-pass-template";
39+
}
40+
default -> throw new IllegalStateException("Unexpected value: " + mailType);
41+
};
42+
43+
// HTML 템플릿에 전달할 데이터 설정
44+
Context context = new Context();
45+
context.setVariable("name", name); // 지원자 이름 전달
46+
47+
// HTML 템플릿 처리
48+
String htmlBody = templateEngine.process(templateName, context);
49+
50+
// 이메일 생성 및 전송
51+
MimeMessage message = javaMailSender.createMimeMessage();
52+
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
53+
54+
helper.setTo(to);
55+
helper.setSubject(subject);
56+
helper.setText(htmlBody, true);
57+
helper.setFrom(from);
58+
59+
javaMailSender.send(message);
60+
}
61+
62+
}

src/main/java/dmu/dasom/api/global/admin/controller/AdminController.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import dmu.dasom.api.domain.applicant.dto.ApplicantResponseDto;
55
import dmu.dasom.api.domain.applicant.dto.ApplicantStatusUpdateRequestDto;
66
import dmu.dasom.api.domain.applicant.service.ApplicantService;
7+
import dmu.dasom.api.domain.email.enums.MailType;
78
import dmu.dasom.api.global.dto.PageResponse;
89
import io.swagger.v3.oas.annotations.Operation;
10+
import io.swagger.v3.oas.annotations.Parameter;
911
import io.swagger.v3.oas.annotations.media.Content;
1012
import io.swagger.v3.oas.annotations.media.ExampleObject;
1113
import io.swagger.v3.oas.annotations.media.Schema;
@@ -79,4 +81,34 @@ public ResponseEntity<ApplicantDetailsResponseDto> updateApplicantStatus(@PathVa
7981
return ResponseEntity.ok(applicantService.updateApplicantStatus(id, request));
8082
}
8183

84+
@Operation(
85+
summary = "메일 전송",
86+
description = "지원자들에게 서류 결과 또는 최종 결과 이메일을 발송합니다."
87+
)
88+
@ApiResponses(value = {
89+
@ApiResponse(responseCode = "200", description = "메일 전송 성공"),
90+
@ApiResponse(responseCode = "400", description = "잘못된 요청",
91+
content = @Content(
92+
mediaType = "application/json",
93+
schema = @Schema(implementation = ErrorResponse.class),
94+
examples = {
95+
@ExampleObject(
96+
name = "전송 실패",
97+
value = "{ \"code\": \"C014\", \"message\": \"이메일 전송에 실패하였습니다.\" }"
98+
)
99+
}
100+
)
101+
)
102+
})
103+
@PostMapping("/applicants/send-email")
104+
public ResponseEntity<Void> sendEmailsToApplicants(
105+
@RequestParam
106+
@Parameter(description = "메일 발송 타입", examples = {
107+
@ExampleObject(name = "서류 합격 메일", value = "DOCUMENT_PASS"),
108+
@ExampleObject(name = "최종 결과 메일", value = "FINAL_RESULT")
109+
}) MailType mailType) {
110+
applicantService.sendEmailsToApplicants(mailType);
111+
return ResponseEntity.ok().build();
112+
}
113+
82114
}

src/main/resources/application-credentials.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ spring:
1515
redis:
1616
host: ${REDIS_HOST}
1717
port: ${REDIS_PORT}
18+
mail:
19+
host: smtp.gmail.com
20+
port: 587
21+
username: ${MAIL_USERNAME}
22+
password: ${MAIL_PASSWORD}
23+
properties:
24+
mail:
25+
smtp:
26+
auth: true
27+
starttls:
28+
enable: true
1829
jwt:
1930
secret: ${JWT_SECRET}
2031
access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<!DOCTYPE html>
2+
<html lang="ko">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>지원 결과 확인</title>
7+
<style>
8+
body {
9+
font-family: Arial, sans-serif;
10+
background-color: #1a1a1a;
11+
color: #ffffff;
12+
margin: 0;
13+
padding: 0;
14+
}
15+
.container {
16+
max-width: 600px;
17+
margin: 0 auto;
18+
padding: 20px;
19+
background-color: #1a1a1a;
20+
border-radius: 8px;
21+
text-align: center;
22+
}
23+
.header {
24+
font-size: 24px;
25+
font-weight: bold;
26+
color: #00B493;
27+
margin-bottom: 20px;
28+
}
29+
.sub-header {
30+
font-size: 16px;
31+
background-color: #00B493;
32+
padding: 10px;
33+
border-radius: 5px;
34+
margin-bottom: 20px;
35+
}
36+
.content {
37+
font-size: 14px;
38+
line-height: 1.8;
39+
}
40+
.content p {
41+
margin-bottom: 10px;
42+
}
43+
.highlight {
44+
color: #00B493;
45+
}
46+
.button {
47+
display: inline-block;
48+
background-color: #00B493;
49+
color: #ffffff;
50+
padding: 12px 24px;
51+
border-radius: 5px;
52+
text-decoration: none;
53+
font-size: 16px;
54+
}
55+
.button:hover {
56+
background-color: #00B493;
57+
}
58+
</style>
59+
</head>
60+
<body>
61+
<div class="container">
62+
<!-- Header Section -->
63+
<div class="header">DASOM</div>
64+
65+
<!-- Sub-header Section -->
66+
<div class="sub-header">컴퓨터소프트웨어공학과 전공동아리 다솜<br>34기 서류 합격자 조회</div>
67+
68+
<!-- Content Section -->
69+
<div class="content">
70+
<p>안녕하세요, <span class="highlight" th:text="${name}"></span>님.</p>
71+
<p>학번 마지막 <span class="highlight">4자리</span> + 전화번호 마지막 <span class="highlight">4자리</span>를 입력하여<br>지원 결과를 확인할 수 있습니다.</p>
72+
<p>예시) <span class="highlight">08470542</span></p>
73+
74+
<!-- Button -->
75+
<a href="https://example.com/result" class="button">결과 확인하기</a>
76+
</div>
77+
</div>
78+
</body>
79+
</html>

0 commit comments

Comments
 (0)