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
|
### Find all invoices of an IMAP email account
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
val mailReader = MailReader()
|
val emailsFetcher = EmailsFetcher()
|
||||||
|
|
||||||
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount(
|
val fetchResult = emailsFetcher.fetchAllEmails(EmailAccount(
|
||||||
username = "", // your mail account username
|
username = "", // your email account username
|
||||||
password = "", // your mail account username
|
password = "", // your email account username
|
||||||
serverAddress = "", // IMAP server address
|
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
|
### Validate eInvoice
|
||||||
|
@ -54,7 +58,7 @@ fun create() {
|
||||||
val creator = EInvoiceCreator()
|
val creator = EInvoiceCreator()
|
||||||
|
|
||||||
// create a PDF that also contains the eInvoice as XML attachment
|
// create a PDF that also contains the eInvoice as XML attachment
|
||||||
creator.createFacturXPdf(invoice, pdfResultFile)
|
creator.createPdfWithAttachedXml(invoice, pdfResultFile)
|
||||||
|
|
||||||
// create only the XML file
|
// create only the XML file
|
||||||
val xml = creator.createFacturXXml(invoice)
|
val xml = creator.createFacturXXml(invoice)
|
||||||
|
@ -84,7 +88,7 @@ val creator = EInvoiceCreator()
|
||||||
creator.attachInvoiceXmlToPdf(invoice, existingPdf, output)
|
creator.attachInvoiceXmlToPdf(invoice, existingPdf, output)
|
||||||
|
|
||||||
// or if you already have the invoice XML:
|
// 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 {
|
fun createFacturXPdf(invoice: Invoice): Path {
|
||||||
val resultFile = createTempPdfFile()
|
val resultFile = createTempPdfFile()
|
||||||
|
|
||||||
creator.createFacturXPdf(invoice, resultFile.toFile())
|
creator.createPdfWithAttachedXml(invoice, resultFile.toFile())
|
||||||
|
|
||||||
return resultFile
|
return resultFile
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,8 +65,10 @@ open class EInvoiceConverter {
|
||||||
protected open fun createXRechnungXml(invoice: Invoice): String = EInvoiceCreator().createXRechnungXml(invoice)
|
protected open fun createXRechnungXml(invoice: Invoice): String = EInvoiceCreator().createXRechnungXml(invoice)
|
||||||
|
|
||||||
protected open fun copyResource(resourceName: String, outputFile: File, outputFileExtension: String) {
|
protected open fun copyResource(resourceName: String, outputFile: File, outputFileExtension: String) {
|
||||||
javaClass.classLoader.getResourceAsStream(resourceName).use {
|
javaClass.classLoader.getResourceAsStream(resourceName).use { inputStream ->
|
||||||
it?.copyTo(File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream())
|
File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream().use { outputStream ->
|
||||||
|
inputStream?.copyTo(outputStream)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,73 +1,33 @@
|
||||||
package net.codinux.invoicing.creation
|
package net.codinux.invoicing.creation
|
||||||
|
|
||||||
import net.codinux.invoicing.mapper.MustangMapper
|
import net.codinux.invoicing.mapper.MustangMapper
|
||||||
|
import net.codinux.invoicing.model.EInvoiceXmlFormat
|
||||||
import net.codinux.invoicing.model.Invoice
|
import net.codinux.invoicing.model.Invoice
|
||||||
import org.mustangproject.ZUGFeRD.*
|
import org.mustangproject.ZUGFeRD.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
open class EInvoiceCreator(
|
open class EInvoiceCreator(
|
||||||
protected open val mapper: MustangMapper = MustangMapper()
|
protected open val mapper: MustangMapper = MustangMapper()
|
||||||
) {
|
) {
|
||||||
|
|
||||||
open fun createXRechnungXml(invoice: Invoice): String {
|
open fun createXRechnungXml(invoice: Invoice) = createXml(invoice, EInvoiceXmlFormat.XRechnung)
|
||||||
val provider = ZUGFeRD2PullProvider()
|
|
||||||
provider.profile = Profiles.getByName("XRechnung")
|
|
||||||
|
|
||||||
return createXml(provider, invoice)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synonym for [createFacturXXml] (ZUGFeRD 2 is a synonym for Factur-X).
|
* Synonym for [createFacturXXml] (ZUGFeRD 2 is a synonym for Factur-X).
|
||||||
*/
|
*/
|
||||||
open fun createZugferdXml(invoice: Invoice) = createFacturXXml(invoice)
|
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()
|
val exporter = ZUGFeRDExporterFromA3()
|
||||||
.setProfile("EN16931") // required for XML?
|
.setProfile(getProfileNameForFormat(format))
|
||||||
|
|
||||||
return createXml(exporter.provider, invoice)
|
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 {
|
protected open fun createXml(provider: IXMLProvider, invoice: Invoice): String {
|
||||||
val transaction = mapper.mapToTransaction(invoice)
|
val transaction = mapper.mapToTransaction(invoice)
|
||||||
|
|
||||||
|
@ -76,4 +36,80 @@ open class EInvoiceCreator(
|
||||||
return String(provider.xml, Charsets.UTF_8)
|
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 username: String,
|
||||||
val password: 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,
|
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
|
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()
|
val extension = file.extension.lowercase()
|
||||||
|
|
||||||
if (extension == "pdf") {
|
if (extension == "pdf") {
|
||||||
eInvoiceReader.extractFromPdf(file.inputStream())
|
file.inputStream().use { inputStream ->
|
||||||
|
eInvoiceReader.extractFromPdf(inputStream)
|
||||||
|
}
|
||||||
} else if (extension == "xml") {
|
} else if (extension == "xml") {
|
||||||
eInvoiceReader.extractFromXml(file.inputStream())
|
file.inputStream().use { inputStream ->
|
||||||
|
eInvoiceReader.extractFromXml(inputStream)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
null
|
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.mapper.MustangMapper
|
||||||
import net.codinux.invoicing.model.Invoice
|
import net.codinux.invoicing.model.Invoice
|
||||||
|
import net.codinux.log.logger
|
||||||
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
|
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
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 extractFromXml(stream: InputStream) = extractFromXml(stream.reader().readText())
|
||||||
|
|
||||||
|
open fun extractFromXmlOrNull(xml: String) = orNull { extractFromXml(xml) }
|
||||||
|
|
||||||
open fun extractFromXml(xml: String): Invoice {
|
open fun extractFromXml(xml: String): Invoice {
|
||||||
val importer = ZUGFeRDInvoiceImporter() // XRechnungImporter only reads properties but not to a Invoice object
|
val importer = ZUGFeRDInvoiceImporter() // XRechnungImporter only reads properties but not to a Invoice object
|
||||||
importer.fromXML(xml)
|
importer.fromXML(xml)
|
||||||
|
@ -28,7 +37,12 @@ open class EInvoiceReader(
|
||||||
return extractInvoice(importer)
|
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 {
|
open fun extractFromPdf(stream: InputStream): Invoice {
|
||||||
val importer = ZUGFeRDInvoiceImporter(stream)
|
val importer = ZUGFeRDInvoiceImporter(stream)
|
||||||
|
@ -36,7 +50,12 @@ open class EInvoiceReader(
|
||||||
return extractInvoice(importer)
|
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 {
|
open fun extractXmlFromPdf(stream: InputStream): String {
|
||||||
val importer = ZUGFeRDInvoiceImporter(stream)
|
val importer = ZUGFeRDInvoiceImporter(stream)
|
||||||
|
@ -58,4 +77,13 @@ open class EInvoiceReader(
|
||||||
return mapper.mapToInvoice(invoice)
|
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
|
package net.codinux.invoicing
|
||||||
|
|
||||||
import net.codinux.invoicing.creation.EInvoiceCreator
|
import net.codinux.invoicing.creation.EInvoiceCreator
|
||||||
import net.codinux.invoicing.mail.MailAccount
|
import net.codinux.invoicing.email.model.EmailAccount
|
||||||
import net.codinux.invoicing.mail.MailReader
|
import net.codinux.invoicing.email.EmailsFetcher
|
||||||
|
import net.codinux.invoicing.model.EInvoiceXmlFormat
|
||||||
import net.codinux.invoicing.model.Invoice
|
import net.codinux.invoicing.model.Invoice
|
||||||
import net.codinux.invoicing.model.InvoiceItem
|
import net.codinux.invoicing.model.InvoiceItem
|
||||||
import net.codinux.invoicing.model.Party
|
import net.codinux.invoicing.model.Party
|
||||||
|
@ -24,15 +25,19 @@ class Demonstration {
|
||||||
val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
|
val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fromMail() {
|
fun fromEmail() {
|
||||||
val mailReader = MailReader()
|
val emailsFetcher = EmailsFetcher()
|
||||||
|
|
||||||
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount(
|
val fetchResult = emailsFetcher.fetchAllEmails(EmailAccount(
|
||||||
username = "", // your mail account username
|
username = "", // your email account username
|
||||||
password = "", // your mail account username
|
password = "", // your email account username
|
||||||
serverAddress = "", // IMAP server address
|
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() {
|
fun validate() {
|
||||||
|
@ -53,7 +58,7 @@ class Demonstration {
|
||||||
val creator = EInvoiceCreator()
|
val creator = EInvoiceCreator()
|
||||||
|
|
||||||
// create a PDF that also contains the eInvoice as XML attachment
|
// create a PDF that also contains the eInvoice as XML attachment
|
||||||
creator.createFacturXPdf(invoice, pdfResultFile)
|
creator.createPdfWithAttachedXml(invoice, pdfResultFile)
|
||||||
|
|
||||||
// create only the XML file
|
// create only the XML file
|
||||||
val xml = creator.createFacturXXml(invoice)
|
val xml = creator.createFacturXXml(invoice)
|
||||||
|
@ -72,9 +77,9 @@ class Demonstration {
|
||||||
creator.attachInvoiceXmlToPdf(invoice, existingPdf, output)
|
creator.attachInvoiceXmlToPdf(invoice, existingPdf, output)
|
||||||
|
|
||||||
// or if you already have the invoice XML:
|
// 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
|
package net.codinux.invoicing.creation
|
||||||
|
|
||||||
|
import net.codinux.invoicing.model.EInvoiceXmlFormat
|
||||||
import net.codinux.invoicing.test.DataGenerator
|
import net.codinux.invoicing.test.DataGenerator
|
||||||
import net.codinux.invoicing.test.InvoiceAsserter
|
import net.codinux.invoicing.test.InvoiceAsserter
|
||||||
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
|
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
|
||||||
|
@ -30,13 +31,26 @@ class EInvoiceCreatorTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun createFacturXPdf() {
|
fun createPdfWithAttachedXml_FacturX() {
|
||||||
val invoice = createInvoice()
|
val invoice = createInvoice()
|
||||||
val testFile = File.createTempFile("Zugferd", ".pdf")
|
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)
|
val xml = String(importer.rawXML, Charsets.UTF_8)
|
||||||
|
|
||||||
assertInvoiceXml(xml)
|
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"/>
|
<appender-ref ref="STDOUT"/>
|
||||||
</root>
|
</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>
|
</configuration>
|
|
@ -1,5 +1,7 @@
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
org.gradle.jvmargs=-Xmx4G
|
||||||
|
|
||||||
org.gradle.parallel=true
|
org.gradle.parallel=true
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue