Skip to content

Commit 3e1496b

Browse files
committed
feat: rough first implementation of emails in backend
1 parent bcaacce commit 3e1496b

21 files changed

+453
-38
lines changed

backend/app/src/main/kotlin/io/tolgee/Application.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import org.springframework.boot.SpringApplication
55
import org.springframework.boot.autoconfigure.SpringBootApplication
66
import org.springframework.boot.autoconfigure.domain.EntityScan
77
import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration
8+
import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration
89
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
910
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
1011
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
1112

1213
@SpringBootApplication(
1314
scanBasePackages = ["io.tolgee"],
14-
exclude = [LdapAutoConfiguration::class],
15+
exclude = [LdapAutoConfiguration::class, ThymeleafAutoConfiguration::class],
1516
)
1617
@EnableJpaAuditing
1718
@EntityScan("io.tolgee.model")

backend/data/build.gradle

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ apply plugin: 'org.hibernate.orm'
5252

5353
repositories {
5454
mavenCentral()
55-
jcenter()
5655
}
5756

5857
idea {
@@ -71,6 +70,7 @@ allOpen {
7170
annotation("org.springframework.beans.factory.annotation.Configurable")
7271
}
7372

73+
apply from: "$rootDir/gradle/email.gradle"
7474
apply from: "$rootDir/gradle/liquibase.gradle"
7575

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

102103
/**
103104
* DB
@@ -170,6 +171,7 @@ dependencies {
170171
implementation("org.apache.commons:commons-configuration2:2.10.1")
171172
implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$jacksonVersion"
172173
implementation("com.opencsv:opencsv:5.9")
174+
implementation 'ognl:ognl:3.3.4'
173175

174176
/**
175177
* Google translation API
@@ -231,18 +233,14 @@ tasks.named('compileJava') {
231233
inputs.files(tasks.named('processResources'))
232234
}
233235

236+
tasks.named('compileKotlin') {
237+
dependsOn 'buildEmails'
238+
}
239+
234240
ktlint {
235241
debug = true
236242
verbose = true
237243
filter {
238244
exclude("**/PluralData.kt")
239245
}
240246
}
241-
242-
hibernate {
243-
enhancement {
244-
enableDirtyTracking = false
245-
enableAssociationManagement = false
246-
enableExtendedEnhancement = false
247-
}
248-
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Copyright (C) 2024 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.tolgee.email
18+
19+
import io.tolgee.configuration.tolgee.SmtpProperties
20+
import io.tolgee.dtos.misc.EmailAttachment
21+
import org.springframework.beans.factory.annotation.Qualifier
22+
import org.springframework.mail.javamail.JavaMailSender
23+
import org.springframework.mail.javamail.MimeMessageHelper
24+
import org.springframework.scheduling.annotation.Async
25+
import org.springframework.stereotype.Service
26+
import org.thymeleaf.TemplateEngine
27+
import org.thymeleaf.context.Context
28+
import java.util.*
29+
30+
@Service
31+
class EmailService(
32+
private val smtpProperties: SmtpProperties,
33+
private val mailSender: JavaMailSender,
34+
@Qualifier("emailTemplateEngine") private val templateEngine: TemplateEngine,
35+
) {
36+
private val smtpFrom
37+
get() = smtpProperties.from
38+
?: throw IllegalStateException("SMTP sender is not configured. See https://docs.tolgee.io/platform/self_hosting/configuration#smtp")
39+
40+
@Async
41+
fun sendEmailTemplate(
42+
recipient: String,
43+
template: String,
44+
locale: Locale,
45+
properties: Map<String, Any> = mapOf(),
46+
attachments: List<EmailAttachment> = listOf()
47+
) {
48+
val context = Context(locale, properties)
49+
val html = templateEngine.process(template, context)
50+
val subject = extractEmailTitle(html)
51+
52+
sendEmail(recipient, subject, html, attachments)
53+
}
54+
55+
@Async
56+
fun sendEmail(
57+
recipient: String,
58+
subject: String,
59+
contents: String,
60+
attachments: List<EmailAttachment> = listOf()
61+
) {
62+
val message = mailSender.createMimeMessage()
63+
val helper = MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,"UTF8")
64+
65+
helper.setFrom(smtpFrom)
66+
helper.setTo(recipient)
67+
helper.setSubject(subject)
68+
helper.setText(contents, true)
69+
attachments.forEach { helper.addAttachment(it.name, it.inputStreamSource) }
70+
71+
mailSender.send(message)
72+
}
73+
74+
private fun extractEmailTitle(html: String): String {
75+
return REGEX_TITLE.find(html)!!.groupValues[1]
76+
}
77+
78+
companion object {
79+
private val REGEX_TITLE = Regex("<title>(.+?)</title>")
80+
}
81+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Copyright (C) 2024 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.tolgee.email
18+
19+
import org.springframework.beans.factory.annotation.Qualifier
20+
import org.springframework.context.MessageSource
21+
import org.springframework.context.annotation.Bean
22+
import org.springframework.context.annotation.Configuration
23+
import org.springframework.context.support.ResourceBundleMessageSource
24+
import org.thymeleaf.TemplateEngine
25+
import org.thymeleaf.spring6.SpringTemplateEngine
26+
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
27+
import org.thymeleaf.templateresolver.ITemplateResolver
28+
import java.util.*
29+
30+
@Configuration
31+
class EmailTemplateConfig {
32+
@Bean("emailTemplateResolver")
33+
fun templateResolver(): ClassLoaderTemplateResolver {
34+
val templateResolver = ClassLoaderTemplateResolver()
35+
templateResolver.characterEncoding = "UTF-8"
36+
templateResolver.prefix = "/email-templates/"
37+
templateResolver.suffix = ".html"
38+
return templateResolver
39+
}
40+
41+
@Bean("emailMessageSource")
42+
fun messageSource(): MessageSource {
43+
val messageSource = ResourceBundleMessageSource()
44+
messageSource.setBasename("email-i18n.messages")
45+
messageSource.setDefaultEncoding("UTF-8")
46+
messageSource.setDefaultLocale(Locale.ENGLISH)
47+
println(messageSource.getMessage("powered-by", null, Locale.ENGLISH))
48+
println(messageSource.getMessage("powered-by", null, Locale.FRENCH))
49+
return messageSource
50+
}
51+
52+
@Bean("emailTemplateEngine")
53+
fun templateEngine(
54+
@Qualifier("emailTemplateResolver") templateResolver: ITemplateResolver,
55+
@Qualifier("emailMessageSource") messageSource: MessageSource
56+
): TemplateEngine {
57+
val templateEngine = SpringTemplateEngine()
58+
templateEngine.templateResolvers = setOf(templateResolver)
59+
templateEngine.setTemplateEngineMessageSource(messageSource)
60+
return templateEngine
61+
}
62+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../../../email/i18n
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../../../email/out
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* Copyright (C) 2024 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.tolgee.email
18+
19+
import io.tolgee.configuration.tolgee.SmtpProperties
20+
import io.tolgee.testing.assert
21+
import jakarta.mail.internet.MimeMessage
22+
import jakarta.mail.internet.MimeMultipart
23+
import org.junit.jupiter.api.BeforeEach
24+
import org.junit.jupiter.api.Test
25+
import org.junit.jupiter.api.extension.ExtendWith
26+
import org.mockito.ArgumentCaptor
27+
import org.mockito.Captor
28+
import org.mockito.kotlin.verify
29+
import org.mockito.kotlin.whenever
30+
import org.springframework.beans.factory.annotation.Autowired
31+
import org.springframework.boot.test.mock.mockito.MockBean
32+
import org.springframework.context.annotation.Import
33+
import org.springframework.mail.javamail.JavaMailSender
34+
import org.springframework.mail.javamail.JavaMailSenderImpl
35+
import org.springframework.stereotype.Component
36+
import org.springframework.test.context.junit.jupiter.SpringExtension
37+
import java.util.*
38+
39+
@Component
40+
@ExtendWith(SpringExtension::class)
41+
@Import(EmailService::class, EmailTemplateConfig::class)
42+
class EmailServiceTest {
43+
@MockBean
44+
private lateinit var smtpProperties: SmtpProperties
45+
46+
@MockBean
47+
private lateinit var mailSender: JavaMailSender
48+
49+
@Autowired
50+
private lateinit var emailService: EmailService
51+
52+
@Captor
53+
private lateinit var emailCaptor: ArgumentCaptor<MimeMessage>
54+
55+
@BeforeEach
56+
fun beforeEach() {
57+
val sender = JavaMailSenderImpl()
58+
whenever(smtpProperties.from).thenReturn("Tolgee Test <[email protected]>")
59+
whenever(mailSender.createMimeMessage()).let {
60+
val msg = sender.createMimeMessage()
61+
it.thenReturn(msg)
62+
}
63+
}
64+
65+
@Test
66+
fun `it sends a rendered email with variables and ICU strings processed`() {
67+
emailService.sendEmailTemplate("[email protected]", "zz-test-email", Locale.ENGLISH, TEST_PROPERTIES)
68+
verify(mailSender).send(emailCaptor.capture())
69+
70+
val email = emailCaptor.value
71+
email.subject.assert.isEqualTo("Test email (written with React Email)")
72+
email.allRecipients.asList().assert.singleElement().asString().isEqualTo("[email protected]")
73+
74+
email.content
75+
.let { it as MimeMultipart }
76+
.let { it.getBodyPart(0).content as MimeMultipart }
77+
.let { it.getBodyPart(0).content as String }
78+
.assert
79+
.contains("Value of `testVar` : <span>test!!</span>")
80+
.contains("<span>Was `testVar` equal to &quot;meow&quot; : </span><span>no</span>")
81+
.contains("Powered by")
82+
.doesNotContain(" th:")
83+
.doesNotContain(" data-th")
84+
}
85+
86+
@Test
87+
fun `it correctly processes conditional blocks`() {
88+
// FWIW this is very close to just testing Thymeleaf itself, but it serves as a sanity check for the template itself
89+
emailService.sendEmailTemplate("[email protected]", "zz-test-email", Locale.ENGLISH, TEST_PROPERTIES_MEOW)
90+
verify(mailSender).send(emailCaptor.capture())
91+
92+
val email = emailCaptor.value
93+
email.content
94+
.let { it as MimeMultipart }
95+
.let { it.getBodyPart(0).content as MimeMultipart }
96+
.let { it.getBodyPart(0).content as String }
97+
.assert
98+
.contains("Value of `testVar` : <span>meow</span>")
99+
.contains("<span>Was `testVar` equal to &quot;meow&quot; : </span><span>yes</span>")
100+
}
101+
102+
companion object {
103+
private val TEST_PROPERTIES = mapOf(
104+
"testVar" to "test!!",
105+
"testList" to listOf(
106+
mapOf("name" to "Name #1"),
107+
mapOf("name" to "Name #2"),
108+
mapOf("name" to "Name #3"),
109+
)
110+
)
111+
112+
private val TEST_PROPERTIES_MEOW = mapOf(
113+
"testVar" to "meow",
114+
"testList" to listOf(
115+
mapOf("name" to "Name #1"),
116+
mapOf("name" to "Name #2"),
117+
mapOf("name" to "Name #3"),
118+
)
119+
)
120+
}
121+
}

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ project(':server-app').afterEvaluate {
5757
}
5858
}
5959

60+
apply from: "./gradle/email.gradle"
6061
apply from: "./gradle/webapp.gradle"
6162
apply from: "./gradle/docker.gradle"
6263
apply from: "./gradle/e2e.gradle"
@@ -66,6 +67,7 @@ project(':server-app').afterEvaluate {
6667
task packResources(type: Zip) {
6768
dependsOn "unpack"
6869
dependsOn "copyDist"
70+
dependsOn "copyEmailResources"
6971
dependsOn "addVersionFile"
7072
from "${project.projectDir}/build/dependency"
7173
archiveFileName = "tolgee.jar"

email/.config/extractor.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ export default async function extractor(
109109
code
110110
);
111111

112-
if (res.key) keys.push(res.key)
113-
if (res.warning) warnings.push(res.warning)
112+
if (res.key) keys.push(res.key);
113+
if (res.warning) warnings.push(res.warning);
114114
}
115115

116116
if (
@@ -158,8 +158,8 @@ export default async function extractor(
158158
code
159159
);
160160

161-
if (res.key) keys.push(res.key)
162-
if (res.warning) warnings.push(res.warning)
161+
if (res.key) keys.push(res.key);
162+
if (res.warning) warnings.push(res.warning);
163163
}
164164
},
165165
});

email/.config/tolgeerc.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
{
22
"$schema": "https://docs.tolgee.io/cli-schema.json",
33
"projectId": 1,
4-
"format": "PROPERTIES_JAVA",
4+
"format": "PROPERTIES_ICU",
55
"patterns": ["./emails/**/*.ts?(x)", "./components/**/*.ts?(x)"],
66
"extractor": "./.config/extractor.ts",
77
"pull": {
8-
"path": "./src/i18n",
9-
"tags": ["email"]
8+
"path": "./i18n",
9+
"fileStructureTemplate": "messages_{snakeLanguageTag}.{extension}"
1010
},
1111
"push": {
1212
"tagNewKeys": ["email"]

0 commit comments

Comments
 (0)