Compare commits
56 Commits
6aaecd1656
...
52bf9daa7d
Author | SHA1 | Date |
---|---|---|
dankito | 52bf9daa7d | |
dankito | c516969cb0 | |
dankito | 8e461b43a5 | |
dankito | 7df4b944ee | |
dankito | f1f16e2e9e | |
dankito | 54a5227fb7 | |
dankito | 9480bc0282 | |
dankito | 07046baa9c | |
dankito | fef3765b8f | |
dankito | 2c8f4ec050 | |
dankito | 4deaf9cb21 | |
dankito | a1eea168e9 | |
dankito | 5abfa0b641 | |
dankito | 922fa629ae | |
dankito | 0a53966b16 | |
dankito | 110da9ced6 | |
dankito | 3d1857de3a | |
dankito | d976f848de | |
dankito | b28403025c | |
dankito | 1d7c07e7d6 | |
dankito | c1c33d80a0 | |
dankito | 00c062f9a9 | |
dankito | 318903db40 | |
dankito | 64966ea827 | |
dankito | d031b9414d | |
dankito | c65d99e35d | |
dankito | 1e372cf592 | |
dankito | 9257205a43 | |
dankito | 3e401b1c00 | |
dankito | 1e986e800d | |
dankito | f24b2004bb | |
dankito | 6ec302e50f | |
dankito | ef42ddee37 | |
dankito | af03c0d5d3 | |
dankito | b37c4619d2 | |
dankito | a078b8bf66 | |
dankito | 2b08db3374 | |
dankito | bb5b89fd5a | |
dankito | ccf48f7cb4 | |
dankito | 4aefa86ab9 | |
dankito | 4dc9b43189 | |
dankito | b3f6f2dbc3 | |
dankito | d70a748ad0 | |
dankito | c3cf0652b2 | |
dankito | 72991218d9 | |
dankito | 199310de86 | |
dankito | dcc4e233aa | |
dankito | e0b4550cd3 | |
dankito | 31ba07d5e9 | |
dankito | bb3f468c48 | |
dankito | 231da572e5 | |
dankito | 8b7bd31cf1 | |
dankito | d66afa50a9 | |
dankito | eb9b42fb97 | |
dankito | aa23cc0eb1 | |
dankito | 0c1d48736c |
20
README.md
20
README.md
|
@ -22,14 +22,18 @@ val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
|
|||
### Find all invoices of an IMAP email account
|
||||
|
||||
```kotlin
|
||||
val mailReader = MailReader()
|
||||
val emailsFetcher = EmailsFetcher()
|
||||
|
||||
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount(
|
||||
username = "", // your mail account username
|
||||
password = "", // your mail account username
|
||||
val fetchResult = emailsFetcher.fetchAllEmails(EmailAccount(
|
||||
username = "", // your email account username
|
||||
password = "", // your email account username
|
||||
serverAddress = "", // IMAP server address
|
||||
port = null // IMAP server port, leave null if default port 993
|
||||
port = null // IMAP server port, can be left null for default port 993
|
||||
))
|
||||
|
||||
fetchResult.emails.forEach { email ->
|
||||
println("${email.sender}: ${email.attachments.firstNotNullOfOrNull { it.invoice }?.totalAmounts?.duePayableAmount}")
|
||||
}
|
||||
```
|
||||
|
||||
### Validate eInvoice
|
||||
|
@ -54,7 +58,7 @@ fun create() {
|
|||
val creator = EInvoiceCreator()
|
||||
|
||||
// create a PDF that also contains the eInvoice as XML attachment
|
||||
creator.createFacturXPdf(invoice, pdfResultFile)
|
||||
creator.createPdfWithAttachedXml(invoice, pdfResultFile)
|
||||
|
||||
// create only the XML file
|
||||
val xml = creator.createFacturXXml(invoice)
|
||||
|
@ -84,7 +88,7 @@ val creator = EInvoiceCreator()
|
|||
creator.attachInvoiceXmlToPdf(invoice, existingPdf, output)
|
||||
|
||||
// or if you already have the invoice XML:
|
||||
val invoiceXml: String = "..." // e.g. creator.createZugferdXml(invoice)
|
||||
val invoiceXml = creator.createXRechnungXml(invoice) // or creator.createZugferdXml(invoice), ...
|
||||
|
||||
creator.attachInvoiceXmlToPdf(invoiceXml, existingPdf, output)
|
||||
creator.attachInvoiceXmlToPdf(invoiceXml, EInvoiceXmlFormat.XRechnung, existingPdf, output)
|
||||
```
|
|
@ -27,7 +27,7 @@ class InvoicingService {
|
|||
fun createFacturXPdf(invoice: Invoice): Path {
|
||||
val resultFile = createTempPdfFile()
|
||||
|
||||
creator.createFacturXPdf(invoice, resultFile.toFile())
|
||||
creator.createPdfWithAttachedXml(invoice, resultFile.toFile())
|
||||
|
||||
return resultFile
|
||||
}
|
||||
|
|
|
@ -65,8 +65,10 @@ open class EInvoiceConverter {
|
|||
protected open fun createXRechnungXml(invoice: Invoice): String = EInvoiceCreator().createXRechnungXml(invoice)
|
||||
|
||||
protected open fun copyResource(resourceName: String, outputFile: File, outputFileExtension: String) {
|
||||
javaClass.classLoader.getResourceAsStream(resourceName).use {
|
||||
it?.copyTo(File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream())
|
||||
javaClass.classLoader.getResourceAsStream(resourceName).use { inputStream ->
|
||||
File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream().use { outputStream ->
|
||||
inputStream?.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,73 +1,33 @@
|
|||
package net.codinux.invoicing.creation
|
||||
|
||||
import net.codinux.invoicing.mapper.MustangMapper
|
||||
import net.codinux.invoicing.model.EInvoiceXmlFormat
|
||||
import net.codinux.invoicing.model.Invoice
|
||||
import org.mustangproject.ZUGFeRD.*
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
open class EInvoiceCreator(
|
||||
protected open val mapper: MustangMapper = MustangMapper()
|
||||
) {
|
||||
|
||||
open fun createXRechnungXml(invoice: Invoice): String {
|
||||
val provider = ZUGFeRD2PullProvider()
|
||||
provider.profile = Profiles.getByName("XRechnung")
|
||||
|
||||
return createXml(provider, invoice)
|
||||
}
|
||||
|
||||
open fun createXRechnungXml(invoice: Invoice) = createXml(invoice, EInvoiceXmlFormat.XRechnung)
|
||||
|
||||
/**
|
||||
* Synonym for [createFacturXXml] (ZUGFeRD 2 is a synonym for Factur-X).
|
||||
*/
|
||||
open fun createZugferdXml(invoice: Invoice) = createFacturXXml(invoice)
|
||||
|
||||
open fun createFacturXXml(invoice: Invoice): String {
|
||||
open fun createFacturXXml(invoice: Invoice) = createXml(invoice, EInvoiceXmlFormat.FacturX)
|
||||
|
||||
protected open fun createXml(invoice: Invoice, format: EInvoiceXmlFormat): String {
|
||||
val exporter = ZUGFeRDExporterFromA3()
|
||||
.setProfile("EN16931") // required for XML?
|
||||
.setProfile(getProfileNameForFormat(format))
|
||||
|
||||
return createXml(exporter.provider, invoice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synonym for [createFacturXPdf] (ZUGFeRD 2 is a synonym for Factur-X).
|
||||
*/
|
||||
open fun createZugferdPdf(invoice: Invoice, outputFile: File) = createFacturXPdf(invoice, outputFile)
|
||||
|
||||
open fun createFacturXPdf(invoice: Invoice, outputFile: File) {
|
||||
val xml = createFacturXXml(invoice)
|
||||
val xmlFile = File.createTempFile(outputFile.nameWithoutExtension, ".xml")
|
||||
.also { it.writeText(xml) }
|
||||
val pdfFile = File(xmlFile.parentFile, xmlFile.nameWithoutExtension + ".pdf")
|
||||
|
||||
val visualizer = ZUGFeRDVisualizer()
|
||||
visualizer.toPDF(xmlFile.absolutePath, pdfFile.absolutePath)
|
||||
|
||||
attachInvoiceXmlToPdf(xml, pdfFile, outputFile)
|
||||
|
||||
xmlFile.delete()
|
||||
pdfFile.delete()
|
||||
}
|
||||
|
||||
|
||||
open fun attachInvoiceXmlToPdf(invoice: Invoice, pdfFile: File, outputFile: File) =
|
||||
attachInvoiceXmlToPdf(createFacturXXml(invoice), pdfFile, outputFile)
|
||||
|
||||
open fun attachInvoiceXmlToPdf(invoiceXml: String, pdfFile: File, outputFile: File) {
|
||||
val exporter = ZUGFeRDExporterFromA3()
|
||||
.setZUGFeRDVersion(2)
|
||||
.setProfile("EN16931") // available values: MINIMUM, BASICWL, BASIC, CIUS, EN16931, EXTENDED, XRECHNUNG
|
||||
// .disableFacturX()
|
||||
.setProducer("danki die geile Sau")
|
||||
.setCreator(System.getProperty("user.name"))
|
||||
|
||||
exporter.load(pdfFile.inputStream())
|
||||
exporter.setXML(invoiceXml.toByteArray())
|
||||
|
||||
exporter.export(outputFile.outputStream())
|
||||
}
|
||||
|
||||
|
||||
protected open fun createXml(provider: IXMLProvider, invoice: Invoice): String {
|
||||
val transaction = mapper.mapToTransaction(invoice)
|
||||
|
||||
|
@ -76,4 +36,80 @@ open class EInvoiceCreator(
|
|||
return String(provider.xml, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a hybrid PDF that also contains the Factur-X / ZUGFeRD or XRechnung XML as attachment.
|
||||
*/
|
||||
@JvmOverloads
|
||||
open fun createPdfWithAttachedXml(invoice: Invoice, outputFile: File, format: EInvoiceXmlFormat = EInvoiceXmlFormat.FacturX) {
|
||||
val xml = createXml(invoice, format)
|
||||
|
||||
createPdfWithAttachedXml(xml, format, outputFile)
|
||||
}
|
||||
|
||||
open fun createPdfWithAttachedXml(invoiceXml: String, format: EInvoiceXmlFormat, outputFile: File) {
|
||||
outputFile.outputStream().use { outputStream ->
|
||||
createPdfWithAttachedXml(invoiceXml, format, outputStream)
|
||||
}
|
||||
}
|
||||
|
||||
open fun createPdfWithAttachedXml(invoiceXml: String, format: EInvoiceXmlFormat, outputFile: OutputStream) {
|
||||
val xmlFile = File.createTempFile("${format.name}-invoice", ".xml")
|
||||
.also { it.writeText(invoiceXml) }
|
||||
val pdfFile = File(xmlFile.parentFile, xmlFile.nameWithoutExtension + ".pdf")
|
||||
|
||||
val visualizer = ZUGFeRDVisualizer()
|
||||
visualizer.toPDF(xmlFile.absolutePath, pdfFile.absolutePath)
|
||||
|
||||
pdfFile.inputStream().use { inputStream ->
|
||||
attachInvoiceXmlToPdf(invoiceXml, format, inputStream, outputFile)
|
||||
}
|
||||
|
||||
xmlFile.delete()
|
||||
pdfFile.delete()
|
||||
}
|
||||
|
||||
|
||||
@JvmOverloads
|
||||
open fun attachInvoiceXmlToPdf(invoice: Invoice, pdfFile: File, outputFile: File, format: EInvoiceXmlFormat = EInvoiceXmlFormat.FacturX) =
|
||||
attachInvoiceXmlToPdf(createXml(invoice, format), format, pdfFile, outputFile)
|
||||
|
||||
open fun attachInvoiceXmlToPdf(invoiceXml: String, format: EInvoiceXmlFormat, pdfFile: File, outputFile: File) {
|
||||
pdfFile.inputStream().use { inputStream ->
|
||||
outputFile.outputStream().use { outputStream ->
|
||||
attachInvoiceXmlToPdf(invoiceXml, format, inputStream, outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun attachInvoiceXmlToPdf(invoiceXml: String, format: EInvoiceXmlFormat, pdfFile: InputStream, outputFile: OutputStream) =
|
||||
attachInvoiceXmlToPdf(invoiceXml, format, pdfFile.readAllBytes(), outputFile)
|
||||
|
||||
open fun attachInvoiceXmlToPdf(invoiceXml: String, format: EInvoiceXmlFormat, pdfFile: ByteArray, outputFile: OutputStream) {
|
||||
val exporter = ZUGFeRDExporterFromA3()
|
||||
.setZUGFeRDVersion(2)
|
||||
.setProfile(getProfileNameForFormat(format))
|
||||
// .disableFacturX()
|
||||
.setProducer("danki die geile Sau")
|
||||
.setCreator(System.getProperty("user.name"))
|
||||
.setCreatorTool("Unglaublich geiles eInvoicing Tool von codinux")
|
||||
|
||||
exporter.load(pdfFile)
|
||||
exporter.setXML(invoiceXml.toByteArray())
|
||||
|
||||
exporter.export(outputFile)
|
||||
}
|
||||
|
||||
|
||||
protected open fun getProfileNameForFormat(format: EInvoiceXmlFormat) = when (format) {
|
||||
EInvoiceXmlFormat.FacturX -> "EN16931" // available values: MINIMUM, BASICWL, BASIC, CIUS, EN16931, EXTENDED, XRECHNUNG
|
||||
EInvoiceXmlFormat.XRechnung -> "XRECHNUNG"
|
||||
}
|
||||
|
||||
protected open fun getFilenameForFormat(format: EInvoiceXmlFormat) = when (format) {
|
||||
EInvoiceXmlFormat.FacturX -> "factur-x.xml"
|
||||
EInvoiceXmlFormat.XRechnung -> "xrechnung.xml"
|
||||
// other available values: "zugferd-invoice.xml" (ZF v2), "ZUGFeRD-invoice.xml" (ZF v1) ("order-x.xml", "cida.xml")
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,357 @@
|
|||
package net.codinux.invoicing.email
|
||||
|
||||
import jakarta.mail.*
|
||||
import jakarta.mail.event.MessageCountAdapter
|
||||
import jakarta.mail.event.MessageCountEvent
|
||||
import jakarta.mail.internet.InternetAddress
|
||||
import jakarta.mail.internet.MimeUtility
|
||||
import kotlinx.coroutines.*
|
||||
import net.codinux.invoicing.email.model.*
|
||||
import net.codinux.invoicing.filesystem.FileUtil
|
||||
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 org.eclipse.angus.mail.imap.IMAPMessage
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.max
|
||||
|
||||
open class EmailsFetcher(
|
||||
protected open val eInvoiceReader: EInvoiceReader = EInvoiceReader(),
|
||||
protected open val coroutineDispatcher: CoroutineDispatcher = Executors.newFixedThreadPool(max(24, Runtime.getRuntime().availableProcessors() * 4)).asCoroutineDispatcher()
|
||||
) {
|
||||
|
||||
protected data class MessagePart(
|
||||
val mediaType: String,
|
||||
val part: Part
|
||||
)
|
||||
|
||||
|
||||
companion object {
|
||||
protected val MessageBodyMediaTypes = listOf("text/plain", "text/html")
|
||||
|
||||
protected val FileNotDownloadedOrErrorOccurred = Pair<Invoice?, File?>(null, null)
|
||||
}
|
||||
|
||||
|
||||
protected val log by logger()
|
||||
|
||||
|
||||
open fun listenForNewEmails(account: EmailAccount, options: ListenForNewMailsOptions) = runBlocking {
|
||||
try {
|
||||
connect(account, options) { store ->
|
||||
val folder = store.getFolder(options.emailFolderName) as IMAPFolder
|
||||
folder.open(Folder.READ_ONLY)
|
||||
|
||||
val status = FetchEmailsStatus(account, folder, options)
|
||||
|
||||
folder.addMessageCountListener(object : MessageCountAdapter() {
|
||||
override fun messagesAdded(event: MessageCountEvent) {
|
||||
event.messages.forEach { message ->
|
||||
getEmail(message, status)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
launch(coroutineDispatcher) {
|
||||
keepConnectionOpen(status, folder, options)
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
log.error(e) { "Listening to new emails of '${account.username}' failed" }
|
||||
options.onError?.invoke(FetchEmailError(FetchEmailErrorType.ListenForNewEmails, null, e))
|
||||
}
|
||||
}
|
||||
|
||||
protected open suspend fun keepConnectionOpen(status: FetchEmailsStatus, folder: IMAPFolder, options: ListenForNewMailsOptions) {
|
||||
val account = status.account
|
||||
log.info { "Listening to new emails of $account" }
|
||||
|
||||
// Use IMAP IDLE to keep the connection alive
|
||||
while (options.stopListening.get() == false) {
|
||||
if (!folder.isOpen) {
|
||||
log.info { "Reopening inbox of $account ..." }
|
||||
folder.open(Folder.READ_ONLY)
|
||||
}
|
||||
|
||||
folder.idle()
|
||||
|
||||
delay(250)
|
||||
}
|
||||
|
||||
log.info { "Stopped listening to new emails of '$account}'" }
|
||||
}
|
||||
|
||||
|
||||
open fun fetchAllEmails(account: EmailAccount, options: FetchEmailsOptions = FetchEmailsOptions()): FetchEmailsResult {
|
||||
try {
|
||||
return connect(account, options) { store ->
|
||||
val folder = store.getFolder(options.emailFolderName) as IMAPFolder
|
||||
folder.open(Folder.READ_ONLY)
|
||||
|
||||
val status = FetchEmailsStatus(account, folder, options)
|
||||
|
||||
val emails = fetchAllEmailsInFolder(status).also {
|
||||
folder.close(false)
|
||||
}
|
||||
|
||||
FetchEmailsResult(emails, null, status.messageSpecificErrors)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
log.error(e) { "Could not fetch emails of account $account" }
|
||||
|
||||
return FetchEmailsResult(emptyList(), e)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun fetchAllEmailsInFolder(status: FetchEmailsStatus): List<Email> = runBlocking {
|
||||
val folder = status.folder
|
||||
val messageCount = folder.messageCount
|
||||
if (messageCount <= 0) {
|
||||
return@runBlocking emptyList()
|
||||
}
|
||||
|
||||
val startUid = (status.options.lastRetrievedMessageId ?: 0) + 1 // message numbers start at 1
|
||||
val messages = folder.getMessagesByUID(startUid, UIDFolder.MAXUID)
|
||||
|
||||
// for each data type like envelope (from, subject, ...), body structure, if message is unread, ... a network request is
|
||||
// executed, making the overall process very slow -> use FetchProfile to prefetch requested data with a single request
|
||||
folder.fetch(messages, getFetchProfile(status))
|
||||
|
||||
messages.mapNotNull { message ->
|
||||
async(coroutineDispatcher) {
|
||||
try {
|
||||
getEmail(message, status)
|
||||
} catch (e: Throwable) {
|
||||
log.error(e) { "Could not get email $message" }
|
||||
status.addError(FetchEmailErrorType.GetEmail, folder.getUID(message), e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
.filterNotNull()
|
||||
}
|
||||
|
||||
private fun getFetchProfile(status: FetchEmailsStatus) = FetchProfile().apply {
|
||||
add(UIDFolder.FetchProfileItem.UID) // message UID
|
||||
add(FetchProfile.Item.ENVELOPE) // from, subject, to, ...
|
||||
add(FetchProfile.Item.CONTENT_INFO) // content type, disposition, ...
|
||||
|
||||
// add(FetchProfile.Item.FLAGS) // message status like unread, deleted, draft, ...
|
||||
// add(IMAPFolder.FetchProfileItem.MESSAGE) // the entire message including all attachments, headers, ... there should be rarely a use case for it
|
||||
}
|
||||
|
||||
protected open fun getEmail(message: Message, status: FetchEmailsStatus): Email? {
|
||||
val date = map(message.sentDate ?: message.receivedDate)
|
||||
status.options.minMessageDate?.let { minDate ->
|
||||
if (date.isBefore(minDate)) {
|
||||
log.debug { "Ignoring message $message with date $date as it is before downloadOnlyMessagesNewerThan date $minDate" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val imapMessage = message as? IMAPMessage
|
||||
val messageId = status.folder.getUID(message)
|
||||
|
||||
val parts = getAllMessageParts(message)
|
||||
val messageBodyParts = parts.filter { it.part.fileName == null && it.mediaType in MessageBodyMediaTypes }
|
||||
val attachmentParts = parts.filter { it !in messageBodyParts }
|
||||
|
||||
val attachments = attachmentParts.mapNotNull { part ->
|
||||
getAttachment(part, status, messageId)
|
||||
}
|
||||
|
||||
val sender = message.from?.firstOrNull()?.let { map(it) }
|
||||
val plainTextBody = getPlainTextBody(messageBodyParts, status, messageId)
|
||||
|
||||
val email = Email(
|
||||
messageId,
|
||||
sender, message.subject ?: "", date,
|
||||
|
||||
message.getRecipients(Message.RecipientType.TO).orEmpty().map { map(it) }, message.getRecipients(Message.RecipientType.CC).orEmpty().map { map(it) }, message.getRecipients(Message.RecipientType.BCC).orEmpty().map { map(it) },
|
||||
(message.replyTo?.firstOrNull() as? InternetAddress)?.let { if (it.address != sender?.address) map(it) else null }, // only set replyTo if it differs from sender
|
||||
|
||||
plainTextBody, getHtmlBody(messageBodyParts, status, messageId, plainTextBody),
|
||||
|
||||
imapMessage?.contentLanguage?.firstOrNull(),
|
||||
parts.any { it.mediaType == "application/pgp-encrypted" },
|
||||
|
||||
attachments
|
||||
)
|
||||
|
||||
status.options.emailReceived(email)
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
protected open fun map(address: Address): EmailAddress =
|
||||
if (address is InternetAddress) { // use MimeUtility to parse e.g. Quoted-printable names that e.g. start with "=?UTF-8?Q?"
|
||||
EmailAddress(address.address, address.personal?.let { MimeUtility.decodeText(it) })
|
||||
} else {
|
||||
EmailAddress(address.toString())
|
||||
}
|
||||
|
||||
protected open fun getAttachment(messagePart: MessagePart, status: FetchEmailsStatus, messageId: Long): EmailAttachment? {
|
||||
try {
|
||||
val part = messagePart.part
|
||||
if (part.fileName == null) { // not an attachment
|
||||
return null
|
||||
}
|
||||
|
||||
val filename = File(part.fileName)
|
||||
val extension = filename.extension.lowercase()
|
||||
|
||||
val (invoice, invoiceFile) = tryToReadEInvoice(part, extension, messagePart.mediaType, status)
|
||||
|
||||
if (invoice != null || Part.ATTACHMENT.equals(part.disposition, ignoreCase = true)) {
|
||||
val file = invoiceFile ?:
|
||||
if (extension !in status.options.downloadAttachmentsWithExtensions) null
|
||||
else downloadAttachment(part, status)
|
||||
|
||||
return EmailAttachment(part.fileName, extension, part.size.takeIf { it > 0 }, mapDisposition(part), messagePart.mediaType, part.contentType, invoice, file)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
log.error(e) { "Could not check attachment '${messagePart.part.fileName}' (${messagePart.mediaType}) for eInvoice" }
|
||||
status.addError(FetchEmailErrorType.GetAttachment, messageId, e)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun mapDisposition(part: Part) = when (part.disposition?.lowercase()) {
|
||||
"inline" -> ContentDisposition.Inline
|
||||
"attachment" -> ContentDisposition.Attachment
|
||||
null -> ContentDisposition.Body
|
||||
else -> ContentDisposition.Unknown
|
||||
}
|
||||
|
||||
protected open fun tryToReadEInvoice(part: Part, extension: String, mediaType: String?, status: FetchEmailsStatus): Pair<Invoice?, File?> =
|
||||
if (extension == "pdf" || mediaType == "application/pdf" || mediaType == "application/octet-stream") {
|
||||
val file = downloadAttachment(part, status)
|
||||
Pair(eInvoiceReader.extractFromPdfOrNull(part.inputStream), file)
|
||||
} else if (extension == "xml" || mediaType == "application/xml" || mediaType == "text/xml") {
|
||||
val file = downloadAttachment(part, status)
|
||||
Pair(eInvoiceReader.extractFromXmlOrNull(part.inputStream), file)
|
||||
} else {
|
||||
FileNotDownloadedOrErrorOccurred
|
||||
}
|
||||
|
||||
private fun downloadAttachment(part: Part, status: FetchEmailsStatus) =
|
||||
File(status.userAttachmentsDownloadDirectory, FileUtil.removeIllegalFileCharacters(part.fileName)).also { file ->
|
||||
part.inputStream.use { inputStream ->
|
||||
file.outputStream().use { outputStream ->
|
||||
inputStream.copyTo(outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
protected open 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
|
||||
*/
|
||||
protected open fun getMediaType(part: Part): String? = part.contentType?.lowercase()?.let { contentType ->
|
||||
val indexOfSeparator = contentType.indexOf(';')
|
||||
|
||||
if (indexOfSeparator > -1) {
|
||||
contentType.substring(0, indexOfSeparator)
|
||||
} else {
|
||||
contentType
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun getPlainTextBody(parts: Collection<MessagePart>, status: FetchEmailsStatus, messageId: Long) =
|
||||
if (status.options.downloadMessageBody) getBodyWithMediaType(parts, "text/plain", status, messageId) else null
|
||||
|
||||
protected open fun getHtmlBody(parts: Collection<MessagePart>, status: FetchEmailsStatus, messageId: Long, plainTextBody: String?) =
|
||||
// in case of downloadOnlyPlainTextOrHtmlMessageBody == true, download html body only if there's no plain text body
|
||||
if (status.options.downloadMessageBody && (status.options.downloadOnlyPlainTextOrHtmlMessageBody == false || plainTextBody == null)) {
|
||||
getBodyWithMediaType(parts, "text/html", status, messageId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
protected open fun getBodyWithMediaType(parts: Collection<MessagePart>, mediaType: String, status: FetchEmailsStatus, messageId: Long): 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'" }
|
||||
status.addError(FetchEmailErrorType.GetMesssageBody, messageId, e)
|
||||
null
|
||||
}
|
||||
|
||||
protected open fun map(date: Date): Instant =
|
||||
date.toInstant()
|
||||
|
||||
|
||||
protected open fun <T> connect(account: EmailAccount, options: FetchEmailsOptions, connected: (Store) -> T): T {
|
||||
val properties = mapAccountToJavaMailProperties(account, options)
|
||||
|
||||
val session = Session.getInstance(properties)
|
||||
session.getStore("imap").use { store ->
|
||||
store.connect(account.serverAddress, account.username, account.password)
|
||||
|
||||
return connected(store)
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun mapAccountToJavaMailProperties(account: EmailAccount, options: FetchEmailsOptions) = Properties().apply {
|
||||
// the documentation of all properties can be found here: https://javaee.github.io/javamail/docs/api/com/sun/mail/imap/package-summary.html
|
||||
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")
|
||||
|
||||
val timeout = (options.connectTimeoutSeconds * 1000).toString()
|
||||
put("mail.imap.connectiontimeout", timeout)
|
||||
put("mail.imap.timeout", timeout)
|
||||
|
||||
// speeds up fetching data tremendously
|
||||
put("mail.imap.fetchsize", "819200") // Partial fetch size in bytes. Defaults to 16K.
|
||||
put("mail.imap.partialfetch", "false") // Controls whether the IMAP partial-fetch capability should be used. Defaults to true.
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package net.codinux.invoicing.email
|
||||
|
||||
data class FetchEmailError(
|
||||
val type: FetchEmailErrorType,
|
||||
val messageId: Long?,
|
||||
val error: Throwable
|
||||
)
|
|
@ -0,0 +1,13 @@
|
|||
package net.codinux.invoicing.email
|
||||
|
||||
enum class FetchEmailErrorType {
|
||||
GetEmail,
|
||||
|
||||
GetMesssageBody,
|
||||
|
||||
GetAttachment,
|
||||
|
||||
ExtractInvoice,
|
||||
|
||||
ListenForNewEmails
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package net.codinux.invoicing.email
|
||||
|
||||
import net.codinux.invoicing.email.model.Email
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
open class FetchEmailsOptions(
|
||||
/**
|
||||
* The ID of the last retrieved message. If set, only messages newer than this ID will be fetched.
|
||||
*/
|
||||
val lastRetrievedMessageId: Long? = null,
|
||||
|
||||
val downloadMessageBody: Boolean = true,
|
||||
/**
|
||||
* If set to true and message contains a plain text message body, then only the plain text message body is downloaded
|
||||
* and the HTML message body ignored / not downloaded. Reduces process time about 50 % (if no attachments get downloaded).
|
||||
*/
|
||||
val downloadOnlyPlainTextOrHtmlMessageBody: Boolean = true,
|
||||
val downloadOnlyMessagesNewerThan: LocalDate? = null,
|
||||
|
||||
/**
|
||||
* Set the extension (without the dot) of files that should be downloaded.
|
||||
*/
|
||||
val downloadAttachmentsWithExtensions: List<String> = DefaultDownloadedAttachmentsWithExtensions,
|
||||
val attachmentsDownloadDirectory: File = DefaultAttachmentsDownloadDirectory,
|
||||
|
||||
val emailFolderName: String = "INBOX",
|
||||
val connectTimeoutSeconds: Int = 5,
|
||||
|
||||
val onError: ((FetchEmailError) -> Unit)? = null,
|
||||
val onEmailReceived: ((Email) -> Unit)? = null
|
||||
) {
|
||||
companion object {
|
||||
val DefaultDownloadedAttachmentsWithExtensions = emptyList<String>()
|
||||
|
||||
val DefaultAttachmentsDownloadDirectory: File = File(System.getProperty("java.io.tmpdir"), "eInvoices").also { it.mkdirs() }
|
||||
}
|
||||
|
||||
|
||||
val minMessageDate: Instant? by lazy { downloadOnlyMessagesNewerThan?.atStartOfDay(ZoneId.systemDefault())?.toInstant() }
|
||||
|
||||
fun emailReceived(email: Email) {
|
||||
onEmailReceived?.invoke(email)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package net.codinux.invoicing.email
|
||||
|
||||
import net.codinux.invoicing.email.model.Email
|
||||
|
||||
data class FetchEmailsResult(
|
||||
val emails: List<Email>,
|
||||
val overallError: Throwable?,
|
||||
val messageSpecificErrors: List<FetchEmailError> = emptyList()
|
||||
)
|
|
@ -0,0 +1,45 @@
|
|||
package net.codinux.invoicing.email
|
||||
|
||||
import jakarta.mail.BodyPart
|
||||
import jakarta.mail.Message
|
||||
import jakarta.mail.Part
|
||||
import net.codinux.invoicing.email.model.EmailAccount
|
||||
import net.codinux.invoicing.filesystem.FileUtil
|
||||
import org.eclipse.angus.mail.imap.IMAPFolder
|
||||
import java.io.File
|
||||
|
||||
data class FetchEmailsStatus(
|
||||
val account: EmailAccount,
|
||||
val folder: IMAPFolder,
|
||||
val options: FetchEmailsOptions,
|
||||
val messageSpecificErrors: MutableList<FetchEmailError> = mutableListOf()
|
||||
) {
|
||||
|
||||
val userAttachmentsDownloadDirectory: File by lazy {
|
||||
val userDirName = FileUtil.removeIllegalFileCharacters(account.username)
|
||||
|
||||
File(options.attachmentsDownloadDirectory, userDirName).also { it.mkdirs() }
|
||||
}
|
||||
|
||||
|
||||
fun addError(type: FetchEmailErrorType, messageId: Long?, error: Throwable) =
|
||||
addError(FetchEmailError(type, messageId, error))
|
||||
|
||||
fun addError(error: FetchEmailError) {
|
||||
messageSpecificErrors.add(error)
|
||||
|
||||
options.onError?.invoke(error)
|
||||
}
|
||||
|
||||
private fun getMessage(part: Part): Message? {
|
||||
if (part is Message) {
|
||||
return part
|
||||
}
|
||||
|
||||
(part as? BodyPart)?.parent?.parent?.let { parent ->
|
||||
return getMessage(parent)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package net.codinux.invoicing.email
|
||||
|
||||
import net.codinux.invoicing.email.model.Email
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
open class ListenForNewMailsOptions(
|
||||
val stopListening: AtomicBoolean = AtomicBoolean(false),
|
||||
|
||||
downloadMessageBody: Boolean = true,
|
||||
downloadOnlyPlainTextOrHtmlMessageBody: Boolean = true,
|
||||
|
||||
downloadAttachmentsWithExtensions: List<String> = DefaultDownloadedAttachmentsWithExtensions,
|
||||
attachmentsDownloadDirectory: File = DefaultAttachmentsDownloadDirectory,
|
||||
|
||||
emailFolderName: String = "INBOX",
|
||||
connectTimeoutSeconds: Int = 5,
|
||||
|
||||
onError: ((FetchEmailError) -> Unit)? = null,
|
||||
onEmailReceived: (Email) -> Unit
|
||||
) : FetchEmailsOptions(
|
||||
null,
|
||||
downloadMessageBody, downloadOnlyPlainTextOrHtmlMessageBody, null,
|
||||
downloadAttachmentsWithExtensions, attachmentsDownloadDirectory,
|
||||
emailFolderName, connectTimeoutSeconds, onError, onEmailReceived
|
||||
)
|
|
@ -0,0 +1,11 @@
|
|||
package net.codinux.invoicing.email.model
|
||||
|
||||
enum class ContentDisposition {
|
||||
Body,
|
||||
|
||||
Inline,
|
||||
|
||||
Attachment,
|
||||
|
||||
Unknown
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
package net.codinux.invoicing.email.model
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
class Email(
|
||||
/**
|
||||
* Unique identifier of the message, used as identifier to retrieve further data of this message or as identifier
|
||||
* of the last downloaded message (to continue fetching emails at messages newer than the message with this id).
|
||||
*
|
||||
* Actually the IMAP UID, but the user should not care which ID this value refers to.
|
||||
* (messageUID: Unique identifier of a message in an email account. Survives e.g. expunging and moving the message to a different folder. This value.
|
||||
* messageNumber: Non stable number. Message numbers e.g. change on expunge.
|
||||
* messageId: Long string that is reference in inReplyTo field to identify to which message this message is a response of. Not of interest for us.
|
||||
*/
|
||||
val messageId: Long,
|
||||
|
||||
val sender: EmailAddress?,
|
||||
val subject: String,
|
||||
val date: Instant,
|
||||
|
||||
val to: List<EmailAddress>,
|
||||
val cc: List<EmailAddress> = emptyList(),
|
||||
val bcc: List<EmailAddress> = emptyList(),
|
||||
val replayTo: EmailAddress? = null,
|
||||
|
||||
val plainTextBody: String? = null,
|
||||
val htmlBody: String? = null,
|
||||
|
||||
val contentLanguage: String? = null,
|
||||
val isEncrypted: Boolean = false,
|
||||
|
||||
val attachments: List<EmailAttachment> = emptyList()
|
||||
) {
|
||||
val plainTextOrHtmlBody: String? by lazy { plainTextBody ?: htmlBody }
|
||||
|
||||
val hasAttachments: Boolean by lazy { attachments.isNotEmpty() }
|
||||
|
||||
val hasEInvoiceAttachment: Boolean by lazy { attachments.any { it.containsEInvoice } }
|
||||
|
||||
val hasPdfAttachment: Boolean by lazy { attachments.any { it.isPdfFile } }
|
||||
|
||||
|
||||
override fun toString() = "${date.atZone(ZoneId.systemDefault()).toLocalDate()} $sender: $subject, ${attachments.size} attachment(s)"
|
||||
}
|
|
@ -1,14 +1,14 @@
|
|||
package net.codinux.invoicing.mail
|
||||
package net.codinux.invoicing.email.model
|
||||
|
||||
class MailAccount(
|
||||
class EmailAccount(
|
||||
val username: String,
|
||||
val password: String,
|
||||
/**
|
||||
* For reading mails the IMAP server address, for sending mails the SMTP server address.
|
||||
* For fetching emails the IMAP server address, for sending emails the SMTP server address.
|
||||
*/
|
||||
val serverAddress: String,
|
||||
/**
|
||||
* Even though not mandatory it's better to specify the port, otherwise default port is tried.
|
||||
* Even though not mandatory it's better to specify the port, otherwise default port (993 for IMAP, 587 for SMTP) is used.
|
||||
*/
|
||||
val port: Int? = null
|
||||
) {
|
|
@ -0,0 +1,16 @@
|
|||
package net.codinux.invoicing.email.model
|
||||
|
||||
class EmailAddress(
|
||||
/**
|
||||
* The email address, like "a@b.com"
|
||||
*/
|
||||
val address: String,
|
||||
/**
|
||||
* Sender or recipient's name like "Mahatma Gandhi"
|
||||
*/
|
||||
val name: String? = null
|
||||
) {
|
||||
override fun toString() =
|
||||
if (name == null) address
|
||||
else "$name <$address>"
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package net.codinux.invoicing.email.model
|
||||
|
||||
import net.codinux.invoicing.model.Invoice
|
||||
import java.io.File
|
||||
|
||||
class EmailAttachment(
|
||||
val filename: String,
|
||||
val extension: String,
|
||||
val size: Int?,
|
||||
val disposition: ContentDisposition,
|
||||
/**
|
||||
* Attachment's media type like "application/xml", "application/pdf", ...
|
||||
*
|
||||
* Should always be non-null, but can theoretically be null.
|
||||
*/
|
||||
val mediaType: String?,
|
||||
val contentType: String?,
|
||||
val invoice: Invoice? = null,
|
||||
val file: File? = null
|
||||
) {
|
||||
val containsEInvoice: Boolean by lazy { invoice != null }
|
||||
|
||||
val isPdfFile: Boolean by lazy { extension == "pdf" || mediaType == "application/pdf" }
|
||||
|
||||
override fun toString() = "$filename: $invoice"
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package net.codinux.invoicing.filesystem
|
||||
|
||||
object FileUtil {
|
||||
|
||||
private val IllegalFileCharacters = listOf('\\', '/', ':', '*', '?', '"', '<', '>', '|', '\u0000')
|
||||
|
||||
|
||||
fun removeIllegalFileCharacters(name: String, replacementChar: Char = '_') = name
|
||||
.map { if (it in IllegalFileCharacters || it.code < 32) replacementChar else it }
|
||||
.joinToString("")
|
||||
|
||||
}
|
|
@ -35,9 +35,13 @@ open class FilesystemInvoiceReader(
|
|||
val extension = file.extension.lowercase()
|
||||
|
||||
if (extension == "pdf") {
|
||||
eInvoiceReader.extractFromPdf(file.inputStream())
|
||||
file.inputStream().use { inputStream ->
|
||||
eInvoiceReader.extractFromPdf(inputStream)
|
||||
}
|
||||
} else if (extension == "xml") {
|
||||
eInvoiceReader.extractFromXml(file.inputStream())
|
||||
file.inputStream().use { inputStream ->
|
||||
eInvoiceReader.extractFromXml(inputStream)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
package net.codinux.invoicing.mail
|
||||
|
||||
import net.codinux.invoicing.model.Invoice
|
||||
import java.io.File
|
||||
|
||||
class MailAttachmentWithEInvoice(
|
||||
val filename: 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
|
||||
) {
|
||||
override fun toString() = "$filename: $invoice"
|
||||
}
|
|
@ -1,268 +0,0 @@
|
|||
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
|
||||
|
||||
open class MailReader(
|
||||
protected open val eInvoiceReader: EInvoiceReader = EInvoiceReader()
|
||||
) {
|
||||
|
||||
protected data class MessagePart(
|
||||
val mediaType: String,
|
||||
val part: Part
|
||||
)
|
||||
|
||||
|
||||
protected open val mailDispatcher = Executors.newFixedThreadPool(max(24, Runtime.getRuntime().availableProcessors() * 4)).asCoroutineDispatcher()
|
||||
|
||||
protected val log by logger()
|
||||
|
||||
|
||||
open 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}'" }
|
||||
}
|
||||
|
||||
protected open 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
open fun listAllMessagesWithEInvoice(account: MailAccount, downloadMessageBody: Boolean = false, emailFolderName: String = "INBOX"): List<MailWithInvoice> {
|
||||
try {
|
||||
return 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()
|
||||
}
|
||||
|
||||
protected open 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()
|
||||
}
|
||||
|
||||
protected open 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
|
||||
}
|
||||
|
||||
protected open 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
|
||||
}
|
||||
|
||||
protected open 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
|
||||
}
|
||||
|
||||
|
||||
protected open 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
|
||||
*/
|
||||
protected open fun getMediaType(part: Part): String? = part.contentType?.lowercase()?.let { contentType ->
|
||||
val indexOfSeparator = contentType.indexOf(';')
|
||||
|
||||
if (indexOfSeparator > -1) {
|
||||
contentType.substring(0, indexOfSeparator)
|
||||
} else {
|
||||
contentType
|
||||
}
|
||||
}
|
||||
|
||||
protected open fun getPlainTextBody(parts: Collection<MessagePart>) = getBodyWithMediaType(parts, "text/plain")
|
||||
|
||||
protected open fun getHtmlBody(parts: Collection<MessagePart>) = getBodyWithMediaType(parts, "text/html")
|
||||
|
||||
protected open 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
|
||||
}
|
||||
|
||||
protected open fun map(date: Date): Instant =
|
||||
date.toInstant()
|
||||
|
||||
|
||||
protected open 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)
|
||||
}
|
||||
}
|
||||
|
||||
protected open 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")
|
||||
}
|
||||
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
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)"
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
package net.codinux.invoicing.model
|
||||
|
||||
enum class EInvoiceXmlFormat {
|
||||
/**
|
||||
* Factur-X is equal / synonym to ZUGFeRD 2
|
||||
*/
|
||||
FacturX,
|
||||
|
||||
XRechnung
|
||||
}
|
|
@ -2,6 +2,7 @@ package net.codinux.invoicing.reader
|
|||
|
||||
import net.codinux.invoicing.mapper.MustangMapper
|
||||
import net.codinux.invoicing.model.Invoice
|
||||
import net.codinux.log.logger
|
||||
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
@ -16,11 +17,19 @@ open class EInvoiceReader(
|
|||
)
|
||||
}
|
||||
|
||||
private val log by logger()
|
||||
|
||||
open fun extractFromXml(xmlFile: File) = extractFromXml(xmlFile.inputStream())
|
||||
|
||||
open fun extractFromXmlOrNull(xmlFile: File) = orNull { extractFromXml(xmlFile) }
|
||||
|
||||
open fun extractFromXml(xmlFile: File) = xmlFile.inputStream().use { extractFromXml(it) }
|
||||
|
||||
open fun extractFromXmlOrNull(stream: InputStream) = orNull { extractFromXml(stream) }
|
||||
|
||||
open fun extractFromXml(stream: InputStream) = extractFromXml(stream.reader().readText())
|
||||
|
||||
open fun extractFromXmlOrNull(xml: String) = orNull { extractFromXml(xml) }
|
||||
|
||||
open fun extractFromXml(xml: String): Invoice {
|
||||
val importer = ZUGFeRDInvoiceImporter() // XRechnungImporter only reads properties but not to a Invoice object
|
||||
importer.fromXML(xml)
|
||||
|
@ -28,7 +37,12 @@ open class EInvoiceReader(
|
|||
return extractInvoice(importer)
|
||||
}
|
||||
|
||||
open fun extractFromPdf(pdfFile: File) = extractFromPdf(pdfFile.inputStream())
|
||||
|
||||
open fun extractFromPdfOrNull(pdfFile: File) = orNull { extractFromPdf(pdfFile) }
|
||||
|
||||
open fun extractFromPdf(pdfFile: File) = pdfFile.inputStream().use { extractFromPdf(it) }
|
||||
|
||||
open fun extractFromPdfOrNull(stream: InputStream) = orNull { extractFromPdf(stream) }
|
||||
|
||||
open fun extractFromPdf(stream: InputStream): Invoice {
|
||||
val importer = ZUGFeRDInvoiceImporter(stream)
|
||||
|
@ -36,7 +50,12 @@ open class EInvoiceReader(
|
|||
return extractInvoice(importer)
|
||||
}
|
||||
|
||||
open fun extractXmlFromPdf(pdfFile: File) = extractXmlFromPdf(pdfFile.inputStream())
|
||||
|
||||
open fun extractXmlFromPdfOrNull(pdfFile: File) = orNull { extractXmlFromPdf(pdfFile) }
|
||||
|
||||
open fun extractXmlFromPdf(pdfFile: File) = pdfFile.inputStream().use { extractXmlFromPdf(it) }
|
||||
|
||||
open fun extractXmlFromPdfOrNull(stream: InputStream) = orNull { extractXmlFromPdf(stream) }
|
||||
|
||||
open fun extractXmlFromPdf(stream: InputStream): String {
|
||||
val importer = ZUGFeRDInvoiceImporter(stream)
|
||||
|
@ -58,4 +77,13 @@ open class EInvoiceReader(
|
|||
return mapper.mapToInvoice(invoice)
|
||||
}
|
||||
|
||||
|
||||
protected open fun <T> orNull(action: () -> T): T? =
|
||||
try {
|
||||
action()
|
||||
} catch (e: Throwable) {
|
||||
log.debug(e) { "Action caused an exception, but orNull() was called" }
|
||||
null
|
||||
}
|
||||
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
package net.codinux.invoicing
|
||||
|
||||
import net.codinux.invoicing.creation.EInvoiceCreator
|
||||
import net.codinux.invoicing.mail.MailAccount
|
||||
import net.codinux.invoicing.mail.MailReader
|
||||
import net.codinux.invoicing.email.model.EmailAccount
|
||||
import net.codinux.invoicing.email.EmailsFetcher
|
||||
import net.codinux.invoicing.model.EInvoiceXmlFormat
|
||||
import net.codinux.invoicing.model.Invoice
|
||||
import net.codinux.invoicing.model.InvoiceItem
|
||||
import net.codinux.invoicing.model.Party
|
||||
|
@ -24,15 +25,19 @@ class Demonstration {
|
|||
val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
|
||||
}
|
||||
|
||||
fun fromMail() {
|
||||
val mailReader = MailReader()
|
||||
fun fromEmail() {
|
||||
val emailsFetcher = EmailsFetcher()
|
||||
|
||||
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount(
|
||||
username = "", // your mail account username
|
||||
password = "", // your mail account username
|
||||
val fetchResult = emailsFetcher.fetchAllEmails(EmailAccount(
|
||||
username = "", // your email account username
|
||||
password = "", // your email account username
|
||||
serverAddress = "", // IMAP server address
|
||||
port = null // IMAP server port, leave null if default port 993
|
||||
port = null // IMAP server port, can be left null for default port 993
|
||||
))
|
||||
|
||||
fetchResult.emails.forEach { email ->
|
||||
println("${email.sender}: ${email.attachments.firstNotNullOfOrNull { it.invoice }?.totalAmounts?.duePayableAmount}")
|
||||
}
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
|
@ -53,7 +58,7 @@ class Demonstration {
|
|||
val creator = EInvoiceCreator()
|
||||
|
||||
// create a PDF that also contains the eInvoice as XML attachment
|
||||
creator.createFacturXPdf(invoice, pdfResultFile)
|
||||
creator.createPdfWithAttachedXml(invoice, pdfResultFile)
|
||||
|
||||
// create only the XML file
|
||||
val xml = creator.createFacturXXml(invoice)
|
||||
|
@ -72,9 +77,9 @@ class Demonstration {
|
|||
creator.attachInvoiceXmlToPdf(invoice, existingPdf, output)
|
||||
|
||||
// or if you already have the invoice XML:
|
||||
val invoiceXml: String = "..." // e.g. creator.createZugferdXml(invoice)
|
||||
val invoiceXml = creator.createXRechnungXml(invoice) // or creator.createZugferdXml(invoice), ...
|
||||
|
||||
creator.attachInvoiceXmlToPdf(invoiceXml, existingPdf, output)
|
||||
creator.attachInvoiceXmlToPdf(invoiceXml, EInvoiceXmlFormat.XRechnung, existingPdf, output)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package net.codinux.invoicing.creation
|
||||
|
||||
import net.codinux.invoicing.model.EInvoiceXmlFormat
|
||||
import net.codinux.invoicing.test.DataGenerator
|
||||
import net.codinux.invoicing.test.InvoiceAsserter
|
||||
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
|
||||
|
@ -30,13 +31,26 @@ class EInvoiceCreatorTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun createFacturXPdf() {
|
||||
fun createPdfWithAttachedXml_FacturX() {
|
||||
val invoice = createInvoice()
|
||||
val testFile = File.createTempFile("Zugferd", ".pdf")
|
||||
|
||||
underTest.createFacturXPdf(invoice, testFile)
|
||||
underTest.createPdfWithAttachedXml(invoice, testFile, EInvoiceXmlFormat.FacturX)
|
||||
|
||||
val importer = ZUGFeRDInvoiceImporter(testFile.inputStream())
|
||||
val importer = testFile.inputStream().use { ZUGFeRDInvoiceImporter(it) }
|
||||
val xml = String(importer.rawXML, Charsets.UTF_8)
|
||||
|
||||
assertInvoiceXml(xml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createPdfWithAttachedXml_XRechnung() {
|
||||
val invoice = createInvoice()
|
||||
val testFile = File.createTempFile("Zugferd", ".pdf")
|
||||
|
||||
underTest.createPdfWithAttachedXml(invoice, testFile, EInvoiceXmlFormat.XRechnung)
|
||||
|
||||
val importer = testFile.inputStream().use { ZUGFeRDInvoiceImporter(it) }
|
||||
val xml = String(importer.rawXML, Charsets.UTF_8)
|
||||
|
||||
assertInvoiceXml(xml)
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
package net.codinux.invoicing.email
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isNotEmpty
|
||||
import assertk.assertions.isNull
|
||||
import net.codinux.invoicing.email.model.EmailAccount
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.Ignore
|
||||
|
||||
@Ignore // not an automatic test, set your email account settings below
|
||||
class EmailsFetcherTest {
|
||||
|
||||
companion object {
|
||||
// specify your email account here
|
||||
private val emailAccount = EmailAccount(
|
||||
username = "",
|
||||
password = "",
|
||||
serverAddress = "",
|
||||
port = null // IMAP server port, can be left null for default port 993
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private val underTest = EmailsFetcher()
|
||||
|
||||
|
||||
@Test
|
||||
fun fetchAllEmails() {
|
||||
val result = underTest.fetchAllEmails(emailAccount, FetchEmailsOptions(downloadMessageBody = true))
|
||||
|
||||
assertThat(result.overallError).isNull()
|
||||
assertThat(result.emails).isNotEmpty()
|
||||
|
||||
val emailsWithoutBody = result.emails.filter { it.plainTextOrHtmlBody == null && it.isEncrypted == false }
|
||||
assertThat(emailsWithoutBody).isEmpty()
|
||||
}
|
||||
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
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
|
||||
|
||||
@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, true)
|
||||
|
||||
assertThat(result).isNotEmpty()
|
||||
|
||||
val messagesWithoutBody = result.filter { it.plainTextOrHtmlBody == null }
|
||||
assertThat(messagesWithoutBody).isEmpty()
|
||||
}
|
||||
|
||||
}
|
|
@ -21,4 +21,12 @@
|
|||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
|
||||
<!-- Apache FOP will flood otherwise the log so that test run crashes -->
|
||||
<logger name="org.apache.fop" level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</logger>
|
||||
<logger name="org.apache.xmlgraphics.image.loader.spi.ImageImplRegistry" level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</logger>
|
||||
|
||||
</configuration>
|
|
@ -1,5 +1,7 @@
|
|||
kotlin.code.style=official
|
||||
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
|
||||
org.gradle.parallel=true
|
||||
|
||||
|
||||
|
|
Loading…
Reference in New Issue