Skip to content

Reading mail ​

Reading in MailKT is a pipeline. Each stage is optional, and later stages only run for the survivors of earlier ones.

EnvelopeAddresses, subject, dates, flags, size, Message-ID relations. MessageEnvelope
→
StructureMIME tree: types, file names, sizes, part refs. No bytes. MessageStructure
→
PartDecoded bytes of one part, within a byte limit. DownloadedPart

A message rejected by sender or subject costs no structure or content fetch. A message whose attachments are rejected by name or type costs no attachment download. MailMessage, the fully decoded message, is available when you really want everything.

What each stage contains ​

ModelContainsNever contains
MessageEnvelopeLocation (folder, UIDVALIDITY, UID), Message-ID, In-Reply-To, References, from/to/cc/reply-to, subject, sent/received dates, flags, content type, advertised size, selected headers, thread ID.Body, attachment bytes.
MessageStructureA tree of MessagePartDescriptors: media type, disposition, file name, content ID, transfer encoding, advertised size, stable MessagePartRef. Helpers parts, attachments, textParts.Bytes.
DownloadedPartThe descriptor plus decoded ByteContent.Anything beyond maxBytes.
MailMessageEnvelope, decoded MimeContent tree, attachments, and plainText / html helpers.

Selecting messages ​

Every read takes a MessageSelection:

kotlin
data class MessageSelection(
    val folder: FolderPath,
    val query: MessageQuery = MessageQuery.ALL,
    val range: MessageRange = MessageRange.All,
    val newestFirst: Boolean = false,
    val batchSize: Int = 50,            // 1..1000
    val checkpoint: ScanCheckpoint? = null,
)
  • MessageQuery holds only predicates that can be evaluated from an envelope: dates, addresses, subject, seen/flagged, keywords, header equality, thread IDs and size. Filtering on body or attachment content necessarily happens after download, in your code.
  • MessageRange fixes the membership of a scan when it starts: All, Dates(from, before) or Positions(first, last, fromNewest). Positions(1, 100, fromNewest = true) is the 100 most recent messages regardless of folder size.

See Models for every field.

Paging ​

page returns one page of envelopes and a next checkpoint. Pages freeze their membership: mail arriving later never shifts a resumed scan. A sparse or heavily filtered page can be empty and still carry a continuation.

kotlin
suspend fun pageThroughInbox(mailbox: Mailbox, inbox: FolderPath): Int {
    var checkpoint: ScanCheckpoint? = null
    var seen = 0
    do {
        val page = mailbox.messages.page(
            MessageSelection(inbox, newestFirst = true, checkpoint = checkpoint),
            limit = 50,
        )
        seen += page.envelopes.size // a sparse page can be empty and still continue
        checkpoint = page.next // persist this only after the page is durably processed
    } while (checkpoint != null)
    return seen
}

Streaming history ​

envelopes streams a frozen snapshot as a cold Flow, batching fetches behind the scenes:

kotlin
suspend fun streamHistory(mailbox: Mailbox, inbox: FolderPath, since: Instant) {
    val selection = MessageSelection(inbox, range = MessageRange.Dates(from = since, before = null))
    mailbox.messages.envelopes(selection).collect { envelope ->
        println("UID ${envelope.location.uid} size=${envelope.advertisedSize}")
    }
}

Selective download ​

The same pipeline serves historical scans and live arrivals, and MailKT needs to know nothing about your rules:

kotlin
/** Envelope, then structure, then only the surviving parts: rejected mail costs no content download. */
suspend fun ingestInvoices(mailbox: Mailbox, inbox: FolderPath, senders: Set<String>): List<ByteArray> {
    val pdfs = mutableListOf<ByteArray>()
    val selection = MessageSelection(inbox, MessageQuery(from = senders.map { MailAddress(it) }.toSet()))
    mailbox.messages.envelopes(selection).collect { envelope ->
        val structure = mailbox.messages.structure(envelope.location)
        structure.attachments
            .filter { it.isPdf && it.fileName?.startsWith("invoice", ignoreCase = true) == true }
            .forEach { part ->
                pdfs += mailbox.messages.download(part.ref, maxBytes = 10L * 1024 * 1024).content.toByteArray()
            }
    }
    return pdfs
}

download requires an explicit maxBytes. Exceeding it throws MailException.LimitExceeded; content is never silently truncated.

Full messages ​

For simple cases, messages streams complete MailMessages and still filters envelopes before fetching any content:

kotlin
suspend fun readFullMessages(mailbox: Mailbox, inbox: FolderPath) {
    mailbox.messages.messages(MessageSelection(inbox, MessageQuery(seen = false))).collect { message ->
        println("Unread message with ${message.attachments.size} attachments; text length ${message.plainText?.length ?: 0}")
    }
}

get(location) fetches a single complete message. Both default to a 25 MiB limit per message (Messages.DEFAULT_MAX_BYTES).

Integrity ​

A MessagePartRef carries the mailbox, folder, UIDVALIDITY, UID and MIME section, and only works with the mailbox that produced it. If between selection and download the message was expunged, UIDVALIDITY changed, or the part changed, you get a typed failure. Another message or part is never substituted.

SituationException
Message expunged or movedMailException.MessageUnavailable
UIDVALIDITY changed, part changedMailException.IntegrityViolation(kind)
Larger than the limitMailException.LimitExceeded

In a real app ​

An invoice importer that backfills a mailbox once and can be stopped and resumed at any time. It filters by sender on the server, skips messages it has already imported, downloads only PDF attachments, and stores its scan position after each page.

Your types

Types such as AccountRepository or Database below stand for your own application code. Everything else is MailKT API.

kotlin
class InvoiceImporter(
    private val invoices: InvoiceRepository,       // your persistence
    private val checkpoints: CheckpointRepository, // your persistence
) {
    private val maxPdfBytes = 15L * 1024 * 1024

    suspend fun backfill(mailbox: Mailbox, inbox: FolderPath, vendors: Set<MailAddress>, since: Instant) {
        val query = MessageQuery(from = vendors, receivedFrom = since)
        var checkpoint: ScanCheckpoint? = checkpoints.loadScan(mailbox.id, job = "invoices")

        do {
            val page = mailbox.messages.page(MessageSelection(inbox, query, checkpoint = checkpoint), limit = 100)
            page.envelopes.forEach { importOne(mailbox, it) }

            checkpoint = page.next
            checkpoints.saveScan(mailbox.id, job = "invoices", checkpoint) // null means finished
        } while (checkpoint != null)
    }

    private suspend fun importOne(mailbox: Mailbox, envelope: MessageEnvelope) {
        val messageId = envelope.messageId ?: return
        if (invoices.exists(mailbox.id, messageId)) return // already imported in an earlier run

        val structure = try {
            mailbox.messages.structure(envelope.location)
        } catch (e: MailException.MessageUnavailable) {
            return // deleted in the meantime
        }

        structure.attachments
            .filter { it.isPdf && (it.advertisedSize ?: 0) <= maxPdfBytes }
            .forEach { part ->
                val pdf = mailbox.messages.download(part.ref, maxBytes = maxPdfBytes)
                invoices.save(
                    mailbox = mailbox.id,
                    messageId = messageId,
                    fileName = part.fileName ?: "invoice.pdf",
                    receivedAt = envelope.receivedAt,
                    bytes = pdf.content.toByteArray(),
                )
            }
    }
}

Messages from other senders never leave the server, and a message with a 40 MB video attachment costs one structure fetch, not a 40 MB download.

Released under the MIT License.