Skip to content

Commit

Permalink
Added support for custom SMTP Server (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
TestaDiRapa committed May 13, 2024
1 parent d66d673 commit 5dba535
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 61 deletions.
48 changes: 41 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,24 @@

To run this project, you will need to add the following environment variables

- `CONTACT_FORM_CONFIGS_FOLDER`
- `MAIL_CONFIGS_FOLDER`
- `TEMPLATES_FOLDER`
- `GOOGLE_RECAPTCHA_SECRET`
- `SENDGRID_API_KEY`
- `CONTACT_FORM_CONFIGS_FOLDER`: An existing folder in your file system where the contact form configs will be stored.
- `MAIL_CONFIGS_FOLDER`: An existing folder in your file system where the email configs will be stored.
- `TEMPLATES_FOLDER`: An existing folder in your file system where the email templates will be stored.
- `GOOGLE_RECAPTCHA_SECRET`: A Google ReCaptcha secret (required only when using forms).

## Documentation

### Supported Mail Providers

- ~~[Sendgrid](https://sendgrid.com/)~~ Removed due to lack of support for batch emails requests
- [Resend](https://resend.io/)
- Custom SMTP server, that can be configured in the email & contact form configs

### Contact Form

#### Example of `CONTACT_FORM_CONFIGS_FOLDER` configuration files

**Resend-based config**
```json
{
"id": "UUID",
Expand All @@ -56,7 +57,25 @@ To run this project, you will need to add the following environment variables
"lang": "fr", // ISO 639-1
"subjectTemplate": "New mail from {{form.firstName}}",
"provider": "RESEND",
"apiKey": ""
"apiKey": "<YOUR_RESEND_API_KEY>"
}
```

**SMTP-based config**
```json
{
"id": "UUID",
"dailyLimit": 10,
"destination": "[email protected]",
"sender": "[email protected]",
"threshold": 0.5, // Recapthca score thresold
"lang": "fr", // ISO 639-1
"subjectTemplate": "New mail from {{form.firstName}}",
"provider": "SMTP",
"username": "<SMTP_USERNAME>",
"password": "<SMTP_PASSWORD>",
"smtpHost": "<SMTP_SERVER_IP>",
"smtpPort": "<SMTP_SERVER_PORT>"
}
```

Expand All @@ -66,13 +85,28 @@ Filename does not have to respect any convention.

#### Example of `MAIL_CONFIGS_FOLDER` configuration files

**Resend-based config**
```json
{
"id": "UUID",
"sender": "[email protected]",
"subjectTemplate": "New mail from {{form.firstName}}",
"provider": "RESEND",
"apiKey": ""
"apiKey": "<YOUR_RESEND_API_KEY>"
}
```

**SMTP-based config**
```json
{
"id": "UUID",
"sender": "[email protected]",
"subjectTemplate": "New mail from {{form.firstName}}",
"provider": "SMTP",
"username": "<SMTP_USERNAME>",
"password": "<SMTP_PASSWORD>",
"smtpHost": "<SMTP_SERVER_IP>",
"smtpPort": "<SMTP_SERVER_PORT>"
}
```

Expand Down
2 changes: 2 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ dependencies {

implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinx_serialization_version")

implementation("com.sun.mail:javax.mail:1.6.2")

testImplementation("io.ktor:ktor-server-tests-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
Expand Down
46 changes: 45 additions & 1 deletion src/main/kotlin/com/vandeas/dto/configs/Config.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,50 @@
package com.vandeas.dto.configs

import com.vandeas.service.Mailer
import com.vandeas.service.impl.mailer.ResendMailer
import com.vandeas.service.impl.mailer.SMTPMailer
import kotlinx.serialization.Serializable

const val RESEND_SERIAL_NAME = "RESEND"
const val SMTP_SERIAL_NAME = "SMTP"

interface Config {
val id: String
val apiKey: String

/**
* Instantiates a new [Mailer] using this config.
*/
fun toMailer(): Mailer

/**
* @return a string that uniquely identifies this config based on the credentials.
*/
fun identifierFromCredentials(): String
}

@Serializable
abstract class ResendProvider: Config {
protected abstract val apiKey: String

override fun toMailer() = ResendMailer(apiKey = apiKey)

override fun identifierFromCredentials() = apiKey

}

@Serializable
abstract class SMTPProvider: Config {
protected abstract val username: String
protected abstract val password: String
protected abstract val smtpHost: String
protected abstract val smtpPort: Int

override fun toMailer() = SMTPMailer(
username = username,
password = password,
host = smtpHost,
port = smtpPort
)

override fun identifierFromCredentials() = "smtp://${username}:${password}@$smtpHost:$smtpPort"
}
56 changes: 40 additions & 16 deletions src/main/kotlin/com/vandeas/dto/configs/ContactFormConfig.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,47 @@
package com.vandeas.dto.configs

import com.vandeas.dto.enums.Providers
import com.vandeas.dto.enums.toMailer
import com.vandeas.service.Mailer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator

@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class ContactFormConfig(
@JsonClassDiscriminator("provider")
sealed interface ContactFormConfig : Config {
val dailyLimit: Int
val destination: String
val sender: String
val threshold: Double
val lang: String
val subjectTemplate: String
}

@Serializable
@SerialName(RESEND_SERIAL_NAME)
data class ResendContactFormConfig(
override val id: String,
val dailyLimit: Int,
val destination: String,
val sender: String,
val threshold: Double,
val lang: String,
val subjectTemplate: String,
val provider: Providers,
override val apiKey: String,
): Config
override val dailyLimit: Int,
override val destination: String,
override val sender: String,
override val threshold: Double,
override val lang: String,
override val subjectTemplate: String,
override val apiKey: String
) : ContactFormConfig, ResendProvider()

fun ContactFormConfig.toMailer(): Mailer {
return provider.toMailer(apiKey)
}
@Serializable
@SerialName(SMTP_SERIAL_NAME)
data class SMTPContactFormConfig(
override val id: String,
override val dailyLimit: Int,
override val destination: String,
override val sender: String,
override val threshold: Double,
override val lang: String,
override val subjectTemplate: String,
override val username: String,
override val password: String,
override val smtpHost: String,
override val smtpPort: Int = 587
) : ContactFormConfig, SMTPProvider()
42 changes: 30 additions & 12 deletions src/main/kotlin/com/vandeas/dto/configs/MailConfig.kt
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
package com.vandeas.dto.configs

import com.vandeas.dto.enums.Providers
import com.vandeas.dto.enums.toMailer
import com.vandeas.service.Mailer
import com.vandeas.service.impl.mailer.ResendMailer
import com.vandeas.service.impl.mailer.SMTPMailer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator

@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class MailConfig(
@JsonClassDiscriminator("provider")
sealed interface MailConfig : Config {
val sender: String
val subjectTemplate: String
}

@Serializable
@SerialName(RESEND_SERIAL_NAME)
data class ResendMailConfig(
override val id: String,
val sender: String,
val subjectTemplate: String,
val provider: Providers,
override val apiKey: String,
): Config
override val sender: String,
override val subjectTemplate: String,
override val apiKey: String
) : MailConfig, ResendProvider()

fun MailConfig.toMailer(): Mailer {
return provider.toMailer(apiKey)
}
@Serializable
@SerialName(SMTP_SERIAL_NAME)
data class SMTPMailConfig(
override val id: String,
override val sender: String,
override val subjectTemplate: String,
override val username: String,
override val password: String,
override val smtpHost: String,
override val smtpPort: Int = 587
) : MailConfig, SMTPProvider()
18 changes: 0 additions & 18 deletions src/main/kotlin/com/vandeas/dto/enums/Providers.kt

This file was deleted.

7 changes: 3 additions & 4 deletions src/main/kotlin/com/vandeas/logic/impl/MailLogicImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import com.vandeas.dto.ContactForm
import com.vandeas.dto.MailInput
import com.vandeas.dto.configs.ContactFormConfig
import com.vandeas.dto.configs.MailConfig
import com.vandeas.dto.configs.toMailer
import com.vandeas.entities.Mail
import com.vandeas.exception.RecaptchaFailedException
import com.vandeas.logic.MailLogic
Expand Down Expand Up @@ -43,7 +42,7 @@ class MailLogicImpl(
val contentTemplate = Template.parse(contactFormConfigHandler.getTemplate(config.id))
val subjectTemplate = Template.parse(config.subjectTemplate)

val mailer = mailers[config.apiKey] ?: config.toMailer().also { mailers[config.apiKey] = it }
val mailer = mailers[config.identifierFromCredentials()] ?: config.toMailer().also { mailers[config.identifierFromCredentials()] = it }

return mailer.sendEmail(
from = config.sender,
Expand All @@ -58,7 +57,7 @@ class MailLogicImpl(
val contentTemplate = Template.parse(mailConfigHandler.getTemplate(config.id))
val subjectTemplate = Template.parse(config.subjectTemplate)

val mailer = mailers[config.apiKey] ?: config.toMailer().also { mailers[config.apiKey] = it }
val mailer = mailers[config.identifierFromCredentials()] ?: config.toMailer().also { mailers[config.identifierFromCredentials()] = it }

return mailer.sendEmail(
from = config.sender,
Expand All @@ -75,7 +74,7 @@ class MailLogicImpl(

mailsByConfigId.entries.map { (config, mails) ->
async {
(mailers[config.apiKey] ?: config.toMailer().also { mailers[config.apiKey] = it }).sendEmails(
(mailers[config.identifierFromCredentials()] ?: config.toMailer().also { mailers[config.identifierFromCredentials()] = it }).sendEmails(
mails = mails.map { mailInput ->
val contentTemplate = Template.parse(mailConfigHandler.getTemplate(config.id))
val subjectTemplate = Template.parse(config.subjectTemplate)
Expand Down
2 changes: 0 additions & 2 deletions src/main/kotlin/com/vandeas/service/Mailer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ package com.vandeas.service
import com.vandeas.entities.Mail

interface Mailer {
val apiKey: String

fun sendEmail(
to: String,
from: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import com.vandeas.service.Response
import io.ktor.http.*

class ResendMailer(
override val apiKey: String
apiKey: String
): Mailer {
private val resend = Resend(apiKey)
override fun sendEmail(to: String, from: String, subject: String, content: String): Response {
Expand Down
61 changes: 61 additions & 0 deletions src/main/kotlin/com/vandeas/service/impl/mailer/SMTPMailer.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.vandeas.service.impl.mailer

import com.vandeas.entities.Mail
import com.vandeas.service.BatchResponse
import com.vandeas.service.Mailer
import com.vandeas.service.Response
import java.util.*
import javax.mail.Authenticator
import javax.mail.Message
import javax.mail.PasswordAuthentication
import javax.mail.SendFailedException
import javax.mail.Session
import javax.mail.Transport
import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeMessage

class SMTPMailer(
username: String,
password: String,
host: String,
port: Int = 587
): Mailer {

private val session: Session = Session.getInstance(
Properties().apply {
put("mail.smtp.host", host)
put("mail.smtp.port", "$port")
put("mail.smtp.auth", "true")
put("mail.smtp.starttls.enable", "true")
},
object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(username, password)
}
},
)

override fun sendEmail(to: String, from: String, subject: String, content: String): Response {
val message = MimeMessage(session).apply {
setFrom(InternetAddress(from))
addRecipient(Message.RecipientType.TO, InternetAddress(to))
this.subject = subject
setText(content)
}
return try {
Transport.send(message)
Response(200, "[$to] ok")
} catch (e: SendFailedException) {
Response(500, "[$to] ${e.message ?: "Unknown error"}")
}
}

override suspend fun sendEmails(mails: List<Mail>) = mails.map {
sendEmail(it.to, it.from, it.subject, it.content)
}.let { responses ->
BatchResponse(
200.takeIf { responses.all { it.isSuccessful } } ?: 500,
responses.map { it.body ?: "Unknown error"}
)
}
}

0 comments on commit 5dba535

Please sign in to comment.