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 timber.log.Timber; import com.fsck.k9.Account.QuoteStyle; import com.fsck.k9.Identity; import com.fsck.k9.K9; import com.fsck.k9.R; import com.fsck.k9.activity.MessageReference; import com.fsck.k9.activity.misc.Attachment; import com.fsck.k9.mail.Address; import com.fsck.k9.mail.Body; import com.fsck.k9.mail.BoundaryGenerator; import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Message.RecipientType; import com.fsck.k9.mail.MessagingException; import com.fsck.k9.mail.internet.MessageIdGenerator; import com.fsck.k9.mail.internet.MimeBodyPart; import com.fsck.k9.mail.internet.MimeHeader; import com.fsck.k9.mail.internet.MimeMessage; import com.fsck.k9.mail.internet.MimeMessageHelper; import com.fsck.k9.mail.internet.MimeMultipart; 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; public abstract class MessageBuilder { private final Context context; private final MessageIdGenerator messageIdGenerator; private final BoundaryGenerator boundaryGenerator; private String subject; private Date sentDate; private boolean hideTimeZone; private Address[] to; private Address[] cc; private Address[] bcc; private String inReplyTo; private String references; private boolean requestReadReceipt; private Identity identity; private SimpleMessageFormat messageFormat; private String text; private List<Attachment> attachments; private String signature; private QuoteStyle quoteStyle; private QuotedTextMode quotedTextMode; private String quotedText; private InsertableHtmlContent quotedHtmlContent; private boolean isReplyAfterQuote; private boolean isSignatureBeforeQuotedText; private boolean identityChanged; private boolean signatureChanged; private int cursorPosition; private MessageReference messageReference; private boolean isDraft; private boolean isPgpInlineEnabled; protected MessageBuilder(Context context, MessageIdGenerator messageIdGenerator, BoundaryGenerator boundaryGenerator) { this.context = context; this.messageIdGenerator = messageIdGenerator; this.boundaryGenerator = boundaryGenerator; } /** * Build the message to be sent (or saved). If there is another message quoted in this one, it will be baked * into the message here. */ protected MimeMessage build() throws MessagingException { //FIXME: check arguments MimeMessage message = new MimeMessage(); buildHeader(message); buildBody(message); return message; } private void buildHeader(MimeMessage message) throws MessagingException { message.addSentDate(sentDate, hideTimeZone); Address from = new Address(identity.getEmail(), identity.getName()); message.setFrom(from); message.setRecipients(RecipientType.TO, to); message.setRecipients(RecipientType.CC, cc); message.setRecipients(RecipientType.BCC, bcc); message.setSubject(subject); if (requestReadReceipt) { message.setHeader("Disposition-Notification-To", from.toEncodedString()); message.setHeader("X-Confirm-Reading-To", from.toEncodedString()); message.setHeader("Return-Receipt-To", from.toEncodedString()); } if (!K9.hideUserAgent()) { message.setHeader("User-Agent", context.getString(R.string.message_header_mua)); } final String replyTo = identity.getReplyTo(); if (replyTo != null) { message.setReplyTo(new Address[] { new Address(replyTo) }); } if (inReplyTo != null) { message.setInReplyTo(inReplyTo); } if (references != null) { message.setReferences(references); } String messageId = messageIdGenerator.generateMessageId(message); message.setMessageId(messageId); if (isDraft && isPgpInlineEnabled) { message.setFlag(Flag.X_DRAFT_OPENPGP_INLINE, true); } } protected MimeMultipart createMimeMultipart() { String boundary = boundaryGenerator.generateBoundary(); return new MimeMultipart(boundary); } private void buildBody(MimeMessage message) throws MessagingException { // Build the body. // TODO FIXME - body can be either an HTML or Text part, depending on whether we're in // HTML mode or not. Should probably fix this so we don't mix up html and text parts. TextBody body = buildText(isDraft); // text/plain part when messageFormat == MessageFormat.HTML TextBody bodyPlain = null; final boolean hasAttachments = !attachments.isEmpty(); if (messageFormat == SimpleMessageFormat.HTML) { // HTML message (with alternative text part) // This is the compiled MIME part for an HTML message. MimeMultipart composedMimeMessage = createMimeMultipart(); composedMimeMessage.setSubType("alternative"); // Let the receiver select either the text or the HTML part. bodyPlain = buildText(isDraft, SimpleMessageFormat.TEXT); composedMimeMessage.addBodyPart(new MimeBodyPart(bodyPlain, "text/plain")); composedMimeMessage.addBodyPart(new MimeBodyPart(body, "text/html")); if (hasAttachments) { // If we're HTML and have attachments, we have a MimeMultipart container to hold the // whole message (mp here), of which one part is a MimeMultipart container // (composedMimeMessage) with the user's composed messages, and subsequent parts for // the attachments. MimeMultipart mp = createMimeMultipart(); mp.addBodyPart(new MimeBodyPart(composedMimeMessage)); addAttachmentsToMessage(mp); MimeMessageHelper.setBody(message, mp); } else { // If no attachments, our multipart/alternative part is the only one we need. MimeMessageHelper.setBody(message, composedMimeMessage); } } else if (messageFormat == SimpleMessageFormat.TEXT) { // Text-only message. if (hasAttachments) { MimeMultipart mp = createMimeMultipart(); mp.addBodyPart(new MimeBodyPart(body, "text/plain")); addAttachmentsToMessage(mp); MimeMessageHelper.setBody(message, mp); } else { // No attachments to include, just stick the text body in the message and call it good. MimeMessageHelper.setBody(message, body); } } // If this is a draft, add metadata for thawing. if (isDraft) { // Add the identity to the message. message.addHeader(K9.IDENTITY_HEADER, buildIdentityHeader(body, bodyPlain)); } } private String buildIdentityHeader(TextBody body, TextBody bodyPlain) { return new IdentityHeaderBuilder() .setCursorPosition(cursorPosition) .setIdentity(identity) .setIdentityChanged(identityChanged) .setMessageFormat(messageFormat) .setMessageReference(messageReference) .setQuotedHtmlContent(quotedHtmlContent) .setQuoteStyle(quoteStyle) .setQuoteTextMode(quotedTextMode) .setSignature(signature) .setSignatureChanged(signatureChanged) .setBody(body) .setBodyPlain(bodyPlain) .build(); } /** * Add attachments as parts into a MimeMultipart container. * @param mp MimeMultipart container in which to insert parts. * @throws MessagingException */ private void addAttachmentsToMessage(final MimeMultipart mp) throws MessagingException { for (Attachment attachment : attachments) { if (attachment.state != Attachment.LoadingState.COMPLETE) { continue; } String contentType = attachment.contentType; if (MimeUtil.isMessage(contentType)) { contentType = "application/octet-stream"; // TODO reencode message body to 7 bit // body = new TempFileMessageBody(attachment.filename); } Body body = new TempFileBody(attachment.filename); MimeBodyPart bp = new MimeBodyPart(body); /* * 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(attachment.name, EncoderUtil.Usage.WORD_ENTITY, 7))); bp.setEncoding(MimeUtility.getEncodingforType(contentType)); /* * TODO: Oh the joys of MIME... * * 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]." * * 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!" */ bp.addHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, String.format(Locale.US, "attachment;\r\n filename=\"%s\";\r\n size=%d", attachment.name, attachment.size)); mp.addBodyPart(bp); } } /** * Build the Body that will contain the text of the message. We'll decide where to * include it later. Draft messages are treated somewhat differently in that signatures are not * appended and HTML separators between composed text and quoted text are not added. * @param isDraft If we should build a message that will be saved as a draft (as opposed to sent). */ private TextBody buildText(boolean isDraft) { return buildText(isDraft, messageFormat); } /** * Build the {@link Body} that will contain the text of the message. * * <p> * Draft messages are treated somewhat differently in that signatures are not appended and HTML * separators between composed text and quoted text are not added. * </p> * * @param isDraft * If {@code true} we build a message that will be saved as a draft (as opposed to * sent). * @param simpleMessageFormat * Specifies what type of message to build ({@code text/plain} vs. {@code text/html}). * * @return {@link TextBody} instance that contains the entered text and possibly the quoted * original message. */ private TextBody buildText(boolean isDraft, SimpleMessageFormat simpleMessageFormat) { String messageText = text; TextBodyBuilder textBodyBuilder = new TextBodyBuilder(messageText); /* * Find out if we need to include the original message as quoted text. * * We include the quoted text in the body if the user didn't choose to * hide it. We always include the quoted text when we're saving a draft. * That's so the user is able to "un-hide" the quoted text if (s)he * opens a saved draft. */ boolean includeQuotedText = (isDraft || quotedTextMode == QuotedTextMode.SHOW); boolean isReplyAfterQuote = (quoteStyle == QuoteStyle.PREFIX && this.isReplyAfterQuote); textBodyBuilder.setIncludeQuotedText(false); if (includeQuotedText) { if (simpleMessageFormat == SimpleMessageFormat.HTML && quotedHtmlContent != null) { textBodyBuilder.setIncludeQuotedText(true); textBodyBuilder.setQuotedTextHtml(quotedHtmlContent); textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote); } if (simpleMessageFormat == SimpleMessageFormat.TEXT && quotedText.length() > 0) { textBodyBuilder.setIncludeQuotedText(true); textBodyBuilder.setQuotedText(quotedText); textBodyBuilder.setReplyAfterQuote(isReplyAfterQuote); } } textBodyBuilder.setInsertSeparator(!isDraft); boolean useSignature = (!isDraft && identity.getSignatureUse()); if (useSignature) { textBodyBuilder.setAppendSignature(true); textBodyBuilder.setSignature(signature); textBodyBuilder.setSignatureBeforeQuotedText(isSignatureBeforeQuotedText); } else { textBodyBuilder.setAppendSignature(false); } TextBody body; if (simpleMessageFormat == SimpleMessageFormat.HTML) { body = textBodyBuilder.buildTextHtml(); } else { body = textBodyBuilder.buildTextPlain(); } return body; } public MessageBuilder setSubject(String subject) { this.subject = subject; return this; } public MessageBuilder setSentDate(Date sentDate) { this.sentDate = sentDate; return this; } public MessageBuilder setHideTimeZone(boolean hideTimeZone) { this.hideTimeZone = hideTimeZone; return this; } public MessageBuilder setTo(List<Address> to) { this.to = to.toArray(new Address[to.size()]); return this; } public MessageBuilder setCc(List<Address> cc) { this.cc = cc.toArray(new Address[cc.size()]); return this; } public MessageBuilder setBcc(List<Address> bcc) { this.bcc = bcc.toArray(new Address[bcc.size()]); return this; } public MessageBuilder setInReplyTo(String inReplyTo) { this.inReplyTo = inReplyTo; return this; } public MessageBuilder setReferences(String references) { this.references = references; return this; } public MessageBuilder setRequestReadReceipt(boolean requestReadReceipt) { this.requestReadReceipt = requestReadReceipt; return this; } public MessageBuilder setIdentity(Identity identity) { this.identity = identity; return this; } public MessageBuilder setMessageFormat(SimpleMessageFormat messageFormat) { this.messageFormat = messageFormat; return this; } public MessageBuilder setText(String text) { this.text = text; return this; } public MessageBuilder setAttachments(List<Attachment> attachments) { this.attachments = attachments; return this; } public MessageBuilder setSignature(String signature) { this.signature = signature; return this; } public MessageBuilder setQuoteStyle(QuoteStyle quoteStyle) { this.quoteStyle = quoteStyle; return this; } public MessageBuilder setQuotedTextMode(QuotedTextMode quotedTextMode) { this.quotedTextMode = quotedTextMode; return this; } public MessageBuilder setQuotedText(String quotedText) { this.quotedText = quotedText; return this; } public MessageBuilder setQuotedHtmlContent(InsertableHtmlContent quotedHtmlContent) { this.quotedHtmlContent = quotedHtmlContent; return this; } public MessageBuilder setReplyAfterQuote(boolean isReplyAfterQuote) { this.isReplyAfterQuote = isReplyAfterQuote; return this; } public MessageBuilder setSignatureBeforeQuotedText(boolean isSignatureBeforeQuotedText) { this.isSignatureBeforeQuotedText = isSignatureBeforeQuotedText; return this; } public MessageBuilder setIdentityChanged(boolean identityChanged) { this.identityChanged = identityChanged; return this; } public MessageBuilder setSignatureChanged(boolean signatureChanged) { this.signatureChanged = signatureChanged; return this; } public MessageBuilder setCursorPosition(int cursorPosition) { this.cursorPosition = cursorPosition; return this; } public MessageBuilder setMessageReference(MessageReference messageReference) { this.messageReference = messageReference; return this; } public MessageBuilder setDraft(boolean isDraft) { this.isDraft = isDraft; return this; } public MessageBuilder setIsPgpInlineEnabled(boolean isPgpInlineEnabled) { this.isPgpInlineEnabled = isPgpInlineEnabled; return this; } public boolean isDraft() { return isDraft; } private Callback asyncCallback; private final Object callbackLock = new Object(); // Postponed results, to be delivered upon reattachment of callback. There should only ever be one of these! private MimeMessage queuedMimeMessage; private MessagingException queuedException; private PendingIntent queuedPendingIntent; private int queuedRequestCode; /** This method builds the message asynchronously, calling *exactly one* of the methods * on the callback on the UI thread after it finishes. The callback may thread-safely * be detached and reattached intermittently. */ final public void buildAsync(Callback callback) { synchronized (callbackLock) { asyncCallback = callback; queuedMimeMessage = null; queuedException = null; queuedPendingIntent = null; } new AsyncTask<Void,Void,Void>() { @Override protected Void doInBackground(Void... params) { buildMessageInternal(); return null; } @Override protected void onPostExecute(Void aVoid) { deliverResult(); } }.execute(); } final public void onActivityResult(final int requestCode, int resultCode, final Intent data, Callback callback) { synchronized (callbackLock) { asyncCallback = callback; queuedMimeMessage = null; queuedException = null; queuedPendingIntent = null; } if (resultCode != Activity.RESULT_OK) { asyncCallback.onMessageBuildCancel(); return; } new AsyncTask<Void,Void,Void>() { @Override protected Void doInBackground(Void... params) { buildMessageOnActivityResult(requestCode, data); return null; } @Override protected void onPostExecute(Void aVoid) { deliverResult(); } }.execute(); } /** This method is called in a worker thread, and should build the actual message. To deliver * its computation result, it must call *exactly one* of the queueMessageBuild* methods before * it finishes. */ abstract protected void buildMessageInternal(); abstract protected void buildMessageOnActivityResult(int requestCode, Intent data); /** This method may be used to temporarily detach the callback. If a result is delivered * while the callback is detached, it will be delivered upon reattachment. */ final public void detachCallback() { synchronized (callbackLock) { asyncCallback = null; } } /** This method attaches a new callback, and must only be called after a previous one was * detached. If the computation finished while the callback was detached, it will be * delivered immediately upon reattachment. */ final public void reattachCallback(Callback callback) { synchronized (callbackLock) { if (asyncCallback != null) { throw new IllegalStateException("need to detach callback before new one can be attached!"); } asyncCallback = callback; deliverResult(); } } final protected void queueMessageBuildSuccess(MimeMessage message) { synchronized (callbackLock) { queuedMimeMessage = message; } } final protected void queueMessageBuildException(MessagingException exception) { synchronized (callbackLock) { queuedException = exception; } } final protected void queueMessageBuildPendingIntent(PendingIntent pendingIntent, int requestCode) { synchronized (callbackLock) { queuedPendingIntent = pendingIntent; queuedRequestCode = requestCode; } } final protected void deliverResult() { synchronized (callbackLock) { if (asyncCallback == null) { Timber.d("Keeping message builder result in queue for later delivery"); return; } if (queuedMimeMessage != null) { asyncCallback.onMessageBuildSuccess(queuedMimeMessage, isDraft); queuedMimeMessage = null; } else if (queuedException != null) { asyncCallback.onMessageBuildException(queuedException); queuedException = null; } else if (queuedPendingIntent != null) { asyncCallback.onMessageBuildReturnPendingIntent(queuedPendingIntent, queuedRequestCode); queuedPendingIntent = null; } asyncCallback = null; } } public interface Callback { void onMessageBuildSuccess(MimeMessage message, boolean isDraft); void onMessageBuildCancel(); void onMessageBuildException(MessagingException exception); void onMessageBuildReturnPendingIntent(PendingIntent pendingIntent, int requestCode); } }