diff --git a/gradle.properties b/gradle.properties index 729003124..b170f9cd1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ koinVersion=0.9.1 robolectricVersion=3.7.1 junitVersion=4.12 mockitoVersion=2.18.0 -okioVersion=1.11.0 +okioVersion=1.14.0 truthVersion=0.35 android.enableAapt2=false diff --git a/k9mail-library/build.gradle b/k9mail-library/build.gradle index 6ddc193aa..49a0e6803 100644 --- a/k9mail-library/build.gradle +++ b/k9mail-library/build.gradle @@ -11,6 +11,7 @@ if (rootProject.testCoverage) { } dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:${kotlinVersion}" implementation 'org.apache.james:apache-mime4j-core:0.8.1' implementation 'org.apache.james:apache-mime4j-dom:0.8.1' implementation "com.squareup.okio:okio:${okioVersion}" @@ -23,7 +24,6 @@ dependencies { androidTestImplementation 'com.android.support.test:runner:0.4.1' androidTestImplementation 'com.madgag.spongycastle:pg:1.51.0.0' - testImplementation "org.jetbrains.kotlin:kotlin-stdlib-jre7:${kotlinVersion}" testImplementation "org.robolectric:robolectric:${robolectricVersion}" testImplementation "junit:junit:${junitVersion}" testImplementation "com.google.truth:truth:${truthVersion}" diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/filter/Hex.java b/k9mail-library/src/main/java/com/fsck/k9/mail/filter/Hex.kt similarity index 53% rename from k9mail-library/src/main/java/com/fsck/k9/mail/filter/Hex.java rename to k9mail-library/src/main/java/com/fsck/k9/mail/filter/Hex.kt index 4ed2acfd7..e02aef95c 100644 --- a/k9mail-library/src/main/java/com/fsck/k9/mail/filter/Hex.java +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/filter/Hex.kt @@ -1,4 +1,5 @@ /* + * Copyright 2018 The K-9 Dog Walkers * Copyright 2001-2004 The Apache Software Foundation. * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,43 +15,44 @@ * limitations under the License. */ -package com.fsck.k9.mail.filter; +package com.fsck.k9.mail.filter /** * This code was copied from the Apache Commons project. * The unnecessary parts have been left out. */ -public class Hex { - /** - * Used building output as Hex - */ - private static final char[] DIGITS = { - '0', '1', '2', '3', '4', '5', '6', '7', - '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' - }; +object Hex { + private val LOWER_CASE = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + private val UPPER_CASE = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F') /** * Converts an array of bytes into an array of characters representing the hexadecimal values of each byte in order. * The returned array will be double the length of the passed array, as it takes two characters to represent any * given byte. * - * @param data - * a byte[] to convert to Hex characters + * @param data a byte[] to convert to Hex characters * @return A String containing lower-case hexadecimal characters */ - public static String encodeHex(byte[] data) { - - int l = data.length; - - char[] out = new char[l << 1]; + @JvmStatic + fun encodeHex(data: ByteArray): String { + val l = data.size + val out = CharArray(l shl 1) // two characters form the hex value. - for (int i = 0, j = 0; i < l; i++) { - out[j++] = DIGITS[(0xF0 & data[i]) >>> 4 ]; - out[j++] = DIGITS[ 0x0F & data[i] ]; + var i = 0 + var j = 0 + while (i < l) { + out[j++] = LOWER_CASE[data[i].toInt() shr 4 and 0x0F] + out[j++] = LOWER_CASE[data[i].toInt() and 0x0F] + i++ } - return new String(out); + return String(out) } + fun StringBuilder.appendHex(value: Byte, lowerCase: Boolean = true) { + val digits = if (lowerCase) LOWER_CASE else UPPER_CASE + append(digits[value.toInt() shr 4 and 0x0F]) + append(digits[value.toInt() and 0x0F]) + } } diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/helper/Utf8.kt b/k9mail-library/src/main/java/com/fsck/k9/mail/helper/Utf8.kt new file mode 100644 index 000000000..2c48f70f6 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/helper/Utf8.kt @@ -0,0 +1,115 @@ +/* + * These functions are based on Okio's UTF-8 code. + * + * Copyright (C) 2018 The K-9 Dog Walkers + * Copyright (C) 2017 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.fsck.k9.mail.helper + +/** + * Encodes this string using UTF-8. + */ +inline fun String.encodeUtf8(beginIndex: Int = 0, endIndex: Int = length, crossinline writeByte: (Byte) -> Unit) { + require(beginIndex >= 0) { "beginIndex < 0: $beginIndex" } + require(endIndex >= beginIndex) { "endIndex < beginIndex: $endIndex < $beginIndex" } + require(endIndex <= length) { "endIndex > length: $endIndex > $length" } + + // Transcode a UTF-16 Java String to UTF-8 bytes. + var i = beginIndex + while (i < endIndex) { + val c = this[i].toInt() + + if (c < 0x80) { + // Emit a 7-bit character with 1 byte. + writeByte(c.toByte()) // 0xxxxxxx + i++ + } else if (c < 0x800) { + // Emit a 11-bit character with 2 bytes. + writeByte((c shr 6 or 0xc0).toByte()) // 110xxxxx + writeByte((c and 0x3f or 0x80).toByte()) // 10xxxxxx + i++ + } else if (c < 0xd800 || c > 0xdfff) { + // Emit a 16-bit character with 3 bytes. + writeByte((c shr 12 or 0xe0).toByte()) // 1110xxxx + writeByte((c shr 6 and 0x3f or 0x80).toByte()) // 10xxxxxx + writeByte((c and 0x3f or 0x80).toByte()) // 10xxxxxx + i++ + } else { + // c is a surrogate. Make sure it is a high surrogate and that its successor is a low surrogate. + // If not, the UTF-16 is invalid, in which case we emit a replacement character. + val low = if (i + 1 < endIndex) this[i + 1].toInt() else 0 + if (c > 0xdbff || low < 0xdc00 || low > 0xdfff) { + writeByte('?'.toByte()) + i++ + continue + } + + // UTF-16 high surrogate: 110110xxxxxxxxxx (10 bits) + // UTF-16 low surrogate: 110111yyyyyyyyyy (10 bits) + // Unicode code point: 00010000000000000000 + xxxxxxxxxxyyyyyyyyyy (21 bits) + val codePoint = 0x010000 + (c and 0xd800.inv() shl 10 or (low and 0xdc00.inv())) + + // Emit a 21-bit character with 4 bytes. + writeByte((codePoint shr 18 or 0xf0).toByte()) // 11110xxx + writeByte((codePoint shr 12 and 0x3f or 0x80).toByte()) // 10xxxxxx + writeByte((codePoint shr 6 and 0x3f or 0x80).toByte()) // 10xxyyyy + writeByte((codePoint and 0x3f or 0x80).toByte()) // 10yyyyyy + i += 2 + } + } +} + +/** + * Returns the number of bytes used to encode `string` as UTF-8 when using [Int.encodeUtf8]. + */ +fun Int.utf8Size(): Int { + return when { + this < 0x80 -> 1 + this < 0x800 -> 2 + this < 0xd800 -> 3 + this < 0xe000 -> 1 + this < 0x10000 -> 3 + else -> 4 + } +} + +/** + * Encodes this code point using UTF-8. + */ +inline fun Int.encodeUtf8(crossinline writeByte: (Byte) -> Unit) { + val codePoint = this + if (codePoint < 0x80) { + // Emit a 7-bit character with 1 byte. + writeByte(codePoint.toByte()) // 0xxxxxxx + } else if (codePoint < 0x800) { + // Emit a 11-bit character with 2 bytes. + writeByte((codePoint shr 6 or 0xc0).toByte()) // 110xxxxx + writeByte((codePoint and 0x3f or 0x80).toByte()) // 10xxxxxx + } else if (codePoint < 0xd800 || codePoint in 0xe000..0x10000) { + // Emit a 16-bit character with 3 bytes. + writeByte((codePoint shr 12 or 0xe0).toByte()) // 1110xxxx + writeByte((codePoint shr 6 and 0x3f or 0x80).toByte()) // 10xxxxxx + writeByte((codePoint and 0x3f or 0x80).toByte()) // 10xxxxxx + } else if (codePoint in 0xd800..0xdfff) { + // codePoint is a surrogate. Emit a replacement character + writeByte('?'.toByte()) + } else { + // Emit a 21-bit character with 4 bytes. + writeByte((codePoint shr 18 or 0xf0).toByte()) // 11110xxx + writeByte((codePoint shr 12 and 0x3f or 0x80).toByte()) // 10xxxxxx + writeByte((codePoint shr 6 and 0x3f or 0x80).toByte()) // 10xxyyyy + writeByte((codePoint and 0x3f or 0x80).toByte()) // 10yyyyyy + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/Headers.kt b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/Headers.kt new file mode 100644 index 000000000..50c020442 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/Headers.kt @@ -0,0 +1,20 @@ +package com.fsck.k9.mail.internet + +object Headers { + @JvmStatic + fun contentType(mimeType: String, name: String): String { + return MimeParameterEncoder.encode(mimeType, mapOf("name" to name)) + } + + @JvmStatic + @JvmOverloads + fun contentDisposition(disposition: String, fileName: String, size: Long? = null): String { + val parameters = if (size == null) { + mapOf("filename" to fileName) + } else { + mapOf("filename" to fileName, "size" to size.toString()) + } + + return MimeParameterEncoder.encode(disposition, parameters) + } +} diff --git a/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt new file mode 100644 index 000000000..c9871e263 --- /dev/null +++ b/k9mail-library/src/main/java/com/fsck/k9/mail/internet/MimeParameterEncoder.kt @@ -0,0 +1,225 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.filter.Hex.appendHex +import com.fsck.k9.mail.helper.encodeUtf8 +import com.fsck.k9.mail.helper.utf8Size + +/** + * Encode MIME parameter values as specified in RFC 2045 and RFC 2231. + */ +object MimeParameterEncoder { + // RFC 5322, section 2.1.1 + private const val MAX_LINE_LENGTH = 78 + + // RFC 5234: CRLF = %d13.10 + private const val CRLF = "\r\n" + + // RFC 5234: HTAB = %x09 + private const val HTAB = '\t' + + // RFC 5234: SP = %x20 + private const val SPACE = ' ' + + // RFC 5234: DQUOTE = %x22 + private const val DQUOTE = '"' + + // RFC 2045: tspecials := "(" / ")" / "<" / ">" / "@" / "," / ";" / ":" / "\" / <"> / "/" / "[" / "]" / "?" / "=" + private val TSPECIALS = charArrayOf('(', ')', '<', '>', '@', ',', ';', ':', '\\', '"', '/', '[', ']', '?', '=') + + private const val ENCODED_VALUE_PREFIX = "UTF-8''" + + + /** + * Create header field value with parameters encoded if necessary. + */ + @JvmStatic + fun encode(value: String, parameters: Map): String { + return if (parameters.isEmpty()) { + value + } else { + buildString { + append(value) + encodeAndAppendParameters(parameters) + } + } + } + + private fun StringBuilder.encodeAndAppendParameters(parameters: Map) { + for ((name, value) in parameters) { + encodeAndAppendParameter(name, value) + } + } + + private fun StringBuilder.encodeAndAppendParameter(name: String, value: String) { + val fixedCostLength = 1 /* folding space */ + name.length + 1 /* equals sign */ + 1 /* semicolon */ + val unencodedValueFitsOnSingleLine = fixedCostLength + value.length <= MAX_LINE_LENGTH + val quotedValueMightFitOnSingleLine = fixedCostLength + value.length + 2 /* quotes */ <= MAX_LINE_LENGTH + + if (unencodedValueFitsOnSingleLine && value.isToken()) { + appendParameter(name, value) + } else if (quotedValueMightFitOnSingleLine && value.isQuotable() && + fixedCostLength + value.quotedLength() <= MAX_LINE_LENGTH) { + appendParameter(name, value.quoted()) + } else { + rfc2231EncodeAndAppendParameter(name, value) + } + } + + private fun StringBuilder.appendParameter(name: String, value: String) { + append(";$CRLF ") + append(name).append('=').append(value) + } + + private fun StringBuilder.rfc2231EncodeAndAppendParameter(name: String, value: String) { + val encodedValueLength = 1 /* folding space */ + name.length + 1 /* asterisk */ + 1 /* equal sign */ + + ENCODED_VALUE_PREFIX.length + value.rfc2231EncodedLength() + 1 /* semicolon */ + + if (encodedValueLength <= MAX_LINE_LENGTH) { + appendRfc2231SingleLineParameter(name, value.rfc2231Encoded()) + } else { + encodeAndAppendRfc2231MultiLineParameter(name, value) + } + } + + private fun StringBuilder.appendRfc2231SingleLineParameter(name: String, encodedValue: String) { + append(";$CRLF ") + append(name) + append("*=$ENCODED_VALUE_PREFIX") + append(encodedValue) + } + + private fun StringBuilder.encodeAndAppendRfc2231MultiLineParameter(name: String, value: String) { + var index = 0 + var line = 0 + var startOfLine = true + var remainingSpaceInLine = 0 + val endIndex = value.length + while (index < endIndex) { + if (startOfLine) { + append(";$CRLF ") + val lineStartIndex = length - 1 + append(name).append('*').append(line).append("*=") + if (line == 0) { + append(ENCODED_VALUE_PREFIX) + } + + remainingSpaceInLine = MAX_LINE_LENGTH - (length - lineStartIndex) - 1 /* semicolon */ + if (remainingSpaceInLine < 3) { + throw UnsupportedOperationException("Parameter name too long") + } + + startOfLine = false + line++ + } + + val codePoint = value.codePointAt(index) + + // Keep all characters encoding a single code point on the same line + val utf8Size = codePoint.utf8Size() + if (utf8Size == 1 && codePoint.toChar().isAttributeChar() && remainingSpaceInLine >= 1) { + append(codePoint.toChar()) + index++ + remainingSpaceInLine-- + } else if (remainingSpaceInLine >= utf8Size * 3) { + codePoint.encodeUtf8 { + append('%') + appendHex(it, lowerCase = false) + remainingSpaceInLine -= 3 + } + index += Character.charCount(codePoint) + } else { + startOfLine = true + } + } + } + + private fun String.rfc2231Encoded() = buildString { + this@rfc2231Encoded.encodeUtf8 { byte -> + val c = byte.toChar() + if (c.isAttributeChar()) { + append(c) + } else { + append('%') + appendHex(byte, lowerCase = false) + } + } + } + + private fun String.rfc2231EncodedLength(): Int { + var length = 0 + encodeUtf8 { byte -> + length += if (byte.toChar().isAttributeChar()) 1 else 3 + } + return length + } + + private fun String.isToken() = when { + isEmpty() -> false + else -> all { it.isTokenChar() } + } + + private fun String.isQuotable() = all { it.isQuotable() } + + private fun String.quoted(): String { + // quoted-string = [CFWS] DQUOTE *([FWS] qcontent) [FWS] DQUOTE [CFWS] + // qcontent = qtext / quoted-pair + // quoted-pair = ("\" (VCHAR / WSP)) + + return buildString(capacity = length + 16) { + append(DQUOTE) + for (c in this@quoted) { + if (c.isQText() || c.isWsp()) { + append(c) + } else if (c.isVChar()) { + append('\\').append(c) + } else { + throw IllegalArgumentException("Unsupported character: $c") + } + } + append(DQUOTE) + } + } + + private fun String.quotedLength(): Int { + var length = 2 /* start and end quote */ + for (c in this) { + if (c.isQText() || c.isWsp()) { + length++ + } else if (c.isVChar()) { + length += 2 + } else { + throw IllegalArgumentException("Unsupported character: $c") + } + } + return length + } + + private fun Char.isQuotable() = when { + isWsp() -> true + isVChar() -> true + else -> false + } + + private fun Char.isTSpecial() = this in TSPECIALS + + // RFC 2045: token := 1* + // RFC 5234: CTL = %x00-1F / %x7F + private fun Char.isTokenChar() = isVChar() && !isTSpecial() + + // RFC 5322: qtext = %d33 / %d35-91 / %d93-126 / obs-qtext + private fun Char.isQText() = when (toInt()) { + 33 -> true + in 35..91 -> true + in 93..126 -> true + else -> false + } + + // RFC 5234: VCHAR = %x21-7E + private fun Char.isVChar() = toInt() in 33..126 + + // RFC 5234: WSP = SP / HTAB + private fun Char.isWsp() = this == SPACE || this == HTAB + + // RFC 2231: attribute-char := + private fun Char.isAttributeChar() = isVChar() && this != '*' && this != '\'' && this != '%' && !isTSpecial() +} diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/MessageTest.kt b/k9mail-library/src/test/java/com/fsck/k9/mail/MessageTest.kt index 706db42f4..5376c874d 100644 --- a/k9mail-library/src/test/java/com/fsck/k9/mail/MessageTest.kt +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/MessageTest.kt @@ -320,5 +320,3 @@ class MessageTest { } private fun Message.getFirstHeader(header: String): String = getHeader(header)[0] - -private fun String.crlf() = replace("\n", "\r\n") diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/TestHelper.kt b/k9mail-library/src/test/java/com/fsck/k9/mail/TestHelper.kt new file mode 100644 index 000000000..73766b23e --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/TestHelper.kt @@ -0,0 +1,3 @@ +package com.fsck.k9.mail + +fun String.crlf() = replace("\n", "\r\n") diff --git a/k9mail-library/src/test/java/com/fsck/k9/mail/internet/MimeParameterEncoderTest.kt b/k9mail-library/src/test/java/com/fsck/k9/mail/internet/MimeParameterEncoderTest.kt new file mode 100644 index 000000000..18d0f1057 --- /dev/null +++ b/k9mail-library/src/test/java/com/fsck/k9/mail/internet/MimeParameterEncoderTest.kt @@ -0,0 +1,130 @@ +package com.fsck.k9.mail.internet + +import com.fsck.k9.mail.crlf +import com.google.common.truth.Truth.assertThat +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/k9mail/src/main/java/com/fsck/k9/message/MessageBuilder.java b/k9mail/src/main/java/com/fsck/k9/message/MessageBuilder.java index f64a5f7a3..b2d0323c8 100644 --- a/k9mail/src/main/java/com/fsck/k9/message/MessageBuilder.java +++ b/k9mail/src/main/java/com/fsck/k9/message/MessageBuilder.java @@ -3,13 +3,14 @@ package com.fsck.k9.message; import java.util.Date; import java.util.List; -import java.util.Locale; import android.app.Activity; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.AsyncTask; + +import com.fsck.k9.mail.internet.Headers; import timber.log.Timber; import com.fsck.k9.Account.QuoteStyle; @@ -34,7 +35,6 @@ import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.mail.internet.TextBody; import com.fsck.k9.mailstore.TempFileBody; import com.fsck.k9.message.quote.InsertableHtmlContent; -import org.apache.james.mime4j.codec.EncoderUtil; import org.apache.james.mime4j.util.MimeUtil; @@ -233,51 +233,18 @@ public abstract class MessageBuilder { } } - /* - * Content-Type is defined in RFC 2045 - * - * Example: - * - * Content-Type: text/plain; charset=us-ascii (Plain text) - * - * TODO: RFC 2231/2184 long parameter encoding - * Example: - * - * Content-Type: application/x-stuff - * title*1*=us-ascii'en'This%20is%20even%20more%20 - * title*2*=%2A%2A%2Afun%2A%2A%2A%20 - * title*3="isn't it!" - */ - private void addContentType(MimeBodyPart bp, String contentType, String name) throws MessagingException { - /* - * Correctly encode the filename here. Otherwise the whole - * header value (all parameters at once) will be encoded by - * MimeHeader.writeTo(). - */ - bp.addHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\r\n name=\"%s\"", - contentType, - EncoderUtil.encodeIfNecessary(name, - EncoderUtil.Usage.WORD_ENTITY, 7))); + private void addContentType(MimeBodyPart bodyPart, String contentType, String name) throws MessagingException { + String value = Headers.contentType(contentType, name); + bodyPart.addHeader(MimeHeader.HEADER_CONTENT_TYPE, value); if (!MimeUtil.isMessage(contentType)) { - bp.setEncoding(MimeUtility.getEncodingforType(contentType)); + bodyPart.setEncoding(MimeUtility.getEncodingforType(contentType)); } } - /* - * TODO: RFC 2231/2184 long parameter encoding - * - * From RFC 2183 (The Content-Disposition Header Field): - * "Parameter values longer than 78 characters, or which - * contain non-ASCII characters, MUST be encoded as specified - * in [RFC 2184]." - * - */ - private void addContentDisposition(MimeBodyPart bp, String name, Long size) { - bp.addHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, String.format(Locale.US, - "attachment;\r\n filename=\"%s\";\r\n size=%d", - EncoderUtil.encodeIfNecessary(name, EncoderUtil.Usage.WORD_ENTITY, 7), - size)); + private void addContentDisposition(MimeBodyPart bodyPart, String fileName, Long size) { + String value = Headers.contentDisposition("attachment", fileName, size); + bodyPart.addHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, value); } /** diff --git a/k9mail/src/test/java/com/fsck/k9/message/MessageBuilderTest.java b/k9mail/src/test/java/com/fsck/k9/message/MessageBuilderTest.java index 361e0b4ce..515df1e19 100644 --- a/k9mail/src/test/java/com/fsck/k9/message/MessageBuilderTest.java +++ b/k9mail/src/test/java/com/fsck/k9/message/MessageBuilderTest.java @@ -29,7 +29,6 @@ import com.fsck.k9.mail.internet.MimeMultipart; import com.fsck.k9.message.MessageBuilder.Callback; import com.fsck.k9.message.quote.InsertableHtmlContent; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.robolectric.Robolectric; @@ -99,24 +98,22 @@ public class MessageBuilderTest extends RobolectricTest { "text =E2=98=AD\r\n" + "--" + BOUNDARY_1 + "\r\n" + "Content-Type: text/plain;\r\n" + - " name=\"attach.txt\"\r\n" + + " name=attach.txt\r\n" + "Content-Transfer-Encoding: base64\r\n" + "Content-Disposition: attachment;\r\n" + - " filename=\"attach.txt\";\r\n" + + " filename=attach.txt;\r\n" + " size=23\r\n" + "\r\n" + "dGV4dCBkYXRhIGluIGF0dGFjaG1lbnQ=\r\n" + "\r\n" + "--" + BOUNDARY_1 + "--\r\n"; - private static final String MESSAGE_CONTENT_WITH_LONG_CONTENT_TYPE = + private static final String MESSAGE_CONTENT_WITH_LONG_FILE_NAME = "Content-Type: multipart/mixed; boundary=\"" + BOUNDARY_1 + "\"\r\n" + "Content-Transfer-Encoding: 7bit\r\n" + "\r\n" + "--" + BOUNDARY_1 + "\r\n" + "Content-Type: text/plain;\r\n" + - " title*1*=1234567891123456789212345678931234567894123456789\r\n" + - " title*2*=5123456789612345678971234567898123456789091234567890;\r\n" + " charset=utf-8\r\n" + "Content-Transfer-Encoding: quoted-printable\r\n" + "\r\n" + @@ -124,10 +121,12 @@ public class MessageBuilderTest extends RobolectricTest { "text =E2=98=AD\r\n" + "--" + BOUNDARY_1 + "\r\n" + "Content-Type: text/plain;\r\n" + - " name=\"attach.txt\"\r\n" + + " name*0*=UTF-8''~~~~~~~~~1~~~~~~~~~2~~~~~~~~~3~~~~~~~~~4~~~~~~~~~5~~~~~~~~~6~;\r\n" + + " name*1*=~~~~~~~~7.txt\r\n" + "Content-Transfer-Encoding: base64\r\n" + "Content-Disposition: attachment;\r\n" + - " filename=\"attach.txt\";\r\n" + + " filename*0*=UTF-8''~~~~~~~~~1~~~~~~~~~2~~~~~~~~~3~~~~~~~~~4~~~~~~~~~5~~~~~~~;\r\n" + + " filename*1*=~~6~~~~~~~~~7.txt;\r\n" + " size=23\r\n" + "\r\n" + "dGV4dCBkYXRhIGluIGF0dGFjaG1lbnQ=\r\n" + @@ -148,10 +147,10 @@ public class MessageBuilderTest extends RobolectricTest { "text =E2=98=AD\r\n" + "--" + BOUNDARY_1 + "\r\n" + "Content-Type: text/plain;\r\n" + - " name=\"=?UTF-8?B?44OG44K544OI5paH5pu4LnR4dA==?=\"\r\n" + + " name*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88%E6%96%87%E6%9B%B8.txt\r\n" + "Content-Transfer-Encoding: base64\r\n" + "Content-Disposition: attachment;\r\n" + - " filename=\"=?UTF-8?B?44OG44K544OI5paH5pu4LnR4dA==?=\";\r\n" + + " filename*=UTF-8''%E3%83%86%E3%82%B9%E3%83%88%E6%96%87%E6%9B%B8.txt;\r\n" + " size=23\r\n" + "\r\n" + "dGV4dCBkYXRhIGluIGF0dGFjaG1lbnQ=\r\n" + @@ -171,9 +170,9 @@ public class MessageBuilderTest extends RobolectricTest { "text =E2=98=AD\r\n" + "--" + BOUNDARY_1 + "\r\n" + "Content-Type: message/rfc822;\r\n" + - " name=\"attach.txt\"\r\n" + + " name=attach.txt\r\n" + "Content-Disposition: attachment;\r\n" + - " filename=\"attach.txt\";\r\n" + + " filename=attach.txt;\r\n" + " size=23\r\n" + "\r\n" + "text data in attachment" + @@ -228,19 +227,19 @@ public class MessageBuilderTest extends RobolectricTest { assertEquals(MESSAGE_HEADERS + MESSAGE_CONTENT_WITH_ATTACH, getMessageContents(message)); } - @Ignore("RFC2231/2184 not implemented") @Test - public void build_withAttachment_longContentType_shouldSucceed() throws Exception { + @Test + public void build_withAttachment_longFileName() throws Exception { MessageBuilder messageBuilder = createSimpleMessageBuilder(); Attachment attachment = createAttachmentWithContent( - "text/plain;title=1234567891123456789212345678931234567894123456789" + - "5123456789612345678971234567898123456789091234567890", - "attach.txt", TEST_ATTACHMENT_TEXT); + "text/plain", + "~~~~~~~~~1~~~~~~~~~2~~~~~~~~~3~~~~~~~~~4~~~~~~~~~5~~~~~~~~~6~~~~~~~~~7.txt", + TEST_ATTACHMENT_TEXT); messageBuilder.setAttachments(Collections.singletonList(attachment)); messageBuilder.buildAsync(callback); MimeMessage message = getMessageFromCallback(); - assertEquals(MESSAGE_HEADERS + MESSAGE_CONTENT_WITH_LONG_CONTENT_TYPE, + assertEquals(MESSAGE_HEADERS + MESSAGE_CONTENT_WITH_LONG_FILE_NAME, getMessageContents(message)); }