summaryrefslogtreecommitdiff
path: root/mail/protocols/imap/src/main/java/com
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /mail/protocols/imap/src/main/java/com
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'mail/protocols/imap/src/main/java/com')
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/AlertResponse.java26
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Capabilities.java20
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/CapabilityResponse.java64
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Commands.java23
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchBodyCallback.java34
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FetchPartCallback.java34
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderListItem.kt10
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNameCodec.kt27
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/FolderNotFoundException.java19
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdGrouper.kt62
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshManager.kt11
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/IdleRefreshTimeoutProvider.kt5
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapCommandSplitter.java64
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnection.kt47
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionManager.kt10
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapConnectionProvider.kt5
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolder.kt92
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapFolderIdler.kt41
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapList.java163
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapMessage.java15
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponse.java62
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseCallback.java24
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParser.java487
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapResponseParserException.java8
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapSettings.kt22
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStore.kt29
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreConfig.kt7
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapStoreSettings.kt27
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ImapUtility.java198
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/InternalImapStore.kt11
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ListResponse.java105
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NamespaceResponse.java62
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/NegativeImapResponseException.java43
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/PermanentFlagsResponse.java86
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapConnection.kt910
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolder.kt1222
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapFolderIdler.kt195
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/RealImapStore.kt321
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseCodeExtractor.java19
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/ResponseTextExtractor.kt27
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/Responses.java20
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SearchResponse.java50
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/SelectOrExamineResponse.java54
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidCopyResponse.java59
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidSearchCommandBuilder.java103
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UidValidityResponse.kt24
-rw-r--r--mail/protocols/imap/src/main/java/com/fsck/k9/mail/store/imap/UntaggedHandler.java7
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;
+}