Skip to content

[COT-264] Feature: 메일 전송 API 구현 #353

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'com.amazonaws:aws-java-sdk-ses:1.12.3'

implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@
import lombok.extern.slf4j.Slf4j;
import org.cotato.csquiz.api.recruitment.dto.ChangeRecruitmentInfoRequest;
import org.cotato.csquiz.api.recruitment.dto.RecruitmentInfoResponse;
import org.cotato.csquiz.api.recruitment.dto.RequestNotificationRequest;
import org.cotato.csquiz.api.recruitment.dto.RequestRecruitmentNotificationRequest;
import org.cotato.csquiz.common.role.RoleAuthority;
import org.cotato.csquiz.domain.auth.entity.Member;
import org.cotato.csquiz.domain.auth.enums.MemberRole;
import org.cotato.csquiz.api.recruitment.dto.RequestRecruitmentNotificationRequest;
import org.cotato.csquiz.domain.recruitment.service.RecruitmentInformationService;
import org.cotato.csquiz.domain.recruitment.service.RecruitmentNotificationService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요건 왜 맨날 순서가 바뀌지 .. 둘이 쓰는 코드 포매터가 다른가

import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -42,7 +45,7 @@ public ResponseEntity<RecruitmentInfoResponse> findRecruitmentInfo() {
public ResponseEntity<Void> changeRecruitmentInfo(@RequestBody @Valid ChangeRecruitmentInfoRequest request) {
recruitmentInformationService.changeRecruitmentInfo(request.isOpened(), request.startDate(), request.endDate(),
request.recruitmentUrl());
return ResponseEntity.noContent().build();
return ResponseEntity.noContent().build();
}

@PostMapping("/notification")
Expand All @@ -52,4 +55,13 @@ public ResponseEntity<Void> requestRecruitmentNotification(
recruitmentNotificationService.requestRecruitmentNotification(request.email(), request.policyCheck());
return ResponseEntity.noContent().build();
}

@Operation(summary = "모집 알림 전송 API")
@RoleAuthority(MemberRole.ADMIN)
@PostMapping("/notification/requester")
public ResponseEntity<Void> requestRecruitmentNotification(@RequestBody @Valid RequestNotificationRequest request,
@AuthenticationPrincipal Member member) {
recruitmentNotificationService.sendRecruitmentNotificationMail(request.generationNumber(), member);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package org.cotato.csquiz.api.recruitment.dto;

import jakarta.validation.constraints.NotNull;

public record RequestNotificationRequest(
@NotNull
Integer generationNumber
) {
}
34 changes: 34 additions & 0 deletions src/main/java/org/cotato/csquiz/common/config/AwsSesConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.cotato.csquiz.common.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.services.simpleemail.AmazonSimpleEmailService;
import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AwsSesConfig {

@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
Comment on lines +11 to +19
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

환경변수에 있는 값들 이렇게 Bean으로 등록해서 관리할 수 있는데 외부에서 주입 필요할땐 다음에 이렇게 쓰면 좋을 것 같아요

@Getter
@Setter
@ConfigurationProperties(prefix = "cloud.aws.s3")
public class S3Properties {
	private String accessKey;
	private String secretKey;
	private String region;
	private String bucketName;
}



@Bean
public AmazonSimpleEmailService amazonSimpleEmailService() {
final BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey);
final AWSStaticCredentialsProvider awsStaticCredentialsProvider = new AWSStaticCredentialsProvider(
basicAWSCredentials
);

return AmazonSimpleEmailServiceClientBuilder.standard()
.withCredentials(awsStaticCredentialsProvider)
.withRegion(region)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,15 @@ public Executor quizSendThreadPoolExecutor() {
taskExecutor.initialize();
return taskExecutor;
}

@Bean("emailSendThreadPoolExecutor")
public Executor emailSendThreadPoolExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(20);
taskExecutor.setMaxPoolSize(100);
taskExecutor.setQueueCapacity(10000);
Comment on lines +27 to +29
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요거 각 필드 개수 선정한 기준이 있나여?

taskExecutor.setThreadNamePrefix("email-send-thread-");
taskExecutor.initialize();
return taskExecutor;
}
}
49 changes: 49 additions & 0 deletions src/main/java/org/cotato/csquiz/common/email/AwsMailSender.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package org.cotato.csquiz.common.email;

import com.amazonaws.services.simpleemail.AmazonSimpleEmailService;
import com.amazonaws.services.simpleemail.model.Body;
import com.amazonaws.services.simpleemail.model.Content;
import com.amazonaws.services.simpleemail.model.Destination;
import com.amazonaws.services.simpleemail.model.Message;
import com.amazonaws.services.simpleemail.model.SendEmailRequest;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class AwsMailSender {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구체적으로 어느 서비스를 활용한 구현체인지 알아야하니까 Ses Email Sender로 가시죠 !!


private final AmazonSimpleEmailService ses;

@Value("${cloud.aws.ses.emailAddress}")
private String from;
Comment on lines +22 to +23
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 딱 이런 부분에서 위에 언급한 것처럼 yaml 파일 상수로 가져와서 쓰면 좋을 것 같아요


public void sendRawMessageBody(String recipient, String htmlBody, String subject) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요기 recipient가 Email 형식인지 검증 안해도 괜찮을까여?

Content subjectContent = new Content(subject);
subjectContent.setCharset("UTF-8");

Content bodyContent = new Content(htmlBody);
bodyContent.setCharset("UTF-8");
Body messageBody = createHtmlBody(bodyContent);

SendEmailRequest req = new SendEmailRequest(
from,
new Destination(List.of(recipient)),
new Message(
new Content(subject),
messageBody
)
);
ses.sendEmail(req);
}

private Body createHtmlBody(Content content) {
Body messageBody = new Body();
messageBody.setHtml(content);
return messageBody;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.cotato.csquiz.domain.recruitment.email;

public record EmailContent(
String subject,
String htmlBody
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.cotato.csquiz.domain.recruitment.email;

public class RecruitmentEmailFactory {
private static final String LINK_URL = "https://www.cotato.kr";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요것도 환경변수로 넣어서 QA랑 main에서 활용을 구분하면 좋을 것 같습니다 !


public static EmailContent createForGeneration(int generation) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기수를 위해서 만든다? 메서드명이 너무 모호한 것 같아요

모집 안내 전용인가..? 용도에 맞게 구체화되었으면 좋겠습니다 !

String subject = generation + "기 모집이 시작됐습니다.";

String htmlBody = """
<html>
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f9f9f9;">
<!-- HEADER -->
<div style="background:#004cab;color:#fff;padding:20px;text-align:center;">
<h1>코테이토 %d기 모집 안내</h1>
</div>
<!-- BODY -->
<div style="padding:20px;background:#fff;">
<p style="font-size:16px;line-height:1.5;">
코테이토 %d기 모집이 시작됐습니다!
</p>
<p style="font-size:14px;">
아래 버튼을 눌러 모집 신청을 진행해주세요.
</p>
<div style="text-align:center;margin:30px 0;">
<a href="%s"
style="display:inline-block;
padding:12px 24px;
background:#0066cc;
color:#fff;
text-decoration:none;
border-radius:4px;
font-weight:bold;">
모집 신청하러 가기
</a>
</div>
</div>
<!-- FOOTER -->
<div style="background:#f1f1f1;color:#777;padding:10px;text-align:center;font-size:12px;">
&copy; 2025 코테이토. All rights reserved.
</div>
</body>
</html>
""".formatted(generation, generation, LINK_URL);

return new EmailContent(subject, htmlBody);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,18 @@ public class RecruitmentNotification extends BaseTimeEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender")
private Member sender;

private RecruitmentNotification(int generationNumber, LocalDateTime sendTime, Member sender) {
this.generationNumber = generationNumber;
this.sendTime = sendTime;
this.sender = sender;
}

public static RecruitmentNotification of(Member member, int generationNumber) {
return new RecruitmentNotification(
generationNumber,
LocalDateTime.now(),
member
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,16 @@ public class RecruitmentNotificationEmailLog extends BaseTimeEntity {

@Column(name = "send_success")
private Boolean sendSuccess;

private RecruitmentNotificationEmailLog(RecruitmentNotificationRequester receiver,
RecruitmentNotification notification, boolean sendSuccess) {
this.receiver = receiver;
this.notification = notification;
this.sendSuccess = sendSuccess;
}

public static RecruitmentNotificationEmailLog of(RecruitmentNotificationRequester receiver,
RecruitmentNotification notification, boolean sendSuccess) {
return new RecruitmentNotificationEmailLog(receiver, notification, sendSuccess);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ private RecruitmentNotificationRequester(String email, Boolean policyChecked, Lo
this.sendStatus = sendStatus;
}

public void updateSendStatus(SendStatus sendStatus) {
this.sendStatus = sendStatus;
}

public static RecruitmentNotificationRequester of(String email, Boolean policyChecked) {
return new RecruitmentNotificationRequester(email, policyChecked, LocalDateTime.now(), SendStatus.NOT_SENT);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.cotato.csquiz.domain.recruitment.repository;

import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.cotato.csquiz.domain.recruitment.entity.RecruitmentNotificationEmailLog;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class RecruitmentNotificationEmailLogJdbcRepository {

private static final int BATCH_SIZE = 1000;

private final JdbcTemplate jdbcTemplate;

public void saveAllWithBatch(List<RecruitmentNotificationEmailLog> logs) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update 를 하는 부분인데 transaction 안걸어도 될까여?


// language=MySQL
final String SQL = "INSERT INTO recruitment_notification_email_log "
+ "(receiver_id, notification_id, send_success, created_at, modified_at) "
+ "VALUES (?, ?, ?, now(), now())";

for (int start = 0; start < logs.size(); start += BATCH_SIZE) {
int end = Math.min(start + BATCH_SIZE, logs.size());
List<RecruitmentNotificationEmailLog> chunk = logs.subList(start, end);

jdbcTemplate.batchUpdate(SQL, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
var log = chunk.get(i);
ps.setLong(1, log.getReceiver().getId());
ps.setLong(2, log.getNotification().getId());
ps.setBoolean(3, log.getSendSuccess());
}

@Override
public int getBatchSize() {
return chunk.size();
}
});
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.cotato.csquiz.domain.recruitment.repository;

import org.cotato.csquiz.domain.recruitment.entity.RecruitmentNotification;
import org.springframework.data.jpa.repository.JpaRepository;

public interface RecruitmentNotificationRepository extends JpaRepository<RecruitmentNotification, Long> {
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
package org.cotato.csquiz.domain.recruitment.repository;

import java.util.List;
import org.cotato.csquiz.domain.recruitment.entity.RecruitmentNotificationRequester;
import org.cotato.csquiz.domain.recruitment.enums.SendStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface RecruitmentNotificationRequesterRepository extends
JpaRepository<RecruitmentNotificationRequester, Long> {
boolean existsByEmailAndSendStatus(String recruitEmail, SendStatus sendStatus);

List<RecruitmentNotificationRequester> findAllBySendStatusIn(List<SendStatus> status);

@Modifying
@Query("update RecruitmentNotificationRequester r set r.sendStatus = :status where r.id in :ids")
void updateSendStatusByIds(@Param("status") SendStatus sendStatus, @Param("ids") List<Long> ids);
Comment on lines +17 to +19
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update 쿼리인데 트랜잭션없어도 괜찮을까여?

}
Loading