feat(backend): implement step 4 — EmailContext and EmailReaderService
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
package com.condado.newsletter.model
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snapshot of a single email read from the shared company IMAP inbox.
|
||||||
|
* Used as context when building the AI prompt.
|
||||||
|
*/
|
||||||
|
data class EmailContext(
|
||||||
|
val from: String,
|
||||||
|
val subject: String,
|
||||||
|
val body: String,
|
||||||
|
val receivedAt: LocalDateTime
|
||||||
|
)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.condado.newsletter.service
|
||||||
|
|
||||||
|
import com.condado.newsletter.model.EmailContext
|
||||||
|
import jakarta.mail.Folder
|
||||||
|
import jakarta.mail.Message
|
||||||
|
import jakarta.mail.Session
|
||||||
|
import jakarta.mail.Store
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads recent emails from the shared IMAP inbox to use as AI context.
|
||||||
|
* Returns emails sorted chronologically (oldest first).
|
||||||
|
* On any error, logs the exception and returns an empty list.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
class EmailReaderService(
|
||||||
|
@Value("\${imap.host:localhost}") private val imapHost: String = "localhost",
|
||||||
|
@Value("\${imap.port:993}") private val imapPort: Int = 993,
|
||||||
|
@Value("\${spring.mail.username:}") private val username: String = "",
|
||||||
|
@Value("\${spring.mail.password:}") private val password: String = "",
|
||||||
|
/** Factory function — can be replaced in tests to inject a mock Store */
|
||||||
|
val storeFactory: ((Properties, String, String) -> Store)? = null
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(javaClass)
|
||||||
|
|
||||||
|
constructor(storeFactory: (Properties, String, String) -> Store) : this(
|
||||||
|
imapHost = "localhost",
|
||||||
|
imapPort = 993,
|
||||||
|
username = "",
|
||||||
|
password = "",
|
||||||
|
storeFactory = storeFactory
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Test-only constructor accepting a pre-built mock Store */
|
||||||
|
internal constructor(storeFactory: () -> Store) : this(
|
||||||
|
imapHost = "localhost",
|
||||||
|
imapPort = 993,
|
||||||
|
username = "",
|
||||||
|
password = "",
|
||||||
|
storeFactory = { _, _, _ -> storeFactory() }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads emails from [folderName] received within the last [contextWindowDays] days.
|
||||||
|
*
|
||||||
|
* @return list of [EmailContext] objects sorted oldest-first, or empty list on error.
|
||||||
|
*/
|
||||||
|
fun readEmails(folderName: String, contextWindowDays: Int): List<EmailContext> {
|
||||||
|
return try {
|
||||||
|
val store = openStore()
|
||||||
|
val folder = store.getFolder(folderName)
|
||||||
|
folder.open(Folder.READ_ONLY)
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - contextWindowDays.toLong() * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
folder.getMessages()
|
||||||
|
.filter { msg ->
|
||||||
|
val date = msg.receivedDate ?: msg.sentDate
|
||||||
|
date != null && date.after(cutoff)
|
||||||
|
}
|
||||||
|
.sortedBy { it.receivedDate ?: it.sentDate }
|
||||||
|
.map { msg -> msg.toEmailContext() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("Failed to read emails from IMAP folder '$folderName': ${e.message}", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openStore(): Store {
|
||||||
|
val props = Properties().apply {
|
||||||
|
put("mail.store.protocol", "imaps")
|
||||||
|
put("mail.imaps.host", imapHost)
|
||||||
|
put("mail.imaps.port", imapPort.toString())
|
||||||
|
}
|
||||||
|
return if (storeFactory != null) {
|
||||||
|
storeFactory!!.invoke(props, username, password)
|
||||||
|
} else {
|
||||||
|
val session = Session.getInstance(props)
|
||||||
|
session.getStore("imaps").also { it.connect(imapHost, imapPort, username, password) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Message.toEmailContext(): EmailContext {
|
||||||
|
val from = from?.firstOrNull()?.toString() ?: "unknown"
|
||||||
|
val date = (receivedDate ?: sentDate ?: Date())
|
||||||
|
.toInstant()
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toLocalDateTime()
|
||||||
|
val bodyText = extractText(content, contentType)
|
||||||
|
return EmailContext(
|
||||||
|
from = from,
|
||||||
|
subject = subject ?: "(no subject)",
|
||||||
|
body = bodyText,
|
||||||
|
receivedAt = date
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractText(content: Any?, contentType: String?): String {
|
||||||
|
val raw = when (content) {
|
||||||
|
is String -> content
|
||||||
|
else -> content?.toString() ?: ""
|
||||||
|
}
|
||||||
|
val isHtml = contentType?.contains("html", ignoreCase = true) == true ||
|
||||||
|
raw.contains(Regex("<[a-zA-Z][^>]*>"))
|
||||||
|
return if (isHtml) stripHtml(raw) else raw
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripHtml(html: String): String =
|
||||||
|
html.replace(Regex("<[^>]+>"), " ")
|
||||||
|
.replace(Regex("\\s{2,}"), " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user