package com.fsck.k9.mailstore; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; import android.content.Context; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.support.annotation.WorkerThread; import timber.log.Timber; import com.fsck.k9.Globals; import com.fsck.k9.K9; import com.fsck.k9.R; import com.fsck.k9.mail.internet.MimeUtility; import com.fsck.k9.message.html.HtmlConverter; import com.fsck.k9.message.html.HtmlSanitizer; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Message; 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; import com.fsck.k9.ui.crypto.MessageCryptoSplitter.CryptoMessageParts; import static com.fsck.k9.mail.internet.MimeUtility.getHeaderParameter; import static com.fsck.k9.mail.internet.Viewable.Alternative; import static com.fsck.k9.mail.internet.Viewable.Html; import static com.fsck.k9.mail.internet.Viewable.MessageHeader; import static com.fsck.k9.mail.internet.Viewable.Text; import static com.fsck.k9.mail.internet.Viewable.Textual; public class MessageViewInfoExtractor { private static final String TEXT_DIVIDER = "------------------------------------------------------------------------"; private static final int TEXT_DIVIDER_LENGTH = TEXT_DIVIDER.length(); private static final String FILENAME_PREFIX = "----- "; private static final int FILENAME_PREFIX_LENGTH = FILENAME_PREFIX.length(); private static final String FILENAME_SUFFIX = " "; private static final int FILENAME_SUFFIX_LENGTH = FILENAME_SUFFIX.length(); private final Context context; private final AttachmentInfoExtractor attachmentInfoExtractor; private final HtmlSanitizer htmlSanitizer; public static MessageViewInfoExtractor getInstance() { Context context = Globals.getContext(); AttachmentInfoExtractor attachmentInfoExtractor = AttachmentInfoExtractor.getInstance(); HtmlSanitizer htmlSanitizer = HtmlSanitizer.getInstance(); return new MessageViewInfoExtractor(context, attachmentInfoExtractor, htmlSanitizer); } @VisibleForTesting MessageViewInfoExtractor(Context context, AttachmentInfoExtractor attachmentInfoExtractor, HtmlSanitizer htmlSanitizer) { this.context = context; this.attachmentInfoExtractor = attachmentInfoExtractor; this.htmlSanitizer = htmlSanitizer; } @WorkerThread public MessageViewInfo extractMessageForView(Message message, @Nullable MessageCryptoAnnotations annotations) throws MessagingException { Part rootPart; CryptoResultAnnotation cryptoResultAnnotation; List<Part> extraParts; CryptoMessageParts cryptoMessageParts = MessageCryptoSplitter.split(message, annotations); if (cryptoMessageParts != null) { rootPart = cryptoMessageParts.contentPart; cryptoResultAnnotation = cryptoMessageParts.contentCryptoAnnotation; extraParts = cryptoMessageParts.extraParts; } else { if (annotations != null && !annotations.isEmpty()) { Timber.e("Got message annotations but no crypto root part!"); } rootPart = message; cryptoResultAnnotation = null; extraParts = null; } List<AttachmentViewInfo> attachmentInfos = new ArrayList<>(); ViewableExtractedText viewable = extractViewableAndAttachments( Collections.singletonList(rootPart), attachmentInfos); List<AttachmentViewInfo> extraAttachmentInfos = new ArrayList<>(); String extraViewableText = null; if (extraParts != null) { ViewableExtractedText extraViewable = extractViewableAndAttachments(extraParts, extraAttachmentInfos); extraViewableText = extraViewable.text; } AttachmentResolver attachmentResolver = AttachmentResolver.createFromPart(rootPart); boolean isMessageIncomplete = !message.isSet(Flag.X_DOWNLOADED_FULL) || MessageExtractor.hasMissingParts(message); return MessageViewInfo.createWithExtractedContent(message, isMessageIncomplete, rootPart, viewable.html, attachmentInfos, cryptoResultAnnotation, attachmentResolver, extraViewableText, extraAttachmentInfos); } private ViewableExtractedText extractViewableAndAttachments(List<Part> parts, List<AttachmentViewInfo> attachmentInfos) throws MessagingException { ArrayList<Viewable> viewableParts = new ArrayList<>(); ArrayList<Part> attachments = new ArrayList<>(); for (Part part : parts) { MessageExtractor.findViewablesAndAttachments(part, viewableParts, attachments); } attachmentInfos.addAll(attachmentInfoExtractor.extractAttachmentInfoForView(attachments)); return extractTextFromViewables(viewableParts); } /** * Extract the viewable textual parts of a message and return the rest as attachments. * * @return A {@link ViewableExtractedText} instance containing the textual parts of the message as * plain text and HTML, and a list of message parts considered attachments. * * @throws com.fsck.k9.mail.MessagingException * In case of an error. */ @VisibleForTesting ViewableExtractedText extractTextFromViewables(List<Viewable> viewables) throws MessagingException { try { // Collect all viewable parts /* * Convert the tree of viewable parts into text and HTML */ // Used to suppress the divider for the first viewable part boolean hideDivider = true; StringBuilder text = new StringBuilder(); StringBuilder html = new StringBuilder(); for (Viewable viewable : viewables) { if (viewable instanceof Textual) { // This is either a text/plain or text/html part. Fill the variables 'text' and // 'html', converting between plain text and HTML as necessary. text.append(buildText(viewable, !hideDivider)); html.append(buildHtml(viewable, !hideDivider)); hideDivider = false; } else if (viewable instanceof MessageHeader) { MessageHeader header = (MessageHeader) viewable; Part containerPart = header.getContainerPart(); Message innerMessage = header.getMessage(); addTextDivider(text, containerPart, !hideDivider); addMessageHeaderText(text, innerMessage); addHtmlDivider(html, containerPart, !hideDivider); addMessageHeaderHtml(html, innerMessage); hideDivider = true; } else if (viewable instanceof Alternative) { // Handle multipart/alternative contents Alternative alternative = (Alternative) viewable; /* * We made sure at least one of text/plain or text/html is present when * creating the Alternative object. If one part is not present we convert the * other one to make sure 'text' and 'html' always contain the same text. */ List<Viewable> textAlternative = alternative.getText().isEmpty() ? alternative.getHtml() : alternative.getText(); List<Viewable> htmlAlternative = alternative.getHtml().isEmpty() ? alternative.getText() : alternative.getHtml(); // Fill the 'text' variable boolean divider = !hideDivider; for (Viewable textViewable : textAlternative) { text.append(buildText(textViewable, divider)); divider = true; } // Fill the 'html' variable divider = !hideDivider; for (Viewable htmlViewable : htmlAlternative) { html.append(buildHtml(htmlViewable, divider)); divider = true; } hideDivider = false; } } String content = HtmlConverter.wrapMessageContent(html); String sanitizedHtml = htmlSanitizer.sanitize(content); return new ViewableExtractedText(text.toString(), sanitizedHtml); } catch (Exception e) { throw new MessagingException("Couldn't extract viewable parts", e); } } /** * Use the contents of a {@link com.fsck.k9.mail.internet.Viewable} to create the HTML to be displayed. * * <p> * This will use {@link HtmlConverter#textToHtml(String)} to convert plain text parts * to HTML if necessary. * </p> * * @param viewable * The viewable part to build the HTML from. * @param prependDivider * {@code true}, if the HTML divider should be inserted as first element. * {@code false}, otherwise. * * @return The contents of the supplied viewable instance as HTML. */ private StringBuilder buildHtml(Viewable viewable, boolean prependDivider) { StringBuilder html = new StringBuilder(); if (viewable instanceof Textual) { Part part = ((Textual)viewable).getPart(); addHtmlDivider(html, part, prependDivider); String t = MessageExtractor.getTextFromPart(part); if (t == null) { t = ""; } else if (viewable instanceof Flowed) { boolean delSp = ((Flowed) viewable).isDelSp(); t = FlowedMessageUtils.deflow(t, delSp); 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) { // That's odd - an Alternative as child of an Alternative; go ahead and try to use the // text/html child; fall-back to the text/plain part. Alternative alternative = (Alternative) viewable; List<Viewable> htmlAlternative = alternative.getHtml().isEmpty() ? alternative.getText() : alternative.getHtml(); boolean divider = prependDivider; for (Viewable htmlViewable : htmlAlternative) { html.append(buildHtml(htmlViewable, divider)); divider = true; } } return html; } private StringBuilder buildText(Viewable viewable, boolean prependDivider) { StringBuilder text = new StringBuilder(); if (viewable instanceof Textual) { Part part = ((Textual)viewable).getPart(); addTextDivider(text, part, prependDivider); String t = MessageExtractor.getTextFromPart(part); if (t == null) { t = ""; } else if (viewable instanceof Html) { t = HtmlConverter.htmlToText(t); } else if (viewable instanceof Flowed) { boolean delSp = ((Flowed) viewable).isDelSp(); t = FlowedMessageUtils.deflow(t, delSp); } else if (!(viewable instanceof Text)) { throw new IllegalStateException("unhandled case!"); } text.append(t); } else if (viewable instanceof Alternative) { // That's odd - an Alternative as child of an Alternative; go ahead and try to use the // text/plain child; fall-back to the text/html part. Alternative alternative = (Alternative) viewable; List<Viewable> textAlternative = alternative.getText().isEmpty() ? alternative.getHtml() : alternative.getText(); boolean divider = prependDivider; for (Viewable textViewable : textAlternative) { text.append(buildText(textViewable, divider)); divider = true; } } return text; } /** * Add an HTML divider between two HTML message parts. * * @param html * The {@link StringBuilder} to append the divider to. * @param part * The message part that will follow after the divider. This is used to extract the * part's name. * @param prependDivider * {@code true}, if the divider should be appended. {@code false}, otherwise. */ private void addHtmlDivider(StringBuilder html, Part part, boolean prependDivider) { if (prependDivider) { String filename = getPartName(part); html.append("<p style=\"margin-top: 2.5em; margin-bottom: 1em; border-bottom: 1px solid #000\">"); html.append(filename); html.append("</p>"); } } /** * Get the name of the message part. * * @param part * The part to get the name for. * * @return The (file)name of the part if available. An empty string, otherwise. */ private static String getPartName(Part part) { String disposition = part.getDisposition(); if (disposition != null) { String name = getHeaderParameter(disposition, "filename"); return (name == null) ? "" : name; } return ""; } /** * Add a plain text divider between two plain text message parts. * * @param text * The {@link StringBuilder} to append the divider to. * @param part * The message part that will follow after the divider. This is used to extract the * part's name. * @param prependDivider * {@code true}, if the divider should be appended. {@code false}, otherwise. */ private void addTextDivider(StringBuilder text, Part part, boolean prependDivider) { if (prependDivider) { String filename = getPartName(part); text.append("\r\n\r\n"); int len = filename.length(); if (len > 0) { if (len > TEXT_DIVIDER_LENGTH - FILENAME_PREFIX_LENGTH - FILENAME_SUFFIX_LENGTH) { filename = filename.substring(0, TEXT_DIVIDER_LENGTH - FILENAME_PREFIX_LENGTH - FILENAME_SUFFIX_LENGTH - 3) + "..."; } text.append(FILENAME_PREFIX); text.append(filename); text.append(FILENAME_SUFFIX); text.append(TEXT_DIVIDER.substring(0, TEXT_DIVIDER_LENGTH - FILENAME_PREFIX_LENGTH - filename.length() - FILENAME_SUFFIX_LENGTH)); } else { text.append(TEXT_DIVIDER); } text.append("\r\n\r\n"); } } /** * Extract important header values from a message to display inline (plain text version). * * @param text * The {@link StringBuilder} that will receive the (plain text) output. * @param message * The message to extract the header values from. * * @throws com.fsck.k9.mail.MessagingException * In case of an error. */ private void addMessageHeaderText(StringBuilder text, Message message) throws MessagingException { // From: <sender> Address[] from = message.getFrom(); if (from != null && from.length > 0) { text.append(context.getString(R.string.message_compose_quote_header_from)); text.append(' '); text.append(Address.toString(from)); text.append("\r\n"); } // To: <recipients> Address[] to = message.getRecipients(Message.RecipientType.TO); if (to != null && to.length > 0) { text.append(context.getString(R.string.message_compose_quote_header_to)); text.append(' '); text.append(Address.toString(to)); text.append("\r\n"); } // Cc: <recipients> Address[] cc = message.getRecipients(Message.RecipientType.CC); if (cc != null && cc.length > 0) { text.append(context.getString(R.string.message_compose_quote_header_cc)); text.append(' '); text.append(Address.toString(cc)); text.append("\r\n"); } // Date: <date> Date date = message.getSentDate(); if (date != null) { text.append(context.getString(R.string.message_compose_quote_header_send_date)); text.append(' '); text.append(date.toString()); text.append("\r\n"); } // Subject: <subject> String subject = message.getSubject(); text.append(context.getString(R.string.message_compose_quote_header_subject)); text.append(' '); if (subject == null) { text.append(context.getString(R.string.general_no_subject)); } else { text.append(subject); } text.append("\r\n\r\n"); } /** * Extract important header values from a message to display inline (HTML version). * * @param html * The {@link StringBuilder} that will receive the (HTML) output. * @param message * The message to extract the header values from. * * @throws com.fsck.k9.mail.MessagingException * In case of an error. */ private void addMessageHeaderHtml(StringBuilder html, Message message) throws MessagingException { html.append("<table style=\"border: 0\">"); // From: <sender> Address[] from = message.getFrom(); if (from != null && from.length > 0) { addTableRow(html, context.getString(R.string.message_compose_quote_header_from), Address.toString(from)); } // To: <recipients> Address[] to = message.getRecipients(Message.RecipientType.TO); if (to != null && to.length > 0) { addTableRow(html, context.getString(R.string.message_compose_quote_header_to), Address.toString(to)); } // Cc: <recipients> Address[] cc = message.getRecipients(Message.RecipientType.CC); if (cc != null && cc.length > 0) { addTableRow(html, context.getString(R.string.message_compose_quote_header_cc), Address.toString(cc)); } // Date: <date> Date date = message.getSentDate(); if (date != null) { addTableRow(html, context.getString(R.string.message_compose_quote_header_send_date), date.toString()); } // Subject: <subject> String subject = message.getSubject(); addTableRow(html, context.getString(R.string.message_compose_quote_header_subject), (subject == null) ? context.getString(R.string.general_no_subject) : subject); html.append("</table>"); } /** * Output an HTML table two column row with some hardcoded style. * * @param html * The {@link StringBuilder} that will receive the output. * @param header * The string to be put in the {@code TH} element. * @param value * The string to be put in the {@code TD} element. */ private static void addTableRow(StringBuilder html, String header, String value) { html.append("<tr><th style=\"text-align: left; vertical-align: top;\">"); html.append(header); html.append("</th>"); html.append("<td>"); html.append(value); html.append("</td></tr>"); } @VisibleForTesting static class ViewableExtractedText { public final String text; public final String html; public ViewableExtractedText(String text, String html) { this.text = text; this.html = html; } } }