summaryrefslogtreecommitdiff
path: root/mail/common/src/test/java
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /mail/common/src/test/java
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'mail/common/src/test/java')
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/AddressTest.java154
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/Address_quoteAtoms.java67
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/BoundaryGeneratorTest.kt40
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/MessageTest.kt341
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/MimeTypeTest.kt82
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/filter/EOLConvertingOutputStreamTest.java125
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/filter/FixedLengthInputStreamTest.java273
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/filter/SignSafeOutputStreamTest.java86
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/filter/SmtpDataStuffingTest.java55
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/AddressHeaderBuilderTest.kt58
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/CharsetSupportTest.java122
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/DecoderUtilTest.java238
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/EncoderUtilTest.kt33
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/FlowedMessageUtilsTest.kt217
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/FormatFlowedHelperTest.kt66
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/MessageExtractorTest.java119
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdGeneratorTest.kt45
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/MessageIdParserTest.kt173
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/MimeHeaderCheckerTest.kt210
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/MimeMessageParseTest.java330
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterDecoderTest.kt390
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/MimeParameterEncoderTest.kt166
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/MimeUtilityTest.java95
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/PartExtensionsTest.kt55
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/TestCharsetProvider.kt28
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/internet/TextBodyTest.java31
-rw-r--r--mail/common/src/test/java/com/fsck/k9/mail/ssl/LocalKeyStoreTest.kt91
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
+ }
+ }
+}