diff options
| author | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
|---|---|---|
| committer | test@dangofactory.cos <you@example.com> | 2026-08-20 16:57:18 +0000 |
| commit | 50ce5d5e6cd0d720ff437d35eace545816848428 (patch) | |
| tree | b59af206a36265cf081945e434900b32b0c43edd /mail/protocols/imap/src/main/java/com | |
| download | monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2 monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip | |
Inital
Diffstat (limited to 'mail/protocols/imap/src/main/java/com')
47 files changed, 4954 insertions, 0 deletions
diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/AlertResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/AlertResponse.java new file mode 100644 index 0000000..49d4c80 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/AlertResponse.java @@ -0,0 +1,26 @@ +package com.fsck.k9.mail.store.imap; + + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; + + +class AlertResponse { + private static final String ALERT_RESPONSE_CODE = "ALERT"; + + + private AlertResponse() { + } + + public static String getAlertText(ImapResponse response) { + if (response.size() < 3 || !response.isList(1)) { + return null; + } + + ImapList responseTextCode = response.getList(1); + if (responseTextCode.size() != 1 || !equalsIgnoreCase(responseTextCode.get(0), ALERT_RESPONSE_CODE)) { + return null; + } + + return response.getString(2); + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java new file mode 100644 index 0000000..3d4b591 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java @@ -0,0 +1,20 @@ +package com.fsck.k9.mail.store.imap; + + +class Capabilities { + public static final String IDLE = "IDLE"; + public static final String CONDSTORE = "CONDSTORE"; + public static final String SASL_IR = "SASL-IR"; + public static final String AUTH_XOAUTH2 = "AUTH=XOAUTH2"; + public static final String AUTH_OAUTHBEARER = "AUTH=OAUTHBEARER"; + public static final String AUTH_CRAM_MD5 = "AUTH=CRAM-MD5"; + public static final String AUTH_PLAIN = "AUTH=PLAIN"; + public static final String AUTH_EXTERNAL = "AUTH=EXTERNAL"; + public static final String LOGINDISABLED = "LOGINDISABLED"; + public static final String NAMESPACE = "NAMESPACE"; + public static final String COMPRESS_DEFLATE = "COMPRESS=DEFLATE"; + public static final String STARTTLS = "STARTTLS"; + public static final String SPECIAL_USE = "SPECIAL-USE"; + public static final String UID_PLUS = "UIDPLUS"; + public static final String LIST_EXTENDED = "LIST-EXTENDED"; +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/CapabilityResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/CapabilityResponse.java new file mode 100644 index 0000000..01b2b7f --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/CapabilityResponse.java @@ -0,0 +1,64 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; + + +class CapabilityResponse { + private final Set<String> capabilities; + + + private CapabilityResponse(Set<String> capabilities) { + this.capabilities = Collections.unmodifiableSet(capabilities); + } + + public static CapabilityResponse parse(List<ImapResponse> responses) { + for (ImapResponse response : responses) { + CapabilityResponse result; + if (!response.isEmpty() && equalsIgnoreCase(response.get(0), Responses.OK) && response.isList(1)) { + ImapList capabilityList = response.getList(1); + result = parse(capabilityList); + } else if (response.getTag() == null) { + result = parse(response); + } else { + result = null; + } + + if (result != null) { + return result; + } + } + + return null; + } + + static CapabilityResponse parse(ImapList capabilityList) { + if (capabilityList.isEmpty() || !equalsIgnoreCase(capabilityList.get(0), Responses.CAPABILITY)) { + return null; + } + + int size = capabilityList.size(); + HashSet<String> capabilities = new HashSet<>(size - 1); + + for (int i = 1; i < size; i++) { + if (!capabilityList.isString(i)) { + return null; + } + + String uppercaseCapability = capabilityList.getString(i).toUpperCase(Locale.US); + capabilities.add(uppercaseCapability); + } + + return new CapabilityResponse(capabilities); + } + + public Set<String> getCapabilities() { + return capabilities; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Commands.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Commands.java new file mode 100644 index 0000000..b883d88 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Commands.java @@ -0,0 +1,23 @@ +package com.fsck.k9.mail.store.imap; + + +class Commands { + public static final String IDLE = "IDLE"; + public static final String NAMESPACE = "NAMESPACE"; + public static final String CAPABILITY = "CAPABILITY"; + public static final String COMPRESS_DEFLATE = "COMPRESS DEFLATE"; + public static final String STARTTLS = "STARTTLS"; + public static final String AUTHENTICATE_XOAUTH2 = "AUTHENTICATE XOAUTH2"; + public static final String AUTHENTICATE_OAUTHBEARER = "AUTHENTICATE OAUTHBEARER"; + public static final String AUTHENTICATE_CRAM_MD5 = "AUTHENTICATE CRAM-MD5"; + public static final String AUTHENTICATE_PLAIN = "AUTHENTICATE PLAIN"; + public static final String AUTHENTICATE_EXTERNAL = "AUTHENTICATE EXTERNAL"; + public static final String LOGIN = "LOGIN"; + public static final String LIST = "LIST"; + public static final String NOOP = "NOOP"; + public static final String UID_SEARCH = "UID SEARCH"; + public static final String UID_STORE = "UID STORE"; + public static final String UID_FETCH = "UID FETCH"; + public static final String UID_COPY = "UID COPY"; + public static final String UID_EXPUNGE = "UID EXPUNGE"; +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchBodyCallback.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchBodyCallback.java new file mode 100644 index 0000000..d37040b --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchBodyCallback.java @@ -0,0 +1,34 @@ +package com.fsck.k9.mail.store.imap; + + +import java.io.IOException; +import java.util.Map; + +import com.fsck.k9.mail.MessagingException; +import com.fsck.k9.mail.filter.FixedLengthInputStream; + + +class FetchBodyCallback implements ImapResponseCallback { + private Map<String, ImapMessage> mMessageMap; + + FetchBodyCallback(Map<String, ImapMessage> messageMap) { + mMessageMap = messageMap; + } + + @Override + public Object foundLiteral(ImapResponse response, + FixedLengthInputStream literal) throws MessagingException, IOException { + if (response.getTag() == null && + ImapResponseParser.equalsIgnoreCase(response.get(1), "FETCH")) { + ImapList fetchList = (ImapList)response.getKeyedValue("FETCH"); + String uid = fetchList.getKeyedString("UID"); + + ImapMessage message = mMessageMap.get(uid); + message.parse(literal); + + // Return placeholder object + return 1; + } + return null; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchPartCallback.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchPartCallback.java new file mode 100644 index 0000000..3b38ee5 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchPartCallback.java @@ -0,0 +1,34 @@ +package com.fsck.k9.mail.store.imap; + + +import java.io.IOException; + +import com.fsck.k9.mail.BodyFactory; +import com.fsck.k9.mail.Part; +import com.fsck.k9.mail.filter.FixedLengthInputStream; +import com.fsck.k9.mail.internet.MimeHeader; + + +class FetchPartCallback implements ImapResponseCallback { + private final Part part; + private final BodyFactory bodyFactory; + + + FetchPartCallback(Part part, BodyFactory bodyFactory) { + this.part = part; + this.bodyFactory = bodyFactory; + } + + @Override + public Object foundLiteral(ImapResponse response, FixedLengthInputStream literal) throws IOException { + if (response.getTag() == null && ImapResponseParser.equalsIgnoreCase(response.get(1), "FETCH")) { + //TODO: check for correct UID + + String contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)[0]; + String contentType = part.getHeader(MimeHeader.HEADER_CONTENT_TYPE)[0]; + + return bodyFactory.createBody(contentTransferEncoding, contentType, literal); + } + return null; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderListItem.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderListItem.kt new file mode 100644 index 0000000..3bc7bb0 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderListItem.kt @@ -0,0 +1,10 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.FolderType + +data class FolderListItem( + val serverId: String, + val name: String, + val type: FolderType, + val oldServerId: String? +) diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.kt new file mode 100644 index 0000000..9619f65 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.kt @@ -0,0 +1,27 @@ +package com.fsck.k9.mail.store.imap + +import com.beetstra.jutf7.CharsetProvider +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets + +internal class FolderNameCodec { + private val modifiedUtf7Charset = CharsetProvider().charsetForName("X-RFC-3501") + private val asciiCharset = StandardCharsets.US_ASCII + + fun encode(folderName: String): String { + val byteBuffer = modifiedUtf7Charset.encode(folderName) + val bytes = ByteArray(byteBuffer.limit()) + byteBuffer.get(bytes) + + return String(bytes, asciiCharset) + } + + fun decode(encodedFolderName: String): String { + val decoder = modifiedUtf7Charset.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + val byteBuffer = ByteBuffer.wrap(encodedFolderName.toByteArray(asciiCharset)) + val charBuffer = decoder.decode(byteBuffer) + + return charBuffer.toString() + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNotFoundException.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNotFoundException.java new file mode 100644 index 0000000..3b15551 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNotFoundException.java @@ -0,0 +1,19 @@ +package com.fsck.k9.mail.store.imap; + + +import com.fsck.k9.mail.MessagingException; + + +public class FolderNotFoundException extends MessagingException { + private final String folderServerId; + + + public FolderNotFoundException(String folderServerId) { + super("Folder not found: " + folderServerId, true); + this.folderServerId = folderServerId; + } + + public String getFolderServerId() { + return folderServerId; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.kt new file mode 100644 index 0000000..3296474 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.kt @@ -0,0 +1,62 @@ +package com.fsck.k9.mail.store.imap + +private const val NO_VALID_ID = -1L + +internal object IdGrouper { + fun groupIds(ids: Set<Long>): GroupedIds { + require(ids.isNotEmpty()) { "groupIds() must be called with non-empty set of IDs" } + + if (ids.size < 2) return GroupedIds(ids, emptyList()) + + val orderedIds = ids.toSortedSet() + val firstId = orderedIds.first() + + val remainingIds = mutableSetOf(firstId) + val idGroups = mutableListOf<ContiguousIdGroup>() + + var previousId = firstId + var currentIdGroupStart = NO_VALID_ID + var currentIdGroupEnd = NO_VALID_ID + for (currentId in orderedIds.asSequence().drop(1)) { + if (previousId + 1L == currentId) { + if (currentIdGroupStart == NO_VALID_ID) { + remainingIds.remove(previousId) + currentIdGroupStart = previousId + currentIdGroupEnd = currentId + } else { + currentIdGroupEnd = currentId + } + } else { + if (currentIdGroupStart != NO_VALID_ID) { + idGroups.add(ContiguousIdGroup(currentIdGroupStart, currentIdGroupEnd)) + currentIdGroupStart = NO_VALID_ID + } + remainingIds.add(currentId) + } + + previousId = currentId + } + + if (currentIdGroupStart != NO_VALID_ID) { + idGroups.add(ContiguousIdGroup(currentIdGroupStart, currentIdGroupEnd)) + } + + return GroupedIds(remainingIds, idGroups) + } +} + +internal class GroupedIds(@JvmField val ids: Set<Long>, @JvmField val idGroups: List<ContiguousIdGroup>) { + init { + require(ids.isNotEmpty() || idGroups.isNotEmpty()) { "Must have at least one ID" } + } +} + +internal class ContiguousIdGroup(val start: Long, val end: Long) { + init { + require(start < end) { "start >= end" } + } + + override fun toString(): String { + return "$start:$end" + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshManager.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshManager.kt new file mode 100644 index 0000000..124c95d --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshManager.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.mail.store.imap + +interface IdleRefreshManager { + fun startTimer(timeout: Long, callback: () -> Unit): IdleRefreshTimer + fun resetTimers() +} + +interface IdleRefreshTimer { + val isWaiting: Boolean + fun cancel() +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshTimeoutProvider.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshTimeoutProvider.kt new file mode 100644 index 0000000..6ad88ac --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshTimeoutProvider.kt @@ -0,0 +1,5 @@ +package com.fsck.k9.mail.store.imap + +interface IdleRefreshTimeoutProvider { + val idleRefreshTimeoutMs: Long +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java new file mode 100644 index 0000000..91bbcd7 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java @@ -0,0 +1,64 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + + +class ImapCommandSplitter { + static List<String> splitCommand(String prefix, String suffix, GroupedIds groupedIds, int lengthLimit) { + List<String> commands = new ArrayList<>(); + Set<Long> workingIdSet = new TreeSet<>(groupedIds.ids); + List<ContiguousIdGroup> workingIdGroups = new ArrayList<>(groupedIds.idGroups); + + int suffixLength = suffix.length(); + int staticCommandLength = prefix.length() + suffixLength + 2; + while (!workingIdSet.isEmpty() || !workingIdGroups.isEmpty()) { + StringBuilder commandBuilder = new StringBuilder(prefix).append(' '); + int length = staticCommandLength; + while (length < lengthLimit) { + if (!workingIdSet.isEmpty()) { + Long id = workingIdSet.iterator().next(); + String idString = Long.toString(id); + + length += idString.length() + 1; + if (length >= lengthLimit) { + break; + } + + commandBuilder.append(idString).append(','); + workingIdSet.remove(id); + } else if (!workingIdGroups.isEmpty()) { + ContiguousIdGroup idGroup = workingIdGroups.iterator().next(); + String idGroupString = idGroup.toString(); + + length += idGroupString.length() + 1; + if (length >= lengthLimit) { + break; + } + + commandBuilder.append(idGroupString).append(','); + workingIdGroups.remove(idGroup); + } else { + break; + } + } + + if (suffixLength != 0) { + // Replace the last comma with a space + commandBuilder.setCharAt(commandBuilder.length() - 1, ' '); + commandBuilder.append(suffix); + } else { + // Remove last comma + commandBuilder.setLength(commandBuilder.length() - 1); + } + + String command = commandBuilder.toString(); + commands.add(command); + } + + return commands; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.kt new file mode 100644 index 0000000..39e8ba0 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.kt @@ -0,0 +1,47 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.MessagingException +import java.io.IOException +import java.io.OutputStream +import java.net.SocketException + +internal interface ImapConnection { + val logId: String + val connectionGeneration: Int + val isConnected: Boolean + val outputStream: OutputStream + val isUidPlusCapable: Boolean + val isIdleCapable: Boolean + + @Throws(IOException::class, MessagingException::class) + fun open() + + fun close() + + @Throws(IOException::class, MessagingException::class) + fun hasCapability(capability: String): Boolean + + @Throws(IOException::class, MessagingException::class) + fun executeSimpleCommand(command: String): List<ImapResponse> + + @Throws(IOException::class, MessagingException::class) + fun executeCommandWithIdSet(commandPrefix: String, commandSuffix: String, ids: Set<Long>): List<ImapResponse> + + @Throws(MessagingException::class, IOException::class) + fun sendCommand(command: String, sensitive: Boolean): String + + @Throws(IOException::class) + fun sendContinuation(continuation: String) + + @Throws(IOException::class) + fun readResponse(): ImapResponse + + @Throws(IOException::class) + fun readResponse(callback: ImapResponseCallback?): ImapResponse + + @Throws(SocketException::class) + fun setSocketDefaultReadTimeout() + + @Throws(SocketException::class) + fun setSocketReadTimeout(timeout: Int) +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionManager.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionManager.kt new file mode 100644 index 0000000..a2c3c84 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionManager.kt @@ -0,0 +1,10 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.MessagingException + +internal interface ImapConnectionManager { + @Throws(MessagingException::class) + fun getConnection(): ImapConnection + + fun releaseConnection(connection: ImapConnection?) +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionProvider.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionProvider.kt new file mode 100644 index 0000000..99b9e5a --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionProvider.kt @@ -0,0 +1,5 @@ +package com.fsck.k9.mail.store.imap + +internal interface ImapConnectionProvider { + fun getConnection(folder: ImapFolder): ImapConnection? +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolder.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolder.kt new file mode 100644 index 0000000..821522b --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolder.kt @@ -0,0 +1,92 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.BodyFactory +import com.fsck.k9.mail.FetchProfile +import com.fsck.k9.mail.Flag +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 java.io.IOException +import java.util.Date + +interface ImapFolder { + val serverId: String + val mode: OpenMode? + val messageCount: Int + + @Throws(MessagingException::class) + fun exists(): Boolean + + @Throws(MessagingException::class) + fun open(mode: OpenMode) + + fun close() + + fun getUidValidity(): Long? + + fun getMessage(uid: String): ImapMessage + + @Throws(MessagingException::class) + fun getUidFromMessageId(messageId: String): String? + + @Throws(MessagingException::class) + fun getMessages( + start: Int, + end: Int, + earliestDate: Date?, + listener: MessageRetrievalListener<ImapMessage>? + ): List<ImapMessage> + + @Throws(IOException::class, MessagingException::class) + fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean + + @Throws(MessagingException::class) + fun fetch( + messages: List<ImapMessage>, + fetchProfile: FetchProfile, + listener: FetchListener?, + maxDownloadSize: Int + ) + + @Throws(MessagingException::class) + fun fetchPart( + message: ImapMessage, + part: Part, + bodyFactory: BodyFactory, + maxDownloadSize: Int + ) + + @Throws(MessagingException::class) + fun search( + queryString: String?, + requiredFlags: Set<Flag>?, + forbiddenFlags: Set<Flag>?, + performFullTextSearch: Boolean + ): List<ImapMessage> + + @Throws(MessagingException::class) + fun appendMessages(messages: List<Message>): Map<String, String>? + + @Throws(MessagingException::class) + fun setFlags(flags: Set<Flag>, value: Boolean) + + @Throws(MessagingException::class) + fun setFlags(messages: List<ImapMessage>, flags: Set<Flag>, value: Boolean) + + @Throws(MessagingException::class) + fun copyMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? + + @Throws(MessagingException::class) + fun moveMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? + + @Throws(MessagingException::class) + fun expunge() + + @Throws(MessagingException::class) + fun expungeUids(uids: List<String>) +} + +interface FetchListener { + fun onFetchResponse(message: ImapMessage, isFirstResponse: Boolean) +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolderIdler.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolderIdler.kt new file mode 100644 index 0000000..be6397f --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolderIdler.kt @@ -0,0 +1,41 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.power.WakeLock + +interface ImapFolderIdler { + fun idle(): IdleResult + fun refresh() + fun stop() + + companion object { + private val connectionProvider = object : ImapConnectionProvider { + override fun getConnection(folder: ImapFolder): ImapConnection? { + require(folder is RealImapFolder) + return folder.connection + } + } + + fun create( + idleRefreshManager: IdleRefreshManager, + wakeLock: WakeLock, + imapStore: ImapStore, + folderServerId: String, + idleRefreshTimeoutProvider: IdleRefreshTimeoutProvider + ): ImapFolderIdler { + return RealImapFolderIdler( + idleRefreshManager, + wakeLock, + imapStore, + connectionProvider, + folderServerId, + idleRefreshTimeoutProvider + ) + } + } +} + +enum class IdleResult { + SYNC, + STOPPED, + NOT_SUPPORTED +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapList.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapList.java new file mode 100644 index 0000000..da325c6 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapList.java @@ -0,0 +1,163 @@ +package com.fsck.k9.mail.store.imap; + + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.Locale; + +import com.fsck.k9.mail.MessagingException; + + +/** + * Represents an IMAP list response and is also the base class for the + * ImapResponse. + */ +class ImapList extends ArrayList<Object> { + private static final long serialVersionUID = -4067248341419617583L; + private static final DateFormat DATE_FORMAT = new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss Z", Locale.US); + private static final DateFormat BAD_DATE_TIME_FORMAT = new SimpleDateFormat("dd MMM yyyy HH:mm:ss Z", Locale.US); + private static final DateFormat BAD_DATE_TIME_FORMAT_2 = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss Z", Locale.US); + private static final DateFormat BAD_DATE_TIME_FORMAT_3 = new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss", Locale.US); + + public ImapList getList(int index) { + return (ImapList)get(index); + } + + public boolean isList(int index) { + return inRange(index) && get(index) instanceof ImapList; + } + + public Object getObject(int index) { + return get(index); + } + + public String getString(int index) { + return (String)get(index); + } + + public boolean isString(int index) { + return inRange(index) && get(index) instanceof String; + } + + public boolean isLong(int index) { + if (!inRange(index)) { + return false; + } + + Object value = get(index); + if (!(value instanceof String)) { + return false; + } + + try { + Long.parseLong((String) value); + return true; + } catch (NumberFormatException e) { + return false; + } + } + + public long getLong(int index) { + return Long.parseLong(getString(index)); + } + + public int getNumber(int index) { + return Integer.parseInt(getString(index)); + } + + public Date getDate(int index) throws MessagingException { + return getDate(getString(index)); + } + + public Date getKeyedDate(String key) throws MessagingException { + return getDate(getKeyedString(key)); + } + + private Date getDate(String value) throws MessagingException { + try { + if (value == null || "NIL".equals(value)) { + return null; + } + return parseDate(value); + } catch (ParseException pe) { + throw new MessagingException("Unable to parse IMAP datetime '" + value + "' ", pe); + } + } + + + public Object getKeyedValue(String key) { + for (int i = 0, count = size() - 1; i < count; i++) { + if (ImapResponseParser.equalsIgnoreCase(get(i), key)) { + return get(i + 1); + } + } + return null; + } + + public ImapList getKeyedList(String key) { + return (ImapList)getKeyedValue(key); + } + + public String getKeyedString(String key) { + return (String)getKeyedValue(key); + } + + public int getKeyedNumber(String key) { + return Integer.parseInt(getKeyedString(key)); + } + + public boolean containsKey(String key) { + if (key == null) { + return false; + } + + for (int i = 0, count = size() - 1; i < count; i++) { + if (ImapResponseParser.equalsIgnoreCase(get(i), key)) { + return true; + } + } + return false; + } + + public int getKeyIndex(String key) { + for (int i = 0, count = size() - 1; i < count; i++) { + if (ImapResponseParser.equalsIgnoreCase(get(i), key)) { + return i; + } + } + + throw new IllegalArgumentException("getKeyIndex() only works for keys that are in the collection."); + } + + private boolean inRange(int index) { + return index >= 0 && index < size(); + } + + private Date parseDate(String value) throws ParseException { + //TODO: clean this up a bit + try { + synchronized (DATE_FORMAT) { + return DATE_FORMAT.parse(value); + } + } catch (Exception e) { + try { + synchronized (BAD_DATE_TIME_FORMAT) { + return BAD_DATE_TIME_FORMAT.parse(value); + } + } catch (Exception e2) { + try { + synchronized (BAD_DATE_TIME_FORMAT_2) { + return BAD_DATE_TIME_FORMAT_2.parse(value); + } + } catch (Exception e3) { + synchronized (BAD_DATE_TIME_FORMAT_3) { + return BAD_DATE_TIME_FORMAT_3.parse(value); + } + } + } + } + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapMessage.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapMessage.java new file mode 100644 index 0000000..52241b7 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapMessage.java @@ -0,0 +1,15 @@ +package com.fsck.k9.mail.store.imap; + + +import com.fsck.k9.mail.internet.MimeMessage; + + +public class ImapMessage extends MimeMessage { + ImapMessage(String uid) { + this.mUid = uid; + } + + public void setSize(int size) { + this.mSize = size; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponse.java new file mode 100644 index 0000000..f5ce67f --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponse.java @@ -0,0 +1,62 @@ +package com.fsck.k9.mail.store.imap; + +/** + * Represents a single response from the IMAP server. + * + * <p> + * Tagged responses will have a non-null tag. Untagged responses will have a null tag. The + * object will contain all of the available tokens at the time the response is received. + * </p> + */ +class ImapResponse extends ImapList { + private static final long serialVersionUID = 6886458551615975669L; + + + private ImapResponseCallback callback; + private final boolean commandContinuationRequested; + private final String tag; + + + private ImapResponse(ImapResponseCallback callback, boolean commandContinuationRequested, String tag) { + this.callback = callback; + this.commandContinuationRequested = commandContinuationRequested; + this.tag = tag; + } + + public static ImapResponse newContinuationRequest(ImapResponseCallback callback) { + return new ImapResponse(callback, true, null); + } + + public static ImapResponse newUntaggedResponse(ImapResponseCallback callback) { + return new ImapResponse(callback, false, null); + } + + public static ImapResponse newTaggedResponse(ImapResponseCallback callback, String tag) { + return new ImapResponse(callback, false, tag); + } + + public boolean isContinuationRequested() { + return commandContinuationRequested; + } + + public String getTag() { + return tag; + } + + public boolean isTagged() { + return tag != null; + } + + public ImapResponseCallback getCallback() { + return callback; + } + + public void setCallback(ImapResponseCallback callback) { + this.callback = callback; + } + + @Override + public String toString() { + return "#" + (commandContinuationRequested ? "+" : tag) + "# " + super.toString(); + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseCallback.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseCallback.java new file mode 100644 index 0000000..79256a2 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseCallback.java @@ -0,0 +1,24 @@ +package com.fsck.k9.mail.store.imap; + +import com.fsck.k9.mail.filter.FixedLengthInputStream; + +interface ImapResponseCallback { + /** + * Callback method that is called by the parser when a literal string + * is found in an IMAP response. + * + * @param response ImapResponse object with the fields that have been + * parsed up until now (excluding the literal string). + * @param literal FixedLengthInputStream that can be used to access + * the literal string. + * + * @return an Object that will be put in the ImapResponse object at the + * place of the literal string. + * + * @throws java.io.IOException passed-through if thrown by FixedLengthInputStream + * @throws Exception if something goes wrong. Parsing will be resumed + * and the exception will be thrown after the + * complete IMAP response has been parsed. + */ + Object foundLiteral(ImapResponse response, FixedLengthInputStream literal) throws Exception; +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java new file mode 100644 index 0000000..8a2e9a8 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java @@ -0,0 +1,487 @@ +package com.fsck.k9.mail.store.imap; + + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.K9MailLib; +import com.fsck.k9.mail.filter.FixedLengthInputStream; +import com.fsck.k9.mail.filter.PeekableInputStream; +import okio.Buffer; + +import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_IMAP; + + +class ImapResponseParser { + private PeekableInputStream inputStream; + private ImapResponse response; + private Exception exception; + + + public ImapResponseParser(PeekableInputStream in) { + this.inputStream = in; + } + + public ImapResponse readResponse() throws IOException { + return readResponse(null); + } + + /** + * Reads the next response available on the stream and returns an {@code ImapResponse} object that represents it. + */ + public ImapResponse readResponse(ImapResponseCallback callback) throws IOException { + try { + int peek = inputStream.peek(); + if (peek == '+') { + readContinuationRequest(callback); + } else if (peek == '*') { + readUntaggedResponse(callback); + } else { + readTaggedResponse(callback); + } + + if (exception != null) { + throw new ImapResponseParserException("readResponse(): Exception in callback method", exception); + } + + return response; + } finally { + response = null; + exception = null; + } + } + + private void readContinuationRequest(ImapResponseCallback callback) throws IOException { + parseCommandContinuationRequest(); + response = ImapResponse.newContinuationRequest(callback); + + skipIfSpace(); + String rest = readStringUntilEndOfLine(); + response.add(rest); + } + + private void readUntaggedResponse(ImapResponseCallback callback) throws IOException { + parseUntaggedResponse(); + response = ImapResponse.newUntaggedResponse(callback); + + readTokens(response); + } + + private void readTaggedResponse(ImapResponseCallback callback) throws IOException { + String tag = parseTaggedResponse(); + response = ImapResponse.newTaggedResponse(callback, tag); + + readTokens(response); + } + + List<ImapResponse> readStatusResponse(String tag, String commandToLog, String logId, + UntaggedHandler untaggedHandler) throws IOException, NegativeImapResponseException { + + List<ImapResponse> responses = new ArrayList<>(); + + ImapResponse response; + do { + response = readResponse(); + + if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) { + Timber.v("%s<<<%s", logId, response); + } + + if (response.getTag() != null && !response.getTag().equalsIgnoreCase(tag)) { + Timber.w("After sending tag %s, got tag response from previous command %s for %s", tag, response, logId); + + Iterator<ImapResponse> responseIterator = responses.iterator(); + + while (responseIterator.hasNext()) { + ImapResponse delResponse = responseIterator.next(); + if (delResponse.getTag() != null || delResponse.size() < 2 || ( + !equalsIgnoreCase(delResponse.get(1), Responses.EXISTS) && + !equalsIgnoreCase(delResponse.get(1), Responses.EXPUNGE))) { + responseIterator.remove(); + } + } + response = null; + continue; + } + + if (response.getTag() == null && untaggedHandler != null) { + untaggedHandler.handleAsyncUntaggedResponse(response); + } + + responses.add(response); + } while (response == null || response.getTag() == null); + + if (response.size() < 1 || !equalsIgnoreCase(response.get(0), Responses.OK)) { + String message = "Command: " + commandToLog + "; response: " + response.toString(); + throw new NegativeImapResponseException(message, responses); + } + + return responses; + } + + private void readTokens(ImapResponse response) throws IOException { + response.clear(); + + Object firstToken = readToken(response); + + checkTokenIsString(firstToken); + String symbol = (String) firstToken; + + response.add(symbol); + + if (isStatusResponse(symbol)) { + parseResponseText(response); + } else if (equalsIgnoreCase(symbol, Responses.LIST) || equalsIgnoreCase(symbol, Responses.LSUB)) { + parseListResponse(response); + } else { + Object token; + while ((token = readToken(response)) != null) { + if (!(token instanceof ImapList)) { + response.add(token); + } + } + } + } + + /** + * Parse {@code resp-text} tokens + * <p> + * Responses "OK", "PREAUTH", "BYE", "NO", "BAD", and continuation request responses can + * contain {@code resp-text} tokens. We parse the {@code resp-text-code} part as tokens and + * read the rest as sequence of characters to avoid the parser interpreting things like + * "{123}" as start of a literal. + * </p> + * <p>Example:</p> + * <p> + * {@code * OK [UIDVALIDITY 3857529045] UIDs valid} + * </p> + * <p> + * See RFC 3501, Section 9 Formal Syntax (resp-text) + * </p> + * + * @param parent + * The {@link ImapResponse} instance that holds the parsed tokens of the response. + * + * @throws IOException + * If there's a network error. + * + * @see #isStatusResponse(String) + */ + private void parseResponseText(ImapResponse parent) throws IOException { + skipIfSpace(); + + int next = inputStream.peek(); + if (next == '[') { + parseList(parent, '[', ']'); + skipIfSpace(); + } + + String rest = readStringUntilEndOfLine(); + + if (rest != null && !rest.isEmpty()) { + // The rest is free-form text. + parent.add(rest); + } + } + + private void parseListResponse(ImapResponse response) throws IOException { + expect(' '); + parseList(response, '(', ')'); + expect(' '); + String delimiter = parseQuotedOrNil(); + response.add(delimiter); + expect(' '); + String name = parseString(); + response.add(name); + expect('\r'); + expect('\n'); + } + + private void skipIfSpace() throws IOException { + if (inputStream.peek() == ' ') { + expect(' '); + } + } + + /** + * Reads the next token of the response. The token can be one of: String - + * for NIL, QUOTED, NUMBER, ATOM. Object - for LITERAL. + * ImapList - for PARENTHESIZED LIST. Can contain any of the above + * elements including List. + * + * @return The next token in the response or null if there are no more + * tokens. + */ + private Object readToken(ImapResponse response) throws IOException { + while (true) { + Object token = parseToken(response); + if (token == null || !(token.equals(")") || token.equals("]"))) { + return token; + } + } + } + + private Object parseToken(ImapList parent) throws IOException { + while (true) { + int ch = inputStream.peek(); + + if (ch == '(') { + return parseList(parent, '(', ')'); + } else if (ch == '[') { + return parseList(parent, '[', ']'); + } else if (ch == ')') { + expect(')'); + return ")"; + } else if (ch == ']') { + expect(']'); + return "]"; + } else if (ch == '"') { + return parseQuoted(); + } else if (ch == '{') { + return parseLiteral(); + } else if (ch == ' ') { + expect(' '); + } else if (ch == '\r') { + expect('\r'); + expect('\n'); + return null; + } else if (ch == '\n') { + expect('\n'); + return null; + } else if (ch == '\t') { + expect('\t'); + } else { + return parseBareString(true); + } + } + } + + private String parseString() throws IOException { + int ch = inputStream.peek(); + + if (ch == '"') { + return parseQuoted(); + } else if (ch == '{') { + return (String) parseLiteral(); + } else { + return parseBareString(false); + } + } + + private boolean parseCommandContinuationRequest() throws IOException { + expect('+'); + return true; + } + + private void parseUntaggedResponse() throws IOException { + expect('*'); + expect(' '); + } + + private String parseTaggedResponse() throws IOException { + return readStringUntil(' '); + } + + private ImapList parseList(ImapList parent, char start, char end) throws IOException { + expect(start); + + ImapList list = new ImapList(); + parent.add(list); + + String endString = String.valueOf(end); + + Object token; + while (true) { + token = parseToken(list); + if (token == null) { + return null; + } else if (token.equals(endString)) { + break; + } else if (!(token instanceof ImapList)) { + list.add(token); + } + } + + return list; + } + + private String parseBareString(boolean allowBrackets) throws IOException { + StringBuilder sb = new StringBuilder(); + + int ch; + while (true) { + ch = inputStream.peek(); + if (ch == -1) { + throw new IOException("parseBareString(): end of stream reached"); + } + + if (ch == '(' || ch == ')' || (allowBrackets && (ch == '[' || ch == ']')) || + ch == '{' || ch == ' ' || ch == '"' || + (ch >= 0x00 && ch <= 0x1f) || ch == 0x7f) { + + if (sb.length() == 0) { + throw new IOException(String.format("parseBareString(): (%04x %c)", ch, ch)); + } + + return sb.toString(); + } else { + sb.append((char) inputStream.read()); + } + } + } + + /** + * A "{" has been read. Read the rest of the size string, the space and then notify the callback with an + * {@code InputStream}. + */ + private Object parseLiteral() throws IOException { + expect('{'); + int size = Integer.parseInt(readStringUntil('}')); + expect('\r'); + expect('\n'); + + if (size == 0) { + return ""; + } + + if (response.getCallback() != null) { + FixedLengthInputStream fixed = new FixedLengthInputStream(inputStream, size); + + Exception callbackException = null; + Object result = null; + try { + result = response.getCallback().foundLiteral(response, fixed); + } catch (IOException e) { + throw e; + } catch (Exception e) { + callbackException = e; + } + + boolean someDataWasRead = fixed.available() != size; + if (someDataWasRead) { + if (result == null && callbackException == null) { + throw new AssertionError("Callback consumed some data but returned no result"); + } + + fixed.skipRemaining(); + } + + if (callbackException != null) { + if (exception == null) { + exception = callbackException; + } + return "EXCEPTION"; + } + + if (result != null) { + return result; + } + } + + byte[] data = new byte[size]; + int read = 0; + while (read != size) { + int count = inputStream.read(data, read, size - read); + if (count == -1) { + throw new IOException("parseLiteral(): end of stream reached"); + } + read += count; + } + + return new String(data, "US-ASCII"); + } + + private String parseQuoted() throws IOException { + expect('"'); + + Buffer buffer = new Buffer(); + int ch; + boolean escape = false; + while ((ch = inputStream.read()) != -1) { + if (!escape && ch == '\\') { + // Found the escape character + escape = true; + } else if (!escape && ch == '"') { + return buffer.readUtf8(); + } else { + buffer.writeByte(ch); + escape = false; + } + } + + throw new IOException("parseQuoted(): end of stream reached"); + } + + private String parseQuotedOrNil() throws IOException { + int peek = inputStream.peek(); + if (peek == '"') { + return parseQuoted(); + } else { + parseNil(); + return null; + } + } + + private void parseNil() throws IOException { + expect('N'); + expect('I'); + expect('L'); + } + + private String readStringUntil(char end) throws IOException { + StringBuilder sb = new StringBuilder(); + + int ch; + while ((ch = inputStream.read()) != -1) { + if (ch == end) { + return sb.toString(); + } else { + sb.append((char) ch); + } + } + + throw new IOException("readStringUntil(): end of stream reached. " + + "Read: \"" + sb.toString() + "\" while waiting for " + formatChar(end)); + } + + private String formatChar(char value) { + return value < 32 ? "[" + Integer.toString(value) + "]" : "'" + value + "'"; + } + + private String readStringUntilEndOfLine() throws IOException { + String rest = readStringUntil('\r'); + expect('\n'); + + return rest; + } + + private void expect(char expected) throws IOException { + int readByte = inputStream.read(); + if (readByte != expected) { + throw new IOException(String.format("Expected %04x (%c) but got %04x (%c)", + (int) expected, expected, readByte, (char) readByte)); + } + } + + private boolean isStatusResponse(String symbol) { + return symbol.equalsIgnoreCase(Responses.OK) || + symbol.equalsIgnoreCase(Responses.NO) || + symbol.equalsIgnoreCase(Responses.BAD) || + symbol.equalsIgnoreCase(Responses.PREAUTH) || + symbol.equalsIgnoreCase(Responses.BYE); + } + + public static boolean equalsIgnoreCase(Object token, String symbol) { + return token instanceof String && symbol.equalsIgnoreCase((String) token); + + } + + private void checkTokenIsString(Object token) throws IOException { + if (!(token instanceof String)) { + throw new IOException("Unexpected non-string token: " + token.getClass().getSimpleName() + " - " + token); + } + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParserException.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParserException.java new file mode 100644 index 0000000..0238d9d --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParserException.java @@ -0,0 +1,8 @@ +package com.fsck.k9.mail.store.imap; + + +public class ImapResponseParserException extends RuntimeException { + public ImapResponseParserException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.kt new file mode 100644 index 0000000..03e7582 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.kt @@ -0,0 +1,22 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.ConnectionSecurity + +/** + * Settings source for IMAP. Implemented in order to remove coupling between [ImapStore] and [ImapConnection]. + */ +internal interface ImapSettings { + val host: String + val port: Int + val connectionSecurity: ConnectionSecurity + val authType: AuthType + val username: String + val password: String? + val clientCertificateAlias: String? + val useCompression: Boolean + + var pathPrefix: String? + var pathDelimiter: String? + fun setCombinedPrefix(prefix: String?) +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt new file mode 100644 index 0000000..bcb484c --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt @@ -0,0 +1,29 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.ssl.TrustedSocketFactory + +interface ImapStore { + @Throws(MessagingException::class) + fun checkSettings() + + fun getFolder(name: String): ImapFolder + + @Throws(MessagingException::class) + fun getFolders(): List<FolderListItem> + + fun closeAllConnections() + + companion object { + fun create( + serverSettings: ServerSettings, + config: ImapStoreConfig, + trustedSocketFactory: TrustedSocketFactory, + oauthTokenProvider: OAuth2TokenProvider? + ): ImapStore { + return RealImapStore(serverSettings, config, trustedSocketFactory, oauthTokenProvider) + } + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreConfig.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreConfig.kt new file mode 100644 index 0000000..0bfbd58 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreConfig.kt @@ -0,0 +1,7 @@ +package com.fsck.k9.mail.store.imap + +interface ImapStoreConfig { + val logLabel: String + fun isSubscribedFoldersOnly(): Boolean + fun useCompression(): Boolean +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreSettings.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreSettings.kt new file mode 100644 index 0000000..e06414d --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreSettings.kt @@ -0,0 +1,27 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.ServerSettings + +/** + * Extract IMAP-specific server settings from [ServerSettings] + */ +object ImapStoreSettings { + private const val AUTODETECT_NAMESPACE_KEY = "autoDetectNamespace" + private const val PATH_PREFIX_KEY = "pathPrefix" + + @JvmStatic + val ServerSettings.autoDetectNamespace: Boolean + get() = extra[AUTODETECT_NAMESPACE_KEY]?.toBoolean() ?: true + + @JvmStatic + val ServerSettings.pathPrefix: String? + get() = extra[PATH_PREFIX_KEY] + + @JvmStatic + fun createExtra(autoDetectNamespace: Boolean, pathPrefix: String?): Map<String, String?> { + return mapOf( + AUTODETECT_NAMESPACE_KEY to autoDetectNamespace.toString(), + PATH_PREFIX_KEY to pathPrefix + ) + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapUtility.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapUtility.java new file mode 100644 index 0000000..1c241e7 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapUtility.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2012 The K-9 Dog Walkers + * Copyright (C) 2011 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.fsck.k9.mail.store.imap; + + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import com.fsck.k9.logging.Timber; +import com.fsck.k9.mail.Flag; + + +/** + * Utility methods for use with IMAP. + */ +class ImapUtility { + /** + * Gets all of the values in a sequence set per RFC 3501. + * + * <p> + * Any ranges are expanded into a list of individual numbers. + * </p> + * + * <pre> + * sequence-number = nz-number / "*" + * sequence-range = sequence-number ":" sequence-number + * sequence-set = (sequence-number / sequence-range) *("," sequence-set) + * </pre> + * + * @param set + * The sequence set string as received by the server. + * + * @return The list of IDs as strings in this sequence set. If the set is invalid, an empty + * list is returned. + */ + public static List<String> getImapSequenceValues(String set) { + List<String> list = new ArrayList<>(); + if (set != null) { + String[] setItems = set.split(","); + for (String item : setItems) { + if (item.indexOf(':') == -1) { + // simple item + if (isNumberValid(item)) { + list.add(item); + } + } else { + // range + list.addAll(getImapRangeValues(item)); + } + } + } + + return list; + } + + /** + * Expand the given number range into a list of individual numbers. + * + * <pre> + * sequence-number = nz-number / "*" + * sequence-range = sequence-number ":" sequence-number + * sequence-set = (sequence-number / sequence-range) *("," sequence-set) + * </pre> + * + * @param range + * The range string as received by the server. + * + * @return The list of IDs as strings in this range. If the range is not valid, an empty list + * is returned. + */ + public static List<String> getImapRangeValues(String range) { + List<String> list = new ArrayList<>(); + try { + if (range != null) { + int colonPos = range.indexOf(':'); + if (colonPos > 0) { + long first = Long.parseLong(range.substring(0, colonPos)); + long second = Long.parseLong(range.substring(colonPos + 1)); + if (is32bitValue(first) && is32bitValue(second)) { + if (first < second) { + for (long i = first; i <= second; i++) { + list.add(Long.toString(i)); + } + } else { + for (long i = first; i >= second; i--) { + list.add(Long.toString(i)); + } + } + } else { + Timber.d("Invalid range: %s", range); + } + } + } + } catch (NumberFormatException e) { + Timber.d(e, "Invalid range value: %s", range); + } + + return list; + } + + private static boolean isNumberValid(String number) { + try { + long value = Long.parseLong(number); + if (is32bitValue(value)) { + return true; + } + } catch (NumberFormatException e) { + // do nothing + } + + Timber.d("Invalid UID value: %s", number); + + return false; + } + + private static boolean is32bitValue(long value) { + return ((value & ~0xFFFFFFFFL) == 0L); + } + + /** + * Encode a string to be able to use it in an IMAP command. + * + * "A quoted string is a sequence of zero or more 7-bit characters, + * excluding CR and LF, with double quote (<">) characters at each + * end." - Section 4.3, RFC 3501 + * + * Double quotes and backslash are escaped by prepending a backslash. + * + * @param str + * The input string (only 7-bit characters allowed). + * + * @return The string encoded as quoted (IMAP) string. + */ + //TODO use a literal string + public static String encodeString(String str) { + return "\"" + str.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + public static ImapResponse getLastResponse(List<ImapResponse> responses) { + int lastIndex = responses.size() - 1; + + return responses.get(lastIndex); + } + + public static String combineFlags(Iterable<Flag> flags, boolean canCreateForwardedFlag) { + List<String> flagNames = new ArrayList<>(); + 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 && canCreateForwardedFlag) { + flagNames.add("$Forwarded"); + } else if (flag == Flag.DRAFT) { + flagNames.add("\\Draft"); + } + } + + return ImapUtility.join(" ", flagNames); + } + + public static String join(String delimiter, Collection<? extends Object> tokens) { + if (tokens == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + boolean firstTime = true; + for (Object token: tokens) { + if (firstTime) { + firstTime = false; + } else { + sb.append(delimiter); + } + sb.append(token); + } + return sb.toString(); + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/InternalImapStore.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/InternalImapStore.kt new file mode 100644 index 0000000..2ae729c --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/InternalImapStore.kt @@ -0,0 +1,11 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.Flag + +internal interface InternalImapStore { + val logLabel: String + + fun getCombinedPrefix(): String + + fun getPermanentFlagsIndex(): MutableSet<Flag> +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ListResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ListResponse.java new file mode 100644 index 0000000..57e38f7 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ListResponse.java @@ -0,0 +1,105 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.jetbrains.annotations.Nullable; + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; + +class ListResponse { + private final List<String> attributes; + private final String hierarchyDelimiter; + private final String name; + + + private ListResponse(List<String> attributes, String hierarchyDelimiter, String name) { + this.attributes = Collections.unmodifiableList(attributes); + this.hierarchyDelimiter = hierarchyDelimiter; + this.name = name; + } + + public static List<ListResponse> parseList(List<ImapResponse> responses) { + return parse(responses, Responses.LIST); + } + + public static List<ListResponse> parseLsub(List<ImapResponse> responses) { + return parse(responses, Responses.LSUB); + } + + private static List<ListResponse> parse(List<ImapResponse> responses, String commandResponse) { + List<ListResponse> listResponses = new ArrayList<>(); + + for (ImapResponse response : responses) { + ListResponse listResponse = parseSingleLine(response, commandResponse); + if (listResponse != null) { + listResponses.add(listResponse); + } + } + + return Collections.unmodifiableList(listResponses); + } + + private static ListResponse parseSingleLine(ImapResponse response, String commandResponse) { + if (response.size() < 4 || !equalsIgnoreCase(response.get(0), commandResponse)) { + return null; + } + + // We have special support for LIST responses in ImapResponseParser so we can relax the length/type checks here + + List<String> attributes = extractAttributes(response); + if (attributes == null) { + return null; + } + + String hierarchyDelimiter = response.getString(2); + if (hierarchyDelimiter != null && hierarchyDelimiter.length() != 1) { + return null; + } + + String name = response.getString(3); + + return new ListResponse(attributes, hierarchyDelimiter, name); + } + + private static List<String> extractAttributes(ImapResponse response) { + ImapList nameAttributes = response.getList(1); + List<String> attributes = new ArrayList<>(nameAttributes.size()); + + for (Object nameAttribute : nameAttributes) { + if (!(nameAttribute instanceof String)) { + return null; + } + + String attribute = (String) nameAttribute; + attributes.add(attribute); + } + + return attributes; + } + + public List<String> getAttributes() { + return attributes; + } + + public boolean hasAttribute(String attribute) { + for (String attributeInResponse : attributes) { + if (attributeInResponse.equalsIgnoreCase(attribute)) { + return true; + } + } + + return false; + } + + @Nullable + public String getHierarchyDelimiter() { + return hierarchyDelimiter; + } + + public String getName() { + return name; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NamespaceResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NamespaceResponse.java new file mode 100644 index 0000000..85ee2cd --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NamespaceResponse.java @@ -0,0 +1,62 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.List; + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; + + +class NamespaceResponse { + private String prefix; + private String hierarchyDelimiter; + + + private NamespaceResponse(String prefix, String hierarchyDelimiter) { + this.prefix = prefix; + this.hierarchyDelimiter = hierarchyDelimiter; + } + + public static NamespaceResponse parse(List<ImapResponse> responses) { + for (ImapResponse response : responses) { + NamespaceResponse prefix = parse(response); + if (prefix != null) { + return prefix; + } + } + + return null; + } + + static NamespaceResponse parse(ImapResponse response) { + if (response.size() < 4 || !equalsIgnoreCase(response.get(0), Responses.NAMESPACE)) { + return null; + } + + if (!response.isList(1)) { + return null; + } + + ImapList personalNamespaces = response.getList(1); + if (!personalNamespaces.isList(0)) { + return null; + } + + ImapList firstPersonalNamespace = personalNamespaces.getList(0); + if (!firstPersonalNamespace.isString(0) || !firstPersonalNamespace.isString(1)) { + return null; + } + + String prefix = firstPersonalNamespace.getString(0); + String hierarchyDelimiter = firstPersonalNamespace.getString(1); + + return new NamespaceResponse(prefix, hierarchyDelimiter); + } + + public String getPrefix() { + return prefix; + } + + public String getHierarchyDelimiter() { + return hierarchyDelimiter; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java new file mode 100644 index 0000000..6a1c44f --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java @@ -0,0 +1,43 @@ +package com.fsck.k9.mail.store.imap; + +import java.util.List; + +import com.fsck.k9.mail.MessagingException; + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; + + +class NegativeImapResponseException extends MessagingException { + private static final long serialVersionUID = 3725007182205882394L; + + private final List<ImapResponse> responses; + private String alertText; + + public NegativeImapResponseException(String message, List<ImapResponse> responses) { + super(message, true); + this.responses = responses; + } + + public String getAlertText() { + if (alertText == null) { + ImapResponse lastResponse = getLastResponse(); + alertText = AlertResponse.getAlertText(lastResponse); + } + + return alertText; + } + + public boolean wasByeResponseReceived() { + for (ImapResponse response : responses) { + if (response.getTag() == null && response.size() >= 1 && equalsIgnoreCase(response.get(0), Responses.BYE)) { + return true; + } + } + + return false; + } + + public ImapResponse getLastResponse() { + return responses.get(responses.size() - 1); + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponse.java new file mode 100644 index 0000000..40f3d5b --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponse.java @@ -0,0 +1,86 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +import com.fsck.k9.mail.Flag; + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; + + +class PermanentFlagsResponse { + private final Set<Flag> flags; + private final boolean canCreateKeywords; + + + private PermanentFlagsResponse(Set<Flag> flags, boolean canCreateKeywords) { + this.flags = Collections.unmodifiableSet(flags); + this.canCreateKeywords = canCreateKeywords; + } + + public static PermanentFlagsResponse parse(ImapResponse response) { + if (response.isTagged() || !equalsIgnoreCase(response.get(0), Responses.OK) || !response.isList(1)) { + return null; + } + + ImapList responseTextList = response.getList(1); + if (responseTextList.size() < 2 || !equalsIgnoreCase(responseTextList.get(0), Responses.PERMANENTFLAGS) || + !responseTextList.isList(1)) { + return null; + } + + ImapList permanentFlagsList = responseTextList.getList(1); + int size = permanentFlagsList.size(); + Set<Flag> flags = new HashSet<>(size); + boolean canCreateKeywords = false; + + for (int i = 0; i < size; i++) { + if (!permanentFlagsList.isString(i)) { + return null; + } + + String flag = permanentFlagsList.getString(i); + String compareFlag = flag.toLowerCase(Locale.US); + + switch (compareFlag) { + case "\\deleted": { + flags.add(Flag.DELETED); + break; + } + case "\\answered": { + flags.add(Flag.ANSWERED); + break; + } + case "\\seen": { + flags.add(Flag.SEEN); + break; + } + case "\\flagged": { + flags.add(Flag.FLAGGED); + break; + } + case "$forwarded": { + flags.add(Flag.FORWARDED); + break; + } + case "\\*": { + canCreateKeywords = true; + break; + } + } + } + + return new PermanentFlagsResponse(flags, canCreateKeywords); + } + + public Set<Flag> getFlags() { + return flags; + } + + public boolean canCreateKeywords() { + return canCreateKeywords; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt new file mode 100644 index 0000000..3fa850f --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt @@ -0,0 +1,910 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.logging.Timber +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.Authentication +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.CertificateValidationException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.K9MailLib +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.NetworkTimeouts.SOCKET_CONNECT_TIMEOUT +import com.fsck.k9.mail.NetworkTimeouts.SOCKET_READ_TIMEOUT +import com.fsck.k9.mail.filter.Base64 +import com.fsck.k9.mail.filter.PeekableInputStream +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.oauth.XOAuth2ChallengeParser +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.fsck.k9.sasl.buildOAuthBearerInitialClientResponse +import com.jcraft.jzlib.JZlib +import com.jcraft.jzlib.ZOutputStream +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketAddress +import java.net.UnknownHostException +import java.security.GeneralSecurityException +import java.security.Security +import java.security.cert.CertificateException +import java.util.regex.Pattern +import java.util.zip.Inflater +import java.util.zip.InflaterInputStream +import javax.net.ssl.SSLException +import org.apache.commons.io.IOUtils + +/** + * A cacheable class that stores the details for a single IMAP connection. + */ +internal class RealImapConnection( + private val settings: ImapSettings, + private val socketFactory: TrustedSocketFactory, + private val oauthTokenProvider: OAuth2TokenProvider?, + override val connectionGeneration: Int, + private val socketConnectTimeout: Int = SOCKET_CONNECT_TIMEOUT, + private val socketReadTimeout: Int = SOCKET_READ_TIMEOUT +) : ImapConnection { + private var socket: Socket? = null + private var inputStream: PeekableInputStream? = null + private var imapOutputStream: OutputStream? = null + private var responseParser: ImapResponseParser? = null + private var nextCommandTag = 0 + private var capabilities = emptySet<String>() + private var stacktraceForClose: Exception? = null + private var open = false + private var retryOAuthWithNewToken = true + + @get:Synchronized + override val outputStream: OutputStream + get() = checkNotNull(imapOutputStream) + + @Synchronized + @Throws(IOException::class, MessagingException::class) + override fun open() { + if (open) { + return + } else if (stacktraceForClose != null) { + throw IllegalStateException( + "open() called after close(). Check wrapped exception to see where close() was called.", + stacktraceForClose + ) + } + + open = true + var authSuccess = false + nextCommandTag = 1 + + adjustDNSCacheTTL() + + try { + socket = connect() + configureSocket() + setUpStreamsAndParserFromSocket() + + readInitialResponse() + requestCapabilitiesIfNecessary() + + upgradeToTlsIfNecessary() + + val responses = authenticate() + authSuccess = true + + extractOrRequestCapabilities(responses) + + enableCompressionIfRequested() + + retrievePathPrefixIfNecessary() + retrievePathDelimiterIfNecessary() + } catch (e: SSLException) { + handleSslException(e) + } catch (e: GeneralSecurityException) { + throw MessagingException("Unable to open connection to IMAP server due to security error.", e) + } finally { + if (!authSuccess) { + Timber.e("Failed to login, closing connection for %s", logId) + close() + } + } + } + + private fun handleSslException(e: SSLException) { + if (e.cause is CertificateException) { + throw CertificateValidationException(e.message, e) + } else { + throw e + } + } + + @get:Synchronized + override val isConnected: Boolean + get() { + return inputStream != null && imapOutputStream != null && + socket.let { socket -> + socket != null && socket.isConnected && !socket.isClosed + } + } + + private fun adjustDNSCacheTTL() { + try { + Security.setProperty("networkaddress.cache.ttl", "0") + } catch (e: Exception) { + Timber.w(e, "Could not set DNS ttl to 0 for %s", logId) + } + + try { + Security.setProperty("networkaddress.cache.negative.ttl", "0") + } catch (e: Exception) { + Timber.w(e, "Could not set DNS negative ttl to 0 for %s", logId) + } + } + + private fun connect(): Socket { + val inetAddresses = InetAddress.getAllByName(settings.host) + + var connectException: Exception? = null + for (address in inetAddresses) { + connectException = try { + return connectToAddress(address) + } catch (e: IOException) { + Timber.w(e, "Could not connect to %s", address) + e + } + } + + throw connectException ?: UnknownHostException() + } + + private fun connectToAddress(address: InetAddress): Socket { + val host = settings.host + val port = settings.port + val clientCertificateAlias = settings.clientCertificateAlias + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + Timber.d("Connecting to %s as %s", host, address) + } + + val socketAddress: SocketAddress = InetSocketAddress(address, port) + val socket = if (settings.connectionSecurity == ConnectionSecurity.SSL_TLS_REQUIRED) { + socketFactory.createSocket(null, host, port, clientCertificateAlias) + } else { + Socket() + } + + socket.connect(socketAddress, socketConnectTimeout) + + return socket + } + + private fun configureSocket() { + setSocketDefaultReadTimeout() + } + + override fun setSocketDefaultReadTimeout() { + setSocketReadTimeout(socketReadTimeout) + } + + @Synchronized + override fun setSocketReadTimeout(timeout: Int) { + socket?.soTimeout = timeout + } + + private fun setUpStreamsAndParserFromSocket() { + val socket = checkNotNull(socket) + + setUpStreamsAndParser(socket.getInputStream(), socket.getOutputStream()) + } + + private fun setUpStreamsAndParser(input: InputStream, output: OutputStream) { + inputStream = PeekableInputStream(BufferedInputStream(input, BUFFER_SIZE)) + responseParser = ImapResponseParser(inputStream) + imapOutputStream = BufferedOutputStream(output, BUFFER_SIZE) + } + + private fun readInitialResponse() { + val responseParser = checkNotNull(responseParser) + + val initialResponse = responseParser.readResponse() + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + Timber.v("%s <<< %s", logId, initialResponse) + } + + extractCapabilities(listOf(initialResponse)) + } + + private fun extractCapabilities(responses: List<ImapResponse>): Boolean { + val capabilityResponse = CapabilityResponse.parse(responses) ?: return false + val receivedCapabilities = capabilityResponse.capabilities + + Timber.d("Saving %s capabilities for %s", receivedCapabilities, logId) + capabilities = receivedCapabilities + + return true + } + + private fun extractOrRequestCapabilities(responses: List<ImapResponse>) { + if (!extractCapabilities(responses)) { + Timber.i("Did not get capabilities in post-auth banner, requesting CAPABILITY for %s", logId) + requestCapabilities() + } + } + + private fun requestCapabilitiesIfNecessary() { + if (capabilities.isNotEmpty()) return + + if (K9MailLib.isDebug()) { + Timber.i("Did not get capabilities in banner, requesting CAPABILITY for %s", logId) + } + + requestCapabilities() + } + + private fun requestCapabilities() { + val responses = executeSimpleCommand(Commands.CAPABILITY) + + if (!extractCapabilities(responses)) { + throw MessagingException("Invalid CAPABILITY response received") + } + } + + private fun upgradeToTlsIfNecessary() { + if (settings.connectionSecurity == ConnectionSecurity.STARTTLS_REQUIRED) { + upgradeToTls() + } + } + + private fun upgradeToTls() { + if (!hasCapability(Capabilities.STARTTLS)) { + /* + * This exception triggers a "Certificate error" + * notification that takes the user to the incoming + * server settings for review. This might be needed if + * the account was configured with an obsolete + * "STARTTLS (if available)" setting. + */ + throw CertificateValidationException("STARTTLS connection security not available") + } + + startTls() + } + + private fun startTls() { + executeSimpleCommand(Commands.STARTTLS) + + val host = settings.host + val port = settings.port + val clientCertificateAlias = settings.clientCertificateAlias + socket = socketFactory.createSocket(socket, host, port, clientCertificateAlias) + + configureSocket() + setUpStreamsAndParserFromSocket() + + // Per RFC 2595 (3.1): Once TLS has been started, reissue CAPABILITY command + if (K9MailLib.isDebug()) { + Timber.i("Updating capabilities after STARTTLS for %s", logId) + } + + requestCapabilities() + } + + private fun authenticate(): List<ImapResponse> { + return when (settings.authType) { + AuthType.XOAUTH2 -> { + if (oauthTokenProvider == null) { + throw MessagingException("No OAuthToken Provider available.") + } else if (!hasCapability(Capabilities.SASL_IR)) { + throw MessagingException("SASL-IR capability is missing.") + } else if (hasCapability(Capabilities.AUTH_OAUTHBEARER)) { + authWithOAuthToken(OAuthMethod.OAUTHBEARER) + } else if (hasCapability(Capabilities.AUTH_XOAUTH2)) { + authWithOAuthToken(OAuthMethod.XOAUTH2) + } else { + throw MessagingException("Server doesn't support SASL OAUTHBEARER or XOAUTH2.") + } + } + AuthType.CRAM_MD5 -> { + if (hasCapability(Capabilities.AUTH_CRAM_MD5)) { + authCramMD5() + } else { + throw MessagingException("Server doesn't support encrypted passwords using CRAM-MD5.") + } + } + AuthType.PLAIN -> { + if (hasCapability(Capabilities.AUTH_PLAIN)) { + saslAuthPlainWithLoginFallback() + } else if (!hasCapability(Capabilities.LOGINDISABLED)) { + login() + } else { + throw MessagingException( + "Server doesn't support unencrypted passwords using AUTH=PLAIN and LOGIN is disabled." + ) + } + } + AuthType.EXTERNAL -> { + if (hasCapability(Capabilities.AUTH_EXTERNAL)) { + saslAuthExternal() + } else { + // Provide notification to user of a problem authenticating using client certificates + throw CertificateValidationException(CertificateValidationException.Reason.MissingCapability) + } + } + else -> { + throw MessagingException("Unhandled authentication method found in the server settings (bug).") + } + } + } + + private fun authWithOAuthToken(method: OAuthMethod): List<ImapResponse> { + val oauthTokenProvider = checkNotNull(oauthTokenProvider) + retryOAuthWithNewToken = true + + return try { + attemptOAuth(method) + } catch (e: NegativeImapResponseException) { + // TODO: Check response code so we don't needlessly invalidate the token. + oauthTokenProvider.invalidateToken() + + if (!retryOAuthWithNewToken) { + throw handlePermanentOAuthFailure(e) + } else { + handleTemporaryOAuthFailure(method, e) + } + } + } + + private fun handlePermanentOAuthFailure(e: NegativeImapResponseException): AuthenticationFailedException { + Timber.v(e, "Permanent failure during authentication using OAuth token") + + return AuthenticationFailedException( + message = "Authentication failed", + throwable = e, + messageFromServer = ResponseTextExtractor.getResponseText(e.lastResponse), + ) + } + + private fun handleTemporaryOAuthFailure(method: OAuthMethod, e: NegativeImapResponseException): List<ImapResponse> { + val oauthTokenProvider = checkNotNull(oauthTokenProvider) + + // We got a response indicating a retry might succeed after token refresh + // We could avoid this if we had a reasonable chance of knowing + // if a token was invalid before use (e.g. due to expiry). But we don't + // This is the intended behaviour per AccountManager + Timber.v(e, "Temporary failure - retrying with new token") + + return try { + attemptOAuth(method) + } catch (e2: NegativeImapResponseException) { + // Okay, we failed on a new token. + // Invalidate the token anyway but assume it's permanent. + Timber.v(e, "Authentication exception for new token, permanent error assumed") + + oauthTokenProvider.invalidateToken() + + throw handlePermanentOAuthFailure(e2) + } + } + + private fun attemptOAuth(method: OAuthMethod): List<ImapResponse> { + val oauthTokenProvider = checkNotNull(oauthTokenProvider) + val responseParser = checkNotNull(responseParser) + + val token = oauthTokenProvider.getToken(OAuth2TokenProvider.OAUTH2_TIMEOUT.toLong()) + + val authString = method.buildInitialClientResponse(settings.username, token) + val tag = sendSaslIrCommand(method.command, authString, true) + + return responseParser.readStatusResponse(tag, method.command, logId, ::handleOAuthUntaggedResponse) + } + + private fun handleOAuthUntaggedResponse(response: ImapResponse) { + if (!response.isContinuationRequested) return + + val imapOutputStream = checkNotNull(imapOutputStream) + + if (response.isString(0)) { + retryOAuthWithNewToken = XOAuth2ChallengeParser.shouldRetry(response.getString(0), settings.host) + } + + imapOutputStream.write('\r'.code) + imapOutputStream.write('\n'.code) + imapOutputStream.flush() + } + + private fun authCramMD5(): List<ImapResponse> { + val command = Commands.AUTHENTICATE_CRAM_MD5 + val tag = sendCommand(command, false) + + val imapOutputStream = checkNotNull(imapOutputStream) + val responseParser = checkNotNull(responseParser) + + val response = readContinuationResponse(tag) + if (response.size != 1 || !response.isString(0)) { + throw MessagingException("Invalid Cram-MD5 nonce received") + } + + val b64Nonce = response.getString(0).toByteArray() + val b64CRAM = Authentication.computeCramMd5Bytes(settings.username, settings.password, b64Nonce) + + imapOutputStream.write(b64CRAM) + imapOutputStream.write('\r'.code) + imapOutputStream.write('\n'.code) + imapOutputStream.flush() + + return try { + responseParser.readStatusResponse(tag, command, logId, null) + } catch (e: NegativeImapResponseException) { + throw handleAuthenticationFailure(e) + } + } + + private fun saslAuthPlainWithLoginFallback(): List<ImapResponse> { + return try { + saslAuthPlain() + } catch (e: AuthenticationFailedException) { + if (!isConnected) { + throw e + } + + loginOrThrow(e) + } + } + + @Suppress("ThrowsCount") + private fun loginOrThrow(originalException: AuthenticationFailedException): List<ImapResponse> { + return try { + login() + } catch (e: AuthenticationFailedException) { + throw e + } catch (e: IOException) { + Timber.d(e, "LOGIN fallback failed") + throw originalException + } catch (e: MessagingException) { + Timber.d(e, "LOGIN fallback failed") + throw originalException + } + } + + private fun saslAuthPlain(): List<ImapResponse> { + val command = Commands.AUTHENTICATE_PLAIN + val tag = sendCommand(command, false) + + val imapOutputStream = checkNotNull(imapOutputStream) + val responseParser = checkNotNull(responseParser) + + readContinuationResponse(tag) + + val credentials = "\u0000" + settings.username + "\u0000" + settings.password + val encodedCredentials = Base64.encodeBase64(credentials.toByteArray()) + + imapOutputStream.write(encodedCredentials) + imapOutputStream.write('\r'.code) + imapOutputStream.write('\n'.code) + imapOutputStream.flush() + + return try { + responseParser.readStatusResponse(tag, command, logId, null) + } catch (e: NegativeImapResponseException) { + throw handleAuthenticationFailure(e) + } + } + + private fun login(): List<ImapResponse> { + val password = checkNotNull(settings.password) + + /* + * Use quoted strings which permit spaces and quotes. (Using IMAP + * string literals would be better, but some servers are broken + * and don't parse them correctly.) + */ + + // escape double-quotes and backslash characters with a backslash + val pattern = Pattern.compile("[\\\\\"]") + val replacement = "\\\\$0" + val encodedUsername = pattern.matcher(settings.username).replaceAll(replacement) + val encodedPassword = pattern.matcher(password).replaceAll(replacement) + + return try { + val command = String.format(Commands.LOGIN + " \"%s\" \"%s\"", encodedUsername, encodedPassword) + executeSimpleCommand(command, true) + } catch (e: NegativeImapResponseException) { + throw handleAuthenticationFailure(e) + } + } + + private fun saslAuthExternal(): List<ImapResponse> { + return try { + val command = Commands.AUTHENTICATE_EXTERNAL + " " + Base64.encode(settings.username) + executeSimpleCommand(command, false) + } catch (e: NegativeImapResponseException) { + /* + * Provide notification to the user of a problem authenticating + * using client certificates. We don't use an + * AuthenticationFailedException because that would trigger a + * "Username or password incorrect" notification in + * AccountSetupCheckSettings. + */ + throw CertificateValidationException(e.message) + } + } + + private fun handleAuthenticationFailure( + negativeResponseException: NegativeImapResponseException + ): MessagingException { + val lastResponse = negativeResponseException.lastResponse + val responseCode = ResponseCodeExtractor.getResponseCode(lastResponse) + + // If there's no response code we simply assume it was an authentication failure. + return if (responseCode == null || responseCode == ResponseCodeExtractor.AUTHENTICATION_FAILED) { + if (negativeResponseException.wasByeResponseReceived()) { + close() + } + + AuthenticationFailedException( + message = "Authentication failed", + throwable = negativeResponseException, + messageFromServer = ResponseTextExtractor.getResponseText(lastResponse), + ) + } else { + close() + + negativeResponseException + } + } + + private fun enableCompressionIfRequested() { + if (hasCapability(Capabilities.COMPRESS_DEFLATE) && settings.useCompression) { + enableCompression() + } + } + + private fun enableCompression() { + try { + executeSimpleCommand(Commands.COMPRESS_DEFLATE) + } catch (e: NegativeImapResponseException) { + Timber.d(e, "Unable to negotiate compression: ") + return + } + + try { + val socket = checkNotNull(socket) + val input = InflaterInputStream(socket.getInputStream(), Inflater(true)) + val output = ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true) + output.flushMode = JZlib.Z_PARTIAL_FLUSH + + setUpStreamsAndParser(input, output) + + if (K9MailLib.isDebug()) { + Timber.i("Compression enabled for %s", logId) + } + } catch (e: IOException) { + close() + Timber.e(e, "Error enabling compression") + } + } + + private fun retrievePathPrefixIfNecessary() { + if (settings.pathPrefix != null) return + + if (hasCapability(Capabilities.NAMESPACE)) { + if (K9MailLib.isDebug()) { + Timber.i("pathPrefix is unset and server has NAMESPACE capability") + } + + handleNamespace() + } else { + if (K9MailLib.isDebug()) { + Timber.i("pathPrefix is unset but server does not have NAMESPACE capability") + } + + settings.pathPrefix = "" + } + } + + private fun handleNamespace() { + val responses = executeSimpleCommand(Commands.NAMESPACE) + + val namespaceResponse = NamespaceResponse.parse(responses) ?: return + + settings.pathPrefix = namespaceResponse.prefix + settings.pathDelimiter = namespaceResponse.hierarchyDelimiter + settings.setCombinedPrefix(null) + + if (K9MailLib.isDebug()) { + Timber.d("Got path '%s' and separator '%s'", namespaceResponse.prefix, namespaceResponse.hierarchyDelimiter) + } + } + + private fun retrievePathDelimiterIfNecessary() { + if (settings.pathDelimiter == null) { + retrievePathDelimiter() + } + } + + private fun retrievePathDelimiter() { + val listResponses = try { + executeSimpleCommand(Commands.LIST + " \"\" \"\"") + } catch (e: NegativeImapResponseException) { + Timber.d(e, "Error getting path delimiter using LIST command") + return + } + + for (response in listResponses) { + if (isListResponse(response)) { + val hierarchyDelimiter = response.getString(2) + + settings.pathDelimiter = hierarchyDelimiter + settings.setCombinedPrefix(null) + + if (K9MailLib.isDebug()) { + Timber.d("Got path delimiter '%s' for %s", hierarchyDelimiter, logId) + } + + break + } + } + } + + private fun isListResponse(response: ImapResponse): Boolean { + if (response.size < 4) return false + + val isListResponse = ImapResponseParser.equalsIgnoreCase(response[0], Responses.LIST) + val hierarchyDelimiterValid = response.isString(2) + + return isListResponse && hierarchyDelimiterValid + } + + override fun hasCapability(capability: String): Boolean { + if (!open) { + open() + } + + return capabilities.contains(capability.uppercase()) + } + + private val isCondstoreCapable: Boolean + get() = hasCapability(Capabilities.CONDSTORE) + + override val isIdleCapable: Boolean + get() { + if (K9MailLib.isDebug()) { + Timber.v("Connection %s has %d capabilities", logId, capabilities.size) + } + + return capabilities.contains(Capabilities.IDLE) + } + + override val isUidPlusCapable: Boolean + get() = capabilities.contains(Capabilities.UID_PLUS) + + @Synchronized + override fun close() { + if (!open) return + + open = false + + stacktraceForClose = Exception() + + IOUtils.closeQuietly(inputStream) + IOUtils.closeQuietly(imapOutputStream) + IOUtils.closeQuietly(socket) + + inputStream = null + imapOutputStream = null + socket = null + } + + override val logId: String + get() = "conn" + hashCode() + + @Synchronized + @Throws(IOException::class, MessagingException::class) + override fun executeSimpleCommand(command: String): List<ImapResponse> { + return executeSimpleCommand(command, false) + } + + @Throws(IOException::class, MessagingException::class) + fun executeSimpleCommand(command: String, sensitive: Boolean): List<ImapResponse> { + var commandToLog = command + if (sensitive && !K9MailLib.isDebugSensitive()) { + commandToLog = "*sensitive*" + } + + val tag = sendCommand(command, sensitive) + + val responseParser = checkNotNull(responseParser) + return try { + responseParser.readStatusResponse(tag, commandToLog, logId, null) + } catch (e: IOException) { + close() + throw e + } + } + + @Synchronized + @Throws(IOException::class, MessagingException::class) + override fun executeCommandWithIdSet( + commandPrefix: String, + commandSuffix: String, + ids: Set<Long> + ): List<ImapResponse> { + val groupedIds = IdGrouper.groupIds(ids) + val splitCommands = ImapCommandSplitter.splitCommand( + commandPrefix, + commandSuffix, + groupedIds, + lineLengthLimit + ) + + return splitCommands.flatMap { splitCommand -> + executeSimpleCommand(splitCommand) + } + } + + @Throws(IOException::class, MessagingException::class) + fun sendSaslIrCommand(command: String, initialClientResponse: String, sensitive: Boolean): String { + try { + open() + + val outputStream = checkNotNull(imapOutputStream) + + val tag = (nextCommandTag++).toString() + val commandToSend = "$tag $command $initialClientResponse\r\n" + + outputStream.write(commandToSend.toByteArray()) + outputStream.flush() + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + if (sensitive && !K9MailLib.isDebugSensitive()) { + Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", logId) + } else { + Timber.v("%s>>> %s %s %s", logId, tag, command, initialClientResponse) + } + } + + return tag + } catch (e: IOException) { + close() + throw e + } catch (e: MessagingException) { + close() + throw e + } + } + + @Synchronized + @Throws(MessagingException::class, IOException::class) + override fun sendCommand(command: String, sensitive: Boolean): String { + try { + open() + + val outputStream = checkNotNull(imapOutputStream) + + val tag = (nextCommandTag++).toString() + val commandToSend = "$tag $command\r\n" + + outputStream.write(commandToSend.toByteArray()) + outputStream.flush() + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + if (sensitive && !K9MailLib.isDebugSensitive()) { + Timber.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", logId) + } else { + Timber.v("%s>>> %s %s", logId, tag, command) + } + } + + return tag + } catch (e: IOException) { + close() + throw e + } catch (e: MessagingException) { + close() + throw e + } + } + + @Synchronized + @Throws(IOException::class) + override fun sendContinuation(continuation: String) { + val outputStream = checkNotNull(imapOutputStream) + + outputStream.write(continuation.toByteArray()) + outputStream.write('\r'.code) + outputStream.write('\n'.code) + outputStream.flush() + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + Timber.v("%s>>> %s", logId, continuation) + } + } + + @Throws(IOException::class) + override fun readResponse(): ImapResponse { + return readResponse(null) + } + + @Throws(IOException::class) + override fun readResponse(callback: ImapResponseCallback?): ImapResponse { + try { + val responseParser = checkNotNull(responseParser) + + val response = responseParser.readResponse(callback) + + if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) { + Timber.v("%s<<<%s", logId, response) + } + + return response + } catch (e: IOException) { + close() + throw e + } + } + + private fun readContinuationResponse(tag: String): ImapResponse { + var response: ImapResponse + do { + response = readResponse() + + val responseTag = response.tag + if (responseTag != null) { + if (responseTag.equals(tag, ignoreCase = true)) { + throw MessagingException("Command continuation aborted: $response") + } else { + Timber.w( + "After sending tag %s, got tag response from previous command %s for %s", + tag, + response, + logId + ) + } + } + } while (!response.isContinuationRequested) + + return response + } + + @get:Throws(IOException::class, MessagingException::class) + val lineLengthLimit: Int + get() = if (isCondstoreCapable) LENGTH_LIMIT_WITH_CONDSTORE else LENGTH_LIMIT_WITHOUT_CONDSTORE + + private enum class OAuthMethod { + XOAUTH2 { + override val command: String = Commands.AUTHENTICATE_XOAUTH2 + + override fun buildInitialClientResponse(username: String, token: String): String { + return Authentication.computeXoauth(username, token) + } + }, + OAUTHBEARER { + override val command: String = Commands.AUTHENTICATE_OAUTHBEARER + + override fun buildInitialClientResponse(username: String, token: String): String { + return buildOAuthBearerInitialClientResponse(username, token) + } + }; + + abstract val command: String + abstract fun buildInitialClientResponse(username: String, token: String): String + } + + companion object { + private const val BUFFER_SIZE = 1024 + + /* The below limits are 20 octets less than the recommended limits, in order to compensate for + * the length of the command tag, the space after the tag and the CRLF at the end of the command + * (these are not taken into account when calculating the length of the command). For more + * information, refer to section 4 of RFC 7162. + * + * The length limit for servers supporting the CONDSTORE extension is large in order to support + * the QRESYNC parameter to the SELECT/EXAMINE commands, which accept a list of known message + * sequence numbers as well as their corresponding UIDs. + */ + private const val LENGTH_LIMIT_WITHOUT_CONDSTORE = 980 + private const val LENGTH_LIMIT_WITH_CONDSTORE = 8172 + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt new file mode 100644 index 0000000..46e132e --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt @@ -0,0 +1,1222 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.logging.Timber +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.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.MimeParameterEncoder.isToken +import com.fsck.k9.mail.internet.MimeParameterEncoder.quotedUtf8 +import com.fsck.k9.mail.internet.MimeUtility +import java.io.IOException +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.max +import kotlin.math.min + +internal class RealImapFolder( + private val internalImapStore: InternalImapStore, + private val connectionManager: ImapConnectionManager, + override val serverId: String, + private val folderNameCodec: FolderNameCodec +) : ImapFolder { + private var uidNext = -1L + internal var connection: ImapConnection? = null + private set + private var exists = false + private var inSearch = false + private var canCreateKeywords = false + private var uidValidity: Long? = null + + override var messageCount = -1 + private set + + override var mode: OpenMode? = null + private set + + val isOpen: Boolean + get() = connection != null + + override fun getUidValidity(): Long? { + check(isOpen) { "ImapFolder needs to be open" } + return uidValidity + } + + @get:Throws(MessagingException::class) + private val prefixedName: String + get() { + var prefixedName = "" + if (!INBOX.equals(serverId, ignoreCase = true)) { + val connection = synchronized(this) { + this.connection ?: connectionManager.getConnection() + } + + try { + connection.open() + } catch (ioe: IOException) { + throw MessagingException("Unable to get IMAP prefix", ioe) + } finally { + if (this.connection == null) { + connectionManager.releaseConnection(connection) + } + } + prefixedName = internalImapStore.getCombinedPrefix() + } + prefixedName += serverId + + return prefixedName + } + + @Throws(MessagingException::class, IOException::class) + private fun executeSimpleCommand(command: String): List<ImapResponse> { + return handleUntaggedResponses(connection!!.executeSimpleCommand(command)) + } + + @Throws(MessagingException::class) + override fun open(mode: OpenMode) { + internalOpen(mode) + + if (messageCount == -1) { + throw MessagingException("Did not find message count during open") + } + } + + @Throws(MessagingException::class) + private fun internalOpen(mode: OpenMode): List<ImapResponse> { + 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 (ioe: IOException) { + /* don't throw */ ioExceptionHandler(connection, ioe) + } + } + + connectionManager.releaseConnection(connection) + + synchronized(this) { + connection = connectionManager.getConnection() + } + + try { + val openCommand = if (mode == OpenMode.READ_WRITE) "SELECT" else "EXAMINE" + val encodedFolderName = folderNameCodec.encode(prefixedName) + val escapedFolderName = ImapUtility.encodeString(encodedFolderName) + val command = String.format("%s %s", openCommand, escapedFolderName) + val 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 (response in responses) { + extractUidValidity(response) + handlePermanentFlags(response) + } + + handleSelectOrExamineOkResponse(ImapUtility.getLastResponse(responses)) + + exists = true + + return responses + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } catch (me: MessagingException) { + Timber.e(me, "Unable to open connection for %s", logId) + throw me + } + } + + private fun extractUidValidity(response: ImapResponse) { + val uidValidityResponse = UidValidityResponse.parse(response) + if (uidValidityResponse != null) { + uidValidity = uidValidityResponse.uidValidity + } + } + + private fun handlePermanentFlags(response: ImapResponse) { + val permanentFlagsResponse = PermanentFlagsResponse.parse(response) ?: return + + val permanentFlags = internalImapStore.getPermanentFlagsIndex() + permanentFlags.addAll(permanentFlagsResponse.flags) + canCreateKeywords = permanentFlagsResponse.canCreateKeywords() + } + + private fun handleSelectOrExamineOkResponse(response: ImapResponse) { + val selectOrExamineResponse = SelectOrExamineResponse.parse(response) ?: return // This shouldn't happen + if (selectOrExamineResponse.hasOpenMode()) { + mode = selectOrExamineResponse.openMode + } + } + + override fun 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 { + connectionManager.releaseConnection(connection) + } + + connection = null + } + } + + @Throws(MessagingException::class) + private fun exists(escapedFolderName: String): Boolean { + return 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)) + + true + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } catch (e: NegativeImapResponseException) { + false + } + } + + @Throws(MessagingException::class) + override fun exists(): Boolean { + 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. + */ + val connection = synchronized(this) { + this.connection ?: connectionManager.getConnection() + } + + return try { + val encodedFolderName = folderNameCodec.encode(prefixedName) + val escapedFolderName = ImapUtility.encodeString(encodedFolderName) + connection.executeSimpleCommand(String.format("STATUS %s (UIDVALIDITY)", escapedFolderName)) + + exists = true + + true + } catch (e: NegativeImapResponseException) { + false + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } finally { + if (this.connection == null) { + connectionManager.releaseConnection(connection) + } + } + } + + @Throws(MessagingException::class) + fun create(): Boolean { + /* + * 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. + */ + val connection = synchronized(this) { + this.connection ?: connectionManager.getConnection() + } + + return try { + val encodedFolderName = folderNameCodec.encode(prefixedName) + val escapedFolderName = ImapUtility.encodeString(encodedFolderName) + connection.executeSimpleCommand(String.format("CREATE %s", escapedFolderName)) + + true + } catch (e: NegativeImapResponseException) { + false + } catch (ioe: IOException) { + throw ioExceptionHandler(this.connection, ioe) + } finally { + if (this.connection == null) { + connectionManager.releaseConnection(connection) + } + } + } + + /** + * Copies the given messages to the specified folder. + * + * **Note:** + * Only the UIDs of the given [ImapMessage] instances are used. It is assumed that all + * UIDs represent valid messages in this folder. + * + * @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. + */ + @Throws(MessagingException::class) + override fun copyMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? { + require(folder is RealImapFolder) { "'folder' needs to be a RealImapFolder instance" } + + if (messages.isEmpty()) { + return null + } + + checkOpen() // only need READ access + + val uids = messages.map { it.uid.toLong() }.toSet() + val encodedDestinationFolderName = folderNameCodec.encode(folder.prefixedName) + val escapedDestinationFolderName = ImapUtility.encodeString(encodedDestinationFolderName) + + // TODO: Just perform the operation and only check for existence of the folder if the operation fails. + if (!exists(escapedDestinationFolderName)) { + if (K9MailLib.isDebug()) { + Timber.i( + "ImapFolder.copyMessages: couldn't find remote folder '%s' for %s", + escapedDestinationFolderName, + logId + ) + } + + throw FolderNotFoundException(folder.serverId) + } + + return try { + val imapResponses = connection!!.executeCommandWithIdSet( + Commands.UID_COPY, + escapedDestinationFolderName, + uids + ) + + UidCopyResponse.parse(imapResponses)?.uidMapping + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + @Throws(MessagingException::class) + override fun moveMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? { + if (messages.isEmpty()) { + return null + } + + val uidMapping = copyMessages(messages, folder) + setFlags(messages, setOf(Flag.DELETED), true) + + return uidMapping + } + + @Throws(MessagingException::class) + private fun getRemoteMessageCount(criteria: String): Int { + checkOpen() + + try { + val command = String.format(Locale.US, "SEARCH 1:* %s", criteria) + val responses = executeSimpleCommand(command) + + return responses.sumOf { response -> + if (ImapResponseParser.equalsIgnoreCase(response[0], "SEARCH")) { + response.size - 1 + } else { + 0 + } + } + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + @get:Throws(MessagingException::class) + val unreadMessageCount: Int + get() = getRemoteMessageCount("UNSEEN NOT DELETED") + + @get:Throws(MessagingException::class) + val flaggedMessageCount: Int + get() = getRemoteMessageCount("FLAGGED NOT DELETED") + + @get:Throws(MessagingException::class) + internal val highestUid: Long + get() = try { + val responses = executeSimpleCommand("UID SEARCH *:*") + val searchResponse = SearchResponse.parse(responses) + + extractHighestUid(searchResponse) + } catch (e: NegativeImapResponseException) { + -1L + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + + private fun extractHighestUid(searchResponse: SearchResponse): Long { + return searchResponse.numbers.maxOrNull() ?: -1L + } + + override fun getMessage(uid: String): ImapMessage { + return ImapMessage(uid) + } + + @Throws(MessagingException::class) + override fun getMessages( + start: Int, + end: Int, + earliestDate: Date?, + listener: MessageRetrievalListener<ImapMessage>? + ): List<ImapMessage> { + return getMessages(start, end, earliestDate, false, listener) + } + + @Throws(MessagingException::class) + private fun getMessages( + start: Int, + end: Int, + earliestDate: Date?, + includeDeleted: Boolean, + listener: MessageRetrievalListener<ImapMessage>? + ): List<ImapMessage> { + if (start < 1 || end < 1 || end < start) { + throw MessagingException(String.format(Locale.US, "Invalid message set %d %d", start, end)) + } + + checkOpen() + + val dateSearchString = getDateSearchString(earliestDate) + val command = String.format( + Locale.US, + "UID SEARCH %d:%d%s%s", + start, + end, + dateSearchString, + if (includeDeleted) "" else " NOT DELETED" + ) + + try { + val imapResponses = connection!!.executeSimpleCommand(command) + val searchResponse = SearchResponse.parse(imapResponses) + return getMessages(searchResponse, listener) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + private fun getDateSearchString(earliestDate: Date?): String { + return if (earliestDate == null) { + "" + } else { + " SINCE " + RFC3501_DATE.get()!!.format(earliestDate) + } + } + + @Throws(IOException::class, MessagingException::class) + override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean { + checkOpen() + + if (indexOfOldestMessage == 1) { + return false + } + + var endIndex = indexOfOldestMessage - 1 + val dateSearchString = getDateSearchString(earliestDate) + + while (endIndex > 0) { + val startIndex = max(0, endIndex - MORE_MESSAGES_WINDOW_SIZE) + 1 + if (existsNonDeletedMessageInRange(startIndex, endIndex, dateSearchString)) { + return true + } + endIndex -= MORE_MESSAGES_WINDOW_SIZE + } + + return false + } + + @Throws(MessagingException::class, IOException::class) + private fun existsNonDeletedMessageInRange(startIndex: Int, endIndex: Int, dateSearchString: String): Boolean { + val command = String.format( + Locale.US, + "SEARCH %d:%d%s NOT DELETED", + startIndex, + endIndex, + dateSearchString + ) + val imapResponses = executeSimpleCommand(command) + + val response = SearchResponse.parse(imapResponses) + return response.numbers.size > 0 + } + + @Throws(MessagingException::class) + internal fun getMessages( + mesgSeqs: Set<Long>, + includeDeleted: Boolean, + listener: MessageRetrievalListener<ImapMessage>? + ): List<ImapMessage> { + checkOpen() + + try { + val commandSuffix = if (includeDeleted) "" else " NOT DELETED" + val imapResponses = connection!!.executeCommandWithIdSet(Commands.UID_SEARCH, commandSuffix, mesgSeqs) + + val searchResponse = SearchResponse.parse(imapResponses) + return getMessages(searchResponse, listener) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + @Throws(MessagingException::class) + internal fun getMessagesFromUids(mesgUids: List<String>): List<ImapMessage> { + checkOpen() + + val uidSet = mesgUids.map { it.toLong() }.toSet() + + try { + val imapResponses = connection!!.executeCommandWithIdSet("UID SEARCH UID", "", uidSet) + + val searchResponse = SearchResponse.parse(imapResponses) + return getMessages(searchResponse, null) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + private fun getMessages( + searchResponse: SearchResponse, + listener: MessageRetrievalListener<ImapMessage>? + ): List<ImapMessage> { + // 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. + val uids = searchResponse.numbers.sortedDescending() + + return uids.map { uidLong -> + val uid = uidLong.toString() + val message = ImapMessage(uid) + listener?.messageFinished(message) + + message + } + } + + @Throws(MessagingException::class) + override fun fetch( + messages: List<ImapMessage>, + fetchProfile: FetchProfile, + listener: FetchListener?, + maxDownloadSize: Int + ) { + if (messages.isEmpty()) { + return + } + + checkOpen() + + val messageMap = messages.associateBy { it.uid } + val uids = messages.map { it.uid } + + val fetchFields: MutableSet<String> = 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 bcc " + + "reply-to message-id references in-reply-to list-unsubscribe sender " + + K9MailLib.IDENTITY_HEADER + " " + K9MailLib.CHAT_HEADER + ")]" + ) + } + + if (fetchProfile.contains(FetchProfile.Item.STRUCTURE)) { + fetchFields.add("BODYSTRUCTURE") + } + + if (fetchProfile.contains(FetchProfile.Item.BODY_SANE)) { + if (maxDownloadSize > 0) { + fetchFields.add(String.format(Locale.US, "BODY.PEEK[]<0.%d>", maxDownloadSize)) + } else { + fetchFields.add("BODY.PEEK[]") + } + } + + if (fetchProfile.contains(FetchProfile.Item.BODY)) { + fetchFields.add("BODY.PEEK[]") + } + + val spaceSeparatedFetchFields = ImapUtility.join(" ", fetchFields) + var windowStart = 0 + val processedUids = mutableSetOf<String>() + while (windowStart < messages.size) { + val windowEnd = min(windowStart + FETCH_WINDOW_SIZE, messages.size) + val uidWindow = uids.subList(windowStart, windowEnd) + + try { + val commaSeparatedUids = ImapUtility.join(",", uidWindow) + val command = String.format("UID FETCH %s (%s)", commaSeparatedUids, spaceSeparatedFetchFields) + connection!!.sendCommand(command, false) + + var callback: ImapResponseCallback? = null + if (fetchProfile.contains(FetchProfile.Item.BODY) || + fetchProfile.contains(FetchProfile.Item.BODY_SANE) + ) { + callback = FetchBodyCallback(messageMap) + } + + var response: ImapResponse + do { + response = connection!!.readResponse(callback) + if (response.tag == null && ImapResponseParser.equalsIgnoreCase(response[1], "FETCH")) { + val fetchList = response.getKeyedValue("FETCH") as ImapList + val uid = fetchList.getKeyedString("UID") + + val message = messageMap[uid] + if (message == null) { + if (K9MailLib.isDebug()) { + Timber.d("Do not have message in messageMap for UID %s for %s", uid, logId) + } + handleUntaggedResponse(response) + continue + } + + val literal = handleFetchResponse(message, fetchList) + if (literal != null) { + when (literal) { + is String -> { + val bodyStream: InputStream = literal.toByteArray().inputStream() + message.parse(bodyStream) + } + is Int -> { + // All the work was done in FetchBodyCallback.foundLiteral() + } + else -> { + // This shouldn't happen + throw MessagingException("Got FETCH response with bogus parameters") + } + } + } + + val isFirstResponse = uid !in processedUids + processedUids.add(uid) + + listener?.onFetchResponse(message, isFirstResponse) + } else { + handleUntaggedResponse(response) + } + } while (response.tag == null) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + + windowStart += FETCH_WINDOW_SIZE + } + } + + @Throws(MessagingException::class) + override fun fetchPart( + message: ImapMessage, + part: Part, + bodyFactory: BodyFactory, + maxDownloadSize: Int + ) { + checkOpen() + + val partId = part.serverExtra + + val fetch = if ("TEXT".equals(partId, ignoreCase = true)) { + String.format(Locale.US, "BODY.PEEK[TEXT]<0.%d>", maxDownloadSize) + } else { + String.format("BODY.PEEK[%s]", partId) + } + + try { + val command = String.format("UID FETCH %s (UID %s)", message.uid, fetch) + connection!!.sendCommand(command, false) + + val callback: ImapResponseCallback = FetchPartCallback(part, bodyFactory) + + var response: ImapResponse + do { + response = connection!!.readResponse(callback) + + if (response.tag == null && ImapResponseParser.equalsIgnoreCase(response[1], "FETCH")) { + val fetchList = response.getKeyedValue("FETCH") as ImapList + val uid = fetchList.getKeyedString("UID") + if (message.uid != uid) { + if (K9MailLib.isDebug()) { + Timber.d("Did not ask for UID %s for %s", uid, logId) + } + handleUntaggedResponse(response) + continue + } + + val literal = handleFetchResponse(message, fetchList) + if (literal != null) { + when (literal) { + is Body -> { + // Most of the work was done in FetchAttachmentCallback.foundLiteral() + MimeMessageHelper.setBody(part, literal as Body?) + } + is String -> { + val bodyStream: InputStream = literal.toByteArray().inputStream() + val contentTransferEncoding = + part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)[0] + val contentType = part.getHeader(MimeHeader.HEADER_CONTENT_TYPE)[0] + val body = bodyFactory.createBody(contentTransferEncoding, contentType, bodyStream) + MimeMessageHelper.setBody(part, body) + } + else -> { + // This shouldn't happen + throw MessagingException("Got FETCH response with bogus parameters") + } + } + } + } else { + handleUntaggedResponse(response) + } + } while (response.tag == null) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + // Returns value of body field + @Throws(MessagingException::class) + private fun handleFetchResponse(message: ImapMessage, fetchList: ImapList): Any? { + var result: Any? = null + if (fetchList.containsKey("FLAGS")) { + val flags = fetchList.getKeyedList("FLAGS") + if (flags != null) { + for (i in flags.indices) { + val flag = flags.getString(i) + when { + flag.equals("\\Deleted", ignoreCase = true) -> { + message.setFlag(Flag.DELETED, true) + } + flag.equals("\\Answered", ignoreCase = true) -> { + message.setFlag(Flag.ANSWERED, true) + } + flag.equals("\\Seen", ignoreCase = true) -> { + message.setFlag(Flag.SEEN, true) + } + flag.equals("\\Flagged", ignoreCase = true) -> { + message.setFlag(Flag.FLAGGED, true) + } + flag.equals("\$Forwarded", ignoreCase = true) -> { + message.setFlag(Flag.FORWARDED, true) + // a message contains FORWARDED FLAG -> so we can also create them + internalImapStore.getPermanentFlagsIndex().add(Flag.FORWARDED) + } + flag.equals("\\Draft", ignoreCase = true) -> { + message.setFlag(Flag.DRAFT, true) + } + } + } + } + } + + if (fetchList.containsKey("INTERNALDATE")) { + message.internalDate = fetchList.getKeyedDate("INTERNALDATE") + } + + if (fetchList.containsKey("RFC822.SIZE")) { + val size = fetchList.getKeyedNumber("RFC822.SIZE") + message.setSize(size) + } + + if (fetchList.containsKey("BODYSTRUCTURE")) { + val bs = fetchList.getKeyedList("BODYSTRUCTURE") + if (bs != null) { + try { + parseBodyStructure(bs, message, "TEXT") + } catch (e: MessagingException) { + if (K9MailLib.isDebug()) { + Timber.d(e, "Error handling message for %s", logId) + } + message.body = null + } + } + } + + if (fetchList.containsKey("BODY")) { + val index = fetchList.getKeyIndex("BODY") + 2 + val size = fetchList.size + if (index < size) { + result = fetchList.getObject(index) + + // Check if there's an origin octet + if (result is String) { + if (result.startsWith("<") && index + 1 < size) { + result = fetchList.getObject(index + 1) + } + } + } + } + + return result + } + + private fun handleUntaggedResponses(responses: List<ImapResponse>): List<ImapResponse> { + for (response in responses) { + handleUntaggedResponse(response) + } + return responses + } + + private fun handlePossibleUidNext(response: ImapResponse) { + if (ImapResponseParser.equalsIgnoreCase(response[0], "OK") && response.size > 1) { + val bracketed = response[1] as? ImapList ?: return + val key = bracketed.firstOrNull() as? String ?: return + if ("UIDNEXT".equals(key, ignoreCase = true)) { + uidNext = bracketed.getLong(1) + if (K9MailLib.isDebug()) { + Timber.d("Got UidNext = %s for %s", uidNext, logId) + } + } + } + } + + /** + * Handle an untagged response that the caller doesn't care to handle themselves. + */ + private fun handleUntaggedResponse(response: ImapResponse) { + if (response.tag == null && response.size > 1) { + if (ImapResponseParser.equalsIgnoreCase(response[1], "EXISTS")) { + messageCount = response.getNumber(0) + if (K9MailLib.isDebug()) { + Timber.d("Got untagged EXISTS with value %d for %s", messageCount, logId) + } + } + + handlePossibleUidNext(response) + + if (ImapResponseParser.equalsIgnoreCase(response[1], "EXPUNGE") && messageCount > 0) { + messageCount-- + if (K9MailLib.isDebug()) { + Timber.d("Got untagged EXPUNGE with messageCount %d for %s", messageCount, logId) + } + } + } + } + + @Throws(MessagingException::class) + private fun parseBodyStructure(bs: ImapList, part: Part, id: String) { + if (bs[0] is ImapList) { + // This is a multipart + val mp = MimeMultipart.newInstance() + for (i in bs.indices) { + if (bs[i] is ImapList) { + // For each part in the message we're going to add a new BodyPart and parse into it. + val bp = MimeBodyPart() + val bodyPartId = (i + 1).toString() + if (id.equals("TEXT", ignoreCase = true)) { + parseBodyStructure(bs.getList(i), bp, bodyPartId) + } else { + parseBodyStructure(bs.getList(i), bp, "$id.$bodyPartId") + } + 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. + val subType = bs.getString(i) + mp.setSubType(subType.lowercase()) + 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) + */ + val type = bs.getString(0) + val subType = bs.getString(1) + val mimeType = "$type/$subType".lowercase() + + var bodyParams: ImapList? = null + if (bs[2] is ImapList) { + bodyParams = bs.getList(2) + } + val encoding = bs.getString(5) + val 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 MessagingException("BODYSTRUCTURE message/rfc822 not yet supported.") + } + + // Set the content type with as much information as we know right now. + val contentType = 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 (i in bodyParams.indices step 2) { + val paramName = bodyParams.getString(i) + val paramValue = bodyParams.getString(i + 1) + val encodedValue = if (paramValue.isToken()) paramValue else paramValue.quotedUtf8() + contentType.append(String.format(";\r\n %s=%s", paramName, encodedValue)) + } + } + + part.setHeader(MimeHeader.HEADER_CONTENT_TYPE, contentType.toString()) + + // Extension items + var bodyDisposition: ImapList? = null + if ("text".equals(type, ignoreCase = true) && bs.size > 9 && bs[9] is ImapList) { + bodyDisposition = bs.getList(9) + } else if (!"text".equals(type, ignoreCase = true) && bs.size > 8 && bs[8] is ImapList) { + bodyDisposition = bs.getList(8) + } + + val contentDisposition = StringBuilder() + if (bodyDisposition != null && !bodyDisposition.isEmpty()) { + if (!"NIL".equals(bodyDisposition.getString(0), ignoreCase = true)) { + contentDisposition.append(bodyDisposition.getString(0).lowercase()) + } + if (bodyDisposition.size > 1 && bodyDisposition[1] is ImapList) { + val bodyDispositionParams = bodyDisposition.getList(1) + // If there is body disposition information we can pull some more information + // about the attachment out. + for (i in bodyDispositionParams.indices step 2) { + val paramName = bodyDispositionParams.getString(i).lowercase() + val paramValue = bodyDispositionParams.getString(i + 1) + val encodedValue = if (paramValue.isToken()) paramValue else paramValue.quotedUtf8() + contentDisposition.append(String.format(";\r\n %s=%s", paramName, encodedValue)) + } + } + } + + 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 is ImapMessage) { + part.setSize(size) + } + + part.serverExtra = id + } + } + + /** + * Appends the given messages to the selected folder. + * + * 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. + * + * @param messages + * The messages to append to the folder. + * + * @return The mapping of original message UIDs to the new server UIDs. + */ + @Throws(MessagingException::class) + override fun appendMessages(messages: List<Message>): Map<String, String>? { + open(OpenMode.READ_WRITE) + checkOpen() + + return try { + val uidMap: MutableMap<String, String> = HashMap() + for (message in messages) { + val messageSize = message.calculateSize() + + val encodeFolderName = folderNameCodec.encode(prefixedName) + val escapedFolderName = ImapUtility.encodeString(encodeFolderName) + val combinedFlags = ImapUtility.combineFlags( + message.flags, + canCreateKeywords || internalImapStore.getPermanentFlagsIndex().contains(Flag.FORWARDED) + ) + val command = String.format( + Locale.US, + "APPEND %s (%s) {%d}", + escapedFolderName, + combinedFlags, + messageSize + ) + connection!!.sendCommand(command, false) + + var response: ImapResponse + do { + response = connection!!.readResponse() + + handleUntaggedResponse(response) + + if (response.isContinuationRequested) { + val eolOut = EOLConvertingOutputStream(connection!!.outputStream) + message.writeTo(eolOut) + eolOut.write('\r'.code) + eolOut.write('\n'.code) + eolOut.flush() + } + } while (response.tag == null) + + if (response.size < 1 || !ImapResponseParser.equalsIgnoreCase(response[0], Responses.OK)) { + throw NegativeImapResponseException("APPEND failed", listOf(response)) + } else 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. + */ + val appendList = response[1] + if (appendList is ImapList) { + if (appendList.size >= 3 && appendList.getString(0) == "APPENDUID") { + val newUid = appendList.getString(2) + if (newUid.isNotEmpty()) { + uidMap[message.uid] = newUid + message.uid = newUid + continue + } + } + } + } + + // This part is executed in case the server does not support UIDPLUS or does not implement the + // APPENDUID response code. + val messageId = extractMessageId(message) + val newUid = messageId?.let { getUidFromMessageId(it) } + if (K9MailLib.isDebug()) { + Timber.d("Got UID %s for message for %s", newUid, logId) + } + + newUid?.let { + uidMap[message.uid] = newUid + message.uid = 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. + if (uidMap.isEmpty()) null else uidMap + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + private fun extractMessageId(message: Message): String? { + return message.getHeader("Message-ID").firstOrNull() + } + + @Throws(MessagingException::class) + override fun getUidFromMessageId(messageId: String): String? { + if (K9MailLib.isDebug()) { + Timber.d("Looking for UID for message with message-id %s for %s", messageId, logId) + } + + val command = String.format("UID SEARCH HEADER MESSAGE-ID %s", ImapUtility.encodeString(messageId)) + val imapResponses = try { + executeSimpleCommand(command) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + + val searchResponse = SearchResponse.parse(imapResponses) + return searchResponse.numbers.firstOrNull()?.toString() + } + + @Throws(MessagingException::class) + override fun expunge() { + open(OpenMode.READ_WRITE) + checkOpen() + + try { + executeSimpleCommand("EXPUNGE") + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + @Throws(MessagingException::class) + override fun expungeUids(uids: List<String>) { + require(uids.isNotEmpty()) { "expungeUids() must be called with a non-empty set of UIDs" } + + open(OpenMode.READ_WRITE) + checkOpen() + + try { + if (connection!!.isUidPlusCapable) { + val longUids = uids.map { it.toLong() }.toSet() + connection!!.executeCommandWithIdSet(Commands.UID_EXPUNGE, "", longUids) + } else { + executeSimpleCommand("EXPUNGE") + } + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + @Throws(MessagingException::class) + override fun setFlags(flags: Set<Flag>, value: Boolean) { + open(OpenMode.READ_WRITE) + checkOpen() + + val canCreateForwardedFlag = canCreateKeywords || internalImapStore.getPermanentFlagsIndex().contains(Flag.FORWARDED) + try { + val combinedFlags = ImapUtility.combineFlags(flags, canCreateForwardedFlag) + val command = String.format( + "%s 1:* %sFLAGS.SILENT (%s)", + Commands.UID_STORE, + if (value) "+" else "-", + combinedFlags + ) + + executeSimpleCommand(command) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + @Throws(MessagingException::class) + override fun setFlags(messages: List<ImapMessage>, flags: Set<Flag>, value: Boolean) { + open(OpenMode.READ_WRITE) + checkOpen() + + val uids = messages.map { it.uid.toLong() }.toSet() + val canCreateForwardedFlag = canCreateKeywords || internalImapStore.getPermanentFlagsIndex().contains(Flag.FORWARDED) + val combinedFlags = ImapUtility.combineFlags(flags, canCreateForwardedFlag) + val commandSuffix = String.format("%sFLAGS.SILENT (%s)", if (value) "+" else "-", combinedFlags) + try { + connection!!.executeCommandWithIdSet(Commands.UID_STORE, commandSuffix, uids) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } + + @Throws(MessagingException::class) + private fun checkOpen() { + if (!isOpen) { + throw MessagingException("Folder $prefixedName is not open.") + } + } + + private fun ioExceptionHandler(connection: ImapConnection?, ioe: IOException): MessagingException { + Timber.e(ioe, "IOException for %s", logId) + connection?.close() + close() + return MessagingException("IO Error", ioe) + } + + override fun equals(other: Any?): Boolean { + if (other is ImapFolder) { + return other.serverId == serverId + } + return super.equals(other) + } + + override fun hashCode(): Int { + return serverId.hashCode() + } + + private val logId: String + get() { + var id = internalImapStore.logLabel + ":" + serverId + "/" + Thread.currentThread().name + if (connection != null) { + id += "/" + connection!!.logId + } + return id + } + + /** + * Search the remote ImapFolder. + */ + @Throws(MessagingException::class) + override fun search( + queryString: String?, + requiredFlags: Set<Flag>?, + forbiddenFlags: Set<Flag>?, + performFullTextSearch: Boolean + ): List<ImapMessage> { + try { + open(OpenMode.READ_ONLY) + checkOpen() + + inSearch = true + + val searchCommand = UidSearchCommandBuilder() + .queryString(queryString) + .performFullTextSearch(performFullTextSearch) + .requiredFlags(requiredFlags) + .forbiddenFlags(forbiddenFlags) + .build() + + try { + val imapResponses = executeSimpleCommand(searchCommand) + val searchResponse = SearchResponse.parse(imapResponses) + + return getMessages(searchResponse, null) + } catch (ioe: IOException) { + throw ioExceptionHandler(connection, ioe) + } + } finally { + inSearch = false + } + } + + companion object { + private const val MORE_MESSAGES_WINDOW_SIZE = 500 + private const val FETCH_WINDOW_SIZE = 100 + + const val INBOX = "INBOX" + + private val RFC3501_DATE: ThreadLocal<SimpleDateFormat> = object : ThreadLocal<SimpleDateFormat>() { + override fun initialValue(): SimpleDateFormat { + return SimpleDateFormat("dd-MMM-yyyy", Locale.US) + } + } + } +} + +enum class OpenMode { + READ_WRITE, + READ_ONLY +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolderIdler.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolderIdler.kt new file mode 100644 index 0000000..12b55d6 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolderIdler.kt @@ -0,0 +1,195 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.logging.Timber +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.power.WakeLock +import java.io.IOException + +private const val SOCKET_EXTRA_TIMEOUT_MS = 2 * 60 * 1000L + +internal class RealImapFolderIdler( + private val idleRefreshManager: IdleRefreshManager, + private val wakeLock: WakeLock, + private val imapStore: ImapStore, + private val connectionProvider: ImapConnectionProvider, + private val folderServerId: String, + private val idleRefreshTimeoutProvider: IdleRefreshTimeoutProvider +) : ImapFolderIdler { + private val logTag = "ImapFolderIdler[$folderServerId]" + + private var folder: ImapFolder? = null + + @get:Synchronized + @set:Synchronized + private var idleRefreshTimer: IdleRefreshTimer? = null + + @Volatile + private var stopIdle = false + + private var idleSent = false + private var doneSent = false + + override fun idle(): IdleResult { + Timber.v("%s.idle()", logTag) + + val folder = imapStore.getFolder(folderServerId).also { this.folder = it } + folder.open(OpenMode.READ_ONLY) + + try { + return folder.idle().also { idleResult -> + Timber.v("%s.idle(): result=%s", logTag, idleResult) + } + } finally { + folder.close() + } + } + + @Synchronized + override fun refresh() { + Timber.v("%s.refresh()", logTag) + endIdle() + } + + @Synchronized + override fun stop() { + Timber.v("%s.stop()", logTag) + stopIdle = true + endIdle() + } + + private fun endIdle() { + if (idleSent && !doneSent) { + idleRefreshTimer?.cancel() + + try { + sendDone() + } catch (e: IOException) { + Timber.v(e, "%s: IOException while sending DONE", logTag) + } + } + } + + private fun ImapFolder.idle(): IdleResult { + var result = IdleResult.STOPPED + + val connection = connectionProvider.getConnection(this)!! + if (!connection.isIdleCapable) { + Timber.w("%s: IDLE not supported by server", logTag) + return IdleResult.NOT_SUPPORTED + } + + stopIdle = false + do { + synchronized(this) { + idleSent = false + doneSent = false + } + + val tag = connection.sendCommand("IDLE", false) + + synchronized(this) { + idleSent = true + } + + var receivedRelevantResponse = false + do { + val response = connection.readResponse() + if (response.tag == tag) { + Timber.w("%s.idle(): IDLE command completed without a continuation request response", logTag) + return IdleResult.NOT_SUPPORTED + } else if (response.isRelevant) { + receivedRelevantResponse = true + } + } while (!response.isContinuationRequested) + + if (receivedRelevantResponse) { + Timber.v("%s.idle(): Received a relevant untagged response right after sending IDLE command", logTag) + result = IdleResult.SYNC + stopIdle = true + sendDone() + } else { + connection.setSocketIdleReadTimeout() + } + + var response: ImapResponse + do { + idleRefreshTimer = idleRefreshManager.startTimer( + timeout = idleRefreshTimeoutProvider.idleRefreshTimeoutMs, + callback = ::idleRefresh + ) + + wakeLock.release() + + try { + response = connection.readResponse() + } finally { + wakeLock.acquire() + idleRefreshTimer?.cancel() + } + + if (response.isRelevant && !stopIdle) { + Timber.v("%s.idle(): Received a relevant untagged response during IDLE", logTag) + result = IdleResult.SYNC + stopIdle = true + sendDone() + } else if (!response.isTagged) { + Timber.v("%s.idle(): Ignoring untagged response", logTag) + } + } while (response.tag != tag) + + if (!response.isOk) { + throw MessagingException("Received non-OK response to IDLE command") + } + } while (!stopIdle) + + return result + } + + @Synchronized + private fun idleRefresh() { + Timber.v("%s.idleRefresh()", logTag) + + if (!idleSent || doneSent) { + Timber.v("%s: Connection is not in a state where it can be refreshed.", logTag) + return + } + + try { + sendDone() + } catch (e: IOException) { + Timber.v(e, "%s: IOException while sending DONE", logTag) + } + } + + @Synchronized + private fun sendDone() { + val folder = folder ?: return + val connection = connectionProvider.getConnection(folder) ?: return + + synchronized(connection) { + if (connection.isConnected) { + doneSent = true + connection.setSocketDefaultReadTimeout() + connection.sendContinuation("DONE") + } + } + } + + private fun ImapConnection.setSocketIdleReadTimeout() { + setSocketReadTimeout((idleRefreshTimeoutProvider.idleRefreshTimeoutMs + SOCKET_EXTRA_TIMEOUT_MS).toInt()) + } + + private val ImapResponse.isRelevant: Boolean + get() { + return if (!isTagged && size >= 2) { + ImapResponseParser.equalsIgnoreCase(get(1), "EXISTS") || + ImapResponseParser.equalsIgnoreCase(get(1), "EXPUNGE") || + ImapResponseParser.equalsIgnoreCase(get(1), "FETCH") + } else { + false + } + } + + private val ImapResponse.isOk: Boolean + get() = isTagged && size >= 1 && ImapResponseParser.equalsIgnoreCase(get(0), Responses.OK) +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.kt new file mode 100644 index 0000000..70914a6 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.kt @@ -0,0 +1,321 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.logging.Timber +import com.fsck.k9.mail.AuthType +import com.fsck.k9.mail.AuthenticationFailedException +import com.fsck.k9.mail.ConnectionSecurity +import com.fsck.k9.mail.Flag +import com.fsck.k9.mail.FolderType +import com.fsck.k9.mail.MessagingException +import com.fsck.k9.mail.ServerSettings +import com.fsck.k9.mail.oauth.OAuth2TokenProvider +import com.fsck.k9.mail.ssl.TrustedSocketFactory +import com.fsck.k9.mail.store.imap.ImapStoreSettings.autoDetectNamespace +import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix +import java.io.IOException +import java.util.Deque +import java.util.LinkedList + +internal open class RealImapStore( + private val serverSettings: ServerSettings, + private val config: ImapStoreConfig, + private val trustedSocketFactory: TrustedSocketFactory, + private val oauthTokenProvider: OAuth2TokenProvider? +) : ImapStore, ImapConnectionManager, InternalImapStore { + private val folderNameCodec: FolderNameCodec = FolderNameCodec() + + private val host: String = checkNotNull(serverSettings.host) + + private var pathPrefix: String? + private var combinedPrefix: String? = null + private var pathDelimiter: String? = null + + private val permanentFlagsIndex: MutableSet<Flag> = mutableSetOf() + private val connections: Deque<ImapConnection> = LinkedList() + + @Volatile + private var connectionGeneration = 1 + + init { + val autoDetectNamespace = serverSettings.autoDetectNamespace + val pathPrefixSetting = serverSettings.pathPrefix + + // Make extra sure pathPrefix is null if "auto-detect namespace" is configured + pathPrefix = if (autoDetectNamespace) null else pathPrefixSetting + } + + override fun getFolder(name: String): ImapFolder { + return RealImapFolder( + internalImapStore = this, + connectionManager = this, + serverId = name, + folderNameCodec = folderNameCodec + ) + } + + override fun getCombinedPrefix(): String { + return combinedPrefix ?: buildCombinedPrefix().also { combinedPrefix = it } + } + + private fun buildCombinedPrefix(): String { + val pathPrefix = pathPrefix ?: return "" + + val trimmedPathPrefix = pathPrefix.trim { it <= ' ' } + val trimmedPathDelimiter = pathDelimiter?.trim { it <= ' ' }.orEmpty() + + return if (trimmedPathPrefix.endsWith(trimmedPathDelimiter)) { + trimmedPathPrefix + } else if (trimmedPathPrefix.isNotEmpty()) { + trimmedPathPrefix + trimmedPathDelimiter + } else { + "" + } + } + + @Throws(MessagingException::class) + override fun getFolders(): List<FolderListItem> { + val connection = getConnection() + + return try { + val folders = listFolders(connection, false) + if (!config.isSubscribedFoldersOnly()) { + return folders + } + + val subscribedFolders = listFolders(connection, true) + limitToSubscribedFolders(folders, subscribedFolders) + } catch (e: AuthenticationFailedException) { + connection.close() + throw e + } catch (e: IOException) { + connection.close() + throw MessagingException("Unable to get folder list.", e) + } catch (e: MessagingException) { + connection.close() + throw MessagingException("Unable to get folder list.", e) + } finally { + releaseConnection(connection) + } + } + + private fun limitToSubscribedFolders( + folders: List<FolderListItem>, + subscribedFolders: List<FolderListItem> + ): List<FolderListItem> { + val subscribedFolderServerIds = subscribedFolders.map { it.serverId }.toSet() + return folders.filter { it.serverId in subscribedFolderServerIds } + } + + @Throws(IOException::class, MessagingException::class) + private fun listFolders(connection: ImapConnection, subscribedOnly: Boolean): List<FolderListItem> { + val commandFormat = when { + subscribedOnly -> { + "LSUB \"\" %s" + } + connection.supportsListExtended -> { + "LIST \"\" %s RETURN (SPECIAL-USE)" + } + else -> { + "LIST \"\" %s" + } + } + + val encodedListPrefix = ImapUtility.encodeString(getCombinedPrefix() + "*") + val responses = connection.executeSimpleCommand(String.format(commandFormat, encodedListPrefix)) + + val listResponses = if (subscribedOnly) { + ListResponse.parseLsub(responses) + } else { + ListResponse.parseList(responses) + } + + val folderMap = mutableMapOf<String, FolderListItem>() + for (listResponse in listResponses) { + val serverId = listResponse.name + + if (pathDelimiter == null) { + pathDelimiter = listResponse.hierarchyDelimiter + combinedPrefix = null + } + + if (RealImapFolder.INBOX.equals(serverId, ignoreCase = true)) { + continue + } else if (listResponse.hasAttribute("\\NoSelect")) { + continue + } + + val name = getFolderDisplayName(serverId) + val oldServerId = getOldServerId(serverId) + + val type = when { + listResponse.hasAttribute("\\Archive") -> FolderType.ARCHIVE + listResponse.hasAttribute("\\All") -> FolderType.ARCHIVE + listResponse.hasAttribute("\\Drafts") -> FolderType.DRAFTS + listResponse.hasAttribute("\\Sent") -> FolderType.SENT + listResponse.hasAttribute("\\Junk") -> FolderType.SPAM + listResponse.hasAttribute("\\Trash") -> FolderType.TRASH + else -> FolderType.REGULAR + } + + val existingItem = folderMap[serverId] + if (existingItem == null || existingItem.type == FolderType.REGULAR) { + folderMap[serverId] = FolderListItem(serverId, name, type, oldServerId) + } + } + + return buildList { + add(FolderListItem(RealImapFolder.INBOX, RealImapFolder.INBOX, FolderType.INBOX, RealImapFolder.INBOX)) + addAll(folderMap.values) + } + } + + private fun getFolderDisplayName(serverId: String): String { + val decodedFolderName = try { + folderNameCodec.decode(serverId) + } catch (e: CharacterCodingException) { + Timber.w(e, "Folder name not correctly encoded with the UTF-7 variant as defined by RFC 3501: %s", serverId) + serverId + } + + val folderNameWithoutPrefix = removePrefixFromFolderName(decodedFolderName) + return folderNameWithoutPrefix ?: decodedFolderName + } + + private fun getOldServerId(serverId: String): String? { + val decodedFolderName = try { + folderNameCodec.decode(serverId) + } catch (e: CharacterCodingException) { + // Previous versions of K-9 Mail ignored folders with invalid UTF-7 encoding + return null + } + + return removePrefixFromFolderName(decodedFolderName) + } + + private fun removePrefixFromFolderName(folderName: String): String? { + val prefix = getCombinedPrefix() + val prefixLength = prefix.length + if (prefixLength == 0) { + return folderName + } + + if (!folderName.startsWith(prefix)) { + // Folder name doesn't start with our configured prefix. But right now when building commands we prefix all + // folders except the INBOX with the prefix. So we won't be able to use this folder. + return null + } + + return folderName.substring(prefixLength) + } + + @Throws(MessagingException::class) + override fun checkSettings() { + try { + val connection = createImapConnection() + + connection.open() + connection.close() + } catch (e: IOException) { + throw MessagingException("Unable to connect", e) + } + } + + @Throws(MessagingException::class) + override fun getConnection(): ImapConnection { + while (true) { + val connection = pollConnection() ?: return createImapConnection() + + try { + connection.executeSimpleCommand(Commands.NOOP) + + // If the command completes without an error this connection is still usable. + return connection + } catch (ioe: IOException) { + connection.close() + } + } + } + + private fun pollConnection(): ImapConnection? { + return synchronized(connections) { + connections.poll() + } + } + + override fun releaseConnection(connection: ImapConnection?) { + if (connection != null && connection.isConnected) { + if (connection.connectionGeneration == connectionGeneration) { + synchronized(connections) { + connections.offer(connection) + } + } else { + connection.close() + } + } + } + + override fun closeAllConnections() { + Timber.v("ImapStore.closeAllConnections()") + + val connectionsToClose = synchronized(connections) { + val connectionsToClose = connections.toList() + + connectionGeneration++ + connections.clear() + + connectionsToClose + } + + for (connection in connectionsToClose) { + connection.close() + } + } + + open fun createImapConnection(): ImapConnection { + return RealImapConnection( + StoreImapSettings(), + trustedSocketFactory, + oauthTokenProvider, + connectionGeneration + ) + } + + override val logLabel: String + get() = config.logLabel + + override fun getPermanentFlagsIndex(): MutableSet<Flag> { + return permanentFlagsIndex + } + + private inner class StoreImapSettings : ImapSettings { + override val host: String = this@RealImapStore.host + override val port: Int = serverSettings.port + override val connectionSecurity: ConnectionSecurity = serverSettings.connectionSecurity + override val authType: AuthType = serverSettings.authenticationType + override val username: String = serverSettings.username + override val password: String? = serverSettings.password + override val clientCertificateAlias: String? = serverSettings.clientCertificateAlias + + override val useCompression: Boolean + get() = this@RealImapStore.config.useCompression() + + override var pathPrefix: String? + get() = this@RealImapStore.pathPrefix + set(value) { + this@RealImapStore.pathPrefix = value + } + + override var pathDelimiter: String? + get() = this@RealImapStore.pathDelimiter + set(value) { + this@RealImapStore.pathDelimiter = value + } + + override fun setCombinedPrefix(prefix: String?) { + combinedPrefix = prefix + } + } +} + +private val ImapConnection.supportsListExtended: Boolean + get() = hasCapability(Capabilities.SPECIAL_USE) && hasCapability(Capabilities.LIST_EXTENDED) diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractor.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractor.java new file mode 100644 index 0000000..2f622c4 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractor.java @@ -0,0 +1,19 @@ +package com.fsck.k9.mail.store.imap; + + +class ResponseCodeExtractor { + public static final String AUTHENTICATION_FAILED = "AUTHENTICATIONFAILED"; + + + private ResponseCodeExtractor() { + } + + public static String getResponseCode(ImapResponse response) { + if (response.size() < 2 || !response.isList(1)) { + return null; + } + + ImapList responseTextCode = response.getList(1); + return responseTextCode.size() != 1 ? null : responseTextCode.getString(0); + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseTextExtractor.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseTextExtractor.kt new file mode 100644 index 0000000..9c49118 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseTextExtractor.kt @@ -0,0 +1,27 @@ +package com.fsck.k9.mail.store.imap + +/** + * Extracts the response text from a (negative) status response. + */ +internal object ResponseTextExtractor { + private const val MINIMUM_RESPONSE_SIZE = 2 + private const val RESPONSE_CODE_INDEX = 1 + private const val SIMPLE_RESPONSE_TEXT_INDEX = 1 + private const val EXTENDED_RESPONSE_TEXT_INDEX = 2 + + fun getResponseText(response: ImapResponse): String? { + if (response.size < MINIMUM_RESPONSE_SIZE) return null + + val responseTextIndex = if (response.isList(RESPONSE_CODE_INDEX)) { + EXTENDED_RESPONSE_TEXT_INDEX + } else { + SIMPLE_RESPONSE_TEXT_INDEX + } + + return if (response.isString(responseTextIndex)) { + response.getString(responseTextIndex) + } else { + null + } + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Responses.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Responses.java new file mode 100644 index 0000000..2ef537a --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Responses.java @@ -0,0 +1,20 @@ +package com.fsck.k9.mail.store.imap; + + +class Responses { + public static final String CAPABILITY = "CAPABILITY"; + public static final String NAMESPACE = "NAMESPACE"; + public static final String LIST = "LIST"; + public static final String LSUB = "LSUB"; + public static final String OK = "OK"; + public static final String NO = "NO"; + public static final String BAD = "BAD"; + public static final String PREAUTH = "PREAUTH"; + public static final String BYE = "BYE"; + public static final String EXISTS = "EXISTS"; + public static final String EXPUNGE = "EXPUNGE"; + public static final String PERMANENTFLAGS = "PERMANENTFLAGS"; + public static final String COPYUID = "COPYUID"; + public static final String SEARCH = "SEARCH"; + public static final String UIDVALIDITY = "UIDVALIDITY"; +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SearchResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SearchResponse.java new file mode 100644 index 0000000..3fac290 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SearchResponse.java @@ -0,0 +1,50 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.ArrayList; +import java.util.List; + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; + + +class SearchResponse { + private final List<Long> numbers; + + + private SearchResponse(List<Long> numbers) { + this.numbers = numbers; + } + + public static SearchResponse parse(List<ImapResponse> responses) { + List<Long> numbers = new ArrayList<>(); + + for (ImapResponse response : responses) { + parseSingleLine(response, numbers); + } + + return new SearchResponse(numbers); + } + + private static void parseSingleLine(ImapResponse response, List<Long> numbers) { + if (response.isTagged() || response.size() < 2 || !equalsIgnoreCase(response.get(0), Responses.SEARCH)) { + return; + } + + int end = response.size(); + for (int i = 1; i < end; i++) { + try { + long number = response.getLong(i); + numbers.add(number); + } catch (NumberFormatException e) { + return; + } + } + } + + /** + * @return A mutable list of numbers from the SEARCH response(s). + */ + public List<Long> getNumbers() { + return numbers; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponse.java new file mode 100644 index 0000000..9e02130 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponse.java @@ -0,0 +1,54 @@ +package com.fsck.k9.mail.store.imap; + + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; + + +class SelectOrExamineResponse { + private final Boolean readWriteMode; + + + private SelectOrExamineResponse(Boolean readWriteMode) { + this.readWriteMode = readWriteMode; + } + + public static SelectOrExamineResponse parse(ImapResponse response) { + if (!response.isTagged() || !equalsIgnoreCase(response.get(0), Responses.OK)) { + return null; + } + + if (!response.isList(1)) { + return noOpenModeInResponse(); + } + + ImapList responseTextList = response.getList(1); + if (!responseTextList.isString(0)) { + return noOpenModeInResponse(); + } + + String responseCode = responseTextList.getString(0); + if ("READ-ONLY".equalsIgnoreCase(responseCode)) { + return new SelectOrExamineResponse(false); + } else if ("READ-WRITE".equalsIgnoreCase(responseCode)) { + return new SelectOrExamineResponse(true); + } + + return noOpenModeInResponse(); + } + + private static SelectOrExamineResponse noOpenModeInResponse() { + return new SelectOrExamineResponse(null); + } + + public boolean hasOpenMode() { + return readWriteMode != null; + } + + public OpenMode getOpenMode() { + if (!hasOpenMode()) { + throw new IllegalStateException("Called getOpenMode() despite hasOpenMode() returning false"); + } + + return readWriteMode ? OpenMode.READ_WRITE : OpenMode.READ_ONLY; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.java new file mode 100644 index 0000000..9d17389 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.java @@ -0,0 +1,59 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase; +import static com.fsck.k9.mail.store.imap.ImapUtility.getImapSequenceValues; + + +class UidCopyResponse { + private final Map<String, String> uidMapping; + + + private UidCopyResponse(Map<String, String> uidMapping) { + this.uidMapping = uidMapping; + } + + public static UidCopyResponse parse(List<ImapResponse> imapResponses) { + Map<String, String> uidMapping = new LinkedHashMap<>(); + for (ImapResponse imapResponse : imapResponses) { + parseUidCopyResponse(imapResponse, uidMapping); + } + + return uidMapping.isEmpty() ? null : new UidCopyResponse(uidMapping); + } + + private static void parseUidCopyResponse(ImapResponse response, Map<String, String> uidMappingOutput) { + if (!response.isTagged() || response.size() < 2 || !equalsIgnoreCase(response.get(0), Responses.OK) || + !response.isList(1)) { + return; + } + + ImapList responseTextList = response.getList(1); + if (responseTextList.size() < 4 || !equalsIgnoreCase(responseTextList.get(0), Responses.COPYUID) || + !responseTextList.isString(1) || !responseTextList.isString(2) || !responseTextList.isString(3)) { + return; + } + + List<String> sourceUids = getImapSequenceValues(responseTextList.getString(2)); + List<String> destinationUids = getImapSequenceValues(responseTextList.getString(3)); + + int size = sourceUids.size(); + if (size == 0 || size != destinationUids.size()) { + return; + } + + for (int i = 0; i < size; i++) { + String sourceUid = sourceUids.get(i); + String destinationUid = destinationUids.get(i); + uidMappingOutput.put(sourceUid, destinationUid); + } + } + + public Map<String, String> getUidMapping() { + return uidMapping; + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java new file mode 100644 index 0000000..65cef90 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java @@ -0,0 +1,103 @@ +package com.fsck.k9.mail.store.imap; + + +import java.util.Set; + +import com.fsck.k9.mail.Flag; + + +class UidSearchCommandBuilder { + private String queryString; + private boolean performFullTextSearch; + private Set<Flag> requiredFlags; + private Set<Flag> forbiddenFlags; + + + public UidSearchCommandBuilder queryString(String queryString) { + this.queryString = queryString; + return this; + } + + public UidSearchCommandBuilder performFullTextSearch(boolean performFullTextSearch) { + this.performFullTextSearch = performFullTextSearch; + return this; + } + + public UidSearchCommandBuilder requiredFlags(Set<Flag> requiredFlags) { + this.requiredFlags = requiredFlags; + return this; + } + + public UidSearchCommandBuilder forbiddenFlags(Set<Flag> forbiddenFlags) { + this.forbiddenFlags = forbiddenFlags; + return this; + } + + public String build() { + StringBuilder builder = new StringBuilder(Commands.UID_SEARCH); + addQueryString(builder); + addFlags(builder, requiredFlags, false); + addFlags(builder, forbiddenFlags, true); + return builder.toString(); + } + + private void addQueryString(StringBuilder builder) { + if (queryString == null) { + return; + } + + String encodedQuery = ImapUtility.encodeString(queryString); + if (performFullTextSearch) { + builder.append(" TEXT ").append(encodedQuery); + } else { + builder.append(" OR OR OR OR SUBJECT ").append(encodedQuery) + .append(" FROM ").append(encodedQuery) + .append(" TO ").append(encodedQuery) + .append(" CC ").append(encodedQuery) + .append(" BCC ").append(encodedQuery); + } + } + + private void addFlags(StringBuilder builder, Set<Flag> flagSet, boolean addNot) { + if (flagSet == null || flagSet.isEmpty()) { + return; + } + + for (Flag flag : flagSet) { + if (addNot) { + builder.append(" NOT"); + } + + //noinspection EnumSwitchStatementWhichMissesCases + switch (flag) { + case DELETED: { + builder.append(" DELETED"); + break; + } + case SEEN: { + builder.append(" SEEN"); + break; + } + case ANSWERED: { + builder.append(" ANSWERED"); + break; + } + case FLAGGED: { + builder.append(" FLAGGED"); + break; + } + case DRAFT: { + builder.append(" DRAFT"); + break; + } + case RECENT: { + builder.append(" RECENT"); + break; + } + default: { + throw new IllegalStateException("Unsupported flag: " + flag); + } + } + } + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidValidityResponse.kt b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidValidityResponse.kt new file mode 100644 index 0000000..5a84abd --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidValidityResponse.kt @@ -0,0 +1,24 @@ +package com.fsck.k9.mail.store.imap + +import com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase + +internal class UidValidityResponse private constructor(val uidValidity: Long) { + companion object { + @JvmStatic + fun parse(response: ImapResponse): UidValidityResponse? { + if (response.isTagged || !equalsIgnoreCase(response[0], Responses.OK) || !response.isList(1)) return null + + val responseTextList = response.getList(1) + if (responseTextList.size < 2 || !equalsIgnoreCase(responseTextList[0], Responses.UIDVALIDITY) || + !responseTextList.isLong(1) + ) { + return null + } + + val uidValidity = responseTextList.getLong(1) + if (uidValidity !in 0L..0xFFFFFFFFL) return null + + return UidValidityResponse(uidValidity) + } + } +} diff --git a/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java new file mode 100644 index 0000000..7dc7244 --- /dev/null +++ b/mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java @@ -0,0 +1,7 @@ +package com.fsck.k9.mail.store.imap; + +import java.io.IOException; + +interface UntaggedHandler { + void handleAsyncUntaggedResponse(ImapResponse response) throws IOException; +} |
