Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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! 👋<br/><br/>
${if (isInitial) "To set a password for your account, <b>follow this link</b>:<br/>" else "To reset your password, <b>follow this link</b>:<br/>"}
<a href="$url">$url</a><br/><br/>
If you have not requested this e-mail, please ignore it.<br/><br/>

Regards,<br/>
Tolgee
""".trimIndent(),
)

Expand All @@ -67,15 +64,12 @@ class ResetPasswordRequestHandler(
EmailParams(
to = request.email,
subject = "Password reset - SSO managed account",
header = "Password reset",
text =
"""
Hello! 👋<br/><br/>
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.<br/><br/>
To access your account, please use the "SSO Login" button on the Tolgee login page. No password reset is needed.<br/><br/>
If you did not make this request, you may safely ignore this email.<br/><br/>

Regards,<br/>
Tolgee
""".trimIndent(),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,31 @@
package io.tolgee.component.email

import io.tolgee.dtos.misc.EmailParams
import io.tolgee.model.UserAccount
import org.springframework.stereotype.Component

@Component
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! 👋<br/><br/>
header = "Verify your e-mail",
text = """
${if (isSignUp) "Welcome to Tolgee. Thanks for signing up. \uD83C\uDF89<br/><br/>" else ""}

To verify your e-mail, <b>follow this link</b>:<br/>
<a href="$url">$url</a><br/><br/>

Regards,<br/>
Tolgee
""".trimIndent(),
)
tolgeeEmailSender.sendEmail(params)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,10 @@ class InvitationEmailSender(
subject = "Invitation to Tolgee",
text =
"""
Hello! 👋<br/><br/>
Good news. ${getInvitationSentence(invitation)}<br/><br/>

To accept the invitation, <b>follow this link</b>:<br/>
<a href="$url">$url</a><br/><br/>

Regards,<br/>
Tolgee
""".trimIndent(),
)
tolgeeEmailSender.sendEmail(params)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,69 +1,28 @@
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 =
"""
<html>
<body style="font-size: 15px">
${params.text}<br/><br/>
<img style="max-width: 100%; width:120px" src="cid:logo.png" />
</body>
</html>
""".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",
val properties = mapOf<String, Any>()
.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",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I recon my original idea was to eventually deprecate that component; i.e. new code would use the new email system while legacy code can still use this until it is eventually migrated to use a dedicated template. Not sure templateName is needed 😅

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We agreed to wrap all existing emails in a default template and just drop the legacy email text into it. Since the new email designs aren’t ready yet, this lets us still use the feature in the meantime 😀

locale = Locale.ENGLISH,
properties = properties,
attachments = params.attachments,
bcc = params.bcc,
replyTo = params.replyTo,
)

mailSender.send(helper.mimeMessage)
}

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 """
<br /><br />
Best regards,
<br />
Tolgee Team
""".trimIndent()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ class EmailParams(
var to: String,
var from: String? = null,
var bcc: Array<String>? = null,
var text: String,
var text: String? = null,
var header: String? = null,
var subject: String,
var attachments: List<EmailAttachment> = listOf(),
var replyTo: String? = null,
var templateName: String? = null,
var recipientName: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,14 +28,16 @@ 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<String, Any?> {
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(),
"instanceUrl" to tolgeeProperties.frontEndUrl,
"instanceQualifier" to if (isCloud) tolgeeProperties.appName else backendUrl.intoQualifier(),
"backendUrl" to backendUrl
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure about that... Especially wrt the instance qualifier. The fallback to using the domain name people are using to access Tolgee is likely a lot more recognizable than whatever internal URL is used.

Problems in development environment are likely related to the fact I completely overlooked that and didn't add the appropriate proxy config to Vite 😅

)
}

Expand Down
21 changes: 16 additions & 5 deletions backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -52,6 +52,9 @@ class EmailService(
locale: Locale,
properties: Map<String, Any> = mapOf(),
attachments: List<EmailAttachment> = listOf(),
subject: String? = null,
bcc: Array<String>? = null,
replyTo: String? = null,
) {
val globalVariables = emailGlobalVariablesProvider()
val context = Context(locale, properties)
Expand All @@ -63,8 +66,8 @@ class EmailService(
context.setVariable(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, tec)

val html = templateEngine.process(template, context)
val subject = extractEmailTitle(html)
sendEmail(recipient, subject, html, attachments)
val subject = subject ?: extractEmailTitle(html)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Setting a subject would break internationalization of emails. I guess this is needed for the backcompat layer, but... not ideal as it makes the service contract loose here :(

I don't think the template allows the subject being a variable directly but that could be mitigated with a passthrough i18n string; i.e. email.legacy.subject-passthrough = "{subject}"

sendEmail(recipient, subject, html, attachments, bcc, replyTo)
}

@Async
Expand All @@ -73,6 +76,8 @@ class EmailService(
subject: String,
contents: String,
attachments: List<EmailAttachment> = listOf(),
bcc: Array<String>? = null,
replyTo: String? = null,
) {
val message = mailSender.createMimeMessage()
val helper = MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED, "UTF8")
Expand All @@ -81,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ class EmailTemplateEngine() : SpringTemplateEngine() {
lateinit var emailMessageResolver: IMessageResolver

override fun initializeSpringSpecific() {
setMessageResolver(emailMessageResolver)
super.initializeSpringSpecific()
if (this::emailMessageResolver.isInitialized) {
setMessageResolver(emailMessageResolver)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 2 additions & 7 deletions backend/data/src/main/resources/I18n_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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}\
<br/><br/>\
{0}\
<br/><br/>\
If you want to unsubscribe from these emails, please check the <a href="{1}">notifications settings page</a>.\
<br/><br/>\
Regards,<br/>\
Tolgee
If you want to unsubscribe from these emails, please check the <a href="{1}">notifications settings page</a>.

notifications.email.view-in-tolgee-link=View in Tolgee

Expand Down
Loading
Loading