Compare commits

...

27 Commits

Author SHA1 Message Date
dankito e43a8a7239 Configured publishing to Maven repository 2024-11-21 23:29:11 +01:00
dankito e48a22e75b Renamed module e-invoicing-domain to e-invoice-domain 2024-11-21 23:23:44 +01:00
dankito 465b566dbc Fixed import 2024-11-21 23:14:57 +01:00
dankito f0631f55b7 Using now a fixed size thread pool as otherwise as many threads get created as there are messages in the inbox which can be really a lot 2024-11-21 23:14:41 +01:00
dankito 4cc03a0036 Implemented configuring if message body should be downloaded 2024-11-21 23:05:32 +01:00
dankito 82bbf8cc8d Implemented parallel message downloading 2024-11-21 23:03:53 +01:00
dankito c4bf7f91ca Added isEncrypted 2024-11-21 21:51:20 +01:00
dankito fbfeb7ced6 Concatenating body parts 2024-11-21 21:50:59 +01:00
dankito 85c392e1b7 Made mediaType lower case 2024-11-21 21:50:23 +01:00
dankito 2373717283 Fixed that filename may is null 2024-11-21 21:49:58 +01:00
dankito a2d34e217f Moved getAllMessageParts() to end 2024-11-21 21:48:24 +01:00
dankito 6d206b25f0 Added hint on messageNumber 2024-11-21 21:38:47 +01:00
dankito 0c3f069f1f Fixed that subject may is null 2024-11-21 21:24:20 +01:00
dankito 2ab10f77d4 Fixed that from may is null 2024-11-21 21:00:34 +01:00
dankito 9aaa2f630c Reading message body 2024-11-21 20:30:20 +01:00
dankito 399581da78 Reading a message part's media type only once 2024-11-21 20:28:14 +01:00
dankito 6b82636fdd Fixed not reading all parts' content 2024-11-21 20:27:05 +01:00
dankito 7e112404a0 Extracted findEInvoice(Message) 2024-11-21 19:30:46 +01:00
dankito 48270e7922 Fixed reading all, also nested, message parts 2024-11-21 19:21:31 +01:00
dankito 2516328da8 Fixed that sent date may is null 2024-11-21 18:40:13 +01:00
dankito 189b1b37ff Added hint that parallelization didn't work 2024-11-21 18:36:27 +01:00
dankito 2b3c3fa5fa Fixed setting file extension and deleting file on exit 2024-11-21 18:36:04 +01:00
dankito da84aaedf6 Implemented extracting media type from content type; made check for Attachment disposition case-insensitive 2024-11-21 17:35:34 +01:00
dankito b09b7cf69b Mapping sent to Instant; also mapping received and messageNumber 2024-11-21 17:02:38 +01:00
dankito 07fdbec5d7 Implemented listenForNewReceivedEInvoices() 2024-11-21 16:50:49 +01:00
dankito a6ac33d8ed Extracted connect() 2024-11-21 15:53:30 +01:00
dankito a1fe8befce Made it work but it's not nice 2024-11-21 04:25:31 +01:00
45 changed files with 363 additions and 155 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "gradle/scripts"]
path = gradle/scripts
url = git@github.com:dankito/GradleScripts.git

View File

@ -15,7 +15,25 @@ allprojects {
group = "net.codinux.invoicing"
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 {
mavenCentral()
}
}
tasks.register("publishAllToMavenLocal") {
dependsOn(
":e-invoice-domain:publishToMavenLocal"
)
}
tasks.register("publishAll") {
dependsOn(
":e-invoice-domain:publish"
)
}

View File

@ -28,7 +28,7 @@ dependencies {
implementation("io.quarkus:quarkus-micrometer")
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:quarkus-loki-log-appender:$lokiLogAppenderVersion")

View File

@ -67,20 +67,29 @@ class InvoicingResource(
@Path("extract")
@POST
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
@Consumes(MediaTypePdf, MediaType.APPLICATION_OCTET_STREAM)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Extract invoice data from a Factur-X / ZUGFeRD or XRechnung file")
@Tag(name = "Extract")
fun extractInvoiceData(invoice: FileUpload) =
service.extractInvoiceData(invoice.uploadedFile())
fun extractInvoiceDataFromPdf(invoice: java.nio.file.Path) =
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")
@POST
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
@Operation(summary = "Validate a Factur-X / ZUGFeRD or XRechnung file")
@Tag(name = "Validate")
fun validateInvoiceXml(invoice: FileUpload) =
service.validateInvoice(invoice.uploadedFile()).reportAsXml
fun validateInvoiceXml(invoice: java.nio.file.Path) =
service.validateInvoice(invoice).reportAsXml
private fun createPdfFileResponse(pdfFile: java.nio.file.Path, invoice: Invoice): Response =

View File

@ -7,7 +7,6 @@ import net.codinux.invoicing.reader.EInvoiceReader
import net.codinux.invoicing.validation.EInvoiceValidator
import java.io.File
import java.nio.file.Path
import kotlin.io.path.extension
@Singleton
class InvoicingService {
@ -42,17 +41,15 @@ class InvoicingService {
}
fun extractInvoiceData(invoiceFile: Path) = when (invoiceFile.extension.lowercase()) {
"xml" -> reader.extractFromXml(invoiceFile.toFile())
"pdf" -> reader.extractFromPdf(invoiceFile.toFile())
else -> throw IllegalArgumentException("We can only extract eInvoice data from .xml and .pdf files")
}
fun extractInvoiceDataFromPdf(invoiceFile: Path) =
reader.extractFromPdf(invoiceFile.toFile())
fun extractInvoiceDataFromXml(invoiceFile: Path) =
reader.extractFromXml(invoiceFile.toFile())
fun validateInvoice(invoiceFile: Path) =when (invoiceFile.extension.lowercase()) {
"xml", "pdf" -> validator.validate(invoiceFile.toFile())
else -> throw IllegalArgumentException("We can only validate .xml and .pdf eInvoice files")
}
fun validateInvoice(invoiceFile: Path) =
validator.validate(invoiceFile.toFile())
private fun createTempPdfFile(): Path =

View File

@ -8,6 +8,8 @@ kotlin {
}
val kotlinCoroutinesVersion: String by project
val mustangVersion: String by project
val angusMailVersion: String by project
@ -19,6 +21,8 @@ val xunitVersion: String by project
val logbackVersion: String by project
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion")
implementation("org.mustangproject:library:$mustangVersion")
implementation("org.mustangproject:validator:$mustangVersion")
@ -38,4 +42,10 @@ dependencies {
tasks.test {
useJUnitPlatform()
}
}
ext["customArtifactId"] = "e-invoice"
apply(from = "../gradle/scripts/publish-codinux-repo.gradle.kts")

View File

@ -5,7 +5,12 @@ import java.io.File
class MailAttachmentWithEInvoice(
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 file: File
) {

View 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")
}
}

View File

@ -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)"
}

View File

@ -1,6 +1,7 @@
package net.codinux.invoicing.mail
import assertk.assertThat
import assertk.assertions.isEmpty
import assertk.assertions.isNotEmpty
import org.junit.jupiter.api.Test
import kotlin.test.Ignore
@ -27,6 +28,9 @@ class MailReaderTest {
val result = underTest.listAllMessagesWithEInvoice(mailAccount)
assertThat(result).isNotEmpty()
val messagesWithoutBody = result.filter { it.plainTextOrHtmlBody == null }
assertThat(messagesWithoutBody).isEmpty()
}
}

View File

@ -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()
}

View File

@ -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"
}

View File

@ -5,7 +5,9 @@ org.gradle.parallel=true
# Quarkus 3.12 requires Kotlin 2.0
#kotlinVersion=1.9.25
#kotlinCoroutinesVersion=1.8.1
kotlinVersion=2.0.20
kotlinCoroutinesVersion=1.9.0
quarkusVersion=3.16.3

1
gradle/scripts Submodule

@ -0,0 +1 @@
Subproject commit 88f1b01167e6a34b5b91f8797845bca0b7e4d3ab

View File

@ -26,6 +26,6 @@ plugins {
rootProject.name = "eInvoicing"
include("e-invoicing-domain")
include("e-invoice-domain")
include("e-invoice-api")