Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(mail): implement sending mail #352

Merged
merged 32 commits into from
Jul 26, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
536b0b2
feat: 메일 기능 구현
MyaGya Oct 19, 2021
b49560d
feat: 메일 폼 구현
MyaGya Oct 19, 2021
ad7e12a
feat: 메일 폼 구현
MyaGya Oct 19, 2021
1ed932f
refactor: MultipartFile Byte 변경 최적화
MyaGya Oct 19, 2021
f002999
feat: html 코드에 대응하고 템플릿을 이용하여 전송하는 기능 추가
MyaGya Oct 20, 2021
8cd0a6c
feat: common 메일 템플릿 title 제거
SunYoungKwon Oct 20, 2021
6327ac5
feat: 첨부 파일 삭제시 삭제되도록 삭제 기능 추가
MyaGya Oct 20, 2021
b174c42
refactor: 코드리뷰 반영
MyaGya Oct 20, 2021
4a6913f
refactor: 사용하지 않는 이메일, 타이틀 제거
MyaGya Oct 20, 2021
41469a1
refactor: 코드 포맷팅 변경
MyaGya Oct 20, 2021
ac18b3f
refactor: 사용이 끝난 BytearrayOutputStream 을 close
MyaGya Oct 21, 2021
045873e
refactor(mail): bcc 메시지 생성 로직 간소화
woowahan-neo Jul 15, 2022
f731050
refactor(mail): attachFiles -> attachments 이름 변경
woowahan-neo Jul 18, 2022
c17566a
refactor(mail): sendBcc 받는 이메일 타입 Array -> List 변경
woowahan-neo Jul 18, 2022
5cf262d
refactor(mail): 동시 전송 인원 상수화
woowahan-neo Jul 18, 2022
51eea62
fix(mail): 발송 확인을 위해 받은 사람에 우테코 메일을 지정
woowahan-neo Jul 18, 2022
90879cb
style(mail): 정렬
woowahan-neo Jul 18, 2022
4342dcb
feat(mail): 메일 발송 권한 체크
woowahan-neo Jul 18, 2022
7e5ef08
feat(mail): 메일 발송 시 초당 최대 전송 속도 처리
woowahan-neo Jul 21, 2022
0d5c7c2
refactor(mail): 렌더링 로직 Sender -> Service 이동
woowahan-neo Jul 21, 2022
7f2d705
chore(mail): 불필요한 쉼표 제거
woowahan-neo Jul 21, 2022
62d2a1e
refactor(mail): 메일 발송 후 히스토리를 남기도록 수정
woowahan-neo Jul 21, 2022
261d41e
fix(mail): change user
woowahan-neo Jul 22, 2022
f8ee988
Merge branch 'develop' into feature/mail-send-api
woowahan-pjs Jul 22, 2022
54f6a6e
refactor(test): remove unused features
woowahan-pjs Jul 22, 2022
cf18525
style: polish the code
woowahan-pjs Jul 22, 2022
b2cbf09
refactor(test): migrate request limiter tests to kotest
woowahan-pjs Jul 22, 2022
7fc8599
refactor(support): move from the apply package to the support package
woowahan-pjs Jul 23, 2022
5a03859
fix(mail): add the missing sender
woowahan-pjs Jul 23, 2022
ca6e986
fix(mail): specify the size of the attachment according to the settin…
woowahan-pjs Jul 25, 2022
b894da0
refactor(mail): distinguish between mail success and failure
woowahan-pjs Jul 25, 2022
1083edc
test(support): add more rate limiter tests using coroutines
woowahan-pjs Jul 25, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 0 additions & 13 deletions src/main/kotlin/apply/application/MailHistoryService.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<MailData> {
return mailHistoryRepository.findAll().map { MailData(it) }
}
Expand Down
5 changes: 4 additions & 1 deletion src/main/kotlin/apply/application/mail/MailData.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,6 +25,8 @@ data class MailData(
@field:NotNull
var sentTime: LocalDateTime = LocalDateTime.now(),

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

@field:NotNull
var id: Long = 0L
) {
Expand All @@ -33,6 +36,6 @@ data class MailData(
mailHistory.sender,
mailHistory.recipients,
mailHistory.sentTime,
mailHistory.id
id = mailHistory.id
)
}
4 changes: 4 additions & 0 deletions src/main/kotlin/apply/application/mail/MailSender.kt
Original file line number Diff line number Diff line change
@@ -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: List<String>, subject: String, body: String, attachments: Map<String, ByteArrayResource>)
}
43 changes: 42 additions & 1 deletion src/main/kotlin/apply/application/mail/MailService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,32 @@ 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
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
import org.thymeleaf.context.Context
import org.thymeleaf.spring5.ISpringTemplateEngine

private const val MAIL_SENDING_UNIT: Int = 50

@Service
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
private val mailSender: MailSender,
private val mailProperties: MailProperties
) {
@Async
@TransactionalEventListener
Expand Down Expand Up @@ -78,4 +86,37 @@ class MailService(
templateEngine.process("mail/email-authentication.html", context)
)
}

@Async
fun sendMailsByBcc(request: MailData, files: Map<String, ByteArrayResource>) {
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

// TODO: 성공과 실패를 분리하여 히스토리 관리
val succeeded = mutableListOf<String>()
val failed = mutableListOf<String>()
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(
MailHistory(
request.subject,
request.body,
request.sender,
request.recipients,
request.sentTime
)
)
}
}
115 changes: 114 additions & 1 deletion src/main/kotlin/apply/infra/mail/AwsMailSender.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,26 @@ 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 support.infra.RateLimiter
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(
Copy link
Contributor

Choose a reason for hiding this comment

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

현재 적용된 Amazon SES 전송 할당량입니다. 아래의 표와 문서를 참고하여 구현해 보세요.

할당량 이름 적용된 할당량 값 AWS 기본 할당량 값
 일일 전송 할당량 100,000 200
 최대 전송 속도 14 1

일일 전송 할당량

현재 AWS 리전의 이 계정에 대하여 24시간 동안 보낼 수 있는 이메일의 최대 개수입니다.

최대 전송 속도

Amazon SES가 현재 AWS 리전의 이 계정에 대해 초당 수락할 수 있는 최대 이메일 개수입니다.

Copy link
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Contributor

Choose a reason for hiding this comment

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

하루에 100,000건까지 보낼 수 있어서 큰 의미가 없다고 판단했어요!

Expand All @@ -32,7 +49,10 @@ class AwsMailSender(
.withRegion(Regions.AP_NORTHEAST_2)
.build()

private val rateLimiter = RateLimiter(14)

override fun send(toAddress: String, subject: String, body: String) {
rateLimiter.acquire()
val request = SendEmailRequest()
.withSource(mailProperties.username)
.withDestination(Destination().withToAddresses(toAddress))
Expand All @@ -44,7 +64,100 @@ class AwsMailSender(
client.sendEmail(request)
}

override fun sendBcc(
toAddresses: List<String>,
subject: String,
body: String,
attachments: Map<String, ByteArrayResource>
) {
rateLimiter.acquire()
val multipartMimeMessage = MultipartMimeMessage(
subject = subject,
userName = mailProperties.username,
recipient = toAddresses,
body = body,
files = attachments
)
val rawEmailRequest = multipartMimeMessage.getRawEmailRequest()
// TODO: MessageRejectedException, AmazonSimpleEmailServiceException 처리
client.sendRawEmail(rawEmailRequest)
}

private fun createContent(data: String): Content {
return Content(data).withCharset("UTF-8")
return Content(data).withCharset(Charsets.UTF_8.name())
}
}

private class MultipartMimeMessage(
val message: MimeMessage = MimeMessage(Session.getDefaultInstance(Properties()))
) {
constructor(
subject: String,
userName: String,
recipient: List<String>,
body: String,
files: Map<String, ByteArrayResource>
) : this() {
val mimeMultipart = MimeMultipart("mixed")
setSubject(subject)
setFrom(userName)
setRecipient(recipient)
addBody(body, 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: List<String>) {
message.setRecipients(
RecipientType.BCC,
recipient.map { InternetAddress(it) }.toTypedArray()
)
}

fun addBody(body: String, mimeMixedPart: MimeMultipart) {
val messageBody = MimeMultipart("alternative")
val wrap = MimeBodyPart()
val htmlPart = MimeBodyPart()
htmlPart.setContent(body, "text/html; charset=UTF-8")
messageBody.addBodyPart(htmlPart)
wrap.setContent(messageBody)
message.setContent(mimeMixedPart)
mimeMixedPart.addBodyPart(wrap)
}

fun addAttachment(files: Map<String, ByteArrayResource>, 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()))
}
}
}
20 changes: 20 additions & 0 deletions src/main/kotlin/apply/infra/mail/SimpleMailSender.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -19,4 +20,23 @@ class SimpleMailSender(
}
mailSender.send(message)
}

override fun sendBcc(
toAddresses: List<String>,
subject: String,
body: String,
attachments: Map<String, ByteArrayResource>
) {
val message = mailSender.createMimeMessage()
val mimeMessageHelper = MimeMessageHelper(message, true).apply {
setFrom(mailProperties.username)
MyaGya marked this conversation as resolved.
Show resolved Hide resolved
setBcc(toAddresses.toTypedArray())
setSubject(subject)
setText(body, true)
}
attachments.forEach { (fileName, data) ->
mimeMessageHelper.addAttachment(fileName, data)
}
mailSender.send(message)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ class EvaluationForm() : BindingIdentityFormLayout<EvaluationData>(EvaluationDat
result?.beforeEvaluation = beforeEvaluation.value
}
return result?.apply {
recruitment
evaluationItems = items
}
}
Expand Down
32 changes: 21 additions & 11 deletions src/main/kotlin/apply/ui/admin/mail/MailForm.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ 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
import support.views.NO_NAME
import support.views.addSortableColumn
Expand All @@ -34,20 +36,22 @@ class MailForm(
private val mailProperties: MailProperties
) : BindingFormLayout<MailData>(MailData::class) {
private val subject: TextField = TextField("제목").apply { setWidthFull() }
private val sender: TextField = createSender()
private val body: TextArea = createBody()
private val mailTargets: MutableSet<MailTargetResponse> = mutableSetOf()
private val uploadFile: MutableMap<String, ByteArrayResource> = mutableMapOf()
private val mailTargetsGrid: Grid<MailTargetResponse> = 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)
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
Expand Down Expand Up @@ -103,15 +107,17 @@ 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()
// }
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<MailTargetResponse> {
Expand All @@ -123,8 +129,12 @@ class MailForm(
}

override fun bindOrNull(): MailData? {
if (mailTargets.isEmpty()) {
return null
}
return bindDefaultOrNull()?.apply {
recipients = mailTargets.map { it.email }.toList()
attachments = uploadFile
}
}

Expand Down
Loading