Skip to content
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

feat(mail): implement sending mail #352

Merged
merged 32 commits into from
Jul 26, 2022
Merged

Conversation

MyaGya
Copy link
Contributor

@MyaGya MyaGya commented Sep 23, 2021

메일 발송을 위한 API입니다.

현재 AWS SES 가 아니라 JavaMailSender 를 사용하고 있습니다. 차후 변경 과정에서 테스트 과정이나 스펙이 바뀔 수 있습니다.

  • 숨은 참조로 발신하며 수신자는 다른 수신자의 정보를 확인할 수 없습니다.
  • 송신 시 첨부파일을 추가할 경우 사용자는 첨부파일을 확인할 수 있습니다.
  • 동시 파일 전송의 수 제한 없음
  • 한 파일의 용량 제한은 없으나 메모리에 올라갈 수 있는 파일 용량은 10MB 로 제한되어 있음
  • 동시에 50명의 인원까지 묶어서 전송하며, 50명 이상의 인원이 존재할 경우 50명 단위로 전송합니다.
  • 중복된 사용자가 50명 안에 포함되어 있을 경우 해당 인원은 중복해서 메일을 보내지는 않습니다. 그러나 새롭게 50명은 채우지는 않으며(50명중 중복인원이 1명 있을 경우 49명만 발송) 50명 묶음에서만 중복 메일을 검사합니다

더 자세한 정보와 이미지는 #305 에서 확인할 수 있습니다.

테스트 방법

application.propertiesusernamepassword 에 자신의 메일 계정과 패스워드를 적습니다.
이 과정에서 Gmail 을 사용중일 경우 해당 이슈 를 참고하며, 보안 수준이 낮은 앱의 엑세스를 허용해 주어야 합니다 링크 참고
image

이후에 postman 혹은 기타 툴로 request 툴로 요청을 보내면 테스트 할 수 있습니다

postman 예시

아래 이미지와 같이 채워 넣을 수 있습니다.
image

json 예시
image
text 타입으로 지정합니다
files 예시
image
file 타입으로 지정합니다

Close #305

Copy link
Contributor

@Rok93 Rok93 left a comment

Choose a reason for hiding this comment

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

마갸 정말 고생많으셨습니다 👍
메일 테스트해보니 정상적으로 잘 보내집니다 👍👍👍

비동기 처리에 첨부파일도 고려해야하고 고려할 요소가 많았을텐데 잘 구현해주신 것 같아요 🥲
단지 중간에 옥에 티(java 클래스)가 보이는 것 같네요 👀
그리고 한 가지 궁금한 점이 현재 AWS SES가 아닌 JavaMailSender로 기능을 구현하셨다고 하셨는데, 현재 이슈는 지금 처럼 구현하면 끝인건가요!? 아니면 AWS SES를 사용하도록 변경해야 해당 이슈가 끝나는 걸까요!? (끝인지 아닌지 몰라서 approve는 한번 미루겠습니다 🤔)

interface MailSender {
fun send(toAddress: String, subject: String, body: String)

fun sendBCC(toAddresses: Array<String>, subject: String, body: String, files: List<Pair<String, ByteArrayResource>>)
Copy link
Contributor

Choose a reason for hiding this comment

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

마갸 여기서 BCC가 B.C.C (Blind carbon copy)로 숨은 참조 의미하는게 맞을까요? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

넵 해당 기능을 말하는게 맞습니다. Github에 돌아다니는 코드를 많이 참고해보니 축약어로 쓰이는 경우가 더 많더라구요


@Async
fun sendMailsByBCC(request: MailSendData, files: List<Pair<String, ByteArrayResource>>) {
for (targetMailsPart in request.targetMails.chunked(MAIL_SENDING_UNIT)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

동시에 50명의 인원까지 묶어서 전송하며, 50명 이상의 인원이 존재할 경우 50명 단위로 전송합니다.의 의미가 여기서부터 시작되는 거군요? chunked로 50명씩 나눠서 snedMailsByBCC를 수행하는거군요.... 오.... 😧

Copy link
Contributor Author

Choose a reason for hiding this comment

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

넵. 사실 메일을 보내는 자체에는 제한이 없지만 AWS 스펙상50개로 제한을 두고 있어 그 내용을 반영했습니다

Comment on lines 1 to 16
package apply.config;

import org.springframework.context.annotation.Bean;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;

public class MailConfig {
private final int MAX_FILE_SIZE = 1024 * 1024 * 10;

@Bean(name = "multipartResolver")
public CommonsMultipartResolver multipartResolver() {
CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();
multipartResolver.setMaxUploadSize(MAX_FILE_SIZE);
return multipartResolver;
}

}
Copy link
Contributor

Choose a reason for hiding this comment

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

갑자기 친숙한 Java 냄새가...? 👀

Suggested change
package apply.config;
import org.springframework.context.annotation.Bean;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
public class MailConfig {
private final int MAX_FILE_SIZE = 1024 * 1024 * 10;
@Bean(name = "multipartResolver")
public CommonsMultipartResolver multipartResolver() {
CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();
multipartResolver.setMaxUploadSize(MAX_FILE_SIZE);
return multipartResolver;
}
}
package apply.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.multipart.commons.CommonsMultipartResolver
@Configuration
class MailConfig {
@Bean
fun multipartResolver(): CommonsMultipartResolver? {
val multipartResolver = CommonsMultipartResolver()
multipartResolver.setMaxUploadSize(MAX_FILE_SIZE.toLong())
return multipartResolver
}
companion object {
private const val MAX_FILE_SIZE = 1024 * 1024 * 10
}
}

위와 같이 갓틀린 클래스로 변경하면 어떨까요 ?? 😃
추가적으로 Bean 이름을 직접 multipartResolver 라고 지정해줄 필요가 없을 것 같아요 😃 (설정하신 이유가 있다면 코틀린 코드에서도 그대로 둬도 될 것 같아요)

Copy link
Contributor Author

@MyaGya MyaGya Sep 27, 2021

Choose a reason for hiding this comment

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

샘플 코드로 가져왔는데 kotlin으로 바꾸는 걸 깜빡했네요. 이런 큰 실수를...
bean 과 관련해서는 샘플 코드에 명시가 되어 있어서 따라 했는데 기본 설정 자체가 메소드 이름을 따라가기 때문에 굳이 의미가 없기는 하네요. 자동으로 지정하는 방식으로 반영하도록 하겠습니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

사실 해당 클래스가 하는 역할을 10MB로 파일 사이즈를 제한하는 기능밖에 없어요. 곰곰히 생각해보니 그정도 기능을 따로 설정으로 뺄 필요는 없을 것 같더라구요. 설정 파일을 아예 삭제했습니다

src/main/kotlin/apply/infra/mail/SimpleMailSender.kt Outdated Show resolved Hide resolved
Copy link
Contributor

@NewWisdom NewWisdom left a comment

Choose a reason for hiding this comment

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

마갸 !!!
작업하신 내용 확인하고 코멘트 남겨보았어요 ㅎㅎ
까다로운 부분이 많았을텐데 메일 고수 답게 깔끔하게 잘 구현해주셨네요 !! 짱짱
메일 부분을 다룬적이 많이 없었는데 코드보면서 이렇게 하는거구나하고 많이 배웠습니다 👏👏
정말 수고 많으셨어요 🔥
아 pr에 적어주신 내용따라 테스트도 해봤는데, 잘 전송됩니다 !!
정리 감사해요 ~

) {
val MAIL_SENDING_UNIT = 50
Copy link
Contributor

Choose a reason for hiding this comment

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

상수는 companion object로 빼는게 어떨까요 ? ㅎㅎ

Copy link
Contributor Author

Choose a reason for hiding this comment

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

해당 패키지에서만 사용할 것 같아 따로 빼지 않았습니다!

Copy link
Contributor

Choose a reason for hiding this comment

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

상수로 빼지 않으면 컨벤션 위반입니다. 그리고 상수로 빼는 것이 더 적절해 보여요.
https://developer.android.com/kotlin/style-guide#constant_names

Comment on lines 5 to 9
data class MailSendData(
@field:NotNull
val subject: String,
@field:NotNull
val content: String,
Copy link
Contributor

Choose a reason for hiding this comment

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

@NotNull은 null만 검증해주니, String 타입은 @NotBlank로 null, 빈 공백 문자열을 막아주는게 어떨까요 ?

그리고 컨트롤러에서 @Valid 어노테이션을 걸어주지 않아서 api로 이 요청을 보내면 해당 dto 검증을 거치지 않고 있네요.
엇 근데 지금 보니까 전반적으로 validation을 걸어준 dto를 쓰는 컨트롤러가 다 @Valid가 걸려있지 않군요... ㅎㅎㅎㅎ
지금은 바딘에서 바로 서비스를 호출하지만 api들을 쓸 때는 컨트롤러에 꼭 어노테이션을 붙여주어야겠네요 !

Copy link
Contributor Author

@MyaGya MyaGya Sep 27, 2021

Choose a reason for hiding this comment

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

실제 validate 하기 위해 걸어 준 부분은 아니었는데 혼동이 오는 부부인 겉 같아요. 아직 예외 정책을 정하지 않았기 때문에 오히려 다 뺴는게 나을 수도 있을 것 같아요

Copy link
Contributor Author

Choose a reason for hiding this comment

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

구현된 기능이 아니라서 혼동을 줄 수 있을 것 같네요. 아예 어노테이션을 삭제해 두었습니다!

Comment on lines 24 to 25
val inputStreamFiles =
files.map { Pair(it.originalFilename!!, ByteArrayResource(IOUtils.toByteArray(it.inputStream))) }
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
val inputStreamFiles =
files.map { Pair(it.originalFilename!!, ByteArrayResource(IOUtils.toByteArray(it.inputStream))) }
val inputStreamFiles =
files.associate { it.originalFilename!! to ByteArrayResource(IOUtils.toByteArray(it.inputStream)) }

inputStreamFiles의 타입을 List<Pair<String, ByteArrayResource>> 대신 Map<String, ByteArrayResource>으로 사용하는 것은 어떨까요 ?
(이렇게 되면 이 변수를 받는 메서드들의 파라미터 타입도 조금 변경되겠네요)
list안에 pair가 들어가서 조금 복잡해보이기도 하고, 저렇게 만들어진 map 안에서도 결국 pair를 쓰고 있으니
map 타입을 쓰는 것도 괜찮아보여요 ㅎㅎ

Copy link
Contributor Author

@MyaGya MyaGya Sep 27, 2021

Choose a reason for hiding this comment

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

"이름" 과 "내부 데이터" 를 연결시켜 주려고 했는데 이번 미션도 그렇고 map 을 쓰는게 보편적인 것 같더라구요. mail 에 파일 데이터를 쓰는 과정에서 foreach 를 쓰기 위해서 pair를 사용했는데 map 타입으로도 keyset 을 사용하지 않고 사용할 수 있더라구요

Comment on lines 19 to 22
@PostMapping
fun sendMail(
@RequestPart(value = "request") request: MailSendData,
@RequestPart(value = "files") files: Array<MultipartFile>,
): ResponseEntity<Unit> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
@PostMapping
fun sendMail(
@RequestPart(value = "request") request: MailSendData,
@RequestPart(value = "files") files: Array<MultipartFile>,
): ResponseEntity<Unit> {
@PostMapping
fun sendMail(
@RequestPart request: MailSendData,
@RequestPart files: Array<MultipartFile>,
): ResponseEntity<Unit> {

굳굳 !!
요청 키값과 변수명이 같다면 value를 생략해도 좋을 것 같아요.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

확인했습니다. RequestPart 부분을 처음 써서 명시해줬는데 없어도 상관없다면 지워줘도 되겠네요

val subject: String,
@field:NotNull
val content: String,
@field:NotNull
Copy link
Contributor

Choose a reason for hiding this comment

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

리스트의 경우 NotEmpty가 되어야 하지 않을까 생각해 봅니다~ㅎㅎㅎ

Copy link
Contributor Author

Choose a reason for hiding this comment

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

어노테이션을 모두 삭제했습니다! 어노테이션이 적용되고 있지 않은 상태였습니다

@@ -14,8 +16,10 @@ import org.thymeleaf.spring5.ISpringTemplateEngine
class MailService(
private val applicationProperties: ApplicationProperties,
private val templateEngine: ISpringTemplateEngine,
private val mailSender: MailSender
private val mailSender: SimpleMailSender
Copy link
Contributor

Choose a reason for hiding this comment

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

AwsMailSender 의 @Component를 제거하고 MailSender로 받아도 되지 않을까요?ㅎㅎㅎ

Copy link
Contributor

Choose a reason for hiding this comment

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

저도 럴배럴 의견에 한표입니다 ㅎㅎ

Copy link
Contributor Author

Choose a reason for hiding this comment

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

제가 왜 여기를 SimpleMailSender 로 바꿨을까요? 과거의 제가 무슨짓을...

@MyaGya
Copy link
Contributor Author

MyaGya commented Sep 27, 2021

2021/09/28 3:43 1차 반영 모두 완료했습니다.
차후 #337 이슈가 마무리 될 경우 해당 기능과 결합해서 확인할 예정입니다

Copy link
Contributor

@Sehwan-Jang Sehwan-Jang left a comment

Choose a reason for hiding this comment

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

코드도 깔끔하고 메일도 잘 보내지는 것 확인했습니다👍
간단한 코멘트만 하나 남겼습니다~~

Comment on lines 7 to 9
val subject: String,
@field:NotNull
val content: String,
Copy link
Contributor

Choose a reason for hiding this comment

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

데이터 클래스의 변수들을 바딘에서 사용하려면 변수타입이 var이어야 합니다~
변수타입을 바꿔야 로키의 pr과 충돌을 피할 수 있을 것 같아요 😊

Copy link
Contributor Author

Choose a reason for hiding this comment

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

네. 바꾸고 확인해보겠습니다

Copy link
Contributor

@Rok93 Rok93 left a comment

Choose a reason for hiding this comment

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

Java 클래스만 사라지면 당장 approve해도 이상이 없었는데, 제가 Request Change 드렸던 걸 깜빡했네요 🙄
마갸 정말 고생많으셨습니다 🙏

조금 궁금한 점은 이전의 MailConfig.java 역할을 하는 설정이나 클래스 파일이 안보이는 것 같은데 혹시 어디에 위치하는걸까요? 🤔

제 작업이 머지된 이후에 이어서 작업하실 것들이 있는 것으로 알고있으니 approve는 했지만 다시 찾아와서 코멘트 남기겠습니다 😃

Copy link
Contributor

@ecsimsw ecsimsw left a comment

Choose a reason for hiding this comment

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

확인했습니다!! 늦어서 죄송합니다.

Copy link
Contributor

@woowahan-pjs woowahan-pjs left a comment

Choose a reason for hiding this comment

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

AwsMailSender를 이어 구현하면 됩니다.

@@ -0,0 +1,7 @@
package apply.application.mail

data class MailSendData(
Copy link
Contributor

Choose a reason for hiding this comment

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

이제 아래 클래스를 사용하면 됩니다. 유효성 검사 애너테이션은 Vaadin에 필요하니 삭제하면 안 됩니다.

data class MailData(
    @field:NotEmpty
    var subject: String = "",

    @field:NotEmpty
    var body: String = "",

    @field:NotEmpty
    var recipients: List<String> = emptyList()
)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

확인했습니다

interface MailSender {
fun send(toAddress: String, subject: String, body: String)

fun sendBCC(toAddresses: Array<String>, subject: String, body: String, files: Map<String, ByteArrayResource>)
Copy link
Contributor

Choose a reason for hiding this comment

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

단체 메일은 숨은 참조가 기본이므로 send() 또는 sendBcc() 중 하나를 선택하면 됩니다.
https://developer.android.com/kotlin/style-guide?hl=ko#camel_case

Suggested change
fun sendBCC(toAddresses: Array<String>, subject: String, body: String, files: Map<String, ByteArrayResource>)
fun sendBcc(toAddresses: Array<String>, subject: String, body: String, files: Map<String, ByteArrayResource>)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

일반적인 send와 MIME 타입의 String 데이터로 구성된 데이터를 send 로 쏠 수 있도록 오버로딩 할 수도 있고
snedBcc 를 통해서 bcc라는걸 명시해줄수도 있겠네요
숨은 참조가 기본이긴 해도 좀 더 정확히 명시하는게 더 좋아보입니다. 우선은 sendBcc로 분리하도록 할게요

@MyaGya
Copy link
Contributor Author

MyaGya commented Oct 8, 2021

Java 클래스만 사라지면 당장 approve해도 이상이 없었는데, 제가 Request Change 드렸던 걸 깜빡했네요 🙄 마갸 정말 고생많으셨습니다 🙏

조금 궁금한 점은 이전의 MailConfig.java 역할을 하는 설정이나 클래스 파일이 안보이는 것 같은데 혹시 어디에 위치하는걸까요? 🤔

제 작업이 머지된 이후에 이어서 작업하실 것들이 있는 것으로 알고있으니 approve는 했지만 다시 찾아와서 코멘트 남기겠습니다 😃

MailConfig 가 어떤부분인지는 모르겠지만 application.properties 부분을 확인하시면 메일 관련 정보들이 남아있는 걸 확인할 수 있습니다

@MyaGya
Copy link
Contributor Author

MyaGya commented Oct 8, 2021

2차로 구현했습니다!
주신 피드백들을 반영했고 parent 커밋을 최신으로 갱신해서 바로 관리자 페이지에서 메일 기능을 확인해 볼 수 있습니다.

테스트를 위한 환경은 아래와 같이 설정해주면 됩니다.

  1. 테스트를 위해서 Gmail 이 필요하며 application.properties 의 username과 password를 Gmail 계정 정보대로 채워 넣는다.
  2. 보안 수준이 낮은 앱 허용이 되어 있지 않다면 허용한다
  3. AwsMailSender@Component 설정을 해제하고 SimpleMailSender@Component 설정을 해준다
  4. 관리자 페이지의 "메일 발송" 탭에서 메일을 보낸다

현재 남은 작업 목록

  • AWS 메일센더 동작 확인
  • 메일을 보내는 동안 로딩 바 혹은 알림 기능 필요한지
  • 메일 보내기에 실패했을 경우의 예외처리
  • 모든 작업 종료 후, 확인용 메일에 수신자 지정 기능 추가. 메일 발송 이력을 남기기 때문에 메일 대상자 마지막에 한 번만 끼워 넣어 발송할 예정

AWS SES 설정을 위해 참고한 문서는 아래와 같습니다

@MyaGya
Copy link
Contributor Author

MyaGya commented Oct 11, 2021

추가 기능 구현

  • AWS 메일 전송 기능 추가
  • AWS 메일 리펙터링
  • 메일 대상자 맨 마지막에 관리자 메일 주소 추가(확인용)

버그

  • fileUpload 과정에서의 x버튼을 눌렀을 때 취소 기능 미구현(CSV 업로드, 메일 발송 업로드)

@MyaGya MyaGya force-pushed the feature/mail-send-api branch from 0d50365 to b8aaf96 Compare October 19, 2021 11:47
Copy link
Contributor

@knae11 knae11 left a comment

Choose a reason for hiding this comment

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

마갸 이메일 구현하시느라 고생이 많으셨습니다 👑 다 구현된 내용을 이해하는데도 쉽지 않네요. 대부분 스타일과 관련된 코멘트이긴 한데 몇자 남겨보았습니다.


추가적으로 남겼었는데, 인텔리제이에서 누락되었네요..ㅠㅠ

  • 프론트 측에서 템플릿 수정으로 email, title 은 코드에서 삭제되도 될까요?

@@ -36,6 +37,7 @@ class MailForm(
private val subject: TextField = TextField("제목").apply { setWidthFull() }
private val body: TextArea = createBody()
private val mailTargets: MutableSet<MailTargetResponse> = mutableSetOf()
private val uploadFile: MutableMap<String, ByteArrayResource> = LinkedHashMap()
Copy link
Contributor

Choose a reason for hiding this comment

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

자바 컬렉션이 아닌 코틀린의 mutableMapOf()를 사용하는 건 어떨까요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

LinkedHashMap 을 한 단계 포장한게 MutableMap 로 사용되고 있는데 순서 유지가 필요하다고 명시를 해주고 싶어 해당 방식대로 사용하였습니다. 둘 다 코틀린 컬렉션입니다.

Copy link
Contributor

@knae11 knae11 Oct 20, 2021

Choose a reason for hiding this comment

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

@SinceKotlin("1.1") public actual typealias LinkedHashMap<K, V> = java.util.LinkedHashMap<K, V>
이렇게 타고 들어가져서 이것은 결국 자바 패키지로 가지는데 혹시 제가 잘 못 알고 있는 걸까요??

Copy link
Contributor

Choose a reason for hiding this comment

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

하지만 바딘에 있는 코드여서 상관 없을 것 같기도 해요. 혼란을 드렸다면 죄송합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

mutableMapOf() 를 들어가보시면 public inline fun <K, V> mutableMapOf(): MutableMap<K, V> = LinkedHashMap() 이와 같이 선언되어 있습니다. mutableMapOf 자체가 내부적으로 LinkedHashMap을 구현하는 방식입니다. 어색함을 느끼시는 것 같아 변경했습니다

Copy link
Contributor

@knae11 knae11 Oct 20, 2021

Choose a reason for hiding this comment

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

네 감사합니다. 하지만 저 부분에서 public inline fun <K, V> mutableMapOf(): MutableMap<K, V> = LinkedHashMap()이 부분에서 LinkedHashMap()을 타고 들어가면 코틀린 컬렉션이 나오기 때문에 다른 것이라고 생각했습니다. 혹시 잘못된 정보라면 알려주세요.


@Async
fun sendMailsByBCC(request: MailData, files: Map<String, ByteArrayResource>) {
request.recipients.plus(mailProperties.username)
Copy link
Contributor

Choose a reason for hiding this comment

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

request.recipients + mailProperties.username 를 사용해도 좋을 것 같아요. 오히려 plus를 사용하는게 읽기 좋다고 느껴지기도 해서 참고만 해주세요 :)

Copy link
Contributor

Choose a reason for hiding this comment

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

오~! 😃 저도 배럴말에 동의합니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

저는 이 부분은 plus 로 두고싶네요. 굳이 한 값을 올리는 데 recipients 라는 그룹을 명시해주기에는 어색하다고 느낍니다

Comment on lines 23 to 24
val inputStreamFiles =
files.associate { (it.originalFilename!! to ByteArrayResource(it.bytes)) }
Copy link
Contributor

@knae11 knae11 Oct 20, 2021

Choose a reason for hiding this comment

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

이 프로젝트에서 주로 = 뒤에 줄바꿈보단 인자를 기준으로 줄바꿈을 하더라고요.

        val inputStreamFiles = files.associate {
            (it.originalFilename!! to ByteArrayResource(IOUtils.toByteArray(it.inputStream)))
        }

이런 방식은 어떨까 의견 내봅니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

IOUtlis 가 사라지고 bytes로 처리했습니다

Copy link
Contributor

Choose a reason for hiding this comment

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

앗 죄송해요 지난 내용과 겹쳐졌나보네요ㅠㅠ 제 뜻은 줄바꿈을 하지 않으면 좋겠다 였습니다!ㅎㅎㅎ

Copy link
Contributor

@Rok93 Rok93 left a comment

Choose a reason for hiding this comment

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

(저는 이미 approve를 드렸던터라 의견 comment로 추가했습니다)
마갸 고생 많으셨습니다.
Data 클래스랑 상수쪽 코멘트 남겼는데, 확인해주시고 괜찮은 것 같다면 반영해주세요 🤗

@field:NotNull
var id: Long = 0L
) {

Copy link
Contributor

Choose a reason for hiding this comment

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

첫 줄 띄우지 않고 바로 쓰는걸로 알고있어요!

Suggested change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

변경했습니다

Comment on lines 27 to 31

var attachFiles: Map<String, ByteArrayResource> = emptyMap(),

@field:NotNull
var id: Long = 0L
Copy link
Contributor

Choose a reason for hiding this comment

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

TermSelectData에서는 필드 파라미터 배치를 이렇게 두고 있더라구요!
id에 NotNull Validation을 붙인 이유가 있을까요!? 다른 xxxData 클래스들은 id에 별다른 Validation을 안붙였더라구요 🤔

Suggested change
var attachFiles: Map<String, ByteArrayResource> = emptyMap(),
@field:NotNull
var id: Long = 0L
var attachFiles: Map<String, ByteArrayResource> = emptyMap(),
var id: Long = 0L

Copy link
Contributor Author

Choose a reason for hiding this comment

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

음... 제 파트에서 검증 로직을 붙인 건 아니라서 정확히는 모르겠지만 mail history 관련해서 사용하고 있을거에요

ex.printStackTrace()
}
}

private fun createContent(data: String): Content {
return Content(data).withCharset("UTF-8")
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return Content(data).withCharset("UTF-8")
return Content(data).withCharset(Charsets.UTF_8.name())

코틀린에서 제공하고있는 상수를 활용해보면 어떨까요? 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

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

변경했습니다


@Async
fun sendMailsByBCC(request: MailData, files: Map<String, ByteArrayResource>) {
request.recipients.plus(mailProperties.username)
Copy link
Contributor

Choose a reason for hiding this comment

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

오~! 😃 저도 배럴말에 동의합니다.

Copy link
Contributor

@NewWisdom NewWisdom left a comment

Choose a reason for hiding this comment

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

수고 많으셨습니다 !!!
낯선 부분이라 코드 이해하는것도 꽤 걸렸는데,
이걸 구현하신 마갸는... 진짜 수고 많으셨습니다 👏
확인하고 몇개 남겨보았는데 가볍게 참고만 부탁드려요~

Comment on lines 119 to 121
fun message(lambda: MultipartMimeMessageBuilder.() -> Unit): MultipartMimeMessageBuilder {
return MultipartMimeMessageBuilder().apply(lambda)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
fun message(lambda: MultipartMimeMessageBuilder.() -> Unit): MultipartMimeMessageBuilder {
return MultipartMimeMessageBuilder().apply(lambda)
}
fun message(lambda: MultipartMimeMessageBuilder.() -> Unit): MultipartMimeMessage {
return MultipartMimeMessageBuilder().apply(lambda).build()
}

와우 dsl 스타일 👏👏
혹시 이렇게 MultipartMimeMessage를 넘기는 것은 어떨까요 ???
그러면 해당 함수를 쓰는 곳에서 build를 생략할 수 있어 깔끔할 거같다는 생각이 드는데
그냥 가볍게 참고만 해주세요 ㅎㅎㅎㅎ

Copy link
Contributor

Choose a reason for hiding this comment

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

오 좋은 아이디어 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

build() 를 붙여주는게 빌더패턴을 썻다고 명시할 수 있어서 이렇게 구현했는데 확실히 dsl 스타일이니 굳이 필요하진 않아보이네요. 변경했습니다.

@@ -125,6 +124,7 @@ class MailForm(
override fun bindOrNull(): MailData? {
return bindDefaultOrNull()?.apply {
recipients = mailTargets.map { it.email }.toList()
attachFiles = uploadFile
Copy link
Contributor

Choose a reason for hiding this comment

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

MailForm의 uploadFile 변수명을 attachFiles로 통일해 해당 코드를 생략하는 방법은 혹시 어떨까요 ??

Copy link
Contributor Author

Choose a reason for hiding this comment

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

이 부분은 조금 명시해주고 싶은 부분입니다. uploadFile 을 이벤트 핸들링 할 때에는 uploadedFile 이라는 명칭을 쓰고(바딘에서) 메일로 보낼 때에는 attachFile 이라는 명칭을 씁니다.
"사용자가 업로드 한 파일" 과 "첨부 파일" 을 구별해주는게 더 낫지 않을까요?

Copy link
Contributor

Choose a reason for hiding this comment

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

메일을 보내기 위한 화면이므로 attachments도 괜찮다고 생각합니다. attachments를 사용하지 않는다면 uploadedFiles가 적절하겠네요.

Suggested change
attachFiles = uploadFile
attachments = uploadedFiles

Comment on lines 70 to 71
val fds: DataSource = ByteArrayDataSource(
fileData.byteArray,
Copy link
Contributor

Choose a reason for hiding this comment

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

지역변수는 타입 선언을 안했던 것 같은데,
헷갈릴까봐 타입 명시를 해준걸까요 ???

Copy link
Contributor Author

@MyaGya MyaGya Oct 20, 2021

Choose a reason for hiding this comment

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

네 맞습니다. 다혀성 개념을 지켜주었다고 보시면 될 것 같아요
해당 변수인 fds는 설계상 바이트 형태의 값인 ByteArrayDataSource 가 올 수도 있지만 로컬 파일을 이용하기 위한 FiledataSource 가 올 수도 있습니다. aws에서 attach 를 하기 위해서는 DataSource 형태이기만 하면 되서 혼동을 주지 않기 위해 부모 타입으로 명시했습니다.

Copy link
Contributor

@Sehwan-Jang Sehwan-Jang left a comment

Choose a reason for hiding this comment

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

진짜 코드를 살짝만 봐도 엄청난 난이도가 느껴지네요...!!
진짜 대단합니다 역시 💯💯💯
간단한 질문과 코멘트 남겼는데 실제로 동작은 잘 되니 approve 하겠습니다 ㅎㅎ

try {
client.sendRawEmail(rawEmailRequest)
} catch (ex: Exception) {
ex.printStackTrace()
Copy link
Contributor

Choose a reason for hiding this comment

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

print 대신 로그를 남기는 것은 어떤가요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

테스트 후 남아있는 코드네요. 제가 지금 로그 정책은 자세히 몰라서 우선은 이렇게 두겠습니다.

Copy link
Contributor

Choose a reason for hiding this comment

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

예외는 ExceptionHandler에서 잡아주고 있지 않나요?

Comment on lines 81 to 82
return MimetypesFileTypeMap().getContentType(fileName)
?: throw IllegalArgumentException("잘못된 확장자입니다.")
Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분은 굳이 줄바꿈이 필요하지 않을 것 같아요 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

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

변경했습니다

Comment on lines 104 to 106
var recipient: Recipient? = null,
var body: String = "",
var files: Map<String, ByteArrayResource>? = null
Copy link
Contributor

Choose a reason for hiding this comment

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

이 부분은 잘몰라서 질문드리는데 recipient와 files 만 nullable 한 타입으로 받는 이유가 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

음... 사실 두 부분은 코틀린 dsl을 쓰면서 만들었다면 null이 될 수 없습니다. 그러나 실제로 null이 없는데 저런식으로 넣는 건 좀 이상하게 보이긴 하네요. 해당 부분도 변경해 주었습니다.

@MyaGya
Copy link
Contributor Author

MyaGya commented Oct 20, 2021

코드리뷰 반영, 첨부파일 삭제시 정상적으로 삭제되어 메일 발송시 해당 부분을 제외하는 기능 추가했습니다

해당 PR이 길어졌으므로 처리한 내역들을 merge 후 정리하여 이슈로 옮기겠습니다.

interface MailSender {
fun send(toAddress: String, subject: String, body: String)

fun sendBcc(toAddresses: Array<String>, subject: String, body: String, files: Map<String, ByteArrayResource>)
Copy link
Contributor

Choose a reason for hiding this comment

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

Array 대신 List를 사용하는 건 어떨까요?
코틀린 개발자들이 Array를 남겨둔건 Array를 사용하는 자바 라이브러리와의 호환성 때문으로 알고 있어요.
저희가 코딩하는 부분에서는 List를 사용하는 편이 굉장히 많은 장점을 누릴 수 있을 거 같네요 :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

우선 array를 사용한 건 해당 코드 구현시에 AWS SES 키가 없어서 JavaSimpleMailSender 를 사용했는데 array로 해야하는 작업이 제법 많더라구요 마찬가지로 AWS 에서도 Array 를 사용해야 하는 부분이 제법 있어서 아예 Array로 사용하게 되었습니다.

Copy link
Contributor

Choose a reason for hiding this comment

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

SimpleMailSender를 제외하면 모두 List로 사용할 수 있습니다.

Suggested change
fun sendBcc(toAddresses: Array<String>, subject: String, body: String, files: Map<String, ByteArrayResource>)
fun sendBcc(toAddresses: List<String>, subject: String, body: String, attachments: Map<String, ByteArrayResource>)

try {
client.sendRawEmail(rawEmailRequest)
} catch (ex: Exception) {
ex.printStackTrace()
Copy link
Contributor

Choose a reason for hiding this comment

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

예외는 ExceptionHandler에서 잡아주고 있지 않나요?

Comment on lines 96 to 116
data class MultipartMimeMessageBuilder(
var session: Session = Session.getDefaultInstance(Properties()),
var applicationProperties: ApplicationProperties = ApplicationProperties(""),
var templateEngine: ISpringTemplateEngine = SpringTemplateEngine(),
var mimeMixedPart: MimeMultipart = MimeMultipart("mixed"),
var subject: String = "",
var userName: String = "",
var recipient: Recipient = Recipient(),
var body: String = "",
var files: Map<String, ByteArrayResource> = mutableMapOf()
) {
fun build(): MultipartMimeMessage {
return MultipartMimeMessage(session, mimeMixedPart, applicationProperties, templateEngine).apply {
setSubject(subject)
setFrom(userName)
setRecipient(recipient)
addBody(body)
addAttachment(files)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

얘기할만한 부분이 꽤 많은 거 같네요

  1. 개인적으로 코틀린에서 빌더는 큰 의미가 없다고 생각합니다. 자바에서 빌더가 주는 장점은 코틀린의 named argument나 기본값 지정 등으로 모두 커버가 가능하다고 생각해요.
  2. 코드를 보면 코틀린 DSL을 사용한 다른 라이브러리를 참고하지 않았나 싶습니다(맞나요?). 만약, DSL -> Builder -> Object 순으로 사용하고 있는 코드를 보셨다면 혹시 빌더가 자바코드인지 한 번 확인해보시는 것도 좋을거 같아요. 보통 자바 라이브러리에서 빌더로 만들어 둔 것들을 감싸서 DSL로 만드는 방식을 사용하더라구요. 그래서 제 생각엔 만약 기존 빌더가 없이 직접 구현할 생각이면 DSL -> Object의 빌더가 없는 구조가 더 괜찮다고 생각합니다.
  3. 빌더나 DSL을 따로 두려고 하면 Object가 가변일 필요가 있을까 싶어요. setXX, addXX는 빌더가 제공하고 Object는 불변으로 만드는게 서로 책임을 분명히 나눌 수 있는 방법 아닐까싶네요.
  4. subject, recipient 등 필수값에 빈 문자열 등을 기본값으로 넣는게 의미가 있을까요? 필수값이라면 기본값 없이 생성자를 통해 받고, 아니라면 addXX, setXX 등을 통해 만드는 건 어떨까요?
  5. 사실 이 빌더를 리뷰하는게 쉽지 않았는데, 아마 한군데서만 사용하고 있어서 그런 거 같아요. 적당한 기능을 제공하는지, 적당히 추상화가 됐는지는 사실 여러 군데서 사용해봐야 판단할 수 있는 거 같습니다. 그런 접근에서 보면 이 기능을 구현하기 위해 빌더나 DSL을 사용한 건 약간 오버스펙이 아닌가 싶기도 합니다 :)
  6. 하지만 DSL에 관심도 많으신거 같고, 잘 다루시는 거 같으니 apply에서 DSL을 적용할만한 부분을 찾자면 문서화 코드에 DSL을 적용해 볼 수 있을 거 같아요. https://github.com/spring-projects/spring-restdocs/issues/677. mockmvc는 kotlin dsl을 지원하지만 restdocs는 아직 지원하지 않거든요(apply에서도 문서화 테스트 코드는 기존 자바 스타일의 mockmvc 코드를 사용하고, 다른 컨트롤러 테스트 코드는 dsl 스타일의 mockmvc를 사용합니다). 시간이 남고 열정이 넘친다면 한 번 도전해볼만 한 거 같네요 ㅎㅎ

리뷰가 약간 의식의 흐름대로 나왔는데, 이상한 부분은 언제든지 답글 주세요. 저도 코드 보면서 공부가 많이 됐네요 :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

네. 좋은 의견 감사합니다.

  1. 사용하는 부분이 큰 의미가 없다는 의견에는 동의합니다. 자바와 달리 코틀린은 기본값을 지정 할 수 있으니까요. 다만, 사용하는 측에서 조금 더 가독성을 높일 수 있어 사용했습니다
  2. 조금 더 공부해보고 나은 방향을 생각해보겠습니다
  3. 그렇네요. builder에서 사용하는 메소드도 private 해져야 하고 멤버 변수도 모두 val으로 처리가 가능하겠군요
  4. 3번과 같은 의견입니다. val로 처리하고 기본값을 모두 없애는게 나아 보입니다. 예외 처리시 좀 더 확실하게 확인할 수 있도록 구현한 부분인데 좋지 않은 방법인 것 같아요
  5. 오버스펙이라는 의견에는 동의합니다! 제가 한번 만들어놓은 BCC를 차후에 건드릴 것 같지 않아서 차라리 내부의 값을 자유롭게 바꿀 수 있는 형태로 외부에 제공하려는 생각에서 DSL 을 사용하게 되었네요
  6. 좋은 레퍼런스 감사합니다

도움이 많이 되었어요. 차후 코드에 반영하도록 하겠습니다!

}

private fun createRawMessage(message: MimeMessage): RawMessage {
val outputStream = ByteArrayOutputStream()
Copy link
Contributor

Choose a reason for hiding this comment

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

Closable.use()로 닫아주는게 좋을 거 같네요

Copy link
Contributor Author

Choose a reason for hiding this comment

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

제가 자원 사용하는 부분 찾아 볼 땐 안보이더니 찾아주셨네요.
꼼꼼한 리뷰 감사합니다

@woowahan-neo
Copy link

리뷰를 반영하다보니 이곳저곳 건드리게 돼서 중심적으로 구현한 부분과 봐줬으면 하는 부분을 따로 정리했습니다~
리뷰 감사합니다 😄

  • 초당 요청 제한을 위해 Guava의 RateLimiter를 사용할까 하다가 간단한 기능이라 직접 구현 하였습니다.
  • RequestPerSecondLimiter의 구현은 메일 발송에 의존적이지 않기 때문에 해당 기능이 필요할 경우 재사용이 가능할 것 같은데요.
    전체적으로 프로젝트를 봤는데 요청을 제한해야 하는 부분이 보이지 않아서 mail 패키지에 위치시켰습니다.
  • 초당 요청 제한을 config로 분리할 수도 있지만 변경이 잦지 않을 것 같고, 값이 하나밖에 없어서 일단 분리 안하고 하드코딩 해뒀습니다.
  • 제한을 넘었을 때 예외처리 메시지를 고민하다 일단 예상보다 많은 요청이 왔어요. 잠시 후 다시 요청해주세요.로 작성 해뒀습니다.
    예외처리 메시지 추천 부탁드립니다~
  • 초당 요청 제한을 넘을 경우 HttpStatus.TOO_MANY_REQUESTS와 함께 HttpHeaders.RETRY_AFTER 헤더를 통해 재요청 정보를 주고 있습니다.
    지금은 1초로 보내주고 있는데 의견 있으시면 말씀 부탁드립니다~
  • 위 HttpStatus.TOO_MANY_REQUESTS를 발생해도 Async로 동작하고 있기 때문에 실제 클라이언트에 메시지가 전달이 불가능한 상황입니다.
    Async를 사용하며 예외처리 메시지를 전달하기 위해선 추가적인 구현이 필요할 것 같은데, 해당 이슈의 목적과 다르고 작업이 크기 때문에 필요하다면 별도 이슈로 진행해야 할 것 같습니다.
  • 히스토리를 DB에 저장 후 이벤트를 발생시켜 메일을 발송하는 로직이 더 자연스럽다고 생각했지만, 히스토리에 대한 중요도가 높지 않다고 판단되어 메일 발송 후 DB에 저장하는 방식으로 처리하였습니다.
    메시지 발송 후 히스토리를 남기는 작업의 중요도가 높다면 전체적으로 재설계가 필요할 것 같은데요.
    재설계 시 큰 작업이 될 수 있어 필요하다면 해당 작업도 별도 이슈에서 처리해야 할 것 같습니다!

Comment on lines 102 to 114
for (targetMailsPart in recipients.chunked(MAIL_SENDING_UNIT)) {
mailSender.sendBcc(targetMailsPart, request.subject, body, files)
}

mailHistoryRepository.save(
MailHistory(
request.subject,
request.body,
request.sender,
request.recipients,
request.sentTime
)
)
Copy link
Contributor

Choose a reason for hiding this comment

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

쓰로틀링 때문에 메일 전송 일부만 실패할 수도 있을 거 같아요. 이 경우에 히스토리 저장 정책은 어떻게 될까요?

Copy link
Contributor

Choose a reason for hiding this comment

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

염두에 두고 있는 구현 방식이 있을까요?

import javax.mail.internet.MimeBodyPart
import javax.mail.internet.MimeMessage
import javax.mail.internet.MimeMultipart
import javax.mail.util.ByteArrayDataSource

@Component
class AwsMailSender(
Copy link
Contributor

Choose a reason for hiding this comment

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

현재 코드에서 일일 전송 제한은 보이지 않는 것 같은데 제한이 큰 의미가 없다고 판단해서일까요?

Comment on lines 32 to 37
private fun sync(requestTime: Long) {
while (available.isNotEmpty()) {
val elapsedTime = requestTime - available.peek()
if (elapsedTime.milliseconds < 1.seconds) {
break
}
Copy link
Contributor

Choose a reason for hiding this comment

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

지금 구현에서 문제되는 부분은 아니지만 혹시라도 나중에 시간 순서대로 queue에 쌓이지 않으면 RateLimiter가 잘못 동작할 수도 있을 거 같아요.
elapsedTime이 음수라면 예외처리하는 로직을 추가해도 좋을 것 같습니다 😄

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor

@ddaaac ddaaac left a comment

Choose a reason for hiding this comment

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

고생하셨습니다!

Copy link
Contributor

@SuyeonChoi SuyeonChoi left a comment

Choose a reason for hiding this comment

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

늦은시간까지 페어프로그래밍 수고하셨습니다!!
ExperimentalCoroutinesApi로 코루틴으로 예외를 잡는 부분,,, 잘 보고 배우고 갑니당👍

import kotlinx.coroutines.test.runTest
import kotlin.time.Duration.Companion.milliseconds

private val Int.ms: Long get() = milliseconds.inWholeMilliseconds
Copy link
Contributor

Choose a reason for hiding this comment

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

확장함수!👍

Copy link

@woowahan-neo woowahan-neo left a comment

Choose a reason for hiding this comment

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

고생 많으셨습니다 👍

@woowahan-pjs woowahan-pjs changed the title feat(mail): mail send api feat(mail): implement sending mail Jul 26, 2022
Copy link
Contributor

@woowahan-pjs woowahan-pjs left a comment

Choose a reason for hiding this comment

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

테스트해 보니 잘 작동하네요. 고생하셨습니다. 👍

@woowahan-pjs woowahan-pjs merged commit 01a9fd8 into develop Jul 26, 2022
@woowahan-pjs woowahan-pjs deleted the feature/mail-send-api branch July 26, 2022 02:04
@MyaGya
Copy link
Contributor Author

MyaGya commented Jul 26, 2022

고생하셨습니다!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.