Renamed classes from 'ReadMails' to 'FetchEmails'

This commit is contained in:
dankito 2024-11-26 02:17:28 +01:00
parent eb9b42fb97
commit d66afa50a9
14 changed files with 93 additions and 93 deletions

View File

@ -22,9 +22,9 @@ val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
### Find all invoices of an IMAP email account ### Find all invoices of an IMAP email account
```kotlin ```kotlin
val mailReader = MailReader() val emailsFetcher = EmailsFetcher()
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount( val mailsWithEInvoices = emailsFetcher.listAllMessagesWithEInvoice(EmailAccount(
username = "", // your mail account username username = "", // your mail account username
password = "", // your mail account username password = "", // your mail account username
serverAddress = "", // IMAP server address serverAddress = "", // IMAP server address

View File

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

View File

@ -3,7 +3,7 @@ package net.codinux.invoicing.mail
import net.codinux.invoicing.model.Invoice import net.codinux.invoicing.model.Invoice
import java.io.File import java.io.File
class MailAttachmentWithEInvoice( class EmailAttachmentWithEInvoice(
val filename: String, val filename: String,
/** /**
* Attachment's media type like "application/xml", "application/pdf", ... * Attachment's media type like "application/xml", "application/pdf", ...

View File

@ -3,7 +3,7 @@ package net.codinux.invoicing.mail
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
class MailWithInvoice( class EmailWithInvoice(
val sender: String?, val sender: String?,
val subject: String, val subject: String,
val sent: Instant?, val sent: Instant?,
@ -13,13 +13,13 @@ class MailWithInvoice(
* "Since message numbers can change within a session if the folder is expunged, clients are advised not to use * "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." * message numbers as references to messages."
* *
* -> use with care. Message numbers are not valid / the same anymore after expunge. * -> use with care. Message numbers are not valid / the same anymore after expunging.
*/ */
val messageNumber: Int, val messageNumber: Int,
val isEncrypted: Boolean = false, val isEncrypted: Boolean = false,
val plainTextBody: String?, val plainTextBody: String?,
val htmlBody: String?, val htmlBody: String?,
val attachmentsWithEInvoice: List<MailAttachmentWithEInvoice> val attachmentsWithEInvoice: List<EmailAttachmentWithEInvoice>
) { ) {
val plainTextOrHtmlBody: String? by lazy { plainTextBody ?: htmlBody } val plainTextOrHtmlBody: String? by lazy { plainTextBody ?: htmlBody }

View File

@ -19,7 +19,7 @@ import java.util.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.math.max import kotlin.math.max
open class MailReader( open class EmailsFetcher(
protected open val eInvoiceReader: EInvoiceReader = EInvoiceReader() protected open val eInvoiceReader: EInvoiceReader = EInvoiceReader()
) { ) {
@ -34,14 +34,14 @@ open class MailReader(
protected val log by logger() protected val log by logger()
open fun listenForNewReceivedEInvoices(account: MailAccount, downloadMessageBody: Boolean = false, emailFolderName: String = "INBOX", open fun listenForNewReceivedEInvoices(account: EmailAccount, downloadMessageBody: Boolean = false, emailFolderName: String = "INBOX",
error: ((ReadMailError) -> Unit)? = null, eInvoiceReceived: (MailWithInvoice) -> Unit) = runBlocking { error: ((FetchEmailsError) -> Unit)? = null, eInvoiceReceived: (EmailWithInvoice) -> Unit) = runBlocking {
try { try {
connect(account) { store -> connect(account) { store ->
val folder = store.getFolder(emailFolderName) val folder = store.getFolder(emailFolderName)
folder.open(Folder.READ_ONLY) folder.open(Folder.READ_ONLY)
val status = ReadMailsStatus(ReadMailsOptions(downloadMessageBody)) val status = FetchEmailsStatus(FetchEmailsOptions(downloadMessageBody))
folder.addMessageCountListener(object : MessageCountAdapter() { folder.addMessageCountListener(object : MessageCountAdapter() {
override fun messagesAdded(event: MessageCountEvent) { override fun messagesAdded(event: MessageCountEvent) {
@ -59,13 +59,13 @@ open class MailReader(
} }
} catch (e: Throwable) { } catch (e: Throwable) {
log.error(e) { "Listening to new received eInvoices of '${account.username}' failed" } log.error(e) { "Listening to new received eInvoices of '${account.username}' failed" }
error?.invoke(ReadMailError(ReadMailsErrorType.ListenForNewEmails, null, e)) error?.invoke(FetchEmailsError(FetchEmailsErrorType.ListenForNewEmails, null, e))
} }
log.info { "Stopped listening to new received eInvoices of '${account.username}'" } log.info { "Stopped listening to new received eInvoices of '${account.username}'" }
} }
protected open suspend fun keepConnectionOpen(account: MailAccount, folder: Folder) { protected open suspend fun keepConnectionOpen(account: EmailAccount, folder: Folder) {
log.info { "Listening to new mails of ${account.username}" } log.info { "Listening to new mails of ${account.username}" }
// Use IMAP IDLE to keep the connection alive // Use IMAP IDLE to keep the connection alive
@ -82,28 +82,28 @@ open class MailReader(
} }
open fun listAllMessagesWithEInvoice(account: MailAccount, downloadMessageBody: Boolean = false, emailFolderName: String = "INBOX"): ReadMailsResult { open fun listAllMessagesWithEInvoice(account: EmailAccount, downloadMessageBody: Boolean = false, emailFolderName: String = "INBOX"): FetchEmailsResult {
try { try {
return connect(account) { store -> return connect(account) { store ->
val inbox = store.getFolder(emailFolderName) val inbox = store.getFolder(emailFolderName)
inbox.open(Folder.READ_ONLY) inbox.open(Folder.READ_ONLY)
val status = ReadMailsStatus(ReadMailsOptions(downloadMessageBody)) val status = FetchEmailsStatus(FetchEmailsOptions(downloadMessageBody))
val mails = listAllMessagesWithEInvoiceInFolder(inbox, status).also { val mails = listAllMessagesWithEInvoiceInFolder(inbox, status).also {
inbox.close(false) inbox.close(false)
} }
ReadMailsResult(mails, null, status.mailSpecificErrors) FetchEmailsResult(mails, null, status.messageSpecificErrors)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
log.error(e) { "Could not read mails of account $account" } log.error(e) { "Could not read mails of account $account" }
return ReadMailsResult(emptyList(), e) return FetchEmailsResult(emptyList(), e)
} }
} }
protected open fun listAllMessagesWithEInvoiceInFolder(folder: Folder, status: ReadMailsStatus): List<MailWithInvoice> = runBlocking { protected open fun listAllMessagesWithEInvoiceInFolder(folder: Folder, status: FetchEmailsStatus): List<EmailWithInvoice> = runBlocking {
val messageCount = folder.messageCount val messageCount = folder.messageCount
if (messageCount <= 0) { if (messageCount <= 0) {
return@runBlocking emptyList() return@runBlocking emptyList()
@ -115,7 +115,7 @@ open class MailReader(
findEInvoice(folder.getMessage(messageNumber), status) findEInvoice(folder.getMessage(messageNumber), status)
} catch (e: Throwable) { } catch (e: Throwable) {
log.error(e) { "Could not get message with messageNumber $messageNumber" } log.error(e) { "Could not get message with messageNumber $messageNumber" }
status.addError(ReadMailsErrorType.GetEmail, messageNumber, e) status.addError(FetchEmailsErrorType.GetEmail, messageNumber, e)
null null
} }
} }
@ -124,7 +124,7 @@ open class MailReader(
.filterNotNull() .filterNotNull()
} }
protected open fun findEInvoice(message: Message, status: ReadMailsStatus): MailWithInvoice? { protected open fun findEInvoice(message: Message, status: FetchEmailsStatus): EmailWithInvoice? {
val parts = getAllMessageParts(message) val parts = getAllMessageParts(message)
val attachmentsWithEInvoice = parts.mapNotNull { part -> val attachmentsWithEInvoice = parts.mapNotNull { part ->
@ -132,7 +132,7 @@ open class MailReader(
} }
if (attachmentsWithEInvoice.isNotEmpty()) { if (attachmentsWithEInvoice.isNotEmpty()) {
return MailWithInvoice( return EmailWithInvoice(
message.from?.joinToString(), message.subject ?: "", message.from?.joinToString(), message.subject ?: "",
message.sentDate?.let { map(it) }, map(message.receivedDate), message.messageNumber, message.sentDate?.let { map(it) }, map(message.receivedDate), message.messageNumber,
parts.any { it.mediaType == "application/pgp-encrypted" }, parts.any { it.mediaType == "application/pgp-encrypted" },
@ -144,7 +144,7 @@ open class MailReader(
return null return null
} }
protected open fun findEInvoice(messagePart: MessagePart, status: ReadMailsStatus): MailAttachmentWithEInvoice? { protected open fun findEInvoice(messagePart: MessagePart, status: FetchEmailsStatus): EmailAttachmentWithEInvoice? {
try { try {
val part = messagePart.part val part = messagePart.part
val invoice = tryToReadEInvoice(part, messagePart.mediaType, status) val invoice = tryToReadEInvoice(part, messagePart.mediaType, status)
@ -156,17 +156,17 @@ open class MailReader(
file.deleteOnExit() file.deleteOnExit()
} }
return MailAttachmentWithEInvoice(part.fileName, messagePart.mediaType, invoice, file) return EmailAttachmentWithEInvoice(part.fileName, messagePart.mediaType, invoice, file)
} }
} catch (e: Throwable) { } catch (e: Throwable) {
log.error(e) { "Could not check attachment '${messagePart.part.fileName}' (${messagePart.mediaType}) for eInvoice" } log.error(e) { "Could not check attachment '${messagePart.part.fileName}' (${messagePart.mediaType}) for eInvoice" }
status.addError(ReadMailsErrorType.GetAttachment, messagePart.part, e) status.addError(FetchEmailsErrorType.GetAttachment, messagePart.part, e)
} }
return null return null
} }
protected open fun tryToReadEInvoice(part: Part, mediaType: String?, status: ReadMailsStatus): Invoice? = try { protected open fun tryToReadEInvoice(part: Part, mediaType: String?, status: FetchEmailsStatus): Invoice? = try {
val filename = part.fileName?.lowercase() ?: "" val filename = part.fileName?.lowercase() ?: ""
if (filename.endsWith(".pdf") || mediaType == "application/pdf" || mediaType == "application/octet-stream") { if (filename.endsWith(".pdf") || mediaType == "application/pdf" || mediaType == "application/octet-stream") {
@ -178,7 +178,7 @@ open class MailReader(
} }
} catch (e: Throwable) { } catch (e: Throwable) {
log.debug(e) { "Could not extract invoices from ${part.fileName}" } log.debug(e) { "Could not extract invoices from ${part.fileName}" }
status.addError(ReadMailsErrorType.ExtractInvoice, part, e) status.addError(FetchEmailsErrorType.ExtractInvoice, part, e)
null null
} }
@ -219,13 +219,13 @@ open class MailReader(
} }
} }
protected open fun getPlainTextBody(parts: Collection<MessagePart>, status: ReadMailsStatus) = protected open fun getPlainTextBody(parts: Collection<MessagePart>, status: FetchEmailsStatus) =
if (status.options.downloadMessageBody) getBodyWithMediaType(parts, "text/plain", status) else null if (status.options.downloadMessageBody) getBodyWithMediaType(parts, "text/plain", status) else null
protected open fun getHtmlBody(parts: Collection<MessagePart>, status: ReadMailsStatus) = protected open fun getHtmlBody(parts: Collection<MessagePart>, status: FetchEmailsStatus) =
if (status.options.downloadMessageBody) getBodyWithMediaType(parts, "text/html", status) else null if (status.options.downloadMessageBody) getBodyWithMediaType(parts, "text/html", status) else null
protected open fun getBodyWithMediaType(parts: Collection<MessagePart>, mediaType: String, status: ReadMailsStatus): String? = try { protected open fun getBodyWithMediaType(parts: Collection<MessagePart>, mediaType: String, status: FetchEmailsStatus): String? = try {
val partsForMediaType = parts.filter { it.mediaType == mediaType } val partsForMediaType = parts.filter { it.mediaType == mediaType }
if (partsForMediaType.size == 1) { if (partsForMediaType.size == 1) {
@ -245,7 +245,7 @@ open class MailReader(
} }
} catch (e: Throwable) { } catch (e: Throwable) {
log.error(e) { "Could not get message body for media type '$mediaType'" } log.error(e) { "Could not get message body for media type '$mediaType'" }
status.addError(ReadMailsErrorType.GetMesssageBody, parts.map { it.part }, e) status.addError(FetchEmailsErrorType.GetMesssageBody, parts.map { it.part }, e)
null null
} }
@ -253,7 +253,7 @@ open class MailReader(
date.toInstant() date.toInstant()
protected open fun <T> connect(account: MailAccount, connected: (Store) -> T): T { protected open fun <T> connect(account: EmailAccount, connected: (Store) -> T): T {
val properties = mapAccountToJavaMailProperties(account) val properties = mapAccountToJavaMailProperties(account)
val session = Session.getInstance(properties) val session = Session.getInstance(properties)
@ -264,7 +264,7 @@ open class MailReader(
} }
} }
protected open fun mapAccountToJavaMailProperties(account: MailAccount) = Properties().apply { protected open fun mapAccountToJavaMailProperties(account: EmailAccount) = Properties().apply {
put("mail.store.protocol", "imap") put("mail.store.protocol", "imap")
put("mail.imap.host", account.serverAddress) put("mail.imap.host", account.serverAddress)

View File

@ -1,7 +1,7 @@
package net.codinux.invoicing.mail package net.codinux.invoicing.mail
data class ReadMailError( data class FetchEmailsError(
val type: ReadMailsErrorType, val type: FetchEmailsErrorType,
val messageNumber: Int?, val messageNumber: Int?,
val error: Throwable val error: Throwable
) )

View File

@ -1,6 +1,6 @@
package net.codinux.invoicing.mail package net.codinux.invoicing.mail
enum class ReadMailsErrorType { enum class FetchEmailsErrorType {
GetEmail, GetEmail,
GetMesssageBody, GetMesssageBody,

View File

@ -1,5 +1,5 @@
package net.codinux.invoicing.mail package net.codinux.invoicing.mail
data class ReadMailsOptions( data class FetchEmailsOptions(
val downloadMessageBody: Boolean = false val downloadMessageBody: Boolean = false
) )

View File

@ -0,0 +1,7 @@
package net.codinux.invoicing.mail
data class FetchEmailsResult(
val emails: List<EmailWithInvoice>,
val overallError: Throwable?,
val messageSpecificErrors: List<FetchEmailsError> = emptyList()
)

View File

@ -0,0 +1,39 @@
package net.codinux.invoicing.mail
import jakarta.mail.BodyPart
import jakarta.mail.Message
import jakarta.mail.Part
data class FetchEmailsStatus(
val options: FetchEmailsOptions,
val messageSpecificErrors: MutableList<FetchEmailsError> = mutableListOf(),
val error: ((FetchEmailsError) -> Unit)? = null
) {
fun addError(type: FetchEmailsErrorType, parts: Collection<Part>, error: Throwable) =
addError(FetchEmailsError(type, parts.firstNotNullOfOrNull { getMessage(it) }?.messageNumber, error))
fun addError(type: FetchEmailsErrorType, part: Part, error: Throwable) =
addError(FetchEmailsError(type, getMessage(part)?.messageNumber, error))
fun addError(type: FetchEmailsErrorType, messageNumber: Int?, error: Throwable) =
addError(FetchEmailsError(type, messageNumber, error))
fun addError(mailError: FetchEmailsError) {
messageSpecificErrors.add(mailError)
error?.invoke(mailError)
}
private fun getMessage(part: Part): Message? {
if (part is Message) {
return part
}
(part as? BodyPart)?.parent.let { parent ->
return getMessage(part)
}
return null
}
}

View File

@ -1,7 +0,0 @@
package net.codinux.invoicing.mail
data class ReadMailsResult(
val emails: List<MailWithInvoice>,
val overallError: Throwable?,
val messageSpecificErrors: List<ReadMailError> = emptyList()
)

View File

@ -1,39 +0,0 @@
package net.codinux.invoicing.mail
import jakarta.mail.BodyPart
import jakarta.mail.Message
import jakarta.mail.Part
data class ReadMailsStatus(
val options: ReadMailsOptions,
val mailSpecificErrors: MutableList<ReadMailError> = mutableListOf(),
val error: ((ReadMailError) -> Unit)? = null
) {
fun addError(type: ReadMailsErrorType, parts: Collection<Part>, error: Throwable) =
addError(ReadMailError(type, parts.firstNotNullOfOrNull { getMessage(it) }?.messageNumber, error))
fun addError(type: ReadMailsErrorType, part: Part, error: Throwable) =
addError(ReadMailError(type, getMessage(part)?.messageNumber, error))
fun addError(type: ReadMailsErrorType, messageNumber: Int?, error: Throwable) =
addError(ReadMailError(type, messageNumber, error))
fun addError(mailError: ReadMailError) {
mailSpecificErrors.add(mailError)
error?.invoke(mailError)
}
private fun getMessage(part: Part): Message? {
if (part is Message) {
return part
}
(part as? BodyPart)?.parent.let { parent ->
return getMessage(part)
}
return null
}
}

View File

@ -1,8 +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.mail.MailAccount import net.codinux.invoicing.mail.EmailAccount
import net.codinux.invoicing.mail.MailReader import net.codinux.invoicing.mail.EmailsFetcher
import net.codinux.invoicing.model.Invoice import net.codinux.invoicing.model.Invoice
import net.codinux.invoicing.model.InvoiceItem import net.codinux.invoicing.model.InvoiceItem
import net.codinux.invoicing.model.Party import net.codinux.invoicing.model.Party
@ -24,10 +24,10 @@ class Demonstration {
val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml")) val invoiceFromXml = reader.extractFromXml(File("XRechnung.xml"))
} }
fun fromMail() { fun fromEmail() {
val mailReader = MailReader() val emailsFetcher = EmailsFetcher()
val mailsWithEInvoices = mailReader.listAllMessagesWithEInvoice(MailAccount( val mailsWithEInvoices = emailsFetcher.listAllMessagesWithEInvoice(EmailAccount(
username = "", // your mail account username username = "", // your mail account username
password = "", // your mail account username password = "", // your mail account username
serverAddress = "", // IMAP server address serverAddress = "", // IMAP server address

View File

@ -7,11 +7,11 @@ import org.junit.jupiter.api.Test
import kotlin.test.Ignore import kotlin.test.Ignore
@Ignore // not an automatic test, set your mail account settings below @Ignore // not an automatic test, set your mail account settings below
class MailReaderTest { class EmailsFetcherTest {
companion object { companion object {
// specify your mail account here // specify your mail account here
private val mailAccount = MailAccount( private val emailAccount = EmailAccount(
username = "", username = "",
password = "", password = "",
serverAddress = "", serverAddress = "",
@ -20,12 +20,12 @@ class MailReaderTest {
} }
private val underTest = MailReader() private val underTest = EmailsFetcher()
@Test @Test
fun listAllMessagesWithEInvoice() { fun listAllMessagesWithEInvoice() {
val result = underTest.listAllMessagesWithEInvoice(mailAccount, true) val result = underTest.listAllMessagesWithEInvoice(emailAccount, true)
assertThat(result.emails).isNotEmpty() assertThat(result.emails).isNotEmpty()