From cdb4f02dc28239f692eff2bbd6b5fa613fff832e Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Fri, 8 Aug 2025 09:25:03 +0200 Subject: [PATCH 01/12] feat: Html e-mail default template --- .../component/email/TolgeeEmailSender.kt | 43 +++----------- .../email/EmailGlobalVariablesProvider.kt | 2 +- .../kotlin/io/tolgee/email/EmailService.kt | 3 +- email/components/Var.ts | 11 +++- email/emails/default.tsx | 56 +++++++++++++++++++ 5 files changed, 76 insertions(+), 39 deletions(-) create mode 100644 email/emails/default.tsx diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt index c7d6f67892..f49c6bb3f8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt @@ -2,50 +2,23 @@ package io.tolgee.component.email import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.misc.EmailParams -import org.springframework.core.io.ClassPathResource -import org.springframework.mail.javamail.JavaMailSender +import io.tolgee.email.EmailService import org.springframework.stereotype.Component +import java.util.Locale @Component class TolgeeEmailSender( private val tolgeeProperties: TolgeeProperties, - private val mailSender: JavaMailSender, - private val mimeMessageHelperFactory: MimeMessageHelperFactory, + private val emailService: EmailService, ) { fun sendEmail(params: EmailParams) { validateProps() - val helper = mimeMessageHelperFactory.create() - helper.setFrom(params.from ?: tolgeeProperties.smtp.from!!) - helper.setTo(params.to) - params.replyTo?.let { - helper.setReplyTo(it) - } - if (!params.bcc.isNullOrEmpty()) { - helper.setBcc(params.bcc!!) - } - helper.setSubject(params.subject) - val content = - """ - - - ${params.text}

- - - - """.trimIndent() - helper.setText(content, true) - - params.attachments.forEach { - helper.addAttachment(it.name, it.inputStreamSource) - } - - helper.addInline( - "logo.png", - { ClassPathResource("tolgee-logo.png").inputStream }, - "image/png", + emailService.sendEmailTemplate( + recipient = params.to, + subject = params.subject, + template = "default", + locale = Locale.ENGLISH ) - - mailSender.send(helper.mimeMessage) } private fun validateProps() { diff --git a/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt b/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt index 7c2450e4fe..b58d3ec416 100644 --- a/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt @@ -34,7 +34,7 @@ class EmailGlobalVariablesProvider( return mapOf( "isCloud" to isCloud, "instanceQualifier" to if (isCloud) tolgeeProperties.appName else tolgeeProperties.frontEndUrl.intoQualifier(), - "instanceUrl" to tolgeeProperties.frontEndUrl, + "instanceUrl" to "http://localhost:8080", ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt b/backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt index f523cb8993..c4c694db97 100644 --- a/backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt @@ -52,6 +52,7 @@ class EmailService( locale: Locale, properties: Map = mapOf(), attachments: List = listOf(), + subject: String? = null, ) { val globalVariables = emailGlobalVariablesProvider() val context = Context(locale, properties) @@ -63,7 +64,7 @@ class EmailService( context.setVariable(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, tec) val html = templateEngine.process(template, context) - val subject = extractEmailTitle(html) + val subject = subject ?: extractEmailTitle(html) sendEmail(recipient, subject, html, attachments) } diff --git a/email/components/Var.ts b/email/components/Var.ts index af35438813..605c24ce34 100644 --- a/email/components/Var.ts +++ b/email/components/Var.ts @@ -27,9 +27,16 @@ export default function Var({ demoValue, dangerouslyInjectValueAsHtmlWithoutSanitization: injectHtml, }: Props) { + const children = injectHtml ? undefined : demoValue; + const html = injectHtml ? demoValue : undefined; + + // eslint-disable-next-line react/no-danger-with-children return React.createElement( 'span', - { [injectHtml ? 'th:utext' : 'th:text']: `\${${variable}}` }, - demoValue + { + [injectHtml ? 'th:utext' : 'th:text']: `\${${variable}}`, + dangerouslySetInnerHTML: { __html: html }, + }, + children ); } diff --git a/email/emails/default.tsx b/email/emails/default.tsx new file mode 100644 index 0000000000..9114aeec1b --- /dev/null +++ b/email/emails/default.tsx @@ -0,0 +1,56 @@ +/** + * 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. + */ + +import * as React from 'react'; +import { Section, Text } from '@react-email/components'; +import t from '../components/translate'; +import ClassicLayout from '../components/layouts/ClassicLayout'; +import LocalizedText from '../components/LocalizedText'; +import Var from '../components/Var'; + +export default function RegistrationConfirmEmail() { + return ( + + + + +
+ +
+ + + +
+ ); +} From f95c57ea35286accf218f59bc7efb627a9166857 Mon Sep 17 00:00:00 2001 From: Jan Cizmar Date: Mon, 11 Aug 2025 09:29:47 +0200 Subject: [PATCH 02/12] feat: Html e-mail default template --- email/components/Var.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/email/components/Var.ts b/email/components/Var.ts index 605c24ce34..3149f2adf5 100644 --- a/email/components/Var.ts +++ b/email/components/Var.ts @@ -27,16 +27,19 @@ export default function Var({ demoValue, dangerouslyInjectValueAsHtmlWithoutSanitization: injectHtml, }: Props) { - const children = injectHtml ? undefined : demoValue; - const html = injectHtml ? demoValue : undefined; + if (injectHtml) { + return React.createElement('span', { + [injectHtml ? 'th:utext' : 'th:text']: `\${${variable}}`, + dangerouslySetInnerHTML: { __html: demoValue }, + }); + } // eslint-disable-next-line react/no-danger-with-children return React.createElement( 'span', { [injectHtml ? 'th:utext' : 'th:text']: `\${${variable}}`, - dangerouslySetInnerHTML: { __html: html }, }, - children + demoValue ); } From dcb6fb1c06686f4a3c6e929c10ea5f1422c05bdf Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Thu, 14 Aug 2025 09:14:49 +0200 Subject: [PATCH 03/12] fix: email translates & assets --- .../email/EmailVerificationSender.kt | 18 ++---- .../component/email/TolgeeEmailSender.kt | 52 +++++++++++++++-- .../configuration/tolgee/TolgeeProperties.kt | 6 ++ .../kotlin/io/tolgee/dtos/misc/EmailParams.kt | 4 +- .../email/EmailGlobalVariablesProvider.kt | 4 +- .../io/tolgee/email/EmailTemplateConfig.kt | 2 +- .../service/EmailVerificationService.kt | 4 +- .../email/EmailGlobalVariablesProviderTest.kt | 8 +-- .../io/tolgee/email/EmailServiceTest.kt | 5 +- .../email-i18n-test/messages_en.properties | 1 + email/HACKING.md | 2 +- email/components/ImgResource.ts | 2 +- email/components/layouts/ClassicLayout.tsx | 16 ++---- email/components/translate.ts | 2 +- email/emails/__tests__/test-email.tsx | 9 +-- email/emails/default.tsx | 56 ------------------- email/emails/registration-confirm.tsx | 14 ++++- email/i18n/messages_en.properties | 7 ++- 18 files changed, 103 insertions(+), 109 deletions(-) delete mode 100644 email/emails/default.tsx diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/EmailVerificationSender.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/EmailVerificationSender.kt index ce1cc8f907..8001afd957 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/email/EmailVerificationSender.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/EmailVerificationSender.kt @@ -1,6 +1,7 @@ package io.tolgee.component.email import io.tolgee.dtos.misc.EmailParams +import io.tolgee.model.UserAccount import org.springframework.stereotype.Component @Component @@ -8,28 +9,19 @@ class EmailVerificationSender( private val tolgeeEmailSender: TolgeeEmailSender, ) { fun sendEmailVerification( - userId: Long, + user: UserAccount, email: String, resultCallbackUrl: String?, code: String, isSignUp: Boolean = true, ) { - val url = "$resultCallbackUrl/$userId/$code" + val url = "$resultCallbackUrl/${user.id}/$code" val params = EmailParams( to = email, subject = "Tolgee e-mail verification", - text = - """ - Hello! 👋

- ${if (isSignUp) "Welcome to Tolgee. Thanks for signing up. \uD83C\uDF89

" else ""} - - To verify your e-mail, follow this link:
- $url

- - Regards,
- Tolgee - """.trimIndent(), + templateName = "registration-confirm", + properties = mapOf("confirmUrl" to url, "username" to user.name, "isSignUp" to isSignUp), ) tolgeeEmailSender.sendEmail(params) } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt index f49c6bb3f8..e2320e8f42 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt @@ -3,22 +3,62 @@ package io.tolgee.component.email import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.misc.EmailParams import io.tolgee.email.EmailService +import org.springframework.core.io.ClassPathResource +import org.springframework.mail.javamail.JavaMailSender import org.springframework.stereotype.Component import java.util.Locale @Component class TolgeeEmailSender( private val tolgeeProperties: TolgeeProperties, + private val mailSender: JavaMailSender, + private val mimeMessageHelperFactory: MimeMessageHelperFactory, private val emailService: EmailService, ) { fun sendEmail(params: EmailParams) { validateProps() - emailService.sendEmailTemplate( - recipient = params.to, - subject = params.subject, - template = "default", - locale = Locale.ENGLISH - ) + if (!params.templateName.isNullOrEmpty()) { + emailService.sendEmailTemplate( + recipient = params.to, + subject = params.subject, + template = params.templateName!!, + locale = Locale.ENGLISH, + properties = params.properties ?: mutableMapOf(), + ) + } else { + val helper = mimeMessageHelperFactory.create() + helper.setFrom(params.from ?: tolgeeProperties.smtp.from!!) + helper.setTo(params.to) + params.replyTo?.let { + helper.setReplyTo(it) + } + if (!params.bcc.isNullOrEmpty()) { + helper.setBcc(params.bcc!!) + } + helper.setSubject(params.subject) + val content = + """ + + + ${params.text}

+ + + + """.trimIndent() + helper.setText(content, true) + + params.attachments.forEach { + helper.addAttachment(it.name, it.inputStreamSource) + } + + helper.addInline( + "logo.png", + { ClassPathResource("tolgee-logo.png").inputStream }, + "image/png", + ) + + mailSender.send(helper.mimeMessage) + } } private fun validateProps() { diff --git a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt index c57161727d..4d703a9768 100644 --- a/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt +++ b/backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/TolgeeProperties.kt @@ -135,4 +135,10 @@ open class TolgeeProperties( description = "LLM Providers configuration", ) var llmProperties: LlmProperties = LlmProperties(), + @DocProperty( + description = "Public URL of the Tolgee API endpoint. While this typically matches the 'frontEndUrl', " + + "it should be set separately when running the backend on a different URL." + + "\n\n" + ) + var backEndUrl: String? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt index 6e751c3e16..dc2650fb11 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt @@ -4,8 +4,10 @@ class EmailParams( var to: String, var from: String? = null, var bcc: Array? = null, - var text: String, + var text: String? = null, var subject: String, var attachments: List = listOf(), var replyTo: String? = null, + var templateName: String? = null, + var properties: Map? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt b/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt index b58d3ec416..06ce29ede2 100644 --- a/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt @@ -16,6 +16,7 @@ package io.tolgee.email +import io.tolgee.component.FrontendUrlProvider import io.tolgee.component.publicBillingConfProvider.PublicBillingConfProvider import io.tolgee.configuration.tolgee.TolgeeProperties import org.springframework.stereotype.Component @@ -27,6 +28,7 @@ class EmailGlobalVariablesProvider( // Used to identify whether we're Tolgee Cloud or not private val billingConfigProvider: PublicBillingConfProvider, private val tolgeeProperties: TolgeeProperties, + private val frontendUrlProvider: FrontendUrlProvider, ) { operator fun invoke(): Map { val isCloud = billingConfigProvider().enabled @@ -34,7 +36,7 @@ class EmailGlobalVariablesProvider( return mapOf( "isCloud" to isCloud, "instanceQualifier" to if (isCloud) tolgeeProperties.appName else tolgeeProperties.frontEndUrl.intoQualifier(), - "instanceUrl" to "http://localhost:8080", + "backendUrl" to (tolgeeProperties.backEndUrl ?: frontendUrlProvider.url) ) } diff --git a/backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateConfig.kt b/backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateConfig.kt index d195e7222c..5efc7f93f5 100644 --- a/backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateConfig.kt +++ b/backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateConfig.kt @@ -42,7 +42,7 @@ class EmailTemplateConfig { @Bean("emailIcuMessageSource") fun messageSource(): MessageSource { val messageSource = ICUReloadableResourceBundleMessageSource() - messageSource.setBasenames("email-i18n/messages", "email-i18n-test/messages") + messageSource.setBasenames("classpath:email-i18n/messages", "email-i18n-test/messages") messageSource.setDefaultEncoding("UTF-8") messageSource.setDefaultLocale(Locale.ENGLISH) return messageSource diff --git a/backend/data/src/main/kotlin/io/tolgee/service/EmailVerificationService.kt b/backend/data/src/main/kotlin/io/tolgee/service/EmailVerificationService.kt index b8e8146111..40338005c4 100644 --- a/backend/data/src/main/kotlin/io/tolgee/service/EmailVerificationService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/service/EmailVerificationService.kt @@ -59,9 +59,9 @@ class EmailVerificationService( userAccountService.saveAndFlush(userAccount) if (newEmail != null) { - emailVerificationSender.sendEmailVerification(userAccount.id, newEmail, resultCallbackUrl, code, false) + emailVerificationSender.sendEmailVerification(userAccount, newEmail, resultCallbackUrl, code, false) } else { - emailVerificationSender.sendEmailVerification(userAccount.id, userAccount.username, resultCallbackUrl, code) + emailVerificationSender.sendEmailVerification(userAccount, userAccount.username, resultCallbackUrl, code) } return emailVerification } diff --git a/backend/data/src/test/kotlin/io/tolgee/email/EmailGlobalVariablesProviderTest.kt b/backend/data/src/test/kotlin/io/tolgee/email/EmailGlobalVariablesProviderTest.kt index 2b604aa81c..23ad630f7b 100644 --- a/backend/data/src/test/kotlin/io/tolgee/email/EmailGlobalVariablesProviderTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/email/EmailGlobalVariablesProviderTest.kt @@ -47,7 +47,7 @@ class EmailGlobalVariablesProviderTest { emailGlobalVariablesProvider().assert .containsEntry("isCloud", true) .containsEntry("instanceQualifier", "Tolgee Test Edition") - .containsEntry("instanceUrl", "https://tolgee.test") + .containsEntry("backendUrl", "https://tolgee.test") .hasSize(3) } @@ -60,7 +60,7 @@ class EmailGlobalVariablesProviderTest { emailGlobalVariablesProvider().assert .containsEntry("isCloud", false) .containsEntry("instanceQualifier", "tolgee.test") - .containsEntry("instanceUrl", "https://tolgee.test") + .containsEntry("backendUrl", "https://tolgee.test") .hasSize(3) } @@ -73,7 +73,7 @@ class EmailGlobalVariablesProviderTest { emailGlobalVariablesProvider().assert .containsEntry("isCloud", false) .containsEntry("instanceQualifier", SELF_HOSTED_DEFAULT_QUALIFIER) - .containsEntry("instanceUrl", "https:/tolgee.test") + .containsEntry("backendUrl", "https:/tolgee.test") .hasSize(3) } @@ -87,7 +87,7 @@ class EmailGlobalVariablesProviderTest { emailGlobalVariablesProvider().assert .containsEntry("isCloud", false) .containsEntry("instanceQualifier", SELF_HOSTED_DEFAULT_QUALIFIER) - .containsEntry("instanceUrl", null) + .containsEntry("backendUrl", null) .hasSize(3) } } diff --git a/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt b/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt index 6f240a99a3..404ae7f377 100644 --- a/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt @@ -67,7 +67,7 @@ class EmailServiceTest { mapOf( "isCloud" to true, "instanceQualifier" to "Tolgee", - "instanceUrl" to "https://tolgee.test", + "backendUrl" to "https://tolgee.test", ) ) } @@ -95,6 +95,8 @@ class EmailServiceTest { // Might be a bit brittle but does the trick for now. .doesNotContain(" th:") .doesNotContain(" data-th") + // All default values have been replaced + .doesNotContain("[DEFAULT]") } @Test @@ -157,6 +159,7 @@ class EmailServiceTest { mapOf("name" to "Name #2"), mapOf("name" to "Name #3"), ), + "tolgee" to "Tolgee", ) private val TEST_PROPERTIES_MEOW = diff --git a/backend/data/src/test/resources/email-i18n-test/messages_en.properties b/backend/data/src/test/resources/email-i18n-test/messages_en.properties index 961e2c7277..89c724bb92 100644 --- a/backend/data/src/test/resources/email-i18n-test/messages_en.properties +++ b/backend/data/src/test/resources/email-i18n-test/messages_en.properties @@ -1,2 +1,3 @@ email-test-string = Testing ICU strings -- {testVar} email-test-it = ICU test: {item__name} +email-test-powered-by = Powered by {tolgee} 🐁 \ No newline at end of file diff --git a/email/HACKING.md b/email/HACKING.md index b3fdc80a6b..cd55d6f512 100644 --- a/email/HACKING.md +++ b/email/HACKING.md @@ -168,7 +168,7 @@ This component receives the following properties: The following global variables are available: - `isCloud` (boolean): Whether this is Tolgee Cloud or not - `instanceQualifier`: Either "Tolgee" for Tolgee Cloud, or the domain name used for the instance -- `instanceUrl`: Base URL of the instance +- `backendUrl`: Base URL of the backend They still need to be passed as demo values, except for localized strings as a default value is provided then. The default value can be overridden. diff --git a/email/components/ImgResource.ts b/email/components/ImgResource.ts index 0f693c83df..ad484e157b 100644 --- a/email/components/ImgResource.ts +++ b/email/components/ImgResource.ts @@ -45,7 +45,7 @@ export default function ImgResource(props: Props) { if (process.env.NODE_ENV === 'production') { // Resources will be copied during final assembly. newProps['data-th-src'] = - `\${instanceUrl} + '/static/emails/${props.resourceName}'`; + `\${backendUrl} + '/static/emails/${props.resourceName}'`; } else { const blob = readFileSync(file); const ext = extname(file).slice(1); diff --git a/email/components/layouts/ClassicLayout.tsx b/email/components/layouts/ClassicLayout.tsx index 0f027cafff..78a108a446 100644 --- a/email/components/layouts/ClassicLayout.tsx +++ b/email/components/layouts/ClassicLayout.tsx @@ -70,22 +70,14 @@ export default function ClassicLayout({ -
- - - - - - +
+ + + {t.render(subject)} -
-
{children}

diff --git a/email/components/translate.ts b/email/components/translate.ts index 4191fd4784..6c0c614e21 100644 --- a/email/components/translate.ts +++ b/email/components/translate.ts @@ -39,7 +39,7 @@ export type MessageProps = Record< const GLOBALS = { isCloud: true, instanceQualifier: 'Tolgee', - instanceUrl: 'https://app.tolgee.io', + backendUrl: 'https://app.tolgee.io', }; let Counter = 0; diff --git a/email/emails/__tests__/test-email.tsx b/email/emails/__tests__/test-email.tsx index 968de192ad..451123bbe5 100644 --- a/email/emails/__tests__/test-email.tsx +++ b/email/emails/__tests__/test-email.tsx @@ -38,7 +38,7 @@ export default function TestEmail() { <_LocalizedText keyName="email-test-string" - defaultValue="Testing ICU strings -- {testVar}" + defaultValue="[DEFAULT] Testing ICU strings -- {testVar}" demoParams={{ link: (c) => ( @@ -74,7 +74,7 @@ export default function TestEmail() {
  • <_LocalizedText keyName="email-test-it" - defaultValue="ICU test: {item__name}" + defaultValue="[DEFAULT] ICU test: {item__name}" demoParams={{ item__name: 'demo...' }} />
  • @@ -84,14 +84,15 @@ export default function TestEmail() { <_LocalizedText - keyName="powered-by" - defaultValue="Powered by Tolgee 🐁" + keyName="email-test-powered-by" + defaultValue="[DEFAULT] Powered by {tolgee} 🐁" demoParams={{ link: (c) => ( {c} ), + tolgee: 'Tolgee', }} /> diff --git a/email/emails/default.tsx b/email/emails/default.tsx deleted file mode 100644 index 9114aeec1b..0000000000 --- a/email/emails/default.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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. - */ - -import * as React from 'react'; -import { Section, Text } from '@react-email/components'; -import t from '../components/translate'; -import ClassicLayout from '../components/layouts/ClassicLayout'; -import LocalizedText from '../components/LocalizedText'; -import Var from '../components/Var'; - -export default function RegistrationConfirmEmail() { - return ( - - - - -
    - -
    - - - -
    - ); -} diff --git a/email/emails/registration-confirm.tsx b/email/emails/registration-confirm.tsx index 862abf2f86..603c1277a3 100644 --- a/email/emails/registration-confirm.tsx +++ b/email/emails/registration-confirm.tsx @@ -21,6 +21,7 @@ import ClassicLayout from '../components/layouts/ClassicLayout'; import TolgeeLink from '../components/atoms/TolgeeLink'; import TolgeeButton from '../components/atoms/TolgeeButton'; import LocalizedText from '../components/LocalizedText'; +import If from '../components/If'; export default function RegistrationConfirmEmail() { return ( @@ -40,9 +41,18 @@ export default function RegistrationConfirmEmail() { />
    + + + {' '} + + +
    diff --git a/email/i18n/messages_en.properties b/email/i18n/messages_en.properties index 41c279a9e4..2fc4c0ddb7 100644 --- a/email/i18n/messages_en.properties +++ b/email/i18n/messages_en.properties @@ -1,12 +1,13 @@ -email-greetings = Hello {username}, +email-greetings = Hello {username} 👋, email-signature = Happy translating,\nTolgee footer-cloud-address = Letovická 1421/22, Řečkovice, 621 00 Brno, Czech Republic footer-cloud-sent-by = 🐭 Sent by Tolgee - Check out our blog and our socials! 🧀 powered-by = Powered by Tolgee 🐁 registration-confirm-cta = Confirm my email -registration-confirm-link = Or, copy and paste this URL into your browser:\n{confirmUrl} +registration-confirm-link = Or, copy and paste this URL into your browser: {confirmUrl} registration-confirm-subject = Confirm your account registrations_not_allowed = Registrations are not enabled registration-welcome-enjoy-your-stay = We hope you'll enjoy your experience! -registration-welcome-text = Welcome and thank you for creating an account! To start using Tolgee, you need to confirm your email. +registration-welcome-text = Welcome and thank you for creating an account! +registration-confirm-email-text = To start using Tolgee, you need to confirm your email. send-reason-created-account = You're receiving this email because you've created an account on {instanceQualifier} From 95165cb0e93f4f77027160bd09708f3f9fa05132 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Mon, 18 Aug 2025 17:16:12 +0200 Subject: [PATCH 04/12] chore: current emails use default layout template --- .../ResetPasswordRequestHandler.kt | 12 +--- .../email/EmailVerificationSender.kt | 9 ++- .../component/email/InvitationEmailSender.kt | 4 -- .../component/email/TolgeeEmailSender.kt | 57 +++------------ .../kotlin/io/tolgee/dtos/misc/EmailParams.kt | 3 +- .../email/EmailGlobalVariablesProvider.kt | 5 +- .../src/main/resources/I18n_en.properties | 9 +-- .../email/EmailGlobalVariablesProviderTest.kt | 10 ++- .../io/tolgee/email/EmailServiceTest.kt | 3 + email/components/Var.ts | 12 +--- email/components/layouts/ClassicLayout.tsx | 34 ++++----- email/emails/__tests__/test-email.tsx | 1 + email/emails/default.tsx | 70 +++++++++++++++++++ email/i18n/messages_en.properties | 3 +- 14 files changed, 132 insertions(+), 100 deletions(-) create mode 100644 email/emails/default.tsx diff --git a/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordRequestHandler.kt b/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordRequestHandler.kt index d283adb9ee..b899173fd7 100644 --- a/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordRequestHandler.kt +++ b/backend/api/src/main/kotlin/io/tolgee/controllers/resetPassword/ResetPasswordRequestHandler.kt @@ -47,15 +47,12 @@ class ResetPasswordRequestHandler( EmailParams( to = request.email, subject = if (isInitial) "Initial password configuration" else "Password reset", - text = + header = "Password reset", + text = """ - Hello! 👋

    ${if (isInitial) "To set a password for your account, follow this link:
    " else "To reset your password, follow this link:
    "} $url

    If you have not requested this e-mail, please ignore it.

    - - Regards,
    - Tolgee """.trimIndent(), ) @@ -67,15 +64,12 @@ class ResetPasswordRequestHandler( EmailParams( to = request.email, subject = "Password reset - SSO managed account", + header = "Password reset", text = """ - Hello! 👋

    We received a request to reset the password for your account. However, your account is managed by your organization and uses a single sign-on (SSO) service to log in.

    To access your account, please use the "SSO Login" button on the Tolgee login page. No password reset is needed.

    If you did not make this request, you may safely ignore this email.

    - - Regards,
    - Tolgee """.trimIndent(), ) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/EmailVerificationSender.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/EmailVerificationSender.kt index 8001afd957..cdf20fba5d 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/email/EmailVerificationSender.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/EmailVerificationSender.kt @@ -20,8 +20,13 @@ class EmailVerificationSender( EmailParams( to = email, subject = "Tolgee e-mail verification", - templateName = "registration-confirm", - properties = mapOf("confirmUrl" to url, "username" to user.name, "isSignUp" to isSignUp), + header = "Verify your e-mail", + text = """ + ${if (isSignUp) "Welcome to Tolgee. Thanks for signing up. \uD83C\uDF89

    " else ""} + + To verify your e-mail, follow this link:
    + $url

    + """.trimIndent(), ) tolgeeEmailSender.sendEmail(params) } diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/InvitationEmailSender.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/InvitationEmailSender.kt index f9ca814384..573221dd08 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/email/InvitationEmailSender.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/InvitationEmailSender.kt @@ -23,14 +23,10 @@ class InvitationEmailSender( subject = "Invitation to Tolgee", text = """ - Hello! 👋

    Good news. ${getInvitationSentence(invitation)}

    To accept the invitation, follow this link:
    $url

    - - Regards,
    - Tolgee """.trimIndent(), ) tolgeeEmailSender.sendEmail(params) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt index e2320e8f42..6c8da092bf 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt @@ -3,62 +3,27 @@ package io.tolgee.component.email import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.misc.EmailParams import io.tolgee.email.EmailService -import org.springframework.core.io.ClassPathResource -import org.springframework.mail.javamail.JavaMailSender import org.springframework.stereotype.Component import java.util.Locale @Component class TolgeeEmailSender( private val tolgeeProperties: TolgeeProperties, - private val mailSender: JavaMailSender, - private val mimeMessageHelperFactory: MimeMessageHelperFactory, private val emailService: EmailService, ) { fun sendEmail(params: EmailParams) { validateProps() - if (!params.templateName.isNullOrEmpty()) { - emailService.sendEmailTemplate( - recipient = params.to, - subject = params.subject, - template = params.templateName!!, - locale = Locale.ENGLISH, - properties = params.properties ?: mutableMapOf(), - ) - } else { - val helper = mimeMessageHelperFactory.create() - helper.setFrom(params.from ?: tolgeeProperties.smtp.from!!) - helper.setTo(params.to) - params.replyTo?.let { - helper.setReplyTo(it) - } - if (!params.bcc.isNullOrEmpty()) { - helper.setBcc(params.bcc!!) - } - helper.setSubject(params.subject) - val content = - """ - - - ${params.text}

    - - - - """.trimIndent() - helper.setText(content, true) - - params.attachments.forEach { - helper.addAttachment(it.name, it.inputStreamSource) - } - - helper.addInline( - "logo.png", - { ClassPathResource("tolgee-logo.png").inputStream }, - "image/png", - ) - - mailSender.send(helper.mimeMessage) - } + val properties = mapOf() + .let { if (params.text != null) it.plus("content" to params.text!!) else it } + .let { if (params.header != null) it.plus("header" to params.header!!) else it } + .let { if (params.recipientName != null) it.plus("recipientName" to params.recipientName!!) else it } + emailService.sendEmailTemplate( + recipient = params.to, + subject = params.subject, + template = params.templateName ?: "default", + locale = Locale.ENGLISH, + properties = properties + ) } private fun validateProps() { diff --git a/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt index dc2650fb11..5f7fb2b7aa 100644 --- a/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt +++ b/backend/data/src/main/kotlin/io/tolgee/dtos/misc/EmailParams.kt @@ -5,9 +5,10 @@ class EmailParams( var from: String? = null, var bcc: Array? = null, var text: String? = null, + var header: String? = null, var subject: String, var attachments: List = listOf(), var replyTo: String? = null, var templateName: String? = null, - var properties: Map? = null, + var recipientName: String? = null, ) diff --git a/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt b/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt index 06ce29ede2..1fcc8caf7c 100644 --- a/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt +++ b/backend/data/src/main/kotlin/io/tolgee/email/EmailGlobalVariablesProvider.kt @@ -32,11 +32,12 @@ class EmailGlobalVariablesProvider( ) { operator fun invoke(): Map { val isCloud = billingConfigProvider().enabled + val backendUrl = tolgeeProperties.backEndUrl ?: frontendUrlProvider.url return mapOf( "isCloud" to isCloud, - "instanceQualifier" to if (isCloud) tolgeeProperties.appName else tolgeeProperties.frontEndUrl.intoQualifier(), - "backendUrl" to (tolgeeProperties.backEndUrl ?: frontendUrlProvider.url) + "instanceQualifier" to if (isCloud) tolgeeProperties.appName else backendUrl.intoQualifier(), + "backendUrl" to backendUrl ) } diff --git a/backend/data/src/main/resources/I18n_en.properties b/backend/data/src/main/resources/I18n_en.properties index 713a3522a8..1427425796 100644 --- a/backend/data/src/main/resources/I18n_en.properties +++ b/backend/data/src/main/resources/I18n_en.properties @@ -82,14 +82,9 @@ slack.common.message.feature-not-available=This feature is not available for you slack.common.message.bot-not-in-channel=Bot is not in the channel. Please invite the bot to the channel using `/invite @Tolgee` and try again. slack.help.message.advanced-subscribe-events-example=`/tolgee subscribe 1 fr --on new_key` - subscribe to new key events in project 1 for French translations -notifications.email.template=Hello!\ +notifications.email.template={0}\

    \ -{0}\ -

    \ -If you want to unsubscribe from these emails, please check the notifications settings page.\ -

    \ -Regards,
    \ -Tolgee +If you want to unsubscribe from these emails, please check the notifications settings page. notifications.email.view-in-tolgee-link=View in Tolgee diff --git a/backend/data/src/test/kotlin/io/tolgee/email/EmailGlobalVariablesProviderTest.kt b/backend/data/src/test/kotlin/io/tolgee/email/EmailGlobalVariablesProviderTest.kt index 23ad630f7b..e046fa2d67 100644 --- a/backend/data/src/test/kotlin/io/tolgee/email/EmailGlobalVariablesProviderTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/email/EmailGlobalVariablesProviderTest.kt @@ -16,6 +16,7 @@ package io.tolgee.email +import io.tolgee.component.FrontendUrlProvider import io.tolgee.component.publicBillingConfProvider.PublicBillingConfProvider import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.response.PublicBillingConfigurationDTO @@ -35,6 +36,9 @@ class EmailGlobalVariablesProviderTest { @MockBean private lateinit var tolgeeProperties: TolgeeProperties + @MockBean + private lateinit var frontendUrlProvider: FrontendUrlProvider + @Autowired private lateinit var emailGlobalVariablesProvider: EmailGlobalVariablesProvider @@ -42,7 +46,7 @@ class EmailGlobalVariablesProviderTest { fun `it returns the correct properties based on config in cloud`() { whenever(publicBillingConfProvider.invoke()).thenReturn(PublicBillingConfigurationDTO(true)) whenever(tolgeeProperties.appName).thenReturn("Tolgee Test Edition") - whenever(tolgeeProperties.frontEndUrl).thenReturn("https://tolgee.test") + whenever(tolgeeProperties.backEndUrl).thenReturn("https://tolgee.test") emailGlobalVariablesProvider().assert .containsEntry("isCloud", true) @@ -55,7 +59,7 @@ class EmailGlobalVariablesProviderTest { fun `it returns the correct properties based on config in self-hosted`() { whenever(publicBillingConfProvider.invoke()).thenReturn(PublicBillingConfigurationDTO(false)) whenever(tolgeeProperties.appName).thenReturn("Tolgee Test Edition") - whenever(tolgeeProperties.frontEndUrl).thenReturn("https://tolgee.test") + whenever(tolgeeProperties.backEndUrl).thenReturn("https://tolgee.test") emailGlobalVariablesProvider().assert .containsEntry("isCloud", false) @@ -68,7 +72,7 @@ class EmailGlobalVariablesProviderTest { fun `it gracefully handles bad frontend url configuration`() { whenever(publicBillingConfProvider.invoke()).thenReturn(PublicBillingConfigurationDTO(false)) whenever(tolgeeProperties.appName).thenReturn("Tolgee Test Edition") - whenever(tolgeeProperties.frontEndUrl).thenReturn("https:/tolgee.test") + whenever(tolgeeProperties.backEndUrl).thenReturn("https:/tolgee.test") emailGlobalVariablesProvider().assert .containsEntry("isCloud", false) diff --git a/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt b/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt index 404ae7f377..4b29f3b09d 100644 --- a/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt @@ -92,6 +92,8 @@ class EmailServiceTest { ) // Makes sure resources have been added as expected .contains(" -
    - - - - {t.render(subject)} - +
    + + + + {header && ( + + + {typeof header === 'string' ? t.render(header) : header} + + + )} +
    +
    {children}

    @@ -146,16 +158,6 @@ export default function ClassicLayout({ />
    - - - - - - - diff --git a/email/emails/__tests__/test-email.tsx b/email/emails/__tests__/test-email.tsx index 451123bbe5..b84700d501 100644 --- a/email/emails/__tests__/test-email.tsx +++ b/email/emails/__tests__/test-email.tsx @@ -27,6 +27,7 @@ export default function TestEmail() { return ( } sendReason="This email was sent for no reason. Hehe!" > diff --git a/email/emails/default.tsx b/email/emails/default.tsx new file mode 100644 index 0000000000..9e295ef295 --- /dev/null +++ b/email/emails/default.tsx @@ -0,0 +1,70 @@ +/** + * 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. + */ + +import * as React from 'react'; +import { Section, Text } from '@react-email/components'; +import t from '../components/translate'; +import ClassicLayout from '../components/layouts/ClassicLayout'; +import LocalizedText from '../components/LocalizedText'; +import Var from '../components/Var'; +import If from '../components/If'; + +export default function DefaultEmail() { + return ( + } + sendReason={t.raw( + 'send-reason-created-account', + "You're receiving this email because you've created an account on {instanceQualifier}", + { instanceQualifier: 'Tolgee' } + )} + > + + + + + + + + + + +
    + + + +
    + + + +
    + ); +} diff --git a/email/i18n/messages_en.properties b/email/i18n/messages_en.properties index 2fc4c0ddb7..b6752e4d5e 100644 --- a/email/i18n/messages_en.properties +++ b/email/i18n/messages_en.properties @@ -1,5 +1,6 @@ email-greetings = Hello {username} 👋, -email-signature = Happy translating,\nTolgee +email-general-greetings = Hello! 👋, +email-signature = Kind Regards,\nTolgee footer-cloud-address = Letovická 1421/22, Řečkovice, 621 00 Brno, Czech Republic footer-cloud-sent-by = 🐭 Sent by Tolgee - Check out our blog and our socials! 🧀 powered-by = Powered by Tolgee 🐁 From 3042feaece32d86cc3fb755dc3eab69e9ed01c0d Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Tue, 19 Aug 2025 11:14:10 +0200 Subject: [PATCH 05/12] chore: BE tests fix --- .../component/email/TolgeeEmailSender.kt | 27 +++---------------- .../kotlin/io/tolgee/email/EmailService.kt | 18 ++++++++++--- .../io/tolgee/email/EmailTemplateEngine.kt | 5 +++- .../io/tolgee/email/EmailServiceTest.kt | 7 +++-- email/emails/default.tsx | 2 +- email/emails/registration-confirm.tsx | 2 +- 6 files changed, 29 insertions(+), 32 deletions(-) diff --git a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt index 6c8da092bf..6453e31c29 100644 --- a/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt +++ b/backend/data/src/main/kotlin/io/tolgee/component/email/TolgeeEmailSender.kt @@ -1,6 +1,5 @@ package io.tolgee.component.email -import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.misc.EmailParams import io.tolgee.email.EmailService import org.springframework.stereotype.Component @@ -8,11 +7,9 @@ import java.util.Locale @Component class TolgeeEmailSender( - private val tolgeeProperties: TolgeeProperties, private val emailService: EmailService, ) { fun sendEmail(params: EmailParams) { - validateProps() val properties = mapOf() .let { if (params.text != null) it.plus("content" to params.text!!) else it } .let { if (params.header != null) it.plus("header" to params.header!!) else it } @@ -22,26 +19,10 @@ class TolgeeEmailSender( subject = params.subject, template = params.templateName ?: "default", locale = Locale.ENGLISH, - properties = properties + properties = properties, + attachments = params.attachments, + bcc = params.bcc, + replyTo = params.replyTo, ) } - - private fun validateProps() { - if (tolgeeProperties.smtp.from.isNullOrEmpty()) { - throw IllegalStateException( - """tolgee.smtp.from property not provided. - |You have to configure smtp properties to send an e-mail. - """.trimMargin(), - ) - } - } - - fun getSignature(): String { - return """ -

    - Best regards, -
    - Tolgee Team - """.trimIndent() - } } diff --git a/backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt b/backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt index c4c694db97..5cf3ef04da 100644 --- a/backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt +++ b/backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt @@ -16,7 +16,7 @@ package io.tolgee.email -import io.tolgee.configuration.tolgee.SmtpProperties +import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.dtos.misc.EmailAttachment import org.springframework.beans.factory.annotation.Qualifier import org.springframework.context.ApplicationContext @@ -32,14 +32,14 @@ import java.util.* @Service class EmailService( private val applicationContext: ApplicationContext, - private val smtpProperties: SmtpProperties, + private val tolgeeProperties: TolgeeProperties, private val mailSender: JavaMailSender, private val emailGlobalVariablesProvider: EmailGlobalVariablesProvider, @Qualifier("emailTemplateEngine") private val templateEngine: TemplateEngine, ) { private val smtpFrom get() = - smtpProperties.from + tolgeeProperties.smtp.from ?: throw IllegalStateException( "SMTP sender is not configured. " + "See https://docs.tolgee.io/platform/self_hosting/configuration#smtp", @@ -53,6 +53,8 @@ class EmailService( properties: Map = mapOf(), attachments: List = listOf(), subject: String? = null, + bcc: Array? = null, + replyTo: String? = null, ) { val globalVariables = emailGlobalVariablesProvider() val context = Context(locale, properties) @@ -65,7 +67,7 @@ class EmailService( val html = templateEngine.process(template, context) val subject = subject ?: extractEmailTitle(html) - sendEmail(recipient, subject, html, attachments) + sendEmail(recipient, subject, html, attachments, bcc, replyTo) } @Async @@ -74,6 +76,8 @@ class EmailService( subject: String, contents: String, attachments: List = listOf(), + bcc: Array? = null, + replyTo: String? = null, ) { val message = mailSender.createMimeMessage() val helper = MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED, "UTF8") @@ -82,6 +86,12 @@ class EmailService( helper.setTo(recipient) helper.setSubject(subject) helper.setText(contents, true) + if (!bcc.isNullOrEmpty()) { + helper.setBcc(bcc) + } + replyTo?.let { + helper.setReplyTo(it) + } attachments.forEach { helper.addAttachment(it.name, it.inputStreamSource) } mailSender.send(message) diff --git a/backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateEngine.kt b/backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateEngine.kt index 4126d79f04..5283c640a8 100644 --- a/backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateEngine.kt +++ b/backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateEngine.kt @@ -23,6 +23,9 @@ class EmailTemplateEngine() : SpringTemplateEngine() { lateinit var emailMessageResolver: IMessageResolver override fun initializeSpringSpecific() { - setMessageResolver(emailMessageResolver) + super.initializeSpringSpecific() + if (this::emailMessageResolver.isInitialized) { + setMessageResolver(emailMessageResolver) + } } } diff --git a/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt b/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt index 4b29f3b09d..994f2bdf44 100644 --- a/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt +++ b/backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt @@ -17,6 +17,7 @@ package io.tolgee.email import io.tolgee.configuration.tolgee.SmtpProperties +import io.tolgee.configuration.tolgee.TolgeeProperties import io.tolgee.testing.assert import jakarta.mail.internet.MimeMessage import jakarta.mail.internet.MimeMultipart @@ -40,7 +41,7 @@ import java.util.* @SpringJUnitConfig(EmailService::class, EmailTemplateConfig::class) class EmailServiceTest { @MockBean - private lateinit var smtpProperties: SmtpProperties + private lateinit var tolgeeProperties: TolgeeProperties @MockBean private lateinit var mailSender: JavaMailSender @@ -57,7 +58,9 @@ class EmailServiceTest { @BeforeEach fun beforeEach() { val sender = JavaMailSenderImpl() - whenever(smtpProperties.from).thenReturn("Tolgee Test ") + val smtp = SmtpProperties() + smtp.from = "Tolgee Test " + whenever(tolgeeProperties.smtp).thenReturn(smtp) whenever(mailSender.createMimeMessage()).let { val msg = sender.createMimeMessage() it.thenReturn(msg) diff --git a/email/emails/default.tsx b/email/emails/default.tsx index 9e295ef295..f1927ae817 100644 --- a/email/emails/default.tsx +++ b/email/emails/default.tsx @@ -39,7 +39,7 @@ export default function DefaultEmail() { diff --git a/email/emails/registration-confirm.tsx b/email/emails/registration-confirm.tsx index 603c1277a3..0504dbfffc 100644 --- a/email/emails/registration-confirm.tsx +++ b/email/emails/registration-confirm.tsx @@ -48,7 +48,7 @@ export default function RegistrationConfirmEmail() { defaultValue="Welcome and thank you for creating an account!" />{' '} - + Date: Fri, 22 Aug 2025 17:11:26 +0200 Subject: [PATCH 06/12] chore: be tests fix --- .../testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt index e8421d0cdd..209a39be77 100644 --- a/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt +++ b/backend/testing/src/main/kotlin/io/tolgee/fixtures/EmailTestUtil.kt @@ -29,6 +29,7 @@ class EmailTestUtil() { messageArgumentCaptor = argumentCaptor() Mockito.clearInvocations(javaMailSender) tolgeeProperties.smtp.from = "aaa@a.a" + tolgeeProperties.backEndUrl = "https://example.com" whenever(javaMailSender.createMimeMessage()).thenAnswer { JavaMailSenderImpl().createMimeMessage() } From d2292615b818cba80f8256aa011304c16dfd1637 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Wed, 10 Sep 2025 17:21:40 +0200 Subject: [PATCH 07/12] chore: e2e tests fix --- e2e/cypress/common/apiCalls/common.ts | 17 +++++++++++++---- .../organizations/organizationInvitations.cy.ts | 4 ++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index 999c7a709c..eb32efc874 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -466,10 +466,19 @@ type Email = { subject: string; }; -export const getAllEmails = () => - cy - .request('http://localhost:21080/api/emails') - .then((r) => r.body as Email[]); +function fetchEmails(attempt = 1) { + return cy.request('http://localhost:21080/api/emails').then((r) => { + const emails = r.body as Email[]; + if (emails.length === 0 && attempt < 3) { + cy.wait(1000); // wait a bit before retrying + return fetchEmails(attempt + 1); + } + return emails; + }); +} + +export const getAllEmails = () => fetchEmails(); + export const deleteAllEmails = () => cy.request({ url: 'http://localhost:21080/api/emails', method: 'DELETE' }); diff --git a/e2e/cypress/e2e/organizations/organizationInvitations.cy.ts b/e2e/cypress/e2e/organizations/organizationInvitations.cy.ts index 7a9ac489db..3feac6e20d 100644 --- a/e2e/cypress/e2e/organizations/organizationInvitations.cy.ts +++ b/e2e/cypress/e2e/organizations/organizationInvitations.cy.ts @@ -6,12 +6,14 @@ import { gcy, } from '../../common/shared'; import { + deleteAllEmails, getParsedEmailInvitationLink, login, logout, setBypassSeatCountCheck, } from '../../common/apiCalls/common'; import { organizationTestData } from '../../common/apiCalls/testData/testData'; +import { waitForGlobalLoading } from '../../common/loading'; describe('Organization Invitations', () => { let organizationData: Record; @@ -28,6 +30,7 @@ describe('Organization Invitations', () => { beforeEach(() => { setBypassSeatCountCheck(true); + deleteAllEmails(); }); afterEach(() => { @@ -145,6 +148,7 @@ describe('Organization Invitations', () => { return clipboard; }); } else { + waitForGlobalLoading(); return assertMessage('Invitation was sent').then(() => { return getParsedEmailInvitationLink(); }); From 5c23cabf0bd77a917084c26d169aae8e40b43904 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Thu, 11 Sep 2025 13:36:19 +0200 Subject: [PATCH 08/12] chore: e2e tests fix --- e2e/cypress/common/apiCalls/common.ts | 186 +++++++++++------- .../e2e/notifications/notifications.cy.ts | 14 +- e2e/docker-compose.yml | 4 +- gradle/e2e.gradle | 12 +- 4 files changed, 130 insertions(+), 86 deletions(-) diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index eb32efc874..b8a31c76c8 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -368,126 +368,162 @@ export const addScreenshot = ( }; export const getLastEmail = () => - getAllEmails().then((r) => { + getLatestEmail().then((r) => { return { - fromAddress: r[0].from.value[0].address, - toAddress: r[0].to.value[0].address, - subject: r[0].subject, - html: r[0].html, + fromAddress: r.From.Address, + toAddress: r.To[0].Address, + subject: r.Subject, + html: r.HTML, }; }); export const getAssignedEmailNotification = () => - getAllEmails().then((r) => { - const content = r[0].html; + getLatestEmail().then((r) => { + const content = r.HTML; const result = [...content.matchAll(/href="(.*?)"/g)]; return { taskLink: result[0][1], myTasksLink: result[2][1], - fromAddress: r[0].from.value[0].address, - toAddress: r[0].to.value[0].address, - content: r[0].html, + fromAddress: r.From.Address, + toAddress: r.To[0].Address, + content: r.HTML, }; }); export const getParsedEmailVerification = () => - getAllEmails().then((r) => { + getLatestEmail().then((r) => { return { - verifyEmailLink: r[0].html.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1'), - fromAddress: r[0].from.value[0].address, - toAddress: r[0].to.value[0].address, - content: r[0].html, + verifyEmailLink: r.HTML.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1'), + fromAddress: r.From.Address, + toAddress: r.To[0].Address, + content: r.HTML, }; }); -export const getParsedEmailVerificationByIndex = (index: number) => - getAllEmails().then((r) => { - return { - verifyEmailLink: r[index].html.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1'), - fromAddress: r[index].from.value[0].address, - toAddress: r[index].to.value[0].address, - content: r[index].html, - }; - }); +export const getParsedEmailVerificationByIndex = (index: number) => { + if (index === 0) { + return getLatestEmail().then((email) => { + return { + verifyEmailLink: email.HTML.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1'), + fromAddress: email.From.Address, + toAddress: email.To[0].Address, + content: email.HTML, + }; + }); + } else { + const id = getAllEmails().then((emails) => { + return emails[index].ID; + }); + return getEmail(id).then((email) => { + return { + verifyEmailLink: email.HTML.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1'), + fromAddress: email.From.Address, + toAddress: email.To[0].Address, + content: email.HTML, + }; + }); + } +}; export const getParsedEmailInvitationLink = () => - getAllEmails().then( - (emails) => - emails[0].html.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1') as string + getLatestEmail().then( + (email) => email.HTML.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1') as string ); export const getAgencyInvitationLinks = () => getAllEmails().then((emails) => { const email = emails.find((e) => - e.html.includes('New translation request') - ); - const links = Array.from( - email.html.matchAll(/(http:\/\/[\w:/]*)/g), - (m) => m[0] - ); - const invitation = links.find((l) => l.includes('accept_invitation')); - const project = links.find( - (l) => l.includes('/projects/') && !l.includes('/task') + e.Subject.includes('New translation request') ); - const tasks = links.filter((l) => l.includes('/task')); - return { - invitation, - project, - tasks, - }; + return getEmail(email.ID).then((e) => { + const links = Array.from( + e.HTML.matchAll(/(http:\/\/[\w:/]*)/g), + (m) => m[0] + ); + + const invitation = links.find((l) => l.includes('accept_invitation')); + const project = links.find( + (l) => l.includes('/projects/') && !l.includes('/task') + ); + const tasks = links.filter((l) => l.includes('/task')); + return { + invitation, + project, + tasks, + }; + }); }); export const getOrderConfirmation = () => getAllEmails().then((emails) => { const email = emails.find((e) => - e.html.includes( - 'The Agency will review your request and will get back to you' - ) + e.Subject.includes('Your translation order to') ); - const links = Array.from( - email.html.matchAll(/(http:\/\/[\w:/]*)/g), - (m) => m[0] - ); - const project = links.find( - (l) => l.includes('/projects/') && !l.includes('/task') - ); - const tasks = links.filter((l) => l.includes('/task')); - return { - project, - tasks, - content: email.html, - }; + return getEmail(email.ID).then((e) => { + const links = Array.from( + e.HTML.matchAll(/(http:\/\/[\w:/]*)/g), + (m) => m[0] + ); + const project = links.find( + (l) => l.includes('/projects/') && !l.includes('/task') + ); + const tasks = links.filter((l) => l.includes('/task')); + return { + project, + tasks, + content: e.HTML, + }; + }); }); type Email = { - to: any; - from: any; - html: string; - subject: string; + ID: string; + To: any; + From: any; + Subject: string; }; -function fetchEmails(attempt = 1) { - return cy.request('http://localhost:21080/api/emails').then((r) => { - const emails = r.body as Email[]; - if (emails.length === 0 && attempt < 3) { - cy.wait(1000); // wait a bit before retrying - return fetchEmails(attempt + 1); - } - return emails; +type EmailSummary = { + HTML: string; + Subject: string; + To: any; + From: any; +}; + +function fetchEmails(limit = 0) { + let options = { url: 'http://localhost:21080/api/v1/messages' }; + if (limit) { + options = { ...options, ...{ qs: { limit } } }; + } + return cy.request(options).then((r) => { + return r.body.messages as Email[]; }); } export const getAllEmails = () => fetchEmails(); +export const getLatestEmail = () => + cy + .request({ url: `http://localhost:21080/api/v1/message/latest` }) + .then((r) => r.body as EmailSummary); + +export const getEmail = (id) => + cy + .request({ url: `http://localhost:21080/api/v1/message/${id}` }) + .then((r) => r.body as EmailSummary); + export const deleteAllEmails = () => - cy.request({ url: 'http://localhost:21080/api/emails', method: 'DELETE' }); + cy.request({ + url: 'http://localhost:21080/api/v1/messages', + method: 'DELETE', + }); export const getParsedResetPasswordEmail = () => - getAllEmails().then((r) => { + getLatestEmail().then((r) => { return { - resetLink: r[0].html.replace(/.*(http:\/\/[\w:/=]*).*/gs, '$1'), - fromAddress: r[0].from.value[0].address, - toAddress: r[0].to.value[0].address, + resetLink: r.HTML.replace(/.*(http:\/\/[\w:/=]*).*/gs, '$1'), + fromAddress: r.From.Address, + toAddress: r.To[0].Address, }; }); diff --git a/e2e/cypress/e2e/notifications/notifications.cy.ts b/e2e/cypress/e2e/notifications/notifications.cy.ts index 2bf72162bc..559b7ed84b 100644 --- a/e2e/cypress/e2e/notifications/notifications.cy.ts +++ b/e2e/cypress/e2e/notifications/notifications.cy.ts @@ -23,8 +23,14 @@ function assertNewestEmail( expectedTextFragment: string ) { getLastEmail().then(({ subject, html }) => { - assert(subject === expectedSubject, 'mail subject'); - assert(html.includes(expectedTextFragment), 'mail text'); + assert( + subject === expectedSubject, + 'Subject does not match, expected: ' + + expectedSubject + + ', actual: ' + + subject + ); + assert(html.includes(expectedTextFragment), 'Mail does not contain text'); }); } @@ -76,7 +82,9 @@ describe('notifications', () => { .scrollIntoView(); notifications.assertUnseenNotificationsCount(0); cy.get('@notificationList').should('have.length', 25); - getAllEmails().then((emails) => assert(emails.length === 25, 'mail count')); + getAllEmails().then((emails) => + assert(emails.length === 25, 'Expected 25 emails, got ' + emails.length) + ); }); it('notifications are clickable and correct mails are sent', () => { diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 82785913f9..60dfe138f5 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -11,9 +11,9 @@ services: - tolgee.smtp.port=1025 - tolgee.frontend-url=http://localhost:8201 fakesmtp: - image: reachfive/fake-smtp-server:0.8.1 + image: axllent/mailpit:v1.27 ports: - "21025:1025" - - "21080:1080" + - "21080:8025" volumes: e2e-db-data: diff --git a/gradle/e2e.gradle b/gradle/e2e.gradle index a00118c9fe..489ae24b6f 100644 --- a/gradle/e2e.gradle +++ b/gradle/e2e.gradle @@ -9,7 +9,7 @@ ext { BILLING_E2E_DIR = "${project.projectDir}/../billing/e2e" WEBAPP_DIR = "${project.projectDir}/webapp" } - +def dockerPath = ["which", "docker"].execute().text.trim() def getE2eChunks() { def totalJobs = System.getenv("E2E_TOTAL_JOBS") @@ -136,31 +136,31 @@ tasks.register('openE2eDev', Exec) { tasks.register('runDockerE2e', Exec) { group = 'e2e' dependsOn "tagDockerLocal" - commandLine "docker", "compose", "up", "-d" + commandLine dockerPath, "compose", "up", "-d" workingDir E2E_DIR finalizedBy "waitForRunningContainer" } tasks.register('runDockerE2eDev', Exec) { group = 'e2e' - commandLine "docker", "compose", "up", "-d", "fakesmtp" + commandLine dockerPath, "compose", "up", "-d", "fakesmtp" workingDir E2E_DIR } tasks.register('stopDockerE2e', Exec) { group = 'e2e' - commandLine "docker", "compose", "stop" + commandLine dockerPath, "compose", "stop" workingDir E2E_DIR } tasks.register('cleanupDockerE2e', Exec) { group = 'e2e' - commandLine "docker", "compose", "rm", "-f" + commandLine dockerPath, "compose", "rm", "-f" workingDir E2E_DIR } tasks.register('tagDockerLocal', Exec) { group = 'e2e' dependsOn "docker" - commandLine "docker", "tag", "tolgee/tolgee", "tolgee/tolgee:local" + commandLine dockerPath, "tag", "tolgee/tolgee", "tolgee/tolgee:local" } From d6fb732cd92133d3cac1df89214cf84f92020829 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Thu, 11 Sep 2025 15:22:06 +0200 Subject: [PATCH 09/12] chore: e2e getLatestEmail with retries --- e2e/cypress/common/apiCalls/common.ts | 37 ++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index b8a31c76c8..6c7de2ca84 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -502,10 +502,39 @@ function fetchEmails(limit = 0) { export const getAllEmails = () => fetchEmails(); -export const getLatestEmail = () => - cy - .request({ url: `http://localhost:21080/api/v1/message/latest` }) - .then((r) => r.body as EmailSummary); +export const getLatestEmail = (): Cypress.Chainable => { + const promise = new Cypress.Promise((resolve, reject) => { + const attempt = (count: number) => { + cy.request({ + url: 'http://localhost:21080/api/v1/message/latest', + failOnStatusCode: false, + }).then((r) => { + const body = r.body as EmailSummary | undefined; + const hasMessage = + r.status === 200 && body && (body.HTML || body.Subject); + + if (hasMessage) { + resolve(body!); + return; + } + + if (count < 3) { + cy.wait(250).then(() => attempt(count + 1)); + } else { + reject( + new Error( + `Failed to fetch latest email after ${count + 1} attempt(s).` + ) + ); + } + }); + }; + + attempt(0); + }); + + return cy.wrap(promise); +}; export const getEmail = (id) => cy From b75ec69bf6a7f2f5763ca4bceb4f415b04836fa2 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Thu, 11 Sep 2025 16:20:48 +0200 Subject: [PATCH 10/12] fix: e2e signUp test --- e2e/cypress/common/apiCalls/common.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/e2e/cypress/common/apiCalls/common.ts b/e2e/cypress/common/apiCalls/common.ts index 6c7de2ca84..311832cfeb 100644 --- a/e2e/cypress/common/apiCalls/common.ts +++ b/e2e/cypress/common/apiCalls/common.ts @@ -411,16 +411,15 @@ export const getParsedEmailVerificationByIndex = (index: number) => { }; }); } else { - const id = getAllEmails().then((emails) => { - return emails[index].ID; - }); - return getEmail(id).then((email) => { - return { - verifyEmailLink: email.HTML.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1'), - fromAddress: email.From.Address, - toAddress: email.To[0].Address, - content: email.HTML, - }; + return getAllEmails().then((emails) => { + return getEmail(emails[index].ID).then((email) => { + return { + verifyEmailLink: email.HTML.replace(/.*(http:\/\/[\w:/]*).*/gs, '$1'), + fromAddress: email.From.Address, + toAddress: email.To[0].Address, + content: email.HTML, + }; + }); }); } }; From 889fcfde4d94bae860c912e0ddd08cfd1faf6831 Mon Sep 17 00:00:00 2001 From: Daniel Krizan Date: Wed, 15 Oct 2025 16:41:14 +0200 Subject: [PATCH 11/12] fix: wait for async email delivery in tests using retry --- .../v2/controllers/V2UserControllerTest.kt | 4 +++- .../OrganizationControllerInvitingTest.kt | 8 +++++-- .../ProjectsControllerInvitationTest.kt | 8 +++++-- .../ResetPasswordControllerTest.kt | 23 +++++++++---------- .../notification/NotificationServiceTest.kt | 7 ++++-- .../v2/controllers/task/TaskControllerTest.kt | 7 +++++- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt index 16a6dea251..7d3d01d013 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/V2UserControllerTest.kt @@ -131,7 +131,9 @@ class V2UserControllerTest : AuthorizedControllerTest() { ) performAuthPut("/v2/user", requestDTO).andIsOk - emailTestUtil.verifyEmailSent() + waitForNotThrowing(timeout = 2000, pollTime = 25) { + emailTestUtil.verifyEmailSent() + } assertThat(emailTestUtil.messageContents.single()) .contains(tolgeeProperties.frontEndUrl.toString()) diff --git a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerInvitingTest.kt b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerInvitingTest.kt index 07d93b5098..66528c5bd4 100644 --- a/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerInvitingTest.kt +++ b/backend/app/src/test/kotlin/io/tolgee/api/v2/controllers/organizationController/OrganizationControllerInvitingTest.kt @@ -162,7 +162,9 @@ class OrganizationControllerInvitingTest : AuthorizedControllerTest() { val organization = prepareTestOrganization() val code = inviteWithUserWithNameAndEmail(organization.id) - emailTestUtil.verifyEmailSent() + waitForNotThrowing(timeout = 2000, pollTime = 25) { + emailTestUtil.verifyEmailSent() + } val messageContent = emailTestUtil.messageContents.single() assertThat(messageContent).contains(code) @@ -176,7 +178,9 @@ class OrganizationControllerInvitingTest : AuthorizedControllerTest() { val organization = prepareTestOrganization() inviteWithUserWithNameAndEmail(organization.id) - emailTestUtil.verifyEmailSent() + waitForNotThrowing(timeout = 2000, pollTime = 25) { + emailTestUtil.verifyEmailSent() + } val messageContent = emailTestUtil.messageContents.single() assertThat(messageContent).doesNotContain(" Date: Wed, 15 Oct 2025 16:42:16 +0200 Subject: [PATCH 12/12] fix: determine npm command dynamically based on OS and installation method --- gradle/utils.gradle | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gradle/utils.gradle b/gradle/utils.gradle index 5140ecc781..3aa2a35ee4 100644 --- a/gradle/utils.gradle +++ b/gradle/utils.gradle @@ -1,7 +1,10 @@ ext { - if (System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows")) { + def osName = System.getProperty("os.name").toLowerCase(Locale.ROOT) + if (osName.contains("windows")) { npmCommandName = "npm.cmd" } else { - npmCommandName = "npm" + def whichNpm = ["bash", "-c", "command -v npm"].execute() + def npmPath = whichNpm.text.trim() + npmCommandName = npmPath ?: "npm" } }