diff --git a/build.gradle.kts b/build.gradle.kts index 6f2a76ae..0154c12a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,10 @@ dependencies { implementation("com.cloudinary:cloudinary:1.0.14") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") implementation("org.springframework.boot:spring-boot-starter-validation:3.1.1") + implementation("org.springframework.boot:spring-boot-starter-mail:3.0.2") + implementation("org.springframework.boot:spring-boot-starter-mustache:3.0.2") + implementation("org.commonmark:commonmark:0.21.0") + implementation("org.commonmark:commonmark-ext-yaml-front-matter:0.21.0") developmentOnly("org.springframework.boot:spring-boot-devtools") runtimeOnly("com.h2database:h2") testImplementation("org.springframework.boot:spring-boot-starter-test") diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt index 5c1bbe1c..26cb571b 100644 --- a/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/BackendApplication.kt @@ -5,10 +5,11 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication import org.springframework.data.jpa.repository.config.EnableJpaAuditing import pt.up.fe.ni.website.backend.config.auth.AuthConfigProperties +import pt.up.fe.ni.website.backend.config.email.EmailConfigProperties import pt.up.fe.ni.website.backend.config.upload.UploadConfigProperties @SpringBootApplication -@EnableConfigurationProperties(AuthConfigProperties::class, UploadConfigProperties::class) +@EnableConfigurationProperties(AuthConfigProperties::class, UploadConfigProperties::class, EmailConfigProperties::class) @EnableJpaAuditing class BackendApplication diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/email/EmailConfig.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/email/EmailConfig.kt new file mode 100644 index 00000000..ea325a07 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/email/EmailConfig.kt @@ -0,0 +1,33 @@ +package pt.up.fe.ni.website.backend.config.email + +import com.samskivert.mustache.Mustache +import org.commonmark.ext.front.matter.YamlFrontMatterExtension +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import org.commonmark.renderer.text.TextContentRenderer +import org.springframework.boot.autoconfigure.mustache.MustacheResourceTemplateLoader +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class EmailConfig( + private val emailConfigProperties: EmailConfigProperties +) { + @Bean + fun mustacheCompiler() = Mustache.compiler().withLoader( + MustacheResourceTemplateLoader(emailConfigProperties.templatePrefix, emailConfigProperties.templateSuffix) + ) + + @Bean + fun commonmarkParser() = Parser.builder().extensions( + listOf( + YamlFrontMatterExtension.create() + ) + ).build() + + @Bean + fun commonmarkHtmlRenderer() = HtmlRenderer.builder().build() + + @Bean + fun commonmarkTextRenderer() = TextContentRenderer.builder().build() +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/config/email/EmailConfigProperties.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/config/email/EmailConfigProperties.kt new file mode 100644 index 00000000..778d435d --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/config/email/EmailConfigProperties.kt @@ -0,0 +1,13 @@ +package pt.up.fe.ni.website.backend.config.email + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "email") +data class EmailConfigProperties( + val from: String, + val fromPersonal: String = from, + val templatePrefix: String = "classpath:templates/email/", + val templateSuffix: String = ".mustache", + val defaultHtmlLayout: String = "layout.html", + val defaultStyle: String = "classpath:email/style.css" +) diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/email/BaseEmailBuilder.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/email/BaseEmailBuilder.kt new file mode 100644 index 00000000..9ebd78b3 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/email/BaseEmailBuilder.kt @@ -0,0 +1,55 @@ +package pt.up.fe.ni.website.backend.email + +import jakarta.validation.Valid +import jakarta.validation.constraints.Email +import org.springframework.mail.javamail.MimeMessageHelper +import pt.up.fe.ni.website.backend.config.ApplicationContextUtils +import pt.up.fe.ni.website.backend.config.email.EmailConfigProperties +import pt.up.fe.ni.website.backend.model.Account + +abstract class BaseEmailBuilder : EmailBuilder { + protected open val emailConfigProperties = ApplicationContextUtils.getBean(EmailConfigProperties::class.java) + + var from: String? = null + var fromPersonal: String? = null + var to: MutableSet = mutableSetOf() + var cc: MutableSet = mutableSetOf() + var bcc: MutableSet = mutableSetOf() + + fun from(@Email email: String, personal: String = email) = apply { + from = email + fromPersonal = personal + } + + fun to(@Email vararg emails: String) = apply { + to.addAll(emails) + } + + fun to(@Valid vararg users: Account) = apply { + to.addAll(users.map { it.email }) + } + + fun cc(@Email vararg emails: String) = apply { + cc.addAll(emails) + } + + fun cc(@Valid vararg users: Account) = apply { + cc.addAll(users.map { it.email }) + } + + fun bcc(@Email vararg emails: String) = apply { + bcc.addAll(emails) + } + + fun bcc(@Valid vararg users: Account) = apply { + bcc.addAll(users.map { it.email }) + } + + override fun build(helper: MimeMessageHelper) { + helper.setFrom(from ?: emailConfigProperties.from, fromPersonal ?: emailConfigProperties.fromPersonal) + + to.forEach(helper::setTo) + cc.forEach(helper::setCc) + bcc.forEach(helper::setBcc) + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/email/EmailBuilder.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/email/EmailBuilder.kt new file mode 100644 index 00000000..30a80db5 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/email/EmailBuilder.kt @@ -0,0 +1,7 @@ +package pt.up.fe.ni.website.backend.email + +import org.springframework.mail.javamail.MimeMessageHelper + +interface EmailBuilder { + fun build(helper: MimeMessageHelper) +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/email/SimpleEmailBuilder.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/email/SimpleEmailBuilder.kt new file mode 100644 index 00000000..a9424a70 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/email/SimpleEmailBuilder.kt @@ -0,0 +1,73 @@ +package pt.up.fe.ni.website.backend.email + +import jakarta.activation.DataSource +import jakarta.activation.FileDataSource +import jakarta.activation.URLDataSource +import java.io.File +import java.net.URL +import org.springframework.mail.javamail.MimeMessageHelper + +class SimpleEmailBuilder : BaseEmailBuilder() { + private var text: String? = null + private var html: String? = null + private var subject: String? = null + private var attachments: MutableList = mutableListOf() + // Inlines - similar to attachments, not shown as downloadable but can be inserted in an email. For example, inline images. + private var inlines: MutableList = mutableListOf() + + fun text(text: String) = apply { + this.text = text + } + + fun html(html: String) = apply { + this.html = html + } + + fun subject(subject: String) = apply { + this.subject = subject + } + + fun attach(name: String, content: DataSource) = apply { + attachments.add(EmailFile(name, content)) + } + + fun attach(name: String, content: File) = apply { + attachments.add(EmailFile(name, FileDataSource(content))) + } + + fun attach(name: String, path: String) = apply { + attachments.add(EmailFile(name, URLDataSource(URL(path)))) + } + + fun inline(name: String, content: DataSource) = apply { + inlines.add(EmailFile(name, content)) + } + + fun inline(name: String, content: File) = apply { + inlines.add(EmailFile(name, FileDataSource(content))) + } + + fun inline(name: String, path: String) = apply { + inlines.add(EmailFile(name, URLDataSource(URL(path)))) + } + + override fun build(helper: MimeMessageHelper) { + super.build(helper) + + when { + text != null && html != null -> helper.setText(text!!, html!!) + html != null -> helper.setText(html!!, true) + text != null -> helper.setText(text!!) + } + + subject?.let { helper.setSubject(it) } + + attachments.forEach { helper.addAttachment(it.name, it.content) } + inlines.forEach { helper.addInline(it.name, it.content) } + } + + private data class EmailFile( + val name: String, + val content: DataSource + ) +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/email/TemplateEmailBuilder.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/email/TemplateEmailBuilder.kt new file mode 100644 index 00000000..c7a9cc09 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/email/TemplateEmailBuilder.kt @@ -0,0 +1,78 @@ +package pt.up.fe.ni.website.backend.email + +import com.samskivert.mustache.Mustache +import org.commonmark.ext.front.matter.YamlFrontMatterVisitor +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import org.commonmark.renderer.text.TextContentRenderer +import org.springframework.core.io.Resource +import org.springframework.core.io.UrlResource +import org.springframework.mail.javamail.MimeMessageHelper +import org.springframework.util.ResourceUtils +import pt.up.fe.ni.website.backend.config.ApplicationContextUtils + +abstract class TemplateEmailBuilder( + private val template: String +) : BaseEmailBuilder() { + private val commonmarkParser = ApplicationContextUtils.getBean(Parser::class.java) + private val commonmarkHtmlRenderer = ApplicationContextUtils.getBean(HtmlRenderer::class.java) + private val commonmarkTextRenderer = ApplicationContextUtils.getBean(TextContentRenderer::class.java) + private val mustache = ApplicationContextUtils.getBean(Mustache.Compiler::class.java) + + private var data: T? = null + + fun data(data: T) = apply { + this.data = data + } + + override fun build(helper: MimeMessageHelper) { + super.build(helper) + + if (data == null) return + + val markdown = mustache.loadTemplate(template).execute(data) + + val doc = commonmarkParser.parse(markdown) + val htmlContent = commonmarkHtmlRenderer.render(doc) + val text = commonmarkTextRenderer.render(doc) + + val yamlVisitor = YamlFrontMatterVisitor() + doc.accept(yamlVisitor) + + val subject = yamlVisitor.data["subject"]?.firstOrNull() + subject?.let { helper.setSubject(it) } + + val styles = yamlVisitor.data.getOrDefault("styles", mutableListOf()).apply { + if (yamlVisitor.data["no_default_style"].isNullOrEmpty()) { + this.add(emailConfigProperties.defaultStyle) + } + }.map { + ResourceUtils.getFile(it).readText() + } + + val htmlTemplate = yamlVisitor.data["layout"]?.firstOrNull() ?: emailConfigProperties.defaultHtmlLayout + val html = mustache.loadTemplate(htmlTemplate).execute( + mapOf( + "subject" to subject, + "content" to htmlContent, + "styles" to styles + ) + ) + + helper.setText(text, html) + + yamlVisitor.data.getOrDefault("attachments", emptyList()).forEach { addFile(helper::addAttachment, it) } + yamlVisitor.data.getOrDefault("inline", emptyList()).forEach { addFile(helper::addInline, it) } + } + + private fun addFile(fn: (String, Resource) -> Any, file: String) { + val split = file.split("\\s*::\\s*".toRegex(), 2) + + if (split.isEmpty()) return + + val name = split[0] + val path = split.getOrElse(1) { split[0] } + + fn(name, UrlResource(path)) + } +} diff --git a/src/main/kotlin/pt/up/fe/ni/website/backend/service/EmailService.kt b/src/main/kotlin/pt/up/fe/ni/website/backend/service/EmailService.kt new file mode 100644 index 00000000..61335751 --- /dev/null +++ b/src/main/kotlin/pt/up/fe/ni/website/backend/service/EmailService.kt @@ -0,0 +1,20 @@ +package pt.up.fe.ni.website.backend.service + +import org.springframework.mail.javamail.JavaMailSender +import org.springframework.mail.javamail.MimeMessageHelper +import org.springframework.stereotype.Service +import pt.up.fe.ni.website.backend.email.EmailBuilder + +@Service +class EmailService( + private val mailSender: JavaMailSender +) { + fun send(email: EmailBuilder) { + val message = mailSender.createMimeMessage() + + val helper = MimeMessageHelper(message, true) + email.build(helper) + + mailSender.send(message) + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 449647da..80a4c760 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -38,3 +38,17 @@ upload.static-serve=http://localhost:3000/static # Cors Origin cors.allow-origin = http://localhost:3000 + +# Email config +spring.mail.host= +spring.mail.port= +spring.mail.username= +spring.mail.password= +spring.mail.properties[mail.smtp.auth]=true +spring.mail.properties[mail.smtp.starttls.enable]=true +spring.mail.properties[mail.smtp.connectiontimeout]=5000 +spring.mail.properties[mail.smtp.timeout]=3000 +spring.mail.properties[mail.smtp.writetimeout]=5000 + +email.from=ni@aefeup.pt +email.from-personal=NIAEFEUP diff --git a/src/main/resources/email/style.css b/src/main/resources/email/style.css new file mode 100644 index 00000000..d34cd93f --- /dev/null +++ b/src/main/resources/email/style.css @@ -0,0 +1,3 @@ +/* +TODO +*/ diff --git a/src/main/resources/templates/email/layout.html.mustache b/src/main/resources/templates/email/layout.html.mustache new file mode 100644 index 00000000..3b497c42 --- /dev/null +++ b/src/main/resources/templates/email/layout.html.mustache @@ -0,0 +1,20 @@ + + + + + + + {{{subject}}} + + {{#styles}} + + {{/styles}} + + + + {{{content}}} + + diff --git a/src/test/kotlin/pt/up/fe/ni/website/backend/email/BaseEmailBuilderTest.kt b/src/test/kotlin/pt/up/fe/ni/website/backend/email/BaseEmailBuilderTest.kt new file mode 100644 index 00000000..9bfaaae9 --- /dev/null +++ b/src/test/kotlin/pt/up/fe/ni/website/backend/email/BaseEmailBuilderTest.kt @@ -0,0 +1,94 @@ +import jakarta.validation.ConstraintViolationException +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.mail.javamail.JavaMailSenderImpl +import org.springframework.mail.javamail.MimeMessageHelper +import pt.up.fe.ni.website.backend.config.email.EmailConfigProperties +import pt.up.fe.ni.website.backend.email.BaseEmailBuilder +import pt.up.fe.ni.website.backend.model.Account + +@ExtendWith(MockitoExtension::class) +class BaseEmailBuilderTest { + private lateinit var emailConfigProperties: EmailConfigProperties + private lateinit var mimeMessageHelper: MimeMessageHelper + private lateinit var baseEmailBuilder: BaseEmailBuilderImpl + + @BeforeEach + fun setup() { + emailConfigProperties = Mockito.mock(EmailConfigProperties::class.java).apply { + Mockito.`when`(from).thenReturn("test@email.com") + Mockito.`when`(fromPersonal).thenReturn("Test") + } + + val javaMailSender = JavaMailSenderImpl() + val mimeMessage = javaMailSender.createMimeMessage() + mimeMessageHelper = MimeMessageHelper(mimeMessage, true) + + baseEmailBuilder = BaseEmailBuilderImpl(emailConfigProperties) + } + + @Test + fun `valid emails are correctly set in 'to' field`() { + baseEmailBuilder.to("to1@email.com", "to2@email.com") + baseEmailBuilder.build(mimeMessageHelper) + + Assertions.assertEquals(setOf("to1@email.com", "to2@email.com"), baseEmailBuilder.getToEmails()) + } + + @Test + fun `invalid emails throw exception`() { + Assertions.assertThrows(ConstraintViolationException::class.java) { + baseEmailBuilder.to("invalid") + } + } + + @Test + fun `valid account emails are correctly set in 'to' field`() { + val account1 = Account("Account 1", "account1@email.com","account1password", null, null, null, null, null) + val account2 = Account("Account 2", "account2@email.com", "account2password", null, null, null, null, null) + + baseEmailBuilder.to(account1, account2) + baseEmailBuilder.build(mimeMessageHelper) + + Assertions.assertEquals(setOf("account1@email.com", "account2@email.com"), baseEmailBuilder.getToEmails()) + } + + @Test + fun `emails are correctly set in 'cc' field`() { + baseEmailBuilder.cc("cc1@email.com", "cc2@email.com") + baseEmailBuilder.build(mimeMessageHelper) + + Assertions.assertEquals(setOf("cc1@email.com", "cc2@email.com"), baseEmailBuilder.getCcEmails()) + } + + @Test + fun `emails are correctly set in 'bcc' field`() { + baseEmailBuilder.bcc("bcc1@email.com", "bcc2@email.com") + baseEmailBuilder.build(mimeMessageHelper) + + Assertions.assertEquals(setOf("bcc1@email.com", "bcc2@email.com"), baseEmailBuilder.getBccEmails()) + } + + @Test + fun `'from' email and personal name are set correctly`() { + baseEmailBuilder.from("from@email.com", "From") + baseEmailBuilder.build(mimeMessageHelper) + + Assertions.assertEquals("from@email.com", baseEmailBuilder.getFromEmail()) + Assertions.assertEquals("From", baseEmailBuilder.getName()) + } +} + +class BaseEmailBuilderImpl( + override val emailConfigProperties: EmailConfigProperties +) : BaseEmailBuilder() { + fun getToEmails(): Set = to + fun getCcEmails(): Set = cc + fun getBccEmails(): Set = bcc + fun getFromEmail(): String? = from + fun getName(): String? = fromPersonal +}