Compare commits
No commits in common. "52bf9daa7d2296ed0ffc9fef7f2cd2b80a4fdcba" and "6aaecd16569ae27cbacc12e61ed2df047f06b004" have entirely different histories.
52bf9daa7d
...
6aaecd1656
20
README.md
20
README.md
|
@ -22,18 +22,14 @@ 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 emailsFetcher = EmailsFetcher()
|
val mailReader = MailReader()
|
||||||
|
|
||||||
val fetchResult = emailsFetcher.fetchAllEmails(EmailAccount(
|
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount(
|
||||||
username = "", // your email account username
|
username = "", // your mail account username
|
||||||
password = "", // your email account username
|
password = "", // your mail account username
|
||||||
serverAddress = "", // IMAP server address
|
serverAddress = "", // IMAP server address
|
||||||
port = null // IMAP server port, can be left null for default port 993
|
port = null // IMAP server port, leave null if default port 993
|
||||||
))
|
))
|
||||||
|
|
||||||
fetchResult.emails.forEach { email ->
|
|
||||||
println("${email.sender}: ${email.attachments.firstNotNullOfOrNull { it.invoice }?.totalAmounts?.duePayableAmount}")
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Validate eInvoice
|
### Validate eInvoice
|
||||||
|
@ -58,7 +54,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.createPdfWithAttachedXml(invoice, pdfResultFile)
|
creator.createFacturXPdf(invoice, pdfResultFile)
|
||||||
|
|
||||||
// create only the XML file
|
// create only the XML file
|
||||||
val xml = creator.createFacturXXml(invoice)
|
val xml = creator.createFacturXXml(invoice)
|
||||||
|
@ -88,7 +84,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 = creator.createXRechnungXml(invoice) // or creator.createZugferdXml(invoice), ...
|
val invoiceXml: String = "..." // e.g. creator.createZugferdXml(invoice)
|
||||||
|
|
||||||
creator.attachInvoiceXmlToPdf(invoiceXml, EInvoiceXmlFormat.XRechnung, existingPdf, output)
|
creator.attachInvoiceXmlToPdf(invoiceXml, 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.createPdfWithAttachedXml(invoice, resultFile.toFile())
|
creator.createFacturXPdf(invoice, resultFile.toFile())
|
||||||
|
|
||||||
return resultFile
|
return resultFile
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,10 +65,8 @@ 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 { inputStream ->
|
javaClass.classLoader.getResourceAsStream(resourceName).use {
|
||||||
File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream().use { outputStream ->
|
it?.copyTo(File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream())
|
||||||
inputStream?.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,33 +1,73 @@
|
||||||
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) = createXml(invoice, EInvoiceXmlFormat.XRechnung)
|
open fun createXRechnungXml(invoice: Invoice): String {
|
||||||
|
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) = createXml(invoice, EInvoiceXmlFormat.FacturX)
|
open fun createFacturXXml(invoice: Invoice): String {
|
||||||
|
|
||||||
protected open fun createXml(invoice: Invoice, format: EInvoiceXmlFormat): String {
|
|
||||||
val exporter = ZUGFeRDExporterFromA3()
|
val exporter = ZUGFeRDExporterFromA3()
|
||||||
.setProfile(getProfileNameForFormat(format))
|
.setProfile("EN16931") // required for XML?
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
@ -36,80 +76,4 @@ 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
|
@ -1,357 +0,0 @@
|
||||||
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.
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
package net.codinux.invoicing.email
|
|
||||||
|
|
||||||
data class FetchEmailError(
|
|
||||||
val type: FetchEmailErrorType,
|
|
||||||
val messageId: Long?,
|
|
||||||
val error: Throwable
|
|
||||||
)
|
|
|
@ -1,13 +0,0 @@
|
||||||
package net.codinux.invoicing.email
|
|
||||||
|
|
||||||
enum class FetchEmailErrorType {
|
|
||||||
GetEmail,
|
|
||||||
|
|
||||||
GetMesssageBody,
|
|
||||||
|
|
||||||
GetAttachment,
|
|
||||||
|
|
||||||
ExtractInvoice,
|
|
||||||
|
|
||||||
ListenForNewEmails
|
|
||||||
}
|
|
|
@ -1,47 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
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()
|
|
||||||
)
|
|
|
@ -1,45 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
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
|
|
||||||
)
|
|
|
@ -1,11 +0,0 @@
|
||||||
package net.codinux.invoicing.email.model
|
|
||||||
|
|
||||||
enum class ContentDisposition {
|
|
||||||
Body,
|
|
||||||
|
|
||||||
Inline,
|
|
||||||
|
|
||||||
Attachment,
|
|
||||||
|
|
||||||
Unknown
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
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,16 +0,0 @@
|
||||||
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>"
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
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,13 +35,9 @@ open class FilesystemInvoiceReader(
|
||||||
val extension = file.extension.lowercase()
|
val extension = file.extension.lowercase()
|
||||||
|
|
||||||
if (extension == "pdf") {
|
if (extension == "pdf") {
|
||||||
file.inputStream().use { inputStream ->
|
eInvoiceReader.extractFromPdf(file.inputStream())
|
||||||
eInvoiceReader.extractFromPdf(inputStream)
|
|
||||||
}
|
|
||||||
} else if (extension == "xml") {
|
} else if (extension == "xml") {
|
||||||
file.inputStream().use { inputStream ->
|
eInvoiceReader.extractFromXml(file.inputStream())
|
||||||
eInvoiceReader.extractFromXml(inputStream)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
package net.codinux.invoicing.email.model
|
package net.codinux.invoicing.mail
|
||||||
|
|
||||||
class EmailAccount(
|
class MailAccount(
|
||||||
val username: String,
|
val username: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
/**
|
/**
|
||||||
* For fetching emails the IMAP server address, for sending emails the SMTP server address.
|
* For reading mails the IMAP server address, for sending mails the SMTP server address.
|
||||||
*/
|
*/
|
||||||
val serverAddress: String,
|
val serverAddress: String,
|
||||||
/**
|
/**
|
||||||
* Even though not mandatory it's better to specify the port, otherwise default port (993 for IMAP, 587 for SMTP) is used.
|
* Even though not mandatory it's better to specify the port, otherwise default port is tried.
|
||||||
*/
|
*/
|
||||||
val port: Int? = null
|
val port: Int? = null
|
||||||
) {
|
) {
|
|
@ -0,0 +1,18 @@
|
||||||
|
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"
|
||||||
|
}
|
|
@ -0,0 +1,268 @@
|
||||||
|
package net.codinux.invoicing.mail
|
||||||
|
|
||||||
|
import jakarta.mail.Folder
|
||||||
|
import jakarta.mail.Message
|
||||||
|
import jakarta.mail.Multipart
|
||||||
|
import jakarta.mail.Part
|
||||||
|
import jakarta.mail.Session
|
||||||
|
import jakarta.mail.Store
|
||||||
|
import jakarta.mail.event.MessageCountAdapter
|
||||||
|
import jakarta.mail.event.MessageCountEvent
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import net.codinux.invoicing.model.Invoice
|
||||||
|
import net.codinux.invoicing.reader.EInvoiceReader
|
||||||
|
import net.codinux.log.logger
|
||||||
|
import org.eclipse.angus.mail.imap.IMAPFolder
|
||||||
|
import java.io.File
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.*
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package net.codinux.invoicing.mail
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
class MailWithInvoice(
|
||||||
|
val sender: String?,
|
||||||
|
val subject: String,
|
||||||
|
val sent: Instant?,
|
||||||
|
val received: Instant,
|
||||||
|
/**
|
||||||
|
* From documentation of underlying mail library:
|
||||||
|
* "Since message numbers can change within a session if the folder is expunged, clients are advised not to use
|
||||||
|
* message numbers as references to messages."
|
||||||
|
*
|
||||||
|
* -> use with care. Message numbers are not valid / the same anymore after expunge.
|
||||||
|
*/
|
||||||
|
val messageNumber: Int,
|
||||||
|
val isEncrypted: Boolean = false,
|
||||||
|
val plainTextBody: String?,
|
||||||
|
val htmlBody: String?,
|
||||||
|
val attachmentsWithEInvoice: List<MailAttachmentWithEInvoice>
|
||||||
|
) {
|
||||||
|
val plainTextOrHtmlBody: String? by lazy { plainTextBody ?: htmlBody }
|
||||||
|
|
||||||
|
override fun toString() = "${(sent ?: received).atZone(ZoneId.systemDefault()).toLocalDate()} $sender: $subject, ${attachmentsWithEInvoice.size} invoice(s)"
|
||||||
|
}
|
|
@ -1,10 +0,0 @@
|
||||||
package net.codinux.invoicing.model
|
|
||||||
|
|
||||||
enum class EInvoiceXmlFormat {
|
|
||||||
/**
|
|
||||||
* Factur-X is equal / synonym to ZUGFeRD 2
|
|
||||||
*/
|
|
||||||
FacturX,
|
|
||||||
|
|
||||||
XRechnung
|
|
||||||
}
|
|
|
@ -2,7 +2,6 @@ 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
|
||||||
|
@ -17,19 +16,11 @@ 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)
|
||||||
|
@ -37,12 +28,7 @@ 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)
|
||||||
|
@ -50,12 +36,7 @@ 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)
|
||||||
|
@ -77,13 +58,4 @@ 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,9 +1,8 @@
|
||||||
package net.codinux.invoicing
|
package net.codinux.invoicing
|
||||||
|
|
||||||
import net.codinux.invoicing.creation.EInvoiceCreator
|
import net.codinux.invoicing.creation.EInvoiceCreator
|
||||||
import net.codinux.invoicing.email.model.EmailAccount
|
import net.codinux.invoicing.mail.MailAccount
|
||||||
import net.codinux.invoicing.email.EmailsFetcher
|
import net.codinux.invoicing.mail.MailReader
|
||||||
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
|
||||||
|
@ -25,19 +24,15 @@ class Demonstration {
|
||||||
val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
|
val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fromEmail() {
|
fun fromMail() {
|
||||||
val emailsFetcher = EmailsFetcher()
|
val mailReader = MailReader()
|
||||||
|
|
||||||
val fetchResult = emailsFetcher.fetchAllEmails(EmailAccount(
|
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount(
|
||||||
username = "", // your email account username
|
username = "", // your mail account username
|
||||||
password = "", // your email account username
|
password = "", // your mail account username
|
||||||
serverAddress = "", // IMAP server address
|
serverAddress = "", // IMAP server address
|
||||||
port = null // IMAP server port, can be left null for default port 993
|
port = null // IMAP server port, leave null if default port 993
|
||||||
))
|
))
|
||||||
|
|
||||||
fetchResult.emails.forEach { email ->
|
|
||||||
println("${email.sender}: ${email.attachments.firstNotNullOfOrNull { it.invoice }?.totalAmounts?.duePayableAmount}")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun validate() {
|
fun validate() {
|
||||||
|
@ -58,7 +53,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.createPdfWithAttachedXml(invoice, pdfResultFile)
|
creator.createFacturXPdf(invoice, pdfResultFile)
|
||||||
|
|
||||||
// create only the XML file
|
// create only the XML file
|
||||||
val xml = creator.createFacturXXml(invoice)
|
val xml = creator.createFacturXXml(invoice)
|
||||||
|
@ -77,9 +72,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 = creator.createXRechnungXml(invoice) // or creator.createZugferdXml(invoice), ...
|
val invoiceXml: String = "..." // e.g. creator.createZugferdXml(invoice)
|
||||||
|
|
||||||
creator.attachInvoiceXmlToPdf(invoiceXml, EInvoiceXmlFormat.XRechnung, existingPdf, output)
|
creator.attachInvoiceXmlToPdf(invoiceXml, existingPdf, output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
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
|
||||||
|
@ -31,26 +30,13 @@ class EInvoiceCreatorTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun createPdfWithAttachedXml_FacturX() {
|
fun createFacturXPdf() {
|
||||||
val invoice = createInvoice()
|
val invoice = createInvoice()
|
||||||
val testFile = File.createTempFile("Zugferd", ".pdf")
|
val testFile = File.createTempFile("Zugferd", ".pdf")
|
||||||
|
|
||||||
underTest.createPdfWithAttachedXml(invoice, testFile, EInvoiceXmlFormat.FacturX)
|
underTest.createFacturXPdf(invoice, testFile)
|
||||||
|
|
||||||
val importer = testFile.inputStream().use { ZUGFeRDInvoiceImporter(it) }
|
val importer = ZUGFeRDInvoiceImporter(testFile.inputStream())
|
||||||
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)
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
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,12 +21,4 @@
|
||||||
<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,7 +1,5 @@
|
||||||
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