diff --git a/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt b/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt index 6325a7b..e5faf88 100644 --- a/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt +++ b/src/main/kotlin/com/android/trisolarisserver/component/EmailStorage.kt @@ -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) + } + } } diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/InboundEmails.kt b/src/main/kotlin/com/android/trisolarisserver/controller/InboundEmails.kt new file mode 100644 index 0000000..1065d57 --- /dev/null +++ b/src/main/kotlin/com/android/trisolarisserver/controller/InboundEmails.kt @@ -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 { + 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") + } + } +} diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Orgs.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Orgs.kt index 07f49e5..099becd 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/Orgs.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Orgs.kt @@ -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() ) } diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/Properties.kt b/src/main/kotlin/com/android/trisolarisserver/controller/Properties.kt index 7c21457..6ad1aa3 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/Properties.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/Properties.kt @@ -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() ) } diff --git a/src/main/kotlin/com/android/trisolarisserver/controller/dto/OrgPropertyDtos.kt b/src/main/kotlin/com/android/trisolarisserver/controller/dto/OrgPropertyDtos.kt index fd1189c..b1f2299 100644 --- a/src/main/kotlin/com/android/trisolarisserver/controller/dto/OrgPropertyDtos.kt +++ b/src/main/kotlin/com/android/trisolarisserver/controller/dto/OrgPropertyDtos.kt @@ -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? = null ) data class OrgResponse( val id: UUID, - val name: String + val name: String, + val emailAliases: Set ) 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? = null + val otaAliases: Set? = null, + val emailAddresses: Set? = 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? = null + val otaAliases: Set? = null, + val emailAddresses: Set? = null ) data class PropertyResponse( @@ -40,7 +44,8 @@ data class PropertyResponse( val timezone: String, val currency: String, val active: Boolean, - val otaAliases: Set + val otaAliases: Set, + val emailAddresses: Set ) data class UserResponse( diff --git a/src/main/kotlin/com/android/trisolarisserver/db/repo/InboundEmailRepo.kt b/src/main/kotlin/com/android/trisolarisserver/db/repo/InboundEmailRepo.kt index dc3375a..af8c1b3 100644 --- a/src/main/kotlin/com/android/trisolarisserver/db/repo/InboundEmailRepo.kt +++ b/src/main/kotlin/com/android/trisolarisserver/db/repo/InboundEmailRepo.kt @@ -7,6 +7,7 @@ import java.util.UUID interface InboundEmailRepo : JpaRepository { 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 } diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/Booking.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/Booking.kt index d219300..464dec4 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/booking/Booking.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/Booking.kt @@ -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) diff --git a/src/main/kotlin/com/android/trisolarisserver/models/booking/InboundEmail.kt b/src/main/kotlin/com/android/trisolarisserver/models/booking/InboundEmail.kt index 5ed62a5..71ff0e7 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/booking/InboundEmail.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/booking/InboundEmail.kt @@ -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, diff --git a/src/main/kotlin/com/android/trisolarisserver/models/property/Organization.kt b/src/main/kotlin/com/android/trisolarisserver/models/property/Organization.kt index 7c7fc3e..80833c8 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/property/Organization.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/property/Organization.kt @@ -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 = mutableSetOf() + @Column(name = "created_at", nullable = false, columnDefinition = "timestamptz") - val createdAt: OffsetDateTime = OffsetDateTime.now() } \ No newline at end of file + val createdAt: OffsetDateTime = OffsetDateTime.now() +} diff --git a/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt b/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt index c27f8d7..89c6e0c 100644 --- a/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt +++ b/src/main/kotlin/com/android/trisolarisserver/models/property/Property.kt @@ -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 = mutableSetOf(), + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable( name = "property_email_alias", diff --git a/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt b/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt index 6f1806e..f87d962 100644 --- a/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt +++ b/src/main/kotlin/com/android/trisolarisserver/service/EmailIngestionService.kt @@ -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, - 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): 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() 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 { + 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 @@ -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 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 11b9839..67e59d8 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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