feat(backend): implement step 4 — EmailContext and EmailReaderService

This commit is contained in:
2026-03-26 18:44:43 -03:00
parent 58b9907c44
commit 81b356af67
2 changed files with 131 additions and 0 deletions

View File

@@ -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
)

View File

@@ -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()
}