344 lines
14 KiB
Kotlin
344 lines
14 KiB
Kotlin
package com.android.trisolarisserver.service
|
|
|
|
import com.android.trisolarisserver.component.EmailStorage
|
|
import com.android.trisolarisserver.component.LlamaClient
|
|
import com.android.trisolarisserver.db.repo.BookingRepo
|
|
import com.android.trisolarisserver.db.repo.GuestRepo
|
|
import com.android.trisolarisserver.db.repo.InboundEmailRepo
|
|
import com.android.trisolarisserver.models.booking.Booking
|
|
import com.android.trisolarisserver.models.booking.BookingStatus
|
|
import com.android.trisolarisserver.models.booking.Guest
|
|
import com.android.trisolarisserver.models.booking.InboundEmail
|
|
import com.android.trisolarisserver.models.booking.InboundEmailStatus
|
|
import com.android.trisolarisserver.models.property.Property
|
|
import com.android.trisolarisserver.repo.PropertyRepo
|
|
import com.fasterxml.jackson.databind.ObjectMapper
|
|
import jakarta.mail.Flags
|
|
import jakarta.mail.Folder
|
|
import jakarta.mail.Message
|
|
import jakarta.mail.Multipart
|
|
import jakarta.mail.Session
|
|
import jakarta.mail.Store
|
|
import jakarta.mail.search.FlagTerm
|
|
import org.springframework.beans.factory.annotation.Value
|
|
import org.springframework.scheduling.annotation.Scheduled
|
|
import org.springframework.stereotype.Component
|
|
import java.time.OffsetDateTime
|
|
import java.time.ZoneId
|
|
import java.util.Properties
|
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
import java.security.MessageDigest
|
|
|
|
@Component
|
|
class EmailIngestionService(
|
|
private val propertyRepo: PropertyRepo,
|
|
private val inboundEmailRepo: InboundEmailRepo,
|
|
private val bookingRepo: BookingRepo,
|
|
private val guestRepo: GuestRepo,
|
|
private val llamaClient: LlamaClient,
|
|
private val emailStorage: EmailStorage,
|
|
private val objectMapper: ObjectMapper,
|
|
@Value("\${mail.imap.host}")
|
|
private val host: String,
|
|
@Value("\${mail.imap.port:993}")
|
|
private val port: Int,
|
|
@Value("\${mail.imap.username}")
|
|
private val username: String,
|
|
@Value("\${mail.imap.password}")
|
|
private val password: String,
|
|
@Value("\${mail.imap.protocol:imaps}")
|
|
private val protocol: String
|
|
) {
|
|
@Value("\${storage.emails.publicBaseUrl}")
|
|
private val publicBaseUrl: String = ""
|
|
private val running = AtomicBoolean(false)
|
|
@Value("\${mail.imap.enabled:false}")
|
|
private val enabled: Boolean = false
|
|
|
|
@Scheduled(fixedDelayString = "\${mail.imap.pollMs:60000}")
|
|
fun pollInbox() {
|
|
if (!enabled) return
|
|
if (!running.compareAndSet(false, true)) return
|
|
try {
|
|
val session = Session.getInstance(Properties())
|
|
val store: Store = session.getStore(protocol)
|
|
store.connect(host, port, username, password)
|
|
val inbox = store.getFolder("INBOX")
|
|
inbox.open(Folder.READ_WRITE)
|
|
|
|
val unseen = inbox.search(FlagTerm(Flags(Flags.Flag.SEEN), false))
|
|
for (message in unseen) {
|
|
processMessage(message)
|
|
message.setFlag(Flags.Flag.SEEN, true)
|
|
}
|
|
inbox.close(true)
|
|
store.close()
|
|
} catch (_: Exception) {
|
|
// swallow to keep scheduler alive
|
|
} finally {
|
|
running.set(false)
|
|
}
|
|
}
|
|
|
|
private fun processMessage(message: Message) {
|
|
val messageId = buildMessageId(message)
|
|
if (messageId != null && inboundEmailRepo.existsByMessageId(messageId)) {
|
|
return
|
|
}
|
|
|
|
val subject = message.subject
|
|
val from = message.from?.firstOrNull()?.toString()
|
|
val recipients = extractRecipients(message)
|
|
val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset)
|
|
val body = extractText(message)
|
|
val property = matchProperty(subject, body, recipients)
|
|
|
|
val inbound = InboundEmail(
|
|
property = property,
|
|
messageId = messageId,
|
|
subject = subject,
|
|
fromAddress = from,
|
|
receivedAt = receivedAt
|
|
)
|
|
|
|
val rawBytes = extractRawMessage(message)
|
|
if (rawBytes != null) {
|
|
inbound.rawEmlPath = emailStorage.storeEml(property?.id, messageId, rawBytes)
|
|
}
|
|
inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body)
|
|
inboundEmailRepo.save(inbound)
|
|
|
|
if (property == null) {
|
|
inbound.status = InboundEmailStatus.SKIPPED
|
|
inbound.processedAt = OffsetDateTime.now()
|
|
inboundEmailRepo.save(inbound)
|
|
return
|
|
}
|
|
|
|
handleExtracted(property, inbound, body, "email:$messageId")
|
|
}
|
|
|
|
private fun extractBookingDetails(body: String): Map<String, String> {
|
|
val results = linkedMapOf<String, String>()
|
|
results["isCancel"] = llamaClient.askText(body, "Is this a cancellation email? Answer YES or NO only.")
|
|
results["otaBookingId"] = llamaClient.askText(body, "OTA BOOKING ID? Reply only the ID or NONE.")
|
|
results["checkinDate"] = llamaClient.askText(body, "CHECKIN DATE? Reply YYYY-MM-DD or NONE.")
|
|
results["checkoutDate"] = llamaClient.askText(body, "CHECKOUT DATE? Reply YYYY-MM-DD or NONE.")
|
|
results["nights"] = llamaClient.askText(body, "NIGHTS? Reply number or NONE.")
|
|
results["amount"] = llamaClient.askText(body, "TOTAL AMOUNT? Reply number only or NONE.")
|
|
results["roomRateWithoutGst"] = llamaClient.askText(body, "ROOM RATE WITHOUT GST? Reply number only or NONE.")
|
|
results["roomRateWithGst"] = llamaClient.askText(body, "ROOM RATE WITH GST? Reply number only or NONE.")
|
|
results["commissionIncludingGst"] = llamaClient.askText(body, "COMMISSION INCLUDING GST? Reply number only or NONE.")
|
|
results["guestName"] = llamaClient.askText(body, "GUEST NAME? Reply name or NONE.")
|
|
results["guestPhone"] = llamaClient.askText(body, "GUEST PHONE? Reply number or NONE.")
|
|
results["propertyName"] = llamaClient.askText(body, "PROPERTY NAME? Reply only name or NONE.")
|
|
results["propertyAddress"] = llamaClient.askText(body, "PROPERTY ADDRESS? Reply only address or NONE.")
|
|
results["roomTypesRaw"] = llamaClient.askText(
|
|
body,
|
|
"Room wise Payment Breakup. Return ONLY JSON. Include all dates."
|
|
)
|
|
results["policy"] = llamaClient.askText(body, "CANCELLATION POLICY? Reply short text or NONE.")
|
|
results["otaSource"] = llamaClient.askText(body, "OTA SOURCE? Reply only name or NONE.")
|
|
|
|
val source = results["otaSource"] ?: ""
|
|
if (source.contains("mmt", ignoreCase = true) || source.contains("make my trip", ignoreCase = true) ||
|
|
source.contains("makemytrip", ignoreCase = true)
|
|
) {
|
|
results["propertyGrossCharges"] =
|
|
llamaClient.askText(body, "PROPERTY GROSS CHARGES? Reply number only or NONE.")
|
|
results["payableToProperty"] =
|
|
llamaClient.askText(body, "PAYABLE TO PROPERTY? Reply number only or NONE.")
|
|
results["taxDeduction"] =
|
|
llamaClient.askText(body, "TAX DEDUCTION (7+8)? Reply number only or NONE.")
|
|
}
|
|
return results
|
|
}
|
|
|
|
private fun resolveGuest(property: Property, extracted: Map<String, String>): Guest {
|
|
val phone = extracted["guestPhone"]?.takeIf { !it.contains("NONE", true) }?.trim()
|
|
if (!phone.isNullOrBlank()) {
|
|
val existing = guestRepo.findByPropertyIdAndPhoneE164(property.id!!, phone)
|
|
if (existing != null) return existing
|
|
}
|
|
val guest = Guest(
|
|
property = property,
|
|
phoneE164 = phone,
|
|
name = extracted["guestName"]?.takeIf { !it.contains("NONE", true) }
|
|
)
|
|
return guestRepo.save(guest)
|
|
}
|
|
|
|
private fun createBooking(
|
|
property: Property,
|
|
guest: Guest,
|
|
extracted: Map<String, String>,
|
|
sourceBookingId: String,
|
|
emailAuditPdfUrl: String?
|
|
): Booking {
|
|
val zone = ZoneId.of(property.timezone)
|
|
val checkin = parsedDate(extracted["checkinDate"], zone)
|
|
val checkout = parsedDate(extracted["checkoutDate"], zone)
|
|
val booking = Booking(
|
|
property = property,
|
|
primaryGuest = guest,
|
|
status = BookingStatus.OPEN,
|
|
source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA",
|
|
sourceBookingId = sourceBookingId,
|
|
expectedCheckinAt = checkin,
|
|
expectedCheckoutAt = checkout,
|
|
emailAuditPdfUrl = emailAuditPdfUrl
|
|
)
|
|
return bookingRepo.save(booking)
|
|
}
|
|
|
|
fun ingestManualPdf(property: Property, inbound: InboundEmail, body: String) {
|
|
inboundEmailRepo.save(inbound)
|
|
handleExtracted(property, inbound, body, "manual:${inbound.id}")
|
|
}
|
|
|
|
private fun handleExtracted(property: Property, inbound: InboundEmail, body: String, fallbackKey: String) {
|
|
val extracted = extractBookingDetails(body)
|
|
inbound.extractedData = objectMapper.writeValueAsString(extracted)
|
|
|
|
val otaBookingId = extracted["otaBookingId"]?.takeIf { !it.contains("NONE", true) }
|
|
if (!otaBookingId.isNullOrBlank() &&
|
|
inboundEmailRepo.existsByPropertyIdAndOtaBookingId(property.id!!, otaBookingId)
|
|
) {
|
|
inbound.status = InboundEmailStatus.SKIPPED
|
|
inbound.processedAt = OffsetDateTime.now()
|
|
inboundEmailRepo.save(inbound)
|
|
return
|
|
}
|
|
|
|
inbound.otaBookingId = otaBookingId
|
|
inboundEmailRepo.save(inbound)
|
|
|
|
val isCancel = extracted["isCancel"]?.contains("YES", ignoreCase = true) == true
|
|
if (isCancel) {
|
|
if (otaBookingId.isNullOrBlank()) {
|
|
inbound.status = InboundEmailStatus.SKIPPED
|
|
inbound.processedAt = OffsetDateTime.now()
|
|
inboundEmailRepo.save(inbound)
|
|
return
|
|
}
|
|
val booking = bookingRepo.findByPropertyIdAndSourceBookingId(property.id!!, otaBookingId)
|
|
if (booking != null) {
|
|
booking.status = BookingStatus.CANCELLED
|
|
bookingRepo.save(booking)
|
|
inbound.booking = booking
|
|
}
|
|
inbound.status = InboundEmailStatus.CANCELLED
|
|
inbound.processedAt = OffsetDateTime.now()
|
|
inboundEmailRepo.save(inbound)
|
|
return
|
|
}
|
|
|
|
val sourceBookingId = otaBookingId ?: fallbackKey
|
|
if (bookingRepo.existsByPropertyIdAndSourceBookingId(property.id!!, sourceBookingId)) {
|
|
inbound.status = InboundEmailStatus.SKIPPED
|
|
inbound.processedAt = OffsetDateTime.now()
|
|
inboundEmailRepo.save(inbound)
|
|
return
|
|
}
|
|
|
|
val guest = resolveGuest(property, extracted)
|
|
val emailUrl = "${publicBaseUrl}/properties/${property.id}/inbound-emails/${inbound.id}/file"
|
|
val booking = createBooking(property, guest, extracted, sourceBookingId, emailUrl)
|
|
inbound.booking = booking
|
|
inbound.status = InboundEmailStatus.CREATED
|
|
inbound.processedAt = OffsetDateTime.now()
|
|
inboundEmailRepo.save(inbound)
|
|
}
|
|
|
|
private fun buildMessageId(message: Message): String? {
|
|
val header = message.getHeader("Message-ID")?.firstOrNull()
|
|
if (!header.isNullOrBlank()) return header
|
|
val from = message.from?.firstOrNull()?.toString() ?: ""
|
|
val subject = message.subject ?: ""
|
|
val received = message.receivedDate?.time?.toString() ?: ""
|
|
val raw = "$from|$subject|$received"
|
|
if (raw.isBlank()) return null
|
|
val digest = MessageDigest.getInstance("SHA-256").digest(raw.toByteArray())
|
|
return digest.joinToString("") { "%02x".format(it) }
|
|
}
|
|
|
|
private fun parsedDate(value: String?, zone: ZoneId): OffsetDateTime? {
|
|
if (value.isNullOrBlank() || value.contains("NONE", true)) return null
|
|
return try {
|
|
val localDate = java.time.LocalDate.parse(value.trim())
|
|
localDate.atTime(12, 0).atZone(zone).toOffsetDateTime()
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun matchProperty(subject: String?, body: String, recipients: List<String>): Property? {
|
|
val haystack = "${subject ?: ""}\n$body".lowercase()
|
|
val properties = propertyRepo.findAll()
|
|
val matches = properties.filter { property ->
|
|
if (recipients.isNotEmpty()) {
|
|
val propertyEmails = property.emailAddresses.map { it.lowercase() }.toSet()
|
|
if (propertyEmails.isNotEmpty() && recipients.any { it.lowercase() in propertyEmails }) {
|
|
return@filter true
|
|
}
|
|
}
|
|
val aliases = mutableSetOf<String>()
|
|
aliases.add(property.name)
|
|
aliases.add(property.code)
|
|
property.addressText?.let { aliases.add(it) }
|
|
aliases.addAll(property.otaAliases)
|
|
aliases.any { alias -> alias.isNotBlank() && haystack.contains(alias.lowercase()) }
|
|
}
|
|
return if (matches.size == 1) matches.first() else null
|
|
}
|
|
|
|
private fun extractRecipients(message: Message): List<String> {
|
|
val list = mutableListOf<String>()
|
|
val to = message.getRecipients(Message.RecipientType.TO)
|
|
val cc = message.getRecipients(Message.RecipientType.CC)
|
|
(to ?: emptyArray()).forEach { list.add(it.toString()) }
|
|
(cc ?: emptyArray()).forEach { list.add(it.toString()) }
|
|
return list
|
|
}
|
|
|
|
private fun extractText(message: Message): String {
|
|
return try {
|
|
val content = message.content
|
|
when (content) {
|
|
is String -> content
|
|
is Multipart -> extractFromMultipart(content)
|
|
else -> content.toString()
|
|
}
|
|
} catch (_: Exception) {
|
|
""
|
|
}
|
|
}
|
|
|
|
private fun extractRawMessage(message: Message): ByteArray? {
|
|
return try {
|
|
val out = java.io.ByteArrayOutputStream()
|
|
message.writeTo(out)
|
|
out.toByteArray()
|
|
} catch (_: Exception) {
|
|
null
|
|
}
|
|
}
|
|
|
|
private fun extractFromMultipart(multipart: Multipart): String {
|
|
var text: String? = null
|
|
var html: String? = null
|
|
for (i in 0 until multipart.count) {
|
|
val part = multipart.getBodyPart(i)
|
|
if (part.isMimeType("text/plain")) {
|
|
text = part.content.toString()
|
|
} else if (part.isMimeType("text/html")) {
|
|
html = part.content.toString()
|
|
} else if (part.content is Multipart) {
|
|
val nested = extractFromMultipart(part.content as Multipart)
|
|
if (nested.isNotBlank()) text = nested
|
|
}
|
|
}
|
|
return text ?: html?.replace(Regex("<[^>]*>"), " ") ?: ""
|
|
}
|
|
}
|