diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 294fc2d..6b1c5b2 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -26,6 +26,7 @@ diff --git a/CHANGELOG.md b/CHANGELOG.md index 566ad30..be368c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ # Unreleased +* smtp: new module introduced for sending emails over SMTP * server: expose Server.listen and bound Server.address as separate properties #93 * server: Server.use() can register extensions that implement multiple supported interfaces at the same time * server: useOnly() will now add parser/renderer if it wasn't yet registered, to avoid confusion diff --git a/README.md b/README.md index d741fd8..2a78798 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ See the [Tutorial](TUTORIAL.md) to grasp the basics quickly. * [jdbc-test](jdbc-test) - provides a way of testing your DB code using a real DB * [jobs](jobs) - provides a simple scheduled JobRunner * [oauth](oauth) - implements OAuth 2.0 login with several providers +* [smtp](smtp) - for sending email over SMTP * [openapi](openapi) - generates OpenAPI 3.0 spec for all routes in a context, viewable with [Swagger UI](https://swagger.io/tools/swagger-ui/) ### Integrations diff --git a/settings.gradle.kts b/settings.gradle.kts index 88f3e51..aea55bb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,4 +33,21 @@ dependencyResolutionManagement { } } -include("core", "server", "json", "csv", "jackson", "i18n", "serialization", "jdbc", "jobs", "jdbc-test", "oauth", "liquibase", "slf4j", "openapi", "sample") +include( + "core", + "server", + "json", + "csv", + "jackson", + "i18n", + "serialization", + "jdbc", + "jobs", + "jdbc-test", + "oauth", + "smtp", + "liquibase", + "slf4j", + "openapi", + "sample" +) diff --git a/smtp/.env b/smtp/.env new file mode 100644 index 0000000..e58e0e9 --- /dev/null +++ b/smtp/.env @@ -0,0 +1,3 @@ +MAIL_FROM=klite@codeborne.com +MAIL_FROM_NAME=Klite Test +SMTP_HOST=localhost diff --git a/smtp/README.md b/smtp/README.md new file mode 100644 index 0000000..bf52523 --- /dev/null +++ b/smtp/README.md @@ -0,0 +1,30 @@ +# klite-smtp + +Provides a way to send plain text or html email over SMTP using *javax.mail*. + +Depends on [klite-i18n](../i18n) for translations. + +To use it, initialize the correct implementation when creating the Server instance: +```kotlin +register(if (Config.isDev) FakeEmailService::class else RealEmailService::class) +``` + +Add approprate content to your translation files, e.g. `en.json`: +```json +{ + "emails": { + "welcome": { + "subject": "Welcome to our service", + "body": "Hello, {name}! Welcome to our service.", + "action": "Click here to login" + } + } +} +``` + +Then you can send emails like this: +```kotlin +emailService.send(Email("recipient@hello.ee"), EmailContent("en", "welcome", mapOf("name" to "John"), URI("https://github.com/login"))) +``` + +You can redefine HTML email template by extending `EmailContent` class. diff --git a/smtp/build.gradle.kts b/smtp/build.gradle.kts new file mode 100644 index 0000000..4c30abd --- /dev/null +++ b/smtp/build.gradle.kts @@ -0,0 +1,6 @@ +dependencies { + api(project(":server")) + implementation(project(":i18n")) + implementation("com.sun.mail:javax.mail:1.6.2") + testImplementation(files("../sample/i18n")) +} diff --git a/smtp/src/EmailContent.kt b/smtp/src/EmailContent.kt new file mode 100644 index 0000000..0abd42e --- /dev/null +++ b/smtp/src/EmailContent.kt @@ -0,0 +1,65 @@ +package klite.smtp + +import klite.MimeTypes +import klite.html.unaryPlus +import klite.i18n.Lang.translate +import org.intellij.lang.annotations.Language +import java.net.URI + +open class EmailContent(val lang: String, val labelKey: String, val substitutions: Map = emptyMap(), val actionUrl: URI? = null) { + open val subject get() = translate(lang, "emails.$labelKey.subject", substitutions) + open val body get() = translate(lang, "emails.$labelKey.body", substitutions) + open val actionLabel get() = translate(lang, "emails.$labelKey.action", substitutions) + + @Language("html") + open fun fullHtml() = """ + + + + + + + + +${contentHtml()} + + +""" + + @Language("html") + protected open fun contentHtml() = """ +
+ + + + +
+

${+subject}

+
${+body}
+ ${actionUrl?.let {""" + + $actionLabel + + """} ?: ""} +

+ ${translate(lang, "title")} +

+
+
+""" +} diff --git a/smtp/src/EmailService.kt b/smtp/src/EmailService.kt new file mode 100644 index 0000000..8c02b66 --- /dev/null +++ b/smtp/src/EmailService.kt @@ -0,0 +1,84 @@ +package klite.smtp + +import klite.* +import java.util.* +import javax.activation.DataHandler +import javax.mail.Authenticator +import javax.mail.Message.RecipientType.* +import javax.mail.Part.ATTACHMENT +import javax.mail.PasswordAuthentication +import javax.mail.Session +import javax.mail.Transport +import javax.mail.internet.* +import javax.mail.util.ByteArrayDataSource +import kotlin.text.Charsets.UTF_8 + +interface EmailService { + fun send(to: Email, subject: String, body: String, bodyMimeType: String = MimeTypes.text, attachments: Map = emptyMap(), cc: List = emptyList()) + + fun send(to: Email, content: EmailContent, attachments: Map = emptyMap(), cc: List = emptyList()) = + send(to, content.subject, content.fullHtml(), MimeTypes.html, attachments, cc) +} + +class FakeEmailService: EmailService { + private val log = logger() + lateinit var lastSentEmail: String + + override fun send(to: Email, subject: String, body: String, bodyMimeType: String, attachments: Map, cc: List) { + lastSentEmail = """ + Email to $to, CC: $cc + Subject: $subject + Body ($bodyMimeType): $body + ${if (attachments.isNotEmpty()) "Attachments: ${attachments.keys}" else ""} + """ + log.info(lastSentEmail) + } +} + +class RealEmailService( + internal val mailFrom: InternetAddress = InternetAddress(Config["MAIL_FROM"], Config.optional("MAIL_FROM_NAME")), + smtpUser: String? = Config.optional("SMTP_USER"), + smtpPort: String? = Config.optional("SMTP_PORT", "25"), + props: Properties = Properties().apply { + this["mail.smtp.host"] = Config.optional("SMTP_HOST", "localhost") + this["mail.smtp.port"] = smtpPort + this["mail.smtp.starttls.enable"] = smtpPort == "587" + this["mail.smtp.auth"] = smtpUser != null + this["mail.smtp.ssl.protocols"] = "TLSv1.2" + }, + private val authenticator: Authenticator = object: Authenticator() { + override fun getPasswordAuthentication() = PasswordAuthentication(smtpUser, Config.required("SMTP_PASS")) + }, + private val session: Session = Session.getInstance(props, authenticator.takeIf { smtpUser != null }) +): EmailService { + override fun send(to: Email, subject: String, body: String, bodyMimeType: String, attachments: Map, cc: List) { + send(to, subject) { + cc.forEach { setRecipient(CC, InternetAddress(it.value)) } + if (attachments.isEmpty()) + setBody(body, bodyMimeType) + else + setContent(MimeMultipart().apply { + addBodyPart(MimeBodyPart().apply { setBody(body, bodyMimeType) }) + attachments.forEach { + addBodyPart(MimeBodyPart().apply { + dataHandler = DataHandler(ByteArrayDataSource(it.value, MimeTypes.typeFor(it.key))) + fileName = it.key + disposition = ATTACHMENT + setHeader("Content-ID", UUID.randomUUID().toString()) + }) + } + }) + } + } + + private fun MimePart.setBody(body: String, bodyMimeType: String) = setContent(body, "$bodyMimeType; charset=UTF-8") + + private fun send(to: Email, subject: String, block: MimeMessage.() -> Unit) = MimeMessage(session).apply { + setFrom(mailFrom) + setRecipient(BCC, mailFrom) + setRecipient(TO, InternetAddress(to.value)) + setSubject(subject, UTF_8.name()) + block() + Transport.send(this) + } +} diff --git a/smtp/test/EmailServiceTest.kt b/smtp/test/EmailServiceTest.kt new file mode 100644 index 0000000..dd19a7b --- /dev/null +++ b/smtp/test/EmailServiceTest.kt @@ -0,0 +1,28 @@ +package klite.smtp + +import ch.tutteli.atrium.api.fluent.en_GB.toBeAnInstanceOf +import ch.tutteli.atrium.api.verbs.expect +import io.mockk.spyk +import io.mockk.verify +import klite.Config +import klite.Email +import klite.MimeTypes +import org.junit.jupiter.api.Test + +val email = Email("some@email.com") + +class EmailServiceTest { + init { Config.useEnvFile() } + + @Test fun `instances can be created with default config`() { + expect(FakeEmailService()).toBeAnInstanceOf() + expect(RealEmailService()).toBeAnInstanceOf() + } + + @Test fun translates() { + val emailService = spyk(FakeEmailService()) + val content = EmailContent("en", "prepayment", mapOf("contractId" to "CONTRACT_ID")) + emailService.send(email, content) + verify { emailService.send(email, content.subject, content.fullHtml(), MimeTypes.html) } + } +} diff --git a/smtp/test/RealEmailServiceTest.kt b/smtp/test/RealEmailServiceTest.kt new file mode 100644 index 0000000..903a9ab --- /dev/null +++ b/smtp/test/RealEmailServiceTest.kt @@ -0,0 +1,58 @@ +package klite.smtp + +import ch.tutteli.atrium.api.fluent.en_GB.toContainExactly +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.fluent.en_GB.toStartWith +import ch.tutteli.atrium.api.verbs.expect +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import klite.Config +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import javax.mail.Message.RecipientType.BCC +import javax.mail.Session +import javax.mail.internet.InternetAddress +import javax.mail.internet.MimeMessage +import javax.mail.internet.MimeMultipart + +class RealEmailServiceTest { + init { Config.useEnvFile() } + val session = mockk(relaxed = true) + val service = RealEmailService(session = session) + + @Test fun `send plain text`() = runTest { + service.send(email, subject = "Subject", body = "Body") + val message = slot() + val toAddress = InternetAddress(email.value) + verify { session.getTransport(toAddress).sendMessage(capture(message), arrayOf(toAddress, service.mailFrom)) } + expect(message.captured.from.toList()).toContainExactly(service.mailFrom) + expect(message.captured.getRecipients(BCC).toList()).toContainExactly(service.mailFrom) + expect(message.captured.subject).toEqual("Subject") + expect(message.captured.contentType).toEqual("text/plain; charset=UTF-8") + expect(message.captured.content).toEqual("Body") + } + + @Test fun `send html`() = runTest { + service.send(email, subject = "Subject", body = "", bodyMimeType = "text/html") + val message = slot() + val toAddress = InternetAddress(email.value) + verify { session.getTransport(toAddress).sendMessage(capture(message), arrayOf(toAddress, service.mailFrom)) } + expect(message.captured.contentType).toEqual("text/html; charset=UTF-8") + expect(message.captured.content).toEqual("") + } + + @Test fun `send with attachment`() = runTest { + service.send(email, subject = "Subject", body = "Body", attachments = mapOf("hello.pdf" to ByteArray(0))) + val message = slot() + val toAddress = InternetAddress(email.value) + verify { session.getTransport(toAddress).sendMessage(capture(message), arrayOf(toAddress, service.mailFrom)) } + expect(message.captured.from.size).toEqual(1) + expect(message.captured.subject).toEqual("Subject") + expect(message.captured.getHeader("Content-Type")[0]).toStartWith("multipart/mixed") + val multipart = (message.captured.content as MimeMultipart) + expect(multipart.getBodyPart(0).contentType).toEqual("text/plain; charset=UTF-8") + expect(multipart.getBodyPart(0).content).toEqual("Body") + expect(multipart.getBodyPart(1).contentType).toEqual("application/pdf; name=hello.pdf") + } +}