-
Notifications
You must be signed in to change notification settings - Fork 19
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
dd25719
commit fa3a0cc
Showing
11 changed files
with
295 additions
and
1 deletion.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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")) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
""" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) } | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} | ||
} |