Merge pull request #1835 from k9mail/flowed-display
Support display of format=flowed (rfc2646)
This commit is contained in:
commit
de60e0e8c4
7 changed files with 272 additions and 4 deletions
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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 :>";
|
||||
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 :> 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 :>";
|
||||
|
|
Loading…
Reference in a new issue