package com.fsck.k9.mail.store.imap;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import android.text.TextUtils;
import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.BodyFactory;
import com.fsck.k9.mail.FetchProfile;
import com.fsck.k9.mail.Flag;
import com.fsck.k9.mail.Folder;
import com.fsck.k9.mail.K9MailLib;
import com.fsck.k9.mail.Message;
import com.fsck.k9.mail.MessageRetrievalListener;
import com.fsck.k9.mail.MessagingException;
import com.fsck.k9.mail.Part;
import com.fsck.k9.mail.filter.EOLConvertingOutputStream;
import com.fsck.k9.mail.internet.MimeBodyPart;
import com.fsck.k9.mail.internet.MimeHeader;
import com.fsck.k9.mail.internet.MimeMessageHelper;
import com.fsck.k9.mail.internet.MimeMultipart;
import com.fsck.k9.mail.internet.MimeUtility;
import timber.log.Timber;
import static com.fsck.k9.mail.store.imap.ImapUtility.getLastResponse;
class ImapFolder extends Folder<ImapMessage> {
private static final ThreadLocal<SimpleDateFormat> RFC3501_DATE = new ThreadLocal<SimpleDateFormat>() {
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("dd-MMM-yyyy", Locale.US);
}
};
private static final int MORE_MESSAGES_WINDOW_SIZE = 500;
private static final int FETCH_WINDOW_SIZE = 100;
protected volatile int messageCount = -1;
protected volatile long uidNext = -1L;
protected volatile ImapConnection connection;
protected ImapStore store = null;
protected Map<Long, String> msgSeqUidMap = new ConcurrentHashMap<Long, String>();
private final FolderNameCodec folderNameCodec;
private final String name;
private int mode;
private volatile boolean exists;
private boolean inSearch = false;
private boolean canCreateKeywords = false;
public ImapFolder(ImapStore store, String name) {
this(store, name, store.getFolderNameCodec());
}
ImapFolder(ImapStore store, String name, FolderNameCodec folderNameCodec) {
super();
this.store = store;
this.name = name;
this.folderNameCodec = folderNameCodec;
}
private String getPrefixedName() throws MessagingException {
String prefixedName = "";
if (!store.getStoreConfig().getInboxFolderName().equalsIgnoreCase(name)) {
ImapConnection connection;
synchronized (this) {
if (this.connection == null) {
connection = store.getConnection();
} else {
connection = this.connection;
}
}
try {
connection.open();
} catch (IOException ioe) {
throw new MessagingException("Unable to get IMAP prefix", ioe);
} finally {
if (this.connection == null) {
store.releaseConnection(connection);
}
}
prefixedName = store.getCombinedPrefix();
}
prefixedName += name;
return prefixedName;
}
private List<ImapResponse> executeSimpleCommand(String command) throws MessagingException, IOException {
return handleUntaggedResponses(connection.executeSimpleCommand(command));
}
@Override
public void open(int mode) throws MessagingException {
internalOpen(mode);
if (messageCount == -1) {
throw new MessagingException("Did not find message count during open");
}
}
protected List<ImapResponse> internalOpen(int mode) throws MessagingException {
if (isOpen() && this.mode == mode) {
// Make sure the connection is valid. If it's not we'll close it down and continue
// on to get a new one.
try {
return executeSimpleCommand(Commands.NOOP);
} catch (IOException ioe) {
/* don't throw */ ioExceptionHandler(connection, ioe);
}
}
store.releaseConnection(connection);
synchronized (this) {
connection = store.getConnection();
}
try {
msgSeqUidMap.clear();
String openCommand = mode == OPEN_MODE_RW ? "SELECT" : "EXAMINE";
String encodedFolderName = folderNameCodec.encode(getPrefixedName());
String escapedFolderName = ImapUtility.encodeString(encodedFolderName);
String command = String.format("%s %s", openCommand, escapedFolderName);
List<ImapResponse> responses = executeSimpleCommand(command);
/*
* If the command succeeds we expect the folder has been opened read-write unless we
* are notified otherwise in the responses.
*/
this.mode = mode;
for (ImapResponse response : responses) {
handlePermanentFlags(response);
}
handleSelectOrExamineOkResponse(getLastResponse(responses));
exists = true;
return responses;
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
} catch (MessagingException me) {
Timber.e(me, "Unable to open connection for %s", getLogId());
throw me;
}
}
private void handlePermanentFlags(ImapResponse response) {
PermanentFlagsResponse permanentFlagsResponse = PermanentFlagsResponse.parse(response);
if (permanentFlagsResponse == null) {
return;
}
Set<Flag> permanentFlags = store.getPermanentFlagsIndex();
permanentFlags.addAll(permanentFlagsResponse.getFlags());
canCreateKeywords = permanentFlagsResponse.canCreateKeywords();
}
private void handleSelectOrExamineOkResponse(ImapResponse response) {
SelectOrExamineResponse selectOrExamineResponse = SelectOrExamineResponse.parse(response);
if (selectOrExamineResponse == null) {
// This shouldn't happen
return;
}
if (selectOrExamineResponse.hasOpenMode()) {
mode = selectOrExamineResponse.getOpenMode();
}
}
@Override
public boolean isOpen() {
return connection != null;
}
@Override
public int getMode() {
return mode;
}
@Override
public void close() {
messageCount = -1;
if (!isOpen()) {
return;
}
synchronized (this) {
// If we are mid-search and we get a close request, we gotta trash the connection.
if (inSearch && connection != null) {
Timber.i("IMAP search was aborted, shutting down connection.");
connection.close();
} else {
store.releaseConnection(connection);
}
connection = null;
}
}
@Override
public String getName() {
return name;
}
private boolean exists(String escapedFolderName) throws MessagingException {
try {
// Since we don't care about RECENT, we'll use that for the check, because we're checking
// a folder other than ourself, and don't want any untagged responses to cause a change
// in our own fields
connection.executeSimpleCommand(String.format("STATUS %s (RECENT)", escapedFolderName));
return true;
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
} catch (NegativeImapResponseException e) {
return false;
}
}
@Override
public boolean exists() throws MessagingException {
if (exists) {
return true;
}
/*
* This method needs to operate in the unselected mode as well as the selected mode
* so we must get the connection ourselves if it's not there. We are specifically
* not calling checkOpen() since we don't care if the folder is open.
*/
ImapConnection connection;
synchronized (this) {
if (this.connection == null) {
connection = store.getConnection();
} else {
connection = this.connection;
}
}
try {
String encodedFolderName = folderNameCodec.encode(getPrefixedName());
String escapedFolderName = ImapUtility.encodeString(encodedFolderName);
connection.executeSimpleCommand(String.format("STATUS %s (UIDVALIDITY)", escapedFolderName));
exists = true;
return true;
} catch (NegativeImapResponseException e) {
return false;
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
} finally {
if (this.connection == null) {
store.releaseConnection(connection);
}
}
}
@Override
public boolean create(FolderType type) throws MessagingException {
/*
* This method needs to operate in the unselected mode as well as the selected mode
* so we must get the connection ourselves if it's not there. We are specifically
* not calling checkOpen() since we don't care if the folder is open.
*/
ImapConnection connection;
synchronized (this) {
if (this.connection == null) {
connection = store.getConnection();
} else {
connection = this.connection;
}
}
try {
String encodedFolderName = folderNameCodec.encode(getPrefixedName());
String escapedFolderName = ImapUtility.encodeString(encodedFolderName);
connection.executeSimpleCommand(String.format("CREATE %s", escapedFolderName));
return true;
} catch (NegativeImapResponseException e) {
return false;
} catch (IOException ioe) {
throw ioExceptionHandler(this.connection, ioe);
} finally {
if (this.connection == null) {
store.releaseConnection(connection);
}
}
}
/**
* Copies the given messages to the specified folder.
*
* <p>
* <strong>Note:</strong>
* Only the UIDs of the given {@link Message} instances are used. It is assumed that all
* UIDs represent valid messages in this folder.
* </p>
*
* @param messages
* The messages to copy to the specified folder.
* @param folder
* The name of the target folder.
*
* @return The mapping of original message UIDs to the new server UIDs.
*/
@Override
public Map<String, String> copyMessages(List<? extends Message> messages, Folder folder) throws MessagingException {
if (!(folder instanceof ImapFolder)) {
throw new MessagingException("ImapFolder.copyMessages passed non-ImapFolder");
}
if (messages.isEmpty()) {
return null;
}
ImapFolder imapFolder = (ImapFolder) folder;
checkOpen(); //only need READ access
String[] uids = new String[messages.size()];
for (int i = 0, count = messages.size(); i < count; i++) {
uids[i] = messages.get(i).getUid();
}
try {
String encodedDestinationFolderName = folderNameCodec.encode(imapFolder.getPrefixedName());
String escapedDestinationFolderName = ImapUtility.encodeString(encodedDestinationFolderName);
//TODO: Try to copy/move the messages first and only create the folder if the
// operation fails. This will save a roundtrip if the folder already exists.
if (!exists(escapedDestinationFolderName)) {
if (K9MailLib.isDebug()) {
Timber.i("ImapFolder.copyMessages: attempting to create remote folder '%s' for %s",
escapedDestinationFolderName, getLogId());
}
imapFolder.create(FolderType.HOLDS_MESSAGES);
}
//TODO: Split this into multiple commands if the command exceeds a certain length.
List<ImapResponse> responses = executeSimpleCommand(String.format("UID COPY %s %s",
combine(uids, ','), escapedDestinationFolderName));
// Get the tagged response for the UID COPY command
ImapResponse response = getLastResponse(responses);
CopyUidResponse copyUidResponse = CopyUidResponse.parse(response);
if (copyUidResponse == null) {
return null;
}
return copyUidResponse.getUidMapping();
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
@Override
public Map<String, String> moveMessages(List<? extends Message> messages, Folder folder) throws MessagingException {
if (messages.isEmpty()) {
return null;
}
Map<String, String> uidMapping = copyMessages(messages, folder);
setFlags(messages, Collections.singleton(Flag.DELETED), true);
return uidMapping;
}
@Override
public void delete(List<? extends Message> messages, String trashFolderName) throws MessagingException {
if (messages.isEmpty()) {
return;
}
if (trashFolderName == null || getName().equalsIgnoreCase(trashFolderName)) {
setFlags(messages, Collections.singleton(Flag.DELETED), true);
} else {
ImapFolder remoteTrashFolder = getStore().getFolder(trashFolderName);
String encodedTrashFolderName = folderNameCodec.encode(remoteTrashFolder.getPrefixedName());
String escapedTrashFolderName = ImapUtility.encodeString(encodedTrashFolderName);
if (!exists(escapedTrashFolderName)) {
if (K9MailLib.isDebug()) {
Timber.i("IMAPMessage.delete: attempting to create remote '%s' folder for %s",
trashFolderName, getLogId());
}
remoteTrashFolder.create(FolderType.HOLDS_MESSAGES);
}
if (exists(escapedTrashFolderName)) {
if (K9MailLib.isDebug()) {
Timber.d("IMAPMessage.delete: copying remote %d messages to '%s' for %s",
messages.size(), trashFolderName, getLogId());
}
moveMessages(messages, remoteTrashFolder);
} else {
throw new MessagingException("IMAPMessage.delete: remote Trash folder " + trashFolderName +
" does not exist and could not be created for " + getLogId(), true);
}
}
}
@Override
public int getMessageCount() {
return messageCount;
}
private int getRemoteMessageCount(String criteria) throws MessagingException {
checkOpen();
try {
int count = 0;
int start = 1;
String command = String.format(Locale.US, "SEARCH %d:* %s", start, criteria);
List<ImapResponse> responses = executeSimpleCommand(command);
for (ImapResponse response : responses) {
if (ImapResponseParser.equalsIgnoreCase(response.get(0), "SEARCH")) {
count += response.size() - 1;
}
}
return count;
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
@Override
public int getUnreadMessageCount() throws MessagingException {
return getRemoteMessageCount("UNSEEN NOT DELETED");
}
@Override
public int getFlaggedMessageCount() throws MessagingException {
return getRemoteMessageCount("FLAGGED NOT DELETED");
}
protected long getHighestUid() throws MessagingException {
try {
String command = "UID SEARCH *:*";
List<ImapResponse> responses = executeSimpleCommand(command);
SearchResponse searchResponse = SearchResponse.parse(responses);
return extractHighestUid(searchResponse);
} catch (NegativeImapResponseException e) {
return -1L;
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
private long extractHighestUid(SearchResponse searchResponse) {
List<Long> uids = searchResponse.getNumbers();
if (uids.isEmpty()) {
return -1L;
}
if (uids.size() == 1) {
return uids.get(0);
}
Collections.sort(uids, Collections.reverseOrder());
return uids.get(0);
}
@Override
public void delete(boolean recurse) throws MessagingException {
throw new Error("ImapFolder.delete() not yet implemented");
}
@Override
public ImapMessage getMessage(String uid) throws MessagingException {
return new ImapMessage(uid, this);
}
@Override
public List<ImapMessage> getMessages(int start, int end, Date earliestDate,
MessageRetrievalListener<ImapMessage> listener) throws MessagingException {
return getMessages(start, end, earliestDate, false, listener);
}
protected List<ImapMessage> getMessages(final int start, final int end, Date earliestDate,
final boolean includeDeleted, final MessageRetrievalListener<ImapMessage> listener)
throws MessagingException {
if (start < 1 || end < 1 || end < start) {
throw new MessagingException(String.format(Locale.US, "Invalid message set %d %d", start, end));
}
final String dateSearchString = getDateSearchString(earliestDate);
ImapSearcher searcher = new ImapSearcher() {
@Override
public List<ImapResponse> search() throws IOException, MessagingException {
String command = String.format(Locale.US, "UID SEARCH %d:%d%s%s", start, end, dateSearchString,
includeDeleted ? "" : " NOT DELETED");
return executeSimpleCommand(command);
}
};
return search(searcher, listener);
}
private String getDateSearchString(Date earliestDate) {
if (earliestDate == null) {
return "";
}
return " SINCE " + RFC3501_DATE.get().format(earliestDate);
}
@Override
public boolean areMoreMessagesAvailable(int indexOfOldestMessage, Date earliestDate) throws IOException,
MessagingException {
checkOpen();
if (indexOfOldestMessage == 1) {
return false;
}
String dateSearchString = getDateSearchString(earliestDate);
int endIndex = indexOfOldestMessage - 1;
while (endIndex > 0) {
int startIndex = Math.max(0, endIndex - MORE_MESSAGES_WINDOW_SIZE) + 1;
if (existsNonDeletedMessageInRange(startIndex, endIndex, dateSearchString)) {
return true;
}
endIndex = endIndex - MORE_MESSAGES_WINDOW_SIZE;
}
return false;
}
private boolean existsNonDeletedMessageInRange(int startIndex, int endIndex, String dateSearchString)
throws MessagingException, IOException {
String command = String.format(Locale.US, "SEARCH %d:%d%s NOT DELETED", startIndex, endIndex, dateSearchString);
List<ImapResponse> responses = executeSimpleCommand(command);
for (ImapResponse response : responses) {
if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(0), "SEARCH")) {
if (response.size() > 1) {
return true;
}
}
}
return false;
}
protected List<ImapMessage> getMessages(final List<Long> mesgSeqs, final boolean includeDeleted,
final MessageRetrievalListener<ImapMessage> listener) throws MessagingException {
ImapSearcher searcher = new ImapSearcher() {
@Override
public List<ImapResponse> search() throws IOException, MessagingException {
String command = String.format("UID SEARCH %s%s", combine(mesgSeqs.toArray(), ','),
includeDeleted ? "" : " NOT DELETED");
return executeSimpleCommand(command);
}
};
return search(searcher, listener);
}
protected List<ImapMessage> getMessagesFromUids(final List<String> mesgUids) throws MessagingException {
ImapSearcher searcher = new ImapSearcher() {
@Override
public List<ImapResponse> search() throws IOException, MessagingException {
String command = String.format("UID SEARCH UID %s", combine(mesgUids.toArray(), ','));
return executeSimpleCommand(command);
}
};
return search(searcher, null);
}
private List<ImapMessage> search(ImapSearcher searcher, MessageRetrievalListener<ImapMessage> listener)
throws MessagingException {
checkOpen();
List<ImapMessage> messages = new ArrayList<>();
try {
List<ImapResponse> responses = searcher.search();
SearchResponse searchResponse = SearchResponse.parse(responses);
List<Long> uids = searchResponse.getNumbers();
// Sort the uids in numerically decreasing order
// By doing it in decreasing order, we ensure newest messages are dealt with first
// This makes the most sense when a limit is imposed, and also prevents UI from going
// crazy adding stuff at the top.
Collections.sort(uids, Collections.reverseOrder());
for (int i = 0, count = uids.size(); i < count; i++) {
String uid = uids.get(i).toString();
if (listener != null) {
listener.messageStarted(uid, i, count);
}
ImapMessage message = new ImapMessage(uid, this);
messages.add(message);
if (listener != null) {
listener.messageFinished(message, i, count);
}
}
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
return messages;
}
@Override
public void fetch(List<ImapMessage> messages, FetchProfile fetchProfile,
MessageRetrievalListener<ImapMessage> listener) throws MessagingException {
if (messages == null || messages.isEmpty()) {
return;
}
checkOpen();
List<String> uids = new ArrayList<>(messages.size());
HashMap<String, Message> messageMap = new HashMap<>();
for (Message message : messages) {
String uid = message.getUid();
uids.add(uid);
messageMap.put(uid, message);
}
Set<String> fetchFields = new LinkedHashSet<>();
fetchFields.add("UID");
if (fetchProfile.contains(FetchProfile.Item.FLAGS)) {
fetchFields.add("FLAGS");
}
if (fetchProfile.contains(FetchProfile.Item.ENVELOPE)) {
fetchFields.add("INTERNALDATE");
fetchFields.add("RFC822.SIZE");
fetchFields.add("BODY.PEEK[HEADER.FIELDS (date subject from content-type to cc " +
"reply-to message-id references in-reply-to " + K9MailLib.IDENTITY_HEADER + ")]");
}
if (fetchProfile.contains(FetchProfile.Item.STRUCTURE)) {
fetchFields.add("BODYSTRUCTURE");
}
if (fetchProfile.contains(FetchProfile.Item.BODY_SANE)) {
int maximumAutoDownloadMessageSize = store.getStoreConfig().getMaximumAutoDownloadMessageSize();
if (maximumAutoDownloadMessageSize > 0) {
fetchFields.add(String.format(Locale.US, "BODY.PEEK[]<0.%d>", maximumAutoDownloadMessageSize));
} else {
fetchFields.add("BODY.PEEK[]");
}
}
if (fetchProfile.contains(FetchProfile.Item.BODY)) {
fetchFields.add("BODY.PEEK[]");
}
String spaceSeparatedFetchFields = combine(fetchFields.toArray(new String[fetchFields.size()]), ' ');
for (int windowStart = 0; windowStart < messages.size(); windowStart += (FETCH_WINDOW_SIZE)) {
int windowEnd = Math.min(windowStart + FETCH_WINDOW_SIZE, messages.size());
List<String> uidWindow = uids.subList(windowStart, windowEnd);
try {
String commaSeparatedUids = combine(uidWindow.toArray(new String[uidWindow.size()]), ',');
String command = String.format("UID FETCH %s (%s)", commaSeparatedUids, spaceSeparatedFetchFields);
connection.sendCommand(command, false);
ImapResponse response;
int messageNumber = 0;
ImapResponseCallback callback = null;
if (fetchProfile.contains(FetchProfile.Item.BODY) ||
fetchProfile.contains(FetchProfile.Item.BODY_SANE)) {
callback = new FetchBodyCallback(messageMap);
}
do {
response = connection.readResponse(callback);
if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(1), "FETCH")) {
ImapList fetchList = (ImapList) response.getKeyedValue("FETCH");
String uid = fetchList.getKeyedString("UID");
long msgSeq = response.getLong(0);
if (uid != null) {
try {
msgSeqUidMap.put(msgSeq, uid);
if (K9MailLib.isDebug()) {
Timber.v("Stored uid '%s' for msgSeq %d into map", uid, msgSeq);
}
} catch (Exception e) {
Timber.e("Unable to store uid '%s' for msgSeq %d", uid, msgSeq);
}
}
Message message = messageMap.get(uid);
if (message == null) {
if (K9MailLib.isDebug()) {
Timber.d("Do not have message in messageMap for UID %s for %s", uid, getLogId());
}
handleUntaggedResponse(response);
continue;
}
if (listener != null) {
listener.messageStarted(uid, messageNumber++, messageMap.size());
}
ImapMessage imapMessage = (ImapMessage) message;
Object literal = handleFetchResponse(imapMessage, fetchList);
if (literal != null) {
if (literal instanceof String) {
String bodyString = (String) literal;
InputStream bodyStream = new ByteArrayInputStream(bodyString.getBytes());
imapMessage.parse(bodyStream);
} else if (literal instanceof Integer) {
// All the work was done in FetchBodyCallback.foundLiteral()
} else {
// This shouldn't happen
throw new MessagingException("Got FETCH response with bogus parameters");
}
}
if (listener != null) {
listener.messageFinished(imapMessage, messageNumber, messageMap.size());
}
} else {
handleUntaggedResponse(response);
}
} while (response.getTag() == null);
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
}
@Override
public void fetchPart(Message message, Part part, MessageRetrievalListener<Message> listener,
BodyFactory bodyFactory) throws MessagingException {
checkOpen();
String partId = part.getServerExtra();
String fetch;
if ("TEXT".equalsIgnoreCase(partId)) {
int maximumAutoDownloadMessageSize = store.getStoreConfig().getMaximumAutoDownloadMessageSize();
fetch = String.format(Locale.US, "BODY.PEEK[TEXT]<0.%d>", maximumAutoDownloadMessageSize);
} else {
fetch = String.format("BODY.PEEK[%s]", partId);
}
try {
String command = String.format("UID FETCH %s (UID %s)", message.getUid(), fetch);
connection.sendCommand(command, false);
ImapResponse response;
int messageNumber = 0;
ImapResponseCallback callback = new FetchPartCallback(part, bodyFactory);
do {
response = connection.readResponse(callback);
if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(1), "FETCH")) {
ImapList fetchList = (ImapList) response.getKeyedValue("FETCH");
String uid = fetchList.getKeyedString("UID");
if (!message.getUid().equals(uid)) {
if (K9MailLib.isDebug()) {
Timber.d("Did not ask for UID %s for %s", uid, getLogId());
}
handleUntaggedResponse(response);
continue;
}
if (listener != null) {
listener.messageStarted(uid, messageNumber++, 1);
}
ImapMessage imapMessage = (ImapMessage) message;
Object literal = handleFetchResponse(imapMessage, fetchList);
if (literal != null) {
if (literal instanceof Body) {
// Most of the work was done in FetchAttachmentCallback.foundLiteral()
MimeMessageHelper.setBody(part, (Body) literal);
} else if (literal instanceof String) {
String bodyString = (String) literal;
InputStream bodyStream = new ByteArrayInputStream(bodyString.getBytes());
String contentTransferEncoding =
part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)[0];
String contentType = part.getHeader(MimeHeader.HEADER_CONTENT_TYPE)[0];
Body body = bodyFactory.createBody(contentTransferEncoding, contentType, bodyStream);
MimeMessageHelper.setBody(part, body);
} else {
// This shouldn't happen
throw new MessagingException("Got FETCH response with bogus parameters");
}
}
if (listener != null) {
listener.messageFinished(message, messageNumber, 1);
}
} else {
handleUntaggedResponse(response);
}
} while (response.getTag() == null);
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
// Returns value of body field
private Object handleFetchResponse(ImapMessage message, ImapList fetchList) throws MessagingException {
Object result = null;
if (fetchList.containsKey("FLAGS")) {
ImapList flags = fetchList.getKeyedList("FLAGS");
if (flags != null) {
for (int i = 0, count = flags.size(); i < count; i++) {
String flag = flags.getString(i);
if (flag.equalsIgnoreCase("\\Deleted")) {
message.setFlagInternal(Flag.DELETED, true);
} else if (flag.equalsIgnoreCase("\\Answered")) {
message.setFlagInternal(Flag.ANSWERED, true);
} else if (flag.equalsIgnoreCase("\\Seen")) {
message.setFlagInternal(Flag.SEEN, true);
} else if (flag.equalsIgnoreCase("\\Flagged")) {
message.setFlagInternal(Flag.FLAGGED, true);
} else if (flag.equalsIgnoreCase("$Forwarded")) {
message.setFlagInternal(Flag.FORWARDED, true);
/* a message contains FORWARDED FLAG -> so we can also create them */
store.getPermanentFlagsIndex().add(Flag.FORWARDED);
}
}
}
}
if (fetchList.containsKey("INTERNALDATE")) {
Date internalDate = fetchList.getKeyedDate("INTERNALDATE");
message.setInternalDate(internalDate);
}
if (fetchList.containsKey("RFC822.SIZE")) {
int size = fetchList.getKeyedNumber("RFC822.SIZE");
message.setSize(size);
}
if (fetchList.containsKey("BODYSTRUCTURE")) {
ImapList bs = fetchList.getKeyedList("BODYSTRUCTURE");
if (bs != null) {
try {
parseBodyStructure(bs, message, "TEXT");
} catch (MessagingException e) {
if (K9MailLib.isDebug()) {
Timber.d(e, "Error handling message for %s", getLogId());
}
message.setBody(null);
}
}
}
if (fetchList.containsKey("BODY")) {
int index = fetchList.getKeyIndex("BODY") + 2;
int size = fetchList.size();
if (index < size) {
result = fetchList.getObject(index);
// Check if there's an origin octet
if (result instanceof String) {
String originOctet = (String) result;
if (originOctet.startsWith("<") && (index + 1) < size) {
result = fetchList.getObject(index + 1);
}
}
}
}
return result;
}
protected List<ImapResponse> handleUntaggedResponses(List<ImapResponse> responses) {
for (ImapResponse response : responses) {
handleUntaggedResponse(response);
}
return responses;
}
protected void handlePossibleUidNext(ImapResponse response) {
if (ImapResponseParser.equalsIgnoreCase(response.get(0), "OK") && response.size() > 1) {
Object bracketedObj = response.get(1);
if (bracketedObj instanceof ImapList) {
ImapList bracketed = (ImapList) bracketedObj;
if (bracketed.size() > 1) {
Object keyObj = bracketed.get(0);
if (keyObj instanceof String) {
String key = (String) keyObj;
if ("UIDNEXT".equalsIgnoreCase(key)) {
uidNext = bracketed.getLong(1);
if (K9MailLib.isDebug()) {
Timber.d("Got UidNext = %s for %s", uidNext, getLogId());
}
}
}
}
}
}
}
/**
* Handle an untagged response that the caller doesn't care to handle themselves.
*/
protected void handleUntaggedResponse(ImapResponse response) {
if (response.getTag() == null && response.size() > 1) {
if (ImapResponseParser.equalsIgnoreCase(response.get(1), "EXISTS")) {
messageCount = response.getNumber(0);
if (K9MailLib.isDebug()) {
Timber.d("Got untagged EXISTS with value %d for %s", messageCount, getLogId());
}
}
handlePossibleUidNext(response);
if (ImapResponseParser.equalsIgnoreCase(response.get(1), "EXPUNGE") && messageCount > 0) {
messageCount--;
if (K9MailLib.isDebug()) {
Timber.d("Got untagged EXPUNGE with messageCount %d for %s", messageCount, getLogId());
}
}
}
}
private void parseBodyStructure(ImapList bs, Part part, String id) throws MessagingException {
if (bs.get(0) instanceof ImapList) {
/*
* This is a multipart/*
*/
MimeMultipart mp = MimeMultipart.newInstance();
for (int i = 0, count = bs.size(); i < count; i++) {
if (bs.get(i) instanceof ImapList) {
/*
* For each part in the message we're going to add a new BodyPart and parse
* into it.
*/
MimeBodyPart bp = new MimeBodyPart();
if (id.equalsIgnoreCase("TEXT")) {
parseBodyStructure(bs.getList(i), bp, Integer.toString(i + 1));
} else {
parseBodyStructure(bs.getList(i), bp, id + "." + (i + 1));
}
mp.addBodyPart(bp);
} else {
/*
* We've got to the end of the children of the part, so now we can find out
* what type it is and bail out.
*/
String subType = bs.getString(i);
mp.setSubType(subType.toLowerCase(Locale.US));
break;
}
}
MimeMessageHelper.setBody(part, mp);
} else {
/*
* This is a body. We need to add as much information as we can find out about
* it to the Part.
*/
/*
* 0| 0 body type
* 1| 1 body subtype
* 2| 2 body parameter parenthesized list
* 3| 3 body id (unused)
* 4| 4 body description (unused)
* 5| 5 body encoding
* 6| 6 body size
* -| 7 text lines (only for type TEXT, unused)
* Extensions (optional):
* 7| 8 body MD5 (unused)
* 8| 9 body disposition
* 9|10 body language (unused)
* 10|11 body location (unused)
*/
String type = bs.getString(0);
String subType = bs.getString(1);
String mimeType = (type + "/" + subType).toLowerCase(Locale.US);
ImapList bodyParams = null;
if (bs.get(2) instanceof ImapList) {
bodyParams = bs.getList(2);
}
String encoding = bs.getString(5);
int size = bs.getNumber(6);
if (MimeUtility.isMessage(mimeType)) {
// A body type of type MESSAGE and subtype RFC822
// contains, immediately after the basic fields, the
// envelope structure, body structure, and size in
// text lines of the encapsulated message.
// [MESSAGE, RFC822, [NAME, Fwd: [#HTR-517941]: update plans at 1am Friday - Memory allocation - displayware.eml], NIL, NIL, 7BIT, 5974, NIL, [INLINE, [FILENAME*0, Fwd: [#HTR-517941]: update plans at 1am Friday - Memory all, FILENAME*1, ocation - displayware.eml]], NIL]
/*
* This will be caught by fetch and handled appropriately.
*/
throw new MessagingException("BODYSTRUCTURE message/rfc822 not yet supported.");
}
/*
* Set the content type with as much information as we know right now.
*/
StringBuilder contentType = new StringBuilder();
contentType.append(mimeType);
if (bodyParams != null) {
/*
* If there are body params we might be able to get some more information out
* of them.
*/
for (int i = 0, count = bodyParams.size(); i < count; i += 2) {
String paramName = bodyParams.getString(i);
String paramValue = bodyParams.getString(i + 1);
contentType.append(String.format(";\r\n %s=\"%s\"", paramName, paramValue));
}
}
part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType.toString());
// Extension items
ImapList bodyDisposition = null;
if ("text".equalsIgnoreCase(type) && bs.size() > 9 && bs.get(9) instanceof ImapList) {
bodyDisposition = bs.getList(9);
} else if (!("text".equalsIgnoreCase(type)) && bs.size() > 8 && bs.get(8) instanceof ImapList) {
bodyDisposition = bs.getList(8);
}
StringBuilder contentDisposition = new StringBuilder();
if (bodyDisposition != null && !bodyDisposition.isEmpty()) {
if (!"NIL".equalsIgnoreCase(bodyDisposition.getString(0))) {
contentDisposition.append(bodyDisposition.getString(0).toLowerCase(Locale.US));
}
if (bodyDisposition.size() > 1 && bodyDisposition.get(1) instanceof ImapList) {
ImapList bodyDispositionParams = bodyDisposition.getList(1);
/*
* If there is body disposition information we can pull some more information
* about the attachment out.
*/
for (int i = 0, count = bodyDispositionParams.size(); i < count; i += 2) {
String paramName = bodyDispositionParams.getString(i).toLowerCase(Locale.US);
String paramValue = bodyDispositionParams.getString(i + 1);
contentDisposition.append(String.format(";\r\n %s=\"%s\"", paramName, paramValue));
}
}
}
if (MimeUtility.getHeaderParameter(contentDisposition.toString(), "size") == null) {
contentDisposition.append(String.format(Locale.US, ";\r\n size=%d", size));
}
/*
* Set the content disposition containing at least the size. Attachment
* handling code will use this down the road.
*/
part.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, contentDisposition.toString());
/*
* Set the Content-Transfer-Encoding header. Attachment code will use this
* to parse the body.
*/
part.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, encoding);
if (part instanceof ImapMessage) {
((ImapMessage) part).setSize(size);
}
part.setServerExtra(id);
}
}
/**
* Appends the given messages to the selected folder.
*
* <p>
* This implementation also determines the new UIDs of the given messages on the IMAP
* server and changes the messages' UIDs to the new server UIDs.
* </p>
*
* @param messages
* The messages to append to the folder.
*
* @return The mapping of original message UIDs to the new server UIDs.
*/
@Override
public Map<String, String> appendMessages(List<? extends Message> messages) throws MessagingException {
open(OPEN_MODE_RW);
checkOpen();
try {
Map<String, String> uidMap = new HashMap<>();
for (Message message : messages) {
long messageSize = message.calculateSize();
String encodeFolderName = folderNameCodec.encode(getPrefixedName());
String escapedFolderName = ImapUtility.encodeString(encodeFolderName);
String command = String.format(Locale.US, "APPEND %s (%s) {%d}", escapedFolderName,
combineFlags(message.getFlags()), messageSize);
connection.sendCommand(command, false);
ImapResponse response;
do {
response = connection.readResponse();
handleUntaggedResponse(response);
if (response.isContinuationRequested()) {
EOLConvertingOutputStream eolOut = new EOLConvertingOutputStream(connection.getOutputStream());
message.writeTo(eolOut);
eolOut.write('\r');
eolOut.write('\n');
eolOut.flush();
}
} while (response.getTag() == null);
if (response.size() > 1) {
/*
* If the server supports UIDPLUS, then along with the APPEND response it
* will return an APPENDUID response code, e.g.
*
* 11 OK [APPENDUID 2 238268] APPEND completed
*
* We can use the UID included in this response to update our records.
*/
Object responseList = response.get(1);
if (responseList instanceof ImapList) {
ImapList appendList = (ImapList) responseList;
if (appendList.size() >= 3 && appendList.getString(0).equals("APPENDUID")) {
String newUid = appendList.getString(2);
if (!TextUtils.isEmpty(newUid)) {
message.setUid(newUid);
uidMap.put(message.getUid(), newUid);
continue;
}
}
}
}
/*
* This part is executed in case the server does not support UIDPLUS or does
* not implement the APPENDUID response code.
*/
String newUid = getUidFromMessageId(message);
if (K9MailLib.isDebug()) {
Timber.d("Got UID %s for message for %s", newUid, getLogId());
}
if (!TextUtils.isEmpty(newUid)) {
uidMap.put(message.getUid(), newUid);
message.setUid(newUid);
}
}
/*
* We need uidMap to be null if new UIDs are not available to maintain consistency
* with the behavior of other similar methods (copyMessages, moveMessages) which
* return null.
*/
return (uidMap.isEmpty()) ? null : uidMap;
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
@Override
public String getUidFromMessageId(Message message) throws MessagingException {
try {
/*
* Try to find the UID of the message we just appended using the
* Message-ID header.
*/
String[] messageIdHeader = message.getHeader("Message-ID");
if (messageIdHeader.length == 0) {
if (K9MailLib.isDebug()) {
Timber.d("Did not get a message-id in order to search for UID for %s", getLogId());
}
return null;
}
String messageId = messageIdHeader[0];
if (K9MailLib.isDebug()) {
Timber.d("Looking for UID for message with message-id %s for %s", messageId, getLogId());
}
String command = String.format("UID SEARCH HEADER MESSAGE-ID %s", ImapUtility.encodeString(messageId));
List<ImapResponse> responses = executeSimpleCommand(command);
for (ImapResponse response : responses) {
if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(0), "SEARCH")
&& response.size() > 1) {
return response.getString(1);
}
}
return null;
} catch (IOException ioe) {
throw new MessagingException("Could not find UID for message based on Message-ID", ioe);
}
}
@Override
public void expunge() throws MessagingException {
open(OPEN_MODE_RW);
checkOpen();
try {
executeSimpleCommand("EXPUNGE");
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
private String combineFlags(Iterable<Flag> flags) {
List<String> flagNames = new ArrayList<String>();
for (Flag flag : flags) {
if (flag == Flag.SEEN) {
flagNames.add("\\Seen");
} else if (flag == Flag.DELETED) {
flagNames.add("\\Deleted");
} else if (flag == Flag.ANSWERED) {
flagNames.add("\\Answered");
} else if (flag == Flag.FLAGGED) {
flagNames.add("\\Flagged");
} else if (flag == Flag.FORWARDED
&& (canCreateKeywords || store.getPermanentFlagsIndex().contains(Flag.FORWARDED))) {
flagNames.add("$Forwarded");
}
}
return combine(flagNames.toArray(new String[flagNames.size()]), ' ');
}
@Override
public void setFlags(Set<Flag> flags, boolean value) throws MessagingException {
open(OPEN_MODE_RW);
checkOpen();
try {
String command = String.format("UID STORE 1:* %sFLAGS.SILENT (%s)", value ? "+" : "-", combineFlags(flags));
executeSimpleCommand(command);
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
@Override
public String getNewPushState(String oldSerializedPushState, Message message) {
try {
String uid = message.getUid();
long messageUid = Long.parseLong(uid);
ImapPushState oldPushState = ImapPushState.parse(oldSerializedPushState);
if (messageUid >= oldPushState.uidNext) {
long uidNext = messageUid + 1;
ImapPushState newPushState = new ImapPushState(uidNext);
return newPushState.toString();
} else {
return null;
}
} catch (Exception e) {
Timber.e(e, "Exception while updated push state for %s", getLogId());
return null;
}
}
@Override
public void setFlags(List<? extends Message> messages, final Set<Flag> flags, boolean value)
throws MessagingException {
open(OPEN_MODE_RW);
checkOpen();
String[] uids = new String[messages.size()];
for (int i = 0, count = messages.size(); i < count; i++) {
uids[i] = messages.get(i).getUid();
}
try {
String command = String.format("UID STORE %s %sFLAGS.SILENT (%s)", combine(uids, ','), value ? "+" : "-",
combineFlags(flags));
executeSimpleCommand(command);
} catch (IOException ioe) {
throw ioExceptionHandler(connection, ioe);
}
}
private void checkOpen() throws MessagingException {
if (!isOpen()) {
throw new MessagingException("Folder " + getPrefixedName() + " is not open.");
}
}
private MessagingException ioExceptionHandler(ImapConnection connection, IOException ioe) {
Timber.e(ioe, "IOException for %s", getLogId());
if (connection != null) {
connection.close();
}
close();
return new MessagingException("IO Error", ioe);
}
@Override
public boolean equals(Object other) {
if (other instanceof ImapFolder) {
ImapFolder otherFolder = (ImapFolder) other;
return otherFolder.getName().equalsIgnoreCase(getName());
}
return super.equals(other);
}
@Override
public int hashCode() {
return getName().hashCode();
}
private ImapStore getStore() {
return store;
}
protected String getLogId() {
String id = store.getStoreConfig().toString() + ":" + getName() + "/" + Thread.currentThread().getName();
if (connection != null) {
id += "/" + connection.getLogId();
}
return id;
}
/**
* Search the remote ImapFolder.
* @param queryString String to query for.
* @param requiredFlags Mandatory flags
* @param forbiddenFlags Flags to exclude
* @return List of messages found
* @throws MessagingException On any error.
*/
@Override
public List<ImapMessage> search(final String queryString, final Set<Flag> requiredFlags,
final Set<Flag> forbiddenFlags) throws MessagingException {
if (!store.getStoreConfig().allowRemoteSearch()) {
throw new MessagingException("Your settings do not allow remote searching of this account");
}
// Setup the searcher
final ImapSearcher searcher = new ImapSearcher() {
@Override
public List<ImapResponse> search() throws IOException, MessagingException {
String imapQuery = "UID SEARCH ";
if (requiredFlags != null) {
for (Flag flag : requiredFlags) {
switch (flag) {
case DELETED: {
imapQuery += "DELETED ";
break;
}
case SEEN: {
imapQuery += "SEEN ";
break;
}
case ANSWERED: {
imapQuery += "ANSWERED ";
break;
}
case FLAGGED: {
imapQuery += "FLAGGED ";
break;
}
case DRAFT: {
imapQuery += "DRAFT ";
break;
}
case RECENT: {
imapQuery += "RECENT ";
break;
}
default: {
break;
}
}
}
}
if (forbiddenFlags != null) {
for (Flag flag : forbiddenFlags) {
switch (flag) {
case DELETED: {
imapQuery += "UNDELETED ";
break;
}
case SEEN: {
imapQuery += "UNSEEN ";
break;
}
case ANSWERED: {
imapQuery += "UNANSWERED ";
break;
}
case FLAGGED: {
imapQuery += "UNFLAGGED ";
break;
}
case DRAFT: {
imapQuery += "UNDRAFT ";
break;
}
case RECENT: {
imapQuery += "UNRECENT ";
break;
}
default: {
break;
}
}
}
}
String encodedQuery = ImapUtility.encodeString(queryString);
if (store.getStoreConfig().isRemoteSearchFullText()) {
imapQuery += "TEXT " + encodedQuery;
} else {
imapQuery += "OR SUBJECT " + encodedQuery + " FROM " + encodedQuery;
}
return executeSimpleCommand(imapQuery);
}
};
try {
open(OPEN_MODE_RO);
checkOpen();
inSearch = true;
return search(searcher, null);
} finally {
inSearch = false;
}
}
private static String combine(Object[] parts, char separator) {
if (parts == null) {
return null;
}
return TextUtils.join(String.valueOf(separator), parts);
}
}