filter mails by property contact alias
This commit is contained in:
@@ -28,14 +28,16 @@ class EmailStorage(
|
||||
val safeName = (messageId ?: UUID.randomUUID().toString()).replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val fileName = "${safeName}_${OffsetDateTime.now().toEpochSecond()}.pdf"
|
||||
val path = dir.resolve(fileName).normalize()
|
||||
val tmp = dir.resolve("${fileName}.tmp").normalize()
|
||||
|
||||
val document = PDDocument()
|
||||
val page = PDPage(PDRectangle.LETTER)
|
||||
document.addPage(page)
|
||||
val header = "Subject: ${subject ?: ""}\n\n"
|
||||
writeText(document, page, header + body)
|
||||
document.save(path.toFile())
|
||||
document.save(tmp.toFile())
|
||||
document.close()
|
||||
atomicMove(tmp, path)
|
||||
return path.toString()
|
||||
}
|
||||
|
||||
@@ -82,4 +84,28 @@ class EmailStorage(
|
||||
content.endText()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,13 @@ class Orgs(
|
||||
}
|
||||
val org = Organization().apply {
|
||||
name = request.name
|
||||
emailAliases = request.emailAliases?.toMutableSet() ?: mutableSetOf()
|
||||
}
|
||||
val saved = orgRepo.save(org)
|
||||
return OrgResponse(
|
||||
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(
|
||||
id = org.id ?: throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Org id missing"),
|
||||
name = org.name ?: ""
|
||||
name = org.name ?: "",
|
||||
emailAliases = org.emailAliases.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ class Properties(
|
||||
timezone = request.timezone ?: "Asia/Kolkata",
|
||||
currency = request.currency ?: "INR",
|
||||
active = request.active ?: true,
|
||||
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf()
|
||||
otaAliases = request.otaAliases?.toMutableSet() ?: mutableSetOf(),
|
||||
emailAddresses = request.emailAddresses?.toMutableSet() ?: mutableSetOf()
|
||||
)
|
||||
val saved = propertyRepo.save(property)
|
||||
return saved.toResponse()
|
||||
@@ -211,6 +212,9 @@ class Properties(
|
||||
if (request.otaAliases != null) {
|
||||
property.otaAliases = request.otaAliases.toMutableSet()
|
||||
}
|
||||
if (request.emailAddresses != null) {
|
||||
property.emailAddresses = request.emailAddresses.toMutableSet()
|
||||
}
|
||||
|
||||
return propertyRepo.save(property).toResponse()
|
||||
}
|
||||
@@ -249,7 +253,8 @@ private fun Property.toResponse(): PropertyResponse {
|
||||
timezone = timezone,
|
||||
currency = currency,
|
||||
active = active,
|
||||
otaAliases = otaAliases.toSet()
|
||||
otaAliases = otaAliases.toSet(),
|
||||
emailAddresses = emailAddresses.toSet()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ package com.android.trisolarisserver.controller.dto
|
||||
import java.util.UUID
|
||||
|
||||
data class OrgCreateRequest(
|
||||
val name: String
|
||||
val name: String,
|
||||
val emailAliases: Set<String>? = null
|
||||
)
|
||||
|
||||
data class OrgResponse(
|
||||
val id: UUID,
|
||||
val name: String
|
||||
val name: String,
|
||||
val emailAliases: Set<String>
|
||||
)
|
||||
|
||||
data class PropertyCreateRequest(
|
||||
@@ -18,7 +20,8 @@ data class PropertyCreateRequest(
|
||||
val timezone: String? = null,
|
||||
val currency: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val otaAliases: Set<String>? = null
|
||||
val otaAliases: Set<String>? = null,
|
||||
val emailAddresses: Set<String>? = null
|
||||
)
|
||||
|
||||
data class PropertyUpdateRequest(
|
||||
@@ -28,7 +31,8 @@ data class PropertyUpdateRequest(
|
||||
val timezone: String? = null,
|
||||
val currency: String? = null,
|
||||
val active: Boolean? = null,
|
||||
val otaAliases: Set<String>? = null
|
||||
val otaAliases: Set<String>? = null,
|
||||
val emailAddresses: Set<String>? = null
|
||||
)
|
||||
|
||||
data class PropertyResponse(
|
||||
@@ -40,7 +44,8 @@ data class PropertyResponse(
|
||||
val timezone: String,
|
||||
val currency: String,
|
||||
val active: Boolean,
|
||||
val otaAliases: Set<String>
|
||||
val otaAliases: Set<String>,
|
||||
val emailAddresses: Set<String>
|
||||
)
|
||||
|
||||
data class UserResponse(
|
||||
|
||||
@@ -7,6 +7,7 @@ import java.util.UUID
|
||||
interface InboundEmailRepo : JpaRepository<InboundEmail, UUID> {
|
||||
fun findByMessageId(messageId: String): InboundEmail?
|
||||
fun findByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): InboundEmail?
|
||||
fun findByIdAndPropertyId(id: UUID, propertyId: UUID): InboundEmail?
|
||||
fun existsByMessageId(messageId: String): Boolean
|
||||
fun existsByPropertyIdAndOtaBookingId(propertyId: UUID, otaBookingId: String): Boolean
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ class Booking(
|
||||
@Column(name = "expected_checkout_at", columnDefinition = "timestamptz")
|
||||
var expectedCheckoutAt: OffsetDateTime? = null,
|
||||
|
||||
@Column(name = "email_audit_pdf_url")
|
||||
var emailAuditPdfUrl: String? = null,
|
||||
|
||||
var notes: String? = null,
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
|
||||
@@ -49,6 +49,9 @@ class InboundEmail(
|
||||
@Column(name = "raw_pdf_path")
|
||||
var rawPdfPath: String? = null,
|
||||
|
||||
@Column(name = "raw_eml_path")
|
||||
var rawEmlPath: String? = null,
|
||||
|
||||
@Column(name = "extracted_data", columnDefinition = "jsonb")
|
||||
var extractedData: String? = null,
|
||||
|
||||
|
||||
@@ -14,5 +14,14 @@ class Organization {
|
||||
@Column(nullable = false)
|
||||
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")
|
||||
val createdAt: OffsetDateTime = OffsetDateTime.now() }
|
||||
val createdAt: OffsetDateTime = OffsetDateTime.now()
|
||||
}
|
||||
|
||||
@@ -37,6 +37,14 @@ class Property(
|
||||
@Column(name = "address_text")
|
||||
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)
|
||||
@CollectionTable(
|
||||
name = "property_email_alias",
|
||||
|
||||
@@ -49,6 +49,8 @@ class EmailIngestionService(
|
||||
@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
|
||||
@@ -86,9 +88,10 @@ class EmailIngestionService(
|
||||
|
||||
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)
|
||||
val property = matchProperty(subject, body, recipients)
|
||||
|
||||
val inbound = InboundEmail(
|
||||
property = property,
|
||||
@@ -98,6 +101,10 @@ class EmailIngestionService(
|
||||
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)
|
||||
|
||||
@@ -153,7 +160,8 @@ class EmailIngestionService(
|
||||
}
|
||||
|
||||
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.status = InboundEmailStatus.CREATED
|
||||
inbound.processedAt = OffsetDateTime.now()
|
||||
@@ -214,7 +222,8 @@ class EmailIngestionService(
|
||||
property: Property,
|
||||
guest: Guest,
|
||||
extracted: Map<String, String>,
|
||||
sourceBookingId: String
|
||||
sourceBookingId: String,
|
||||
emailAuditPdfUrl: String?
|
||||
): Booking {
|
||||
val zone = ZoneId.of(property.timezone)
|
||||
val checkin = parsedDate(extracted["checkinDate"], zone)
|
||||
@@ -226,7 +235,8 @@ class EmailIngestionService(
|
||||
source = extracted["otaSource"]?.takeIf { !it.contains("NONE", true) } ?: "OTA",
|
||||
sourceBookingId = sourceBookingId,
|
||||
expectedCheckinAt = checkin,
|
||||
expectedCheckoutAt = checkout
|
||||
expectedCheckoutAt = checkout,
|
||||
emailAuditPdfUrl = emailAuditPdfUrl
|
||||
)
|
||||
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 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 orgEmails = property.org.emailAliases.map { it.lowercase() }.toSet()
|
||||
if (orgEmails.isNotEmpty() && recipients.any { it.lowercase() in orgEmails }) {
|
||||
return@filter true
|
||||
}
|
||||
}
|
||||
val aliases = mutableSetOf<String>()
|
||||
aliases.add(property.name)
|
||||
aliases.add(property.code)
|
||||
@@ -267,6 +287,15 @@ class EmailIngestionService(
|
||||
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
|
||||
@@ -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 {
|
||||
var text: String? = null
|
||||
var html: String? = null
|
||||
|
||||
@@ -10,6 +10,7 @@ storage.documents.publicBaseUrl=https://api.hoteltrisolaris.in
|
||||
storage.documents.tokenSecret=change-me
|
||||
storage.documents.tokenTtlSeconds=300
|
||||
storage.emails.root=/home/androidlover5842/docs/emails
|
||||
storage.emails.publicBaseUrl=https://api.hoteltrisolaris.in
|
||||
mail.imap.host=localhost
|
||||
mail.imap.port=993
|
||||
mail.imap.protocol=imaps
|
||||
|
||||
Reference in New Issue
Block a user