ImapClient.deleteMessage and moveMessages flagged the target \Deleted then called the untargeted Folder.expunge(), which permanently removes EVERY \Deleted-flagged message in the folder — not just the intended UIDs. That is a data-loss window whenever a second client, Gmail, or a partial earlier move has left other messages flagged \Deleted. The repository's batch delete/expunge and trash-fallback paths also looped single-UID deleteMessage, paying N logins + N expunges for an N-message selection. Add a batch deleteMessages(uids) that opens the folder once, flags the matched messages \Deleted, and issues a single targeted UID EXPUNGE (RFC 4315) via IMAPFolder.expunge(Message[]) through a shared expungeTargeted() helper. Route moveMessages through the same helper, delegate single-UID deleteMessage to deleteMessages, and route MailRepositoryImpl.expunge and the moveByRole trash fallback through the batch method. moveToFolder already batches via moveMessages, so it inherits the targeted expunge. On a server without UIDPLUS, Angus raises "UID EXPUNGE not supported" rather than silently falling back to the unrelated-mail-destroying untargeted expunge — a loud failure is the safe outcome. Gmail, Outlook, and GreenMail all advertise UIDPLUS. Tests (GreenMail, no emulator): deleteMessages/moveMessages expunge only the given UIDs and spare other \Deleted-flagged mail; a batch delete of three messages opens exactly one connection and pays one LOGIN. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
489 lines
22 KiB
Kotlin
489 lines
22 KiB
Kotlin
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
package org.libremail.mail
|
|
|
|
import android.util.Log
|
|
import com.icegreen.greenmail.util.GreenMail
|
|
import com.icegreen.greenmail.util.GreenMailUtil
|
|
import com.icegreen.greenmail.util.ServerSetupTest
|
|
import io.mockk.every
|
|
import io.mockk.mockkStatic
|
|
import io.mockk.unmockkAll
|
|
import jakarta.activation.DataHandler
|
|
import jakarta.mail.Flags
|
|
import jakarta.mail.Folder
|
|
import jakarta.mail.Message
|
|
import jakarta.mail.Part
|
|
import jakarta.mail.Session
|
|
import jakarta.mail.internet.InternetAddress
|
|
import jakarta.mail.internet.MimeBodyPart
|
|
import jakarta.mail.internet.MimeMessage
|
|
import jakarta.mail.internet.MimeMultipart
|
|
import jakarta.mail.util.ByteArrayDataSource
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.cancelAndJoin
|
|
import kotlinx.coroutines.channels.Channel
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.runBlocking
|
|
import kotlinx.coroutines.test.runTest
|
|
import kotlinx.coroutines.withTimeout
|
|
import org.junit.After
|
|
import org.junit.Before
|
|
import org.junit.Test
|
|
import org.libremail.domain.model.ImapConnectionParams
|
|
import org.libremail.domain.model.MailSecurity
|
|
import java.util.Properties
|
|
import kotlin.test.assertContentEquals
|
|
import kotlin.test.assertEquals
|
|
import kotlin.test.assertFailsWith
|
|
import kotlin.test.assertFalse
|
|
import kotlin.test.assertTrue
|
|
|
|
class ImapClientTest {
|
|
|
|
private lateinit var greenMail: GreenMail
|
|
private val client = ImapClient()
|
|
|
|
@Before
|
|
fun setUp() {
|
|
greenMail = GreenMail(ServerSetupTest.SMTP_IMAP)
|
|
greenMail.start()
|
|
greenMail.setUser("alice@example.org", "secret")
|
|
}
|
|
|
|
@After
|
|
fun tearDown() {
|
|
greenMail.stop()
|
|
unmockkAll()
|
|
}
|
|
|
|
private fun params(secret: String = "secret") = ImapConnectionParams(
|
|
host = "127.0.0.1",
|
|
port = greenMail.imap.port,
|
|
security = MailSecurity.NONE,
|
|
username = "alice@example.org",
|
|
secret = secret,
|
|
useXoauth2 = false,
|
|
)
|
|
|
|
@Test
|
|
fun `listFolders returns INBOX for a valid login`() = runTest {
|
|
assertTrue(client.listFolders(params()).any { it.fullName.equals("INBOX", ignoreCase = true) })
|
|
}
|
|
|
|
@Test
|
|
fun `listFolders fails for a wrong password`() = runTest {
|
|
assertFailsWith<Exception> { client.listFolders(params(secret = "wrong-password")) }
|
|
}
|
|
|
|
@Test
|
|
fun `fetchRecentInbox returns delivered messages newest first`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "First subject", "Body one")
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Second subject", "Body two")
|
|
greenMail.waitForIncomingEmail(2)
|
|
|
|
val messages = client.fetchRecent(params(), "INBOX", limit = 50)
|
|
|
|
assertEquals(2, messages.size)
|
|
assertEquals("Second subject", messages.first().subject)
|
|
assertEquals(setOf("First subject", "Second subject"), messages.map { it.subject }.toSet())
|
|
assertEquals("bob@example.org", messages.first { it.subject == "First subject" }.senderEmail)
|
|
}
|
|
|
|
@Test
|
|
fun `fetchBodyMarkingSeen returns the body and marks the message read`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Hello", "The quick brown fox.")
|
|
greenMail.waitForIncomingEmail(1)
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
|
|
|
|
val content = client.fetchBodyMarkingSeen(params(), "INBOX", uid)
|
|
|
|
assertTrue(content.body.contains("quick brown fox"), "body=${content.body}")
|
|
assertTrue(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should be marked read")
|
|
}
|
|
|
|
@Test
|
|
fun `search returns only messages matching the query`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Vacation plans", "Beach trip")
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "carol@example.org", "Invoice 42", "Payment due")
|
|
greenMail.waitForIncomingEmail(2)
|
|
|
|
val results = client.search(params(), "INBOX", query = "Vacation", limit = 50)
|
|
|
|
assertEquals(1, results.size)
|
|
assertEquals("Vacation plans", results.first().subject)
|
|
}
|
|
|
|
@Test
|
|
fun `listFolders includes a created non-inbox folder`() = runTest {
|
|
appendMessage("Archive", "bob@example.org", "Archived", "Stored away")
|
|
|
|
val names = client.listFolders(params()).map { it.fullName }
|
|
|
|
assertTrue(names.any { it.equals("INBOX", ignoreCase = true) })
|
|
assertTrue(names.any { it.equals("Archive", ignoreCase = true) }, "folders=$names")
|
|
}
|
|
|
|
@Test
|
|
fun `moveMessages relocates a message to another folder`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Move me", "Relocate this")
|
|
greenMail.waitForIncomingEmail(1)
|
|
appendMessage("Archive", "carol@example.org", "Seed", "Creates the Archive folder")
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first { it.subject == "Move me" }.uid
|
|
|
|
client.moveMessages(params(), "INBOX", listOf(uid), "Archive")
|
|
|
|
val inbox = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject }
|
|
val archive = client.fetchRecent(params(), "Archive", limit = 50).map { it.subject }
|
|
assertFalse(inbox.contains("Move me"), "inbox=$inbox")
|
|
assertTrue(archive.contains("Move me"), "archive=$archive")
|
|
}
|
|
|
|
@Test
|
|
fun `fetchForReply returns recipients and body without marking the message read`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Question", "What time?")
|
|
greenMail.waitForIncomingEmail(1)
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
|
|
|
|
val context = client.fetchForReply(params(), "INBOX", uid)
|
|
|
|
assertEquals("bob@example.org", context.fromEmail)
|
|
assertTrue(context.toRecipients.contains("alice@example.org"), "to=${context.toRecipients}")
|
|
assertTrue(context.body.contains("What time?"), "body=${context.body}")
|
|
assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should stay unread")
|
|
}
|
|
|
|
@Test
|
|
fun `fetchBodyPeek returns body and attachments without marking the message read`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Peek", "Peek body text")
|
|
greenMail.waitForIncomingEmail(1)
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
|
|
|
|
val content = client.fetchBodyPeek(params(), "INBOX", uid)
|
|
|
|
assertTrue(content.body.contains("Peek body text"), "body=${content.body}")
|
|
assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isRead, "should stay unread")
|
|
}
|
|
|
|
@Test
|
|
fun `fetchBodyPeek splits inline cid images from real attachments`() = runTest {
|
|
// A digest-style message: multipart/related(html + inline image) alongside a real attachment.
|
|
appendInlineImageDigest()
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
|
|
|
|
val content = client.fetchBodyPeek(params(), "INBOX", uid)
|
|
|
|
assertTrue(content.isHtml, "the html body must be chosen")
|
|
assertTrue(content.body.contains("cid:logo1"), "body=${content.body}")
|
|
// Both parts are collected (so the inline image's bytes are fetchable by index), but only the
|
|
// inline one carries a Content-ID — the reader filters on that to keep it out of the list.
|
|
assertEquals(2, content.attachments.size, "attachments=${content.attachments}")
|
|
val inline = content.attachments.single { it.contentId != null }
|
|
assertEquals("logo1", inline.contentId)
|
|
assertEquals("logo.png", inline.filename)
|
|
assertTrue(inline.mimeType.equals("image/png", ignoreCase = true), "mime=${inline.mimeType}")
|
|
val attachment = content.attachments.single { it.contentId == null }
|
|
assertEquals("invoice.pdf", attachment.filename)
|
|
}
|
|
|
|
@Test
|
|
fun `fetchRecent reads a non-inbox folder isolated from the inbox`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Inbox subject", "In the inbox")
|
|
greenMail.waitForIncomingEmail(1)
|
|
appendMessage("Archive", "carol@example.org", "Archived subject", "In the archive")
|
|
|
|
val archive = client.fetchRecent(params(), "Archive", limit = 50)
|
|
assertEquals(1, archive.size)
|
|
assertEquals("Archived subject", archive.first().subject)
|
|
|
|
// The archived message must not leak into the inbox (UIDs are per-folder).
|
|
val inbox = client.fetchRecent(params(), "INBOX", limit = 50)
|
|
assertEquals(setOf("Inbox subject"), inbox.map { it.subject }.toSet())
|
|
}
|
|
|
|
@Test
|
|
fun `fetchAttachment downloads a part's bytes by its index`() = runTest {
|
|
appendInlineImageDigest() // part 0 = inline logo.png, part 1 = invoice.pdf
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
|
|
|
|
val inline = client.fetchAttachment(params(), "INBOX", uid, 0)
|
|
val attachment = client.fetchAttachment(params(), "INBOX", uid, 1)
|
|
|
|
assertEquals("logo.png", inline.filename)
|
|
assertContentEquals(byteArrayOf(1, 2, 3, 4), inline.bytes)
|
|
assertEquals("invoice.pdf", attachment.filename)
|
|
assertContentEquals(byteArrayOf(5, 6, 7), attachment.bytes)
|
|
assertTrue(attachment.mimeType.contains("pdf", ignoreCase = true), attachment.mimeType)
|
|
}
|
|
|
|
@Test
|
|
fun `fetchAttachment fails for an out-of-range part index`() = runTest {
|
|
appendInlineImageDigest()
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
|
|
|
|
assertFailsWith<Exception> { client.fetchAttachment(params(), "INBOX", uid, 99) }
|
|
}
|
|
|
|
@Test
|
|
fun `fetchAttachment fails when the message is not found`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Solo", "Body")
|
|
greenMail.waitForIncomingEmail(1)
|
|
|
|
assertFailsWith<Exception> { client.fetchAttachment(params(), "INBOX", "999999", 0) }
|
|
}
|
|
|
|
@Test
|
|
fun `setFlag marks a message flagged on the server`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Star me", "Body")
|
|
greenMail.waitForIncomingEmail(1)
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
|
|
|
|
client.setFlag(params(), "INBOX", uid, Flags.Flag.FLAGGED, value = true)
|
|
|
|
assertTrue(client.fetchRecent(params(), "INBOX", limit = 50).first().isFlagged, "should be flagged")
|
|
}
|
|
|
|
@Test
|
|
fun `setFlag on an unknown uid is a no-op`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Present", "Body")
|
|
greenMail.waitForIncomingEmail(1)
|
|
|
|
// No message has this uid, so getMessageByUID returns null and setFlag simply does nothing.
|
|
client.setFlag(params(), "INBOX", "999999", Flags.Flag.FLAGGED, value = true)
|
|
|
|
assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isFlagged)
|
|
}
|
|
|
|
@Test
|
|
fun `deleteMessage expunges the message from the folder`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Delete me", "Body")
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Keep me", "Body")
|
|
greenMail.waitForIncomingEmail(2)
|
|
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first { it.subject == "Delete me" }.uid
|
|
|
|
client.deleteMessage(params(), "INBOX", uid)
|
|
|
|
val remaining = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject }
|
|
assertFalse(remaining.contains("Delete me"), "remaining=$remaining")
|
|
assertTrue(remaining.contains("Keep me"), "remaining=$remaining")
|
|
}
|
|
|
|
@Test
|
|
fun `deleteMessages expunges only the given uids and spares other Deleted-flagged mail`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Target", "delete this")
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Bystander", "flagged elsewhere")
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Untouched", "no flag at all")
|
|
greenMail.waitForIncomingEmail(3)
|
|
val bySubject = client.fetchRecent(params(), "INBOX", limit = 50).associateBy { it.subject }
|
|
|
|
// Simulate a second client (or a partial earlier move): "Bystander" is flagged \Deleted but has
|
|
// NOT been expunged — setFlag closes the folder with expunge=false, so the flag persists.
|
|
client.setFlag(params(), "INBOX", bySubject.getValue("Bystander").uid, Flags.Flag.DELETED, value = true)
|
|
|
|
// Delete only "Target". The old untargeted mailbox.expunge() would ALSO permanently drop the
|
|
// \Deleted "Bystander"; a targeted UID EXPUNGE removes just the target (issue #295).
|
|
client.deleteMessages(params(), "INBOX", listOf(bySubject.getValue("Target").uid))
|
|
|
|
val remaining = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject }
|
|
assertFalse(remaining.contains("Target"), "the targeted message must be expunged, remaining=$remaining")
|
|
assertTrue(remaining.contains("Bystander"), "an unrelated \\Deleted message must survive, remaining=$remaining")
|
|
assertTrue(remaining.contains("Untouched"), "an unflagged message must survive, remaining=$remaining")
|
|
}
|
|
|
|
@Test
|
|
fun `deleteMessages removes every uid in the batch`() = runTest {
|
|
listOf("A", "B", "C", "D").forEach {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", it, "Body $it")
|
|
}
|
|
greenMail.waitForIncomingEmail(4)
|
|
val bySubject = client.fetchRecent(params(), "INBOX", limit = 50).associateBy { it.subject }
|
|
|
|
client.deleteMessages(params(), "INBOX", listOf(bySubject.getValue("A").uid, bySubject.getValue("C").uid))
|
|
|
|
val remaining = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject }.toSet()
|
|
assertEquals(setOf("B", "D"), remaining, "remaining=$remaining")
|
|
}
|
|
|
|
@Test
|
|
fun `moveMessages expunges only the moved uids and spares other Deleted-flagged mail`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Move me", "relocate this")
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Bystander", "flagged elsewhere")
|
|
greenMail.waitForIncomingEmail(2)
|
|
appendMessage("Archive", "carol@example.org", "Seed", "Creates the Archive folder")
|
|
val bySubject = client.fetchRecent(params(), "INBOX", limit = 50).associateBy { it.subject }
|
|
// "Bystander" is flagged \Deleted (by another client) but left in the source folder, un-expunged.
|
|
client.setFlag(params(), "INBOX", bySubject.getValue("Bystander").uid, Flags.Flag.DELETED, value = true)
|
|
|
|
client.moveMessages(params(), "INBOX", listOf(bySubject.getValue("Move me").uid), "Archive")
|
|
|
|
val inbox = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject }
|
|
assertFalse(inbox.contains("Move me"), "the moved message must leave the source, inbox=$inbox")
|
|
// The move's expunge must be targeted: an unrelated \Deleted message must NOT be dropped (#295).
|
|
assertTrue(inbox.contains("Bystander"), "an unrelated \\Deleted message must survive the move, inbox=$inbox")
|
|
val archive = client.fetchRecent(params(), "Archive", limit = 50).map { it.subject }
|
|
assertTrue(archive.contains("Move me"), "archive=$archive")
|
|
}
|
|
|
|
@Test
|
|
fun `fetchRecent returns empty for an empty folder`() = runTest {
|
|
createFolder("Empty")
|
|
|
|
assertTrue(client.fetchRecent(params(), "Empty", limit = 50).isEmpty())
|
|
}
|
|
|
|
@Test
|
|
fun `body and reply fetches fail for an unknown uid`() = runTest {
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Present", "Body")
|
|
greenMail.waitForIncomingEmail(1)
|
|
|
|
assertFailsWith<Exception> { client.fetchBodyMarkingSeen(params(), "INBOX", "999999") }
|
|
assertFailsWith<Exception> { client.fetchBodyPeek(params(), "INBOX", "999999") }
|
|
assertFailsWith<Exception> { client.fetchForReply(params(), "INBOX", "999999") }
|
|
}
|
|
|
|
@Test
|
|
fun `STARTTLS and XOAUTH2 params drive the corresponding connection properties`() = runTest {
|
|
// GreenMail here is plaintext with no XOAUTH2, so the connect fails — but buildProps has already
|
|
// run, which is the STARTTLS + XOAUTH2 property wiring this exercises.
|
|
val params = ImapConnectionParams(
|
|
host = "127.0.0.1",
|
|
port = greenMail.imap.port,
|
|
security = MailSecurity.STARTTLS,
|
|
username = "alice@example.org",
|
|
secret = "secret",
|
|
useXoauth2 = true,
|
|
strictStartTls = true,
|
|
)
|
|
|
|
assertFailsWith<Exception> { client.listFolders(params) }
|
|
}
|
|
|
|
@Test
|
|
fun `idle syncs once on connect, again on newly delivered mail, and stops on cancel`() = runBlocking {
|
|
mockkStatic(Log::class) // idle() logs connect/push at debug level
|
|
every { Log.d(any(), any()) } returns 0
|
|
val activity = Channel<Unit>(Channel.UNLIMITED)
|
|
val job = launch(Dispatchers.IO) { client.idle(params()) { activity.send(Unit) } }
|
|
try {
|
|
// A sync fires immediately on connect to catch anything already waiting...
|
|
withTimeout(IDLE_TIMEOUT_MS) { activity.receive() }
|
|
// ...then an IMAP IDLE push fires another when new mail arrives.
|
|
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Pushed", "Body")
|
|
withTimeout(IDLE_TIMEOUT_MS) { activity.receive() }
|
|
} finally {
|
|
job.cancelAndJoin() // cancelling closes the connection and unblocks idle()
|
|
}
|
|
assertTrue(job.isCompleted, "the idle loop must terminate on cancellation")
|
|
}
|
|
|
|
/** Creates [folderName] (holding messages) if it does not already exist. */
|
|
private fun createFolder(folderName: String) {
|
|
val props = Properties().apply {
|
|
put("mail.store.protocol", "imap")
|
|
put("mail.imap.host", "127.0.0.1")
|
|
put("mail.imap.port", greenMail.imap.port.toString())
|
|
}
|
|
val store = Session.getInstance(props).getStore("imap")
|
|
store.connect("127.0.0.1", greenMail.imap.port, "alice@example.org", "secret")
|
|
try {
|
|
val folder = store.getFolder(folderName)
|
|
if (!folder.exists()) folder.create(Folder.HOLDS_MESSAGES)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Appends a rich digest to the INBOX: a `multipart/mixed` of a `multipart/related` (HTML body
|
|
* referencing an inline image via `cid:logo1`) plus a genuine PDF attachment — the shape that
|
|
* regressed inline images into the attachment list (issue #133).
|
|
*/
|
|
private fun appendInlineImageDigest() {
|
|
val props = Properties().apply {
|
|
put("mail.store.protocol", "imap")
|
|
put("mail.imap.host", "127.0.0.1")
|
|
put("mail.imap.port", greenMail.imap.port.toString())
|
|
}
|
|
val session = Session.getInstance(props)
|
|
|
|
val htmlPart = MimeBodyPart().apply {
|
|
setContent("<html><body><img src=\"cid:logo1\"><p>Hello</p></body></html>", "text/html; charset=utf-8")
|
|
}
|
|
val inlineImage = MimeBodyPart().apply {
|
|
dataHandler = DataHandler(ByteArrayDataSource(byteArrayOf(1, 2, 3, 4), "image/png"))
|
|
contentID = "<logo1>"
|
|
disposition = Part.INLINE
|
|
fileName = "logo.png"
|
|
}
|
|
val related = MimeBodyPart().apply {
|
|
setContent(
|
|
MimeMultipart("related").apply {
|
|
addBodyPart(htmlPart)
|
|
addBodyPart(inlineImage)
|
|
},
|
|
)
|
|
}
|
|
val attachment = MimeBodyPart().apply {
|
|
dataHandler = DataHandler(ByteArrayDataSource(byteArrayOf(5, 6, 7), "application/pdf"))
|
|
disposition = Part.ATTACHMENT
|
|
fileName = "invoice.pdf"
|
|
}
|
|
val message = MimeMessage(session).apply {
|
|
setFrom(InternetAddress("bob@example.org"))
|
|
setRecipient(Message.RecipientType.TO, InternetAddress("alice@example.org"))
|
|
subject = "Daily Digest"
|
|
setContent(
|
|
MimeMultipart("mixed").apply {
|
|
addBodyPart(related)
|
|
addBodyPart(attachment)
|
|
},
|
|
)
|
|
// Flush each part's Content-Type/Content-ID/Content-Disposition into headers so the
|
|
// appended raw MIME round-trips them (without this the Content-ID is dropped).
|
|
saveChanges()
|
|
}
|
|
|
|
val store = session.getStore("imap")
|
|
store.connect("127.0.0.1", greenMail.imap.port, "alice@example.org", "secret")
|
|
try {
|
|
val inbox = store.getFolder("INBOX")
|
|
inbox.open(Folder.READ_WRITE)
|
|
inbox.appendMessages(arrayOf(message))
|
|
inbox.close(false)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
}
|
|
|
|
/** Creates [folderName] if needed and appends a message to it, via Jakarta Mail directly. */
|
|
private fun appendMessage(folderName: String, from: String, subject: String, body: String) {
|
|
val props = Properties().apply {
|
|
put("mail.store.protocol", "imap")
|
|
put("mail.imap.host", "127.0.0.1")
|
|
put("mail.imap.port", greenMail.imap.port.toString())
|
|
}
|
|
val session = Session.getInstance(props)
|
|
val store = session.getStore("imap")
|
|
store.connect("127.0.0.1", greenMail.imap.port, "alice@example.org", "secret")
|
|
try {
|
|
val folder = store.getFolder(folderName)
|
|
if (!folder.exists()) folder.create(Folder.HOLDS_MESSAGES)
|
|
folder.open(Folder.READ_WRITE)
|
|
val message = MimeMessage(session).apply {
|
|
setFrom(InternetAddress(from))
|
|
setRecipient(Message.RecipientType.TO, InternetAddress("alice@example.org"))
|
|
this.subject = subject
|
|
setText(body)
|
|
}
|
|
folder.appendMessages(arrayOf(message))
|
|
folder.close(false)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
}
|
|
|
|
private companion object {
|
|
/** Generous ceiling for the in-process IDLE round trip so the assertion never races the server. */
|
|
const val IDLE_TIMEOUT_MS = 15_000L
|
|
}
|
|
}
|