filter mails by property contact alias

This commit is contained in:
androidlover5842
2026-01-24 21:57:06 +05:30
parent 9300a85bd3
commit 0d3472c60e
12 changed files with 178 additions and 16 deletions

View File

@@ -28,14 +28,16 @@ class EmailStorage(
val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_") val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_")
val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf" val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf"
val path = dir.resolve(fileName).normalize() val path = dir.resolve(fileName).normalize()
val tmp = dir.resolve("${fileName}.tmp").normalize()
val document = PDDocument() val document = PDDocument()
val page = PDPage(PDRectangle.LETTER) val page = PDPage(PDRectangle.LETTER)
document.addPage(page) document.addPage(page)
val header = "Subject: ${subject ?: ""}\n\n" val header = "Subject: ${subject ?: ""}\n\n"
writeText(document, page, header + body) writeText(document, page, header + body)
document.save(path.toFile()) document.save(tmp.toFile())
document.close() document.close()
atomicMove(tmp, path)
return path.toString() return path.toString()
} }
@@ -82,4 +84,28 @@ class EmailStorage(
content.endText() content.endText()
content.close() content.close()
} }
fun storeEml(propertyId: UUID?, messageId: String?, rawBytes: ByteArray): String {
val dir = if (propertyId != null) {
Paths.get(rootPath, propertyId.toString(), "raw")
} else {
Paths.get(rootPath, "unassigned", "raw")
}
Files.createDirectories(dir)
val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_")
val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.eml"
val path = dir.resolve(fileName).normalize()
val tmp = dir.resolve("${fileName}.tmp").normalize()
Files.write(tmp, rawBytes)
atomicMove(tmp, path)
return path.toString()
}
private fun atomicMove(tmp: Path, target: Path) {
try {
Files.move(tmp, target, java.nio.file.StandardCopyOption.ATOMIC_MOVE, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
} catch (_: Exception) {
Files.move(tmp, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)
}
}
} }

View File

@@ -0,0 +1,59 @@
package com.android.trisolarisserver.controller
import com.android.trisolarisserver.component.PropertyAccess
import com.android.trisolarisserver.db.repo.InboundEmailRepo
import com.android.trisolarisserver.models.property.Role
import com.android.trisolarisserver.security.MyPrincipal
import org.springframework.core.io.FileSystemResource
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.nio.file.Files
import java.nio.file.Paths
import java.util.UUID
@RestController
@RequestMapping("/properties/{propertyId}/inbound-emails")
class InboundEmails(
private val propertyAccess: PropertyAccess,
private val inboundEmailRepo: InboundEmailRepo
) {
@GetMapping("/{emailId}/file")
fun downloadEmailPdf(
@PathVariable propertyId: UUID,
@PathVariable emailId: UUID,
@AuthenticationPrincipal principal: MyPrincipal?
): ResponseEntity<FileSystemResource> {
requirePrincipal(principal)
propertyAccess.requireMember(propertyId, principal!!.userId)
propertyAccess.requireAnyRole(propertyId, principal.userId, Role.ADMIN, Role.MANAGER)
val email = inboundEmailRepo.findByIdAndPropertyId(emailId, propertyId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Email not found")
val path = email.rawPdfPath ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Email PDF missing")
val file = Paths.get(path)
if (!Files.exists(file)) {
throw ResponseStatusException(HttpStatus.NOT_FOUND, "Email PDF missing")
}
val resource = FileSystemResource(file)
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_PDF)
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"email-${emailId}.pdf\"")
.contentLength(resource.contentLength())
.body(resource)
}
private fun requirePrincipal(principal: MyPrincipal?) {
if (principal == null) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing principal")
}
}
}

View File

@@ -41,11 +41,13 @@ class Orgs(
} }
val org = Organization().apply { val org = Organization().apply {
name = request.name name = request.name
emailAliases = request.emailAliases?.toMutableSet() ?: mutableSetOf()
} }
val saved = orgRepo.save(org) val saved = orgRepo.save(org)
return OrgResponse( return OrgResponse(
id = saved.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"), id = saved.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = saved.name ?: "" name = saved.name ?: "",
emailAliases = saved.emailAliases.toSet()
) )
} }
@@ -63,7 +65,8 @@ class Orgs(
} }
return OrgResponse( return OrgResponse(
id = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"), id = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
name = org.name ?: "" name = org.name ?: "",
emailAliases = org.emailAliases.toSet()
) )
} }

View File

@@ -66,7 +66,8 @@ class Properties(
timezone = request.timezone ?: "Asia/Kolkata", timezone = request.timezone ?: "Asia/Kolkata",
currency = request.currency ?: "INR", currency = request.currency ?: "INR",
active = request.active ?: true, active = request.active ?: true,
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf() otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf()
) )
val saved = propertyRepo.save(property) val saved = propertyRepo.save(property)
return saved.toResponse() return saved.toResponse()
@@ -211,6 +212,9 @@ class Properties(
if (request.otaAliases != null) { if (request.otaAliases != null) {
property.otaAliases = request.otaAliases.toMutableSet() property.otaAliases = request.otaAliases.toMutableSet()
} }
if (request.emailAddresses != null) {
property.emailAddresses = request.emailAddresses.toMutableSet()
}
return propertyRepo.save(property).toResponse() return propertyRepo.save(property).toResponse()
} }
@@ -249,7 +253,8 @@ private fun Property.toResponse(): PropertyResponse {
timezone = timezone, timezone = timezone,
currency = currency, currency = currency,
active = active, active = active,
otaAliases = otaAliases.toSet() otaAliases = otaAliases.toSet(),
emailAddresses = emailAddresses.toSet()
) )
} }

View File

@@ -3,12 +3,14 @@ package com.android.trisolarisserver.controller.dto
import java.util.UUID import java.util.UUID
data class OrgCreateRequest( data class OrgCreateRequest(
val name: String val name: String,
val emailAliases: Set<String>? = null
) )
data class OrgResponse( data class OrgResponse(
val id: UUID, val id: UUID,
val name: String val name: String,
val emailAliases: Set<String>
) )
data class PropertyCreateRequest( data class PropertyCreateRequest(
@@ -18,7 +20,8 @@ data class PropertyCreateRequest(
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null
) )
data class PropertyUpdateRequest( data class PropertyUpdateRequest(
@@ -28,7 +31,8 @@ data class PropertyUpdateRequest(
val timezone: String? = null, val timezone: String? = null,
val currency: String? = null, val currency: String? = null,
val active: Boolean? = null, val active: Boolean? = null,
val otaAliases: Set<String>? = null val otaAliases: Set<String>? = null,
val emailAddresses: Set<String>? = null
) )
data class PropertyResponse( data class PropertyResponse(
@@ -40,7 +44,8 @@ data class PropertyResponse(
val timezone: String, val timezone: String,
val currency: String, val currency: String,
val active: Boolean, val active: Boolean,
val otaAliases: Set<String> val otaAliases: Set<String>,
val emailAddresses: Set<String>
) )
data class UserResponse( data class UserResponse(

View File

@@ -7,6 +7,7 @@ import java.util.UUID
interface InboundEmailRepo : JpaRepository<InboundEmail, UUID> { interface InboundEmailRepo : JpaRepository<InboundEmail, UUID> {
fun findByMessageId(messageId: String): InboundEmail? fun findByMessageId(messageId: String): InboundEmail?
fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail? fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail?
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): InboundEmail?
fun existsByMessageId(messageId: String): Boolean fun existsByMessageId(messageId: String): Boolean
fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean
} }

View File

@@ -49,6 +49,9 @@ class Booking(
@Column(name = "expected_checkout_at", columnDefinition = "timestamptz") @Column(name = "expected_checkout_at", columnDefinition = "timestamptz")
var expectedCheckoutAt: OffsetDateTime? = null, var expectedCheckoutAt: OffsetDateTime? = null,
@Column(name = "email_audit_pdf_url")
var emailAuditPdfUrl: String? = null,
var notes: String? = null, var notes: String? = null,
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)

View File

@@ -49,6 +49,9 @@ class InboundEmail(
@Column(name = "raw_pdf_path") @Column(name = "raw_pdf_path")
var rawPdfPath: String? = null, var rawPdfPath: String? = null,
@Column(name = "raw_eml_path")
var rawEmlPath: String? = null,
@Column(name = "extracted_data", columnDefinition = "jsonb") @Column(name = "extracted_data", columnDefinition = "jsonb")
var extractedData: String? = null, var extractedData: String? = null,

View File

@@ -14,5 +14,14 @@ class Organization {
@Column(nullable = false) @Column(nullable = false)
var name: String? = null var name: String? = null
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "org_email_alias",
joinColumns = [JoinColumn(name = "org_id")]
)
@Column(name = "email", nullable = false)
var emailAliases: MutableSet<String> = mutableSetOf()
@Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz")
val createdAt: OffsetDateTime = OffsetDateTime.now() } val createdAt: OffsetDateTime = OffsetDateTime.now()
}

View File

@@ -37,6 +37,14 @@ class Property(
@Column(name = "address_text") @Column(name = "address_text")
var addressText: String? = null, var addressText: String? = null,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
name = "property_email_address",
joinColumns = [JoinColumn(name = "property_id")]
)
@Column(name = "email", nullable = false)
var emailAddresses: MutableSet<String> = mutableSetOf(),
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
@CollectionTable( @CollectionTable(
name = "property_email_alias", name = "property_email_alias",

View File

@@ -49,6 +49,8 @@ class EmailIngestionService(
@Value("\${mail.imap.protocol:imaps}") @Value("\${mail.imap.protocol:imaps}")
private val protocol: String private val protocol: String
) { ) {
@Value("\${storage.emails.publicBaseUrl}")
private val publicBaseUrl: String = ""
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
@Value("\${mail.imap.enabled:false}") @Value("\${mail.imap.enabled:false}")
private val enabled: Boolean = false private val enabled: Boolean = false
@@ -86,9 +88,10 @@ class EmailIngestionService(
val subject = message.subject val subject = message.subject
val from = message.from?.firstOrNull()?.toString() val from = message.from?.firstOrNull()?.toString()
val recipients = extractRecipients(message)
val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset) val receivedAt = message.receivedDate?.toInstant()?.atOffset(OffsetDateTime.now().offset)
val body = extractText(message) val body = extractText(message)
val property = matchProperty(subject, body) val property = matchProperty(subject, body, recipients)
val inbound = InboundEmail( val inbound = InboundEmail(
property = property, property = property,
@@ -98,6 +101,10 @@ class EmailIngestionService(
receivedAt = receivedAt 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) inbound.rawPdfPath = emailStorage.storePdf(property?.id, messageId, subject, body)
inboundEmailRepo.save(inbound) inboundEmailRepo.save(inbound)
@@ -153,7 +160,8 @@ class EmailIngestionService(
} }
val guest = resolveGuest(property, extracted) val guest = resolveGuest(property, extracted)
val booking = createBooking(property, guest, extracted, sourceBookingId) val emailUrl = "${publicBaseUrl}/properties/${property.id}/inbound-emails/${inbound.id}/file"
val booking = createBooking(property, guest, extracted, sourceBookingId, emailUrl)
inbound.booking = booking inbound.booking = booking
inbound.status = InboundEmailStatus.CREATED inbound.status = InboundEmailStatus.CREATED
inbound.processedAt = OffsetDateTime.now() inbound.processedAt = OffsetDateTime.now()
@@ -214,7 +222,8 @@ class EmailIngestionService(
property: Property, property: Property,
guest: Guest, guest: Guest,
extracted: Map<String, String>, extracted: Map<String, String>,
sourceBookingId: String sourceBookingId: String,
emailAuditPdfUrl: String?
): Booking { ): Booking {
val zone = ZoneId.of(property.timezone) val zone = ZoneId.of(property.timezone)
val checkin = parsedDate(extracted["checkinDate"], zone) val checkin = parsedDate(extracted["checkinDate"], zone)
@@ -226,7 +235,8 @@ class EmailIngestionService(
source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA", source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA",
sourceBookingId = sourceBookingId, sourceBookingId = sourceBookingId,
expectedCheckinAt = checkin, expectedCheckinAt = checkin,
expectedCheckoutAt = checkout expectedCheckoutAt = checkout,
emailAuditPdfUrl = emailAuditPdfUrl
) )
return bookingRepo.save(booking) return bookingRepo.save(booking)
} }
@@ -253,10 +263,20 @@ class EmailIngestionService(
} }
} }
private fun matchProperty(subject: String?, body: String): Property? { private fun matchProperty(subject: String?, body: String, recipients: List<String>): Property? {
val haystack = "${subject ?: ""}\n$body".lowercase() val haystack = "${subject ?: ""}\n$body".lowercase()
val properties = propertyRepo.findAll() val properties = propertyRepo.findAll()
val matches = properties.filter { property -> 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 orgEmails = property.org.emailAliases.map { it.lowercase() }.toSet()
if (orgEmails.isNotEmpty() && recipients.any { it.lowercase() in orgEmails }) {
return@filter true
}
}
val aliases = mutableSetOf<String>() val aliases = mutableSetOf<String>()
aliases.add(property.name) aliases.add(property.name)
aliases.add(property.code) aliases.add(property.code)
@@ -267,6 +287,15 @@ class EmailIngestionService(
return if (matches.size == 1) matches.first() else null 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 { private fun extractText(message: Message): String {
return try { return try {
val content = message.content val content = message.content
@@ -280,6 +309,16 @@ class EmailIngestionService(
} }
} }
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 { private fun extractFromMultipart(multipart: Multipart): String {
var text: String? = null var text: String? = null
var html: String? = null var html: String? = null

View File

@@ -10,6 +10,7 @@ storage.documents.publicBaseUrl=https://api.hoteltrisolaris.in
storage.documents.tokenSecret=change-me storage.documents.tokenSecret=change-me
storage.documents.tokenTtlSeconds=300 storage.documents.tokenTtlSeconds=300
storage.emails.root=/home/androidlover5842/docs/emails storage.emails.root=/home/androidlover5842/docs/emails
storage.emails.publicBaseUrl=https://api.hoteltrisolaris.in
mail.imap.host=localhost mail.imap.host=localhost
mail.imap.port=993 mail.imap.port=993
mail.imap.protocol=imaps mail.imap.protocol=imaps