Merge pull request #1835 from k9mail/flowed-display

Support display of format=flowed (rfc2646)
This commit is contained in:
cketti 2017-01-01 02:47:40 +01:00 committed by GitHub
commit de60e0e8c4
7 changed files with 272 additions and 4 deletions

View file

@ -20,11 +20,13 @@ import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.Multipart;
import com.fsck.k9.mail.Part;
import com.fsck.k9.mail.internet.Viewable.Flowed;
import org.apache.commons.io.input.BoundedInputStream;
import static com.fsck.k9.mail.K9MailLib.LOG_TAG;
import static com.fsck.k9.mail.internet.CharsetSupport.fixupCharset;
import static com.fsck.k9.mail.internet.MimeUtility.getHeaderParameter;
import static com.fsck.k9.mail.internet.MimeUtility.isFormatFlowed;
import static com.fsck.k9.mail.internet.MimeUtility.isSameMimeType;
import static com.fsck.k9.mail.internet.Viewable.Alternative;
import static com.fsck.k9.mail.internet.Viewable.Html;
@ -188,13 +190,17 @@ public class MessageExtractor {
return;
}
String mimeType = part.getMimeType();
Viewable viewable;
if (isSameMimeType(mimeType, "text/plain")) {
Text text = new Text(part);
outputViewableParts.add(text);
if (isFormatFlowed(part.getContentType())) {
viewable = new Flowed(part);
} else {
viewable = new Text(part);
}
} else {
Html html = new Html(part);
outputViewableParts.add(html);
viewable = new Html(part);
}
outputViewableParts.add(viewable);
} else if (isSameMimeType(part.getMimeType(), "application/pgp-signature")) {
// ignore this type explicitly
} else {

View file

@ -25,6 +25,9 @@ import org.apache.james.mime4j.util.MimeUtil;
public class MimeUtility {
public static final String DEFAULT_ATTACHMENT_MIME_TYPE = "application/octet-stream";
public static final String K9_SETTINGS_MIME_TYPE = "application/x-k9settings";
private static final String TEXT_PLAIN = "text/plain";
private static final String HEADER_PARAM_FORMAT = "format";
private static final String HEADER_FORMAT_FLOWED = "flowed";
/*
* http://www.w3schools.com/media/media_mimeref.asp
@ -1137,4 +1140,13 @@ public class MimeUtility {
public static boolean isSameMimeType(String mimeType, String otherMimeType) {
return mimeType != null && mimeType.equalsIgnoreCase(otherMimeType);
}
static boolean isFormatFlowed(String contentType) {
String mimeType = getHeaderParameter(contentType, null);
if (isSameMimeType(TEXT_PLAIN, mimeType)) {
String formatParameter = getHeaderParameter(contentType, HEADER_PARAM_FORMAT);
return HEADER_FORMAT_FLOWED.equalsIgnoreCase(formatParameter);
}
return false;
}
}

View file

@ -41,6 +41,12 @@ public interface Viewable {
}
}
class Flowed extends Textual {
public Flowed(Part part) {
super(part);
}
}
/**
* Class representing a {@code text/html} part of a message.
*/

View file

@ -133,4 +133,19 @@ public class MimeUtilityTest {
public void isSameMimeType_withSecondArgumentBeingNull_shouldReturnFalse() throws Exception {
assertFalse(MimeUtility.isSameMimeType("text/html", null));
}
@Test
public void isFormatFlowed_withTextPlainFormatFlowed__shouldReturnTrue() throws Exception {
assertTrue(MimeUtility.isFormatFlowed("text/plain; format=flowed"));
}
@Test
public void isFormatFlowed_withTextPlain__shouldReturnFalse() throws Exception {
assertFalse(MimeUtility.isFormatFlowed("text/plain"));
}
@Test
public void isFormatFlowed_withTextHtmlFormatFlowed__shouldReturnFalse() throws Exception {
assertFalse(MimeUtility.isFormatFlowed("text/html; format=flowed"));
}
}

View file

@ -21,6 +21,8 @@ import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.Part;
import com.fsck.k9.mail.internet.MessageExtractor;
import com.fsck.k9.mail.internet.Viewable;
import com.fsck.k9.mail.internet.Viewable.Flowed;
import com.fsck.k9.mailstore.util.FlowedMessageUtils;
import com.fsck.k9.message.extractors.AttachmentInfoExtractor;
import com.fsck.k9.ui.crypto.MessageCryptoAnnotations;
import com.fsck.k9.ui.crypto.MessageCryptoSplitter;
@ -224,8 +226,13 @@ public class MessageViewInfoExtractor {
String t = MessageExtractor.getTextFromPart(part);
if (t == null) {
t = "";
} else if (viewable instanceof Flowed) {
t = FlowedMessageUtils.deflow(t, false);
t = HtmlConverter.textToHtml(t);
} else if (viewable instanceof Text) {
t = HtmlConverter.textToHtml(t);
} else if (!(viewable instanceof Html)) {
throw new IllegalStateException("unhandled case!");
}
html.append(t);
} else if (viewable instanceof Alternative) {
@ -257,6 +264,10 @@ public class MessageViewInfoExtractor {
t = "";
} else if (viewable instanceof Html) {
t = HtmlConverter.htmlToText(t);
} else if (viewable instanceof Flowed) {
t = FlowedMessageUtils.deflow(t, false);
} else if (!(viewable instanceof Text)) {
throw new IllegalStateException("unhandled case!");
}
text.append(t);
} else if (viewable instanceof Alternative) {

View file

@ -0,0 +1,189 @@
package com.fsck.k9.mailstore.util;
/**
* Adapted from the Apache James project, see
* https://james.apache.org/mailet/base/apidocs/org/apache/mailet/base/FlowedMessageUtils.html
*
* <p>Manages texts encoded as <code>text/plain; format=flowed</code>.</p>
* <p>As a reference see:</p>
* <ul>
* <li><a href='http://www.rfc-editor.org/rfc/rfc2646.txt'>RFC2646</a></li>
* <li><a href='http://www.rfc-editor.org/rfc/rfc3676.txt'>RFC3676</a> (new method with DelSP support).
* </ul>
* <h4>Note</h4>
* <ul>
* <li>In order to decode, the input text must belong to a mail with headers similar to:
* Content-Type: text/plain; charset="CHARSET"; [delsp="yes|no"; ]format="flowed"
* (the quotes around CHARSET are not mandatory).
* Furthermore the header Content-Transfer-Encoding MUST NOT BE Quoted-Printable
* (see RFC3676 paragraph 4.2).(In fact this happens often for non 7bit messages).
* </li>
* <li>When encoding the input text will be changed eliminating every space found before CRLF,
* otherwise it won't be possible to recognize hard breaks from soft breaks.
* In this scenario encoding and decoding a message will not return a message identical to
* the original (lines with hard breaks will be trimmed)
* </li>
* </ul>
*/
public final class FlowedMessageUtils {
private static final char RFC2646_SPACE = ' ';
private static final char RFC2646_QUOTE = '>';
private static final String RFC2646_SIGNATURE = "-- ";
private static final String RFC2646_CRLF = "\r\n";
private static final String RFC2646_FROM = "From ";
private static final int RFC2646_WIDTH = 78;
private FlowedMessageUtils() {
// this class cannot be instantiated
}
/**
* Decodes a text previously wrapped using "format=flowed".
*/
public static String deflow(String text, boolean delSp) {
String[] lines = text.split("\r\n|\n", -1);
StringBuffer result = null;
StringBuffer resultLine = new StringBuffer();
int resultLineQuoteDepth = 0;
boolean resultLineFlowed = false;
// One more cycle, to close the last line
for (int i = 0; i <= lines.length; i++) {
String line = i < lines.length ? lines[i] : null;
int actualQuoteDepth = 0;
if (line != null && line.length() > 0) {
if (line.equals(RFC2646_SIGNATURE))
// signature handling (the previous line is not flowed)
resultLineFlowed = false;
else if (line.charAt(0) == RFC2646_QUOTE) {
// Quote
actualQuoteDepth = 1;
while (actualQuoteDepth < line.length() && line.charAt(actualQuoteDepth) == RFC2646_QUOTE) actualQuoteDepth ++;
// if quote-depth changes wrt the previous line then this is not flowed
if (resultLineQuoteDepth != actualQuoteDepth) resultLineFlowed = false;
line = line.substring(actualQuoteDepth);
} else {
// id quote-depth changes wrt the first line then this is not flowed
if (resultLineQuoteDepth > 0) resultLineFlowed = false;
}
if (line.length() > 0 && line.charAt(0) == RFC2646_SPACE)
// Line space-stuffed
line = line.substring(1);
// if the previous was the last then it was not flowed
} else if (line == null) resultLineFlowed = false;
// Add the PREVIOUS line.
// This often will find the flow looking for a space as the last char of the line.
// With quote changes or signatures it could be the followinf line to void the flow.
if (!resultLineFlowed && i > 0) {
if (resultLineQuoteDepth > 0) resultLine.insert(0, RFC2646_SPACE);
for (int j = 0; j < resultLineQuoteDepth; j++) resultLine.insert(0, RFC2646_QUOTE);
if (result == null) result = new StringBuffer();
else result.append(RFC2646_CRLF);
result.append(resultLine.toString());
resultLine = new StringBuffer();
resultLineFlowed = false;
}
resultLineQuoteDepth = actualQuoteDepth;
if (line != null) {
if (!line.equals(RFC2646_SIGNATURE) && line.endsWith("" + RFC2646_SPACE) && i < lines.length - 1) {
// Line flowed (NOTE: for the split operation the line having i == lines.length is the last that does not end with RFC2646_CRLF)
if (delSp) line = line.substring(0, line.length() - 1);
resultLineFlowed = true;
}
else resultLineFlowed = false;
resultLine.append(line);
}
}
return result.toString();
}
/**
* Encodes a text (using standard with).
*/
public static String flow(String text, boolean delSp) {
return flow(text, delSp, RFC2646_WIDTH);
}
/**
* Decodes a text.
*/
public static String flow(String text, boolean delSp, int width) {
StringBuilder result = new StringBuilder();
String[] lines = text.split("\r\n|\n", -1);
for (int i = 0; i < lines.length; i ++) {
String line = lines[i];
boolean notempty = line.length() > 0;
int quoteDepth = 0;
while (quoteDepth < line.length() && line.charAt(quoteDepth) == RFC2646_QUOTE) quoteDepth ++;
if (quoteDepth > 0) {
if (quoteDepth + 1 < line.length() && line.charAt(quoteDepth) == RFC2646_SPACE) line = line.substring(quoteDepth + 1);
else line = line.substring(quoteDepth);
}
while (notempty) {
int extra = 0;
if (quoteDepth == 0) {
if (line.startsWith("" + RFC2646_SPACE) || line.startsWith("" + RFC2646_QUOTE) || line.startsWith(RFC2646_FROM)) {
line = "" + RFC2646_SPACE + line;
extra = 1;
}
} else {
line = RFC2646_SPACE + line;
for (int j = 0; j < quoteDepth; j++) line = "" + RFC2646_QUOTE + line;
extra = quoteDepth + 1;
}
int j = width - 1;
if (j >= line.length()) j = line.length() - 1;
else {
while (j >= extra && ((delSp && isAlphaChar(text, j)) || (!delSp && line.charAt(j) != RFC2646_SPACE))) j --;
if (j < extra) {
// Not able to cut a word: skip to word end even if greater than the max width
j = width - 1;
while (j < line.length() - 1 && ((delSp && isAlphaChar(text, j)) || (!delSp && line.charAt(j) != RFC2646_SPACE))) j ++;
}
}
result.append(line.substring(0, j + 1));
if (j < line.length() - 1) {
if (delSp) result.append(RFC2646_SPACE);
result.append(RFC2646_CRLF);
}
line = line.substring(j + 1);
notempty = line.length() > 0;
}
if (i < lines.length - 1) {
// NOTE: Have to trim the spaces before, otherwise it won't recognize soft-break from hard break.
// Deflow of flowed message will not be identical to the original.
while (result.length() > 0 && result.charAt(result.length() - 1) == RFC2646_SPACE) result.deleteCharAt(result.length() - 1);
result.append(RFC2646_CRLF);
}
}
return result.toString();
}
/**
* Checks whether the char is part of a word.
* <p>RFC assert a word cannot be splitted (even if the length is greater than the maximum length).
*/
public static boolean isAlphaChar(String text, int index) {
// Note: a list of chars is available here:
// http://www.zvon.org/tmRFC/RFC2646/Output/index.html
char c = text.charAt(index);
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
}
}

View file

@ -47,6 +47,7 @@ import static org.mockito.Mockito.when;
public class MessageViewInfoExtractorTest {
public static final String BODY_TEXT = "K-9 Mail rocks :>";
public static final String BODY_TEXT_HTML = "K-9 Mail rocks :&gt;";
public static final String BODY_TEXT_FLOWED = "K-9 Mail rocks :> \r\nflowed line\r\nnot flowed line";
private MessageViewInfoExtractor messageViewInfoExtractor;
@ -73,6 +74,7 @@ public class MessageViewInfoExtractorTest {
// Create message
MimeMessage message = new MimeMessage();
MimeMessageHelper.setBody(message, body);
message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain; format=flowed");
// Prepare fixture
HtmlSanitizer htmlSanitizer = mock(HtmlSanitizer.class);
@ -117,6 +119,33 @@ public class MessageViewInfoExtractorTest {
assertEquals(expectedHtml, getHtmlBodyText(container.html));
}
@Test
public void testTextPlainFormatFlowed() throws MessagingException {
// Create text/plain body
TextBody body = new TextBody(BODY_TEXT_FLOWED);
// Create message
MimeMessage message = new MimeMessage();
MimeMessageHelper.setBody(message, body);
message.setHeader(MimeHeader.HEADER_CONTENT_TYPE, "text/plain; format=flowed");
// Extract text
List<Part> outputNonViewableParts = new ArrayList<>();
ArrayList<Viewable> outputViewableParts = new ArrayList<>();
MessageExtractor.findViewablesAndAttachments(message, outputViewableParts, outputNonViewableParts);
ViewableExtractedText container = messageViewInfoExtractor.extractTextFromViewables(outputViewableParts);
String expectedText = "K-9 Mail rocks :> flowed line\r\n" +
"not flowed line";
String expectedHtml =
"<pre class=\"k9mail\">" +
"K-9 Mail rocks :&gt; flowed line<br />not flowed line" +
"</pre>";
assertEquals(expectedText, container.text);
assertEquals(expectedHtml, getHtmlBodyText(container.html));
}
@Test
public void testSimpleHtmlMessage() throws MessagingException {
String bodyText = "<strong>K-9 Mail</strong> rocks :&gt;";