Implemented MailReader that checks all mails of an email account if they have an eInvoice as attachment

This commit is contained in:
dankito 2024-11-15 17:47:46 +01:00
parent bb48356011
commit 42f4d09ff3
7 changed files with 210 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -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<String?>()
private val extractionErrors = mutableSetOf<Throwable>()
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? {
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()
}

View File

@ -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<MailAttachmentWithEInvoice>
) {
override fun toString() = "$date $sender: $subject, ${attachmentsWithEInvoice.size} invoices"
}

View File

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

View File

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