Skip to content

Instantly share code, notes, and snippets.

@xhanin
Created April 1, 2021 05:29
Show Gist options
  • Save xhanin/5d1e063dbe4bb893e4c60e488ef16bed to your computer and use it in GitHub Desktop.
Save xhanin/5d1e063dbe4bb893e4c60e488ef16bed to your computer and use it in GitHub Desktop.
A simple Kotlin Mailhog Test Container - allow to use mailhog to test sending emails with a real smtp server
package mailhog
/*
dependencies:
testImplementation("org.testcontainers:testcontainers:1.15.2")
testImplementation(platform("org.http4k:http4k-bom:4.3.5.4"))
testImplementation("org.http4k:http4k-core")
testImplementation("org.http4k:http4k-client-apache") {
because("mailhog http api messages access")
}
testImplementation("org.http4k:http4k-format-jackson") {
because("mailhog messages access unmarshalling")
}
testImplementation("com.sun.mail:javax.mail:1.5.6") {
because("mailhog messages decoding")
}
*/
import com.fasterxml.jackson.annotation.JsonInclude
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import mailhog.MailhogJackson.auto
import org.http4k.client.ApacheClient
import org.http4k.core.Body
import org.http4k.core.Method
import org.http4k.core.Request
import org.http4k.format.ConfigurableJackson
import org.testcontainers.containers.GenericContainer
import org.testcontainers.containers.wait.strategy.Wait
import javax.mail.internet.MimeUtility
/**
* A test container for mailhog, to allow to test sending emails with a real smtp server.
*
* Example of use:
* <code>
* @Container
* val mailhog = MailhogContainer()
*
* @Test fun `my_test`() {
* myMailServiceUnderTest.setupSmtp(mailhog.smtpHost, mailhog.smtpPort)
*
* myMailServiceUnderTest.sendAMail()
*
* val messages = mailhog.messages
* expectThat(messages).hasSize(1)
*
* expectThat(messages)
* .filter { it.to.map { it.address }.contains("test1@email.com") }
* .hasSize(1).first().and {
* get { subject }.contains("My Test")
* get { body }.contains("Hello!")
* }
* }
* </code>
*/
class MailhogContainer: GenericContainer<MailhogContainer>("mailhog/mailhog") {
private val PORT_SMTP = 1025
private val PORT_HTTP = 8025
init {
withExposedPorts(PORT_SMTP, PORT_HTTP)
waitingFor(Wait.forHttp("/"))
}
val smtpPort get() = getMappedPort(PORT_SMTP)
val smtpHost get() = getContainerIpAddress()
val httpUrl get() = "http://${getContainerIpAddress()}:${getMappedPort(PORT_HTTP)}"
val messages get() = messagesBodyLens(client(Request(Method.GET, httpUrl + "/api/v2/messages")))
.items?.map { it.toMailMessage() }?:listOf()
companion object {
val messagesBodyLens = Body.auto<MailhogMessages>().toLens()
val client = ApacheClient()
}
}
// message structure for easy common use case testing
data class MailMessage(val from:EmailAddress, val to:List<EmailAddress>, val subject:String, val body:String)
data class EmailAddress(val address:String)
fun MailhogMessage.toMailMessage() = MailMessage(
from = From?.toEmailAddress()?:throw IllegalArgumentException("no from address"),
to = (To?:listOf()).map { it.toEmailAddress() },
subject = Content?.Headers?.get(MailhogMailHeaders.Subject.name)?.firstOrNull().orEmpty(),
body = Content?.Body.orEmpty()
.let { MimeUtility.decode(it.byteInputStream(), "quoted-printable").bufferedReader().readText() }
)
fun MailhogEmailAddress.toEmailAddress() = EmailAddress(Mailbox.orEmpty() + "@" + Domain.orEmpty())
// raw Mailhog data structure
data class MailhogMessages(val total: Number?, val count: Number?, val start: Number?, val items: List<MailhogMessage>?)
data class MailhogMessage(val ID: String?, val From: MailhogEmailAddress?, val To: List<MailhogEmailAddress>?, val Content: MailhogContent?, val Created: String?, val MIME: Any?, val Raw: MailhogRaw?)
data class MailhogEmailAddress(val Relays: Any?, val Mailbox: String?, val Domain: String?, val Params: String?)
data class MailhogContent(val Headers: Map<String, List<String>>, val Body: String?, val Size: Number?, val MIME: Any?)
data class MailhogRaw(val From: String?, val To: List<String>?, val Data: String?, val Helo: String?)
enum class MailhogMailHeaders {
`Content-Transfer-Encoding`, `Content-Type`, Date, From,
`MIME-Version`, `Message-ID`, Received, `Return-Path`, Subject, To
}
object MailhogJackson : ConfigurableJackson(
ObjectMapper()
.registerModule(KotlinModule())
.deactivateDefaultTyping()
.setSerializationInclusion(JsonInclude.Include.NON_NULL)
)
@JapuDCret
Copy link

I have adapted this for the ktor http client and also added a (crude) functionality to extract plaintext and html content

open class MailhogContainer(dockerImageName: DockerImageName = DockerImageName.parse("mailhog/mailhog")) :
    GenericContainer<MailhogContainer>(dockerImageName) {
    private val PORT_SMTP = 1025
    private val PORT_HTTP = 8025

    init {
        withExposedPorts(PORT_SMTP, PORT_HTTP)
        waitingFor(Wait.forHttp("/"))
    }

    val smtpPort get() = getMappedPort(PORT_SMTP)
    val smtpHost get() = host
    val httpUrl get() = "http://$host:${getMappedPort(PORT_HTTP)}"

    val messages: List<MailMessage>
        get() = runBlocking {
            client.get("/api/v2/messages")
                .body<MailhogMessages>()
                .items?.map { it.toMailMessage() } ?: listOf()
        }

    private val client = HttpClient().config {
        expectSuccess = true

        install(DefaultRequest) {
            url("$httpUrl/")
            accept(ContentType.Application.Json)
            contentType(ContentType.Application.Json)
        }

        install(ContentNegotiation) {
            register(ContentType.Application.Json, JacksonConverter(objectMapper = MailhogJackson))
            // mailhog sets this strange content type, see https://github.com/mailhog/MailHog/issues/165
            register(ContentType("text", "json"), JacksonConverter(objectMapper = MailhogJackson))
        }

        install(Logging) {
            level = LogLevel.ALL
        }
    }
}

data class MailMessage(val from: EmailAddress, val to: List<EmailAddress>, val subject: String, val body: String, val textContent: String, val htmlContent: String)
data class EmailAddress(val address: String)

fun MailhogMessage.toMailMessage() = MailMessage(
    from = From?.toEmailAddress() ?: throw IllegalArgumentException("no from address"),
    to = (To ?: listOf()).map { it.toEmailAddress() },
    subject = Content?.Headers?.get(MailhogMailHeaders.Subject.name)?.firstOrNull().orEmpty(),
    body = readBody(),
    textContent = readBodyContentPart(ContentType.Text.Plain.toString()),
    htmlContent = readBodyContentPart(ContentType.Text.Html.toString()),
)

private fun MailhogMessage.readBody() = Content?.Body.orEmpty()
    .let { MimeUtility.decode(it.byteInputStream(), "quoted-printable").bufferedReader().readText() }

private fun MailhogMessage.readBodyContentPart(contentType: String): String {
    val body = readBody()
    var start = body.indexOf(contentType) + contentType.length
    if (start < 0) {
        return ""
    }
    val quotedPrintableText = "quoted-printable"
    val quotedPrintablePos = body.indexOf(quotedPrintableText, start) + quotedPrintableText.length
    if (quotedPrintablePos > start) {
        start = quotedPrintablePos
    }
    val end = body.indexOf("------=_Part", start)

    return body.substring(start, end).trim()
}

private fun MailhogEmailAddress.toEmailAddress() = EmailAddress(Mailbox.orEmpty() + "@" + Domain.orEmpty())

// raw Mailhog data structure
data class MailhogMessages(
    val total: Number?,
    val count: Number?,
    val start: Number?,
    val items: List<MailhogMessage>?
)

data class MailhogMessage(
    val ID: String?,
    val From: MailhogEmailAddress?,
    val To: List<MailhogEmailAddress>?,
    val Content: MailhogContent?,
    val Created: String?,
    val MIME: Any?,
    val Raw: MailhogRaw?
)

data class MailhogEmailAddress(val Relays: Any?, val Mailbox: String?, val Domain: String?, val Params: String?)

data class MailhogContent(
    val Headers: Map<String, List<String>>,
    val Body: String?,
    val Size: Number?,
    val MIME: Any?
)

data class MailhogRaw(val From: String?, val To: List<String>?, val Data: String?, val Helo: String?)

enum class MailhogMailHeaders {
    `Content-Transfer-Encoding`, `Content-Type`, Date, From,
    `MIME-Version`, `Message-ID`, Received, `Return-Path`, Subject, To
}

val MailhogJackson = jacksonObjectMapper().apply {
    deactivateDefaultTyping()
    setSerializationInclusion(JsonInclude.Include.NON_NULL)
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment