From 536b0b2cfb1588276eb3409ab87c1748e8d2a6e6 Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Tue, 19 Oct 2021 19:13:16 +0900 Subject: [PATCH 01/31] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apply/application/mail/MailSender.kt | 4 + .../apply/application/mail/MailService.kt | 15 ++- .../kotlin/apply/infra/mail/AwsMailSender.kt | 24 +++++ .../apply/infra/mail/MultipartMimeMessage.kt | 100 ++++++++++++++++++ .../apply/infra/mail/SimpleMailSender.kt | 20 ++++ .../kotlin/apply/ui/api/MailRestController.kt | 29 +++++ 6 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt create mode 100644 src/main/kotlin/apply/ui/api/MailRestController.kt diff --git a/src/main/kotlin/apply/application/mail/MailSender.kt b/src/main/kotlin/apply/application/mail/MailSender.kt index 886645543..65dd24a67 100644 --- a/src/main/kotlin/apply/application/mail/MailSender.kt +++ b/src/main/kotlin/apply/application/mail/MailSender.kt @@ -1,5 +1,9 @@ package apply.application.mail +import org.springframework.core.io.ByteArrayResource + interface MailSender { fun send(toAddress: String, subject: String, body: String) + + fun sendBcc(toAddresses: Array, subject: String, body: String, files: Map) } diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index 8a676c55c..780438ba7 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -7,6 +7,8 @@ import apply.domain.recruitment.getById import apply.domain.user.PasswordResetEvent import apply.domain.user.UserRepository import apply.domain.user.getById +import org.springframework.boot.autoconfigure.mail.MailProperties +import org.springframework.core.io.ByteArrayResource import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service import org.springframework.transaction.event.TransactionalEventListener @@ -19,8 +21,11 @@ class MailService( private val recruitmentRepository: RecruitmentRepository, private val applicationProperties: ApplicationProperties, private val templateEngine: ISpringTemplateEngine, - private val mailSender: MailSender + private val mailSender: MailSender, + private val mailProperties: MailProperties ) { + val MAIL_SENDING_UNIT = 50 + @Async @TransactionalEventListener fun sendPasswordResetMail(event: PasswordResetEvent) { @@ -78,4 +83,12 @@ class MailService( templateEngine.process("mail/email-authentication.html", context) ) } + + @Async + fun sendMailsByBCC(request: MailData, files: Map) { + request.recipients.plus(mailProperties.username) + for (targetMailsPart in request.recipients.chunked(MAIL_SENDING_UNIT)) { + mailSender.sendBcc(targetMailsPart.toTypedArray(), request.subject, request.body, files) + } + } } diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 3c844ed1a..f113e8779 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -12,7 +12,9 @@ import com.amazonaws.services.simpleemail.model.Destination import com.amazonaws.services.simpleemail.model.Message import com.amazonaws.services.simpleemail.model.SendEmailRequest import org.springframework.boot.autoconfigure.mail.MailProperties +import org.springframework.core.io.ByteArrayResource import org.springframework.stereotype.Component +import javax.mail.Message as javaxMessage @Component class AwsMailSender( @@ -44,6 +46,28 @@ class AwsMailSender( client.sendEmail(request) } + override fun sendBcc( + toAddresses: Array, + subject: String, + body: String, + files: Map + ) { + val multipartMimeMessage = message { + this.subject = subject + this.userName = mailProperties.username + this.recipient = Recipient(javaxMessage.RecipientType.BCC, toAddresses) + this.body = body + this.files = files + }.build() + + val rawEmailRequest = multipartMimeMessage.getRawEmailRequest() + try { + client.sendRawEmail(rawEmailRequest) + } catch (ex: Exception) { + ex.printStackTrace() + } + } + private fun createContent(data: String): Content { return Content(data).withCharset("UTF-8") } diff --git a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt new file mode 100644 index 000000000..99d73f64f --- /dev/null +++ b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt @@ -0,0 +1,100 @@ +package apply.infra.mail + +import com.amazonaws.services.simpleemail.model.RawMessage +import com.amazonaws.services.simpleemail.model.SendRawEmailRequest +import org.springframework.core.io.ByteArrayResource +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.util.Properties +import javax.activation.DataHandler +import javax.activation.DataSource +import javax.activation.MimetypesFileTypeMap +import javax.mail.Message +import javax.mail.Session +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeBodyPart +import javax.mail.internet.MimeMessage +import javax.mail.internet.MimeMultipart +import javax.mail.util.ByteArrayDataSource + +data class Recipient(val recipientType: Message.RecipientType, val toAddresses: Array) + +class MultipartMimeMessage(session: Session, private val mimeMixedPart: MimeMultipart) { + val message: MimeMessage = MimeMessage(session) + + fun setSubject(subject: String) { + message.setSubject(subject, "UTF-8") + } + + fun setFrom(userName: String) { + message.setFrom(InternetAddress(userName)) + } + + fun setRecipient(recipient: Recipient) { + message.setRecipients(recipient.recipientType, recipient.toAddresses.map { InternetAddress(it) }.toTypedArray()) + } + + fun addBody(body: String) { + val messageBody = MimeMultipart("alternative") + val wrap = MimeBodyPart() + val textPart = MimeBodyPart() + textPart.setContent(body, "text/plain; charset=UTF-8") + messageBody.addBodyPart(textPart) + wrap.setContent(messageBody) + message.setContent(mimeMixedPart) + mimeMixedPart.addBodyPart(wrap) + } + + fun addAttachment(files: Map) { + for ((fileName, fileData) in files) { + val att = MimeBodyPart() + val fds: DataSource = ByteArrayDataSource( + fileData.byteArray, + findMimeContentTypeByFileName(fileName) + ) + att.dataHandler = DataHandler(fds) + att.fileName = fileName + mimeMixedPart.addBodyPart(att) + } + } + + private fun findMimeContentTypeByFileName(fileName: String): String { + return MimetypesFileTypeMap().getContentType(fileName) + ?: throw IllegalArgumentException("잘못된 확장자입니다.") + } + + fun getRawEmailRequest(): SendRawEmailRequest { + val rawMessage = createRawMessage(message) + return SendRawEmailRequest(rawMessage) + } + + private fun createRawMessage(message: MimeMessage): RawMessage { + val outputStream = ByteArrayOutputStream() + message.writeTo(outputStream) + return RawMessage(ByteBuffer.wrap(outputStream.toByteArray())) + } +} + +data class MultipartMimeMessageBuilder( + var session: Session = Session.getDefaultInstance(Properties()), + var mimeMixedPart: MimeMultipart = MimeMultipart("mixed"), + var subject: String = "", + var userName: String = "", + var recipient: Recipient? = null, + var body: String = "", + var files: Map? = null +) { + fun build(): MultipartMimeMessage { + return MultipartMimeMessage(session, mimeMixedPart).apply { + setSubject(subject) + setFrom(userName) + setRecipient(recipient!!) + addBody(body) + addAttachment(files!!) + } + } +} + +fun message(lambda: MultipartMimeMessageBuilder.() -> Unit): MultipartMimeMessageBuilder { + return MultipartMimeMessageBuilder().apply(lambda) +} diff --git a/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt b/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt index 808542b2d..bbbe2f89f 100644 --- a/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt @@ -2,6 +2,7 @@ package apply.infra.mail import apply.application.mail.MailSender import org.springframework.boot.autoconfigure.mail.MailProperties +import org.springframework.core.io.ByteArrayResource import org.springframework.mail.javamail.JavaMailSender import org.springframework.mail.javamail.MimeMessageHelper @@ -19,4 +20,23 @@ class SimpleMailSender( } mailSender.send(message) } + + override fun sendBcc( + toAddresses: Array, + subject: String, + body: String, + files: Map + ) { + val message = mailSender.createMimeMessage() + val mimeMessageHelper = MimeMessageHelper(message, true).apply { + setFrom(mailProperties.username) + setBcc(toAddresses) + setSubject(subject) + setText(body, true) + } + files.forEach { (fileName, data) -> + mimeMessageHelper.addAttachment(fileName, data) + } + mailSender.send(message) + } } diff --git a/src/main/kotlin/apply/ui/api/MailRestController.kt b/src/main/kotlin/apply/ui/api/MailRestController.kt new file mode 100644 index 000000000..2053de3e4 --- /dev/null +++ b/src/main/kotlin/apply/ui/api/MailRestController.kt @@ -0,0 +1,29 @@ +package apply.ui.api + +import apply.application.mail.MailData +import apply.application.mail.MailService +import org.apache.commons.io.IOUtils +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile + +@RestController +@RequestMapping("/api/mail") +class MailRestController( + private val mailService: MailService +) { + @PostMapping + fun sendMail( + @RequestPart request: MailData, + @RequestPart files: Array, + ): ResponseEntity { + val inputStreamFiles = + files.associate { (it.originalFilename!! to ByteArrayResource(IOUtils.toByteArray(it.inputStream))) } + mailService.sendMailsByBCC(request, inputStreamFiles) + return ResponseEntity.noContent().build() + } +} From b49560d1baff9cfd939f9f46ee635199f1cbd192 Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Tue, 19 Oct 2021 20:32:46 +0900 Subject: [PATCH 02/31] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=BC=20=ED=8F=BC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/application/mail/MailData.kt | 6 +++++- src/main/kotlin/apply/ui/admin/mail/MailForm.kt | 14 +++++++------- .../kotlin/apply/ui/admin/mail/MailsFormView.kt | 4 +++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailData.kt b/src/main/kotlin/apply/application/mail/MailData.kt index 63227dc3f..8b3f67197 100644 --- a/src/main/kotlin/apply/application/mail/MailData.kt +++ b/src/main/kotlin/apply/application/mail/MailData.kt @@ -1,6 +1,7 @@ package apply.application.mail import apply.domain.mail.MailHistory +import org.springframework.core.io.ByteArrayResource import java.time.LocalDateTime import javax.validation.constraints.NotEmpty import javax.validation.constraints.NotNull @@ -24,15 +25,18 @@ data class MailData( @field:NotNull var sentTime: LocalDateTime = LocalDateTime.now(), + var attachFiles: Map = emptyMap(), + @field:NotNull var id: Long = 0L ) { + constructor(mailHistory: MailHistory) : this( mailHistory.subject, mailHistory.body, mailHistory.sender, mailHistory.recipients, mailHistory.sentTime, - mailHistory.id + id = mailHistory.id ) } diff --git a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt index 4054b64fe..2a34e4dad 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt @@ -18,6 +18,7 @@ import com.vaadin.flow.component.upload.receivers.MultiFileMemoryBuffer import com.vaadin.flow.data.renderer.ComponentRenderer import com.vaadin.flow.data.renderer.Renderer import org.springframework.boot.autoconfigure.mail.MailProperties +import org.springframework.core.io.ByteArrayResource import support.views.BindingFormLayout import support.views.NO_NAME import support.views.addSortableColumn @@ -36,6 +37,7 @@ class MailForm( private val subject: TextField = TextField("제목").apply { setWidthFull() } private val body: TextArea = createBody() private val mailTargets: MutableSet = mutableSetOf() + private val uploadFile: MutableMap = LinkedHashMap() private val mailTargetsGrid: Grid = createMailTargetsGrid(mailTargets) private val recipientFilter: Component = createRecipientFilter() private val fileUpload: Component = createFileUpload() @@ -104,13 +106,10 @@ class MailForm( private fun createFileUpload(): Upload { return createUpload("파일첨부", MultiFileMemoryBuffer()) { - // TODO: 추후 업로드 된 파일을 메일로 첨부하는 로직이 추가되어야 함 - // (uploadFiles 같은 필드를 두고 mail을 보내는 기능에 포함시키면 될 것 같음) - // it.files.forEach { fileName -> - // val fileData = it.getFileData(fileName) - // val inputStream = it.getInputStream(fileName) - // val readBytes = inputStream.readBytes() - // } + it.files.forEach { fileName -> + val byteArray = it.getInputStream(fileName).readBytes() + uploadFile[fileName] = ByteArrayResource(byteArray) + } } } @@ -125,6 +124,7 @@ class MailForm( override fun bindOrNull(): MailData? { return bindDefaultOrNull()?.apply { recipients = mailTargets.map { it.email }.toList() + attachFiles = uploadFile } } diff --git a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt index b4f71ad2b..4322c3432 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt @@ -5,6 +5,7 @@ import apply.application.MailHistoryService import apply.application.MailTargetService import apply.application.RecruitmentService import apply.application.UserService +import apply.application.mail.MailService import apply.ui.admin.BaseLayout import com.vaadin.flow.component.Component import com.vaadin.flow.component.UI @@ -30,6 +31,7 @@ class MailsFormView( evaluationService: EvaluationService, mailTargetService: MailTargetService, private val mailHistoryService: MailHistoryService, + private val mailService: MailService, mailProperties: MailProperties ) : VerticalLayout(), HasUrlParameter { private val mailForm: MailForm = MailForm( @@ -65,7 +67,7 @@ class MailsFormView( return createPrimaryButton("보내기") { mailForm.bindOrNull()?.let { mailHistoryService.save(it) - // TODO: emailService.메일전송(it, uploadFile) + mailService.sendMailsByBCC(it, it.attachFiles) UI.getCurrent().navigate(MailsView::class.java) } } From ad7e12a55615d87287512e8e34f53080880cf809 Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Tue, 19 Oct 2021 20:39:25 +0900 Subject: [PATCH 03/31] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=BC=20=ED=8F=BC=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/kotlin/apply/MailHistoryFixtures.kt.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/kotlin/apply/MailHistoryFixtures.kt.kt b/src/test/kotlin/apply/MailHistoryFixtures.kt.kt index d58835ab8..fa828d416 100644 --- a/src/test/kotlin/apply/MailHistoryFixtures.kt.kt +++ b/src/test/kotlin/apply/MailHistoryFixtures.kt.kt @@ -29,5 +29,5 @@ fun createMailData( sentTime: LocalDateTime = SENT_TIME, id: Long = 0L ): MailData { - return MailData(subject, body, sender, recipients, sentTime, id) + return MailData(subject, body, sender, recipients, sentTime, id = id) } From 1ed932fb16eb8cc1f1fade0e19e408aad140c1d5 Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Wed, 20 Oct 2021 01:40:27 +0900 Subject: [PATCH 04/31] =?UTF-8?q?refactor:=20MultipartFile=20Byte=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/ui/api/MailRestController.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/apply/ui/api/MailRestController.kt b/src/main/kotlin/apply/ui/api/MailRestController.kt index 2053de3e4..3b3366035 100644 --- a/src/main/kotlin/apply/ui/api/MailRestController.kt +++ b/src/main/kotlin/apply/ui/api/MailRestController.kt @@ -2,7 +2,6 @@ package apply.ui.api import apply.application.mail.MailData import apply.application.mail.MailService -import org.apache.commons.io.IOUtils import org.springframework.core.io.ByteArrayResource import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -22,7 +21,7 @@ class MailRestController( @RequestPart files: Array, ): ResponseEntity { val inputStreamFiles = - files.associate { (it.originalFilename!! to ByteArrayResource(IOUtils.toByteArray(it.inputStream))) } + files.associate { (it.originalFilename!! to ByteArrayResource(it.bytes)) } mailService.sendMailsByBCC(request, inputStreamFiles) return ResponseEntity.noContent().build() } From f002999647e87370f074bfaaac341396c5abbe9b Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Wed, 20 Oct 2021 20:56:03 +0900 Subject: [PATCH 05/31] =?UTF-8?q?feat:=20html=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=EC=9D=91=ED=95=98=EA=B3=A0=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=EC=9D=84=20=EC=9D=B4=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EC=A0=84=EC=86=A1=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/apply/infra/mail/AwsMailSender.kt | 8 +++++ .../apply/infra/mail/MultipartMimeMessage.kt | 31 ++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index f113e8779..24464f4e9 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -1,5 +1,6 @@ package apply.infra.mail +import apply.application.ApplicationProperties import apply.application.mail.MailSender import com.amazonaws.auth.AWSStaticCredentialsProvider import com.amazonaws.auth.BasicAWSCredentials @@ -14,11 +15,14 @@ import com.amazonaws.services.simpleemail.model.SendEmailRequest import org.springframework.boot.autoconfigure.mail.MailProperties import org.springframework.core.io.ByteArrayResource import org.springframework.stereotype.Component +import org.thymeleaf.spring5.ISpringTemplateEngine import javax.mail.Message as javaxMessage @Component class AwsMailSender( private val mailProperties: MailProperties, + private val applicationProperties: ApplicationProperties, + private val templateEngine: ISpringTemplateEngine, awsProperties: AwsProperties ) : MailSender { private val client: AmazonSimpleEmailService = AmazonSimpleEmailServiceClientBuilder @@ -52,7 +56,11 @@ class AwsMailSender( body: String, files: Map ) { + val applicationProperties = applicationProperties + val templateEngine = templateEngine val multipartMimeMessage = message { + this.applicationProperties = applicationProperties + this.templateEngine = templateEngine this.subject = subject this.userName = mailProperties.username this.recipient = Recipient(javaxMessage.RecipientType.BCC, toAddresses) diff --git a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt index 99d73f64f..759492660 100644 --- a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt +++ b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt @@ -1,8 +1,12 @@ package apply.infra.mail +import apply.application.ApplicationProperties import com.amazonaws.services.simpleemail.model.RawMessage import com.amazonaws.services.simpleemail.model.SendRawEmailRequest import org.springframework.core.io.ByteArrayResource +import org.thymeleaf.context.Context +import org.thymeleaf.spring5.ISpringTemplateEngine +import org.thymeleaf.spring5.SpringTemplateEngine import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.util.Properties @@ -19,7 +23,12 @@ import javax.mail.util.ByteArrayDataSource data class Recipient(val recipientType: Message.RecipientType, val toAddresses: Array) -class MultipartMimeMessage(session: Session, private val mimeMixedPart: MimeMultipart) { +class MultipartMimeMessage( + session: Session, + private val mimeMixedPart: MimeMultipart, + private val applicationProperties: ApplicationProperties, + private val templateEngine: ISpringTemplateEngine +) { val message: MimeMessage = MimeMessage(session) fun setSubject(subject: String) { @@ -37,9 +46,19 @@ class MultipartMimeMessage(session: Session, private val mimeMixedPart: MimeMult fun addBody(body: String) { val messageBody = MimeMultipart("alternative") val wrap = MimeBodyPart() - val textPart = MimeBodyPart() - textPart.setContent(body, "text/plain; charset=UTF-8") - messageBody.addBodyPart(textPart) + val context = Context().apply { + setVariables( + mapOf( + "email" to "email", + "title" to "title", + "content" to body, + "url" to applicationProperties.url + ) + ) + } + val htmlPart = MimeBodyPart() + htmlPart.setContent(templateEngine.process("mail/common", context), "text/html; charset=UTF-8") + messageBody.addBodyPart(htmlPart) wrap.setContent(messageBody) message.setContent(mimeMixedPart) mimeMixedPart.addBodyPart(wrap) @@ -77,6 +96,8 @@ class MultipartMimeMessage(session: Session, private val mimeMixedPart: MimeMult 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 = "", @@ -85,7 +106,7 @@ data class MultipartMimeMessageBuilder( var files: Map? = null ) { fun build(): MultipartMimeMessage { - return MultipartMimeMessage(session, mimeMixedPart).apply { + return MultipartMimeMessage(session, mimeMixedPart, applicationProperties, templateEngine).apply { setSubject(subject) setFrom(userName) setRecipient(recipient!!) From 8cd0a6c5aafeb962900dd1e82e22750fae0b9c2a Mon Sep 17 00:00:00 2001 From: SunYoungKwon Date: Wed, 20 Oct 2021 21:33:23 +0900 Subject: [PATCH 06/31] =?UTF-8?q?feat:=20common=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20title=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/templates/mail/common.html | 28 ++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/main/resources/templates/mail/common.html b/src/main/resources/templates/mail/common.html index 952ad610c..759ac3eef 100644 --- a/src/main/resources/templates/mail/common.html +++ b/src/main/resources/templates/mail/common.html @@ -43,37 +43,27 @@ border="0" cellpadding="0" cellspacing="0" - style="color: #333333; padding: 0; font-size: 16px" + style=" + color: #333333; + padding: 0; + font-size: 16px; + margin-left: auto; + margin-right: auto; + " > - - - - - - + From 6327ac5db35e4e73f5c465453a65d4fd1dab4b81 Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Thu, 21 Oct 2021 03:04:09 +0900 Subject: [PATCH 07/31] =?UTF-8?q?feat:=20=EC=B2=A8=EB=B6=80=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C=EC=8B=9C=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/ui/admin/mail/MailForm.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt index 2a34e4dad..a632677c6 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt @@ -17,6 +17,7 @@ import com.vaadin.flow.component.upload.Upload import com.vaadin.flow.component.upload.receivers.MultiFileMemoryBuffer import com.vaadin.flow.data.renderer.ComponentRenderer import com.vaadin.flow.data.renderer.Renderer +import elemental.json.JsonObject import org.springframework.boot.autoconfigure.mail.MailProperties import org.springframework.core.io.ByteArrayResource import support.views.BindingFormLayout @@ -40,7 +41,7 @@ class MailForm( private val uploadFile: MutableMap = LinkedHashMap() private val mailTargetsGrid: Grid = createMailTargetsGrid(mailTargets) private val recipientFilter: Component = createRecipientFilter() - private val fileUpload: Component = createFileUpload() + private val fileUpload: Upload = createFileUpload() init { add(subject, createSender(), recipientFilter, mailTargetsGrid, body, fileUpload) @@ -105,12 +106,17 @@ class MailForm( } private fun createFileUpload(): Upload { - return createUpload("파일첨부", MultiFileMemoryBuffer()) { + val upload = createUpload("파일첨부", MultiFileMemoryBuffer()) { it.files.forEach { fileName -> val byteArray = it.getInputStream(fileName).readBytes() uploadFile[fileName] = ByteArrayResource(byteArray) } } + upload.element.addEventListener("file-remove") { event -> + val eventData: JsonObject = event.eventData + uploadFile.remove(eventData.getString("event.detail.file.name")) + }.addEventData("event.detail.file.name") + return upload } private fun createRemoveButton(): Renderer { From b174c4262bc7d0e5795497b21146781e71d75df4 Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Thu, 21 Oct 2021 03:34:22 +0900 Subject: [PATCH 08/31] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/apply/application/mail/MailData.kt | 1 - .../kotlin/apply/infra/mail/AwsMailSender.kt | 4 ++-- .../apply/infra/mail/MultipartMimeMessage.kt | 21 ++++++++++--------- .../kotlin/apply/ui/admin/mail/MailForm.kt | 4 +--- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailData.kt b/src/main/kotlin/apply/application/mail/MailData.kt index 8b3f67197..48daa0433 100644 --- a/src/main/kotlin/apply/application/mail/MailData.kt +++ b/src/main/kotlin/apply/application/mail/MailData.kt @@ -30,7 +30,6 @@ data class MailData( @field:NotNull var id: Long = 0L ) { - constructor(mailHistory: MailHistory) : this( mailHistory.subject, mailHistory.body, diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 24464f4e9..008cae5ab 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -66,7 +66,7 @@ class AwsMailSender( this.recipient = Recipient(javaxMessage.RecipientType.BCC, toAddresses) this.body = body this.files = files - }.build() + } val rawEmailRequest = multipartMimeMessage.getRawEmailRequest() try { @@ -77,6 +77,6 @@ class AwsMailSender( } private fun createContent(data: String): Content { - return Content(data).withCharset("UTF-8") + return Content(data).withCharset(Charsets.UTF_8.name()) } } diff --git a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt index 759492660..caac2c0d6 100644 --- a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt +++ b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt @@ -21,7 +21,9 @@ import javax.mail.internet.MimeMessage import javax.mail.internet.MimeMultipart import javax.mail.util.ByteArrayDataSource -data class Recipient(val recipientType: Message.RecipientType, val toAddresses: Array) +data class Recipient(val recipientType: Message.RecipientType, val toAddresses: Array) { + constructor() : this(Message.RecipientType.BCC, arrayOf()) +} class MultipartMimeMessage( session: Session, @@ -32,7 +34,7 @@ class MultipartMimeMessage( val message: MimeMessage = MimeMessage(session) fun setSubject(subject: String) { - message.setSubject(subject, "UTF-8") + message.setSubject(subject, Charsets.UTF_8.name()) } fun setFrom(userName: String) { @@ -78,8 +80,7 @@ class MultipartMimeMessage( } private fun findMimeContentTypeByFileName(fileName: String): String { - return MimetypesFileTypeMap().getContentType(fileName) - ?: throw IllegalArgumentException("잘못된 확장자입니다.") + return MimetypesFileTypeMap().getContentType(fileName) ?: throw IllegalArgumentException("잘못된 확장자입니다.") } fun getRawEmailRequest(): SendRawEmailRequest { @@ -101,21 +102,21 @@ data class MultipartMimeMessageBuilder( var mimeMixedPart: MimeMultipart = MimeMultipart("mixed"), var subject: String = "", var userName: String = "", - var recipient: Recipient? = null, + var recipient: Recipient = Recipient(), var body: String = "", - var files: Map? = null + var files: Map = mutableMapOf() ) { fun build(): MultipartMimeMessage { return MultipartMimeMessage(session, mimeMixedPart, applicationProperties, templateEngine).apply { setSubject(subject) setFrom(userName) - setRecipient(recipient!!) + setRecipient(recipient) addBody(body) - addAttachment(files!!) + addAttachment(files) } } } -fun message(lambda: MultipartMimeMessageBuilder.() -> Unit): MultipartMimeMessageBuilder { - return MultipartMimeMessageBuilder().apply(lambda) +fun message(lambda: MultipartMimeMessageBuilder.() -> Unit): MultipartMimeMessage { + return MultipartMimeMessageBuilder().apply(lambda).build() } diff --git a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt index a632677c6..7983af1d0 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt @@ -38,18 +38,16 @@ class MailForm( private val subject: TextField = TextField("제목").apply { setWidthFull() } private val body: TextArea = createBody() private val mailTargets: MutableSet = mutableSetOf() - private val uploadFile: MutableMap = LinkedHashMap() + private val uploadFile: MutableMap = mutableMapOf() private val mailTargetsGrid: Grid = createMailTargetsGrid(mailTargets) private val recipientFilter: Component = createRecipientFilter() private val fileUpload: Upload = createFileUpload() - init { add(subject, createSender(), recipientFilter, mailTargetsGrid, body, fileUpload) setResponsiveSteps(ResponsiveStep("0", 1)) drawRequired() refreshGridFooter() } - private fun createSender(): Component { return TextField("보낸사람").apply { value = mailProperties.username From 4a6913f05e550cc8a4b70f304a7257eacac94813 Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Thu, 21 Oct 2021 03:39:02 +0900 Subject: [PATCH 09/31] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=9D=B4=EB=A9=94=EC=9D=BC,?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=ED=8B=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt index caac2c0d6..3a93f9a19 100644 --- a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt +++ b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt @@ -51,8 +51,6 @@ class MultipartMimeMessage( val context = Context().apply { setVariables( mapOf( - "email" to "email", - "title" to "title", "content" to body, "url" to applicationProperties.url ) From 41469a170f274f178146dbad072ec37acab5a80c Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Thu, 21 Oct 2021 04:29:44 +0900 Subject: [PATCH 10/31] =?UTF-8?q?refactor:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/ui/api/MailRestController.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/apply/ui/api/MailRestController.kt b/src/main/kotlin/apply/ui/api/MailRestController.kt index 3b3366035..5e3559e52 100644 --- a/src/main/kotlin/apply/ui/api/MailRestController.kt +++ b/src/main/kotlin/apply/ui/api/MailRestController.kt @@ -20,8 +20,7 @@ class MailRestController( @RequestPart request: MailData, @RequestPart files: Array, ): ResponseEntity { - val inputStreamFiles = - files.associate { (it.originalFilename!! to ByteArrayResource(it.bytes)) } + val inputStreamFiles = files.associate { (it.originalFilename!! to ByteArrayResource(it.bytes)) } mailService.sendMailsByBCC(request, inputStreamFiles) return ResponseEntity.noContent().build() } From ac18b3f4b9e866e47c4e2d7619bc5b84d1b55e8c Mon Sep 17 00:00:00 2001 From: MyaGya <38939015+MyaGya@users.noreply.github.com> Date: Thu, 21 Oct 2021 22:09:35 +0900 Subject: [PATCH 11/31] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=EC=9D=B4?= =?UTF-8?q?=20=EB=81=9D=EB=82=9C=20BytearrayOutputStream=20=EC=9D=84=20clo?= =?UTF-8?q?se?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt index 3a93f9a19..6f9cd453d 100644 --- a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt +++ b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt @@ -87,9 +87,10 @@ class MultipartMimeMessage( } private fun createRawMessage(message: MimeMessage): RawMessage { - val outputStream = ByteArrayOutputStream() - message.writeTo(outputStream) - return RawMessage(ByteBuffer.wrap(outputStream.toByteArray())) + ByteArrayOutputStream().use { outputStream -> + message.writeTo(outputStream) + return RawMessage(ByteBuffer.wrap(outputStream.toByteArray())) + } } } From 045873e6d4c88f8156c040f8be7cafee97f4d8bb Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Fri, 15 Jul 2022 11:17:48 +0900 Subject: [PATCH 12/31] =?UTF-8?q?refactor(mail):=20bcc=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 코드의 원형을 지키면서 빌더 부분 제외 - 추후 확장이 필요할 때 리팩토링 --- .../apply/application/mail/MailService.kt | 2 +- .../kotlin/apply/infra/mail/AwsMailSender.kt | 125 +++++++++++++++--- .../apply/infra/mail/MultipartMimeMessage.kt | 121 ----------------- .../apply/ui/admin/mail/MailsFormView.kt | 2 +- .../kotlin/apply/ui/api/MailRestController.kt | 2 +- 5 files changed, 111 insertions(+), 141 deletions(-) delete mode 100644 src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index 780438ba7..c312f97b5 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -85,7 +85,7 @@ class MailService( } @Async - fun sendMailsByBCC(request: MailData, files: Map) { + fun sendMailsByBcc(request: MailData, files: Map) { request.recipients.plus(mailProperties.username) for (targetMailsPart in request.recipients.chunked(MAIL_SENDING_UNIT)) { mailSender.sendBcc(targetMailsPart.toTypedArray(), request.subject, request.body, files) diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 008cae5ab..cb6f2e6ce 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -11,12 +11,28 @@ 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.RawMessage import com.amazonaws.services.simpleemail.model.SendEmailRequest +import com.amazonaws.services.simpleemail.model.SendRawEmailRequest import org.springframework.boot.autoconfigure.mail.MailProperties import org.springframework.core.io.ByteArrayResource import org.springframework.stereotype.Component +import org.thymeleaf.ITemplateEngine +import org.thymeleaf.context.Context import org.thymeleaf.spring5.ISpringTemplateEngine -import javax.mail.Message as javaxMessage +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.util.Properties +import javax.activation.DataHandler +import javax.activation.DataSource +import javax.activation.MimetypesFileTypeMap +import javax.mail.Message.RecipientType +import javax.mail.Session +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeBodyPart +import javax.mail.internet.MimeMessage +import javax.mail.internet.MimeMultipart +import javax.mail.util.ByteArrayDataSource @Component class AwsMailSender( @@ -56,27 +72,102 @@ class AwsMailSender( body: String, files: Map ) { - val applicationProperties = applicationProperties - val templateEngine = templateEngine - val multipartMimeMessage = message { - this.applicationProperties = applicationProperties - this.templateEngine = templateEngine - this.subject = subject - this.userName = mailProperties.username - this.recipient = Recipient(javaxMessage.RecipientType.BCC, toAddresses) - this.body = body - this.files = files - } + val multipartMimeMessage = MultipartMimeMessage( + applicationProperties = applicationProperties, + templateEngine = templateEngine, + subject = subject, + userName = mailProperties.username, + recipient = toAddresses, + body = body, + files = files + ) val rawEmailRequest = multipartMimeMessage.getRawEmailRequest() - try { - client.sendRawEmail(rawEmailRequest) - } catch (ex: Exception) { - ex.printStackTrace() - } + client.sendRawEmail(rawEmailRequest) } private fun createContent(data: String): Content { return Content(data).withCharset(Charsets.UTF_8.name()) } } + +private class MultipartMimeMessage( + val message: MimeMessage = MimeMessage(Session.getDefaultInstance(Properties())) +) { + constructor( + applicationProperties: ApplicationProperties, + templateEngine: ITemplateEngine, + subject: String, + userName: String, + recipient: Array, + body: String, + files: Map + ) : this() { + val mimeMultipart = MimeMultipart("mixed") + setSubject(subject) + setFrom(userName) + setRecipient(recipient) + addBody(body, applicationProperties.url, templateEngine, mimeMultipart) + addAttachment(files, mimeMultipart) + } + + fun setSubject(subject: String) { + message.setSubject(subject, Charsets.UTF_8.name()) + } + + fun setFrom(userName: String) { + message.setFrom(InternetAddress(userName)) + } + + fun setRecipient(recipient: Array) { + message.setRecipients(RecipientType.BCC, recipient.map { InternetAddress(it) }.toTypedArray()) + } + + fun addBody(body: String, url: String, templateEngine: ITemplateEngine, mimeMixedPart: MimeMultipart) { + val messageBody = MimeMultipart("alternative") + val wrap = MimeBodyPart() + val context = Context().apply { + setVariables( + mapOf( + "content" to body, + "url" to url + ) + ) + } + val htmlPart = MimeBodyPart() + htmlPart.setContent(templateEngine.process("mail/common", context), "text/html; charset=UTF-8") + messageBody.addBodyPart(htmlPart) + wrap.setContent(messageBody) + message.setContent(mimeMixedPart) + mimeMixedPart.addBodyPart(wrap) + } + + fun addAttachment(files: Map, mimeMixedPart: MimeMultipart) { + for ((fileName, fileData) in files) { + val bodyPart = MimeBodyPart() + val fds: DataSource = ByteArrayDataSource( + fileData.byteArray, + findMimeContentTypeByFileName(fileName) + ) + bodyPart.dataHandler = DataHandler(fds) + bodyPart.fileName = fileName + mimeMixedPart.addBodyPart(bodyPart) + } + } + + fun findMimeContentTypeByFileName(fileName: String): String { + return MimetypesFileTypeMap().getContentType(fileName) ?: throw IllegalArgumentException("잘못된 확장자입니다.") + } + + fun getRawEmailRequest(): SendRawEmailRequest { + val rawMessage = createRawMessage(message) + return SendRawEmailRequest(rawMessage) + } + + fun createRawMessage(message: MimeMessage): RawMessage { + ByteArrayOutputStream().use { outputStream -> + message.writeTo(outputStream) + return RawMessage(ByteBuffer.wrap(outputStream.toByteArray())) + } + } +} diff --git a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt b/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt deleted file mode 100644 index 6f9cd453d..000000000 --- a/src/main/kotlin/apply/infra/mail/MultipartMimeMessage.kt +++ /dev/null @@ -1,121 +0,0 @@ -package apply.infra.mail - -import apply.application.ApplicationProperties -import com.amazonaws.services.simpleemail.model.RawMessage -import com.amazonaws.services.simpleemail.model.SendRawEmailRequest -import org.springframework.core.io.ByteArrayResource -import org.thymeleaf.context.Context -import org.thymeleaf.spring5.ISpringTemplateEngine -import org.thymeleaf.spring5.SpringTemplateEngine -import java.io.ByteArrayOutputStream -import java.nio.ByteBuffer -import java.util.Properties -import javax.activation.DataHandler -import javax.activation.DataSource -import javax.activation.MimetypesFileTypeMap -import javax.mail.Message -import javax.mail.Session -import javax.mail.internet.InternetAddress -import javax.mail.internet.MimeBodyPart -import javax.mail.internet.MimeMessage -import javax.mail.internet.MimeMultipart -import javax.mail.util.ByteArrayDataSource - -data class Recipient(val recipientType: Message.RecipientType, val toAddresses: Array) { - constructor() : this(Message.RecipientType.BCC, arrayOf()) -} - -class MultipartMimeMessage( - session: Session, - private val mimeMixedPart: MimeMultipart, - private val applicationProperties: ApplicationProperties, - private val templateEngine: ISpringTemplateEngine -) { - val message: MimeMessage = MimeMessage(session) - - fun setSubject(subject: String) { - message.setSubject(subject, Charsets.UTF_8.name()) - } - - fun setFrom(userName: String) { - message.setFrom(InternetAddress(userName)) - } - - fun setRecipient(recipient: Recipient) { - message.setRecipients(recipient.recipientType, recipient.toAddresses.map { InternetAddress(it) }.toTypedArray()) - } - - fun addBody(body: String) { - val messageBody = MimeMultipart("alternative") - val wrap = MimeBodyPart() - val context = Context().apply { - setVariables( - mapOf( - "content" to body, - "url" to applicationProperties.url - ) - ) - } - val htmlPart = MimeBodyPart() - htmlPart.setContent(templateEngine.process("mail/common", context), "text/html; charset=UTF-8") - messageBody.addBodyPart(htmlPart) - wrap.setContent(messageBody) - message.setContent(mimeMixedPart) - mimeMixedPart.addBodyPart(wrap) - } - - fun addAttachment(files: Map) { - for ((fileName, fileData) in files) { - val att = MimeBodyPart() - val fds: DataSource = ByteArrayDataSource( - fileData.byteArray, - findMimeContentTypeByFileName(fileName) - ) - att.dataHandler = DataHandler(fds) - att.fileName = fileName - mimeMixedPart.addBodyPart(att) - } - } - - private fun findMimeContentTypeByFileName(fileName: String): String { - return MimetypesFileTypeMap().getContentType(fileName) ?: throw IllegalArgumentException("잘못된 확장자입니다.") - } - - fun getRawEmailRequest(): SendRawEmailRequest { - val rawMessage = createRawMessage(message) - return SendRawEmailRequest(rawMessage) - } - - private fun createRawMessage(message: MimeMessage): RawMessage { - ByteArrayOutputStream().use { outputStream -> - message.writeTo(outputStream) - return RawMessage(ByteBuffer.wrap(outputStream.toByteArray())) - } - } -} - -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 = mutableMapOf() -) { - fun build(): MultipartMimeMessage { - return MultipartMimeMessage(session, mimeMixedPart, applicationProperties, templateEngine).apply { - setSubject(subject) - setFrom(userName) - setRecipient(recipient) - addBody(body) - addAttachment(files) - } - } -} - -fun message(lambda: MultipartMimeMessageBuilder.() -> Unit): MultipartMimeMessage { - return MultipartMimeMessageBuilder().apply(lambda).build() -} diff --git a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt index 4322c3432..6cb4dfe13 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt @@ -67,7 +67,7 @@ class MailsFormView( return createPrimaryButton("보내기") { mailForm.bindOrNull()?.let { mailHistoryService.save(it) - mailService.sendMailsByBCC(it, it.attachFiles) + mailService.sendMailsByBcc(it, it.attachFiles) UI.getCurrent().navigate(MailsView::class.java) } } diff --git a/src/main/kotlin/apply/ui/api/MailRestController.kt b/src/main/kotlin/apply/ui/api/MailRestController.kt index 5e3559e52..c5c168400 100644 --- a/src/main/kotlin/apply/ui/api/MailRestController.kt +++ b/src/main/kotlin/apply/ui/api/MailRestController.kt @@ -21,7 +21,7 @@ class MailRestController( @RequestPart files: Array, ): ResponseEntity { val inputStreamFiles = files.associate { (it.originalFilename!! to ByteArrayResource(it.bytes)) } - mailService.sendMailsByBCC(request, inputStreamFiles) + mailService.sendMailsByBcc(request, inputStreamFiles) return ResponseEntity.noContent().build() } } From f7310503a9fabbdcf3a4476b47b1104cc58d1a4e Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Mon, 18 Jul 2022 15:14:01 +0900 Subject: [PATCH 13/31] =?UTF-8?q?refactor(mail):=20attachFiles=20->=20atta?= =?UTF-8?q?chments=20=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/application/mail/MailData.kt | 2 +- src/main/kotlin/apply/application/mail/MailSender.kt | 2 +- src/main/kotlin/apply/infra/mail/AwsMailSender.kt | 4 ++-- src/main/kotlin/apply/infra/mail/SimpleMailSender.kt | 4 ++-- src/main/kotlin/apply/ui/admin/mail/MailForm.kt | 2 +- src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailData.kt b/src/main/kotlin/apply/application/mail/MailData.kt index 48daa0433..392f38225 100644 --- a/src/main/kotlin/apply/application/mail/MailData.kt +++ b/src/main/kotlin/apply/application/mail/MailData.kt @@ -25,7 +25,7 @@ data class MailData( @field:NotNull var sentTime: LocalDateTime = LocalDateTime.now(), - var attachFiles: Map = emptyMap(), + var attachments: Map = emptyMap(), @field:NotNull var id: Long = 0L diff --git a/src/main/kotlin/apply/application/mail/MailSender.kt b/src/main/kotlin/apply/application/mail/MailSender.kt index 65dd24a67..3024c8338 100644 --- a/src/main/kotlin/apply/application/mail/MailSender.kt +++ b/src/main/kotlin/apply/application/mail/MailSender.kt @@ -5,5 +5,5 @@ import org.springframework.core.io.ByteArrayResource interface MailSender { fun send(toAddress: String, subject: String, body: String) - fun sendBcc(toAddresses: Array, subject: String, body: String, files: Map) + fun sendBcc(toAddresses: Array, subject: String, body: String, attachments: Map) } diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index cb6f2e6ce..27e45e4c5 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -70,7 +70,7 @@ class AwsMailSender( toAddresses: Array, subject: String, body: String, - files: Map + attachments: Map ) { val multipartMimeMessage = MultipartMimeMessage( applicationProperties = applicationProperties, @@ -79,7 +79,7 @@ class AwsMailSender( userName = mailProperties.username, recipient = toAddresses, body = body, - files = files + files = attachments ) val rawEmailRequest = multipartMimeMessage.getRawEmailRequest() diff --git a/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt b/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt index bbbe2f89f..04752757c 100644 --- a/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt @@ -25,7 +25,7 @@ class SimpleMailSender( toAddresses: Array, subject: String, body: String, - files: Map + attachments: Map ) { val message = mailSender.createMimeMessage() val mimeMessageHelper = MimeMessageHelper(message, true).apply { @@ -34,7 +34,7 @@ class SimpleMailSender( setSubject(subject) setText(body, true) } - files.forEach { (fileName, data) -> + attachments.forEach { (fileName, data) -> mimeMessageHelper.addAttachment(fileName, data) } mailSender.send(message) diff --git a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt index 7983af1d0..904c4bc83 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt @@ -128,7 +128,7 @@ class MailForm( override fun bindOrNull(): MailData? { return bindDefaultOrNull()?.apply { recipients = mailTargets.map { it.email }.toList() - attachFiles = uploadFile + attachments = uploadFile } } diff --git a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt index 6cb4dfe13..1e4003d0e 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt @@ -67,7 +67,7 @@ class MailsFormView( return createPrimaryButton("보내기") { mailForm.bindOrNull()?.let { mailHistoryService.save(it) - mailService.sendMailsByBcc(it, it.attachFiles) + mailService.sendMailsByBcc(it, it.attachments) UI.getCurrent().navigate(MailsView::class.java) } } From c17566a92e0c8c3f727569ba4178af697bbdb820 Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Mon, 18 Jul 2022 15:17:05 +0900 Subject: [PATCH 14/31] =?UTF-8?q?refactor(mail):=20sendBcc=20=EB=B0=9B?= =?UTF-8?q?=EB=8A=94=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?Array=20->=20List=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/application/mail/MailSender.kt | 2 +- src/main/kotlin/apply/application/mail/MailService.kt | 2 +- src/main/kotlin/apply/infra/mail/AwsMailSender.kt | 6 +++--- src/main/kotlin/apply/infra/mail/SimpleMailSender.kt | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailSender.kt b/src/main/kotlin/apply/application/mail/MailSender.kt index 3024c8338..98d991994 100644 --- a/src/main/kotlin/apply/application/mail/MailSender.kt +++ b/src/main/kotlin/apply/application/mail/MailSender.kt @@ -5,5 +5,5 @@ import org.springframework.core.io.ByteArrayResource interface MailSender { fun send(toAddress: String, subject: String, body: String) - fun sendBcc(toAddresses: Array, subject: String, body: String, attachments: Map) + fun sendBcc(toAddresses: List, subject: String, body: String, attachments: Map) } diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index c312f97b5..9ed056bf2 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -88,7 +88,7 @@ class MailService( fun sendMailsByBcc(request: MailData, files: Map) { request.recipients.plus(mailProperties.username) for (targetMailsPart in request.recipients.chunked(MAIL_SENDING_UNIT)) { - mailSender.sendBcc(targetMailsPart.toTypedArray(), request.subject, request.body, files) + mailSender.sendBcc(targetMailsPart, request.subject, request.body, files) } } } diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 27e45e4c5..92bc0f9c9 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -67,7 +67,7 @@ class AwsMailSender( } override fun sendBcc( - toAddresses: Array, + toAddresses: List, subject: String, body: String, attachments: Map @@ -99,7 +99,7 @@ private class MultipartMimeMessage( templateEngine: ITemplateEngine, subject: String, userName: String, - recipient: Array, + recipient: List, body: String, files: Map ) : this() { @@ -119,7 +119,7 @@ private class MultipartMimeMessage( message.setFrom(InternetAddress(userName)) } - fun setRecipient(recipient: Array) { + fun setRecipient(recipient: List) { message.setRecipients(RecipientType.BCC, recipient.map { InternetAddress(it) }.toTypedArray()) } diff --git a/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt b/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt index 04752757c..1a1c9f53d 100644 --- a/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/SimpleMailSender.kt @@ -22,7 +22,7 @@ class SimpleMailSender( } override fun sendBcc( - toAddresses: Array, + toAddresses: List, subject: String, body: String, attachments: Map @@ -30,7 +30,7 @@ class SimpleMailSender( val message = mailSender.createMimeMessage() val mimeMessageHelper = MimeMessageHelper(message, true).apply { setFrom(mailProperties.username) - setBcc(toAddresses) + setBcc(toAddresses.toTypedArray()) setSubject(subject) setText(body, true) } From 5cf262d4800ed2a7108b05d2d6c5ab23af209dec Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Mon, 18 Jul 2022 15:25:50 +0900 Subject: [PATCH 15/31] =?UTF-8?q?refactor(mail):=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=9D=B8=EC=9B=90=20=EC=83=81=EC=88=98?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/application/mail/MailService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index 9ed056bf2..61d7486a6 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -15,6 +15,8 @@ import org.springframework.transaction.event.TransactionalEventListener import org.thymeleaf.context.Context import org.thymeleaf.spring5.ISpringTemplateEngine +private const val MAIL_SENDING_UNIT = 50 + @Service class MailService( private val userRepository: UserRepository, @@ -24,8 +26,6 @@ class MailService( private val mailSender: MailSender, private val mailProperties: MailProperties ) { - val MAIL_SENDING_UNIT = 50 - @Async @TransactionalEventListener fun sendPasswordResetMail(event: PasswordResetEvent) { From 51eea62c8e1a849941dfab29ca14c43fc14f0517 Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Mon, 18 Jul 2022 15:28:19 +0900 Subject: [PATCH 16/31] =?UTF-8?q?fix(mail):=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=80=20=EC=82=AC=EB=9E=8C=EC=97=90=20=EC=9A=B0=ED=85=8C?= =?UTF-8?q?=EC=BD=94=20=EB=A9=94=EC=9D=BC=EC=9D=84=20=EC=A7=80=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/application/mail/MailService.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index 61d7486a6..4ea4c77d5 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -86,8 +86,8 @@ class MailService( @Async fun sendMailsByBcc(request: MailData, files: Map) { - request.recipients.plus(mailProperties.username) - for (targetMailsPart in request.recipients.chunked(MAIL_SENDING_UNIT)) { + val recipients = request.recipients + mailProperties.username + for (targetMailsPart in recipients.chunked(MAIL_SENDING_UNIT)) { mailSender.sendBcc(targetMailsPart, request.subject, request.body, files) } } From 90879cbf1f44514f514db7bb215e9372b789e80a Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Mon, 18 Jul 2022 15:35:47 +0900 Subject: [PATCH 17/31] =?UTF-8?q?style(mail):=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/ui/admin/mail/MailForm.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt index 904c4bc83..029bf6171 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt @@ -42,12 +42,14 @@ class MailForm( private val mailTargetsGrid: Grid = createMailTargetsGrid(mailTargets) private val recipientFilter: Component = createRecipientFilter() private val fileUpload: Upload = createFileUpload() + init { add(subject, createSender(), recipientFilter, mailTargetsGrid, body, fileUpload) setResponsiveSteps(ResponsiveStep("0", 1)) drawRequired() refreshGridFooter() } + private fun createSender(): Component { return TextField("보낸사람").apply { value = mailProperties.username From 4342dcb52ef7573376053434e9d5b41fc2ebfebb Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Mon, 18 Jul 2022 15:40:14 +0900 Subject: [PATCH 18/31] =?UTF-8?q?feat(mail):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EA=B6=8C=ED=95=9C=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/ui/api/MailRestController.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/apply/ui/api/MailRestController.kt b/src/main/kotlin/apply/ui/api/MailRestController.kt index c5c168400..873a3ae7f 100644 --- a/src/main/kotlin/apply/ui/api/MailRestController.kt +++ b/src/main/kotlin/apply/ui/api/MailRestController.kt @@ -2,6 +2,8 @@ package apply.ui.api import apply.application.mail.MailData import apply.application.mail.MailService +import apply.security.LoginUser +import org.apache.catalina.User import org.springframework.core.io.ByteArrayResource import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping @@ -19,6 +21,7 @@ class MailRestController( fun sendMail( @RequestPart request: MailData, @RequestPart files: Array, + @LoginUser(administrator = true) user: User, ): ResponseEntity { val inputStreamFiles = files.associate { (it.originalFilename!! to ByteArrayResource(it.bytes)) } mailService.sendMailsByBcc(request, inputStreamFiles) From 7e5ef0893573d9ae5c7dea0d289871bb3f6a405a Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Thu, 21 Jul 2022 10:47:16 +0900 Subject: [PATCH 19/31] =?UTF-8?q?feat(mail):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=8B=9C=20=EC=B4=88=EB=8B=B9=20=EC=B5=9C?= =?UTF-8?q?=EB=8C=80=20=EC=A0=84=EC=86=A1=20=EC=86=8D=EB=8F=84=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/apply/infra/mail/AwsMailSender.kt | 12 ++++ .../throttle/ExceedRateLimitException.kt | 3 + .../infra/throttle/RequestPerSecondLimiter.kt | 38 +++++++++++ .../kotlin/apply/ui/api/ExceptionHandler.kt | 9 +++ .../throttle/RequestPerSecondLimiterTest.kt | 66 +++++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 src/main/kotlin/apply/infra/throttle/ExceedRateLimitException.kt create mode 100644 src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt create mode 100644 src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 92bc0f9c9..026931d51 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -2,6 +2,8 @@ package apply.infra.mail import apply.application.ApplicationProperties import apply.application.mail.MailSender +import apply.infra.throttle.ExceedRateLimitException +import apply.infra.throttle.RequestPerSecondLimiter import com.amazonaws.auth.AWSStaticCredentialsProvider import com.amazonaws.auth.BasicAWSCredentials import com.amazonaws.regions.Regions @@ -54,7 +56,13 @@ class AwsMailSender( .withRegion(Regions.AP_NORTHEAST_2) .build() + private val requestPerSecondLimiter = RequestPerSecondLimiter(14) + override fun send(toAddress: String, subject: String, body: String) { + if (requestPerSecondLimiter.isExceed()) { + throw ExceedRateLimitException("예상보다 많은 요청이 왔어요. 잠시 후 다시 요청해주세요.") + } + val request = SendEmailRequest() .withSource(mailProperties.username) .withDestination(Destination().withToAddresses(toAddress)) @@ -72,6 +80,10 @@ class AwsMailSender( body: String, attachments: Map ) { + if (requestPerSecondLimiter.isExceed()) { + throw ExceedRateLimitException("예상보다 많은 요청이 왔어요. 잠시 후 다시 요청해주세요.") + } + val multipartMimeMessage = MultipartMimeMessage( applicationProperties = applicationProperties, templateEngine = templateEngine, diff --git a/src/main/kotlin/apply/infra/throttle/ExceedRateLimitException.kt b/src/main/kotlin/apply/infra/throttle/ExceedRateLimitException.kt new file mode 100644 index 000000000..5ca51d1dc --- /dev/null +++ b/src/main/kotlin/apply/infra/throttle/ExceedRateLimitException.kt @@ -0,0 +1,3 @@ +package apply.infra.throttle + +class ExceedRateLimitException(message: String? = null) : RuntimeException(message) diff --git a/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt b/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt new file mode 100644 index 000000000..47bc86493 --- /dev/null +++ b/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt @@ -0,0 +1,38 @@ +package apply.infra.throttle + +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue + +private const val ONE_SECOND = 1_000 + +class RequestPerSecondLimiter( + private val permitsPerSecond: Int +) { + private val requestTimes: Queue = ConcurrentLinkedQueue() + + fun isExceed(requestTime: Long = System.currentTimeMillis()): Boolean = !tryAcquire(requestTime) + + fun tryAcquire(requestTime: Long = System.currentTimeMillis()): Boolean { + sync(requestTime) + + val acquirable = requestTimes.size < permitsPerSecond + if (acquirable) { + requestTimes.offer(requestTime) + return true + } + + return false + } + + private fun sync(requestTime: Long) { + while (requestTimes.isNotEmpty()) { + val elapsedTime = requestTime - requestTimes.peek() + val withinOneSecond = elapsedTime < ONE_SECOND + if (withinOneSecond) { + break + } + + requestTimes.poll() + } + } +} diff --git a/src/main/kotlin/apply/ui/api/ExceptionHandler.kt b/src/main/kotlin/apply/ui/api/ExceptionHandler.kt index 22369442e..c48569575 100644 --- a/src/main/kotlin/apply/ui/api/ExceptionHandler.kt +++ b/src/main/kotlin/apply/ui/api/ExceptionHandler.kt @@ -2,6 +2,7 @@ package apply.ui.api import apply.domain.applicationform.DuplicateApplicationException import apply.domain.user.UnidentifiedUserException +import apply.infra.throttle.ExceedRateLimitException import apply.security.LoginFailedException import com.fasterxml.jackson.databind.exc.InvalidFormatException import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException @@ -84,6 +85,14 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { .body(ApiResponse.error(exception.message)) } + @ExceptionHandler(ExceedRateLimitException::class) + fun handleExceedRateLimitException(exception: ExceedRateLimitException): ResponseEntity> { + logger.error("message", exception) + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .header(HttpHeaders.RETRY_AFTER, "1") + .body(ApiResponse.error(exception.message)) + } + @ExceptionHandler(Exception::class) fun handleGlobalException(exception: Exception): ResponseEntity> { logger.error("message", exception) diff --git a/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt b/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt new file mode 100644 index 000000000..685a24b3d --- /dev/null +++ b/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt @@ -0,0 +1,66 @@ +package apply.infra.throttle + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertAll + +internal class RequestPerSecondLimiterTest { + + @Test + fun `초당 1회로 제한할 경우 1초 간격 요청은 성공한다`() { + val requestPerSecondLimiter = RequestPerSecondLimiter(1) + val baseTime = System.currentTimeMillis() + + assertAll( + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1000)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 2000)).isTrue } + ) + } + + @Test + fun `초당 1회로 제한할 경우 초당 2번 요청하면 실패한다`() { + val requestPerSecondLimiter = RequestPerSecondLimiter(1) + val baseTime = System.currentTimeMillis() + + assertAll( + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 999)).isFalse }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1000)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1999)).isFalse } + ) + } + + @Test + fun `초당 2회로 제한할 경우 초당 2회 요청은 성공한다`() { + val requestPerSecondLimiter = RequestPerSecondLimiter(2) + val baseTime = System.currentTimeMillis() + + assertAll( + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1001)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1500)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 2001)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 4000)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 4000)).isTrue } + ) + } + + @Test + fun `초당 2회로 제한할 경우 초당 3번 요청하면 실패한다`() { + val requestPerSecondLimiter = RequestPerSecondLimiter(2) + val baseTime = System.currentTimeMillis() + + assertAll( + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isFalse }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1000)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1500)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1600)).isFalse }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 2000)).isTrue }, + { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 2400)).isFalse } + ) + } +} From 0d5c7c2868af5caedb2a64ae5200c0cd0714d551 Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Thu, 21 Jul 2022 12:55:50 +0900 Subject: [PATCH 20/31] =?UTF-8?q?refactor(mail):=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EB=A1=9C=EC=A7=81=20Sender=20->=20Service=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apply/application/mail/MailService.kt | 11 ++++++- .../kotlin/apply/infra/mail/AwsMailSender.kt | 30 +++++-------------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index 4ea4c77d5..4ac8d3c63 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -86,9 +86,18 @@ class MailService( @Async fun sendMailsByBcc(request: MailData, files: Map) { + val context = Context().apply { + setVariables( + mapOf( + "content" to request.body, + "url" to applicationProperties.url + ) + ) + } + val body = templateEngine.process("mail/common", context) val recipients = request.recipients + mailProperties.username for (targetMailsPart in recipients.chunked(MAIL_SENDING_UNIT)) { - mailSender.sendBcc(targetMailsPart, request.subject, request.body, files) + mailSender.sendBcc(targetMailsPart, request.subject, body, files) } } } diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 026931d51..74f6e279b 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -1,6 +1,5 @@ package apply.infra.mail -import apply.application.ApplicationProperties import apply.application.mail.MailSender import apply.infra.throttle.ExceedRateLimitException import apply.infra.throttle.RequestPerSecondLimiter @@ -19,16 +18,12 @@ import com.amazonaws.services.simpleemail.model.SendRawEmailRequest import org.springframework.boot.autoconfigure.mail.MailProperties import org.springframework.core.io.ByteArrayResource import org.springframework.stereotype.Component -import org.thymeleaf.ITemplateEngine -import org.thymeleaf.context.Context -import org.thymeleaf.spring5.ISpringTemplateEngine import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.util.Properties import javax.activation.DataHandler import javax.activation.DataSource import javax.activation.MimetypesFileTypeMap -import javax.mail.Message.RecipientType import javax.mail.Session import javax.mail.internet.InternetAddress import javax.mail.internet.MimeBodyPart @@ -39,8 +34,6 @@ import javax.mail.util.ByteArrayDataSource @Component class AwsMailSender( private val mailProperties: MailProperties, - private val applicationProperties: ApplicationProperties, - private val templateEngine: ISpringTemplateEngine, awsProperties: AwsProperties ) : MailSender { private val client: AmazonSimpleEmailService = AmazonSimpleEmailServiceClientBuilder @@ -85,8 +78,6 @@ class AwsMailSender( } val multipartMimeMessage = MultipartMimeMessage( - applicationProperties = applicationProperties, - templateEngine = templateEngine, subject = subject, userName = mailProperties.username, recipient = toAddresses, @@ -107,8 +98,6 @@ private class MultipartMimeMessage( val message: MimeMessage = MimeMessage(Session.getDefaultInstance(Properties())) ) { constructor( - applicationProperties: ApplicationProperties, - templateEngine: ITemplateEngine, subject: String, userName: String, recipient: List, @@ -119,7 +108,7 @@ private class MultipartMimeMessage( setSubject(subject) setFrom(userName) setRecipient(recipient) - addBody(body, applicationProperties.url, templateEngine, mimeMultipart) + addBody(body, mimeMultipart) addAttachment(files, mimeMultipart) } @@ -132,22 +121,17 @@ private class MultipartMimeMessage( } fun setRecipient(recipient: List) { - message.setRecipients(RecipientType.BCC, recipient.map { InternetAddress(it) }.toTypedArray()) + message.setRecipients( + javax.mail.Message.RecipientType.BCC, + recipient.map { InternetAddress(it) }.toTypedArray() + ) } - fun addBody(body: String, url: String, templateEngine: ITemplateEngine, mimeMixedPart: MimeMultipart) { + fun addBody(body: String, mimeMixedPart: MimeMultipart) { val messageBody = MimeMultipart("alternative") val wrap = MimeBodyPart() - val context = Context().apply { - setVariables( - mapOf( - "content" to body, - "url" to url - ) - ) - } val htmlPart = MimeBodyPart() - htmlPart.setContent(templateEngine.process("mail/common", context), "text/html; charset=UTF-8") + htmlPart.setContent(body, "text/html; charset=UTF-8") messageBody.addBodyPart(htmlPart) wrap.setContent(messageBody) message.setContent(mimeMixedPart) From 7f2d7058c19504cf8979419c11dd8a8bc6869389 Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Thu, 21 Jul 2022 12:58:28 +0900 Subject: [PATCH 21/31] =?UTF-8?q?chore(mail):=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=89=BC=ED=91=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/apply/ui/api/MailRestController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/apply/ui/api/MailRestController.kt b/src/main/kotlin/apply/ui/api/MailRestController.kt index 873a3ae7f..e36f1d506 100644 --- a/src/main/kotlin/apply/ui/api/MailRestController.kt +++ b/src/main/kotlin/apply/ui/api/MailRestController.kt @@ -21,7 +21,7 @@ class MailRestController( fun sendMail( @RequestPart request: MailData, @RequestPart files: Array, - @LoginUser(administrator = true) user: User, + @LoginUser(administrator = true) user: User ): ResponseEntity { val inputStreamFiles = files.associate { (it.originalFilename!! to ByteArrayResource(it.bytes)) } mailService.sendMailsByBcc(request, inputStreamFiles) From 62d2a1e30f23643e5aa477cc5ea1f3cf089b57db Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Thu, 21 Jul 2022 14:14:06 +0900 Subject: [PATCH 22/31] =?UTF-8?q?refactor(mail):=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=ED=9B=84=20=ED=9E=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EB=82=A8=EA=B8=B0=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/apply/application/mail/MailService.kt | 13 +++++++++++++ .../kotlin/apply/ui/admin/mail/MailsFormView.kt | 1 - 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index 4ac8d3c63..1d9f2ef33 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -2,6 +2,8 @@ package apply.application.mail import apply.application.ApplicationProperties import apply.domain.applicationform.ApplicationFormSubmittedEvent +import apply.domain.mail.MailHistory +import apply.domain.mail.MailHistoryRepository import apply.domain.recruitment.RecruitmentRepository import apply.domain.recruitment.getById import apply.domain.user.PasswordResetEvent @@ -21,6 +23,7 @@ private const val MAIL_SENDING_UNIT = 50 class MailService( private val userRepository: UserRepository, private val recruitmentRepository: RecruitmentRepository, + private val mailHistoryRepository: MailHistoryRepository, private val applicationProperties: ApplicationProperties, private val templateEngine: ISpringTemplateEngine, private val mailSender: MailSender, @@ -99,5 +102,15 @@ class MailService( 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 + ) + ) } } diff --git a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt index 1e4003d0e..99cacb2b7 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt @@ -66,7 +66,6 @@ class MailsFormView( private fun createSubmitButton(): Button { return createPrimaryButton("보내기") { mailForm.bindOrNull()?.let { - mailHistoryService.save(it) mailService.sendMailsByBcc(it, it.attachments) UI.getCurrent().navigate(MailsView::class.java) } From 261d41eb73cba3d65e3e4f42a2da673bdc5043d6 Mon Sep 17 00:00:00 2001 From: woowahan-neo Date: Fri, 22 Jul 2022 09:24:39 +0900 Subject: [PATCH 23/31] fix(mail): change user --- src/main/kotlin/apply/ui/api/MailRestController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/apply/ui/api/MailRestController.kt b/src/main/kotlin/apply/ui/api/MailRestController.kt index e36f1d506..4feefaebe 100644 --- a/src/main/kotlin/apply/ui/api/MailRestController.kt +++ b/src/main/kotlin/apply/ui/api/MailRestController.kt @@ -2,8 +2,8 @@ package apply.ui.api import apply.application.mail.MailData import apply.application.mail.MailService +import apply.domain.user.User import apply.security.LoginUser -import org.apache.catalina.User import org.springframework.core.io.ByteArrayResource import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping From 54f6a6ea7eaf8e104af1290e844566e3cf93abf0 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Fri, 22 Jul 2022 10:44:00 +0900 Subject: [PATCH 24/31] refactor(test): remove unused features --- .../apply/application/MailHistoryService.kt | 13 ------------- .../apply/ui/api/MailHistoryRestController.kt | 13 ------------- .../apply/application/MailHistoryServiceTest.kt | 8 -------- .../ui/api/MailHistoryRestControllerTest.kt | 17 ----------------- 4 files changed, 51 deletions(-) diff --git a/src/main/kotlin/apply/application/MailHistoryService.kt b/src/main/kotlin/apply/application/MailHistoryService.kt index 2c77a2592..86df61263 100644 --- a/src/main/kotlin/apply/application/MailHistoryService.kt +++ b/src/main/kotlin/apply/application/MailHistoryService.kt @@ -1,7 +1,6 @@ package apply.application import apply.application.mail.MailData -import apply.domain.mail.MailHistory import apply.domain.mail.MailHistoryRepository import apply.domain.mail.getById import org.springframework.stereotype.Service @@ -12,18 +11,6 @@ import javax.transaction.Transactional class MailHistoryService( private val mailHistoryRepository: MailHistoryRepository ) { - fun save(request: MailData) { - mailHistoryRepository.save( - MailHistory( - request.subject, - request.body, - request.sender, - request.recipients, - request.sentTime - ) - ) - } - fun findAll(): List { return mailHistoryRepository.findAll().map { MailData(it) } } diff --git a/src/main/kotlin/apply/ui/api/MailHistoryRestController.kt b/src/main/kotlin/apply/ui/api/MailHistoryRestController.kt index 112909875..eabc26ac2 100644 --- a/src/main/kotlin/apply/ui/api/MailHistoryRestController.kt +++ b/src/main/kotlin/apply/ui/api/MailHistoryRestController.kt @@ -7,27 +7,14 @@ import apply.security.LoginUser import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -import javax.validation.Valid @RestController @RequestMapping("/api/mail-history") class MailHistoryRestController( private val mailHistoryService: MailHistoryService ) { - @PostMapping - fun save( - @RequestBody @Valid request: MailData, - @LoginUser(administrator = true) user: User - ): ResponseEntity { - // todo: 파일 첨부하여 보내는 로직 필요 - mailHistoryService.save(request) - return ResponseEntity.ok().build() - } - @GetMapping("/{mailHistoryId}") fun getById( @PathVariable mailHistoryId: Long, diff --git a/src/test/kotlin/apply/application/MailHistoryServiceTest.kt b/src/test/kotlin/apply/application/MailHistoryServiceTest.kt index f79a59a82..ee044b143 100644 --- a/src/test/kotlin/apply/application/MailHistoryServiceTest.kt +++ b/src/test/kotlin/apply/application/MailHistoryServiceTest.kt @@ -8,7 +8,6 @@ import io.mockk.impl.annotations.MockK import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertDoesNotThrow import support.test.UnitTest import java.time.LocalDateTime @@ -24,13 +23,6 @@ class MailHistoryServiceTest { mailHistoryService = MailHistoryService(mailHistoryRepository) } - @Test - fun `메일 이력을 저장한다`() { - val mailData = createMailData() - every { mailHistoryRepository.save(any()) } returns createMailHistory() - assertDoesNotThrow { mailHistoryService.save(mailData) } - } - @Test fun `저장된 메일 이력을 모두 조회한다`() { val now = LocalDateTime.now() diff --git a/src/test/kotlin/apply/ui/api/MailHistoryRestControllerTest.kt b/src/test/kotlin/apply/ui/api/MailHistoryRestControllerTest.kt index 1fbd3373d..41829dd88 100644 --- a/src/test/kotlin/apply/ui/api/MailHistoryRestControllerTest.kt +++ b/src/test/kotlin/apply/ui/api/MailHistoryRestControllerTest.kt @@ -3,17 +3,13 @@ package apply.ui.api import apply.application.MailHistoryService import apply.createMailData import com.ninjasquad.springmockk.MockkBean -import io.mockk.Runs import io.mockk.every -import io.mockk.just import org.junit.jupiter.api.Test import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.FilterType import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType import org.springframework.test.web.servlet.get -import org.springframework.test.web.servlet.post @WebMvcTest( controllers = [MailHistoryRestController::class], @@ -25,19 +21,6 @@ class MailHistoryRestControllerTest : RestControllerTest() { @MockkBean private lateinit var mailHistoryService: MailHistoryService - @Test - fun `이메일 이력을 저장한다`() { - every { mailHistoryService.save(any()) } just Runs - - mockMvc.post("/api/mail-history") { - header(HttpHeaders.AUTHORIZATION, "Bearer valid_token") - contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(createMailData()) - }.andExpect { - status { isOk } - } - } - @Test fun `이메일 내역을 단일 조회한다`() { val mailData = createMailData() From cf185254bb3c0e4b89254913dd1735487e76af2d Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Fri, 22 Jul 2022 14:38:29 +0900 Subject: [PATCH 25/31] style: polish the code --- .../apply/application/mail/MailService.kt | 2 +- .../kotlin/apply/infra/mail/AwsMailSender.kt | 3 ++- .../infra/throttle/RequestPerSecondLimiter.kt | 23 ++++++++----------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index 1d9f2ef33..aba414f6e 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -17,7 +17,7 @@ import org.springframework.transaction.event.TransactionalEventListener import org.thymeleaf.context.Context import org.thymeleaf.spring5.ISpringTemplateEngine -private const val MAIL_SENDING_UNIT = 50 +private const val MAIL_SENDING_UNIT: Int = 50 @Service class MailService( diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 74f6e279b..51cdf3859 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -24,6 +24,7 @@ import java.util.Properties import javax.activation.DataHandler import javax.activation.DataSource import javax.activation.MimetypesFileTypeMap +import javax.mail.Message.RecipientType import javax.mail.Session import javax.mail.internet.InternetAddress import javax.mail.internet.MimeBodyPart @@ -122,7 +123,7 @@ private class MultipartMimeMessage( fun setRecipient(recipient: List) { message.setRecipients( - javax.mail.Message.RecipientType.BCC, + RecipientType.BCC, recipient.map { InternetAddress(it) }.toTypedArray() ) } diff --git a/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt b/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt index 47bc86493..b2c33206d 100644 --- a/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt +++ b/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt @@ -2,36 +2,31 @@ package apply.infra.throttle import java.util.Queue import java.util.concurrent.ConcurrentLinkedQueue - -private const val ONE_SECOND = 1_000 +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class RequestPerSecondLimiter( - private val permitsPerSecond: Int -) { + private val permitsPerSecond: Int, private val requestTimes: Queue = ConcurrentLinkedQueue() - +) { fun isExceed(requestTime: Long = System.currentTimeMillis()): Boolean = !tryAcquire(requestTime) fun tryAcquire(requestTime: Long = System.currentTimeMillis()): Boolean { sync(requestTime) - - val acquirable = requestTimes.size < permitsPerSecond - if (acquirable) { + return if (requestTimes.size < permitsPerSecond) { requestTimes.offer(requestTime) - return true + true + } else { + false } - - return false } private fun sync(requestTime: Long) { while (requestTimes.isNotEmpty()) { val elapsedTime = requestTime - requestTimes.peek() - val withinOneSecond = elapsedTime < ONE_SECOND - if (withinOneSecond) { + if (elapsedTime.milliseconds < 1.seconds) { break } - requestTimes.poll() } } From b2cbf0940b53582040984c10b691b3a78c3feef3 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Fri, 22 Jul 2022 15:24:28 +0900 Subject: [PATCH 26/31] refactor(test): migrate request limiter tests to kotest --- .../throttle/RequestPerSecondLimiterTest.kt | 102 ++++++++---------- 1 file changed, 46 insertions(+), 56 deletions(-) diff --git a/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt b/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt index 685a24b3d..f139f875a 100644 --- a/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt +++ b/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt @@ -1,66 +1,56 @@ package apply.infra.throttle -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertAll - -internal class RequestPerSecondLimiterTest { - - @Test - fun `초당 1회로 제한할 경우 1초 간격 요청은 성공한다`() { - val requestPerSecondLimiter = RequestPerSecondLimiter(1) - val baseTime = System.currentTimeMillis() - - assertAll( - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1000)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 2000)).isTrue } - ) +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue + +class RequestPerSecondLimiterTest : StringSpec({ + "초당 1회로 제한할 경우 1초 간격 요청은 성공한다" { + val limiter = RequestPerSecondLimiter(1) + val currentTimeMillis = System.currentTimeMillis() + assertSoftly(limiter) { + tryAcquire(currentTimeMillis).shouldBeTrue() + tryAcquire(currentTimeMillis + 1000).shouldBeTrue() + tryAcquire(currentTimeMillis + 2000).shouldBeTrue() + } } - @Test - fun `초당 1회로 제한할 경우 초당 2번 요청하면 실패한다`() { - val requestPerSecondLimiter = RequestPerSecondLimiter(1) - val baseTime = System.currentTimeMillis() - - assertAll( - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 999)).isFalse }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1000)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1999)).isFalse } - ) + "초당 1회로 제한할 경우 초당 2번 요청하면 실패한다" { + val limiter = RequestPerSecondLimiter(1) + val currentTimeMillis = System.currentTimeMillis() + assertSoftly(limiter) { + tryAcquire(currentTimeMillis).shouldBeTrue() + tryAcquire(currentTimeMillis + 500).shouldBeFalse() + tryAcquire(currentTimeMillis + 1000).shouldBeTrue() + tryAcquire(currentTimeMillis + 1500).shouldBeFalse() + } } - @Test - fun `초당 2회로 제한할 경우 초당 2회 요청은 성공한다`() { - val requestPerSecondLimiter = RequestPerSecondLimiter(2) - val baseTime = System.currentTimeMillis() - - assertAll( - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1001)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1500)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 2001)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 4000)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 4000)).isTrue } - ) + "초당 2회로 제한할 경우 초당 2회 요청은 성공한다" { + val limiter = RequestPerSecondLimiter(2) + val currentTimeMillis = System.currentTimeMillis() + assertSoftly(limiter) { + tryAcquire(currentTimeMillis).shouldBeTrue() + tryAcquire(currentTimeMillis).shouldBeTrue() + tryAcquire(currentTimeMillis + 1000).shouldBeTrue() + tryAcquire(currentTimeMillis + 1500).shouldBeTrue() + tryAcquire(currentTimeMillis + 2000).shouldBeTrue() + } } - @Test - fun `초당 2회로 제한할 경우 초당 3번 요청하면 실패한다`() { - val requestPerSecondLimiter = RequestPerSecondLimiter(2) + "초당 2회로 제한할 경우 초당 3번 요청하면 실패한다" { + val limiter = RequestPerSecondLimiter(2) val baseTime = System.currentTimeMillis() - - assertAll( - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime)).isFalse }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1000)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1500)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 1600)).isFalse }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 2000)).isTrue }, - { assertThat(requestPerSecondLimiter.tryAcquire(baseTime + 2400)).isFalse } - ) + assertSoftly(limiter) { + tryAcquire(baseTime).shouldBeTrue() + tryAcquire(baseTime).shouldBeTrue() + tryAcquire(baseTime).shouldBeFalse() + tryAcquire(baseTime + 1000).shouldBeTrue() + tryAcquire(baseTime + 1500).shouldBeTrue() + tryAcquire(baseTime + 1600).shouldBeFalse() + tryAcquire(baseTime + 2000).shouldBeTrue() + tryAcquire(baseTime + 2100).shouldBeFalse() + } } -} +}) From 7fc8599d017198558fd05c12ec86320ca9d61413 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Sat, 23 Jul 2022 10:50:04 +0900 Subject: [PATCH 27/31] refactor(support): move from the apply package to the support package --- .../kotlin/apply/infra/mail/AwsMailSender.kt | 16 ++---- .../throttle/ExceedRateLimitException.kt | 3 - .../infra/throttle/RequestPerSecondLimiter.kt | 33 ----------- .../kotlin/apply/ui/api/ExceptionHandler.kt | 6 +- .../support/infra/ExceededRequestException.kt | 3 + src/main/kotlin/support/infra/RateLimiter.kt | 41 ++++++++++++++ .../throttle/RequestPerSecondLimiterTest.kt | 56 ------------------- .../kotlin/support/infra/RateLimiterTest.kt | 56 +++++++++++++++++++ 8 files changed, 107 insertions(+), 107 deletions(-) delete mode 100644 src/main/kotlin/apply/infra/throttle/ExceedRateLimitException.kt delete mode 100644 src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt create mode 100644 src/main/kotlin/support/infra/ExceededRequestException.kt create mode 100644 src/main/kotlin/support/infra/RateLimiter.kt delete mode 100644 src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt create mode 100644 src/test/kotlin/support/infra/RateLimiterTest.kt diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 51cdf3859..15deff1ab 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -1,8 +1,6 @@ package apply.infra.mail import apply.application.mail.MailSender -import apply.infra.throttle.ExceedRateLimitException -import apply.infra.throttle.RequestPerSecondLimiter import com.amazonaws.auth.AWSStaticCredentialsProvider import com.amazonaws.auth.BasicAWSCredentials import com.amazonaws.regions.Regions @@ -18,6 +16,7 @@ import com.amazonaws.services.simpleemail.model.SendRawEmailRequest import org.springframework.boot.autoconfigure.mail.MailProperties import org.springframework.core.io.ByteArrayResource import org.springframework.stereotype.Component +import support.infra.RateLimiter import java.io.ByteArrayOutputStream import java.nio.ByteBuffer import java.util.Properties @@ -50,13 +49,10 @@ class AwsMailSender( .withRegion(Regions.AP_NORTHEAST_2) .build() - private val requestPerSecondLimiter = RequestPerSecondLimiter(14) + private val rateLimiter = RateLimiter(14) override fun send(toAddress: String, subject: String, body: String) { - if (requestPerSecondLimiter.isExceed()) { - throw ExceedRateLimitException("예상보다 많은 요청이 왔어요. 잠시 후 다시 요청해주세요.") - } - + rateLimiter.acquire() val request = SendEmailRequest() .withSource(mailProperties.username) .withDestination(Destination().withToAddresses(toAddress)) @@ -74,10 +70,7 @@ class AwsMailSender( body: String, attachments: Map ) { - if (requestPerSecondLimiter.isExceed()) { - throw ExceedRateLimitException("예상보다 많은 요청이 왔어요. 잠시 후 다시 요청해주세요.") - } - + rateLimiter.acquire() val multipartMimeMessage = MultipartMimeMessage( subject = subject, userName = mailProperties.username, @@ -85,7 +78,6 @@ class AwsMailSender( body = body, files = attachments ) - val rawEmailRequest = multipartMimeMessage.getRawEmailRequest() client.sendRawEmail(rawEmailRequest) } diff --git a/src/main/kotlin/apply/infra/throttle/ExceedRateLimitException.kt b/src/main/kotlin/apply/infra/throttle/ExceedRateLimitException.kt deleted file mode 100644 index 5ca51d1dc..000000000 --- a/src/main/kotlin/apply/infra/throttle/ExceedRateLimitException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package apply.infra.throttle - -class ExceedRateLimitException(message: String? = null) : RuntimeException(message) diff --git a/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt b/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt deleted file mode 100644 index b2c33206d..000000000 --- a/src/main/kotlin/apply/infra/throttle/RequestPerSecondLimiter.kt +++ /dev/null @@ -1,33 +0,0 @@ -package apply.infra.throttle - -import java.util.Queue -import java.util.concurrent.ConcurrentLinkedQueue -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -class RequestPerSecondLimiter( - private val permitsPerSecond: Int, - private val requestTimes: Queue = ConcurrentLinkedQueue() -) { - fun isExceed(requestTime: Long = System.currentTimeMillis()): Boolean = !tryAcquire(requestTime) - - fun tryAcquire(requestTime: Long = System.currentTimeMillis()): Boolean { - sync(requestTime) - return if (requestTimes.size < permitsPerSecond) { - requestTimes.offer(requestTime) - true - } else { - false - } - } - - private fun sync(requestTime: Long) { - while (requestTimes.isNotEmpty()) { - val elapsedTime = requestTime - requestTimes.peek() - if (elapsedTime.milliseconds < 1.seconds) { - break - } - requestTimes.poll() - } - } -} diff --git a/src/main/kotlin/apply/ui/api/ExceptionHandler.kt b/src/main/kotlin/apply/ui/api/ExceptionHandler.kt index c48569575..59210b51c 100644 --- a/src/main/kotlin/apply/ui/api/ExceptionHandler.kt +++ b/src/main/kotlin/apply/ui/api/ExceptionHandler.kt @@ -2,7 +2,6 @@ package apply.ui.api import apply.domain.applicationform.DuplicateApplicationException import apply.domain.user.UnidentifiedUserException -import apply.infra.throttle.ExceedRateLimitException import apply.security.LoginFailedException import com.fasterxml.jackson.databind.exc.InvalidFormatException import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException @@ -15,6 +14,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice import org.springframework.web.context.request.WebRequest import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler +import support.infra.ExceededRequestException import javax.persistence.EntityNotFoundException @RestControllerAdvice @@ -85,8 +85,8 @@ class ExceptionHandler : ResponseEntityExceptionHandler() { .body(ApiResponse.error(exception.message)) } - @ExceptionHandler(ExceedRateLimitException::class) - fun handleExceedRateLimitException(exception: ExceedRateLimitException): ResponseEntity> { + @ExceptionHandler(ExceededRequestException::class) + fun handleExceedRateLimitException(exception: ExceededRequestException): ResponseEntity> { logger.error("message", exception) return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) .header(HttpHeaders.RETRY_AFTER, "1") diff --git a/src/main/kotlin/support/infra/ExceededRequestException.kt b/src/main/kotlin/support/infra/ExceededRequestException.kt new file mode 100644 index 000000000..ce64a054c --- /dev/null +++ b/src/main/kotlin/support/infra/ExceededRequestException.kt @@ -0,0 +1,3 @@ +package support.infra + +class ExceededRequestException(message: String? = null) : RuntimeException(message) diff --git a/src/main/kotlin/support/infra/RateLimiter.kt b/src/main/kotlin/support/infra/RateLimiter.kt new file mode 100644 index 000000000..3ae88d349 --- /dev/null +++ b/src/main/kotlin/support/infra/RateLimiter.kt @@ -0,0 +1,41 @@ +package support.infra + +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +class RateLimiter( + private val permitsPerSecond: Int, + private val available: Queue = ConcurrentLinkedQueue() +) { + init { + require(permitsPerSecond >= 1) + } + + fun acquire(requestTime: Long = System.currentTimeMillis()) { + if (!tryAcquire(requestTime)) { + throw ExceededRequestException("허용된 요청 수를 초과했습니다.") + } + } + + private fun tryAcquire(requestTime: Long = System.currentTimeMillis()): Boolean { + sync(requestTime) + return if (available.size < permitsPerSecond) { + available.offer(requestTime) + true + } else { + false + } + } + + private fun sync(requestTime: Long) { + while (available.isNotEmpty()) { + val elapsedTime = requestTime - available.peek() + if (elapsedTime.milliseconds < 1.seconds) { + break + } + available.poll() + } + } +} diff --git a/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt b/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt deleted file mode 100644 index f139f875a..000000000 --- a/src/test/kotlin/apply/infra/throttle/RequestPerSecondLimiterTest.kt +++ /dev/null @@ -1,56 +0,0 @@ -package apply.infra.throttle - -import io.kotest.assertions.assertSoftly -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.booleans.shouldBeFalse -import io.kotest.matchers.booleans.shouldBeTrue - -class RequestPerSecondLimiterTest : StringSpec({ - "초당 1회로 제한할 경우 1초 간격 요청은 성공한다" { - val limiter = RequestPerSecondLimiter(1) - val currentTimeMillis = System.currentTimeMillis() - assertSoftly(limiter) { - tryAcquire(currentTimeMillis).shouldBeTrue() - tryAcquire(currentTimeMillis + 1000).shouldBeTrue() - tryAcquire(currentTimeMillis + 2000).shouldBeTrue() - } - } - - "초당 1회로 제한할 경우 초당 2번 요청하면 실패한다" { - val limiter = RequestPerSecondLimiter(1) - val currentTimeMillis = System.currentTimeMillis() - assertSoftly(limiter) { - tryAcquire(currentTimeMillis).shouldBeTrue() - tryAcquire(currentTimeMillis + 500).shouldBeFalse() - tryAcquire(currentTimeMillis + 1000).shouldBeTrue() - tryAcquire(currentTimeMillis + 1500).shouldBeFalse() - } - } - - "초당 2회로 제한할 경우 초당 2회 요청은 성공한다" { - val limiter = RequestPerSecondLimiter(2) - val currentTimeMillis = System.currentTimeMillis() - assertSoftly(limiter) { - tryAcquire(currentTimeMillis).shouldBeTrue() - tryAcquire(currentTimeMillis).shouldBeTrue() - tryAcquire(currentTimeMillis + 1000).shouldBeTrue() - tryAcquire(currentTimeMillis + 1500).shouldBeTrue() - tryAcquire(currentTimeMillis + 2000).shouldBeTrue() - } - } - - "초당 2회로 제한할 경우 초당 3번 요청하면 실패한다" { - val limiter = RequestPerSecondLimiter(2) - val baseTime = System.currentTimeMillis() - assertSoftly(limiter) { - tryAcquire(baseTime).shouldBeTrue() - tryAcquire(baseTime).shouldBeTrue() - tryAcquire(baseTime).shouldBeFalse() - tryAcquire(baseTime + 1000).shouldBeTrue() - tryAcquire(baseTime + 1500).shouldBeTrue() - tryAcquire(baseTime + 1600).shouldBeFalse() - tryAcquire(baseTime + 2000).shouldBeTrue() - tryAcquire(baseTime + 2100).shouldBeFalse() - } - } -}) diff --git a/src/test/kotlin/support/infra/RateLimiterTest.kt b/src/test/kotlin/support/infra/RateLimiterTest.kt new file mode 100644 index 000000000..1505f519f --- /dev/null +++ b/src/test/kotlin/support/infra/RateLimiterTest.kt @@ -0,0 +1,56 @@ +package support.infra + +import io.kotest.assertions.assertSoftly +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.StringSpec + +class RateLimiterTest : StringSpec({ + "초당 1회로 제한할 경우 1초 간격 요청은 성공한다" { + val limiter = RateLimiter(1) + val current = System.currentTimeMillis() + assertSoftly(limiter) { + shouldNotThrowAny { acquire(current) } + shouldNotThrowAny { acquire(current + 1000) } + shouldNotThrowAny { acquire(current + 2000) } + } + } + + "초당 1회로 제한할 경우 초당 2번 요청하면 실패한다" { + val limiter = RateLimiter(1) + val current = System.currentTimeMillis() + assertSoftly(limiter) { + shouldNotThrowAny { acquire(current) } + shouldThrow { acquire(current + 500) } + shouldNotThrowAny { acquire(current + 1000) } + shouldThrow { acquire(current + 1500) } + } + } + + "초당 2회로 제한할 경우 초당 2회 요청은 성공한다" { + val limiter = RateLimiter(2) + val current = System.currentTimeMillis() + assertSoftly(limiter) { + shouldNotThrowAny { acquire(current) } + shouldNotThrowAny { acquire(current) } + shouldNotThrowAny { acquire(current + 1000) } + shouldNotThrowAny { acquire(current + 1500) } + shouldNotThrowAny { acquire(current + 2000) } + } + } + + "초당 2회로 제한할 경우 초당 3번 요청하면 실패한다" { + val limiter = RateLimiter(2) + val current = System.currentTimeMillis() + assertSoftly(limiter) { + shouldNotThrowAny { acquire(current) } + shouldNotThrowAny { acquire(current) } + shouldThrow { acquire(current) } + shouldNotThrowAny { acquire(current + 1000) } + shouldNotThrowAny { acquire(current + 1500) } + shouldThrow { acquire(current + 1600) } + shouldNotThrowAny { acquire(current + 2000) } + shouldThrow { acquire(current + 2100) } + } + } +}) From 5a038597297813aeb22022849ddc853b177a1f2c Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Sat, 23 Jul 2022 11:34:13 +0900 Subject: [PATCH 28/31] fix(mail): add the missing sender --- src/main/kotlin/apply/infra/mail/AwsMailSender.kt | 2 +- src/main/kotlin/apply/ui/admin/mail/MailForm.kt | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 15deff1ab..3b0eaf681 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -79,7 +79,7 @@ class AwsMailSender( files = attachments ) val rawEmailRequest = multipartMimeMessage.getRawEmailRequest() - client.sendRawEmail(rawEmailRequest) + client.sendRawEmail(rawEmailRequest) // TODO MessageRejectedException 처리 } private fun createContent(data: String): Content { diff --git a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt index c4a7fbc62..8b1737d4e 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt @@ -36,6 +36,7 @@ class MailForm( private val mailProperties: MailProperties ) : BindingFormLayout(MailData::class) { private val subject: TextField = TextField("제목").apply { setWidthFull() } + private val sender: TextField = createSender() private val body: TextArea = createBody() private val mailTargets: MutableSet = mutableSetOf() private val uploadFile: MutableMap = mutableMapOf() @@ -44,13 +45,13 @@ class MailForm( private val fileUpload: Upload = createFileUpload() init { - add(subject, createSender(), recipientFilter, mailTargetsGrid, body, fileUpload) + add(subject, sender, recipientFilter, mailTargetsGrid, body, fileUpload) setResponsiveSteps(ResponsiveStep("0", 1)) drawRequired() refreshGridFooter() } - private fun createSender(): Component { + private fun createSender(): TextField { return TextField("보낸사람").apply { value = mailProperties.username isReadOnly = true From ca6e986213a35b2c0290ff087b7d22601c3e225e Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Mon, 25 Jul 2022 09:53:55 +0900 Subject: [PATCH 29/31] fix(mail): specify the size of the attachment according to the settings of aws and spring --- src/main/kotlin/apply/infra/mail/AwsMailSender.kt | 3 ++- .../kotlin/apply/ui/admin/evaluation/EvaluationForm.kt | 1 - src/main/kotlin/apply/ui/admin/mail/MailForm.kt | 3 +++ src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt | 8 ++++++-- src/main/kotlin/support/views/Buttons.kt | 2 +- src/main/resources/application.properties | 3 +++ 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt index 3b0eaf681..ce2cbcd63 100644 --- a/src/main/kotlin/apply/infra/mail/AwsMailSender.kt +++ b/src/main/kotlin/apply/infra/mail/AwsMailSender.kt @@ -79,7 +79,8 @@ class AwsMailSender( files = attachments ) val rawEmailRequest = multipartMimeMessage.getRawEmailRequest() - client.sendRawEmail(rawEmailRequest) // TODO MessageRejectedException 처리 + // TODO: MessageRejectedException, AmazonSimpleEmailServiceException 처리 + client.sendRawEmail(rawEmailRequest) } private fun createContent(data: String): Content { diff --git a/src/main/kotlin/apply/ui/admin/evaluation/EvaluationForm.kt b/src/main/kotlin/apply/ui/admin/evaluation/EvaluationForm.kt index 7629199d5..f5520bff5 100644 --- a/src/main/kotlin/apply/ui/admin/evaluation/EvaluationForm.kt +++ b/src/main/kotlin/apply/ui/admin/evaluation/EvaluationForm.kt @@ -89,7 +89,6 @@ class EvaluationForm() : BindingIdentityFormLayout(EvaluationDat result?.beforeEvaluation = beforeEvaluation.value } return result?.apply { - recruitment evaluationItems = items } } diff --git a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt index 8b1737d4e..d0d1bb113 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailForm.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailForm.kt @@ -129,6 +129,9 @@ class MailForm( } override fun bindOrNull(): MailData? { + if (mailTargets.isEmpty()) { + return null + } return bindDefaultOrNull()?.apply { recipients = mailTargets.map { it.email }.toList() attachments = uploadFile diff --git a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt index 8fdd424d8..d1ac8ccdb 100644 --- a/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt +++ b/src/main/kotlin/apply/ui/admin/mail/MailsFormView.kt @@ -22,6 +22,7 @@ import support.views.EDIT_VALUE import support.views.FORM_URL_PATTERN import support.views.Title import support.views.createContrastButton +import support.views.createNotification import support.views.createPrimaryButton @Route(value = "admin/mails", layout = BaseLayout::class) @@ -65,8 +66,11 @@ class MailsFormView( private fun createSubmitButton(): Button { return createPrimaryButton("보내기") { - mailForm.bindOrNull()?.let { - mailService.sendMailsByBcc(it, it.attachments) + val result = mailForm.bindOrNull() + if (result == null) { + createNotification("받는사람을 한 명 이상 지정해야 합니다.") + } else { + mailService.sendMailsByBcc(result, result.attachments) UI.getCurrent().navigate(MailsView::class.java) } } diff --git a/src/main/kotlin/support/views/Buttons.kt b/src/main/kotlin/support/views/Buttons.kt index 139f6839c..c0f63d119 100644 --- a/src/main/kotlin/support/views/Buttons.kt +++ b/src/main/kotlin/support/views/Buttons.kt @@ -38,7 +38,7 @@ fun createUpload( succeededListener: UploadSucceededListener ): Upload { return Upload(receiver).apply { - maxFileSize = 10_485_760 + maxFileSize = 3_145_728 // spring.servlet.multipart.max-file-size uploadButton = createPrimaryButton(text) { } addSucceededListener { succeededListener(receiver) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index dafc18352..a72aa807d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -10,6 +10,9 @@ spring.mail.properties.mail.smtp.starttls.enable=true spring.mail.properties.mail.mime.charset=UTF-8 spring.mail.username=EMAIL +spring.servlet.multipart.max-file-size=3MB +spring.servlet.multipart.max-request-size=6MB + aws.access-key=ACCESS_KEY aws.secret-key=SECRET_KEY From b894da05265b4b6a81f93ee5319b3c427af8e384 Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Mon, 25 Jul 2022 21:31:39 +0900 Subject: [PATCH 30/31] refactor(mail): distinguish between mail success and failure --- src/main/kotlin/apply/application/mail/MailService.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/apply/application/mail/MailService.kt b/src/main/kotlin/apply/application/mail/MailService.kt index aba414f6e..058ab3f87 100644 --- a/src/main/kotlin/apply/application/mail/MailService.kt +++ b/src/main/kotlin/apply/application/mail/MailService.kt @@ -99,8 +99,14 @@ class MailService( } val body = templateEngine.process("mail/common", context) val recipients = request.recipients + mailProperties.username - for (targetMailsPart in recipients.chunked(MAIL_SENDING_UNIT)) { - mailSender.sendBcc(targetMailsPart, request.subject, body, files) + + // TODO: 성공과 실패를 분리하여 히스토리 관리 + val succeeded = mutableListOf() + val failed = mutableListOf() + for (addresses in recipients.chunked(MAIL_SENDING_UNIT)) { + runCatching { mailSender.sendBcc(addresses, request.subject, body, files) } + .onSuccess { succeeded.addAll(addresses) } + .onFailure { failed.addAll(addresses) } } mailHistoryRepository.save( From 1083edc5104dd5b9989b4c562c5b0711002ab58b Mon Sep 17 00:00:00 2001 From: woowahan-pjs Date: Mon, 25 Jul 2022 23:10:34 +0900 Subject: [PATCH 31/31] test(support): add more rate limiter tests using coroutines --- src/main/kotlin/support/infra/RateLimiter.kt | 1 + .../kotlin/support/infra/RateLimiterTest.kt | 68 +++++++++++-------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/src/main/kotlin/support/infra/RateLimiter.kt b/src/main/kotlin/support/infra/RateLimiter.kt index 3ae88d349..da209c3c8 100644 --- a/src/main/kotlin/support/infra/RateLimiter.kt +++ b/src/main/kotlin/support/infra/RateLimiter.kt @@ -32,6 +32,7 @@ class RateLimiter( private fun sync(requestTime: Long) { while (available.isNotEmpty()) { val elapsedTime = requestTime - available.peek() + require(elapsedTime >= 0) { "잘못된 요청 시간입니다." } if (elapsedTime.milliseconds < 1.seconds) { break } diff --git a/src/test/kotlin/support/infra/RateLimiterTest.kt b/src/test/kotlin/support/infra/RateLimiterTest.kt index 1505f519f..da2091a02 100644 --- a/src/test/kotlin/support/infra/RateLimiterTest.kt +++ b/src/test/kotlin/support/infra/RateLimiterTest.kt @@ -4,53 +4,63 @@ import io.kotest.assertions.assertSoftly import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.StringSpec +import io.kotest.inspectors.forAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.time.Duration.Companion.milliseconds + +private val Int.ms: Long get() = milliseconds.inWholeMilliseconds class RateLimiterTest : StringSpec({ "초당 1회로 제한할 경우 1초 간격 요청은 성공한다" { val limiter = RateLimiter(1) - val current = System.currentTimeMillis() - assertSoftly(limiter) { - shouldNotThrowAny { acquire(current) } - shouldNotThrowAny { acquire(current + 1000) } - shouldNotThrowAny { acquire(current + 2000) } + listOf(0.ms, 1000.ms, 2000.ms).forAll { + shouldNotThrowAny { limiter.acquire(it) } } } "초당 1회로 제한할 경우 초당 2번 요청하면 실패한다" { - val limiter = RateLimiter(1) - val current = System.currentTimeMillis() - assertSoftly(limiter) { - shouldNotThrowAny { acquire(current) } - shouldThrow { acquire(current + 500) } - shouldNotThrowAny { acquire(current + 1000) } - shouldThrow { acquire(current + 1500) } + val limiter = RateLimiter(1).apply { + acquire(0.ms) + } + listOf(100.ms, 500.ms, 900.ms).forAll { + shouldThrow { limiter.acquire(it) } } } "초당 2회로 제한할 경우 초당 2회 요청은 성공한다" { val limiter = RateLimiter(2) - val current = System.currentTimeMillis() - assertSoftly(limiter) { - shouldNotThrowAny { acquire(current) } - shouldNotThrowAny { acquire(current) } - shouldNotThrowAny { acquire(current + 1000) } - shouldNotThrowAny { acquire(current + 1500) } - shouldNotThrowAny { acquire(current + 2000) } + listOf(0.ms, 500.ms, 1000.ms, 1500.ms, 2000.ms).forAll { + shouldNotThrowAny { limiter.acquire(it) } } } "초당 2회로 제한할 경우 초당 3번 요청하면 실패한다" { - val limiter = RateLimiter(2) - val current = System.currentTimeMillis() + val limiter = RateLimiter(2).apply { + acquire(500.ms) + acquire(600.ms) + } assertSoftly(limiter) { - shouldNotThrowAny { acquire(current) } - shouldNotThrowAny { acquire(current) } - shouldThrow { acquire(current) } - shouldNotThrowAny { acquire(current + 1000) } - shouldNotThrowAny { acquire(current + 1500) } - shouldThrow { acquire(current + 1600) } - shouldNotThrowAny { acquire(current + 2000) } - shouldThrow { acquire(current + 2100) } + shouldThrow { acquire(1400.ms) } + shouldNotThrowAny { acquire(1500.ms) } + } + } + + "요청은 순서대로 이루어져야 한다" { + val limiter = RateLimiter(2).apply { + acquire(1000.ms) + } + shouldThrow { limiter.acquire(0.ms) } + } + + "경쟁 상태에서도 순서를 유지한다" { + runTest { + val limiter = RateLimiter(1000) + launch { + repeat(1000) { + shouldNotThrowAny { limiter.acquire() } + } + }.join() } } })