diff options
Diffstat (limited to 'src/com/android/emailcommon/internet')
-rw-r--r-- | src/com/android/emailcommon/internet/BinaryTempFileBody.java | 89 | ||||
-rw-r--r-- | src/com/android/emailcommon/internet/MimeBodyPart.java | 193 | ||||
-rw-r--r-- | src/com/android/emailcommon/internet/MimeHeader.java | 153 | ||||
-rw-r--r-- | src/com/android/emailcommon/internet/MimeMessage.java | 626 | ||||
-rw-r--r-- | src/com/android/emailcommon/internet/MimeMultipart.java | 111 | ||||
-rw-r--r-- | src/com/android/emailcommon/internet/MimeUtility.java | 452 | ||||
-rw-r--r-- | src/com/android/emailcommon/internet/Rfc822Output.java | 357 | ||||
-rw-r--r-- | src/com/android/emailcommon/internet/TextBody.java | 62 |
8 files changed, 2043 insertions, 0 deletions
diff --git a/src/com/android/emailcommon/internet/BinaryTempFileBody.java b/src/com/android/emailcommon/internet/BinaryTempFileBody.java new file mode 100644 index 000000000..f0821edd4 --- /dev/null +++ b/src/com/android/emailcommon/internet/BinaryTempFileBody.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * 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.android.emailcommon.internet; + +import com.android.emailcommon.TempDirectory; +import com.android.emailcommon.mail.Body; +import com.android.emailcommon.mail.MessagingException; + +import org.apache.commons.io.IOUtils; + +import android.util.Base64; +import android.util.Base64OutputStream; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * A Body that is backed by a temp file. The Body exposes a getOutputStream method that allows + * the user to write to the temp file. After the write the body is available via getInputStream + * and writeTo one time. After writeTo is called, or the InputStream returned from + * getInputStream is closed the file is deleted and the Body should be considered disposed of. + */ +public class BinaryTempFileBody implements Body { + private File mFile; + + /** + * An alternate way to put data into a BinaryTempFileBody is to simply supply an already- + * created file. Note that this file will be deleted after it is read. + * @param filePath The file containing the data to be stored on disk temporarily + */ + public void setFile(String filePath) { + mFile = new File(filePath); + } + + public OutputStream getOutputStream() throws IOException { + mFile = File.createTempFile("body", null, TempDirectory.getTempDirectory()); + mFile.deleteOnExit(); + return new FileOutputStream(mFile); + } + + public InputStream getInputStream() throws MessagingException { + try { + return new BinaryTempFileBodyInputStream(new FileInputStream(mFile)); + } + catch (IOException ioe) { + throw new MessagingException("Unable to open body", ioe); + } + } + + public void writeTo(OutputStream out) throws IOException, MessagingException { + InputStream in = getInputStream(); + Base64OutputStream base64Out = new Base64OutputStream( + out, Base64.CRLF | Base64.NO_CLOSE); + IOUtils.copy(in, base64Out); + base64Out.close(); + mFile.delete(); + } + + class BinaryTempFileBodyInputStream extends FilterInputStream { + public BinaryTempFileBodyInputStream(InputStream in) { + super(in); + } + + @Override + public void close() throws IOException { + super.close(); + mFile.delete(); + } + } +} diff --git a/src/com/android/emailcommon/internet/MimeBodyPart.java b/src/com/android/emailcommon/internet/MimeBodyPart.java new file mode 100644 index 000000000..01efd554f --- /dev/null +++ b/src/com/android/emailcommon/internet/MimeBodyPart.java @@ -0,0 +1,193 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * 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.android.emailcommon.internet; + +import com.android.emailcommon.mail.Body; +import com.android.emailcommon.mail.BodyPart; +import com.android.emailcommon.mail.MessagingException; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.regex.Pattern; + +/** + * TODO this is a close approximation of Message, need to update along with + * Message. + */ +public class MimeBodyPart extends BodyPart { + protected MimeHeader mHeader = new MimeHeader(); + protected MimeHeader mExtendedHeader; + protected Body mBody; + protected int mSize; + + // regex that matches content id surrounded by "<>" optionally. + private static final Pattern REMOVE_OPTIONAL_BRACKETS = Pattern.compile("^<?([^>]+)>?$"); + // regex that matches end of line. + private static final Pattern END_OF_LINE = Pattern.compile("\r?\n"); + + public MimeBodyPart() throws MessagingException { + this(null); + } + + public MimeBodyPart(Body body) throws MessagingException { + this(body, null); + } + + public MimeBodyPart(Body body, String mimeType) throws MessagingException { + if (mimeType != null) { + setHeader(MimeHeader.HEADER_CONTENT_TYPE, mimeType); + } + setBody(body); + } + + protected String getFirstHeader(String name) throws MessagingException { + return mHeader.getFirstHeader(name); + } + + public void addHeader(String name, String value) throws MessagingException { + mHeader.addHeader(name, value); + } + + public void setHeader(String name, String value) throws MessagingException { + mHeader.setHeader(name, value); + } + + public String[] getHeader(String name) throws MessagingException { + return mHeader.getHeader(name); + } + + public void removeHeader(String name) throws MessagingException { + mHeader.removeHeader(name); + } + + public Body getBody() throws MessagingException { + return mBody; + } + + public void setBody(Body body) throws MessagingException { + this.mBody = body; + if (body instanceof com.android.emailcommon.mail.Multipart) { + com.android.emailcommon.mail.Multipart multipart = + ((com.android.emailcommon.mail.Multipart)body); + multipart.setParent(this); + setHeader(MimeHeader.HEADER_CONTENT_TYPE, multipart.getContentType()); + } + else if (body instanceof TextBody) { + String contentType = String.format("%s;\n charset=utf-8", getMimeType()); + String name = MimeUtility.getHeaderParameter(getContentType(), "name"); + if (name != null) { + contentType += String.format(";\n name=\"%s\"", name); + } + setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType); + setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); + } + } + + public String getContentType() throws MessagingException { + String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); + if (contentType == null) { + return "text/plain"; + } else { + return contentType; + } + } + + public String getDisposition() throws MessagingException { + String contentDisposition = getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION); + if (contentDisposition == null) { + return null; + } else { + return contentDisposition; + } + } + + public String getContentId() throws MessagingException { + String contentId = getFirstHeader(MimeHeader.HEADER_CONTENT_ID); + if (contentId == null) { + return null; + } else { + // remove optionally surrounding brackets. + return REMOVE_OPTIONAL_BRACKETS.matcher(contentId).replaceAll("$1"); + } + } + + public String getMimeType() throws MessagingException { + return MimeUtility.getHeaderParameter(getContentType(), null); + } + + public boolean isMimeType(String mimeType) throws MessagingException { + return getMimeType().equals(mimeType); + } + + public void setSize(int size) { + this.mSize = size; + } + + public int getSize() throws MessagingException { + return mSize; + } + + /** + * Set extended header + * + * @param name Extended header name + * @param value header value - flattened by removing CR-NL if any + * remove header if value is null + * @throws MessagingException + */ + public void setExtendedHeader(String name, String value) throws MessagingException { + if (value == null) { + if (mExtendedHeader != null) { + mExtendedHeader.removeHeader(name); + } + return; + } + if (mExtendedHeader == null) { + mExtendedHeader = new MimeHeader(); + } + mExtendedHeader.setHeader(name, END_OF_LINE.matcher(value).replaceAll("")); + } + + /** + * Get extended header + * + * @param name Extended header name + * @return header value - null if header does not exist + * @throws MessagingException + */ + public String getExtendedHeader(String name) throws MessagingException { + if (mExtendedHeader == null) { + return null; + } + return mExtendedHeader.getFirstHeader(name); + } + + /** + * Write the MimeMessage out in MIME format. + */ + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + mHeader.writeTo(out); + writer.write("\r\n"); + writer.flush(); + if (mBody != null) { + mBody.writeTo(out); + } + } +} diff --git a/src/com/android/emailcommon/internet/MimeHeader.java b/src/com/android/emailcommon/internet/MimeHeader.java new file mode 100644 index 000000000..b0ad7772e --- /dev/null +++ b/src/com/android/emailcommon/internet/MimeHeader.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * 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.android.emailcommon.internet; + +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.utility.Utility; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.ArrayList; + +public class MimeHeader { + /** + * Application specific header that contains Store specific information about an attachment. + * In IMAP this contains the IMAP BODYSTRUCTURE part id so that the ImapStore can later + * retrieve the attachment at will from the server. + * The info is recorded from this header on LocalStore.appendMessages and is put back + * into the MIME data by LocalStore.fetch. + */ + public static final String HEADER_ANDROID_ATTACHMENT_STORE_DATA = "X-Android-Attachment-StoreData"; + /** + * Application specific header that is used to tag body parts for quoted/forwarded messages. + */ + public static final String HEADER_ANDROID_BODY_QUOTED_PART = "X-Android-Body-Quoted-Part"; + + public static final String HEADER_CONTENT_TYPE = "Content-Type"; + public static final String HEADER_CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding"; + public static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition"; + public static final String HEADER_CONTENT_ID = "Content-ID"; + + /** + * Fields that should be omitted when writing the header using writeTo() + */ + private static final String[] WRITE_OMIT_FIELDS = { +// HEADER_ANDROID_ATTACHMENT_DOWNLOADED, +// HEADER_ANDROID_ATTACHMENT_ID, + HEADER_ANDROID_ATTACHMENT_STORE_DATA + }; + + protected final ArrayList<Field> mFields = new ArrayList<Field>(); + + public void clear() { + mFields.clear(); + } + + public String getFirstHeader(String name) throws MessagingException { + String[] header = getHeader(name); + if (header == null) { + return null; + } + return header[0]; + } + + public void addHeader(String name, String value) throws MessagingException { + mFields.add(new Field(name, value)); + } + + public void setHeader(String name, String value) throws MessagingException { + if (name == null || value == null) { + return; + } + removeHeader(name); + addHeader(name, value); + } + + public String[] getHeader(String name) throws MessagingException { + ArrayList<String> values = new ArrayList<String>(); + for (Field field : mFields) { + if (field.name.equalsIgnoreCase(name)) { + values.add(field.value); + } + } + if (values.size() == 0) { + return null; + } + return values.toArray(new String[] {}); + } + + public void removeHeader(String name) throws MessagingException { + ArrayList<Field> removeFields = new ArrayList<Field>(); + for (Field field : mFields) { + if (field.name.equalsIgnoreCase(name)) { + removeFields.add(field); + } + } + mFields.removeAll(removeFields); + } + + /** + * Write header into String + * + * @return CR-NL separated header string except the headers in writeOmitFields + * null if header is empty + */ + public String writeToString() { + if (mFields.size() == 0) { + return null; + } + StringBuilder builder = new StringBuilder(); + for (Field field : mFields) { + if (!Utility.arrayContains(WRITE_OMIT_FIELDS, field.name)) { + builder.append(field.name + ": " + field.value + "\r\n"); + } + } + return builder.toString(); + } + + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + for (Field field : mFields) { + if (!Utility.arrayContains(WRITE_OMIT_FIELDS, field.name)) { + writer.write(field.name + ": " + field.value + "\r\n"); + } + } + writer.flush(); + } + + private static class Field { + final String name; + final String value; + + public Field(String name, String value) { + this.name = name; + this.value = value; + } + + @Override + public String toString() { + return name + "=" + value; + } + } + + @Override + public String toString() { + return (mFields == null) ? null : mFields.toString(); + } +} diff --git a/src/com/android/emailcommon/internet/MimeMessage.java b/src/com/android/emailcommon/internet/MimeMessage.java new file mode 100644 index 000000000..412092da4 --- /dev/null +++ b/src/com/android/emailcommon/internet/MimeMessage.java @@ -0,0 +1,626 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * 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.android.emailcommon.internet; + +import com.android.emailcommon.mail.Address; +import com.android.emailcommon.mail.Body; +import com.android.emailcommon.mail.BodyPart; +import com.android.emailcommon.mail.Message; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Multipart; +import com.android.emailcommon.mail.Part; + +import org.apache.james.mime4j.BodyDescriptor; +import org.apache.james.mime4j.ContentHandler; +import org.apache.james.mime4j.EOLConvertingInputStream; +import org.apache.james.mime4j.MimeStreamParser; +import org.apache.james.mime4j.field.DateTimeField; +import org.apache.james.mime4j.field.Field; + +import android.text.TextUtils; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Stack; +import java.util.regex.Pattern; + +/** + * An implementation of Message that stores all of its metadata in RFC 822 and + * RFC 2045 style headers. + * + * NOTE: Automatic generation of a local message-id is becoming unwieldy and should be removed. + * It would be better to simply do it explicitly on local creation of new outgoing messages. + */ +public class MimeMessage extends Message { + private MimeHeader mHeader; + private MimeHeader mExtendedHeader; + + // NOTE: The fields here are transcribed out of headers, and values stored here will supercede + // the values found in the headers. Use caution to prevent any out-of-phase errors. In + // particular, any adds/changes/deletes here must be echoed by changes in the parse() function. + private Address[] mFrom; + private Address[] mTo; + private Address[] mCc; + private Address[] mBcc; + private Address[] mReplyTo; + private Date mSentDate; + private Body mBody; + protected int mSize; + private boolean mInhibitLocalMessageId = false; + + // Shared random source for generating local message-id values + private static final java.util.Random sRandom = new java.util.Random(); + + // In MIME, en_US-like date format should be used. In other words "MMM" should be encoded to + // "Jan", not the other localized format like "Ene" (meaning January in locale es). + // This conversion is used when generating outgoing MIME messages. Incoming MIME date + // headers are parsed by org.apache.james.mime4j.field.DateTimeField which does not have any + // localization code. + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US); + + // regex that matches content id surrounded by "<>" optionally. + private static final Pattern REMOVE_OPTIONAL_BRACKETS = Pattern.compile("^<?([^>]+)>?$"); + // regex that matches end of line. + private static final Pattern END_OF_LINE = Pattern.compile("\r?\n"); + + public MimeMessage() { + mHeader = null; + } + + /** + * Generate a local message id. This is only used when none has been assigned, and is + * installed lazily. Any remote (typically server-assigned) message id takes precedence. + * @return a long, locally-generated message-ID value + */ + private String generateMessageId() { + StringBuffer sb = new StringBuffer(); + sb.append("<"); + for (int i = 0; i < 24; i++) { + // We'll use a 5-bit range (0..31) + int value = sRandom.nextInt() & 31; + char c = "0123456789abcdefghijklmnopqrstuv".charAt(value); + sb.append(c); + } + sb.append("."); + sb.append(Long.toString(System.currentTimeMillis())); + sb.append("@email.android.com>"); + return sb.toString(); + } + + /** + * Parse the given InputStream using Apache Mime4J to build a MimeMessage. + * + * @param in + * @throws IOException + * @throws MessagingException + */ + public MimeMessage(InputStream in) throws IOException, MessagingException { + parse(in); + } + + protected void parse(InputStream in) throws IOException, MessagingException { + // Before parsing the input stream, clear all local fields that may be superceded by + // the new incoming message. + getMimeHeaders().clear(); + mInhibitLocalMessageId = true; + mFrom = null; + mTo = null; + mCc = null; + mBcc = null; + mReplyTo = null; + mSentDate = null; + mBody = null; + + MimeStreamParser parser = new MimeStreamParser(); + parser.setContentHandler(new MimeMessageBuilder()); + parser.parse(new EOLConvertingInputStream(in)); + } + + /** + * Return the internal mHeader value, with very lazy initialization. + * The goal is to save memory by not creating the headers until needed. + */ + private MimeHeader getMimeHeaders() { + if (mHeader == null) { + mHeader = new MimeHeader(); + } + return mHeader; + } + + @Override + public Date getReceivedDate() throws MessagingException { + return null; + } + + @Override + public Date getSentDate() throws MessagingException { + if (mSentDate == null) { + try { + DateTimeField field = (DateTimeField)Field.parse("Date: " + + MimeUtility.unfoldAndDecode(getFirstHeader("Date"))); + mSentDate = field.getDate(); + } catch (Exception e) { + + } + } + return mSentDate; + } + + @Override + public void setSentDate(Date sentDate) throws MessagingException { + setHeader("Date", DATE_FORMAT.format(sentDate)); + this.mSentDate = sentDate; + } + + @Override + public String getContentType() throws MessagingException { + String contentType = getFirstHeader(MimeHeader.HEADER_CONTENT_TYPE); + if (contentType == null) { + return "text/plain"; + } else { + return contentType; + } + } + + public String getDisposition() throws MessagingException { + String contentDisposition = getFirstHeader(MimeHeader.HEADER_CONTENT_DISPOSITION); + if (contentDisposition == null) { + return null; + } else { + return contentDisposition; + } + } + + public String getContentId() throws MessagingException { + String contentId = getFirstHeader(MimeHeader.HEADER_CONTENT_ID); + if (contentId == null) { + return null; + } else { + // remove optionally surrounding brackets. + return REMOVE_OPTIONAL_BRACKETS.matcher(contentId).replaceAll("$1"); + } + } + + public String getMimeType() throws MessagingException { + return MimeUtility.getHeaderParameter(getContentType(), null); + } + + public int getSize() throws MessagingException { + return mSize; + } + + /** + * Returns a list of the given recipient type from this message. If no addresses are + * found the method returns an empty array. + */ + @Override + public Address[] getRecipients(RecipientType type) throws MessagingException { + if (type == RecipientType.TO) { + if (mTo == null) { + mTo = Address.parse(MimeUtility.unfold(getFirstHeader("To"))); + } + return mTo; + } else if (type == RecipientType.CC) { + if (mCc == null) { + mCc = Address.parse(MimeUtility.unfold(getFirstHeader("CC"))); + } + return mCc; + } else if (type == RecipientType.BCC) { + if (mBcc == null) { + mBcc = Address.parse(MimeUtility.unfold(getFirstHeader("BCC"))); + } + return mBcc; + } else { + throw new MessagingException("Unrecognized recipient type."); + } + } + + @Override + public void setRecipients(RecipientType type, Address[] addresses) throws MessagingException { + final int TO_LENGTH = 4; // "To: " + final int CC_LENGTH = 4; // "Cc: " + final int BCC_LENGTH = 5; // "Bcc: " + if (type == RecipientType.TO) { + if (addresses == null || addresses.length == 0) { + removeHeader("To"); + this.mTo = null; + } else { + setHeader("To", MimeUtility.fold(Address.toHeader(addresses), TO_LENGTH)); + this.mTo = addresses; + } + } else if (type == RecipientType.CC) { + if (addresses == null || addresses.length == 0) { + removeHeader("CC"); + this.mCc = null; + } else { + setHeader("CC", MimeUtility.fold(Address.toHeader(addresses), CC_LENGTH)); + this.mCc = addresses; + } + } else if (type == RecipientType.BCC) { + if (addresses == null || addresses.length == 0) { + removeHeader("BCC"); + this.mBcc = null; + } else { + setHeader("BCC", MimeUtility.fold(Address.toHeader(addresses), BCC_LENGTH)); + this.mBcc = addresses; + } + } else { + throw new MessagingException("Unrecognized recipient type."); + } + } + + /** + * Returns the unfolded, decoded value of the Subject header. + */ + @Override + public String getSubject() throws MessagingException { + return MimeUtility.unfoldAndDecode(getFirstHeader("Subject")); + } + + @Override + public void setSubject(String subject) throws MessagingException { + final int HEADER_NAME_LENGTH = 9; // "Subject: " + setHeader("Subject", MimeUtility.foldAndEncode2(subject, HEADER_NAME_LENGTH)); + } + + @Override + public Address[] getFrom() throws MessagingException { + if (mFrom == null) { + String list = MimeUtility.unfold(getFirstHeader("From")); + if (list == null || list.length() == 0) { + list = MimeUtility.unfold(getFirstHeader("Sender")); + } + mFrom = Address.parse(list); + } + return mFrom; + } + + @Override + public void setFrom(Address from) throws MessagingException { + final int FROM_LENGTH = 6; // "From: " + if (from != null) { + setHeader("From", MimeUtility.fold(from.toHeader(), FROM_LENGTH)); + this.mFrom = new Address[] { + from + }; + } else { + this.mFrom = null; + } + } + + @Override + public Address[] getReplyTo() throws MessagingException { + if (mReplyTo == null) { + mReplyTo = Address.parse(MimeUtility.unfold(getFirstHeader("Reply-to"))); + } + return mReplyTo; + } + + @Override + public void setReplyTo(Address[] replyTo) throws MessagingException { + final int REPLY_TO_LENGTH = 10; // "Reply-to: " + if (replyTo == null || replyTo.length == 0) { + removeHeader("Reply-to"); + mReplyTo = null; + } else { + setHeader("Reply-to", MimeUtility.fold(Address.toHeader(replyTo), REPLY_TO_LENGTH)); + mReplyTo = replyTo; + } + } + + /** + * Set the mime "Message-ID" header + * @param messageId the new Message-ID value + * @throws MessagingException + */ + @Override + public void setMessageId(String messageId) throws MessagingException { + setHeader("Message-ID", messageId); + } + + /** + * Get the mime "Message-ID" header. This value will be preloaded with a locally-generated + * random ID, if the value has not previously been set. Local generation can be inhibited/ + * overridden by explicitly clearing the headers, removing the message-id header, etc. + * @return the Message-ID header string, or null if explicitly has been set to null + */ + @Override + public String getMessageId() throws MessagingException { + String messageId = getFirstHeader("Message-ID"); + if (messageId == null && !mInhibitLocalMessageId) { + messageId = generateMessageId(); + setMessageId(messageId); + } + return messageId; + } + + @Override + public void saveChanges() throws MessagingException { + throw new MessagingException("saveChanges not yet implemented"); + } + + @Override + public Body getBody() throws MessagingException { + return mBody; + } + + @Override + public void setBody(Body body) throws MessagingException { + this.mBody = body; + if (body instanceof Multipart) { + Multipart multipart = ((Multipart)body); + multipart.setParent(this); + setHeader(MimeHeader.HEADER_CONTENT_TYPE, multipart.getContentType()); + setHeader("MIME-Version", "1.0"); + } + else if (body instanceof TextBody) { + setHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\n charset=utf-8", + getMimeType())); + setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); + } + } + + protected String getFirstHeader(String name) throws MessagingException { + return getMimeHeaders().getFirstHeader(name); + } + + @Override + public void addHeader(String name, String value) throws MessagingException { + getMimeHeaders().addHeader(name, value); + } + + @Override + public void setHeader(String name, String value) throws MessagingException { + getMimeHeaders().setHeader(name, value); + } + + @Override + public String[] getHeader(String name) throws MessagingException { + return getMimeHeaders().getHeader(name); + } + + @Override + public void removeHeader(String name) throws MessagingException { + getMimeHeaders().removeHeader(name); + if ("Message-ID".equalsIgnoreCase(name)) { + mInhibitLocalMessageId = true; + } + } + + /** + * Set extended header + * + * @param name Extended header name + * @param value header value - flattened by removing CR-NL if any + * remove header if value is null + * @throws MessagingException + */ + public void setExtendedHeader(String name, String value) throws MessagingException { + if (value == null) { + if (mExtendedHeader != null) { + mExtendedHeader.removeHeader(name); + } + return; + } + if (mExtendedHeader == null) { + mExtendedHeader = new MimeHeader(); + } + mExtendedHeader.setHeader(name, END_OF_LINE.matcher(value).replaceAll("")); + } + + /** + * Get extended header + * + * @param name Extended header name + * @return header value - null if header does not exist + * @throws MessagingException + */ + public String getExtendedHeader(String name) throws MessagingException { + if (mExtendedHeader == null) { + return null; + } + return mExtendedHeader.getFirstHeader(name); + } + + /** + * Set entire extended headers from String + * + * @param headers Extended header and its value - "CR-NL-separated pairs + * if null or empty, remove entire extended headers + * @throws MessagingException + */ + public void setExtendedHeaders(String headers) throws MessagingException { + if (TextUtils.isEmpty(headers)) { + mExtendedHeader = null; + } else { + mExtendedHeader = new MimeHeader(); + for (String header : END_OF_LINE.split(headers)) { + String[] tokens = header.split(":", 2); + if (tokens.length != 2) { + throw new MessagingException("Illegal extended headers: " + headers); + } + mExtendedHeader.setHeader(tokens[0].trim(), tokens[1].trim()); + } + } + } + + /** + * Get entire extended headers as String + * + * @return "CR-NL-separated extended headers - null if extended header does not exist + */ + public String getExtendedHeaders() { + if (mExtendedHeader != null) { + return mExtendedHeader.writeToString(); + } + return null; + } + + /** + * Write message header and body to output stream + * + * @param out Output steam to write message header and body. + */ + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + // Force creation of local message-id + getMessageId(); + getMimeHeaders().writeTo(out); + // mExtendedHeader will not be write out to external output stream, + // because it is intended to internal use. + writer.write("\r\n"); + writer.flush(); + if (mBody != null) { + mBody.writeTo(out); + } + } + + public InputStream getInputStream() throws MessagingException { + return null; + } + + class MimeMessageBuilder implements ContentHandler { + private Stack<Object> stack = new Stack<Object>(); + + public MimeMessageBuilder() { + } + + private void expect(Class c) { + if (!c.isInstance(stack.peek())) { + throw new IllegalStateException("Internal stack error: " + "Expected '" + + c.getName() + "' found '" + stack.peek().getClass().getName() + "'"); + } + } + + public void startMessage() { + if (stack.isEmpty()) { + stack.push(MimeMessage.this); + } else { + expect(Part.class); + try { + MimeMessage m = new MimeMessage(); + ((Part)stack.peek()).setBody(m); + stack.push(m); + } catch (MessagingException me) { + throw new Error(me); + } + } + } + + public void endMessage() { + expect(MimeMessage.class); + stack.pop(); + } + + public void startHeader() { + expect(Part.class); + } + + public void field(String fieldData) { + expect(Part.class); + try { + String[] tokens = fieldData.split(":", 2); + ((Part)stack.peek()).addHeader(tokens[0], tokens[1].trim()); + } catch (MessagingException me) { + throw new Error(me); + } + } + + public void endHeader() { + expect(Part.class); + } + + public void startMultipart(BodyDescriptor bd) { + expect(Part.class); + + Part e = (Part)stack.peek(); + try { + MimeMultipart multiPart = new MimeMultipart(e.getContentType()); + e.setBody(multiPart); + stack.push(multiPart); + } catch (MessagingException me) { + throw new Error(me); + } + } + + public void body(BodyDescriptor bd, InputStream in) throws IOException { + expect(Part.class); + Body body = MimeUtility.decodeBody(in, bd.getTransferEncoding()); + try { + ((Part)stack.peek()).setBody(body); + } catch (MessagingException me) { + throw new Error(me); + } + } + + public void endMultipart() { + stack.pop(); + } + + public void startBodyPart() { + expect(MimeMultipart.class); + + try { + MimeBodyPart bodyPart = new MimeBodyPart(); + ((MimeMultipart)stack.peek()).addBodyPart(bodyPart); + stack.push(bodyPart); + } catch (MessagingException me) { + throw new Error(me); + } + } + + public void endBodyPart() { + expect(BodyPart.class); + stack.pop(); + } + + public void epilogue(InputStream is) throws IOException { + expect(MimeMultipart.class); + StringBuffer sb = new StringBuffer(); + int b; + while ((b = is.read()) != -1) { + sb.append((char)b); + } + // ((Multipart) stack.peek()).setEpilogue(sb.toString()); + } + + public void preamble(InputStream is) throws IOException { + expect(MimeMultipart.class); + StringBuffer sb = new StringBuffer(); + int b; + while ((b = is.read()) != -1) { + sb.append((char)b); + } + try { + ((MimeMultipart)stack.peek()).setPreamble(sb.toString()); + } catch (MessagingException me) { + throw new Error(me); + } + } + + public void raw(InputStream is) throws IOException { + throw new UnsupportedOperationException("Not supported"); + } + } +} diff --git a/src/com/android/emailcommon/internet/MimeMultipart.java b/src/com/android/emailcommon/internet/MimeMultipart.java new file mode 100644 index 000000000..e6977ee4f --- /dev/null +++ b/src/com/android/emailcommon/internet/MimeMultipart.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * 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.android.emailcommon.internet; + +import com.android.emailcommon.mail.BodyPart; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Multipart; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; + +public class MimeMultipart extends Multipart { + protected String mPreamble; + + protected String mContentType; + + protected String mBoundary; + + protected String mSubType; + + public MimeMultipart() throws MessagingException { + mBoundary = generateBoundary(); + setSubType("mixed"); + } + + public MimeMultipart(String contentType) throws MessagingException { + this.mContentType = contentType; + try { + mSubType = MimeUtility.getHeaderParameter(contentType, null).split("/")[1]; + mBoundary = MimeUtility.getHeaderParameter(contentType, "boundary"); + if (mBoundary == null) { + throw new MessagingException("MultiPart does not contain boundary: " + contentType); + } + } catch (Exception e) { + throw new MessagingException( + "Invalid MultiPart Content-Type; must contain subtype and boundary. (" + + contentType + ")", e); + } + } + + public String generateBoundary() { + StringBuffer sb = new StringBuffer(); + sb.append("----"); + for (int i = 0; i < 30; i++) { + sb.append(Integer.toString((int)(Math.random() * 35), 36)); + } + return sb.toString().toUpperCase(); + } + + public String getPreamble() throws MessagingException { + return mPreamble; + } + + public void setPreamble(String preamble) throws MessagingException { + this.mPreamble = preamble; + } + + @Override + public String getContentType() throws MessagingException { + return mContentType; + } + + public void setSubType(String subType) throws MessagingException { + this.mSubType = subType; + mContentType = String.format("multipart/%s; boundary=\"%s\"", subType, mBoundary); + } + + public void writeTo(OutputStream out) throws IOException, MessagingException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out), 1024); + + if (mPreamble != null) { + writer.write(mPreamble + "\r\n"); + } + + for (int i = 0, count = mParts.size(); i < count; i++) { + BodyPart bodyPart = mParts.get(i); + writer.write("--" + mBoundary + "\r\n"); + writer.flush(); + bodyPart.writeTo(out); + writer.write("\r\n"); + } + + writer.write("--" + mBoundary + "--\r\n"); + writer.flush(); + } + + public InputStream getInputStream() throws MessagingException { + return null; + } + + public String getSubTypeForTest() { + return mSubType; + } +} diff --git a/src/com/android/emailcommon/internet/MimeUtility.java b/src/com/android/emailcommon/internet/MimeUtility.java new file mode 100644 index 000000000..69330d76b --- /dev/null +++ b/src/com/android/emailcommon/internet/MimeUtility.java @@ -0,0 +1,452 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * 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.android.emailcommon.internet; + +import com.android.emailcommon.Logging; +import com.android.emailcommon.mail.Body; +import com.android.emailcommon.mail.BodyPart; +import com.android.emailcommon.mail.Message; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.mail.Multipart; +import com.android.emailcommon.mail.Part; + +import org.apache.commons.io.IOUtils; +import org.apache.james.mime4j.codec.EncoderUtil; +import org.apache.james.mime4j.decoder.DecoderUtil; +import org.apache.james.mime4j.decoder.QuotedPrintableInputStream; +import org.apache.james.mime4j.util.CharsetUtil; + +import android.util.Base64; +import android.util.Base64DataException; +import android.util.Base64InputStream; +import android.util.Log; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class MimeUtility { + + public static final String MIME_TYPE_RFC822 = "message/rfc822"; + private final static Pattern PATTERN_CR_OR_LF = Pattern.compile("\r|\n"); + + /** + * Replace sequences of CRLF+WSP with WSP. Tries to preserve original string + * object whenever possible. + */ + public static String unfold(String s) { + if (s == null) { + return null; + } + Matcher patternMatcher = PATTERN_CR_OR_LF.matcher(s); + if (patternMatcher.find()) { + patternMatcher.reset(); + s = patternMatcher.replaceAll(""); + } + return s; + } + + public static String decode(String s) { + if (s == null) { + return null; + } + return DecoderUtil.decodeEncodedWords(s); + } + + public static String unfoldAndDecode(String s) { + return decode(unfold(s)); + } + + // TODO implement proper foldAndEncode + // NOTE: When this really works, we *must* remove all calls to foldAndEncode2() to prevent + // duplication of encoding. + public static String foldAndEncode(String s) { + return s; + } + + /** + * INTERIM version of foldAndEncode that will be used only by Subject: headers. + * This is safer than implementing foldAndEncode() (see above) and risking unknown damage + * to other headers. + * + * TODO: Copy this code to foldAndEncode(), get rid of this function, confirm all working OK. + * + * @param s original string to encode and fold + * @param usedCharacters number of characters already used up by header name + + * @return the String ready to be transmitted + */ + public static String foldAndEncode2(String s, int usedCharacters) { + // james.mime4j.codec.EncoderUtil.java + // encode: encodeIfNecessary(text, usage, numUsedInHeaderName) + // Usage.TEXT_TOKENlooks like the right thing for subjects + // use WORD_ENTITY for address/names + + String encoded = EncoderUtil.encodeIfNecessary(s, EncoderUtil.Usage.TEXT_TOKEN, + usedCharacters); + + return fold(encoded, usedCharacters); + } + + /** + * INTERIM: From newer version of org.apache.james (but we don't want to import + * the entire MimeUtil class). + * + * Splits the specified string into a multiple-line representation with + * lines no longer than 76 characters (because the line might contain + * encoded words; see <a href='http://www.faqs.org/rfcs/rfc2047.html'>RFC + * 2047</a> section 2). If the string contains non-whitespace sequences + * longer than 76 characters a line break is inserted at the whitespace + * character following the sequence resulting in a line longer than 76 + * characters. + * + * @param s + * string to split. + * @param usedCharacters + * number of characters already used up. Usually the number of + * characters for header field name plus colon and one space. + * @return a multiple-line representation of the given string. + */ + public static String fold(String s, int usedCharacters) { + final int maxCharacters = 76; + + final int length = s.length(); + if (usedCharacters + length <= maxCharacters) + return s; + + StringBuilder sb = new StringBuilder(); + + int lastLineBreak = -usedCharacters; + int wspIdx = indexOfWsp(s, 0); + while (true) { + if (wspIdx == length) { + sb.append(s.substring(Math.max(0, lastLineBreak))); + return sb.toString(); + } + + int nextWspIdx = indexOfWsp(s, wspIdx + 1); + + if (nextWspIdx - lastLineBreak > maxCharacters) { + sb.append(s.substring(Math.max(0, lastLineBreak), wspIdx)); + sb.append("\r\n"); + lastLineBreak = wspIdx; + } + + wspIdx = nextWspIdx; + } + } + + /** + * INTERIM: From newer version of org.apache.james (but we don't want to import + * the entire MimeUtil class). + * + * Search for whitespace. + */ + private static int indexOfWsp(String s, int fromIndex) { + final int len = s.length(); + for (int index = fromIndex; index < len; index++) { + char c = s.charAt(index); + if (c == ' ' || c == '\t') + return index; + } + return len; + } + + /** + * Returns the named parameter of a header field. If name is null the first + * parameter is returned, or if there are no additional parameters in the + * field the entire field is returned. Otherwise the named parameter is + * searched for in a case insensitive fashion and returned. If the parameter + * cannot be found the method returns null. + * + * TODO: quite inefficient with the inner trimming & splitting. + * TODO: Also has a latent bug: uses "startsWith" to match the name, which can false-positive. + * TODO: The doc says that for a null name you get the first param, but you get the header. + * Should probably just fix the doc, but if other code assumes that behavior, fix the code. + * TODO: Need to decode %-escaped strings, as in: filename="ab%22d". + * ('+' -> ' ' conversion too? check RFC) + * + * @param header + * @param name + * @return the entire header (if name=null), the found parameter, or null + */ + public static String getHeaderParameter(String header, String name) { + if (header == null) { + return null; + } + String[] parts = unfold(header).split(";"); + if (name == null) { + return parts[0].trim(); + } + String lowerCaseName = name.toLowerCase(); + for (String part : parts) { + if (part.trim().toLowerCase().startsWith(lowerCaseName)) { + String[] parameterParts = part.split("=", 2); + if (parameterParts.length < 2) { + return null; + } + String parameter = parameterParts[1].trim(); + if (parameter.startsWith("\"") && parameter.endsWith("\"")) { + return parameter.substring(1, parameter.length() - 1); + } else { + return parameter; + } + } + } + return null; + } + + public static Part findFirstPartByMimeType(Part part, String mimeType) + throws MessagingException { + if (part.getBody() instanceof Multipart) { + Multipart multipart = (Multipart)part.getBody(); + for (int i = 0, count = multipart.getCount(); i < count; i++) { + BodyPart bodyPart = multipart.getBodyPart(i); + Part ret = findFirstPartByMimeType(bodyPart, mimeType); + if (ret != null) { + return ret; + } + } + } + else if (part.getMimeType().equalsIgnoreCase(mimeType)) { + return part; + } + return null; + } + + public static Part findPartByContentId(Part part, String contentId) throws Exception { + if (part.getBody() instanceof Multipart) { + Multipart multipart = (Multipart)part.getBody(); + for (int i = 0, count = multipart.getCount(); i < count; i++) { + BodyPart bodyPart = multipart.getBodyPart(i); + Part ret = findPartByContentId(bodyPart, contentId); + if (ret != null) { + return ret; + } + } + } + String cid = part.getContentId(); + if (contentId.equals(cid)) { + return part; + } + return null; + } + + /** + * Reads the Part's body and returns a String based on any charset conversion that needed + * to be done. + * @param part The part containing a body + * @return a String containing the converted text in the body, or null if there was no text + * or an error during conversion. + */ + public static String getTextFromPart(Part part) { + try { + if (part != null && part.getBody() != null) { + InputStream in = part.getBody().getInputStream(); + String mimeType = part.getMimeType(); + if (mimeType != null && MimeUtility.mimeTypeMatches(mimeType, "text/*")) { + /* + * Now we read the part into a buffer for further processing. Because + * the stream is now wrapped we'll remove any transfer encoding at this point. + */ + ByteArrayOutputStream out = new ByteArrayOutputStream(); + IOUtils.copy(in, out); + in.close(); + in = null; // we want all of our memory back, and close might not release + + /* + * We've got a text part, so let's see if it needs to be processed further. + */ + String charset = getHeaderParameter(part.getContentType(), "charset"); + if (charset != null) { + /* + * See if there is conversion from the MIME charset to the Java one. + */ + charset = CharsetUtil.toJavaCharset(charset); + } + /* + * No encoding, so use us-ascii, which is the standard. + */ + if (charset == null) { + charset = "ASCII"; + } + /* + * Convert and return as new String + */ + String result = out.toString(charset); + out.close(); + return result; + } + } + + } + catch (OutOfMemoryError oom) { + /* + * If we are not able to process the body there's nothing we can do about it. Return + * null and let the upper layers handle the missing content. + */ + Log.e(Logging.LOG_TAG, "Unable to getTextFromPart " + oom.toString()); + } + catch (Exception e) { + /* + * If we are not able to process the body there's nothing we can do about it. Return + * null and let the upper layers handle the missing content. + */ + Log.e(Logging.LOG_TAG, "Unable to getTextFromPart " + e.toString()); + } + return null; + } + + /** + * Returns true if the given mimeType matches the matchAgainst specification. The comparison + * ignores case and the matchAgainst string may include "*" for a wildcard (e.g. "image/*"). + * + * @param mimeType A MIME type to check. + * @param matchAgainst A MIME type to check against. May include wildcards. + * @return true if the mimeType matches + */ + public static boolean mimeTypeMatches(String mimeType, String matchAgainst) { + Pattern p = Pattern.compile(matchAgainst.replaceAll("\\*", "\\.\\*"), + Pattern.CASE_INSENSITIVE); + return p.matcher(mimeType).matches(); + } + + /** + * Returns true if the given mimeType matches any of the matchAgainst specifications. The + * comparison ignores case and the matchAgainst strings may include "*" for a wildcard + * (e.g. "image/*"). + * + * @param mimeType A MIME type to check. + * @param matchAgainst An array of MIME types to check against. May include wildcards. + * @return true if the mimeType matches any of the matchAgainst strings + */ + public static boolean mimeTypeMatches(String mimeType, String[] matchAgainst) { + for (String matchType : matchAgainst) { + if (mimeTypeMatches(mimeType, matchType)) { + return true; + } + } + return false; + } + + /** + * Given an input stream and a transfer encoding, return a wrapped input stream for that + * encoding (or the original if none is required) + * @param in the input stream + * @param contentTransferEncoding the content transfer encoding + * @return a properly wrapped stream + */ + public static InputStream getInputStreamForContentTransferEncoding(InputStream in, + String contentTransferEncoding) { + if (contentTransferEncoding != null) { + contentTransferEncoding = + MimeUtility.getHeaderParameter(contentTransferEncoding, null); + if ("quoted-printable".equalsIgnoreCase(contentTransferEncoding)) { + in = new QuotedPrintableInputStream(in); + } + else if ("base64".equalsIgnoreCase(contentTransferEncoding)) { + in = new Base64InputStream(in, Base64.DEFAULT); + } + } + return in; + } + + /** + * Removes any content transfer encoding from the stream and returns a Body. + */ + public static Body decodeBody(InputStream in, String contentTransferEncoding) + throws IOException { + /* + * We'll remove any transfer encoding by wrapping the stream. + */ + in = getInputStreamForContentTransferEncoding(in, contentTransferEncoding); + BinaryTempFileBody tempBody = new BinaryTempFileBody(); + OutputStream out = tempBody.getOutputStream(); + try { + IOUtils.copy(in, out); + } catch (Base64DataException bde) { + // STOPSHIP Need to fix this somehow + //String warning = "\n\n" + Email.getMessageDecodeErrorString(); + //out.write(warning.getBytes()); + } finally { + out.close(); + } + return tempBody; + } + + /** + * Recursively scan a Part (usually a Message) and sort out which of its children will be + * "viewable" and which will be attachments. + * + * @param part The part to be broken down + * @param viewables This arraylist will be populated with all parts that appear to be + * the "message" (e.g. text/plain & text/html) + * @param attachments This arraylist will be populated with all parts that appear to be + * attachments (including inlines) + * @throws MessagingException + */ + public static void collectParts(Part part, ArrayList<Part> viewables, + ArrayList<Part> attachments) throws MessagingException { + String disposition = part.getDisposition(); + String dispositionType = null; + String dispositionFilename = null; + if (disposition != null) { + dispositionType = MimeUtility.getHeaderParameter(disposition, null); + dispositionFilename = MimeUtility.getHeaderParameter(disposition, "filename"); + } + boolean attachmentDisposition = "attachment".equalsIgnoreCase(dispositionType); + boolean inlineDisposition = "inline".equalsIgnoreCase(dispositionType); + + // A guess that this part is intended to be an attachment + boolean attachment = attachmentDisposition + || (dispositionFilename != null && !inlineDisposition); + + // A guess that this part is intended to be an inline. + boolean inline = inlineDisposition && (dispositionFilename != null); + + // One or the other + boolean attachmentOrInline = attachment || inline; + + if (part.getBody() instanceof Multipart) { + // If the part is Multipart but not alternative it's either mixed or + // something we don't know about, which means we treat it as mixed + // per the spec. We just process its pieces recursively. + Multipart mp = (Multipart)part.getBody(); + for (int i = 0; i < mp.getCount(); i++) { + collectParts(mp.getBodyPart(i), viewables, attachments); + } + } else if (part.getBody() instanceof Message) { + // If the part is an embedded message we just continue to process + // it, pulling any viewables or attachments into the running list. + Message message = (Message)part.getBody(); + collectParts(message, viewables, attachments); + } else if ((!attachmentOrInline) && ("text/html".equalsIgnoreCase(part.getMimeType()))) { + // If the part is HTML and we got this far, it's a viewable part of a mixed + viewables.add(part); + } else if ((!attachmentOrInline) && ("text/plain".equalsIgnoreCase(part.getMimeType()))) { + // If the part is text and we got this far, it's a viewable part of a mixed + viewables.add(part); + } else if (attachmentOrInline) { + // Finally, if it's an attachment or an inline we will include it as an attachment. + attachments.add(part); + } + } +} diff --git a/src/com/android/emailcommon/internet/Rfc822Output.java b/src/com/android/emailcommon/internet/Rfc822Output.java new file mode 100644 index 000000000..7382d30b2 --- /dev/null +++ b/src/com/android/emailcommon/internet/Rfc822Output.java @@ -0,0 +1,357 @@ +/* + * Copyright (C) 2009 The Android Open Source Project + * + * 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.android.emailcommon.internet; + +import com.android.emailcommon.mail.Address; +import com.android.emailcommon.mail.MessagingException; +import com.android.emailcommon.provider.EmailContent.Attachment; +import com.android.emailcommon.provider.EmailContent.Body; +import com.android.emailcommon.provider.EmailContent.Message; + +import org.apache.commons.io.IOUtils; + +import android.content.ContentUris; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.util.Base64; +import android.util.Base64OutputStream; + +import java.io.BufferedOutputStream; +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility class to output RFC 822 messages from provider email messages + */ +public class Rfc822Output { + + private static final Pattern PATTERN_START_OF_LINE = Pattern.compile("(?m)^"); + private static final Pattern PATTERN_ENDLINE_CRLF = Pattern.compile("\r\n"); + + // In MIME, en_US-like date format should be used. In other words "MMM" should be encoded to + // "Jan", not the other localized format like "Ene" (meaning January in locale es). + private static final SimpleDateFormat DATE_FORMAT = + new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US); + + private static final String WHERE_NOT_SMART_FORWARD = "(" + Attachment.FLAGS + "&" + + Attachment.FLAG_SMART_FORWARD + ")=0"; + + /*package*/ static String buildBodyText(Context context, Message message, + boolean useSmartReply) { + Body body = Body.restoreBodyWithMessageId(context, message.mId); + if (body == null) { + return null; + } + + String text = body.mTextContent; + int flags = message.mFlags; + boolean isReply = (flags & Message.FLAG_TYPE_REPLY) != 0; + boolean isForward = (flags & Message.FLAG_TYPE_FORWARD) != 0; + // For all forwards/replies, we add the intro text + if (isReply || isForward) { + String intro = body.mIntroText == null ? "" : body.mIntroText; + text += intro; + } + if (useSmartReply) { + // useSmartReply is set to true for use by SmartReply/SmartForward in EAS. + // SmartForward doesn't put a break between the original and new text, so we add an LF + if (isForward) { + text += "\n"; + } + return text; + } + + String quotedText = body.mTextReply; + if (quotedText != null) { + // fix CR-LF line endings to LF-only needed by EditText. + Matcher matcher = PATTERN_ENDLINE_CRLF.matcher(quotedText); + quotedText = matcher.replaceAll("\n"); + } + if (isReply) { + if (quotedText != null) { + Matcher matcher = PATTERN_START_OF_LINE.matcher(quotedText); + text += matcher.replaceAll(">"); + } + } else if (isForward) { + if (quotedText != null) { + text += quotedText; + } + } + return text; + } + + /** + * Write the entire message to an output stream. This method provides buffering, so it is + * not necessary to pass in a buffered output stream here. + * + * @param context system context for accessing the provider + * @param messageId the message to write out + * @param out the output stream to write the message to + * @param useSmartReply whether or not quoted text is appended to a reply/forward + * + * TODO alternative parts (e.g. text+html) are not supported here. + */ + public static void writeTo(Context context, long messageId, OutputStream out, + boolean useSmartReply, boolean sendBcc) throws IOException, MessagingException { + Message message = Message.restoreMessageWithId(context, messageId); + if (message == null) { + // throw something? + return; + } + + OutputStream stream = new BufferedOutputStream(out, 1024); + Writer writer = new OutputStreamWriter(stream); + + // Write the fixed headers. Ordering is arbitrary (the legacy code iterated through a + // hashmap here). + + String date = DATE_FORMAT.format(new Date(message.mTimeStamp)); + writeHeader(writer, "Date", date); + + writeEncodedHeader(writer, "Subject", message.mSubject); + + writeHeader(writer, "Message-ID", message.mMessageId); + + writeAddressHeader(writer, "From", message.mFrom); + writeAddressHeader(writer, "To", message.mTo); + writeAddressHeader(writer, "Cc", message.mCc); + // Address fields. Note that we skip bcc unless the sendBcc argument is true + // SMTP should NOT send bcc headers, but EAS must send it! + if (sendBcc) { + writeAddressHeader(writer, "Bcc", message.mBcc); + } + writeAddressHeader(writer, "Reply-To", message.mReplyTo); + writeHeader(writer, "MIME-Version", "1.0"); + + // Analyze message and determine if we have multiparts + String text = buildBodyText(context, message, useSmartReply); + + Uri uri = ContentUris.withAppendedId(Attachment.MESSAGE_ID_URI, messageId); + Cursor attachmentsCursor = context.getContentResolver().query(uri, + Attachment.CONTENT_PROJECTION, WHERE_NOT_SMART_FORWARD, null, null); + + try { + int attachmentCount = attachmentsCursor.getCount(); + boolean multipart = attachmentCount > 0; + String multipartBoundary = null; + String multipartType = "mixed"; + + // Simplified case for no multipart - just emit text and be done. + if (!multipart) { + if (text != null) { + writeTextWithHeaders(writer, stream, text); + } else { + writer.write("\r\n"); // a truly empty message + } + } else { + // continue with multipart headers, then into multipart body + multipartBoundary = "--_com.android.email_" + System.nanoTime(); + + // Move to the first attachment; this must succeed because multipart is true + attachmentsCursor.moveToFirst(); + if (attachmentCount == 1) { + // If we've got one attachment and it's an ics "attachment", we want to send + // this as multipart/alternative instead of multipart/mixed + int flags = attachmentsCursor.getInt(Attachment.CONTENT_FLAGS_COLUMN); + if ((flags & Attachment.FLAG_ICS_ALTERNATIVE_PART) != 0) { + multipartType = "alternative"; + } + } + + writeHeader(writer, "Content-Type", + "multipart/" + multipartType + "; boundary=\"" + multipartBoundary + "\""); + // Finish headers and prepare for body section(s) + writer.write("\r\n"); + + // first multipart element is the body + if (text != null) { + writeBoundary(writer, multipartBoundary, false); + writeTextWithHeaders(writer, stream, text); + } + + // Write out the attachments until we run out + do { + writeBoundary(writer, multipartBoundary, false); + Attachment attachment = + Attachment.getContent(attachmentsCursor, Attachment.class); + writeOneAttachment(context, writer, stream, attachment); + writer.write("\r\n"); + } while (attachmentsCursor.moveToNext()); + + // end of multipart section + writeBoundary(writer, multipartBoundary, true); + } + } finally { + attachmentsCursor.close(); + } + + writer.flush(); + out.flush(); + } + + /** + * Write a single attachment and its payload + */ + private static void writeOneAttachment(Context context, Writer writer, OutputStream out, + Attachment attachment) throws IOException, MessagingException { + writeHeader(writer, "Content-Type", + attachment.mMimeType + ";\n name=\"" + attachment.mFileName + "\""); + writeHeader(writer, "Content-Transfer-Encoding", "base64"); + // Most attachments (real files) will send Content-Disposition. The suppression option + // is used when sending calendar invites. + if ((attachment.mFlags & Attachment.FLAG_ICS_ALTERNATIVE_PART) == 0) { + writeHeader(writer, "Content-Disposition", + "attachment;" + + "\n filename=\"" + attachment.mFileName + "\";" + + "\n size=" + Long.toString(attachment.mSize)); + } + writeHeader(writer, "Content-ID", attachment.mContentId); + writer.append("\r\n"); + + // Set up input stream and write it out via base64 + InputStream inStream = null; + try { + // Use content, if provided; otherwise, use the contentUri + if (attachment.mContentBytes != null) { + inStream = new ByteArrayInputStream(attachment.mContentBytes); + } else { + // try to open the file + Uri fileUri = Uri.parse(attachment.mContentUri); + inStream = context.getContentResolver().openInputStream(fileUri); + } + // switch to output stream for base64 text output + writer.flush(); + Base64OutputStream base64Out = new Base64OutputStream( + out, Base64.CRLF | Base64.NO_CLOSE); + // copy base64 data and close up + IOUtils.copy(inStream, base64Out); + base64Out.close(); + + // The old Base64OutputStream wrote an extra CRLF after + // the output. It's not required by the base-64 spec; not + // sure if it's required by RFC 822 or not. + out.write('\r'); + out.write('\n'); + out.flush(); + } + catch (FileNotFoundException fnfe) { + // Ignore this - empty file is OK + } + catch (IOException ioe) { + throw new MessagingException("Invalid attachment.", ioe); + } + } + + /** + * Write a single header with no wrapping or encoding + * + * @param writer the output writer + * @param name the header name + * @param value the header value + */ + private static void writeHeader(Writer writer, String name, String value) throws IOException { + if (value != null && value.length() > 0) { + writer.append(name); + writer.append(": "); + writer.append(value); + writer.append("\r\n"); + } + } + + /** + * Write a single header using appropriate folding & encoding + * + * @param writer the output writer + * @param name the header name + * @param value the header value + */ + private static void writeEncodedHeader(Writer writer, String name, String value) + throws IOException { + if (value != null && value.length() > 0) { + writer.append(name); + writer.append(": "); + writer.append(MimeUtility.foldAndEncode2(value, name.length() + 2)); + writer.append("\r\n"); + } + } + + /** + * Unpack, encode, and fold address(es) into a header + * + * @param writer the output writer + * @param name the header name + * @param value the header value (a packed list of addresses) + */ + private static void writeAddressHeader(Writer writer, String name, String value) + throws IOException { + if (value != null && value.length() > 0) { + writer.append(name); + writer.append(": "); + writer.append(MimeUtility.fold(Address.packedToHeader(value), name.length() + 2)); + writer.append("\r\n"); + } + } + + /** + * Write a multipart boundary + * + * @param writer the output writer + * @param boundary the boundary string + * @param end false if inner boundary, true if final boundary + */ + private static void writeBoundary(Writer writer, String boundary, boolean end) + throws IOException { + writer.append("--"); + writer.append(boundary); + if (end) { + writer.append("--"); + } + writer.append("\r\n"); + } + + /** + * Write text (either as main body or inside a multipart), preceded by appropriate headers. + * + * Note this always uses base64, even when not required. Slightly less efficient for + * US-ASCII text, but handles all formats even when non-ascii chars are involved. A small + * optimization might be to prescan the string for safety and send raw if possible. + * + * @param writer the output writer + * @param out the output stream inside the writer (used for byte[] access) + * @param text The original text of the message + */ + private static void writeTextWithHeaders(Writer writer, OutputStream out, String text) + throws IOException { + writeHeader(writer, "Content-Type", "text/plain; charset=utf-8"); + writeHeader(writer, "Content-Transfer-Encoding", "base64"); + writer.write("\r\n"); + byte[] bytes = text.getBytes("UTF-8"); + writer.flush(); + out.write(Base64.encode(bytes, Base64.CRLF)); + } +} diff --git a/src/com/android/emailcommon/internet/TextBody.java b/src/com/android/emailcommon/internet/TextBody.java new file mode 100644 index 000000000..09c265c8e --- /dev/null +++ b/src/com/android/emailcommon/internet/TextBody.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * 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.android.emailcommon.internet; + +import com.android.emailcommon.mail.Body; +import com.android.emailcommon.mail.MessagingException; + +import android.util.Base64; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; + +public class TextBody implements Body { + String mBody; + + public TextBody(String body) { + this.mBody = body; + } + + public void writeTo(OutputStream out) throws IOException, MessagingException { + byte[] bytes = mBody.getBytes("UTF-8"); + out.write(Base64.encode(bytes, Base64.CRLF)); + } + + /** + * Get the text of the body in it's unencoded format. + * @return + */ + public String getText() { + return mBody; + } + + /** + * Returns an InputStream that reads this body's text in UTF-8 format. + */ + public InputStream getInputStream() throws MessagingException { + try { + byte[] b = mBody.getBytes("UTF-8"); + return new ByteArrayInputStream(b); + } + catch (UnsupportedEncodingException usee) { + return null; + } + } +} |