Merge pull request #3364 from k9mail/header_parameter_encoding
MIME parameter encoding (RFC 2231)
This commit is contained in:
commit
f3bac31b76
11 changed files with 543 additions and 84 deletions
|
@ -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
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
115
k9mail-library/src/main/java/com/fsck/k9/mail/helper/Utf8.kt
Normal file
115
k9mail-library/src/main/java/com/fsck/k9/mail/helper/Utf8.kt
Normal file
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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, String>): String {
|
||||
return if (parameters.isEmpty()) {
|
||||
value
|
||||
} else {
|
||||
buildString {
|
||||
append(value)
|
||||
encodeAndAppendParameters(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.encodeAndAppendParameters(parameters: Map<String, String>) {
|
||||
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*<any (US-ASCII) CHAR except SPACE, CTLs, or tspecials>
|
||||
// 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 := <any (US-ASCII) CHAR except SPACE, CTLs, "*", "'", "%", or tspecials>
|
||||
private fun Char.isAttributeChar() = isVChar() && this != '*' && this != '\'' && this != '%' && !isTSpecial()
|
||||
}
|
|
@ -320,5 +320,3 @@ class MessageTest {
|
|||
}
|
||||
|
||||
private fun Message.getFirstHeader(header: String): String = getHeader(header)[0]
|
||||
|
||||
private fun String.crlf() = replace("\n", "\r\n")
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
package com.fsck.k9.mail
|
||||
|
||||
fun String.crlf() = replace("\n", "\r\n")
|
|
@ -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"))
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue