Skip to content

Commit

Permalink
introduce klite-smtp module
Browse files Browse the repository at this point in the history
  • Loading branch information
angryziber committed Jan 7, 2025
1 parent dd25719 commit fa3a0cc
Show file tree
Hide file tree
Showing 11 changed files with 295 additions and 1 deletion.
1 change: 1 addition & 0 deletions .idea/gradle.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
3 changes: 3 additions & 0 deletions smtp/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
MAIL_FROM=[email protected]
MAIL_FROM_NAME=Klite Test
SMTP_HOST=localhost
30 changes: 30 additions & 0 deletions smtp/README.md
Original file line number Diff line number Diff line change
@@ -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("[email protected]"), EmailContent("en", "welcome", mapOf("name" to "John"), URI("https://github.com/login")))
```

You can redefine HTML email template by extending `EmailContent` class.
6 changes: 6 additions & 0 deletions smtp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
dependencies {
api(project(":server"))
implementation(project(":i18n"))
implementation("com.sun.mail:javax.mail:1.6.2")
testImplementation(files("../sample/i18n"))
}
65 changes: 65 additions & 0 deletions smtp/src/EmailContent.kt
Original file line number Diff line number Diff line change
@@ -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<String, String> = 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() = """
<html lang="$lang">
<head>
<meta charset="${MimeTypes.textCharset}">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="x-apple-disable-message-reformatting">
<style>
body {
font-family: sans-serif;
line-height: 1.375em;
margin: 0;
padding: 0;
word-spacing: normal;
text-align: left;
text-size-adjust: 100%;
}
table {
border: none;
border-spacing: 0;
}
</style>
</head>
<body>
${contentHtml()}
</body>
</html>
"""

@Language("html")
protected open fun contentHtml() = """
<div role="article" aria-roledescription="email" lang="$lang" style="background-color: rgb(243, 244, 246); padding: 1em">
<table role="presentation" style="width: 94%; max-width: 480px; margin: 0 auto">
<tr>
<td style="padding: 2em; background: white; color: rgb(17, 24, 39)">
<h1 style="margin: 1em 0; font-size: 1.625em; line-height: 1.25; font-weight: bold">${+subject}</h1>
<div style="margin-bottom: 1em; white-space: pre-line">${+body}</div>
${actionUrl?.let {"""
<a href="$it" style="background: rgb(17, 24, 39); font-weight: bold; text-decoration: none; text-align: center; padding: 1em 2em; color: white; border-radius: 4px; margin-bottom: 1em; display: block">
$actionLabel
</a>
"""} ?: ""}
<p style="margin: 0; font-size: 0.8em; color: rgb(107, 114, 128); text-align: center">
${translate(lang, "title")}
</p>
</td>
</tr>
</table>
</div>
"""
}
84 changes: 84 additions & 0 deletions smtp/src/EmailService.kt
Original file line number Diff line number Diff line change
@@ -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<String, ByteArray> = emptyMap(), cc: List<Email> = emptyList())

fun send(to: Email, content: EmailContent, attachments: Map<String, ByteArray> = emptyMap(), cc: List<Email> = 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<String, ByteArray>, cc: List<Email>) {
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<String, ByteArray>, cc: List<Email>) {
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)
}
}
28 changes: 28 additions & 0 deletions smtp/test/EmailServiceTest.kt
Original file line number Diff line number Diff line change
@@ -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("[email protected]")

class EmailServiceTest {
init { Config.useEnvFile() }

@Test fun `instances can be created with default config`() {
expect(FakeEmailService()).toBeAnInstanceOf<EmailService>()
expect(RealEmailService()).toBeAnInstanceOf<EmailService>()
}

@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) }
}
}
58 changes: 58 additions & 0 deletions smtp/test/RealEmailServiceTest.kt
Original file line number Diff line number Diff line change
@@ -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<Session>(relaxed = true)
val service = RealEmailService(session = session)

@Test fun `send plain text`() = runTest {
service.send(email, subject = "Subject", body = "Body")
val message = slot<MimeMessage>()
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 = "<Body>", bodyMimeType = "text/html")
val message = slot<MimeMessage>()
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("<Body>")
}

@Test fun `send with attachment`() = runTest {
service.send(email, subject = "Subject", body = "Body", attachments = mapOf("hello.pdf" to ByteArray(0)))
val message = slot<MimeMessage>()
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")
}
}

0 comments on commit fa3a0cc

Please sign in to comment.