diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/EmailContext.kt b/backend/src/main/kotlin/com/condado/newsletter/model/EmailContext.kt new file mode 100644 index 0000000..21a0dfc --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/EmailContext.kt @@ -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 +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/EmailReaderService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/EmailReaderService.kt new file mode 100644 index 0000000..f8ff6f8 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/EmailReaderService.kt @@ -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 { + 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() +}