From 42f4d09ff317db20caa918b00e5ba13328cd69d6 Mon Sep 17 00:00:00 2001 From: dankito Date: Fri, 15 Nov 2024 17:47:46 +0100 Subject: [PATCH] Implemented MailReader that checks all mails of an email account if they have an eInvoice as attachment --- e-invoicing-domain/build.gradle.kts | 4 + .../net/codinux/invoicing/mail/MailAccount.kt | 16 +++ .../mail/MailAttachmentWithEInvoice.kt | 13 ++ .../net/codinux/invoicing/mail/MailReader.kt | 131 ++++++++++++++++++ .../codinux/invoicing/mail/MailWithInvoice.kt | 12 ++ .../codinux/invoicing/mail/MailReaderTest.kt | 32 +++++ gradle.properties | 2 + 7 files changed, 210 insertions(+) create mode 100644 e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailAccount.kt create mode 100644 e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailAttachmentWithEInvoice.kt create mode 100644 e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailReader.kt create mode 100644 e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailWithInvoice.kt create mode 100644 e-invoicing-domain/src/test/kotlin/net/codinux/invoicing/mail/MailReaderTest.kt diff --git a/e-invoicing-domain/build.gradle.kts b/e-invoicing-domain/build.gradle.kts index c230ed3..73ad53c 100644 --- a/e-invoicing-domain/build.gradle.kts +++ b/e-invoicing-domain/build.gradle.kts @@ -10,6 +10,8 @@ kotlin { val mustangVersion: String by project +val angusMailVersion: String by project + val klfVersion: String by project val assertKVersion: String by project @@ -19,6 +21,8 @@ val logbackVersion: String by project dependencies { implementation("org.mustangproject:library:$mustangVersion") + implementation("org.eclipse.angus:angus-mail:$angusMailVersion") + implementation("net.codinux.log:klf:$klfVersion") diff --git a/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailAccount.kt b/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailAccount.kt new file mode 100644 index 0000000..2c4c004 --- /dev/null +++ b/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailAccount.kt @@ -0,0 +1,16 @@ +package net.codinux.invoicing.mail + +class MailAccount( + val username: String, + val password: String, + /** + * For reading mails the IMAP server address, for sending mails the SMTP server address. + */ + val serverAddress: String, + /** + * Even though not mandatory it's better to specify the port, otherwise default port is tried. + */ + val port: Int? = null +) { + override fun toString() = "$username $serverAddress${port?.let { ":$it" } ?: ""}" +} \ No newline at end of file diff --git a/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailAttachmentWithEInvoice.kt b/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailAttachmentWithEInvoice.kt new file mode 100644 index 0000000..5862207 --- /dev/null +++ b/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailAttachmentWithEInvoice.kt @@ -0,0 +1,13 @@ +package net.codinux.invoicing.mail + +import net.codinux.invoicing.model.Invoice +import java.io.File + +class MailAttachmentWithEInvoice( + val filename: String, + val contentType: String, + val invoice: Invoice, + val file: File +) { + override fun toString() = "$filename: $invoice" +} \ No newline at end of file diff --git a/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailReader.kt b/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailReader.kt new file mode 100644 index 0000000..f56e8aa --- /dev/null +++ b/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailReader.kt @@ -0,0 +1,131 @@ +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 extractionErrorMessages = mutableSetOf() + + private val extractionErrors = mutableSetOf() + + private val log by logger() + + + fun listAllMessagesWithEInvoice(account: MailAccount): List { + 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 = 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? { + val filename = part.fileName.lowercase() + val contentType = part.contentType.lowercase() + + return if (filename.endsWith(".pdf") || contentType.startsWith("application/pdf") || contentType.startsWith("application/octet-stream")) { + try { + eInvoiceReader.extractFromPdf(part.inputStream) + } catch (e: Throwable) { + extractionErrorMessages.add(e.message) + extractionErrors.add(e) + null + } + } else if (filename.endsWith(".xml") || contentType.startsWith("application/xml") || contentType.startsWith("text/xml")) { + eInvoiceReader.readFromXml(part.inputStream) + } else { + null + } + } + + // TODO: same code as in MustangMapper + private fun map(date: Date): LocalDate = + date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate() + +} \ No newline at end of file diff --git a/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailWithInvoice.kt b/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailWithInvoice.kt new file mode 100644 index 0000000..835a3a3 --- /dev/null +++ b/e-invoicing-domain/src/main/kotlin/net/codinux/invoicing/mail/MailWithInvoice.kt @@ -0,0 +1,12 @@ +package net.codinux.invoicing.mail + +import java.time.LocalDate + +class MailWithInvoice( + val sender: String, + val subject: String, + val date: LocalDate, + val attachmentsWithEInvoice: List +) { + override fun toString() = "$date $sender: $subject, ${attachmentsWithEInvoice.size} invoices" +} \ No newline at end of file diff --git a/e-invoicing-domain/src/test/kotlin/net/codinux/invoicing/mail/MailReaderTest.kt b/e-invoicing-domain/src/test/kotlin/net/codinux/invoicing/mail/MailReaderTest.kt new file mode 100644 index 0000000..6ef1250 --- /dev/null +++ b/e-invoicing-domain/src/test/kotlin/net/codinux/invoicing/mail/MailReaderTest.kt @@ -0,0 +1,32 @@ +package net.codinux.invoicing.mail + +import assertk.assertThat +import assertk.assertions.isNotEmpty +import org.junit.jupiter.api.Test +import kotlin.test.Ignore + +@Ignore // not an automatic test, set your mail account settings below +class MailReaderTest { + + companion object { + // specify your mail account here + private val mailAccount = MailAccount( + username = "", + password = "", + serverAddress = "", + port = null // can be left as null if default port 993 is used + ) + } + + + private val underTest = MailReader() + + + @Test + fun listAllMessagesWithEInvoice() { + val result = underTest.listAllMessagesWithEInvoice(mailAccount) + + assertThat(result).isNotEmpty() + } + +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index e5e81b3..c25b71c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,8 @@ kotlinVersion=1.9.25 mustangVersion=2.14.2 +angusMailVersion=2.0.3 + klfVersion=1.6.2 # only used for tests logbackVersion=1.5.12