diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /mail/common/src/test/java | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'mail/common/src/test/java')
27 files changed, 3690 insertions, 0 deletions
diff --git a/mail/common/src/test/java/com/fsck/k9/mail/AddressTest.java b/mail/common/src/test/java/com/fsck/k9/mail/AddressTest.java new file mode 100644 index 0000000..5ec0585 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/AddressTest.java @@ -0,0 +1,154 @@ +package com.fsck.k9.mail; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + + +public class AddressTest { + /** + * test the possibility to parse "From:" fields with no email. + * for example: From: News for Vector Limited - Google Finance + * http://code.google.com/p/k9mail/issues/detail?id=3814 + */ + @Test + public void parse_withMissingEmail__shouldSetPersonal() { + Address[] addresses = Address.parse("NAME ONLY"); + + assertEquals(0, addresses.length); + } + + /** + * test name + valid email + */ + @Test + public void parse_withValidEmailAndPersonal_shouldSetBoth() { + Address[] addresses = Address.parse("Max Mustermann <maxmuster@mann.com>"); + + assertEquals(1, addresses.length); + assertEquals("maxmuster@mann.com", addresses[0].getAddress()); + assertEquals("Max Mustermann", addresses[0].getPersonal()); + } + + @Test + public void parse_withUnusualEmails_shouldSetAddress() { + String[] testEmails = new String [] { + "prettyandsimple@example.com", + "very.common@example.com", + "disposable.style.email.with+symbol@example.com", + "other.email-with-dash@example.com", + //TODO: Handle addresses with quotes + /* + "\"much.more unusual\"@example.com", + "\"very.unusual.@.unusual.com\"@example.com", + //"very.(),:;<>[]\".VERY.\"very@\\ \"very\".unusual"@strange.example.com + "\"very.(),:;<>[]\\\".VERY.\\\"very@\\\\ \\\"very\\\".unusual\"@strange.example.com", + "\"()<>[]:,;@\\\\\\\"!#$%&'*+-/=?^_`{}| ~.a\"@example.org", + "\" \"@example.org", + */ + "admin@mailserver1", + "#!$%&'*+-/=?^_`{}|~@example.org", + "example@localhost", + "example@s.solutions", + "user@com", + "user@localserver", + "user@[IPv6:2001:db8::1]" + }; + + for(String testEmail: testEmails) { + Address[] addresses = Address.parse("Anonymous <"+testEmail+">"); + + assertEquals(1, addresses.length); + assertEquals(testEmail, addresses[0].getAddress()); + } + } + + @Test + public void parse_withEncodedPersonal_shouldDecode() { + Address[] addresses = Address.parse( + "=?UTF-8?B?WWFob28h44OA44Kk44Os44Kv44OI44Kq44OV44Kh44O8?= <directoffer-master@mail.yahoo.co.jp>"); + + assertEquals("Yahoo!ダイレクトオファー", addresses[0].getPersonal()); + assertEquals("directoffer-master@mail.yahoo.co.jp", addresses[0].getAddress()); + + } + + @Test + public void parse_withQuotedEncodedPersonal_shouldDecode() { + Address[] addresses = Address.parse( + "\"=?UTF-8?B?WWFob28h44OA44Kk44Os44Kv44OI44Kq44OV44Kh44O8?= \"<directoffer-master@mail.yahoo.co.jp>"); + + assertEquals("Yahoo!ダイレクトオファー ", addresses[0].getPersonal()); + assertEquals("directoffer-master@mail.yahoo.co.jp", addresses[0].getAddress()); + + } + + /** + * test with multi email addresses + */ + @Test + public void parse_withMultipleEmails_shouldDecodeBoth() { + Address[] addresses = Address.parse("lorem@ipsum.us,mark@twain.com"); + assertEquals(2, addresses.length); + assertEquals("lorem@ipsum.us", addresses[0].getAddress()); + assertEquals(null, addresses[0].getPersonal()); + assertEquals("mark@twain.com", addresses[1].getAddress()); + assertEquals(null, addresses[1].getPersonal()); + } + + @Test + public void stringQuotationShouldCorrectlyQuote() { + assertEquals("\"sample\"", Address.quoteString("sample")); + assertEquals("\"\"sample\"\"", Address.quoteString("\"\"sample\"\"")); + assertEquals("\"sample\"", Address.quoteString("\"sample\"")); + assertEquals("\"sa\"mp\"le\"", Address.quoteString("sa\"mp\"le")); + assertEquals("\"sa\"mp\"le\"", Address.quoteString("\"sa\"mp\"le\"")); + assertEquals("\"\"\"", Address.quoteString("\"")); + } + + @Test + public void hashCode_withoutAddress() throws Exception { + Address[] addresses = Address.parse("name only"); + + assertEquals(0, addresses.length); + } + + @Test + public void hashCode_withoutPersonal() throws Exception { + Address address = Address.parse("alice@example.org")[0]; + assertNull(address.getPersonal()); + + address.hashCode(); + } + + @Test + public void equals_withoutPersonal_matchesSame() throws Exception { + Address address = Address.parse("alice@example.org")[0]; + Address address2 = Address.parse("alice@example.org")[0]; + assertNull(address.getPersonal()); + + boolean result = address.equals(address2); + + assertTrue(result); + } + + @Test + public void equals_withoutPersonal_doesNotMatchWithAddress() throws Exception { + Address address = Address.parse("alice@example.org")[0]; + Address address2 = Address.parse("Alice <alice@example.org>")[0]; + + boolean result = address.equals(address2); + + assertFalse(result); + } + + @Test + public void handlesInvalidBase64Encoding() throws Exception { + Address address = Address.parse("=?utf-8?b?invalid#?= <oops@example.com>")[0]; + assertEquals("oops@example.com", address.getAddress()); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/Address_quoteAtoms.java b/mail/common/src/test/java/com/fsck/k9/mail/Address_quoteAtoms.java new file mode 100644 index 0000000..6de5165 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/Address_quoteAtoms.java @@ -0,0 +1,67 @@ +package com.fsck.k9.mail; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class Address_quoteAtoms { + @Test + public void testNoQuote() { + // Alpha + noQuote("a"); + noQuote("aa"); + noQuote("aaa aaa"); + + // Numeric + noQuote("1"); + noQuote("12"); + noQuote("123 456"); + + // Alpha Numeric + noQuote("abc 123"); + + // Specials + noQuote("!"); + noQuote("#"); + noQuote("$"); + noQuote("%"); + noQuote("&"); + noQuote("'"); + noQuote("*"); + noQuote("+"); + noQuote("-"); + noQuote("/"); + noQuote("="); + noQuote("?"); + noQuote("^"); + noQuote("_"); + noQuote("`"); + noQuote("{"); + noQuote("|"); + noQuote("}"); + noQuote("~"); + + // Combos + noQuote("bob barker! #1!"); + noQuote("!"); + noQuote("#&#!"); + noQuote("{|}"); + noQuote("'-=+=-'"); + } + + @Test + public void testQuote() { + assertEquals("\"bob s. barker\"", quote("bob s. barker")); + assertEquals("\":(\"", quote(":(")); + } + + private void noQuote(final String s) { + assertEquals(s, Address.quoteAtoms(s)); + } + + private String quote(final String s) { + return Address.quoteAtoms(s); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.kt new file mode 100644 index 0000000..e08a995 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.kt @@ -0,0 +1,40 @@ +package com.fsck.k9.mail + +import assertk.assertThat +import assertk.assertions.isEqualTo +import java.util.Random +import org.junit.Test +import org.mockito.kotlin.mock + +class BoundaryGeneratorTest { + @Test + fun `generateBoundary() with all zeros`() { + val random = createRandom(0) + val boundaryGenerator = BoundaryGenerator(random) + + val result = boundaryGenerator.generateBoundary() + + assertThat(result).isEqualTo("----000000000000000000000000000000") + } + + @Test + fun generateBoundary() { + val random = createRandom( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 35 + ) + val boundaryGenerator = BoundaryGenerator(random) + + val result = boundaryGenerator.generateBoundary() + + assertThat(result).isEqualTo("----0123456789ABCDEFGHIJKLMNOPQRSZ") + } + + private fun createRandom(vararg values: Int): Random { + return mock { + var ongoingStubbing = on { nextInt(36) } + for (value in values) { + ongoingStubbing = ongoingStubbing.thenReturn(value) + } + } + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/MessageTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/MessageTest.kt new file mode 100644 index 0000000..e904210 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/MessageTest.kt @@ -0,0 +1,341 @@ +package com.fsck.k9.mail + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.internet.BinaryTempFileBody +import com.fsck.k9.mail.internet.BinaryTempFileMessageBody +import com.fsck.k9.mail.internet.MimeBodyPart +import com.fsck.k9.mail.internet.MimeHeader +import com.fsck.k9.mail.internet.MimeMessage +import com.fsck.k9.mail.internet.MimeMessageHelper +import com.fsck.k9.mail.internet.MimeMultipart +import com.fsck.k9.mail.internet.TextBody +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files +import java.util.Date +import java.util.TimeZone +import okio.Buffer +import org.apache.james.mime4j.util.MimeUtil +import org.junit.After +import org.junit.Before +import org.junit.Test + +class MessageTest { + private lateinit var tempDirectory: File + private var mimeBoundary: Int = 0 + + @Before + fun setUp() { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + tempDirectory = Files.createTempDirectory("MessageTest").toFile() + BinaryTempFileBody.setTempDirectory(tempDirectory) + } + + @After + fun tearDown() { + tempDirectory.deleteRecursively() + } + + @Test + fun setSendDate_shouldSetSendDate() { + val message = sampleMessage() + val date = Date(0L) + + message.setSentDate(date, false) + + assertThat(message.sentDate).isEqualTo(date) + } + + @Test + fun setSendDate_withoutHidingTimeZone_shouldCreateDateHeaderWithTimezone() { + val message = sampleMessage() + + message.setSentDate(Date(0L), false) + + assertThat(message.getFirstHeader("Date")).isEqualTo("Thu, 01 Jan 1970 09:00:00 +0900") + } + + @Test + fun setSendDate_withHidingTimeZone_shouldCreateDateHeaderWithTimezoneSetToZero() { + val message = sampleMessage() + + message.setSentDate(Date(0L), true) + + assertThat(message.getFirstHeader("Date")).isEqualTo("Thu, 01 Jan 1970 00:00:00 +0000") + } + + @Test + fun writeTo_withNestedMessage() { + mimeBoundary = 101 + val message = nestedMessage(nestedMessage(sampleMessage())) + val out = ByteArrayOutputStream() + + message.writeTo(out) + + assertThat(out.toString()).isEqualTo( + """ + From: from@example.com + To: to@example.com + Subject: Test Message + Date: Wed, 28 Aug 2013 08:51:09 -0400 + MIME-Version: 1.0 + Content-Type: multipart/mixed; + boundary=----Boundary103 + Content-Transfer-Encoding: 7bit + + ------Boundary103 + Content-Type: text/plain; + charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Testing=2E + This is a text body with some greek characters=2E + =CE=B1=CE=B2=CE=B3=CE=B4=CE=B5=CE=B6=CE=B7=CE=B8 + End of test=2E + + ------Boundary103 + Content-Type: application/octet-stream + Content-Transfer-Encoding: base64 + + ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + + ------Boundary103 + Content-Type: message/rfc822 + Content-Disposition: attachment + Content-Transfer-Encoding: 7bit + + From: from@example.com + To: to@example.com + Subject: Test Message + Date: Wed, 28 Aug 2013 08:51:09 -0400 + MIME-Version: 1.0 + Content-Type: multipart/mixed; + boundary=----Boundary102 + Content-Transfer-Encoding: 7bit + + ------Boundary102 + Content-Type: text/plain; + charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Testing=2E + This is a text body with some greek characters=2E + =CE=B1=CE=B2=CE=B3=CE=B4=CE=B5=CE=B6=CE=B7=CE=B8 + End of test=2E + + ------Boundary102 + Content-Type: application/octet-stream + Content-Transfer-Encoding: base64 + + ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + + ------Boundary102 + Content-Type: message/rfc822 + Content-Disposition: attachment + Content-Transfer-Encoding: 7bit + + From: from@example.com + To: to@example.com + Subject: Test Message + Date: Wed, 28 Aug 2013 08:51:09 -0400 + MIME-Version: 1.0 + Content-Type: multipart/mixed; + boundary=----Boundary101 + Content-Transfer-Encoding: 7bit + + ------Boundary101 + Content-Type: text/plain; + charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Testing=2E + This is a text body with some greek characters=2E + =CE=B1=CE=B2=CE=B3=CE=B4=CE=B5=CE=B6=CE=B7=CE=B8 + End of test=2E + + ------Boundary101 + Content-Type: application/octet-stream + Content-Transfer-Encoding: base64 + + ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + + ------Boundary101-- + + ------Boundary102-- + + ------Boundary103-- + + """.trimIndent().crlf() + ) + } + + @Test + fun toBodyPart() { + mimeBoundary = 101 + val message = nestedMessage(nestedMessage(sampleMessage())) + val out = ByteArrayOutputStream() + val bodyPart = message.toBodyPart() + + bodyPart.writeTo(out) + + assertThat(out.toString()).isEqualTo( + """ + Content-Type: multipart/mixed; + boundary=----Boundary103 + Content-Transfer-Encoding: 7bit + + ------Boundary103 + Content-Type: text/plain; + charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Testing=2E + This is a text body with some greek characters=2E + =CE=B1=CE=B2=CE=B3=CE=B4=CE=B5=CE=B6=CE=B7=CE=B8 + End of test=2E + + ------Boundary103 + Content-Type: application/octet-stream + Content-Transfer-Encoding: base64 + + ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + + ------Boundary103 + Content-Type: message/rfc822 + Content-Disposition: attachment + Content-Transfer-Encoding: 7bit + + From: from@example.com + To: to@example.com + Subject: Test Message + Date: Wed, 28 Aug 2013 08:51:09 -0400 + MIME-Version: 1.0 + Content-Type: multipart/mixed; + boundary=----Boundary102 + Content-Transfer-Encoding: 7bit + + ------Boundary102 + Content-Type: text/plain; + charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Testing=2E + This is a text body with some greek characters=2E + =CE=B1=CE=B2=CE=B3=CE=B4=CE=B5=CE=B6=CE=B7=CE=B8 + End of test=2E + + ------Boundary102 + Content-Type: application/octet-stream + Content-Transfer-Encoding: base64 + + ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + + ------Boundary102 + Content-Type: message/rfc822 + Content-Disposition: attachment + Content-Transfer-Encoding: 7bit + + From: from@example.com + To: to@example.com + Subject: Test Message + Date: Wed, 28 Aug 2013 08:51:09 -0400 + MIME-Version: 1.0 + Content-Type: multipart/mixed; + boundary=----Boundary101 + Content-Transfer-Encoding: 7bit + + ------Boundary101 + Content-Type: text/plain; + charset=utf-8 + Content-Transfer-Encoding: quoted-printable + + Testing=2E + This is a text body with some greek characters=2E + =CE=B1=CE=B2=CE=B3=CE=B4=CE=B5=CE=B6=CE=B7=CE=B8 + End of test=2E + + ------Boundary101 + Content-Type: application/octet-stream + Content-Transfer-Encoding: base64 + + ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ + + ------Boundary101-- + + ------Boundary102-- + + ------Boundary103-- + + """.trimIndent().crlf() + ) + } + + private fun sampleMessage(): MimeMessage { + val message = MimeMessage().apply { + setFrom(Address("from@example.com")) + setHeader("To", "to@example.com") + subject = "Test Message" + setHeader("Date", "Wed, 28 Aug 2013 08:51:09 -0400") + setEncoding(MimeUtil.ENC_7BIT) + } + + val multipartBody = MimeMultipart("multipart/mixed", generateBoundary()).apply { + addBodyPart(textBodyPart()) + addBodyPart(binaryBodyPart()) + } + + MimeMessageHelper.setBody(message, multipartBody) + + return message + } + + private fun nestedMessage(subMessage: MimeMessage): MimeMessage { + val tempMessageBody = BinaryTempFileMessageBody(MimeUtil.ENC_8BIT).apply { + outputStream.use { subMessage.writeTo(it) } + } + + val bodyPart = MimeBodyPart(tempMessageBody, "message/rfc822").apply { + setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, "attachment") + setEncoding(MimeUtil.ENC_7BIT) + } + + return sampleMessage().apply { + val multipart = body as Multipart + multipart.addBodyPart(bodyPart) + } + } + + private fun binaryBodyPart(): MimeBodyPart { + val buffer = Buffer().writeUtf8("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\r\n") + val tempFileBody = BinaryTempFileBody(MimeUtil.ENC_BASE64).apply { + outputStream.use { buffer.writeTo(it) } + } + + return MimeBodyPart(tempFileBody, "application/octet-stream").apply { + setEncoding(MimeUtil.ENC_BASE64) + } + } + + private fun textBodyPart(): MimeBodyPart { + val textBody = TextBody( + """ + Testing. + This is a text body with some greek characters. + αβγδεζηθ + End of test. + + """.trimIndent().crlf() + ) + + return MimeBodyPart().apply { + MimeMessageHelper.setBody(this, textBody) + } + } + + private fun generateBoundary(): String { + return "----Boundary${mimeBoundary++}" + } +} + +private fun Message.getFirstHeader(header: String): String = getHeader(header)[0] diff --git a/mail/common/src/test/java/com/fsck/k9/mail/MimeTypeTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/MimeTypeTest.kt new file mode 100644 index 0000000..611592c --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/MimeTypeTest.kt @@ -0,0 +1,82 @@ +package com.fsck.k9.mail + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFailure +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.message +import assertk.assertions.startsWith +import com.fsck.k9.mail.MimeType.Companion.toMimeType +import com.fsck.k9.mail.MimeType.Companion.toMimeTypeOrNull +import org.junit.Test + +class MimeTypeTest { + @Test + fun commonTypes() { + assertParsedMimeType("text/plain", type = "text", subtype = "plain") + assertParsedMimeType("text/html", type = "text", subtype = "html") + assertParsedMimeType("application/octet-stream", type = "application", subtype = "octet-stream") + assertParsedMimeType("message/rfc822", type = "message", subtype = "rfc822") + assertParsedMimeType("message/global", type = "message", subtype = "global") + assertParsedMimeType("multipart/alternative", type = "multipart", subtype = "alternative") + assertParsedMimeType("multipart/mixed", type = "multipart", subtype = "mixed") + assertParsedMimeType("multipart/encrypted", type = "multipart", subtype = "encrypted") + } + + @Test + fun checkListOfMimeTypes() { + // TODO: Try to parse all IANA-registered media types + // https://www.iana.org/assignments/media-types/media-types.xhtml + } + + @Test + fun lowerCasing() { + assertParsedMimeType("text/plain", type = "text", subtype = "plain") + assertParsedMimeType("text/PLAIN", type = "text", subtype = "plain") + assertParsedMimeType("TEXT/plain", type = "text", subtype = "plain") + assertParsedMimeType("TEXT/PLAIN", type = "text", subtype = "plain") + assertParsedMimeType("TeXt/pLaIn", type = "text", subtype = "plain") + assertParsedMimeType("APPLICATION/OCTET-STREAM", type = "application", subtype = "octet-stream") + } + + @Test + fun invalidMimeTypes() { + assertInvalidMimeType("") + assertInvalidMimeType("text") + assertInvalidMimeType("text plain") + assertInvalidMimeType("image/ png") + assertInvalidMimeType("message /rfc822") + assertInvalidMimeType("application/something(odd)") + } + + @Test + fun invalidMimeTypesReturnNull() { + assertInvalidMimeTypeReturnsNull("") + assertInvalidMimeTypeReturnsNull("text") + assertInvalidMimeTypeReturnsNull("text plain") + assertInvalidMimeTypeReturnsNull("image/ png") + assertInvalidMimeTypeReturnsNull("message /rfc822") + assertInvalidMimeTypeReturnsNull("application/something(odd)") + } + + private fun assertParsedMimeType(input: String, type: String, subtype: String) { + val mimeType = input.toMimeType() + + assertThat(mimeType.type).isEqualTo(type) + assertThat(mimeType.subtype).isEqualTo(subtype) + } + + private fun assertInvalidMimeType(input: String) { + assertThat { + input.toMimeType() + }.isFailure() + .isInstanceOf(IllegalArgumentException::class) + .message().isNotNull().startsWith("Invalid MIME type: ") + } + + private fun assertInvalidMimeTypeReturnsNull(input: String) { + assertThat(input.toMimeTypeOrNull()).isNull() + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/filter/EOLConvertingOutputStreamTest.java b/mail/common/src/test/java/com/fsck/k9/mail/filter/EOLConvertingOutputStreamTest.java new file mode 100644 index 0000000..5091e62 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/filter/EOLConvertingOutputStreamTest.java @@ -0,0 +1,125 @@ +package com.fsck.k9.mail.filter; + + +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; + +import static org.junit.Assert.assertEquals; + + +public class EOLConvertingOutputStreamTest { + private EOLConvertingOutputStream subject; + private ByteArrayOutputStream out; + + @Before + public void setUp() throws Exception { + out = new ByteArrayOutputStream(); + subject = new EOLConvertingOutputStream(out); + } + + @Test + public void testFilterWithoutCRorLF() throws Exception { + subject.write("Unchanged".getBytes()); + subject.flush(); + assertEquals("Unchanged", out.toString()); + } + + @Test + public void testFilterWithCRLF() throws Exception { + subject.write("Filter\r\nNext Line".getBytes()); + subject.flush(); + assertEquals("Filter\r\nNext Line", out.toString()); + } + + @Test + public void testFilterWithJustCR() throws Exception { + subject.write("\n\n\n".getBytes()); + subject.flush(); + assertEquals("\r\n\r\n\r\n", out.toString()); + } + + @Test + public void testFilterWithCR() throws Exception { + subject.write("Filter\rNext Line".getBytes()); + subject.flush(); + assertEquals("Filter\r\nNext Line", out.toString()); + } + + @Test + public void testFilterWithLF() throws Exception { + subject.write("Filter\nNext Line".getBytes()); + subject.flush(); + assertEquals("Filter\r\nNext Line", out.toString()); + } + + @Test + public void testFlushWithCR() throws Exception { + subject.write("Flush\r".getBytes()); + subject.flush(); + assertEquals("Flush\r\n", out.toString()); + subject.write("\n\n\n".getBytes()); + assertEquals("Flush\r\n\r\n\r\n", out.toString()); + } + + @Test + public void testFlushWithCRNotFollowedByLF() throws Exception { + subject.write("Flush\r".getBytes()); + subject.flush(); + subject.write("Next line".getBytes()); + assertEquals("Flush\r\nNext line", out.toString()); + } + + @Test + public void testFlushWithLF() throws Exception { + subject.write("Flush\n".getBytes()); + subject.flush(); + subject.write("\n".getBytes()); + assertEquals("Flush\r\n\r\n", out.toString()); + } + + @Test + public void testFlushWithCrFollowedByLf() throws Exception { + subject.write("Flush\r".getBytes()); + subject.flush(); + subject.write("\n".getBytes()); + assertEquals("Flush\r\n", out.toString()); + } + + @Test + public void endWithCrLfAndFlush_withoutNewline_shouldAddNewline() throws Exception { + subject.write("The end".getBytes()); + + subject.endWithCrLfAndFlush(); + + assertEquals("The end\r\n", out.toString()); + } + + @Test + public void endWithCrLfAndFlush_endingWithNewline_shouldNotAddAdditionalNewline() throws Exception { + subject.write("The end\r\n".getBytes()); + + subject.endWithCrLfAndFlush(); + + assertEquals("The end\r\n", out.toString()); + } + + @Test + public void endWithCrLfAndFlush_endingWithCr_shouldCompleteNewline() throws Exception { + subject.write("The end\r".getBytes()); + + subject.endWithCrLfAndFlush(); + + assertEquals("The end\r\n", out.toString()); + } + + @Test + public void endWithCrLfAndFlush_endingWithLf_shouldCompleteNewline() throws Exception { + subject.write("The end\n".getBytes()); + + subject.endWithCrLfAndFlush(); + + assertEquals("The end\r\n", out.toString()); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/filter/FixedLengthInputStreamTest.java b/mail/common/src/test/java/com/fsck/k9/mail/filter/FixedLengthInputStreamTest.java new file mode 100644 index 0000000..b750b5d --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/filter/FixedLengthInputStreamTest.java @@ -0,0 +1,273 @@ +package com.fsck.k9.mail.filter; + + +import java.io.IOException; +import java.io.InputStream; + +import okio.Buffer; +import okio.ByteString; +import okio.Okio; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class FixedLengthInputStreamTest { + @Test + public void readingStream_shouldReturnDataUpToLimit() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello world"), 5); + + String readString = readStreamAsUtf8String(fixedLengthInputStream); + + assertEquals("Hello", readString); + } + + @Test + public void readingStream_shouldNotConsumeMoreThanLimitFromUnderlyingInputStream() throws Exception { + InputStream inputStream = inputStream("Hello world"); + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream, 5); + + exhaustStream(fixedLengthInputStream); + + assertRemainingInputStreamEquals(" world", inputStream); + } + + @Test + //TODO: Maybe this should throw. The underlying stream delivering less bytes than expected is most likely an error. + public void readingStream_withLimitGreaterThanNumberOfBytesInUnderlyingInputStream() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 100); + + String readString = readStreamAsUtf8String(fixedLengthInputStream); + + assertEquals("Hello World", readString); + } + + @Test + public void read_withOverSizedByteArray_shouldReturnDataUpToLimit() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 6); + + byte[] data = new byte[100]; + int numberOfBytesRead = fixedLengthInputStream.read(data); + + assertEquals(6, numberOfBytesRead); + assertEquals("Hello ", ByteString.of(data, 0, numberOfBytesRead).utf8()); + } + + @Test + public void read_withOverSizedByteArray_shouldNotConsumeMoreThanLimitFromUnderlyingStream() throws Exception { + InputStream inputStream = inputStream("Hello World"); + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream, 6); + + //noinspection ResultOfMethodCallIgnored + fixedLengthInputStream.read(new byte[100]); + + assertRemainingInputStreamEquals("World", inputStream); + } + + @Test + public void read_withByteArraySmallerThanLimit_shouldConsumeSizeOfByteArray() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 6); + + byte[] data = new byte[5]; + int numberOfBytesRead = fixedLengthInputStream.read(data); + + assertEquals(5, numberOfBytesRead); + assertEquals("Hello", ByteString.of(data).utf8()); + } + + @Test + public void read_withOverSizedByteArrayInMiddleOfStream_shouldReturnDataUpToLimit() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 6); + consumeBytes(fixedLengthInputStream, 5); + + byte[] data = new byte[10]; + int numberOfBytesRead = fixedLengthInputStream.read(data); + + assertEquals(1, numberOfBytesRead); + assertEquals(" ", ByteString.of(data, 0, numberOfBytesRead).utf8()); + } + + @Test + public void read_withOverSizedByteArrayInMiddleOfStream_shouldNotConsumeMoreThanLimitFromUnderlyingStream() + throws Exception { + InputStream inputStream = inputStream("Hello World"); + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream, 6); + consumeBytes(fixedLengthInputStream, 5); + + //noinspection ResultOfMethodCallIgnored + fixedLengthInputStream.read(new byte[10]); + + assertRemainingInputStreamEquals("World", inputStream); + } + + @Test + public void read_atStartOfStream() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Word"), 2); + + int readByte = fixedLengthInputStream.read(); + + assertEquals('W', (char) readByte); + } + + @Test + public void read_inMiddleOfStream() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Word"), 2); + consumeBytes(fixedLengthInputStream, 1); + + int readByte = fixedLengthInputStream.read(); + + assertEquals('o', (char) readByte); + } + + @Test + public void read_atEndOfStream_shouldReturnMinusOne() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello world"), 5); + exhaustStream(fixedLengthInputStream); + + int readByte = fixedLengthInputStream.read(); + + assertEquals(-1, readByte); + } + + @Test + public void readArray_atEndOfStream_shouldReturnMinusOne() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello world"), 5); + exhaustStream(fixedLengthInputStream); + + int numberOfBytesRead = fixedLengthInputStream.read(new byte[2]); + + assertEquals(-1, numberOfBytesRead); + } + + @Test + public void readArrayWithOffset_atEndOfStream_shouldReturnMinusOne() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello world"), 5); + exhaustStream(fixedLengthInputStream); + + int numberOfBytesRead = fixedLengthInputStream.read(new byte[2], 0, 2); + + assertEquals(-1, numberOfBytesRead); + } + + @Test + public void available_atStartOfStream() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 5); + + int available = fixedLengthInputStream.available(); + + assertEquals(5, available); + } + + @Test + public void available_afterPartialRead() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 5); + //noinspection ResultOfMethodCallIgnored + fixedLengthInputStream.read(); + + int available = fixedLengthInputStream.available(); + + assertEquals(4, available); + } + + @Test + public void available_afterPartialReadArray() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 5); + consumeBytes(fixedLengthInputStream, 2); + + int available = fixedLengthInputStream.available(); + + assertEquals(3, available); + } + + @Test + public void available_afterStreamHasBeenExhausted() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 5); + exhaustStream(fixedLengthInputStream); + + int available = fixedLengthInputStream.available(); + + assertEquals(0, available); + } + + @Test + public void available_afterSkip() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 5); + guaranteedSkip(fixedLengthInputStream, 2); + + int available = fixedLengthInputStream.available(); + + assertEquals(3, available); + } + + @Test + public void available_afterSkipRemaining() throws Exception { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 5); + fixedLengthInputStream.skipRemaining(); + + int available = fixedLengthInputStream.available(); + + assertEquals(0, available); + } + + @Test + public void skip_shouldConsumeBytes() throws IOException { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 5); + + guaranteedSkip(fixedLengthInputStream, 2); + + assertRemainingInputStreamEquals("llo", fixedLengthInputStream); + } + + @Test + public void skipRemaining_shouldExhaustStream() throws IOException { + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream("Hello World"), 5); + + fixedLengthInputStream.skipRemaining(); + + assertInputStreamExhausted(fixedLengthInputStream); + } + + @Test + public void skipRemaining_shouldNotConsumeMoreThanLimitFromUnderlyingInputStream() throws IOException { + InputStream inputStream = inputStream("Hello World"); + FixedLengthInputStream fixedLengthInputStream = new FixedLengthInputStream(inputStream, 6); + + fixedLengthInputStream.skipRemaining(); + + assertRemainingInputStreamEquals("World", inputStream); + } + + + private String readStreamAsUtf8String(InputStream inputStream) throws IOException { + return Okio.buffer(Okio.source(inputStream)).readUtf8(); + } + + private void exhaustStream(InputStream inputStream) throws IOException { + Okio.buffer(Okio.source(inputStream)).readAll(Okio.blackhole()); + } + + private void consumeBytes(InputStream inputStream, int numberOfBytes) throws IOException { + int read = inputStream.read(new byte[numberOfBytes]); + assertEquals(numberOfBytes, read); + } + + private void guaranteedSkip(InputStream inputStream, int numberOfBytesToSkip) throws IOException { + int remaining = numberOfBytesToSkip; + while (remaining > 0) { + remaining -= inputStream.skip(remaining); + } + assertEquals(0, remaining); + } + + private void assertRemainingInputStreamEquals(String expected, InputStream inputStream) throws IOException { + assertEquals(expected, readStreamAsUtf8String(inputStream)); + } + + private void assertInputStreamExhausted(InputStream inputStream) throws IOException { + assertEquals(-1, inputStream.read()); + } + + private InputStream inputStream(String data) { + return new Buffer().writeUtf8(data).inputStream(); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/filter/SignSafeOutputStreamTest.java b/mail/common/src/test/java/com/fsck/k9/mail/filter/SignSafeOutputStreamTest.java new file mode 100644 index 0000000..c9013a6 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/filter/SignSafeOutputStreamTest.java @@ -0,0 +1,86 @@ +package com.fsck.k9.mail.filter; + + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +import org.apache.james.mime4j.codec.QuotedPrintableOutputStream; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class SignSafeOutputStreamTest { + private static final String INPUT_STRING = + "It's generally a good idea to encode lines that begin with\r\n" + + "From because some mail transport agents will insert a greater-\r\n" + + "than (>) sign, thus invalidating the signature.\r\n\r\n" + + "Also, in some cases it might be desirable to encode any \r\n" + + "trailing whitespace that occurs on lines in order to ensure \r\n" + + "that the message signature is not invalidated when passing \r\n" + + "a gateway that modifies such whitespace (like BITNET). \r\n\r\n"; + + private static final String EXPECTED_QUOTED_PRINTABLE = + "It's generally a good idea to encode lines that begin with\r\n" + + "From because some mail transport agents will insert a greater-\r\n" + + "than (>) sign, thus invalidating the signature=2E\r\n\r\n" + + "Also, in some cases it might be desirable to encode any =20\r\n" + + "trailing whitespace that occurs on lines in order to ensure =20\r\n" + + "that the message signature is not invalidated when passing =20\r\n" + + "a gateway that modifies such whitespace (like BITNET)=2E =20\r\n\r\n"; + + private static final String EXPECTED_QUOTED_PRINTABLE_SIGNSAFE = + "It's generally a good idea to encode lines that begin with\r\n" + + "From=20because some mail transport agents will insert a greater-\r\n" + + "than (>) sign, thus invalidating the signature=2E\r\n\r\n" + + "Also, in some cases it might be desirable to encode any =20\r\n" + + "trailing whitespace that occurs on lines in order to ensure =20\r\n" + + "that the message signature is not invalidated when passing =20\r\n" + + "a gateway that modifies such whitespace (like BITNET)=2E =20\r\n\r\n"; + + private static final String EXPECTED_SIGNSAFE = + "It's generally a good idea to encode lines that begin with\r\n" + + "From=20because some mail transport agents will insert a greater-\r\n" + + "than (>) sign, thus invalidating the signature.\r\n\r\n" + + "Also, in some cases it might be desirable to encode any \r\n" + + "trailing whitespace that occurs on lines in order to ensure \r\n" + + "that the message signature is not invalidated when passing \r\n" + + "a gateway that modifies such whitespace (like BITNET). \r\n\r\n"; + + @Test + public void testSignSafeOutputStream() throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + OutputStream output = new SignSafeOutputStream(byteArrayOutputStream); + output.write(INPUT_STRING.getBytes("US-ASCII")); + output.close(); + + assertEquals(EXPECTED_SIGNSAFE, new String(byteArrayOutputStream.toByteArray(), "US-ASCII")); + } + + @Test + public void testSignSafeQuotedPrintableOutputStream() throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + OutputStream signSafeOutputStream = new SignSafeOutputStream(byteArrayOutputStream); + OutputStream quotedPrintableOutputStream = new QuotedPrintableOutputStream(signSafeOutputStream, false); + quotedPrintableOutputStream.write(INPUT_STRING.getBytes("US-ASCII")); + quotedPrintableOutputStream.close(); + signSafeOutputStream.close(); + + assertEquals(EXPECTED_QUOTED_PRINTABLE_SIGNSAFE, + new String(byteArrayOutputStream.toByteArray(), "US-ASCII")); + } + + @Test + public void testQuotedPrintableOutputStream() throws IOException { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + + OutputStream output = new QuotedPrintableOutputStream(byteArrayOutputStream, false); + output.write(INPUT_STRING.getBytes("US-ASCII")); + output.close(); + + assertEquals(EXPECTED_QUOTED_PRINTABLE, + new String(byteArrayOutputStream.toByteArray(), "US-ASCII")); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/filter/SmtpDataStuffingTest.java b/mail/common/src/test/java/com/fsck/k9/mail/filter/SmtpDataStuffingTest.java new file mode 100644 index 0000000..1b20217 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/filter/SmtpDataStuffingTest.java @@ -0,0 +1,55 @@ +package com.fsck.k9.mail.filter; + + +import java.io.IOException; + +import okio.Buffer; +import okio.ByteString; +import org.junit.Before; +import org.junit.Test; + +import static junit.framework.Assert.assertEquals; + + +public class SmtpDataStuffingTest { + private Buffer buffer; + private SmtpDataStuffing smtpDataStuffing; + + @Before + public void setUp() throws Exception { + buffer = new Buffer(); + smtpDataStuffing = new SmtpDataStuffing(buffer.outputStream()); + } + + @Test + public void dotAtStartOfLine() throws IOException { + smtpDataStuffing.write(bytesFor("Hello dot\r\n.")); + + assertEquals("Hello dot\r\n..", buffer.readUtf8()); + } + + @Test + public void dotAtStartOfStream() throws IOException { + smtpDataStuffing.write(bytesFor(".Hello dots")); + + assertEquals("..Hello dots", buffer.readUtf8()); + } + + @Test + public void linesNotStartingWithDot() throws IOException { + smtpDataStuffing.write(bytesFor("Hello\r\nworld\r\n")); + + assertEquals("Hello\r\nworld\r\n", buffer.readUtf8()); + } + + @Test + public void dotsThatNeedStuffingMixedWithOnesThatDoNot() throws IOException { + smtpDataStuffing.write(bytesFor("\r\n.Hello . dots.\r\n..\r\n.\r\n...")); + + assertEquals("\r\n..Hello . dots.\r\n...\r\n..\r\n....", buffer.readUtf8()); + } + + private byte[] bytesFor(String input) { + return ByteString.encodeUtf8(input).toByteArray(); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/AddressHeaderBuilderTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/AddressHeaderBuilderTest.kt new file mode 100644 index 0000000..672b8ed --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/AddressHeaderBuilderTest.kt @@ -0,0 +1,58 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.Address +import com.fsck.k9.mail.crlf +import org.junit.Assert.assertEquals +import org.junit.Test + +class AddressHeaderBuilderTest { + + @Test + fun createHeaderValue_withSingleAddress() { + val addresses = arrayOf(Address("test@domain.example")) + + val headerValue = AddressHeaderBuilder.createHeaderValue(addresses) + + assertEquals("test@domain.example", headerValue) + } + + @Test + fun createHeaderValue_withTwoAddressesThatFitOnSingleLine() { + val addresses = arrayOf( + Address("one@domain.example"), + Address("two@domain.example") + ) + + val headerValue = AddressHeaderBuilder.createHeaderValue(addresses) + + assertEquals("one@domain.example, two@domain.example", headerValue) + } + + @Test + fun createHeaderValue_withMultipleAddressesThatNeedWrapping() { + val addresses = arrayOf( + Address("one@domain.example", "Person One"), + Address("two+because.i.can@this.is.quite.some.domain.example", "Person \"Long Email Address\" Two"), + Address("three@domain.example", "Person Three"), + Address("four@domain.example", "Person Four"), + Address("five@domain.example", "Person Five") + ) + + val headerValue = AddressHeaderBuilder.createHeaderValue(addresses) + + assertEquals( + """ + |Person One <one@domain.example>, + | "Person \"Long Email Address\" Two" <two+because.i.can@this.is.quite.some.domain.example>, + | Person Three <three@domain.example>, Person Four <four@domain.example>, + | Person Five <five@domain.example> + """.trimMargin().crlf(), + headerValue + ) + } + + @Test(expected = IllegalArgumentException::class) + fun createHeaderValue_withoutAddresses_shouldThrow() { + AddressHeaderBuilder.createHeaderValue(emptyArray()) + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/CharsetSupportTest.java b/mail/common/src/test/java/com/fsck/k9/mail/internet/CharsetSupportTest.java new file mode 100644 index 0000000..845b1c1 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/CharsetSupportTest.java @@ -0,0 +1,122 @@ +package com.fsck.k9.mail.internet; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class CharsetSupportTest { + + @Test + public void testFixupCharset() throws Exception { + String charsetOnMail; + String expect; + + charsetOnMail = "CP932"; + expect = "shift_jis"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, new MimeMessage())); + +// charsetOnMail = "koi8-u"; +// expect = "koi8-r"; +// assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, new MimeMessage())); + + MimeMessage message; + + message = new MimeMessage(); + message.setHeader("From", "aaa@docomo.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-docomo-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@dwmail.jp"); + charsetOnMail = "shift_jis"; + expect = "x-docomo-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@pdx.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-docomo-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@willcom.com"); + charsetOnMail = "shift_jis"; + expect = "x-docomo-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@emnet.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-docomo-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@emobile.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-docomo-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@softbank.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-softbank-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@vodafone.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-softbank-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@disney.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-softbank-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@vertuclub.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-softbank-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@ezweb.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-kddi-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + + message = new MimeMessage(); + message.setHeader("From", "aaa@ido.ne.jp"); + charsetOnMail = "shift_jis"; + expect = "x-kddi-shift_jis-2007"; + assertEquals(expect, CharsetSupport.fixupCharset(charsetOnMail, message)); + } + + @Test + public void readToString_withUnsupportedCharset_shouldFallBackToAscii() throws IOException { + InputStream inputStream = new ByteArrayInputStream("input".getBytes()); + String charset = "unsupported"; + + String result = CharsetSupport.readToString(inputStream, charset); + + assertEquals("input", result); + } + + @Test + public void readToString_withInvalidCharset_shouldFallBackToAscii() throws IOException { + InputStream inputStream = new ByteArrayInputStream("input".getBytes()); + String charset = "invalid\n"; + + String result = CharsetSupport.readToString(inputStream, charset); + + assertEquals("input", result); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/DecoderUtilTest.java b/mail/common/src/test/java/com/fsck/k9/mail/internet/DecoderUtilTest.java new file mode 100644 index 0000000..e5c027b --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/DecoderUtilTest.java @@ -0,0 +1,238 @@ +package com.fsck.k9.mail.internet; + + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class DecoderUtilTest { + private static final String INVALID = "=?utf-8?Q??="; + + + @Test + public void decodeEncodedWords_withInvalidEncodedWord_shouldReturnInputText() { + // We use INVALID as instance of an invalid encoded word in tests. If at some point we decide to change the code + // to recognize empty encoded text as valid and decode it to an empty string, a lot of tests will break. + // Hopefully this test will help the developer figure out why the other tests broke. + assertInputDecodesToExpected(INVALID, INVALID); + } + + @Test + public void decodeEncodedWords_with_unencoded_data_returns_original_text() { + assertInputDecodesToExpected("abc", "abc"); + } + + @Test + public void decodeEncodedWords_withAsciiCharset_encoded_data_returns_text() { + assertInputDecodesToExpected("=?us-ascii?q?abc?=", "abc"); + } + + @Test + public void decodeEncodedWords_withStartOnly_encoding_format_returnAsText() { + assertInputDecodesToExpected("=?", "=?"); + } + + @Test + public void decodeEncodedWords_withEncodedWordAndOnlyStartOfEncodedWord_shouldDecodeAndAddSuffix() { + assertInputDecodesToExpected("=?utf-8?Q?abc?= =?", "abc =?"); + } + + @Test + public void decodeEncodedWords_withStartAndSeparatorOnly_returnAsText() { + assertInputDecodesToExpected("=??", "=??"); + } + + @Test + public void decodeEncodedWords_withEncodedWordAndOnlyStartAndSeparatorOfEncodedWord_shouldDecodeAndAddSuffix() { + assertInputDecodesToExpected("=?utf-8?Q?abc?= =??", "abc =??"); + } + + @Test + public void decodeEncodedWords_withStartAnd2SeparatorOnly_returnAsText() { + assertInputDecodesToExpected("=???", "=???"); + } + + @Test + public void decodeEncodedWords_withEncodedWordAndOnlyStartAndTwoSeparatorsOfEncodedWord_shouldDecodeAndAddSuffix() { + assertInputDecodesToExpected("=?utf-8?Q?abc?= =???", "abc =???"); + } + + @Test + public void decodeEncodedWords_withStartAnd3SeparatorOnly_returnAsText() { + assertInputDecodesToExpected("=????", "=????"); + } + + @Test + public void decodeEncodedWords_withEncodedWordAndOnlyStartAndThreeSeparatorsOfEncodedWord_shouldDecodeAndAddSuffix() { + assertInputDecodesToExpected("=?utf-8?Q?abc?= =????", "abc =????"); + } + + @Test + public void decodeEncodedWords_withSeparatorsOnly_returnAsText() { + assertInputDecodesToExpected("=????=", "=????="); + } + + @Test + public void decodeEncodedWords_withMissingCharset_returnAsText() { + assertInputDecodesToExpected("=??q??=", "=??q??="); + } + + @Test + public void decodeEncodedWords_withTextAndMissingCharset_returnAsText() { + assertInputDecodesToExpected("=??q?a?=", "a"); + } + + @Test + public void decodeEncodedWords_withNoTextCharsetOrEncoding_returnAsText() { + assertInputDecodesToExpected("=??=", "=??="); + } + + @Test + public void decodeEncodedWords_with_MissingEncodingAndData_returnAsText() { + assertInputDecodesToExpected("=?x?=", "=?x?="); + } + + @Test + public void decodeEncodedWords_withMissingEncoding_returnAsText() { + assertInputDecodesToExpected("=?x??=", "=?x??="); + } + + @Test + public void decodeEncodedWords_with_incompleteEncodingFormat_returnAsText() { + assertInputDecodesToExpected("=?x?q?=", "=?x?q?="); + } + + @Test + public void decodeEncodedWords_with_unrecognisedEncoding_withEmptyEncodedData_returnAsText() { + assertInputDecodesToExpected("=?x?q??=", "=?x?q??="); + } + + @Test + public void decodeEncodedWords_withUnrecognisedEncoding_withEncodedData_return_encoded_data() { + assertInputDecodesToExpected("=?x?q?X?=", "X"); + } + + @Test + public void decodeEncodedWords_withInvalidBase64String_returnsEmptyString() { + assertInputDecodesToExpected("=?us-ascii?b?ab#?=", ""); + } + + @Test + public void decodeEncodedWords_withPartiallyEncoded_returnsBothSections() { + assertInputDecodesToExpected("=?us-ascii?q?abc?= =?", "abc =?"); + } + + @Test + public void decodeEncodedWords_withPartiallyEncodedAfter_returnsBothSections() { + assertInputDecodesToExpected("def=?us-ascii?q?abc?=", "defabc"); + } + + @Test + public void decodeEncodedWords_withUnrecognisedCharset_returnsEncodedData() { + assertInputDecodesToExpected("=?x?= =?", "=?x?= =?"); + } + + @Test + public void decodeEncodedWords_withMultipleEncodedSections_decodesBoth() { + assertInputDecodesToExpected("=?us-ascii?q?abc?= =?us-ascii?q?def?=", "abcdef"); + } + + @Test + public void decodeEncodedWords_withMultipleEncodedSections_decodesSequentialSectionTogether() { + // Splitting mid-character is RFC2047 non-compliant but seen in practice. + // "=?utf-8?B?b2hhaSDw?=" individually decodes to "ohai �" + // "=?utf-8?B?n5Kp==?=" individually decodes to "���" + // (invalid bytes in a UTF-8 sequence are replaced with the replacement character) + assertInputDecodesToExpected("=?utf-8?B?b2hhaSDw?= =?utf-8?B?n5Kp?=", "ohai 💩"); + } + + @Test + public void decodeEncodedWords_withMultipleEncodedSectionsButCharsetAndEncodingDifferingInCase_decodesSequentialSectionTogether() { + assertInputDecodesToExpected("=?utf-8?B?b2hhaSDw?= =?UTF-8?b?n5Kp?=", "ohai 💩"); + } + + @Test + public void decodeEncodedWords_withEncodedWordWhitespaceInvalidEncodedWord_shouldOnlyDecodeEncodedWord() { + assertInputDecodesToExpected("=?utf-8?Q?abc?= " + INVALID, "abc " + INVALID); + } + + @Test + public void decodeEncodedWords_withInvalidEncodedWordWhitespaceInvalidEncodedWord_shouldReturnInputText() { + String input = INVALID + " " + INVALID; + assertInputDecodesToExpected(input, input); + } + + @Test + public void decodeEncodedWords_withEncodedWordNonWhitespaceSeparatorEncodedWord_shouldDecodeBothAndKeepSeparator() { + assertInputDecodesToExpected("=?utf-8?Q?ab?= -- =?utf-8?Q?cd?=", "ab -- cd"); + } + + @Test + public void decodeEncodedWords_withInvalidEncodedWordWhitespaceEncodedWord_shouldOnlyDecodeEncodedWord() { + assertInputDecodesToExpected(INVALID + " =?utf-8?Q?abc?=", INVALID + " abc"); + } + + @Test + public void decodeEncodedWords_withEncodedWordFollowedByEncodedWordWithDifferentEncoding_shouldDecodeIndividually() { + assertInputDecodesToExpected("=?utf-8?Q?ab?= =?utf-8?B?Y2Q=?=", "abcd"); + } + + @Test + public void decodeEncodedWords_withEncodedWordSeparatorEncodedWordWithDifferentEncoding_shouldDecodeIndividuallyAndKeepSeparator() { + assertInputDecodesToExpected("=?utf-8?Q?ab?= / =?utf-8?B?Y2Q=?=", "ab / cd"); + } + + @Test + public void decodeEncodedWords_withEncodedWordFollowedByEncodedWordWithDifferentCharset_shouldDecodeIndividually() { + assertInputDecodesToExpected("=?us-ascii?Q?oh_no_?= =?utf-8?Q?=F0=9F=92=A9?=", "oh no 💩"); + } + + @Test + public void decodeEncodedWords_withTwoCompleteEncodedWords_shouldProvideBoth() { + assertInputDecodesToExpected("=?UTF-8?B?W+aWsOioguWWrl0g6aGn5a6iOiB4eHhAeHh4LmNvbSDmnInmlrDoqILllq46ICMyMDE4MA==?= " + + "=?UTF-8?B?MTE4MTIzNDU2Nzg=?=", "[新訂單] 顧客: xxx@xxx.com 有新訂單: #2018011812345678"); + } + + @Test + public void decodeEncodedWords_withRFC2047examples_decodesCorrectly() { + assertInputDecodesToExpected("(=?ISO-8859-1?Q?a?=)", "(a)"); + + assertInputDecodesToExpected("(=?ISO-8859-1?Q?a?= b)", "(a b)"); + + assertInputDecodesToExpected("(=?ISO-8859-1?Q?a?= =?ISO-8859-1?Q?b?=)", "(ab)"); + + assertInputDecodesToExpected("(=?ISO-8859-1?Q?a?= =?ISO-8859-1?Q?b?=)", "(ab)"); + + assertInputDecodesToExpected("(=?ISO-8859-1?Q?a?= \n =?ISO-8859-1?Q?b?=)", "(ab)"); + + assertInputDecodesToExpected("(=?ISO-8859-1?Q?a_b?=)", "(a b)"); + + assertInputDecodesToExpected("(=?ISO-8859-1?Q?a?= =?ISO-8859-2?Q?_b?=)", "(a b)"); + } + + @Test + public void decodeEncodedWords_withLanguageInformation() { + // Example from RFC 2231, section 5. This is unlikely to ever fail because our charset fallback is US-ASCII. + assertInputDecodesToExpected("=?US-ASCII*EN?Q?Keith_Moore?= <moore@cs.utk.edu>", + "Keith Moore <moore@cs.utk.edu>"); + + assertInputDecodesToExpected("=?utf-8*de?b?R3LDvMOfZQ==?=", "Grüße"); + } + + @Test + public void decodeEncodedWords_withMultipleIso2022JpEncodedWordsProperlyEndingWithSwitchingToAscii() { + // If we try to combine the base64-decoded data of both encoded words and only then perform the charset + // decoding, we end up with an escape sequence switching to ASCII (end of first encoded word) followed by an + // escape sequence switching to JIS X 0208:1983 (start of second encoded word). The decoder on Android reports + // an error for this case, leading to a replacement character being inserted. + // We use the ISO-2022-JP-TEST charset to get Android's behavior on the JVM. See TestCharsetProvider. + assertInputDecodesToExpected("=?ISO-2022-JP-TEST?B?GyRCRnxLXDhsJEhGfEtcOGwkSEZ8S1w4bCROJUElJyVDGyhC?=\r\n" + + " =?ISO-2022-JP-TEST?B?GyRCJS8bKEI=?=", "日本語と日本語と日本語のチェック"); + } + + private void assertInputDecodesToExpected(String input, String expected) { + String decodedText = DecoderUtil.decodeEncodedWords(input, null); + assertEquals(expected, decodedText); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/EncoderUtilTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/EncoderUtilTest.kt new file mode 100644 index 0000000..422c7b8 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/EncoderUtilTest.kt @@ -0,0 +1,33 @@ +package com.fsck.k9.mail.internet + +import org.junit.Assert.assertEquals +import org.junit.Test + +class EncoderUtilTest { + @Test + fun singleNonAsciiCharacter() { + assertInputEncodesToExpected("123456789Ä", "=?UTF-8?Q?123456789=C3=84?=") + } + + @Test + fun onlyNonAsciiCharacters() { + assertInputEncodesToExpected("ÄÖÜÄÖÜÄÖÜÄ", "=?UTF-8?B?w4TDlsOcw4TDlsOcw4TDlsOcw4Q=?=") + } + + @Test + fun nonAsciiCharactersThatNeedToBeEncodedAsMultipleEncodedWords() { + assertInputEncodesToExpected( + "Re: \uD83D\uDC15\uD83D\uDC36\uD83D\uDC29\uD83D\uDC08\uD83D\uDC31\uD83D\uDC00\uD83D\uDC01\uD83D\uDC2D" + + "\uD83D\uDC39\uD83D\uDC22\uD83D\uDC07\uD83D\uDC30\uD83D\uDC13\uD83D\uDC14\uD83D\uDC23\uD83D\uDC24" + + "\uD83D\uDC25\uD83D\uDC26\uD83D\uDC0F\uD83D\uDC11\uD83D\uDC10", + "=?UTF-8?B?UmU6IPCfkJXwn5C28J+QqfCfkIjwn5Cx8J+QgPCfkIHwn5Ct8J+QuQ==?= " + + "=?UTF-8?B?8J+QovCfkIfwn5Cw8J+Qk/CfkJTwn5Cj?= " + + "=?UTF-8?B?8J+QpPCfkKXwn5Cm8J+Qj/CfkJHwn5CQ?=" + ) + } + + private fun assertInputEncodesToExpected(input: String, expected: String) { + val encodedText = EncoderUtil.encodeEncodedWord(input) + assertEquals(expected, encodedText) + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/FlowedMessageUtilsTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/FlowedMessageUtilsTest.kt new file mode 100644 index 0000000..ddc053a --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/FlowedMessageUtilsTest.kt @@ -0,0 +1,217 @@ +package com.fsck.k9.mail.internet + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.crlf +import org.junit.Test + +class FlowedMessageUtilsTest { + @Test + fun `deflow() with simple text`() { + val input = "Text that should be \r\n" + + "displayed on one line" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo("Text that should be displayed on one line") + } + + @Test + fun `deflow() with only some lines ending in space`() { + val input = "Text that \r\n" + + "should be \r\n" + + "displayed on \r\n" + + "one line.\r\n" + + "Text that should retain\r\n" + + "its line break." + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo( + """ + Text that should be displayed on one line. + Text that should retain + its line break. + """.trimIndent().crlf() + ) + } + + @Test + fun `deflow() with nothing to do`() { + val input = "Line one\r\nLine two\r\n" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo(input) + } + + @Test + fun `deflow() with quoted text`() { + val input = "On [date], [user] wrote:\r\n" + + "> Text that should be displayed \r\n" + + "> on one line\r\n" + + "\r\n" + + "Some more text that should be \r\n" + + "displayed on one line.\r\n" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo( + """ + |On [date], [user] wrote: + |> Text that should be displayed on one line + | + |Some more text that should be displayed on one line. + | + """.trimMargin().crlf() + ) + } + + @Test + fun `deflow() with quoted text ending in space`() { + val input = "> Quoted text \r\n" + + "Some other text" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo("> Quoted text \r\nSome other text") + } + + @Test + fun `deflow() with quoted text ending in space before quoted text of different quoting depth`() { + val input = ">> Depth 2 \r\n" + + "> Depth 1 \r\n" + + "> is here\r\n" + + "Some other text" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo( + """ + >> Depth 2${" "} + > Depth 1 is here + Some other text + """.trimIndent().crlf() + ) + } + + @Test + fun `deflow() with quoted text ending in space followed by empty line`() { + val input = "> Quoted \r\n" + + "\r\n" + + "Text" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo(input) + } + + @Test + fun `deflow() with delSp=true`() { + val input = "Text that is wrapped mid wo \r\nrd" + + val result = FlowedMessageUtils.deflow(input, delSp = true) + + assertThat(result).isEqualTo("Text that is wrapped mid word") + } + + @Test + fun `deflow() with quoted text and space-stuffing and delSp=true`() { + val input = "> Quoted te \r\n" + + "> xt" + + val result = FlowedMessageUtils.deflow(input, delSp = true) + + assertThat(result).isEqualTo("> Quoted text") + } + + @Test + fun `deflow() with space-stuffed second line`() { + val input = "Text that should be \r\n" + + " displayed on one line" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo("Text that should be displayed on one line") + } + + @Test + fun `deflow() with only space-stuffing`() { + val input = "Line 1\r\n" + + " Line 2\r\n" + + " Line 3\r\n" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo("Line 1\r\nLine 2\r\nLine 3\r\n") + } + + @Test + fun `deflow() with quoted space-stuffed second line`() { + val input = "> Text that should be \r\n" + + "> displayed on one line" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo("> Text that should be displayed on one line") + } + + @Test + fun `deflow() with text containing signature`() { + val input = "Text that should be \r\n" + + "displayed on one line.\r\n" + + "\r\n" + + "-- \r\n" + + "Signature \r\n" + + "text" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo( + """ + Text that should be displayed on one line. + + --${" "} + Signature text + """.trimIndent().crlf() + ) + } + + @Test + fun `deflow() with quoted text containing signature`() { + val input = "> Text that should be \r\n" + + "> displayed on one line.\r\n" + + "> \r\n" + + "> -- \r\n" + + "> Signature \r\n" + + "> text" + + val result = FlowedMessageUtils.deflow(input, delSp = false) + + assertThat(result).isEqualTo( + """ + > Text that should be displayed on one line. + >${" "} + > --${" "} + > Signature text + """.trimIndent().crlf() + ) + } + + @Test + fun `deflow() with flowed line followed by signature separator`() { + val input = "Fake flowed line \r\n" + + "-- \r\n" + + "Signature" + + val result = FlowedMessageUtils.deflow(input, delSp = true) + + assertThat(result).isEqualTo( + """ + Fake flowed line + --${" "} + Signature + """.trimIndent().crlf() + ) + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/FormatFlowedHelperTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/FormatFlowedHelperTest.kt new file mode 100644 index 0000000..cd965f9 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/FormatFlowedHelperTest.kt @@ -0,0 +1,66 @@ +package com.fsck.k9.mail.internet + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.Test + +class FormatFlowedHelperTest { + @Test + fun `plain text and format=flowed`() { + val contentTypeHeader = "text/plain; format=flowed" + + val result = FormatFlowedHelper.checkFormatFlowed(contentTypeHeader) + + assertThat(result.isFormatFlowed).isTrue() + assertThat(result.isDelSp).isFalse() + } + + @Test + fun `plain text and format=flowed and delsp=yes`() { + val contentTypeHeader = "text/plain; format=flowed; delsp=yes" + + val result = FormatFlowedHelper.checkFormatFlowed(contentTypeHeader) + + assertThat(result.isFormatFlowed).isTrue() + assertThat(result.isDelSp).isTrue() + } + + @Test + fun `plain text without format=flowed`() { + val contentTypeHeader = "text/plain" + + val result = FormatFlowedHelper.checkFormatFlowed(contentTypeHeader) + + assertThat(result.isFormatFlowed).isFalse() + } + + @Test + fun `plain text without format=flowed but delsp=yes`() { + val contentTypeHeader = "text/plain; delsp=yes" + + val result = FormatFlowedHelper.checkFormatFlowed(contentTypeHeader) + + assertThat(result.isFormatFlowed).isFalse() + assertThat(result.isDelSp).isFalse() + } + + @Test + fun `HTML and format=flowed`() { + val contentTypeHeader = "text/html; format=flowed" + + val result = FormatFlowedHelper.checkFormatFlowed(contentTypeHeader) + + assertThat(result.isFormatFlowed).isFalse() + } + + @Test + fun `HTML and format=flowed and delsp=yes`() { + val contentTypeHeader = "text/html; format=flowed; delsp=yes" + + val result = FormatFlowedHelper.checkFormatFlowed(contentTypeHeader) + + assertThat(result.isFormatFlowed).isFalse() + assertThat(result.isDelSp).isFalse() + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageExtractorTest.java b/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageExtractorTest.java new file mode 100644 index 0000000..3dd5452 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageExtractorTest.java @@ -0,0 +1,119 @@ +package com.fsck.k9.mail.internet; + + +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mailstore.BinaryMemoryBody; +import org.apache.james.mime4j.util.MimeUtil; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + + +public class MessageExtractorTest { + private MimeBodyPart part; + + + @Before + public void setUp() throws Exception { + part = new MimeBodyPart(); + } + + @Test + public void getTextFromPart_withNoBody_shouldReturnNull() throws Exception { + part.setBody(null); + + String result = MessageExtractor.getTextFromPart(part); + + assertNull(result); + } + + @Test + public void getTextFromPart_withTextBody_shouldReturnText() throws Exception { + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain; charset=utf-8"); + BinaryMemoryBody body = new BinaryMemoryBody("Sample text body".getBytes(), MimeUtil.ENC_8BIT); + part.setBody(body); + + String result = MessageExtractor.getTextFromPart(part); + + assertEquals("Sample text body", result); + } + + @Test + public void getTextFromPart_withRawDataBodyWithNonText_shouldReturnNull() throws Exception { + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "image/jpeg"); + BinaryMemoryBody body = new BinaryMemoryBody("Sample text body".getBytes(), MimeUtil.ENC_8BIT); + part.setBody(body); + + String result = MessageExtractor.getTextFromPart(part); + + assertNull(result); + } + + @Test + public void getTextFromPart_withExceptionThrownGettingInputStream_shouldReturnNull() throws Exception { + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/html"); + Body body = mock(Body.class); + when(body.getInputStream()).thenThrow(new MessagingException("Test")); + part.setBody(body); + + String result = MessageExtractor.getTextFromPart(part); + + assertNull(result); + } + + @Test + public void getTextFromPart_withUnknownEncoding_shouldReturnUnmodifiedBodyContents() throws Exception { + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain"); + String bodyText = "Sample text body"; + BinaryMemoryBody body = new BinaryMemoryBody(bodyText.getBytes(), "unknown encoding"); + part.setBody(body); + + String result = MessageExtractor.getTextFromPart(part); + + assertEquals(bodyText, result); + } + + @Test + public void getTextFromPart_withPlainTextWithCharsetInContentTypeRawDataBody_shouldReturnText() throws Exception { + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain; charset=UTF-8"); + BinaryMemoryBody body = new BinaryMemoryBody("Sample text body".getBytes(), MimeUtil.ENC_8BIT); + part.setBody(body); + + String result = MessageExtractor.getTextFromPart(part); + + assertEquals("Sample text body", result); + } + + @Test + public void getTextFromPart_withHtmlWithCharsetInContentTypeRawDataBody_shouldReturnHtmlText() throws Exception { + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/html; charset=UTF-8"); + BinaryMemoryBody body = new BinaryMemoryBody( + "<html><body>Sample text body</body></html>".getBytes(), MimeUtil.ENC_8BIT); + part.setBody(body); + + String result = MessageExtractor.getTextFromPart(part); + + assertEquals("<html><body>Sample text body</body></html>", result); + } + + @Test + public void getTextFromPart_withHtmlWithCharsetInHtmlRawDataBody_shouldReturnHtmlText() throws Exception { + String bodyText = "<html><head>" + + "<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">" + + "</head><body>Sample text body</body></html>"; + BinaryMemoryBody body = new BinaryMemoryBody(bodyText.getBytes(), MimeUtil.ENC_8BIT); + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/html"); + part.setBody(body); + + String result = MessageExtractor.getTextFromPart(part); + + assertNotNull(result); + assertEquals(bodyText, result); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdGeneratorTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdGeneratorTest.kt new file mode 100644 index 0000000..6ce7a46 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdGeneratorTest.kt @@ -0,0 +1,45 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.Address +import org.junit.Assert.assertEquals +import org.junit.Test + +class MessageIdGeneratorTest { + private val messageIdGenerator = MessageIdGenerator( + object : UuidGenerator { + override fun randomUuid() = "00000000-0000-4000-0000-000000000000" + } + ) + + @Test + fun generateMessageId_withFromAndReplyToAddress() { + val message = MimeMessage().apply { + setFrom(Address("alice@example.org")) + replyTo = Address.parse("bob@example.com") + } + + val result = messageIdGenerator.generateMessageId(message) + + assertEquals("<00000000-0000-4000-0000-000000000000@example.org>", result) + } + + @Test + fun generateMessageId_withReplyToAddress() { + val message = MimeMessage().apply { + replyTo = Address.parse("bob@example.com") + } + + val result = messageIdGenerator.generateMessageId(message) + + assertEquals("<00000000-0000-4000-0000-000000000000@example.com>", result) + } + + @Test + fun generateMessageId_withoutRelevantHeaders() { + val message = MimeMessage() + + val result = messageIdGenerator.generateMessageId(message) + + assertEquals("<00000000-0000-4000-0000-000000000000@fallback.k9mail.app>", result) + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdParserTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdParserTest.kt new file mode 100644 index 0000000..0d3f868 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdParserTest.kt @@ -0,0 +1,173 @@ +package com.fsck.k9.mail.internet + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +private const val MESSAGE_ID = "<left-side@domain.example>" + +class MessageIdParserTest { + @Test + fun `typical message identifiers`() { + assertMessageIdValid("<left-side@right-side>") + assertMessageIdValid("<left-side@domain.example>") + } + + @Test + fun `message identifier with domain literal`() { + assertMessageIdValid("<left-side@[dtext]>") + } + + @Test + fun `message identifier with extra space`() { + assertMessageIdWithExtraValid(MESSAGE_ID, " ") + } + + @Test + fun `message identifier with multiple extra spaces`() { + assertMessageIdWithExtraValid(MESSAGE_ID, " ") + } + + @Test + fun `message identifier with extra tab`() { + assertMessageIdWithExtraValid(MESSAGE_ID, "\t") + } + + @Test + fun `message identifier with extra comment`() { + assertMessageIdWithExtraValid(MESSAGE_ID, "(comment)") + } + + @Test + fun `message identifier with extra nested comments`() { + assertMessageIdWithExtraValid(MESSAGE_ID, "(comment one (nested comment (nested nested comment)))") + } + + @Test + fun `message identifier with extra comment and folding whitespace`() { + assertMessageIdWithExtraValid(MESSAGE_ID, " \r\n\t(comment \\(\r\n more comment)\r\n \t") + } + + @Test + fun `message identifier with excessive extra comment nesting`() { + val extra = "(".repeat(10_000) + ")".repeat(10_000) + assertMessageIdWithExtraValid(MESSAGE_ID, extra) + } + + @Test + fun `multiple message identifiers`() { + val messageId1 = "<left-side@right-side>" + val messageId2 = "<left-side@domain.example>" + + assertMessageIdsValid("$messageId1 $messageId2", listOf(messageId1, messageId2)) + } + + @Test + fun `multiple message identifiers without separation`() { + val messageId1 = "<left-side@right-side>" + val messageId2 = "<left-side@domain.example>" + + assertMessageIdsValid("$messageId1$messageId2", listOf(messageId1, messageId2)) + } + + @Test + fun `multiple message identifiers separated by tab`() { + val messageId1 = "<left-side@right-side>" + val messageId2 = "<left-side@domain.example>" + + assertMessageIdsValid("$messageId1\t$messageId2", listOf(messageId1, messageId2)) + } + + @Test + fun `multiple message identifiers separated by line break`() { + val messageId1 = "<left-side@right-side>" + val messageId2 = "<left-side@domain.example>" + + assertMessageIdsValid("$messageId1\r\n $messageId2", listOf(messageId1, messageId2)) + } + + @Test + fun `multiple message identifiers separated by comment`() { + val messageId1 = "<left-side@right-side>" + val messageId2 = "<left-side@domain.example>" + + assertMessageIdsValid("$messageId1(comment <this.is@ignored>)$messageId2", listOf(messageId1, messageId2)) + } + + @Test(expected = MimeHeaderParserException::class) + fun `message identifier with additional data should throw`() { + MessageIdParser.parse("$MESSAGE_ID extra") + } + + @Test(expected = MimeHeaderParserException::class) + fun `message identifiers with additional data should throw`() { + MessageIdParser.parseList("<one@domain.example> <two@domain.example> extra") + } + + @Test(expected = MimeHeaderParserException::class) + fun `message identifier missing angle brackets`() { + MessageIdParser.parse("left-side@domain.example") + } + + @Test(expected = MimeHeaderParserException::class) + fun `message identifier missing left side`() { + MessageIdParser.parse("<@domain.example>") + } + + @Test(expected = MimeHeaderParserException::class) + fun `message identifier containing only left side`() { + MessageIdParser.parse("<left-side>") + } + + @Test(expected = MimeHeaderParserException::class) + fun `message identifier missing right side`() { + MessageIdParser.parse("<left-side@>") + } + + @Test(expected = MimeHeaderParserException::class) + fun `empty input`() { + MessageIdParser.parse("") + } + + @Test(expected = MimeHeaderParserException::class) + fun `empty input for list`() { + MessageIdParser.parseList("") + } + + private fun assertMessageIdValid(input: String, expected: String = input) { + showMimeHeaderParserError(input) { + assertThat(MessageIdParser.parse(input)).isEqualTo(expected) + } + + assertMessageIdsValid(input, listOf(expected)) + } + + private fun assertMessageIdsValid(input: String, expected: List<String>) { + showMimeHeaderParserError(input) { + val messageIds = MessageIdParser.parseList(input) + assertThat(messageIds).isEqualTo(expected) + } + } + + /** + * Test input with [extra] prepended, appended, and both at the same time. + */ + @Suppress("SameParameterValue") + private fun assertMessageIdWithExtraValid(messageId: String, extra: String) { + assertMessageIdValid("$extra$messageId", messageId) + assertMessageIdValid("$messageId$extra", messageId) + assertMessageIdValid("$extra$messageId$extra", messageId) + } + + private fun showMimeHeaderParserError(input: String, block: () -> Unit) { + try { + block() + } catch (e: MimeHeaderParserException) { + // Replace tabs with spaces so the error indicator lines up + val tweakedInput = input.replace("\t", " ") + println("Input: $tweakedInput") + println("Error: " + "-".repeat(e.errorIndex) + "^") + throw e + } + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeHeaderCheckerTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeHeaderCheckerTest.kt new file mode 100644 index 0000000..189a909 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeHeaderCheckerTest.kt @@ -0,0 +1,210 @@ +package com.fsck.k9.mail.internet + +import org.junit.Assert.fail +import org.junit.Test + +class MimeHeaderCheckerTest { + @Test + fun emptyValue() { + assertValidHeader("Subject: ") + } + + @Test + fun blankValue() { + assertValidHeader("Subject: ") + } + + @Test + fun textEndingInSpace() { + assertValidHeader("Subject: Text ") + } + + @Test + fun textContainingSpaces() { + assertValidHeader("Subject: Text containing spaces") + } + + @Test + fun allVisibleCharacters() { + val text = (33..126).map { it.toChar() }.joinToString("") + assertValidHeader("Subject: $text") + } + + @Test + fun blankFirstLine() { + assertValidHeader("Subject: \r\n Two") + } + + @Test + fun twoLines() { + assertValidHeader("Subject: One\r\n Two") + } + + @Test + fun threeLines() { + assertValidHeader("Subject: One\r\n Two\r\n Three") + } + + @Test + fun secondLineStartingWithTab() { + assertValidHeader("Subject: One\r\n\tTwo") + } + + @Test + fun secondLineStartingWithMultipleWhitespace() { + assertValidHeader("Subject: One\r\n \t Two") + } + + @Test + fun singleLineAtMaximumLineLength() { + val longText = "x".repeat(998 /* text limit */ - 4 /* Test */ - 2 /* colon, space */) + assertValidHeader("Test: $longText") + } + + @Test + fun firstLineAtMaximumLineLength() { + val longText = "x".repeat(998 /* text limit */ - 4 /* Test */ - 2 /* colon, space */) + assertValidHeader("Test: $longText\r\n Text") + } + + @Test + fun middleLineAtMaximumLineLength() { + val longText = "x".repeat(998 - 1 /* space */) + assertValidHeader("Test: One\r\n $longText\r\n Three") + } + + @Test + fun lastLineAtMaximumLineLength() { + val longText = "x".repeat(998 - 1 /* space */) + assertValidHeader("Test: One\r\n $longText") + } + + @Test + fun colonInHeaderName() { + assertInvalidHeader("Header:Name: Text") + } + + @Test + fun nonAsciiCharacterInHeaderName() { + assertInvalidHeader("Sübject: Text") + } + + @Test + fun headerNameExceedingLineLimit() { + val longName = "x".repeat(998 - 2 /* space, colon */ + 1) + assertInvalidHeader("$longName: ") + } + + @Test + fun nonAsciiCharacter() { + assertInvalidHeader("Subject: ö") + } + + @Test + fun nonVisibleCharacter() { + assertInvalidHeader("Subject: \u0007") + } + + @Test + fun endingInCR() { + assertInvalidHeader("Subject: Text\r") + } + + @Test + fun endingInLF() { + assertInvalidHeader("Subject: Text\n") + } + + @Test + fun endingInCRLF() { + assertInvalidHeader("Subject: Text\r\n") + } + + @Test + fun lineBreakNotFollowedByWhitespace() { + assertInvalidHeader("Subject: One\r\nTwo") + } + + @Test + fun singleCR() { + assertInvalidHeader("Subject: One\rTwo") + } + + @Test + fun singleCrFollowedByWhitespace() { + assertInvalidHeader("Subject: One\r Two") + } + + @Test + fun consecutiveCRs() { + assertInvalidHeader("Subject: \r\r\n Two") + } + + @Test + fun singleLF() { + assertInvalidHeader("Subject: One\nTwo") + } + + @Test + fun singleLfFollowedByWhitespace() { + assertInvalidHeader("Subject: One\n Two") + } + + @Test + fun consecutiveLFs() { + assertInvalidHeader("Subject: \r\n\n Two") + } + + @Test + fun consecutiveLineBreaks() { + assertInvalidHeader("Subject: One\r\n\r\n Two") + } + + @Test + fun blankMiddleLine() { + assertInvalidHeader("Subject: One\r\n \r\n Two") + } + + @Test + fun endsWithBlankLine() { + assertInvalidHeader("Subject: One\r\n ") + } + + @Test + fun singleLineExceedingLineLength() { + val longText = "x".repeat(998 /* text limit */ - 4 /* Test */ - 2 /* colon, space */ + 1) + assertInvalidHeader("Test: $longText") + } + + @Test + fun firstLineExceedingLineLength() { + val longText = "x".repeat(998 /* text limit */ - 4 /* Test */ - 2 /* colon, space */ + 1) + assertInvalidHeader("Test: $longText\r\n Text") + } + + @Test + fun middleLineExceedingLineLength() { + val longText = "x".repeat(998 - 1 /* space */ + 1) + assertInvalidHeader("Test: One\r\n $longText\r\n Three") + } + + @Test + fun lastLineExceedingLineLength() { + val longText = "x".repeat(998 - 1 /* space */ + 1) + assertInvalidHeader("Test: One\r\n $longText") + } + + private fun assertValidHeader(header: String) { + val (name, value) = header.split(": ", limit = 2) + MimeHeaderChecker.checkHeader(name, value) + } + + private fun assertInvalidHeader(header: String) { + val (name, value) = header.split(": ", limit = 2) + try { + MimeHeaderChecker.checkHeader(name, value) + fail("Expected exception") + } catch (expected: MimeHeaderParserException) { + } + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeMessageParseTest.java b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeMessageParseTest.java new file mode 100644 index 0000000..3046a91 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeMessageParseTest.java @@ -0,0 +1,330 @@ +package com.fsck.k9.mail.internet; + + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import com.fsck.k9.mail.Address; +import com.fsck.k9.mail.Body; +import com.fsck.k9.mail.BodyPart; +import com.fsck.k9.mail.Message.RecipientType; +import com.fsck.k9.mail.Multipart; +import org.apache.commons.io.IOUtils; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class MimeMessageParseTest { + @Before + public void setup() { + BinaryTempFileBody.setTempDirectory(new File(System.getProperty("java.io.tmpdir"))); + } + + @Test + public void testSinglePart7BitNoRecurse() throws Exception { + MimeMessage msg = parseWithoutRecurse(toStream( + "From: <adam@example.org>\r\n" + + "To: <eva@example.org>\r\n" + + "Subject: Testmail\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-type: text/plain\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "this is some test text.")); + + checkAddresses(msg.getFrom(), "adam@example.org"); + checkAddresses(msg.getRecipients(RecipientType.TO), "eva@example.org"); + assertEquals("Testmail", msg.getSubject()); + assertEquals("text/plain", msg.getContentType()); + assertEquals("this is some test text.", streamToString(MimeUtility.decodeBody(msg.getBody()))); + } + + @Test + public void headerFieldNameWithSpace() throws Exception { + MimeMessage msg = parseWithoutRecurse(toStream("" + + "From : <adam@example.org>\r\n" + + "\r\n" + + "Body")); + + assertEquals("<adam@example.org>", msg.getHeader("From")[0]); + } + + @Test + public void testSinglePart8BitRecurse() throws Exception { + MimeMessage msg = parseWithRecurse(toStream( + "From: <adam@example.org>\r\n" + + "To: <eva@example.org>\r\n" + + "Subject: Testmail\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-type: text/plain; encoding=ISO-8859-1\r\n" + + "Content-Transfer-Encoding: 8bit\r\n" + + "\r\n" + + "gefährliche Umlaute")); + + checkAddresses(msg.getFrom(), "adam@example.org"); + checkAddresses(msg.getRecipients(RecipientType.TO), "eva@example.org"); + assertEquals("Testmail", msg.getSubject()); + assertEquals("text/plain; encoding=ISO-8859-1", msg.getContentType()); + assertEquals("gefährliche Umlaute", streamToString(MimeUtility.decodeBody(msg.getBody()))); + } + + @Test + public void testSinglePartBase64NoRecurse() throws Exception { + MimeMessage msg = parseWithoutRecurse(toStream( + "From: <adam@example.org>\r\n" + + "To: <eva@example.org>\r\n" + + "Subject: Testmail\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-type: text/plain\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "\r\n" + + "dGhpcyBpcyBzb21lIG1vcmUgdGVzdCB0ZXh0Lg==\r\n")); + + checkAddresses(msg.getFrom(), "adam@example.org"); + checkAddresses(msg.getRecipients(RecipientType.TO), "eva@example.org"); + assertEquals("Testmail", msg.getSubject()); + assertEquals("text/plain", msg.getContentType()); + assertEquals("this is some more test text.", streamToString(MimeUtility.decodeBody(msg.getBody()))); + } + + @Test + public void testMultipartSingleLayerNoRecurse() throws Exception { + MimeMessage msg = parseWithoutRecurse(toStream( + "From: <x@example.org>\r\n" + + "To: <y@example.org>\r\n" + + "Subject: Testmail 2\r\n" + + "MIME-Version: 1.0\n" + + "Content-Type: multipart/mixed; boundary=frontier\n" + + "\n" + + "This is a message with multiple parts in MIME format.\n" + + "--frontier\n" + + "Content-Type: text/plain\n" + + "\n" + + "This is the body of the message.\n" + + "--frontier\n" + + "Content-Type: application/octet-stream\n" + + "Content-Transfer-Encoding: base64\n" + + "\n" + + "PGh0bWw+CiAgPGhlYWQ+CiAgPC9oZWFkPgogIDxib2R5PgogICAgPHA+VGhpcyBpcyB0aGUg\n" + + "Ym9keSBvZiB0aGUgbWVzc2FnZS48L3A+CiAgPC9ib2R5Pgo8L2h0bWw+Cg=\n" + + "--frontier--")); + + checkAddresses(msg.getFrom(), "x@example.org"); + checkAddresses(msg.getRecipients(RecipientType.TO), "y@example.org"); + assertEquals("Testmail 2", msg.getSubject()); + assertEquals("multipart/mixed; boundary=frontier", msg.getContentType()); + checkLeafParts(msg, + "This is the body of the message.", + "<html>\n" + + " <head>\n" + + " </head>\n" + + " <body>\n" + + " <p>This is the body of the message.</p>\n" + + " </body>\n" + + "</html>\n" + + ""); + } + + @Test + public void decodeBody_withUnknownEncoding_shouldReturnUnmodifiedBodyContents() throws Exception { + MimeMessage msg = parseWithoutRecurse(toStream( + "From: <adam@example.org>\r\n" + + "To: <eva@example.org>\r\n" + + "Subject: Testmail\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-type: text/plain\r\n" + + "Content-Transfer-Encoding: utf-8\r\n" + + "\r\n" + + "dGhpcyBpcyBzb21lIG1vcmUgdGVzdCB0ZXh0Lg==\r\n")); + + InputStream inputStream = MimeUtility.decodeBody(msg.getBody()); + + assertEquals("dGhpcyBpcyBzb21lIG1vcmUgdGVzdCB0ZXh0Lg==\r\n", streamToString(inputStream)); + } + + @Test + public void testMultipartSingleLayerRecurse() throws Exception { + MimeMessage msg = parseWithRecurse(toStream( + "From: <x@example.org>\r\n" + + "To: <y@example.org>\r\n" + + "Subject: Testmail 2\r\n" + + "MIME-Version: 1.0\n" + + "Content-Type: multipart/mixed; boundary=frontier\n" + + "\n" + + "This is a message with multiple parts in MIME format.\n" + + "--frontier\n" + + "Content-Type: text/plain\n" + + "\n" + + "This is the body of the message.\n" + + "--frontier\n" + + "Content-Type: application/octet-stream\n" + + "Content-Transfer-Encoding: base64\n" + + "\n" + + "PGh0bWw+CiAgPGhlYWQ+CiAgPC9oZWFkPgogIDxib2R5PgogICAgPHA+VGhpcyBpcyB0aGUg\n" + + "Ym9keSBvZiB0aGUgbWVzc2FnZS48L3A+CiAgPC9ib2R5Pgo8L2h0bWw+Cg=\n" + + "--frontier--")); + + checkAddresses(msg.getFrom(), "x@example.org"); + checkAddresses(msg.getRecipients(RecipientType.TO), "y@example.org"); + assertEquals("Testmail 2", msg.getSubject()); + assertEquals("multipart/mixed; boundary=frontier", msg.getContentType()); + checkLeafParts(msg, + "This is the body of the message.", + "<html>\n" + + " <head>\n" + + " </head>\n" + + " <body>\n" + + " <p>This is the body of the message.</p>\n" + + " </body>\n" + + "</html>\n" + + ""); + } + + @Test + public void testMultipartTwoLayersRecurse() throws Exception { + MimeMessage msg = parseWithRecurse(toStream( + "From: <x@example.org>\r\n" + + "To: <y@example.org>\r\n" + + "Subject: Testmail 2\r\n" + + "MIME-Version: 1.0\n" + + "Content-Type: multipart/mixed; boundary=1\n" + + "\n" + + "This is a message with multiple parts in MIME format.\n" + + "--1\n" + + "Content-Type: text/plain\n" + + "\n" + + "some text in the first part\n" + + "--1\n" + + "Content-Type: multipart/alternative; boundary=2\n" + + "\n" + + "--2\n" + + "Content-Type: text/plain\n" + + "\n" + + "alternative 1\n" + + "--2\n" + + "Content-Type: text/plain\n" + + "\n" + + "alternative 2\n" + + "--2--\n" + + "--1--")); + + checkAddresses(msg.getFrom(), "x@example.org"); + checkAddresses(msg.getRecipients(RecipientType.TO), "y@example.org"); + assertEquals("Testmail 2", msg.getSubject()); + assertEquals("multipart/mixed; boundary=1", msg.getContentType()); + checkLeafParts(msg, + "some text in the first part", + "alternative 1", + "alternative 2"); + } + + + private static ByteArrayInputStream toStream(String rawMailData) throws Exception { + return new ByteArrayInputStream(rawMailData.getBytes("ISO-8859-1")); + } + + private static MimeMessage parseWithoutRecurse(InputStream data) throws Exception { + return MimeMessage.parseMimeMessage(data, false); + } + + private static MimeMessage parseWithRecurse(InputStream data) throws Exception { + return MimeMessage.parseMimeMessage(data, true); + } + + private static void checkAddresses(Address[] actual, String... expected) { + for (int i = 0; i < actual.length; i++) { + assertEquals(actual[i].toEncodedString(), expected[i]); + } + assertEquals(expected.length, actual.length); + } + + private static String streamToString(InputStream stream) throws Exception { + return IOUtils.toString(stream, "ISO-8859-1"); + } + + private static List<Body> getLeafParts(Body body) { + if (body instanceof Multipart) { + List<Body> ret = new ArrayList<>(); + for (BodyPart child : ((Multipart) body).getBodyParts()) { + ret.addAll(getLeafParts(child.getBody())); + } + return ret; + } else { + return Collections.singletonList(body); + } + } + + private static void checkLeafParts(MimeMessage msg, String... expectedParts) throws Exception { + List<String> actual = new ArrayList<>(); + for (Body leaf : getLeafParts(msg.getBody())) { + actual.add(streamToString(MimeUtility.decodeBody(leaf))); + } + assertEquals(Arrays.asList(expectedParts), actual); + } + + @Test + public void getRecipients_withXOriginalTo() throws Exception { + MimeMessage msg = parseWithoutRecurse(toStream( + "From: <adam@example.org>\r\n" + + "To: <eva@example.org>\r\n" + + "X-Original-To: <test@mail.com>\r\n" + + "Subject: Testmail\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-type: text/plain\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "this is some test text.")); + + Address[] xOriginalAddresses = msg.getRecipients(RecipientType.X_ORIGINAL_TO); + + assertEquals(1, xOriginalAddresses.length); + assertEquals(new Address("<test@mail.com>"), xOriginalAddresses[0]); + } + + @Test + public void getRecipients_withDeliveredTo() throws Exception { + MimeMessage msg = parseWithoutRecurse(toStream( + "From: <adam@example.org>\r\n" + + "To: <eva@example.org>\r\n" + + "Delivered-To: <test@mail.com>\r\n" + + "Subject: Testmail\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-type: text/plain\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "this is some test text.")); + + Address[] deliveredToAddresses = msg.getRecipients(RecipientType.DELIVERED_TO); + + assertEquals(1, deliveredToAddresses.length); + assertEquals(new Address("<test@mail.com>"), deliveredToAddresses[0]); + } + + @Test + public void getRecipients_withXEnvelopeTo() throws Exception { + MimeMessage msg = parseWithoutRecurse(toStream( + "From: <adam@example.org>\r\n" + + "To: <eva@example.org>\r\n" + + "X-Envelope-To: <test@mail.com>\r\n" + + "Subject: Testmail\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-type: text/plain\r\n" + + "Content-Transfer-Encoding: 7bit\r\n" + + "\r\n" + + "this is some test text.")); + + Address[] xEnvelopeToAddresses = msg.getRecipients(RecipientType.X_ENVELOPE_TO); + + assertEquals(1, xEnvelopeToAddresses.length); + assertEquals(new Address("<test@mail.com>"), xEnvelopeToAddresses[0]); + } + + +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt new file mode 100644 index 0000000..8842ab5 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt @@ -0,0 +1,390 @@ +package com.fsck.k9.mail.internet + +import assertk.assertThat +import assertk.assertions.containsOnly +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import org.junit.Test + +class MimeParameterDecoderTest { + @Test + fun rfc2045_example1() { + val mimeValue = MimeParameterDecoder.decode("text/plain; charset=us-ascii (Plain text)") + + assertThat(mimeValue.value).isEqualTo("text/plain") + assertThat(mimeValue.parameters).containsOnly("charset" to "us-ascii") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun rfc2045_example2() { + val mimeValue = MimeParameterDecoder.decode("text/plain; charset=\"us-ascii\"") + + assertThat(mimeValue.value).isEqualTo("text/plain") + assertThat(mimeValue.parameters).containsOnly("charset" to "us-ascii") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun rfc2231_example1() { + val mimeValue = MimeParameterDecoder.decode( + "message/external-body; access-type=URL;\r\n" + + " URL*0=\"ftp://\";\r\n" + + " URL*1=\"cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar\"", + ) + + assertThat(mimeValue.value).isEqualTo("message/external-body") + assertThat(mimeValue.parameters).containsOnly( + "url" to "ftp://cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar", + "access-type" to "URL", + ) + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun rfc2231_example2() { + val mimeValue = MimeParameterDecoder.decode( + "message/external-body; access-type=URL;\r\n" + + " URL=\"ftp://cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar\"", + ) + + assertThat(mimeValue.value).isEqualTo("message/external-body") + assertThat(mimeValue.parameters).containsOnly( + "access-type" to "URL", + "url" to "ftp://cs.utk.edu/pub/moore/bulk-mailer/bulk-mailer.tar", + ) + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun rfc2231_example3() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A", + ) + + assertThat(mimeValue.value).isEqualTo("application/x-stuff") + assertThat(mimeValue.parameters).containsOnly("name" to "This is ***fun***") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun rfc2231_example4() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name*0*=us-ascii'en'This%20is%20even%20more%20;\r\n" + + " name*1*=%2A%2A%2Afun%2A%2A%2A%20;\r\n" + + " name*2=\"isn't it!\"", + ) + + assertThat(mimeValue.value).isEqualTo("application/x-stuff") + assertThat(mimeValue.parameters).containsOnly("name" to "This is even more ***fun*** isn't it!") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun multiple_sections_out_of_order() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name*2=\"[three]\";\r\n" + + " name*1=\"[two]\";\r\n" + + " name*0=\"[one]\"", + ) + + assertThat(mimeValue.parameters).containsOnly("name" to "[one][two][three]") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun multiple_sections_differently_cased() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name*0=\"[one]\";\r\n" + + " NAME*1=\"[two]\";\r\n" + + " nAmE*2=\"[three]\"", + ) + + assertThat(mimeValue.parameters).containsOnly("name" to "[one][two][three]") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun multiple_sections_switching_between_extended_and_regular_value() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name*0*=utf-8'en'%5Bone%5D;\r\n" + + " name*1*=%5btwo%5d;\r\n" + + " name*2=\"[three]\";\r\n" + + " name*3*=%5Bfour%5D;\r\n" + + " name*4=\"[five]\";\r\n" + + " name*5=six", + ) + + assertThat(mimeValue.parameters).containsOnly("name" to "[one][two][three][four][five]six") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun rfc2045_and_rfc2231_style_parameters_should_use_rfc2231() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name=\"filename.ext\";\r\n" + + " name*=utf-8''filen%C3%A4me.ext", + ) + + assertThat(mimeValue.parameters).containsOnly("name" to "filenäme.ext") + assertThat(mimeValue.ignoredParameters).containsOnly("name" to "filename.ext") + } + + @Test + fun duplicate_parameter_names() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name=one;\r\n" + + " extra=something;\r\n" + + " name=two", + ) + + assertThat(mimeValue.parameters).containsOnly("extra" to "something") + assertThat(mimeValue.ignoredParameters).containsOnly("name" to "one", "name" to "two") + } + + @Test + fun duplicate_parameter_names_differing_in_case() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name=one;\r\n" + + " extra=something;\r\n" + + " NAME=two", + ) + + assertThat(mimeValue.parameters).containsOnly("extra" to "something") + assertThat(mimeValue.ignoredParameters).containsOnly("name" to "one", "name" to "two") + } + + @Test + fun name_only_parameter() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; parameter") + + assertThat(mimeValue.parserErrorIndex).isEqualTo(30) + assertThat(mimeValue.parameters).isEmpty() + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun missing_parameter_value() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; parameter=") + + assertThat(mimeValue.parserErrorIndex).isEqualTo(31) + assertThat(mimeValue.parameters).isEmpty() + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun comments_everywhere() { + val mimeValue = MimeParameterDecoder.decode( + "(comment)application(comment)/(comment)x-stuff" + + "(comment);(comment)\r\n" + + " (comment)name(comment)=(comment)one(comment);(comment)\r\n" + + " (comment) extra (comment) = (comment) something (comment)", + ) + + assertThat(mimeValue.parameters).containsOnly("name" to "one", "extra" to "something") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun iso8859_1_charset() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*=iso-8859-1''filen%E4me.ext") + + assertThat(mimeValue.parameters).containsOnly("name" to "filenäme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun missing_charset() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*=''filen%AAme.ext") + + assertThat(mimeValue.parameters).containsOnly("name" to "filen%AAme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun unknown_charset() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*=foobar''filen%AAme.ext") + + assertThat(mimeValue.parameters).containsOnly("name" to "filen%AAme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun section_index_missing() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name**=utf-8''filename") + + assertThat(mimeValue.parameters).containsOnly("name**" to "utf-8''filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun section_index_not_a_number() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*x*=filename") + + assertThat(mimeValue.parameters).containsOnly("name*x*" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun section_index_prefixed_with_plus() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*+0=filename") + + assertThat(mimeValue.parameters).containsOnly("name*+0" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun section_index_prefixed_with_minus() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*-0=filename") + + assertThat(mimeValue.parameters).containsOnly("name*-0" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun section_index_with_two_zeros() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*00=filename") + + assertThat(mimeValue.parameters).containsOnly("name*00" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun section_index_with_leading_zero() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name*0=one;\r\n" + + " name*01=two", + ) + + assertThat(mimeValue.parameters).containsOnly("name" to "one", "name*01" to "two") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun section_index_with_huge_number() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name*10000000000000000000=filename", + ) + + assertThat(mimeValue.parameters).containsOnly("name*10000000000000000000" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun extended_parameter_name_with_additional_asterisk() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0**=utf-8''filename") + + assertThat(mimeValue.parameters).containsOnly("name*0**" to "utf-8''filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun extended_parameter_name_with_additional_text() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0*x=utf-8''filename") + + assertThat(mimeValue.parameters).containsOnly("name*0*x" to "utf-8''filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun extended_parameter_value_with_quoted_string() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0*=\"utf-8''filename\"") + + assertThat(mimeValue.parameters).containsOnly("name*0*" to "utf-8''filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun extended_initial_parameter_value_missing_single_quotes() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0*=filename") + + assertThat(mimeValue.parameters).containsOnly("name*0*" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun extended_initial_parameter_value_missing_second_single_quote() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*0*='") + + assertThat(mimeValue.parameters).containsOnly("name*0*" to "'") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun extended_parameter_value_with_trailing_percent_sign() { + val mimeValue = MimeParameterDecoder.decode("attachment; filename*=utf-8''file%") + + assertThat(mimeValue.parameters).containsOnly("filename*" to "utf-8''file%") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun extended_parameter_value_with_invalid_percent_encoding() { + val mimeValue = MimeParameterDecoder.decode("attachment; filename*=UTF-8''f%oo.html") + + assertThat(mimeValue.parameters).containsOnly("filename*" to "UTF-8''f%oo.html") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun section_0_missing() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name*1=filename") + + assertThat(mimeValue.parameters).containsOnly("name*1" to "filename") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun semicolon_in_parameter_value() { + val mimeValue = MimeParameterDecoder.decode("attachment; filename=\"Here's a semicolon;.txt\"") + + assertThat(mimeValue.parameters).containsOnly("filename" to "Here's a semicolon;.txt") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun rfc2047_encoded() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name=\"=?UTF-8?Q?filn=C3=A4me=2Eext?=\"", + ) + + assertThat(mimeValue.parameters).containsOnly("name" to "filnäme.ext") + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun rfc2047_encoded_multiple_lines() { + val mimeValue = MimeParameterDecoder.decode( + "application/x-stuff;\r\n" + + " name=\"=?UTF-8?Q?File_name_that_is_so_long_it_likes_to_be_wrapped_i?=\r\n" + + " =?UTF-8?Q?nto_multiple_lines=2E_Also_?=\r\n" + + " =?UTF-8?Q?non-ASCII_characters=3A_=C3=A4=E2=82=AC=F0=9F=8C=9E?=\"", + ) + + assertThat(mimeValue.parameters).containsOnly( + "name" to "File name that is so long it likes to be wrapped " + + "into multiple lines. Also non-ASCII characters: ä€\uD83C\uDF1E", + ) + assertThat(mimeValue.ignoredParameters).isEmpty() + } + + @Test + fun `UTF-8 data in header value`() { + val mimeValue = MimeParameterDecoder.decode("application/x-stuff; name=\"filenäme.ext\"") + + assertThat(mimeValue.parameters).containsOnly("name" to "filenäme.ext") + + assertThat(mimeValue.ignoredParameters).isEmpty() + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterEncoderTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterEncoderTest.kt new file mode 100644 index 0000000..dea748a --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterEncoderTest.kt @@ -0,0 +1,166 @@ +package com.fsck.k9.mail.internet + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.fsck.k9.mail.crlf +import org.junit.Test + +class MimeParameterEncoderTest { + @Test + fun valueWithoutParameters() { + val header = MimeParameterEncoder.encode("inline", emptyMap()) + + assertThat(header).isEqualTo("inline") + } + + @Test + fun simpleParameterValue() { + val header = MimeParameterEncoder.encode("attachment", mapOf("filename" to "kitten.png")) + + assertThat(header).isEqualTo( + """ + |attachment; + | filename=kitten.png + """.trimMargin().crlf() + ) + } + + @Test + fun backslashesInParameterValue() { + val header = MimeParameterEncoder.encode( + "attachment", + mapOf("filename" to "Important Document \\Confidential\\.pdf") + ) + + assertThat(header).isEqualTo( + """ + |attachment; + | filename="Important Document \\Confidential\\.pdf" + """.trimMargin().crlf() + ) + } + + @Test + fun nonAsciiCharactersInParameterValue() { + val header = MimeParameterEncoder.encode("attachment", mapOf("filename" to "Übergrößenträger.dat")) + + assertThat(header).isEqualTo( + """ + |attachment; + | filename*=UTF-8''%C3%9Cbergr%C3%B6%C3%9Fentr%C3%A4ger.dat + """.trimMargin().crlf() + ) + } + + @Test + fun longParameterValueWithAsciiOnlyCharacters() { + val header = MimeParameterEncoder.encode( + "attachment", + mapOf( + "filename" to "This file name is quite long and exceeds the recommended header line length " + + "of 78 characters.txt" + ) + ) + + // For now this is encoded like parameters that contain non-ASCII characters. However we could use + // continuations without character set encoding to make it look like this: + // + // attachment; + // filename*0="This file name is quite long and exceeds the recommended header"; + // filename*1=" line length of 78 characters.txt" + assertThat(header).isEqualTo( + """ + |attachment; + | filename*0*=UTF-8''This%20file%20name%20is%20quite%20long%20and%20exceeds%20; + | filename*1*=the%20recommended%20header%20line%20length%20of%2078%20character; + | filename*2*=s.txt + """.trimMargin().crlf() + ) + } + + @Test + fun longParameterValueWithNonAsciiCharacters() { + val header = MimeParameterEncoder.encode( + "attachment", + mapOf("filename" to "üüüüüüüüüüüüüüüüüüüüüü.txt", "size" to "54321") + ) + + assertThat(header).isEqualTo( + """ + |attachment; + | filename*0*=UTF-8''%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC; + | filename*1*=%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC%C3%BC; + | filename*2*=%C3%BC%C3%BC%C3%BC.txt; + | size=54321 + """.trimMargin().crlf() + ) + } + + @Test + fun parameterValueWithControlCharacter() { + val header = MimeParameterEncoder.encode( + "value", + mapOf("something" to "foo\u0000bar") + ) + + assertThat(header).isEqualTo( + """ + |value; + | something*=UTF-8''foo%00bar + """.trimMargin().crlf() + ) + } + + @Test + fun mixedParameterValues() { + val header = MimeParameterEncoder.encode( + "value", + mapOf( + "token" to "foobar", + "quoted" to "something containing spaces", + "non-ascii" to "Grüße", + "long" to "one~two~three~four~five~six~seven~eight~nine~ten~eleven~twelve~thirteen~fourteen~fifteen" + ) + ) + + assertThat(header).isEqualTo( + """ + |value; + | token=foobar; + | quoted="something containing spaces"; + | non-ascii*=UTF-8''Gr%C3%BC%C3%9Fe; + | long*0*=UTF-8''one~two~three~four~five~six~seven~eight~nine~ten~eleven~twelv; + | long*1*=e~thirteen~fourteen~fifteen + """.trimMargin().crlf() + ) + } + + @Test + fun nonAttributeCharactersInParameterValue() { + val header = MimeParameterEncoder.encode( + "value", + mapOf( + "param1" to "*'%", + "param2" to "=*'%", + "param3" to "ü*'%" + ) + ) + + assertThat(header).isEqualTo( + """ + |value; + | param1=*'%; + | param2="=*'%"; + | param3*=UTF-8''%C3%BC%2A%27%25 + """.trimMargin().crlf() + ) + } + + @Test(expected = UnsupportedOperationException::class) + fun overlyLongParameterName_shouldThrow() { + MimeParameterEncoder.encode( + "attachment", + mapOf("parameter_name_that_exceeds_the_line_length_recommendation_almost_on_its_own" to "foobar") + ) + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeUtilityTest.java b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeUtilityTest.java new file mode 100644 index 0000000..c31d5c4 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/MimeUtilityTest.java @@ -0,0 +1,95 @@ +package com.fsck.k9.mail.internet; + + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + + +public class MimeUtilityTest { + @Test + public void isMultipart_withLowerCaseMultipart_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isMultipart("multipart/mixed")); + } + + @Test + public void isMultipart_withUpperCaseMultipart_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isMultipart("MULTIPART/ALTERNATIVE")); + } + + @Test + public void isMultipart_withMixedCaseMultipart_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isMultipart("Multipart/Alternative")); + } + + @Test + public void isMultipart_withoutMultipart_shouldReturnFalse() throws Exception { + assertFalse(MimeUtility.isMultipart("message/rfc822")); + } + + @Test + public void isMultipart_withNullArgument_shouldReturnFalse() throws Exception { + assertFalse(MimeUtility.isMultipart(null)); + } + + @Test + public void isMessage_withLowerCaseMessage_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isMessage("message/rfc822")); + } + + @Test + public void isMessage_withUpperCaseMessage_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isMessage("MESSAGE/RFC822")); + } + + @Test + public void isMessage_withMixedCaseMessage_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isMessage("Message/Rfc822")); + } + + @Test + public void isMessage_withoutMessageRfc822_shouldReturnFalse() throws Exception { + assertFalse(MimeUtility.isMessage("Message/Partial")); + } + + @Test + public void isMessage_withoutMessage_shouldReturnFalse() throws Exception { + assertFalse(MimeUtility.isMessage("multipart/mixed")); + } + + @Test + public void isMessage_withNullArgument_shouldReturnFalse() throws Exception { + assertFalse(MimeUtility.isMessage(null)); + } + + @Test + public void isSameMimeType_withSameTypeAndCase_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isSameMimeType("text/plain", "text/plain")); + } + + @Test + public void isSameMimeType_withSameTypeButMixedCase_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isSameMimeType("text/plain", "Text/Plain")); + } + + @Test + public void isSameMimeType_withSameTypeAndLowerAndUpperCase_shouldReturnTrue() throws Exception { + assertTrue(MimeUtility.isSameMimeType("TEXT/PLAIN", "text/plain")); + } + + @Test + public void isSameMimeType_withDifferentType_shouldReturnFalse() throws Exception { + assertFalse(MimeUtility.isSameMimeType("text/plain", "text/html")); + } + + @Test + public void isSameMimeType_withFirstArgumentBeingNull_shouldReturnFalse() throws Exception { + assertFalse(MimeUtility.isSameMimeType(null, "text/html")); + } + + @Test + public void isSameMimeType_withSecondArgumentBeingNull_shouldReturnFalse() throws Exception { + assertFalse(MimeUtility.isSameMimeType("text/html", null)); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/PartExtensionsTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/PartExtensionsTest.kt new file mode 100644 index 0000000..aa62ceb --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/PartExtensionsTest.kt @@ -0,0 +1,55 @@ +package com.fsck.k9.mail.internet + +import assertk.assertThat +import assertk.assertions.isEqualTo +import org.junit.Test + +class PartExtensionsTest { + @Test + fun `get charset without charset parameter`() { + assertGetCharset(headerValue = "text/plain", expectedCharset = null) + } + + @Test + fun `get charset with single charset parameter`() { + assertGetCharset(headerValue = "text/plain; charset=UTF-8", expectedCharset = "utf-8") + } + + @Test + fun `get charset with single quoted charset parameter`() { + assertGetCharset(headerValue = "text/plain; charset=\"iso-8859-1\"", expectedCharset = "ISO-8859-1") + } + + @Test + fun `get charset with two charset parameters where values match exactly`() { + assertGetCharset(headerValue = "text/plain; charset=utf-8; charset=utf-8", expectedCharset = "utf-8") + } + + @Test + fun `get charset with two charset parameters where values differ in case`() { + assertGetCharset(headerValue = "text/plain; charset=utf-8; charset=UTF-8", expectedCharset = "utf-8") + } + + @Test + fun `get charset with two charset parameters where values differ in quoting`() { + assertGetCharset(headerValue = "text/plain; charset=utf-8; charset=\"utf-8\"", expectedCharset = "utf-8") + } + + @Test + fun `get charset with two charset parameters with conflicting values`() { + assertGetCharset(headerValue = "text/plain; charset=utf-8; charset=iso-8859-1", expectedCharset = null) + } + + @Test + fun `get charset with extended parameter syntax`() { + assertGetCharset(headerValue = "text/plain; charset*=us-ascii'en-us'utf-8", expectedCharset = null) + } + + private fun assertGetCharset(headerValue: String, expectedCharset: String?) { + val part = MimeBodyPart.create(null, headerValue) + + val charset = part.charset + + assertThat(charset).isEqualTo(other = expectedCharset, ignoreCase = true) + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/TestCharsetProvider.kt b/mail/common/src/test/java/com/fsck/k9/mail/internet/TestCharsetProvider.kt new file mode 100644 index 0000000..0223c88 --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/TestCharsetProvider.kt @@ -0,0 +1,28 @@ +package com.fsck.k9.mail.internet + +import com.ibm.icu.charset.CharsetProviderICU +import java.nio.charset.Charset +import java.nio.charset.spi.CharsetProvider + +/** + * CharsetProvider that adds the "ISO-2022-JP-TEST" charset. + * + * The "ISO-2022-JP" decoder on the JVM is more lenient than the ICU4J decoder that is used on Android. For tests we + * use the ICU4J implementation that is also used on Android. + */ +class TestCharsetProvider : CharsetProvider() { + private val icuCharsetProvider = CharsetProviderICU() + private val charset = icuCharsetProvider.charsetForName("ISO-2022-JP") + + override fun charsets(): Iterator<Charset> { + return listOf(charset).iterator() + } + + override fun charsetForName(charsetName: String?): Charset? { + return if (charsetName?.equals("ISO-2022-JP-TEST", ignoreCase = true) == true) { + charset + } else { + null + } + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/internet/TextBodyTest.java b/mail/common/src/test/java/com/fsck/k9/mail/internet/TextBodyTest.java new file mode 100644 index 0000000..c96b7ae --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/internet/TextBodyTest.java @@ -0,0 +1,31 @@ +package com.fsck.k9.mail.internet; + + +import java.io.IOException; + +import com.fsck.k9.mail.MessagingException; +import okio.Buffer; +import org.apache.james.mime4j.util.MimeUtil; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + + +public class TextBodyTest { + @Test + public void getSize_withSignUnsafeData_shouldReturnCorrectValue() throws Exception { + TextBody textBody = new TextBody("From Bernd"); + textBody.setEncoding(MimeUtil.ENC_QUOTED_PRINTABLE); + + long result = textBody.getSize(); + + int outputSize = getSizeOfSerializedBody(textBody); + assertEquals(outputSize, result); + } + + private int getSizeOfSerializedBody(TextBody textBody) throws IOException, MessagingException { + Buffer buffer = new Buffer(); + textBody.writeTo(buffer.outputStream()); + return buffer.readByteString().size(); + } +} diff --git a/mail/common/src/test/java/com/fsck/k9/mail/ssl/LocalKeyStoreTest.kt b/mail/common/src/test/java/com/fsck/k9/mail/ssl/LocalKeyStoreTest.kt new file mode 100644 index 0000000..1da830d --- /dev/null +++ b/mail/common/src/test/java/com/fsck/k9/mail/ssl/LocalKeyStoreTest.kt @@ -0,0 +1,91 @@ +package com.fsck.k9.mail.ssl + +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import java.nio.file.Files +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import org.junit.After +import org.junit.Test + +class LocalKeyStoreTest { + private val host = "mail.domain.example" + private val port = 587 + + private val tempDirectory = Files.createTempDirectory("KeyStore").toFile() + private val localKeyStore = LocalKeyStore(directoryProvider = { tempDirectory }) + + @After + fun tearDown() { + tempDirectory.deleteRecursively() + } + + @Test + fun `empty LocalKeyStore should not consider certificate valid`() { + val certificate = readCertificate(host) + + val isCertificateValid = localKeyStore.isValidCertificate(certificate, host, port) + + assertThat(isCertificateValid).isFalse() + } + + @Test + fun `stored certificate should be considered valid`() { + val certificate = readCertificate(host) + localKeyStore.addCertificate(host, port, certificate) + + val isCertificateValid = localKeyStore.isValidCertificate(certificate, host, port) + + assertThat(isCertificateValid).isTrue() + } + + @Test + fun `host names must match`() { + val certificate = readCertificate(host) + localKeyStore.addCertificate(host, port, certificate) + + val isCertificateValid = localKeyStore.isValidCertificate(certificate, "evil.domain.example", port) + + assertThat(isCertificateValid).isFalse() + } + + @Test + fun `port numbers must match`() { + val certificate = readCertificate(host) + localKeyStore.addCertificate(host, port, certificate) + + val isCertificateValid = localKeyStore.isValidCertificate(certificate, host, 123) + + assertThat(isCertificateValid).isFalse() + } + + @Test + fun `different certificate should not be considered valid`() { + val certificate = readCertificate(host) + localKeyStore.addCertificate(host, port, certificate) + val anotherCertificate = readCertificate("mail.another-domain.example") + + val isCertificateValid = localKeyStore.isValidCertificate(anotherCertificate, host, port) + + assertThat(isCertificateValid).isFalse() + } + + @Test + fun `deleted certificate shouldn't be considered valid`() { + val certificate = readCertificate(host) + localKeyStore.addCertificate(host, port, certificate) + localKeyStore.deleteCertificate(host, port) + + val isCertificateValid = localKeyStore.isValidCertificate(certificate, host, port) + + assertThat(isCertificateValid).isFalse() + } + + private fun readCertificate(name: String): X509Certificate { + val certificateFactory = CertificateFactory.getInstance("X.509") + LocalKeyStoreTest::class.java.getResourceAsStream("/certificates/$name.pem")!!.let { inputStream -> + return certificateFactory.generateCertificate(inputStream) as X509Certificate + } + } +} |
