-
-
Notifications
You must be signed in to change notification settings - Fork 206
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: rough first implementation of emails in backend
- Loading branch information
Showing
22 changed files
with
454 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
81 changes: 81 additions & 0 deletions
81
backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
/** | ||
* Copyright (C) 2024 Tolgee s.r.o. and contributors | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package io.tolgee.email | ||
|
||
import io.tolgee.configuration.tolgee.SmtpProperties | ||
import io.tolgee.dtos.misc.EmailAttachment | ||
import org.springframework.beans.factory.annotation.Qualifier | ||
import org.springframework.mail.javamail.JavaMailSender | ||
import org.springframework.mail.javamail.MimeMessageHelper | ||
import org.springframework.scheduling.annotation.Async | ||
import org.springframework.stereotype.Service | ||
import org.thymeleaf.TemplateEngine | ||
import org.thymeleaf.context.Context | ||
import java.util.* | ||
|
||
@Service | ||
class EmailService( | ||
private val smtpProperties: SmtpProperties, | ||
private val mailSender: JavaMailSender, | ||
@Qualifier("emailTemplateEngine") private val templateEngine: TemplateEngine, | ||
) { | ||
private val smtpFrom | ||
get() = smtpProperties.from | ||
?: throw IllegalStateException("SMTP sender is not configured. See https://docs.tolgee.io/platform/self_hosting/configuration#smtp") | ||
|
||
@Async | ||
fun sendEmailTemplate( | ||
recipient: String, | ||
template: String, | ||
locale: Locale, | ||
properties: Map<String, Any> = mapOf(), | ||
attachments: List<EmailAttachment> = listOf() | ||
) { | ||
val context = Context(locale, properties) | ||
val html = templateEngine.process(template, context) | ||
val subject = extractEmailTitle(html) | ||
|
||
sendEmail(recipient, subject, html, attachments) | ||
} | ||
|
||
@Async | ||
fun sendEmail( | ||
recipient: String, | ||
subject: String, | ||
contents: String, | ||
attachments: List<EmailAttachment> = listOf() | ||
) { | ||
val message = mailSender.createMimeMessage() | ||
val helper = MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,"UTF8") | ||
|
||
helper.setFrom(smtpFrom) | ||
helper.setTo(recipient) | ||
helper.setSubject(subject) | ||
helper.setText(contents, true) | ||
attachments.forEach { helper.addAttachment(it.name, it.inputStreamSource) } | ||
|
||
mailSender.send(message) | ||
} | ||
|
||
private fun extractEmailTitle(html: String): String { | ||
return REGEX_TITLE.find(html)!!.groupValues[1] | ||
} | ||
|
||
companion object { | ||
private val REGEX_TITLE = Regex("<title>(.+?)</title>") | ||
} | ||
} |
62 changes: 62 additions & 0 deletions
62
backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateConfig.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
/** | ||
* Copyright (C) 2024 Tolgee s.r.o. and contributors | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package io.tolgee.email | ||
|
||
import org.springframework.beans.factory.annotation.Qualifier | ||
import org.springframework.context.MessageSource | ||
import org.springframework.context.annotation.Bean | ||
import org.springframework.context.annotation.Configuration | ||
import org.springframework.context.support.ResourceBundleMessageSource | ||
import org.thymeleaf.TemplateEngine | ||
import org.thymeleaf.spring6.SpringTemplateEngine | ||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver | ||
import org.thymeleaf.templateresolver.ITemplateResolver | ||
import java.util.* | ||
|
||
@Configuration | ||
class EmailTemplateConfig { | ||
@Bean("emailTemplateResolver") | ||
fun templateResolver(): ClassLoaderTemplateResolver { | ||
val templateResolver = ClassLoaderTemplateResolver() | ||
templateResolver.characterEncoding = "UTF-8" | ||
templateResolver.prefix = "/email-templates/" | ||
templateResolver.suffix = ".html" | ||
return templateResolver | ||
} | ||
|
||
@Bean("emailMessageSource") | ||
fun messageSource(): MessageSource { | ||
val messageSource = ResourceBundleMessageSource() | ||
messageSource.setBasename("email-i18n.messages") | ||
messageSource.setDefaultEncoding("UTF-8") | ||
messageSource.setDefaultLocale(Locale.ENGLISH) | ||
println(messageSource.getMessage("powered-by", null, Locale.ENGLISH)) | ||
println(messageSource.getMessage("powered-by", null, Locale.FRENCH)) | ||
return messageSource | ||
} | ||
|
||
@Bean("emailTemplateEngine") | ||
fun templateEngine( | ||
@Qualifier("emailTemplateResolver") templateResolver: ITemplateResolver, | ||
@Qualifier("emailMessageSource") messageSource: MessageSource | ||
): TemplateEngine { | ||
val templateEngine = SpringTemplateEngine() | ||
templateEngine.templateResolvers = setOf(templateResolver) | ||
templateEngine.setTemplateEngineMessageSource(messageSource) | ||
return templateEngine | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
../../../../../email/i18n |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
../../../../../email/out |
121 changes: 121 additions & 0 deletions
121
backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
/** | ||
* Copyright (C) 2024 Tolgee s.r.o. and contributors | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package io.tolgee.email | ||
|
||
import io.tolgee.configuration.tolgee.SmtpProperties | ||
import io.tolgee.testing.assert | ||
import jakarta.mail.internet.MimeMessage | ||
import jakarta.mail.internet.MimeMultipart | ||
import org.junit.jupiter.api.BeforeEach | ||
import org.junit.jupiter.api.Test | ||
import org.junit.jupiter.api.extension.ExtendWith | ||
import org.mockito.ArgumentCaptor | ||
import org.mockito.Captor | ||
import org.mockito.kotlin.verify | ||
import org.mockito.kotlin.whenever | ||
import org.springframework.beans.factory.annotation.Autowired | ||
import org.springframework.boot.test.mock.mockito.MockBean | ||
import org.springframework.context.annotation.Import | ||
import org.springframework.mail.javamail.JavaMailSender | ||
import org.springframework.mail.javamail.JavaMailSenderImpl | ||
import org.springframework.stereotype.Component | ||
import org.springframework.test.context.junit.jupiter.SpringExtension | ||
import java.util.* | ||
|
||
@Component | ||
@ExtendWith(SpringExtension::class) | ||
@Import(EmailService::class, EmailTemplateConfig::class) | ||
class EmailServiceTest { | ||
@MockBean | ||
private lateinit var smtpProperties: SmtpProperties | ||
|
||
@MockBean | ||
private lateinit var mailSender: JavaMailSender | ||
|
||
@Autowired | ||
private lateinit var emailService: EmailService | ||
|
||
@Captor | ||
private lateinit var emailCaptor: ArgumentCaptor<MimeMessage> | ||
|
||
@BeforeEach | ||
fun beforeEach() { | ||
val sender = JavaMailSenderImpl() | ||
whenever(smtpProperties.from).thenReturn("Tolgee Test <[email protected]>") | ||
whenever(mailSender.createMimeMessage()).let { | ||
val msg = sender.createMimeMessage() | ||
it.thenReturn(msg) | ||
} | ||
} | ||
|
||
@Test | ||
fun `it sends a rendered email with variables and ICU strings processed`() { | ||
emailService.sendEmailTemplate("[email protected]", "zz-test-email", Locale.ENGLISH, TEST_PROPERTIES) | ||
verify(mailSender).send(emailCaptor.capture()) | ||
|
||
val email = emailCaptor.value | ||
email.subject.assert.isEqualTo("Test email (written with React Email)") | ||
email.allRecipients.asList().assert.singleElement().asString().isEqualTo("[email protected]") | ||
|
||
email.content | ||
.let { it as MimeMultipart } | ||
.let { it.getBodyPart(0).content as MimeMultipart } | ||
.let { it.getBodyPart(0).content as String } | ||
.assert | ||
.contains("Value of `testVar` : <span>test!!</span>") | ||
.contains("<span>Was `testVar` equal to "meow" : </span><span>no</span>") | ||
.contains("Powered by") | ||
.doesNotContain(" th:") | ||
.doesNotContain(" data-th") | ||
} | ||
|
||
@Test | ||
fun `it correctly processes conditional blocks`() { | ||
// FWIW this is very close to just testing Thymeleaf itself, but it serves as a sanity check for the template itself | ||
emailService.sendEmailTemplate("[email protected]", "zz-test-email", Locale.ENGLISH, TEST_PROPERTIES_MEOW) | ||
verify(mailSender).send(emailCaptor.capture()) | ||
|
||
val email = emailCaptor.value | ||
email.content | ||
.let { it as MimeMultipart } | ||
.let { it.getBodyPart(0).content as MimeMultipart } | ||
.let { it.getBodyPart(0).content as String } | ||
.assert | ||
.contains("Value of `testVar` : <span>meow</span>") | ||
.contains("<span>Was `testVar` equal to "meow" : </span><span>yes</span>") | ||
} | ||
|
||
companion object { | ||
private val TEST_PROPERTIES = mapOf( | ||
"testVar" to "test!!", | ||
"testList" to listOf( | ||
mapOf("name" to "Name #1"), | ||
mapOf("name" to "Name #2"), | ||
mapOf("name" to "Name #3"), | ||
) | ||
) | ||
|
||
private val TEST_PROPERTIES_MEOW = mapOf( | ||
"testVar" to "meow", | ||
"testList" to listOf( | ||
mapOf("name" to "Name #1"), | ||
mapOf("name" to "Name #2"), | ||
mapOf("name" to "Name #3"), | ||
) | ||
) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.