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 { val results = linkedMapOf() 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): 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, 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): 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() 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 { val list = mutableListOf() 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("<[^>]*>"), " ") ?: "" } }