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