Compare commits

...

56 Commits

Author SHA1 Message Date
dankito 52bf9daa7d Changed defaults for downloadMessageBody and downloadOnlyPlainTextOrHtmlMessageBody 2024-11-28 16:26:36 +01:00
dankito c516969cb0 Added properties to easy filter emails and attachments 2024-11-28 16:11:38 +01:00
dankito 8e461b43a5 Implemented option downloadOnlyMessagesNewerThan 2024-11-28 04:08:36 +01:00
dankito 7df4b944ee Fixed ensuring that extension is in lowercase 2024-11-28 03:53:27 +01:00
dankito f1f16e2e9e Using now messageId for FetchEmailError 2024-11-28 03:48:14 +01:00
dankito 54a5227fb7 Renamed FetchEmailsError to FetchEmailError 2024-11-28 03:41:14 +01:00
dankito 9480bc0282 Sped up fetching data 2024-11-28 02:52:29 +01:00
dankito 07046baa9c Raised Gradle heap size 2024-11-28 02:22:32 +01:00
dankito fef3765b8f Fixed that filename is always non-null 2024-11-28 02:21:15 +01:00
dankito 2c8f4ec050 Added check for isEncrypted == false as encrypted messages don't have a (readable) message body 2024-11-28 02:19:32 +01:00
dankito 4deaf9cb21 Using the new OrNull() methods so we don't have to catch the exceptions here 2024-11-28 02:13:58 +01:00
dankito a1eea168e9 Added overloads that catch exceptions and return null on error 2024-11-28 02:04:20 +01:00
dankito 5abfa0b641 By default not downloading any attachments anymore 2024-11-28 01:55:18 +01:00
dankito 922fa629ae Moved bodies up 2024-11-28 01:51:49 +01:00
dankito 0a53966b16 Up prioritized messageId and added documentation for it 2024-11-28 01:50:52 +01:00
dankito 110da9ced6 Added BCC field 2024-11-28 01:48:50 +01:00
dankito 3d1857de3a Forgot to commit contentLanguage property 2024-11-28 01:41:39 +01:00
dankito d976f848de Implemented option downloadOnlyPlainTextOrHtmlMessageBody 2024-11-28 01:40:20 +01:00
dankito b28403025c Added extension, size, contentType and disposition to EmailAttachment 2024-11-28 01:33:31 +01:00
dankito 1d7c07e7d6 Fixed that replyTo and getRecipients() may return null 2024-11-27 04:36:36 +01:00
dankito c1c33d80a0 Added contentLanguage 2024-11-27 04:35:55 +01:00
dankito 00c062f9a9 Moved date up and added default values 2024-11-27 04:23:07 +01:00
dankito 318903db40 Mapping sender, to, cc and replyTo 2024-11-27 04:15:23 +01:00
dankito 64966ea827 Added FetchProfile to speed up fetch process 2024-11-27 03:47:51 +01:00
dankito d031b9414d Renamed findAttachment() to getAttachment() 2024-11-27 03:35:56 +01:00
dankito c65d99e35d Removed unnecessary async { } 2024-11-27 03:35:19 +01:00
dankito 1e372cf592 Implemented saving message UID so that on next app run fetchAllEmails() can continue on last downloaded message 2024-11-26 21:44:01 +01:00
dankito 9257205a43 Fixed that Apache FOP logged so many messages that Gradle crashed 2024-11-26 21:39:00 +01:00
dankito 3e401b1c00 Using now stable message UID instead of unreliable messageNumber 2024-11-26 21:22:18 +01:00
dankito 1e986e800d Added method overloads with Input-/OutputStream 2024-11-26 20:45:18 +01:00
dankito f24b2004bb Controlling now eInvoice XML format via EInvoiceXmlFormat 2024-11-26 16:40:22 +01:00
dankito 6ec302e50f Implemented attaching XRechnung to Factur-X PDF 2024-11-26 16:05:35 +01:00
dankito ef42ddee37 Closing streams 2024-11-26 15:55:14 +01:00
dankito af03c0d5d3 Removed debugging code 2024-11-26 06:44:24 +01:00
dankito b37c4619d2 Removing illegal filename characters also from attachment filename 2024-11-26 06:14:14 +01:00
dankito a078b8bf66 Implemented configuring connect timeout 2024-11-26 06:10:01 +01:00
dankito 2b08db3374 Fixed getting parent 2024-11-26 06:06:12 +01:00
dankito bb5b89fd5a Removed message body parts from attachment parts 2024-11-26 06:05:58 +01:00
dankito ccf48f7cb4 Added mail account username to attachments download directory 2024-11-26 04:32:49 +01:00
dankito 4aefa86ab9 Downloading eInvoice files only once 2024-11-26 04:12:14 +01:00
dankito 4dc9b43189 Implemented configuring attachments download folder 2024-11-26 04:06:22 +01:00
dankito b3f6f2dbc3 Implemented configuring which attachments should be downloaded 2024-11-26 03:57:39 +01:00
dankito d70a748ad0 Returning now all mails and attachments, not only those with eInvoices 2024-11-26 03:47:55 +01:00
dankito c3cf0652b2 Made coroutineDispatcher configurable 2024-11-26 03:38:35 +01:00
dankito 72991218d9 Little refactoring 2024-11-26 03:26:43 +01:00
dankito 199310de86 Added flag to stop listening to new emails 2024-11-26 03:26:24 +01:00
dankito dcc4e233aa Moved data classes to model subpackage 2024-11-26 03:21:35 +01:00
dankito e0b4550cd3 Added ListenForNewMailsOptions so that there onEmailReceived has to be set 2024-11-26 03:19:11 +01:00
dankito 31ba07d5e9 Added emailFolderName, onError and onEmailReceived to FetchEmailsOptions, so that also fetchAllEmails() can set a progress listener for each received email 2024-11-26 03:06:56 +01:00
dankito bb3f468c48 Renamed package mail to email 2024-11-26 02:46:10 +01:00
dankito 231da572e5 Renamed error listener to onError 2024-11-26 02:44:54 +01:00
dankito 8b7bd31cf1 Renamed listAllMessagesWithEInvoice() to fetchAllEmails() and listenForNewReceivedEInvoices() to listenForNewEmails() 2024-11-26 02:43:24 +01:00
dankito d66afa50a9 Renamed classes from 'ReadMails' to 'FetchEmails' 2024-11-26 02:27:16 +01:00
dankito eb9b42fb97 Raised timeouts 2024-11-26 02:12:33 +01:00
dankito aa23cc0eb1 Improved wording 2024-11-26 01:34:51 +01:00
dankito 0c1d48736c Added errors to result of listAllMessagesWithEInvoice() 2024-11-26 01:34:26 +01:00
29 changed files with 848 additions and 431 deletions

View File

@ -22,14 +22,18 @@ val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
### Find all invoices of an IMAP email account
```kotlin
val mailReader = MailReader()
val emailsFetcher = EmailsFetcher()
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount(
username = "", // your mail account username
password = "", // your mail account username
val fetchResult = emailsFetcher.fetchAllEmails(EmailAccount(
username = "", // your email account username
password = "", // your email account username
serverAddress = "", // IMAP server address
port = null // IMAP server port, leave null if default port 993
port = null // IMAP server port, can be left null for default port 993
))
fetchResult.emails.forEach { email ->
println("${email.sender}: ${email.attachments.firstNotNullOfOrNull { it.invoice }?.totalAmounts?.duePayableAmount}")
}
```
### Validate eInvoice
@ -54,7 +58,7 @@ fun create() {
val creator = EInvoiceCreator()
// create a PDF that also contains the eInvoice as XML attachment
creator.createFacturXPdf(invoice, pdfResultFile)
creator.createPdfWithAttachedXml(invoice, pdfResultFile)
// create only the XML file
val xml = creator.createFacturXXml(invoice)
@ -84,7 +88,7 @@ val creator = EInvoiceCreator()
creator.attachInvoiceXmlToPdf(invoice, existingPdf, output)
// or if you already have the invoice XML:
val invoiceXml: String = "..." // e.g. creator.createZugferdXml(invoice)
val invoiceXml = creator.createXRechnungXml(invoice) // or creator.createZugferdXml(invoice), ...
creator.attachInvoiceXmlToPdf(invoiceXml, existingPdf, output)
creator.attachInvoiceXmlToPdf(invoiceXml, EInvoiceXmlFormat.XRechnung, existingPdf, output)
```

View File

@ -27,7 +27,7 @@ class InvoicingService {
fun createFacturXPdf(invoice: Invoice): Path {
val resultFile = createTempPdfFile()
creator.createFacturXPdf(invoice, resultFile.toFile())
creator.createPdfWithAttachedXml(invoice, resultFile.toFile())
return resultFile
}

View File

@ -65,8 +65,10 @@ open class EInvoiceConverter {
protected open fun createXRechnungXml(invoice: Invoice): String = EInvoiceCreator().createXRechnungXml(invoice)
protected open fun copyResource(resourceName: String, outputFile: File, outputFileExtension: String) {
javaClass.classLoader.getResourceAsStream(resourceName).use {
it?.copyTo(File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream())
javaClass.classLoader.getResourceAsStream(resourceName).use { inputStream ->
File(outputFile.parentFile, outputFile.nameWithoutExtension + outputFileExtension).outputStream().use { outputStream ->
inputStream?.copyTo(outputStream)
}
}
}

View File

@ -1,73 +1,33 @@
package net.codinux.invoicing.creation
import net.codinux.invoicing.mapper.MustangMapper
import net.codinux.invoicing.model.EInvoiceXmlFormat
import net.codinux.invoicing.model.Invoice
import org.mustangproject.ZUGFeRD.*
import java.io.File
import java.io.InputStream
import java.io.OutputStream
open class EInvoiceCreator(
protected open val mapper: MustangMapper = MustangMapper()
) {
open fun createXRechnungXml(invoice: Invoice): String {
val provider = ZUGFeRD2PullProvider()
provider.profile = Profiles.getByName("XRechnung")
return createXml(provider, invoice)
}
open fun createXRechnungXml(invoice: Invoice) = createXml(invoice, EInvoiceXmlFormat.XRechnung)
/**
* Synonym for [createFacturXXml] (ZUGFeRD 2 is a synonym for Factur-X).
*/
open fun createZugferdXml(invoice: Invoice) = createFacturXXml(invoice)
open fun createFacturXXml(invoice: Invoice): String {
open fun createFacturXXml(invoice: Invoice) = createXml(invoice, EInvoiceXmlFormat.FacturX)
protected open fun createXml(invoice: Invoice, format: EInvoiceXmlFormat): String {
val exporter = ZUGFeRDExporterFromA3()
.setProfile("EN16931") // required for XML?
.setProfile(getProfileNameForFormat(format))
return createXml(exporter.provider, invoice)
}
/**
* Synonym for [createFacturXPdf] (ZUGFeRD 2 is a synonym for Factur-X).
*/
open fun createZugferdPdf(invoice: Invoice, outputFile: File) = createFacturXPdf(invoice, outputFile)
open fun createFacturXPdf(invoice: Invoice, outputFile: File) {
val xml = createFacturXXml(invoice)
val xmlFile = File.createTempFile(outputFile.nameWithoutExtension, ".xml")
.also { it.writeText(xml) }
val pdfFile = File(xmlFile.parentFile, xmlFile.nameWithoutExtension + ".pdf")
val visualizer = ZUGFeRDVisualizer()
visualizer.toPDF(xmlFile.absolutePath, pdfFile.absolutePath)
attachInvoiceXmlToPdf(xml, pdfFile, outputFile)
xmlFile.delete()
pdfFile.delete()
}
open fun attachInvoiceXmlToPdf(invoice: Invoice, pdfFile: File, outputFile: File) =
attachInvoiceXmlToPdf(createFacturXXml(invoice), pdfFile, outputFile)
open fun attachInvoiceXmlToPdf(invoiceXml: String, pdfFile: File, outputFile: File) {
val exporter = ZUGFeRDExporterFromA3()
.setZUGFeRDVersion(2)
.setProfile("EN16931") // available values: MINIMUM, BASICWL, BASIC, CIUS, EN16931, EXTENDED, XRECHNUNG
// .disableFacturX()
.setProducer("danki die geile Sau")
.setCreator(System.getProperty("user.name"))
exporter.load(pdfFile.inputStream())
exporter.setXML(invoiceXml.toByteArray())
exporter.export(outputFile.outputStream())
}
protected open fun createXml(provider: IXMLProvider, invoice: Invoice): String {
val transaction = mapper.mapToTransaction(invoice)
@ -76,4 +36,80 @@ open class EInvoiceCreator(
return String(provider.xml, Charsets.UTF_8)
}
/**
* Creates a hybrid PDF that also contains the Factur-X / ZUGFeRD or XRechnung XML as attachment.
*/
@JvmOverloads
open fun createPdfWithAttachedXml(invoice: Invoice, outputFile: File, format: EInvoiceXmlFormat = EInvoiceXmlFormat.FacturX) {
val xml = createXml(invoice, format)
createPdfWithAttachedXml(xml, format, outputFile)
}
open fun createPdfWithAttachedXml(invoiceXml: String, format: EInvoiceXmlFormat, outputFile: File) {
outputFile.outputStream().use { outputStream ->
createPdfWithAttachedXml(invoiceXml, format, outputStream)
}
}
open fun createPdfWithAttachedXml(invoiceXml: String, format: EInvoiceXmlFormat, outputFile: OutputStream) {
val xmlFile = File.createTempFile("${format.name}-invoice", ".xml")
.also { it.writeText(invoiceXml) }
val pdfFile = File(xmlFile.parentFile, xmlFile.nameWithoutExtension + ".pdf")
val visualizer = ZUGFeRDVisualizer()
visualizer.toPDF(xmlFile.absolutePath, pdfFile.absolutePath)
pdfFile.inputStream().use { inputStream ->
attachInvoiceXmlToPdf(invoiceXml, format, inputStream, outputFile)
}
xmlFile.delete()
pdfFile.delete()
}
@JvmOverloads
open fun attachInvoiceXmlToPdf(invoice: Invoice, pdfFile: File, outputFile: File, format: EInvoiceXmlFormat = EInvoiceXmlFormat.FacturX) =
attachInvoiceXmlToPdf(createXml(invoice, format), format, pdfFile, outputFile)
open fun attachInvoiceXmlToPdf(invoiceXml: String, format: EInvoiceXmlFormat, pdfFile: File, outputFile: File) {
pdfFile.inputStream().use { inputStream ->
outputFile.outputStream().use { outputStream ->
attachInvoiceXmlToPdf(invoiceXml, format, inputStream, outputStream)
}
}
}
open fun attachInvoiceXmlToPdf(invoiceXml: String, format: EInvoiceXmlFormat, pdfFile: InputStream, outputFile: OutputStream) =
attachInvoiceXmlToPdf(invoiceXml, format, pdfFile.readAllBytes(), outputFile)
open fun attachInvoiceXmlToPdf(invoiceXml: String, format: EInvoiceXmlFormat, pdfFile: ByteArray, outputFile: OutputStream) {
val exporter = ZUGFeRDExporterFromA3()
.setZUGFeRDVersion(2)
.setProfile(getProfileNameForFormat(format))
// .disableFacturX()
.setProducer("danki die geile Sau")
.setCreator(System.getProperty("user.name"))
.setCreatorTool("Unglaublich geiles eInvoicing Tool von codinux")
exporter.load(pdfFile)
exporter.setXML(invoiceXml.toByteArray())
exporter.export(outputFile)
}
protected open fun getProfileNameForFormat(format: EInvoiceXmlFormat) = when (format) {
EInvoiceXmlFormat.FacturX -> "EN16931" // available values: MINIMUM, BASICWL, BASIC, CIUS, EN16931, EXTENDED, XRECHNUNG
EInvoiceXmlFormat.XRechnung -> "XRECHNUNG"
}
protected open fun getFilenameForFormat(format: EInvoiceXmlFormat) = when (format) {
EInvoiceXmlFormat.FacturX -> "factur-x.xml"
EInvoiceXmlFormat.XRechnung -> "xrechnung.xml"
// other available values: "zugferd-invoice.xml" (ZF v2), "ZUGFeRD-invoice.xml" (ZF v1) ("order-x.xml", "cida.xml")
}
}

View File

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

View File

@ -0,0 +1,7 @@
package net.codinux.invoicing.email
data class FetchEmailError(
val type: FetchEmailErrorType,
val messageId: Long?,
val error: Throwable
)

View File

@ -0,0 +1,13 @@
package net.codinux.invoicing.email
enum class FetchEmailErrorType {
GetEmail,
GetMesssageBody,
GetAttachment,
ExtractInvoice,
ListenForNewEmails
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
package net.codinux.invoicing.email.model
enum class ContentDisposition {
Body,
Inline,
Attachment,
Unknown
}

View File

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

View File

@ -1,14 +1,14 @@
package net.codinux.invoicing.mail
package net.codinux.invoicing.email.model
class MailAccount(
class EmailAccount(
val username: String,
val password: String,
/**
* For reading mails the IMAP server address, for sending mails the SMTP server address.
* For fetching emails the IMAP server address, for sending emails the SMTP server address.
*/
val serverAddress: String,
/**
* Even though not mandatory it's better to specify the port, otherwise default port is tried.
* Even though not mandatory it's better to specify the port, otherwise default port (993 for IMAP, 587 for SMTP) is used.
*/
val port: Int? = null
) {

View File

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

View File

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

View File

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

View File

@ -35,9 +35,13 @@ open class FilesystemInvoiceReader(
val extension = file.extension.lowercase()
if (extension == "pdf") {
eInvoiceReader.extractFromPdf(file.inputStream())
file.inputStream().use { inputStream ->
eInvoiceReader.extractFromPdf(inputStream)
}
} else if (extension == "xml") {
eInvoiceReader.extractFromXml(file.inputStream())
file.inputStream().use { inputStream ->
eInvoiceReader.extractFromXml(inputStream)
}
} else {
null
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
package net.codinux.invoicing.model
enum class EInvoiceXmlFormat {
/**
* Factur-X is equal / synonym to ZUGFeRD 2
*/
FacturX,
XRechnung
}

View File

@ -2,6 +2,7 @@ package net.codinux.invoicing.reader
import net.codinux.invoicing.mapper.MustangMapper
import net.codinux.invoicing.model.Invoice
import net.codinux.log.logger
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
import java.io.File
import java.io.InputStream
@ -16,11 +17,19 @@ open class EInvoiceReader(
)
}
private val log by logger()
open fun extractFromXml(xmlFile: File) = extractFromXml(xmlFile.inputStream())
open fun extractFromXmlOrNull(xmlFile: File) = orNull { extractFromXml(xmlFile) }
open fun extractFromXml(xmlFile: File) = xmlFile.inputStream().use { extractFromXml(it) }
open fun extractFromXmlOrNull(stream: InputStream) = orNull { extractFromXml(stream) }
open fun extractFromXml(stream: InputStream) = extractFromXml(stream.reader().readText())
open fun extractFromXmlOrNull(xml: String) = orNull { extractFromXml(xml) }
open fun extractFromXml(xml: String): Invoice {
val importer = ZUGFeRDInvoiceImporter() // XRechnungImporter only reads properties but not to a Invoice object
importer.fromXML(xml)
@ -28,7 +37,12 @@ open class EInvoiceReader(
return extractInvoice(importer)
}
open fun extractFromPdf(pdfFile: File) = extractFromPdf(pdfFile.inputStream())
open fun extractFromPdfOrNull(pdfFile: File) = orNull { extractFromPdf(pdfFile) }
open fun extractFromPdf(pdfFile: File) = pdfFile.inputStream().use { extractFromPdf(it) }
open fun extractFromPdfOrNull(stream: InputStream) = orNull { extractFromPdf(stream) }
open fun extractFromPdf(stream: InputStream): Invoice {
val importer = ZUGFeRDInvoiceImporter(stream)
@ -36,7 +50,12 @@ open class EInvoiceReader(
return extractInvoice(importer)
}
open fun extractXmlFromPdf(pdfFile: File) = extractXmlFromPdf(pdfFile.inputStream())
open fun extractXmlFromPdfOrNull(pdfFile: File) = orNull { extractXmlFromPdf(pdfFile) }
open fun extractXmlFromPdf(pdfFile: File) = pdfFile.inputStream().use { extractXmlFromPdf(it) }
open fun extractXmlFromPdfOrNull(stream: InputStream) = orNull { extractXmlFromPdf(stream) }
open fun extractXmlFromPdf(stream: InputStream): String {
val importer = ZUGFeRDInvoiceImporter(stream)
@ -58,4 +77,13 @@ open class EInvoiceReader(
return mapper.mapToInvoice(invoice)
}
protected open fun <T> orNull(action: () -> T): T? =
try {
action()
} catch (e: Throwable) {
log.debug(e) { "Action caused an exception, but orNull() was called" }
null
}
}

View File

@ -1,8 +1,9 @@
package net.codinux.invoicing
import net.codinux.invoicing.creation.EInvoiceCreator
import net.codinux.invoicing.mail.MailAccount
import net.codinux.invoicing.mail.MailReader
import net.codinux.invoicing.email.model.EmailAccount
import net.codinux.invoicing.email.EmailsFetcher
import net.codinux.invoicing.model.EInvoiceXmlFormat
import net.codinux.invoicing.model.Invoice
import net.codinux.invoicing.model.InvoiceItem
import net.codinux.invoicing.model.Party
@ -24,15 +25,19 @@ class Demonstration {
val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
}
fun fromMail() {
val mailReader = MailReader()
fun fromEmail() {
val emailsFetcher = EmailsFetcher()
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount(
username = "", // your mail account username
password = "", // your mail account username
val fetchResult = emailsFetcher.fetchAllEmails(EmailAccount(
username = "", // your email account username
password = "", // your email account username
serverAddress = "", // IMAP server address
port = null // IMAP server port, leave null if default port 993
port = null // IMAP server port, can be left null for default port 993
))
fetchResult.emails.forEach { email ->
println("${email.sender}: ${email.attachments.firstNotNullOfOrNull { it.invoice }?.totalAmounts?.duePayableAmount}")
}
}
fun validate() {
@ -53,7 +58,7 @@ class Demonstration {
val creator = EInvoiceCreator()
// create a PDF that also contains the eInvoice as XML attachment
creator.createFacturXPdf(invoice, pdfResultFile)
creator.createPdfWithAttachedXml(invoice, pdfResultFile)
// create only the XML file
val xml = creator.createFacturXXml(invoice)
@ -72,9 +77,9 @@ class Demonstration {
creator.attachInvoiceXmlToPdf(invoice, existingPdf, output)
// or if you already have the invoice XML:
val invoiceXml: String = "..." // e.g. creator.createZugferdXml(invoice)
val invoiceXml = creator.createXRechnungXml(invoice) // or creator.createZugferdXml(invoice), ...
creator.attachInvoiceXmlToPdf(invoiceXml, existingPdf, output)
creator.attachInvoiceXmlToPdf(invoiceXml, EInvoiceXmlFormat.XRechnung, existingPdf, output)
}

View File

@ -1,5 +1,6 @@
package net.codinux.invoicing.creation
import net.codinux.invoicing.model.EInvoiceXmlFormat
import net.codinux.invoicing.test.DataGenerator
import net.codinux.invoicing.test.InvoiceAsserter
import org.mustangproject.ZUGFeRD.ZUGFeRDInvoiceImporter
@ -30,13 +31,26 @@ class EInvoiceCreatorTest {
}
@Test
fun createFacturXPdf() {
fun createPdfWithAttachedXml_FacturX() {
val invoice = createInvoice()
val testFile = File.createTempFile("Zugferd", ".pdf")
underTest.createFacturXPdf(invoice, testFile)
underTest.createPdfWithAttachedXml(invoice, testFile, EInvoiceXmlFormat.FacturX)
val importer = ZUGFeRDInvoiceImporter(testFile.inputStream())
val importer = testFile.inputStream().use { ZUGFeRDInvoiceImporter(it) }
val xml = String(importer.rawXML, Charsets.UTF_8)
assertInvoiceXml(xml)
}
@Test
fun createPdfWithAttachedXml_XRechnung() {
val invoice = createInvoice()
val testFile = File.createTempFile("Zugferd", ".pdf")
underTest.createPdfWithAttachedXml(invoice, testFile, EInvoiceXmlFormat.XRechnung)
val importer = testFile.inputStream().use { ZUGFeRDInvoiceImporter(it) }
val xml = String(importer.rawXML, Charsets.UTF_8)
assertInvoiceXml(xml)

View File

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

View File

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

View File

@ -21,4 +21,12 @@
<appender-ref ref="STDOUT"/>
</root>
<!-- Apache FOP will flood otherwise the log so that test run crashes -->
<logger name="org.apache.fop" level="INFO">
<appender-ref ref="STDOUT"/>
</logger>
<logger name="org.apache.xmlgraphics.image.loader.spi.ImageImplRegistry" level="INFO">
<appender-ref ref="STDOUT"/>
</logger>
</configuration>

View File

@ -1,5 +1,7 @@
kotlin.code.style=official
org.gradle.jvmargs=-Xmx4G
org.gradle.parallel=true