Compare commits
27 Commits
f9db232fbc
...
e43a8a7239
Author | SHA1 | Date |
---|---|---|
dankito | e43a8a7239 | |
dankito | e48a22e75b | |
dankito | 465b566dbc | |
dankito | f0631f55b7 | |
dankito | 4cc03a0036 | |
dankito | 82bbf8cc8d | |
dankito | c4bf7f91ca | |
dankito | fbfeb7ced6 | |
dankito | 85c392e1b7 | |
dankito | 2373717283 | |
dankito | a2d34e217f | |
dankito | 6d206b25f0 | |
dankito | 0c3f069f1f | |
dankito | 2ab10f77d4 | |
dankito | 9aaa2f630c | |
dankito | 399581da78 | |
dankito | 6b82636fdd | |
dankito | 7e112404a0 | |
dankito | 48270e7922 | |
dankito | 2516328da8 | |
dankito | 189b1b37ff | |
dankito | 2b3c3fa5fa | |
dankito | da84aaedf6 | |
dankito | b09b7cf69b | |
dankito | 07fdbec5d7 | |
dankito | a6ac33d8ed | |
dankito | a1fe8befce |
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "gradle/scripts"]
|
||||||
|
path = gradle/scripts
|
||||||
|
url = git@github.com:dankito/GradleScripts.git
|
|
@ -15,7 +15,25 @@ allprojects {
|
||||||
group = "net.codinux.invoicing"
|
group = "net.codinux.invoicing"
|
||||||
version = "0.5.0-SNAPSHOT"
|
version = "0.5.0-SNAPSHOT"
|
||||||
|
|
||||||
|
ext["sourceCodeRepositoryBaseUrl"] = "git.dankito.net/codinux/eInvoicing"
|
||||||
|
|
||||||
|
ext["projectDescription"] = "Tools to work with eInvoices according to EU standard EN 16931"
|
||||||
|
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
tasks.register("publishAllToMavenLocal") {
|
||||||
|
dependsOn(
|
||||||
|
":e-invoice-domain:publishToMavenLocal"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("publishAll") {
|
||||||
|
dependsOn(
|
||||||
|
":e-invoice-domain:publish"
|
||||||
|
)
|
||||||
}
|
}
|
|
@ -28,7 +28,7 @@ dependencies {
|
||||||
implementation("io.quarkus:quarkus-micrometer")
|
implementation("io.quarkus:quarkus-micrometer")
|
||||||
implementation("io.quarkus:quarkus-micrometer-registry-prometheus")
|
implementation("io.quarkus:quarkus-micrometer-registry-prometheus")
|
||||||
|
|
||||||
implementation(project(":e-invoicing-domain"))
|
implementation(project(":e-invoice-domain"))
|
||||||
|
|
||||||
implementation("net.codinux.log:klf:$klfVersion")
|
implementation("net.codinux.log:klf:$klfVersion")
|
||||||
implementation("net.codinux.log:quarkus-loki-log-appender:$lokiLogAppenderVersion")
|
implementation("net.codinux.log:quarkus-loki-log-appender:$lokiLogAppenderVersion")
|
||||||
|
|
|
@ -67,20 +67,29 @@ class InvoicingResource(
|
||||||
|
|
||||||
@Path("extract")
|
@Path("extract")
|
||||||
@POST
|
@POST
|
||||||
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
|
@Consumes(MediaTypePdf, MediaType.APPLICATION_OCTET_STREAM)
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@Operation(summary = "Extract invoice data from a Factur-X / ZUGFeRD or XRechnung file")
|
@Operation(summary = "Extract invoice data from a Factur-X / ZUGFeRD or XRechnung file")
|
||||||
@Tag(name = "Extract")
|
@Tag(name = "Extract")
|
||||||
fun extractInvoiceData(invoice: FileUpload) =
|
fun extractInvoiceDataFromPdf(invoice: java.nio.file.Path) =
|
||||||
service.extractInvoiceData(invoice.uploadedFile())
|
service.extractInvoiceDataFromPdf(invoice)
|
||||||
|
|
||||||
|
@Path("extract")
|
||||||
|
@POST
|
||||||
|
@Consumes(MediaType.APPLICATION_XML)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Operation(summary = "Extract invoice data from a Factur-X / ZUGFeRD or XRechnung file")
|
||||||
|
@Tag(name = "Extract")
|
||||||
|
fun extractInvoiceDataFromXml(invoice: java.nio.file.Path) =
|
||||||
|
service.extractInvoiceDataFromXml(invoice)
|
||||||
|
|
||||||
@Path("validate")
|
@Path("validate")
|
||||||
@POST
|
@POST
|
||||||
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
|
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
@Operation(summary = "Validate a Factur-X / ZUGFeRD or XRechnung file")
|
@Operation(summary = "Validate a Factur-X / ZUGFeRD or XRechnung file")
|
||||||
@Tag(name = "Validate")
|
@Tag(name = "Validate")
|
||||||
fun validateInvoiceXml(invoice: FileUpload) =
|
fun validateInvoiceXml(invoice: java.nio.file.Path) =
|
||||||
service.validateInvoice(invoice.uploadedFile()).reportAsXml
|
service.validateInvoice(invoice).reportAsXml
|
||||||
|
|
||||||
|
|
||||||
private fun createPdfFileResponse(pdfFile: java.nio.file.Path, invoice: Invoice): Response =
|
private fun createPdfFileResponse(pdfFile: java.nio.file.Path, invoice: Invoice): Response =
|
||||||
|
|
|
@ -7,7 +7,6 @@ import net.codinux.invoicing.reader.EInvoiceReader
|
||||||
import net.codinux.invoicing.validation.EInvoiceValidator
|
import net.codinux.invoicing.validation.EInvoiceValidator
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.io.path.extension
|
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class InvoicingService {
|
class InvoicingService {
|
||||||
|
@ -42,17 +41,15 @@ class InvoicingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun extractInvoiceData(invoiceFile: Path) = when (invoiceFile.extension.lowercase()) {
|
fun extractInvoiceDataFromPdf(invoiceFile: Path) =
|
||||||
"xml" -> reader.extractFromXml(invoiceFile.toFile())
|
reader.extractFromPdf(invoiceFile.toFile())
|
||||||
"pdf" -> reader.extractFromPdf(invoiceFile.toFile())
|
|
||||||
else -> throw IllegalArgumentException("We can only extract eInvoice data from .xml and .pdf files")
|
fun extractInvoiceDataFromXml(invoiceFile: Path) =
|
||||||
}
|
reader.extractFromXml(invoiceFile.toFile())
|
||||||
|
|
||||||
|
|
||||||
fun validateInvoice(invoiceFile: Path) =when (invoiceFile.extension.lowercase()) {
|
fun validateInvoice(invoiceFile: Path) =
|
||||||
"xml", "pdf" -> validator.validate(invoiceFile.toFile())
|
validator.validate(invoiceFile.toFile())
|
||||||
else -> throw IllegalArgumentException("We can only validate .xml and .pdf eInvoice files")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun createTempPdfFile(): Path =
|
private fun createTempPdfFile(): Path =
|
||||||
|
|
|
@ -8,6 +8,8 @@ kotlin {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val kotlinCoroutinesVersion: String by project
|
||||||
|
|
||||||
val mustangVersion: String by project
|
val mustangVersion: String by project
|
||||||
|
|
||||||
val angusMailVersion: String by project
|
val angusMailVersion: String by project
|
||||||
|
@ -19,6 +21,8 @@ val xunitVersion: String by project
|
||||||
val logbackVersion: String by project
|
val logbackVersion: String by project
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion")
|
||||||
|
|
||||||
implementation("org.mustangproject:library:$mustangVersion")
|
implementation("org.mustangproject:library:$mustangVersion")
|
||||||
implementation("org.mustangproject:validator:$mustangVersion")
|
implementation("org.mustangproject:validator:$mustangVersion")
|
||||||
|
|
||||||
|
@ -38,4 +42,10 @@ dependencies {
|
||||||
|
|
||||||
tasks.test {
|
tasks.test {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
ext["customArtifactId"] = "e-invoice"
|
||||||
|
|
||||||
|
apply(from = "../gradle/scripts/publish-codinux-repo.gradle.kts")
|
|
@ -5,7 +5,12 @@ import java.io.File
|
||||||
|
|
||||||
class MailAttachmentWithEInvoice(
|
class MailAttachmentWithEInvoice(
|
||||||
val filename: String,
|
val filename: String,
|
||||||
val contentType: String,
|
/**
|
||||||
|
* Attachment's media type like "application/xml", "application/pdf", ...
|
||||||
|
*
|
||||||
|
* Should always be non-null, but can theoretically be null.
|
||||||
|
*/
|
||||||
|
val mediaType: String?,
|
||||||
val invoice: Invoice,
|
val invoice: Invoice,
|
||||||
val file: File
|
val file: File
|
||||||
) {
|
) {
|
|
@ -0,0 +1,268 @@
|
||||||
|
package net.codinux.invoicing.mail
|
||||||
|
|
||||||
|
import jakarta.mail.Folder
|
||||||
|
import jakarta.mail.Message
|
||||||
|
import jakarta.mail.Multipart
|
||||||
|
import jakarta.mail.Part
|
||||||
|
import jakarta.mail.Session
|
||||||
|
import jakarta.mail.Store
|
||||||
|
import jakarta.mail.event.MessageCountAdapter
|
||||||
|
import jakarta.mail.event.MessageCountEvent
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import net.codinux.invoicing.model.Invoice
|
||||||
|
import net.codinux.invoicing.reader.EInvoiceReader
|
||||||
|
import net.codinux.log.logger
|
||||||
|
import org.eclipse.angus.mail.imap.IMAPFolder
|
||||||
|
import java.io.File
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class MailReader(
|
||||||
|
private val eInvoiceReader: EInvoiceReader = EInvoiceReader()
|
||||||
|
) {
|
||||||
|
|
||||||
|
private data class MessagePart(
|
||||||
|
val mediaType: String,
|
||||||
|
val part: Part
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
private val mailDispatcher = Executors.newFixedThreadPool(max(24, Runtime.getRuntime().availableProcessors() * 4)).asCoroutineDispatcher()
|
||||||
|
|
||||||
|
private val log by logger()
|
||||||
|
|
||||||
|
|
||||||
|
fun listenForNewReceivedEInvoices(account: MailAccount, downloadMessageBody: Boolean = false, emailFolderName: String = "INBOX", eInvoiceReceived: (MailWithInvoice) -> Unit) = runBlocking {
|
||||||
|
try {
|
||||||
|
connect(account) { store ->
|
||||||
|
val folder = store.getFolder(emailFolderName)
|
||||||
|
folder.open(Folder.READ_ONLY)
|
||||||
|
|
||||||
|
folder.addMessageCountListener(object : MessageCountAdapter() {
|
||||||
|
override fun messagesAdded(event: MessageCountEvent) {
|
||||||
|
event.messages.forEach { message ->
|
||||||
|
findEInvoice(message, downloadMessageBody)?.let {
|
||||||
|
eInvoiceReceived(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
launch(mailDispatcher) {
|
||||||
|
keepConnectionOpen(account, folder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.error(e) { "Listening to new received eInvoices of '${account.username}' failed" }
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info { "Stopped listening to new received eInvoices of '${account.username}'" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun keepConnectionOpen(account: MailAccount, folder: Folder) {
|
||||||
|
log.info { "Listening to new mails of ${account.username}" }
|
||||||
|
|
||||||
|
// Use IMAP IDLE to keep the connection alive
|
||||||
|
while (true) {
|
||||||
|
if (!folder.isOpen) {
|
||||||
|
log.info { "Reopening inbox of ${account.username} ..." }
|
||||||
|
folder.open(Folder.READ_ONLY)
|
||||||
|
}
|
||||||
|
|
||||||
|
(folder as IMAPFolder).idle()
|
||||||
|
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun listAllMessagesWithEInvoice(account: MailAccount, downloadMessageBody: Boolean = false, emailFolderName: String = "INBOX"): List<MailWithInvoice> {
|
||||||
|
try {
|
||||||
|
connect(account) { store ->
|
||||||
|
val inbox = store.getFolder(emailFolderName)
|
||||||
|
inbox.open(Folder.READ_ONLY)
|
||||||
|
|
||||||
|
listAllMessagesWithEInvoiceInFolder(inbox, downloadMessageBody).also {
|
||||||
|
inbox.close(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.error(e) { "Could not read mails of account $account" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listAllMessagesWithEInvoiceInFolder(folder: Folder, downloadMessageBody: Boolean): List<MailWithInvoice> = runBlocking {
|
||||||
|
val messageCount = folder.messageCount
|
||||||
|
if (messageCount <= 0) {
|
||||||
|
return@runBlocking emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
IntRange(1, messageCount).mapNotNull { messageNumber -> // message numbers start at 1
|
||||||
|
async(mailDispatcher) {
|
||||||
|
try {
|
||||||
|
findEInvoice(folder.getMessage(messageNumber), downloadMessageBody)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.error(e) { "Could not get message with messageNumber $messageNumber" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.awaitAll()
|
||||||
|
.filterNotNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findEInvoice(message: Message, downloadMessageBody: Boolean): MailWithInvoice? {
|
||||||
|
try {
|
||||||
|
val parts = getAllMessageParts(message)
|
||||||
|
|
||||||
|
val attachmentsWithEInvoice = parts.mapNotNull { part ->
|
||||||
|
findEInvoice(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentsWithEInvoice.isNotEmpty()) {
|
||||||
|
return MailWithInvoice(
|
||||||
|
message.from?.joinToString(), message.subject ?: "",
|
||||||
|
message.sentDate?.let { map(it) }, map(message.receivedDate), message.messageNumber,
|
||||||
|
parts.any { it.mediaType == "application/pgp-encrypted" },
|
||||||
|
if (downloadMessageBody) getPlainTextBody(parts) else null, if (downloadMessageBody) getHtmlBody(parts) else null,
|
||||||
|
attachmentsWithEInvoice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.error(e) { "Could not read message $message" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findEInvoice(messagePart: MessagePart): MailAttachmentWithEInvoice? {
|
||||||
|
try {
|
||||||
|
val part = messagePart.part
|
||||||
|
val invoice = tryToReadEInvoice(part, messagePart.mediaType)
|
||||||
|
|
||||||
|
if (invoice != null) {
|
||||||
|
val filename = File(part.fileName)
|
||||||
|
val file = File.createTempFile(filename.nameWithoutExtension, "." + filename.extension).also { file ->
|
||||||
|
part.inputStream.use { it.copyTo(file.outputStream()) }
|
||||||
|
file.deleteOnExit()
|
||||||
|
}
|
||||||
|
|
||||||
|
return MailAttachmentWithEInvoice(part.fileName, messagePart.mediaType, invoice, file)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.error(e) { "Could not check attachment '${messagePart.part.fileName}' (${messagePart.mediaType}) for eInvoice" }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryToReadEInvoice(part: Part, mediaType: String?): Invoice? = try {
|
||||||
|
val filename = part.fileName?.lowercase() ?: ""
|
||||||
|
|
||||||
|
if (filename.endsWith(".pdf") || mediaType == "application/pdf" || mediaType == "application/octet-stream") {
|
||||||
|
eInvoiceReader.extractFromPdf(part.inputStream)
|
||||||
|
} else if (filename.endsWith(".xml") || mediaType == "application/xml" || mediaType == "text/xml") {
|
||||||
|
eInvoiceReader.extractFromXml(part.inputStream)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.debug(e) { "Could not extract invoices from ${part.fileName}" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun getAllMessageParts(part: Part): List<MessagePart> {
|
||||||
|
return if (part.isMimeType("multipart/*")) {
|
||||||
|
val multipart = part.content as Multipart
|
||||||
|
val parts = IntRange(0, multipart.count - 1).map { multipart.getBodyPart(it) }
|
||||||
|
|
||||||
|
parts.flatMap { subPart ->
|
||||||
|
getAllMessageParts(subPart)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val mediaType = getMediaType(part)
|
||||||
|
if (mediaType == null) {
|
||||||
|
log.warn { "Could not determine media type of message part $part" }
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
listOf(MessagePart(mediaType, part))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In most cases parameters are added to content-type's media type, e.g.
|
||||||
|
* - text/html; charset=utf-8
|
||||||
|
* - multipart/related; boundary="boundary-related"; type="text/html"
|
||||||
|
*
|
||||||
|
* -> This method removes parameters and return media type (first part) only
|
||||||
|
*/
|
||||||
|
private fun getMediaType(part: Part): String? = part.contentType?.lowercase()?.let { contentType ->
|
||||||
|
val indexOfSeparator = contentType.indexOf(';')
|
||||||
|
|
||||||
|
if (indexOfSeparator > -1) {
|
||||||
|
contentType.substring(0, indexOfSeparator)
|
||||||
|
} else {
|
||||||
|
contentType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPlainTextBody(parts: Collection<MessagePart>) = getBodyWithMediaType(parts, "text/plain")
|
||||||
|
|
||||||
|
private fun getHtmlBody(parts: Collection<MessagePart>) = getBodyWithMediaType(parts, "text/html")
|
||||||
|
|
||||||
|
private fun getBodyWithMediaType(parts: Collection<MessagePart>, mediaType: String): String? = try {
|
||||||
|
val partsForMediaType = parts.filter { it.mediaType == mediaType }
|
||||||
|
|
||||||
|
if (partsForMediaType.size == 1) {
|
||||||
|
partsForMediaType.first().part.content as? String
|
||||||
|
} else if (partsForMediaType.isEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val partsForMediaTypeWithoutFilename = partsForMediaType.filter { it.part.fileName == null }
|
||||||
|
if (partsForMediaTypeWithoutFilename.size == 1) {
|
||||||
|
partsForMediaTypeWithoutFilename.first().part.content as? String
|
||||||
|
} else if (partsForMediaTypeWithoutFilename.isEmpty()) {
|
||||||
|
log.warn { "Multiple message parts with media type '$mediaType' found, but all have a filename" }
|
||||||
|
null
|
||||||
|
} else { // if there are multiple parts without filename, then the second one is in most cases quoted previous message(s)
|
||||||
|
partsForMediaTypeWithoutFilename.mapNotNull { it.part.content as? String }.joinToString("\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
log.error(e) { "Could not get message body for media type '$mediaType'" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun map(date: Date): Instant =
|
||||||
|
date.toInstant()
|
||||||
|
|
||||||
|
|
||||||
|
private fun <T> connect(account: MailAccount, connected: (Store) -> T?): T? {
|
||||||
|
val properties = mapAccountToJavaMailProperties(account)
|
||||||
|
|
||||||
|
val session = Session.getInstance(properties)
|
||||||
|
session.getStore("imap").use { store ->
|
||||||
|
store.connect(account.serverAddress, account.username, account.password)
|
||||||
|
|
||||||
|
return connected(store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapAccountToJavaMailProperties(account: MailAccount) = Properties().apply {
|
||||||
|
put("mail.store.protocol", "imap")
|
||||||
|
|
||||||
|
put("mail.imap.host", account.serverAddress)
|
||||||
|
put("mail.imap.port", account.port?.toString() ?: "993") // Default IMAP over SSL
|
||||||
|
put("mail.imap.ssl.enable", "true")
|
||||||
|
|
||||||
|
put("mail.imap.connectiontimeout", "5000")
|
||||||
|
put("mail.imap.timeout", "5000")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package net.codinux.invoicing.mail
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
class MailWithInvoice(
|
||||||
|
val sender: String?,
|
||||||
|
val subject: String,
|
||||||
|
val sent: Instant?,
|
||||||
|
val received: Instant,
|
||||||
|
/**
|
||||||
|
* From documentation of underlying mail library:
|
||||||
|
* "Since message numbers can change within a session if the folder is expunged, clients are advised not to use
|
||||||
|
* message numbers as references to messages."
|
||||||
|
*
|
||||||
|
* -> use with care. Message numbers are not valid / the same anymore after expunge.
|
||||||
|
*/
|
||||||
|
val messageNumber: Int,
|
||||||
|
val isEncrypted: Boolean = false,
|
||||||
|
val plainTextBody: String?,
|
||||||
|
val htmlBody: String?,
|
||||||
|
val attachmentsWithEInvoice: List<MailAttachmentWithEInvoice>
|
||||||
|
) {
|
||||||
|
val plainTextOrHtmlBody: String? by lazy { plainTextBody ?: htmlBody }
|
||||||
|
|
||||||
|
override fun toString() = "${(sent ?: received).atZone(ZoneId.systemDefault()).toLocalDate()} $sender: $subject, ${attachmentsWithEInvoice.size} invoice(s)"
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
package net.codinux.invoicing.mail
|
package net.codinux.invoicing.mail
|
||||||
|
|
||||||
import assertk.assertThat
|
import assertk.assertThat
|
||||||
|
import assertk.assertions.isEmpty
|
||||||
import assertk.assertions.isNotEmpty
|
import assertk.assertions.isNotEmpty
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.test.Ignore
|
import kotlin.test.Ignore
|
||||||
|
@ -27,6 +28,9 @@ class MailReaderTest {
|
||||||
val result = underTest.listAllMessagesWithEInvoice(mailAccount)
|
val result = underTest.listAllMessagesWithEInvoice(mailAccount)
|
||||||
|
|
||||||
assertThat(result).isNotEmpty()
|
assertThat(result).isNotEmpty()
|
||||||
|
|
||||||
|
val messagesWithoutBody = result.filter { it.plainTextOrHtmlBody == null }
|
||||||
|
assertThat(messagesWithoutBody).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -1,124 +0,0 @@
|
||||||
package net.codinux.invoicing.mail
|
|
||||||
|
|
||||||
import jakarta.mail.BodyPart
|
|
||||||
import jakarta.mail.Folder
|
|
||||||
import jakarta.mail.Part
|
|
||||||
import jakarta.mail.Session
|
|
||||||
import jakarta.mail.internet.MimeMultipart
|
|
||||||
import net.codinux.invoicing.model.Invoice
|
|
||||||
import net.codinux.invoicing.reader.EInvoiceReader
|
|
||||||
import net.codinux.log.logger
|
|
||||||
import java.io.File
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class MailReader(
|
|
||||||
private val eInvoiceReader: EInvoiceReader = EInvoiceReader()
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val log by logger()
|
|
||||||
|
|
||||||
|
|
||||||
fun listAllMessagesWithEInvoice(account: MailAccount): List<MailWithInvoice> {
|
|
||||||
val properties = mapAccountToJavaMailProperties(account)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val session = Session.getInstance(properties)
|
|
||||||
session.getStore("imap").use { store ->
|
|
||||||
store.connect(account.serverAddress, account.username, account.password)
|
|
||||||
|
|
||||||
val inbox = store.getFolder("INBOX")
|
|
||||||
inbox.open(Folder.READ_ONLY)
|
|
||||||
|
|
||||||
return listAllMessagesWithEInvoiceInFolder(inbox).also {
|
|
||||||
inbox.close(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
log.error(e) { "Could not read mails of account $account" }
|
|
||||||
}
|
|
||||||
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapAccountToJavaMailProperties(account: MailAccount) = Properties().apply {
|
|
||||||
put("mail.store.protocol", "imap")
|
|
||||||
|
|
||||||
put("mail.imap.host", account.serverAddress)
|
|
||||||
put("mail.imap.port", account.port?.toString() ?: "993") // Default IMAP over SSL
|
|
||||||
put("mail.imap.ssl.enable", "true")
|
|
||||||
|
|
||||||
put("mail.imap.connectiontimeout", "5000")
|
|
||||||
put("mail.imap.timeout", "5000")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun listAllMessagesWithEInvoiceInFolder(folder: Folder): List<MailWithInvoice> = folder.messages.mapNotNull { message ->
|
|
||||||
try {
|
|
||||||
if (message.isMimeType("multipart/*")) {
|
|
||||||
val multipart = message.content as MimeMultipart
|
|
||||||
val parts = IntRange(0, multipart.count - 1).map { multipart.getBodyPart(it) }
|
|
||||||
|
|
||||||
val attachmentsWithEInvoice = parts.mapNotNull { part ->
|
|
||||||
findEInvoice(part)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachmentsWithEInvoice.isNotEmpty()) {
|
|
||||||
return@mapNotNull MailWithInvoice(message.from.joinToString(), message.subject, map(message.sentDate), attachmentsWithEInvoice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
log.error(e) { "Could not read mail $message" }
|
|
||||||
}
|
|
||||||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findEInvoice(part: BodyPart): MailAttachmentWithEInvoice? {
|
|
||||||
try {
|
|
||||||
if (part.disposition == Part.ATTACHMENT) {
|
|
||||||
val invoice = tryToReadEInvoice(part)
|
|
||||||
if (invoice != null) {
|
|
||||||
var contentType = part.contentType
|
|
||||||
val indexOfSeparator = contentType.indexOf(';')
|
|
||||||
if (indexOfSeparator > -1) {
|
|
||||||
contentType = contentType.substring(0, indexOfSeparator)
|
|
||||||
}
|
|
||||||
|
|
||||||
val filename = File(part.fileName)
|
|
||||||
val file = File.createTempFile(filename.nameWithoutExtension, filename.extension).also { file ->
|
|
||||||
part.inputStream.use { it.copyTo(file.outputStream()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return MailAttachmentWithEInvoice(part.fileName, contentType, invoice, file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
log.error(e) { "Could not check attachment '${part.fileName}' for eInvoice" }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tryToReadEInvoice(part: BodyPart): Invoice? = try {
|
|
||||||
val filename = part.fileName.lowercase()
|
|
||||||
val contentType = part.contentType.lowercase()
|
|
||||||
|
|
||||||
if (filename.endsWith(".pdf") || contentType.startsWith("application/pdf") || contentType.startsWith("application/octet-stream")) {
|
|
||||||
eInvoiceReader.extractFromPdf(part.inputStream)
|
|
||||||
} else if (filename.endsWith(".xml") || contentType.startsWith("application/xml") || contentType.startsWith("text/xml")) {
|
|
||||||
eInvoiceReader.extractFromXml(part.inputStream)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
log.debug(e) { "Could not extract invoices from ${part.fileName}" }
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: same code as in MustangMapper
|
|
||||||
private fun map(date: Date): LocalDate =
|
|
||||||
date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate()
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
package net.codinux.invoicing.mail
|
|
||||||
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
class MailWithInvoice(
|
|
||||||
val sender: String,
|
|
||||||
val subject: String,
|
|
||||||
val date: LocalDate,
|
|
||||||
val attachmentsWithEInvoice: List<MailAttachmentWithEInvoice>
|
|
||||||
) {
|
|
||||||
override fun toString() = "$date $sender: $subject, ${attachmentsWithEInvoice.size} invoices"
|
|
||||||
}
|
|
|
@ -5,7 +5,9 @@ org.gradle.parallel=true
|
||||||
|
|
||||||
# Quarkus 3.12 requires Kotlin 2.0
|
# Quarkus 3.12 requires Kotlin 2.0
|
||||||
#kotlinVersion=1.9.25
|
#kotlinVersion=1.9.25
|
||||||
|
#kotlinCoroutinesVersion=1.8.1
|
||||||
kotlinVersion=2.0.20
|
kotlinVersion=2.0.20
|
||||||
|
kotlinCoroutinesVersion=1.9.0
|
||||||
|
|
||||||
quarkusVersion=3.16.3
|
quarkusVersion=3.16.3
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 88f1b01167e6a34b5b91f8797845bca0b7e4d3ab
|
|
@ -26,6 +26,6 @@ plugins {
|
||||||
|
|
||||||
rootProject.name = "eInvoicing"
|
rootProject.name = "eInvoicing"
|
||||||
|
|
||||||
include("e-invoicing-domain")
|
include("e-invoice-domain")
|
||||||
|
|
||||||
include("e-invoice-api")
|
include("e-invoice-api")
|
||||||
|
|
Loading…
Reference in New Issue