Skip to content

Commit 5b10964

Browse files
committed
feat: finalize working impl of react emails renderer
1 parent a61a296 commit 5b10964

File tree

13 files changed

+437
-170
lines changed

13 files changed

+437
-170
lines changed

DEVELOPMENT.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ To learn more about externalized configuration in Spring boot, read [the docs](h
6767

6868
Since we set the active profile to `dev`, Spring uses the `application-dev.yaml` configuration file.
6969

70+
## Writing emails
71+
72+
Please refer to [email/HACKING.md](email/HACKING.md).
73+
7074
## Updating the database changelog
7175

7276
Tolgee uses Liquibase to handle the database migration. The migrations are run on every app startup. To update the changelog, run:
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package io.tolgee.email
2+
3+
import com.transferwise.icu.ICUMessageSource
4+
import org.springframework.context.MessageSource
5+
import org.springframework.context.MessageSourceResolvable
6+
import org.springframework.context.NoSuchMessageException
7+
import java.util.Locale
8+
import java.util.regex.Pattern
9+
10+
/**
11+
* Transforms a classic ICUMessageSource into an XML-aware message source.
12+
* The transformation performed is specific to emails template structure, and isn't suitable for general-purpose use.
13+
*
14+
* Cannot be written as a subclass of ICUReloadableResourceBundleMessageSource as its `getMessage` methods are final.
15+
*/
16+
class EmailMessageSource(
17+
private val provider: ICUMessageSource
18+
) : ICUMessageSource {
19+
private var counter = 0L
20+
21+
override fun getMessage(
22+
code: String,
23+
args: Array<out Any?>?,
24+
defaultMessage: String?,
25+
locale: Locale
26+
): String? {
27+
return provider.getMessage(code, args, defaultMessage, locale)?.postProcessMessage(code)
28+
}
29+
30+
override fun getMessage(
31+
code: String,
32+
args: Array<out Any?>?,
33+
locale: Locale
34+
): String {
35+
return provider.getMessage(code, args, locale).postProcessMessage(code)
36+
}
37+
38+
override fun getMessage(
39+
code: String,
40+
args: Map<String, Any?>?,
41+
defaultMessage: String?,
42+
locale: Locale
43+
): String? {
44+
return provider.getMessage(code, args, defaultMessage, locale)?.postProcessMessage(code)
45+
}
46+
47+
override fun getMessage(
48+
code: String,
49+
args: Map<String, Any?>?,
50+
locale: Locale
51+
): String? {
52+
return provider.getMessage(code, args, locale).postProcessMessage(code)
53+
}
54+
55+
override fun getMessage(
56+
resolvable: MessageSourceResolvable,
57+
locale: Locale
58+
): String {
59+
resolvable.codes?.forEach { code ->
60+
getMessage(code, resolvable.arguments, null, locale)?.let {
61+
return it
62+
}
63+
}
64+
65+
throw NoSuchMessageException(resolvable.codes?.lastOrNull() ?: "", locale)
66+
}
67+
68+
override fun getParentMessageSource(): MessageSource? {
69+
return provider.parentMessageSource
70+
}
71+
72+
override fun setParentMessageSource(parent: MessageSource?) {
73+
provider.parentMessageSource = parent
74+
}
75+
76+
@Synchronized
77+
private fun count(): Long {
78+
val ret = counter
79+
if (counter == Long.MAX_VALUE) {
80+
counter = 0L
81+
} else {
82+
counter += 1L
83+
}
84+
return ret
85+
}
86+
87+
private fun makeRef(): String = "intl-ref-${count()}"
88+
89+
private fun String.postProcessMessage(code: String): String {
90+
var str = this.replace("\n", "<br/>")
91+
// Prevent Thymeleaf injection (for the second pass)
92+
.replace("{", "&#123;")
93+
.replace("[", "&#91;")
94+
.replace("$", "&#36;")
95+
.replace("*", "&#42;")
96+
.replace("#", "&#35;")
97+
.replace("~", "&#126;")
98+
99+
// Dumb heuristic to skip XML parsing for trivial cases, to improve performance.
100+
if (contains('<') && contains('>')) {
101+
var delta = 0
102+
val stack = mutableListOf(code)
103+
val m = XML_PATTERN.matcher(str)
104+
105+
while (m.find()) {
106+
if (m.group(1) == null) {
107+
stack.add(m.group(2))
108+
109+
val ref = makeRef()
110+
val replacement = "<div th:ref=\"$ref\" th:replace=\"~{::intl-${stack.joinToString("--")}(~{:: $ref/content})}\"><content th:remove=\"tag\">"
111+
str = str.replaceRange(delta + m.start(), delta + m.end(), replacement)
112+
delta += replacement.length - (m.end() - m.start())
113+
} else {
114+
stack.removeLast()
115+
116+
val replacement = "</content></div>"
117+
str = str.replaceRange(delta + m.start(), delta + m.end(), replacement)
118+
delta += replacement.length - (m.end() - m.start())
119+
}
120+
}
121+
}
122+
123+
return str
124+
}
125+
126+
companion object {
127+
private val XML_PATTERN = Pattern.compile("<(/)?([a-zA-Z-]+)>")
128+
}
129+
}

backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,17 @@ class EmailService(
5050
attachments: List<EmailAttachment> = listOf(),
5151
) {
5252
val context = Context(locale, properties)
53-
val html = templateEngine.process(template, context)
54-
val subject = extractEmailTitle(html)
5553

54+
// Do two passes, so Thymeleaf expressions rendered by messages can get processed
55+
context.setVariable("isSecondPass", false)
56+
val firstPass = templateEngine.process(template, context)
57+
58+
println(firstPass)
59+
60+
context.setVariable("isSecondPass", true)
61+
val html = templateEngine.process(firstPass, context)
62+
63+
val subject = extractEmailTitle(html)
5664
sendEmail(recipient, subject, html, attachments)
5765
}
5866

backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateConfig.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import org.thymeleaf.TemplateEngine
2626
import org.thymeleaf.spring6.SpringTemplateEngine
2727
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver
2828
import org.thymeleaf.templateresolver.ITemplateResolver
29+
import org.thymeleaf.templateresolver.StringTemplateResolver
2930
import java.util.*
3031

3132
@Configuration
@@ -45,17 +46,20 @@ class EmailTemplateConfig {
4546
messageSource.setBasenames("email-i18n/messages", "email-i18n-test/messages")
4647
messageSource.setDefaultEncoding("UTF-8")
4748
messageSource.setDefaultLocale(Locale.ENGLISH)
48-
return messageSource
49+
return EmailMessageSource(messageSource)
4950
}
5051

5152
@Bean("emailTemplateEngine")
5253
fun templateEngine(
5354
@Qualifier("emailTemplateResolver") templateResolver: ITemplateResolver,
5455
@Qualifier("emailMessageSource") messageSource: MessageSource,
5556
): TemplateEngine {
57+
val stringTemplateResolver = StringTemplateResolver()
58+
stringTemplateResolver.resolvablePatternSpec.addPattern("<!DOCTYPE*")
59+
5660
val templateEngine = SpringTemplateEngine()
5761
templateEngine.enableSpringELCompiler = true
58-
templateEngine.templateResolvers = setOf(templateResolver)
62+
templateEngine.templateResolvers = setOf(stringTemplateResolver, templateResolver)
5963
templateEngine.setTemplateEngineMessageSource(messageSource)
6064
return templateEngine
6165
}

0 commit comments

Comments
 (0)