Skip to content

Commit

Permalink
feat: rough first implementation of emails in backend
Browse files Browse the repository at this point in the history
  • Loading branch information
cyyynthia committed Nov 17, 2024
1 parent bcaacce commit 0d73c0f
Show file tree
Hide file tree
Showing 22 changed files with 454 additions and 39 deletions.
3 changes: 2 additions & 1 deletion backend/app/src/main/kotlin/io/tolgee/Application.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.autoconfigure.domain.EntityScan
import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.data.jpa.repository.config.EnableJpaRepositories

@SpringBootApplication(
scanBasePackages = ["io.tolgee"],
exclude = [LdapAutoConfiguration::class],
exclude = [LdapAutoConfiguration::class, ThymeleafAutoConfiguration::class],
)
@EnableJpaAuditing
@EntityScan("io.tolgee.model")
Expand Down
16 changes: 7 additions & 9 deletions backend/data/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ apply plugin: 'org.hibernate.orm'

repositories {
mavenCentral()
jcenter()
}

idea {
Expand All @@ -71,6 +70,7 @@ allOpen {
annotation("org.springframework.beans.factory.annotation.Configurable")
}

apply from: "$rootDir/gradle/email.gradle"
apply from: "$rootDir/gradle/liquibase.gradle"

configureLiquibase("public", "hibernate:spring:io.tolgee", 'src/main/resources/db/changelog/schema.xml')
Expand Down Expand Up @@ -98,6 +98,7 @@ dependencies {
implementation "org.springframework.boot:spring-boot-configuration-processor"
implementation "org.springframework.boot:spring-boot-starter-batch"
implementation "org.springframework.boot:spring-boot-starter-websocket"
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"

/**
* DB
Expand Down Expand Up @@ -170,6 +171,7 @@ dependencies {
implementation("org.apache.commons:commons-configuration2:2.10.1")
implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion"
implementation("com.opencsv:opencsv:5.9")
implementation 'ognl:ognl:3.3.4'

/**
* Google translation API
Expand Down Expand Up @@ -231,18 +233,14 @@ tasks.named('compileJava') {
inputs.files(tasks.named('processResources'))
}

tasks.named('compileKotlin') {
dependsOn 'buildEmails'
}

ktlint {
debug = true
verbose = true
filter {
exclude("**/PluralData.kt")
}
}

hibernate {
enhancement {
enableDirtyTracking = false
enableAssociationManagement = false
enableExtendedEnhancement = false
}
}
81 changes: 81 additions & 0 deletions backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt
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>")
}
}
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
}
}
1 change: 1 addition & 0 deletions backend/data/src/main/resources/email-i18n
1 change: 1 addition & 0 deletions backend/data/src/main/resources/email-templates
121 changes: 121 additions & 0 deletions backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt
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 &quot;meow&quot; : </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 &quot;meow&quot; : </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"),
)
)
}
}
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ project(':server-app').afterEvaluate {
}
}

apply from: "./gradle/email.gradle"
apply from: "./gradle/webapp.gradle"
apply from: "./gradle/docker.gradle"
apply from: "./gradle/e2e.gradle"
Expand All @@ -66,6 +67,7 @@ project(':server-app').afterEvaluate {
task packResources(type: Zip) {
dependsOn "unpack"
dependsOn "copyDist"
dependsOn "copyEmailResources"
dependsOn "addVersionFile"
from "${project.projectDir}/build/dependency"
archiveFileName = "tolgee.jar"
Expand Down
8 changes: 4 additions & 4 deletions email/.config/extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ export default async function extractor(
code
);

if (res.key) keys.push(res.key)
if (res.warning) warnings.push(res.warning)
if (res.key) keys.push(res.key);
if (res.warning) warnings.push(res.warning);
}

if (
Expand Down Expand Up @@ -158,8 +158,8 @@ export default async function extractor(
code
);

if (res.key) keys.push(res.key)
if (res.warning) warnings.push(res.warning)
if (res.key) keys.push(res.key);
if (res.warning) warnings.push(res.warning);
}
},
});
Expand Down
6 changes: 3 additions & 3 deletions email/.config/tolgeerc.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"$schema": "https://docs.tolgee.io/cli-schema.json",
"projectId": 1,
"format": "PROPERTIES_JAVA",
"format": "PROPERTIES_ICU",
"patterns": ["./emails/**/*.ts?(x)", "./components/**/*.ts?(x)"],
"extractor": "./.config/extractor.ts",
"pull": {
"path": "./src/i18n",
"tags": ["email"]
"path": "./i18n",
"fileStructureTemplate": "messages_{snakeLanguageTag}.{extension}"
},
"push": {
"tagNewKeys": ["email"]
Expand Down
Loading

0 comments on commit 0d73c0f

Please sign in to comment.