Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
25
mail/protocols/imap/build.gradle.kts
Normal file
25
mail/protocols/imap/build.gradle.kts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
val testCoverageEnabled: Boolean by extra
|
||||
if (testCoverageEnabled) {
|
||||
apply(plugin = "jacoco")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.mail.common)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.feature.mail.folder.api)
|
||||
|
||||
implementation(libs.jzlib)
|
||||
implementation(libs.jutf7)
|
||||
implementation(libs.commons.io)
|
||||
implementation(libs.okio)
|
||||
|
||||
testImplementation(projects.core.logging.testing)
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(libs.okio)
|
||||
testImplementation(libs.mime4j.core)
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase
|
||||
|
||||
internal object AlertResponse {
|
||||
private const val ALERT_RESPONSE_CODE = "ALERT"
|
||||
private const val MINIMUM_RESPONSE_SIZE = 3
|
||||
|
||||
@JvmStatic
|
||||
fun getAlertText(response: ImapResponse): String? {
|
||||
return if (
|
||||
response.size >= MINIMUM_RESPONSE_SIZE &&
|
||||
response.isList(1)
|
||||
) {
|
||||
val responseTextCode = response.getList(1)
|
||||
if (responseTextCode.size == 1 && equalsIgnoreCase(responseTextCode[0], ALERT_RESPONSE_CODE)) {
|
||||
response.getString(2)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
internal object Capabilities {
|
||||
const val IDLE: String = "IDLE"
|
||||
const val CONDSTORE: String = "CONDSTORE"
|
||||
const val SASL_IR: String = "SASL-IR"
|
||||
const val AUTH_XOAUTH2: String = "AUTH=XOAUTH2"
|
||||
const val AUTH_OAUTHBEARER: String = "AUTH=OAUTHBEARER"
|
||||
const val AUTH_CRAM_MD5: String = "AUTH=CRAM-MD5"
|
||||
const val AUTH_PLAIN: String = "AUTH=PLAIN"
|
||||
const val AUTH_EXTERNAL: String = "AUTH=EXTERNAL"
|
||||
const val LOGINDISABLED: String = "LOGINDISABLED"
|
||||
const val NAMESPACE: String = "NAMESPACE"
|
||||
const val COMPRESS_DEFLATE: String = "COMPRESS=DEFLATE"
|
||||
const val ID: String = "ID"
|
||||
const val STARTTLS: String = "STARTTLS"
|
||||
const val SPECIAL_USE: String = "SPECIAL-USE"
|
||||
const val UID_PLUS: String = "UIDPLUS"
|
||||
const val LIST_EXTENDED: String = "LIST-EXTENDED"
|
||||
const val MOVE: String = "MOVE"
|
||||
const val ENABLE: String = "ENABLE"
|
||||
const val CREATE_SPECIAL_USE: String = "CREATE-SPECIAL-USE"
|
||||
const val UTF8_ACCEPT: String = "UTF8=ACCEPT"
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
internal object Commands {
|
||||
const val IDLE: String = "IDLE"
|
||||
const val NAMESPACE: String = "NAMESPACE"
|
||||
const val CAPABILITY: String = "CAPABILITY"
|
||||
const val COMPRESS_DEFLATE: String = "COMPRESS DEFLATE"
|
||||
const val STARTTLS: String = "STARTTLS"
|
||||
const val AUTHENTICATE_XOAUTH2: String = "AUTHENTICATE XOAUTH2"
|
||||
const val AUTHENTICATE_OAUTHBEARER: String = "AUTHENTICATE OAUTHBEARER"
|
||||
const val AUTHENTICATE_CRAM_MD5: String = "AUTHENTICATE CRAM-MD5"
|
||||
const val AUTHENTICATE_PLAIN: String = "AUTHENTICATE PLAIN"
|
||||
const val AUTHENTICATE_EXTERNAL: String = "AUTHENTICATE EXTERNAL"
|
||||
const val LOGIN: String = "LOGIN"
|
||||
const val LIST: String = "LIST"
|
||||
const val NOOP: String = "NOOP"
|
||||
const val UID_SEARCH: String = "UID SEARCH"
|
||||
const val UID_STORE: String = "UID STORE"
|
||||
const val UID_FETCH: String = "UID FETCH"
|
||||
const val UID_COPY: String = "UID COPY"
|
||||
const val UID_MOVE: String = "UID MOVE"
|
||||
const val UID_EXPUNGE: String = "UID EXPUNGE"
|
||||
const val ENABLE: String = "ENABLE UTF8=ACCEPT"
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.store.imap.ImapResponseParser.equalsIgnoreCase
|
||||
import java.util.Locale
|
||||
|
||||
internal class EnabledResponse private constructor(val capabilities: Set<String>) {
|
||||
|
||||
companion object {
|
||||
fun parse(responses: List<ImapResponse>): EnabledResponse? {
|
||||
var result: EnabledResponse? = null
|
||||
for (response in responses) {
|
||||
if (result == null && response.tag == null) {
|
||||
result = parse(response)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parse(capabilityList: ImapList): EnabledResponse? {
|
||||
if (capabilityList.isEmpty() || !equalsIgnoreCase(capabilityList[0], Responses.ENABLED)) {
|
||||
return null
|
||||
}
|
||||
val capabilities = mutableSetOf<String>()
|
||||
var isValid = true
|
||||
for (i in 1 until capabilityList.size) {
|
||||
if (!capabilityList.isString(i)) {
|
||||
isValid = false
|
||||
break
|
||||
}
|
||||
capabilities.add(capabilityList.getString(i).uppercase(Locale.US))
|
||||
}
|
||||
|
||||
return if (isValid) EnabledResponse(capabilities) else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.filter.FixedLengthInputStream
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
const val LITERAL_HANDLED = 1
|
||||
|
||||
internal class FetchBodyCallback(private val messageMap: Map<String, ImapMessage>) : ImapResponseCallback {
|
||||
@Throws(MessagingException::class, IOException::class)
|
||||
override fun foundLiteral(
|
||||
response: ImapResponse,
|
||||
literal: FixedLengthInputStream,
|
||||
): Any? {
|
||||
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]
|
||||
message?.parse(literal)
|
||||
|
||||
// Return placeholder object
|
||||
return LITERAL_HANDLED
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
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
|
||||
import java.io.IOException
|
||||
|
||||
internal class FetchPartCallback(private val part: Part, private val bodyFactory: BodyFactory) :
|
||||
ImapResponseCallback {
|
||||
@Throws(IOException::class)
|
||||
override fun foundLiteral(response: ImapResponse, literal: FixedLengthInputStream): Any? {
|
||||
if (response.tag == null && ImapResponseParser.equalsIgnoreCase(response[1], "FETCH")) {
|
||||
// TODO: check for correct UID
|
||||
|
||||
val contentTransferEncoding = part.getHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING)[0]
|
||||
val contentType = part.getHeader(MimeHeader.HEADER_CONTENT_TYPE)[0]
|
||||
|
||||
return bodyFactory.createBody(contentTransferEncoding, contentType, literal)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import net.thunderbird.feature.mail.folder.api.FolderPathDelimiter
|
||||
|
||||
data class FolderListItem(
|
||||
val serverId: String,
|
||||
val name: String,
|
||||
val type: FolderType,
|
||||
val folderPathDelimiter: FolderPathDelimiter? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
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
|
||||
var acceptUtf8Encoding = false
|
||||
|
||||
fun encode(folderName: String): String {
|
||||
if (acceptUtf8Encoding) {
|
||||
return folderName
|
||||
}
|
||||
|
||||
val byteBuffer = modifiedUtf7Charset.encode(folderName)
|
||||
val bytes = ByteArray(byteBuffer.limit())
|
||||
byteBuffer.get(bytes)
|
||||
|
||||
return String(bytes, asciiCharset)
|
||||
}
|
||||
|
||||
fun decode(encodedFolderName: String): String {
|
||||
if (acceptUtf8Encoding) {
|
||||
return encodedFolderName
|
||||
}
|
||||
|
||||
val decoder = modifiedUtf7Charset.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
|
||||
val byteBuffer = ByteBuffer.wrap(encodedFolderName.toByteArray(asciiCharset))
|
||||
val charBuffer = decoder.decode(byteBuffer)
|
||||
|
||||
return charBuffer.toString()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
interface IdleRefreshTimeoutProvider {
|
||||
val idleRefreshTimeoutMs: Long
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
data class ImapClientInfo(
|
||||
val appName: String,
|
||||
val appVersion: String,
|
||||
)
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.net.SocketException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
internal interface ImapConnection {
|
||||
val logId: String
|
||||
val connectionGeneration: Int
|
||||
val isConnected: Boolean
|
||||
val outputStream: OutputStream
|
||||
val isUidPlusCapable: Boolean
|
||||
val isUtf8AcceptCapable: Boolean
|
||||
val isIdleCapable: Boolean
|
||||
|
||||
@Throws(IOException::class, MessagingException::class)
|
||||
fun open()
|
||||
|
||||
fun close()
|
||||
|
||||
fun canSendUTF8QuotedStrings(): Boolean
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
internal interface ImapConnectionManager {
|
||||
@Throws(MessagingException::class)
|
||||
fun getConnection(): ImapConnection
|
||||
|
||||
fun releaseConnection(connection: ImapConnection?)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
internal interface ImapConnectionProvider {
|
||||
fun getConnection(folder: ImapFolder): ImapConnection?
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
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.FolderType
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessageRetrievalListener
|
||||
import com.fsck.k9.mail.Part
|
||||
import java.io.IOException
|
||||
import java.util.Date
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
interface ImapFolder {
|
||||
val serverId: String
|
||||
val mode: OpenMode?
|
||||
val messageCount: Int
|
||||
val isOpen: Boolean
|
||||
|
||||
@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 setFlagsForAllMessages(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 deleteMessages(messages: List<ImapMessage>)
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun deleteAllMessages()
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun expunge()
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun expungeUids(uids: List<String>)
|
||||
|
||||
/**
|
||||
* Creates this folder on the IMAP server.
|
||||
*
|
||||
* @throws MessagingException when fails to create folder on IMAP server.
|
||||
*/
|
||||
@Throws(MessagingException::class)
|
||||
fun create(folderType: FolderType = FolderType.REGULAR): Boolean
|
||||
}
|
||||
|
||||
interface FetchListener {
|
||||
fun onFetchResponse(message: ImapMessage, isFirstResponse: Boolean)
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.folders.FolderFetcher
|
||||
import com.fsck.k9.mail.folders.FolderFetcherException
|
||||
import com.fsck.k9.mail.folders.FolderServerId
|
||||
import com.fsck.k9.mail.folders.RemoteFolder
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
|
||||
/**
|
||||
* Fetches the list of folders from an IMAP server.
|
||||
*/
|
||||
class ImapFolderFetcher internal constructor(
|
||||
private val trustedSocketFactory: TrustedSocketFactory,
|
||||
private val oAuth2TokenProviderFactory: OAuth2TokenProviderFactory?,
|
||||
private val clientInfoAppName: String,
|
||||
private val clientInfoAppVersion: String,
|
||||
private val imapStoreFactory: ImapStoreFactory,
|
||||
) : FolderFetcher {
|
||||
constructor(
|
||||
trustedSocketFactory: TrustedSocketFactory,
|
||||
oAuth2TokenProviderFactory: OAuth2TokenProviderFactory?,
|
||||
clientInfoAppName: String,
|
||||
clientInfoAppVersion: String,
|
||||
) : this(
|
||||
trustedSocketFactory,
|
||||
oAuth2TokenProviderFactory,
|
||||
clientInfoAppName,
|
||||
clientInfoAppVersion,
|
||||
imapStoreFactory = ImapStore.Companion,
|
||||
)
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun getFolders(serverSettings: ServerSettings, authStateStorage: AuthStateStorage?): List<RemoteFolder> {
|
||||
require(serverSettings.type == "imap")
|
||||
|
||||
val config = object : ImapStoreConfig {
|
||||
override val logLabel = "folder-fetcher"
|
||||
override fun isSubscribedFoldersOnly() = false
|
||||
override fun isExpungeImmediately() = false
|
||||
override fun clientInfo() = ImapClientInfo(appName = clientInfoAppName, appVersion = clientInfoAppVersion)
|
||||
}
|
||||
val oAuth2TokenProvider = createOAuth2TokenProviderOrNull(authStateStorage)
|
||||
val store = imapStoreFactory.create(serverSettings, config, trustedSocketFactory, oAuth2TokenProvider)
|
||||
|
||||
return try {
|
||||
store.getFolders()
|
||||
.asSequence()
|
||||
.map { folder ->
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId(folder.serverId),
|
||||
displayName = folder.name,
|
||||
type = folder.type,
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
throw FolderFetcherException(messageFromServer = e.messageFromServer, cause = e)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
throw FolderFetcherException(messageFromServer = e.responseText, cause = e)
|
||||
} catch (e: Exception) {
|
||||
throw FolderFetcherException(cause = e)
|
||||
} finally {
|
||||
store.closeAllConnections()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOAuth2TokenProviderOrNull(authStateStorage: AuthStateStorage?): OAuth2TokenProvider? {
|
||||
return authStateStorage?.let {
|
||||
oAuth2TokenProviderFactory?.create(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 net.thunderbird.core.common.exception.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.internet.MimeMessage
|
||||
|
||||
class ImapMessage(uid: String) : MimeMessage() {
|
||||
init {
|
||||
this.mUid = uid
|
||||
}
|
||||
|
||||
fun setSize(size: Int) {
|
||||
this.mSize = size
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
/**
|
||||
* Represents a single response from the IMAP server.
|
||||
*
|
||||
* Tagged responses have a non-null tag.
|
||||
* Untagged responses have a null tag.
|
||||
* Continuation requests are identified with a `+`.
|
||||
* The object will contain all of the available tokens at the time the response is received.
|
||||
*/
|
||||
internal class ImapResponse private constructor(
|
||||
var callback: ImapResponseCallback?,
|
||||
val isContinuationRequested: Boolean,
|
||||
val tag: String?,
|
||||
) : ImapList(), Serializable {
|
||||
|
||||
companion object {
|
||||
private const val serialVersionUID: Long = 6886458551615975669L
|
||||
|
||||
@JvmStatic
|
||||
fun newContinuationRequest(callback: ImapResponseCallback?): ImapResponse {
|
||||
return ImapResponse(callback, true, null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun newUntaggedResponse(callback: ImapResponseCallback?): ImapResponse {
|
||||
return ImapResponse(callback, false, null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun newTaggedResponse(callback: ImapResponseCallback?, tag: String): ImapResponse {
|
||||
return ImapResponse(callback, false, tag)
|
||||
}
|
||||
}
|
||||
|
||||
val isTagged: Boolean
|
||||
get() = tag != null
|
||||
|
||||
override fun toString(): String {
|
||||
val displayTag = if (isContinuationRequested) "+" else tag
|
||||
return "#$displayTag# ${super.toString()}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,514 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
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;
|
||||
private boolean utf8Accept;
|
||||
private FolderNameCodec folderNameCodec;
|
||||
|
||||
public ImapResponseParser(PeekableInputStream in, FolderNameCodec folderNameCodec) {
|
||||
this.inputStream = in;
|
||||
this.utf8Accept = false;
|
||||
this.folderNameCodec = folderNameCodec;
|
||||
}
|
||||
|
||||
public void setUtf8Accepted(final boolean yes) {
|
||||
utf8Accept = yes;
|
||||
folderNameCodec.setAcceptUtf8Encoding(yes);
|
||||
}
|
||||
|
||||
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) {
|
||||
Log.v("%s<<<%s", logId, response);
|
||||
}
|
||||
|
||||
if (response.getTag() != null && !response.getTag().equalsIgnoreCase(tag)) {
|
||||
Log.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();
|
||||
if (utf8Accept) {
|
||||
// RFCs 9051 and 9755 allow UTF8 in folder names. "The
|
||||
// "UTF8=ACCEPT" capability indicates that the server
|
||||
// ... can provide UTF-8 responses to the "LIST" and
|
||||
// "LSUB" commands."
|
||||
} else {
|
||||
name = folderNameCodec.decode(name);
|
||||
}
|
||||
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;
|
||||
try {
|
||||
size = Integer.parseInt(readStringUntil('}'));
|
||||
} catch (NumberFormatException e) {
|
||||
throw new ImapResponseParserException("Invalid value for size of literal string", e);
|
||||
}
|
||||
|
||||
if (size < 0) {
|
||||
throw new ImapResponseParserException("Invalid value for size of literal string");
|
||||
}
|
||||
|
||||
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, "UTF8");
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!(token instanceof String)) {
|
||||
throw new ImapResponseParserException("Unexpected non-string token");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
class ImapResponseParserException @JvmOverloads constructor(
|
||||
override val message: String? = null,
|
||||
override val cause: Throwable? = null,
|
||||
) : RuntimeException(message, cause)
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.CertificateValidationException
|
||||
import com.fsck.k9.mail.ClientCertificateError.CertificateExpired
|
||||
import com.fsck.k9.mail.ClientCertificateError.RetrievalFailure
|
||||
import com.fsck.k9.mail.ClientCertificateException
|
||||
import com.fsck.k9.mail.MissingCapabilityException
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProviderFactory
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidationResult
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidationResult.ClientCertificateError
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidator
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
class ImapServerSettingsValidator(
|
||||
private val trustedSocketFactory: TrustedSocketFactory,
|
||||
private val oAuth2TokenProviderFactory: OAuth2TokenProviderFactory?,
|
||||
private val clientInfoAppName: String,
|
||||
private val clientInfoAppVersion: String,
|
||||
) : ServerSettingsValidator {
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun checkServerSettings(
|
||||
serverSettings: ServerSettings,
|
||||
authStateStorage: AuthStateStorage?,
|
||||
): ServerSettingsValidationResult {
|
||||
val config = object : ImapStoreConfig {
|
||||
override val logLabel = "check"
|
||||
override fun isSubscribedFoldersOnly() = false
|
||||
override fun isExpungeImmediately() = false
|
||||
override fun clientInfo() = ImapClientInfo(appName = clientInfoAppName, appVersion = clientInfoAppVersion)
|
||||
}
|
||||
val oAuth2TokenProvider = createOAuth2TokenProviderOrNull(authStateStorage)
|
||||
val store = RealImapStore(serverSettings, config, trustedSocketFactory, oAuth2TokenProvider)
|
||||
|
||||
return try {
|
||||
store.checkSettings()
|
||||
|
||||
ServerSettingsValidationResult.Success
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
ServerSettingsValidationResult.AuthenticationError(e.messageFromServer)
|
||||
} catch (e: CertificateValidationException) {
|
||||
ServerSettingsValidationResult.CertificateError(e.certificateChain)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
ServerSettingsValidationResult.ServerError(e.responseText)
|
||||
} catch (e: MissingCapabilityException) {
|
||||
ServerSettingsValidationResult.MissingServerCapabilityError(e.capabilityName)
|
||||
} catch (e: ClientCertificateException) {
|
||||
when (e.error) {
|
||||
RetrievalFailure -> ClientCertificateError.ClientCertificateRetrievalFailure
|
||||
CertificateExpired -> ClientCertificateError.ClientCertificateExpired
|
||||
}
|
||||
} catch (e: MessagingException) {
|
||||
val cause = e.cause
|
||||
if (cause is IOException) {
|
||||
ServerSettingsValidationResult.NetworkError(cause)
|
||||
} else {
|
||||
ServerSettingsValidationResult.UnknownError(e)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
ServerSettingsValidationResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
ServerSettingsValidationResult.UnknownError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createOAuth2TokenProviderOrNull(authStateStorage: AuthStateStorage?): OAuth2TokenProvider? {
|
||||
return authStateStorage?.let {
|
||||
oAuth2TokenProviderFactory?.create(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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
|
||||
val clientInfo: ImapClientInfo?
|
||||
|
||||
var pathPrefix: String?
|
||||
var pathDelimiter: String?
|
||||
fun setCombinedPrefix(prefix: String?)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
interface ImapStore {
|
||||
/**
|
||||
* The IMAP prefix combined with the Path delimiter given by the server.
|
||||
*/
|
||||
val combinedPrefix: String?
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun checkSettings()
|
||||
|
||||
fun getFolder(name: String): ImapFolder
|
||||
|
||||
@Throws(MessagingException::class)
|
||||
fun getFolders(): List<FolderListItem>
|
||||
|
||||
fun closeAllConnections()
|
||||
|
||||
fun fetchImapPrefix()
|
||||
|
||||
companion object : ImapStoreFactory {
|
||||
override fun create(
|
||||
serverSettings: ServerSettings,
|
||||
config: ImapStoreConfig,
|
||||
trustedSocketFactory: TrustedSocketFactory,
|
||||
oauthTokenProvider: OAuth2TokenProvider?,
|
||||
): ImapStore {
|
||||
return RealImapStore(serverSettings, config, trustedSocketFactory, oauthTokenProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
interface ImapStoreConfig {
|
||||
val logLabel: String
|
||||
fun isSubscribedFoldersOnly(): Boolean
|
||||
fun isExpungeImmediately(): Boolean
|
||||
fun clientInfo(): ImapClientInfo
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
|
||||
fun interface ImapStoreFactory {
|
||||
fun create(
|
||||
serverSettings: ServerSettings,
|
||||
config: ImapStoreConfig,
|
||||
trustedSocketFactory: TrustedSocketFactory,
|
||||
oauthTokenProvider: OAuth2TokenProvider?,
|
||||
): ImapStore
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
|
||||
/**
|
||||
* Extract IMAP-specific server settings from [ServerSettings]
|
||||
*/
|
||||
object ImapStoreSettings {
|
||||
@VisibleForTesting
|
||||
const val AUTODETECT_NAMESPACE_KEY = "autoDetectNamespace"
|
||||
|
||||
@VisibleForTesting
|
||||
const val PATH_PREFIX_KEY = "pathPrefix"
|
||||
|
||||
@VisibleForTesting
|
||||
const val SEND_CLIENT_INFO = "sendClientInfo"
|
||||
|
||||
@VisibleForTesting
|
||||
const val USE_COMPRESSION = "useCompression"
|
||||
|
||||
@JvmStatic
|
||||
val ServerSettings.autoDetectNamespace: Boolean
|
||||
get() = extra[AUTODETECT_NAMESPACE_KEY]?.toBoolean() ?: true
|
||||
|
||||
@JvmStatic
|
||||
val ServerSettings.pathPrefix: String?
|
||||
get() = extra[PATH_PREFIX_KEY]
|
||||
|
||||
@JvmStatic
|
||||
val ServerSettings.isUseCompression: Boolean
|
||||
get() = extra[USE_COMPRESSION]?.toBoolean() ?: true
|
||||
|
||||
@JvmStatic
|
||||
val ServerSettings.isSendClientInfo: Boolean
|
||||
get() = extra[SEND_CLIENT_INFO]?.toBoolean() ?: true
|
||||
|
||||
// Note: These extras are currently held in the instance referenced by Account.incomingServerSettings
|
||||
@JvmStatic
|
||||
fun createExtra(autoDetectNamespace: Boolean, pathPrefix: String?): Map<String, String?> {
|
||||
return mapOf(
|
||||
AUTODETECT_NAMESPACE_KEY to autoDetectNamespace.toString(),
|
||||
PATH_PREFIX_KEY to pathPrefix,
|
||||
)
|
||||
}
|
||||
|
||||
// Note: These extras are required when creating an ImapStore instance.
|
||||
fun createExtra(
|
||||
autoDetectNamespace: Boolean,
|
||||
pathPrefix: String?,
|
||||
useCompression: Boolean,
|
||||
sendClientInfo: Boolean,
|
||||
): Map<String, String?> {
|
||||
return mapOf(
|
||||
AUTODETECT_NAMESPACE_KEY to autoDetectNamespace.toString(),
|
||||
PATH_PREFIX_KEY to pathPrefix,
|
||||
USE_COMPRESSION to useCompression.toString(),
|
||||
SEND_CLIENT_INFO to sendClientInfo.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 net.thunderbird.core.logging.legacy.Log;
|
||||
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 {
|
||||
Log.d("Invalid range: %s", range);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
Log.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
|
||||
}
|
||||
|
||||
Log.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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.Flag
|
||||
|
||||
internal interface InternalImapStore {
|
||||
val logLabel: String
|
||||
val config: ImapStoreConfig
|
||||
|
||||
/**
|
||||
* The IMAP prefix combined with the Path delimiter given by the server.
|
||||
*/
|
||||
val combinedPrefix: String?
|
||||
|
||||
fun getPermanentFlagsIndex(): MutableSet<Flag>
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
internal class NegativeImapResponseException(
|
||||
message: String?,
|
||||
private val responses: List<ImapResponse>,
|
||||
) : MessagingException(message, true) {
|
||||
|
||||
init {
|
||||
require(responses.isNotEmpty()) { "List of responses must not be empty" }
|
||||
}
|
||||
|
||||
val lastResponse: ImapResponse
|
||||
get() = responses.last()
|
||||
|
||||
val responseText: String? by lazy { ResponseTextExtractor.getResponseText(lastResponse) }
|
||||
|
||||
val alertText: String? by lazy { AlertResponse.getAlertText(lastResponse) }
|
||||
|
||||
fun wasByeResponseReceived(): Boolean {
|
||||
return responses.any { it.isByeResponse }
|
||||
}
|
||||
|
||||
private val ImapResponse.isByeResponse: Boolean
|
||||
get() = !isTagged && isNotEmpty() && ImapResponseParser.equalsIgnoreCase(first(), Responses.BYE)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,943 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
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.MissingCapabilityException
|
||||
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.CertificateChainExtractor
|
||||
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.util.regex.Pattern
|
||||
import java.util.zip.Inflater
|
||||
import java.util.zip.InflaterInputStream
|
||||
import javax.net.ssl.SSLException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
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?,
|
||||
private val folderNameCodec: FolderNameCodec,
|
||||
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 enabled = 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()
|
||||
sendClientInfoIfSupported()
|
||||
enableCapabilitiesIfSupported()
|
||||
|
||||
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) {
|
||||
Log.e("Failed to login, closing connection for %s", logId)
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSslException(e: SSLException) {
|
||||
val certificateChain = CertificateChainExtractor.extract(e)
|
||||
if (certificateChain != null) {
|
||||
throw CertificateValidationException(certificateChain, 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) {
|
||||
Log.w(e, "Could not set DNS ttl to 0 for %s", logId)
|
||||
}
|
||||
|
||||
try {
|
||||
Security.setProperty("networkaddress.cache.negative.ttl", "0")
|
||||
} catch (e: Exception) {
|
||||
Log.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) {
|
||||
Log.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) {
|
||||
Log.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, folderNameCodec)
|
||||
imapOutputStream = BufferedOutputStream(output, BUFFER_SIZE)
|
||||
}
|
||||
|
||||
private fun readInitialResponse() {
|
||||
val responseParser = checkNotNull(responseParser)
|
||||
|
||||
val initialResponse = responseParser.readResponse()
|
||||
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_IMAP) {
|
||||
Log.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
|
||||
|
||||
Log.d("Saving %s capabilities for %s", receivedCapabilities, logId)
|
||||
capabilities = receivedCapabilities
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun extractOrRequestCapabilities(responses: List<ImapResponse>) {
|
||||
if (!extractCapabilities(responses)) {
|
||||
Log.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()) {
|
||||
Log.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 enableCapabilitiesIfSupported() {
|
||||
if (!hasCapability(Capabilities.ENABLE)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val responses = executeSimpleCommand(Commands.ENABLE)
|
||||
val enabledResponse = EnabledResponse.parse(responses) ?: return
|
||||
enabled = enabledResponse.capabilities
|
||||
responseParser?.setUtf8Accepted(isUtf8AcceptCapable)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
Log.d(e, "Ignoring negative response to ENABLE command")
|
||||
}
|
||||
}
|
||||
|
||||
private fun upgradeToTlsIfNecessary() {
|
||||
if (settings.connectionSecurity == ConnectionSecurity.STARTTLS_REQUIRED) {
|
||||
upgradeToTls()
|
||||
}
|
||||
}
|
||||
|
||||
private fun upgradeToTls() {
|
||||
if (!hasCapability(Capabilities.STARTTLS)) {
|
||||
throw MissingCapabilityException(Capabilities.STARTTLS)
|
||||
}
|
||||
|
||||
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()) {
|
||||
Log.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 MissingCapabilityException(Capabilities.SASL_IR)
|
||||
} else if (hasCapability(Capabilities.AUTH_OAUTHBEARER)) {
|
||||
authWithOAuthToken(OAuthMethod.OAUTHBEARER)
|
||||
} else if (hasCapability(Capabilities.AUTH_XOAUTH2)) {
|
||||
authWithOAuthToken(OAuthMethod.XOAUTH2)
|
||||
} else {
|
||||
throw MissingCapabilityException(Capabilities.AUTH_OAUTHBEARER)
|
||||
}
|
||||
}
|
||||
AuthType.CRAM_MD5 -> {
|
||||
if (hasCapability(Capabilities.AUTH_CRAM_MD5)) {
|
||||
authCramMD5()
|
||||
} else {
|
||||
throw MissingCapabilityException(Capabilities.AUTH_CRAM_MD5)
|
||||
}
|
||||
}
|
||||
AuthType.PLAIN -> {
|
||||
if (hasCapability(Capabilities.AUTH_PLAIN)) {
|
||||
saslAuthPlainWithLoginFallback()
|
||||
} else if (!hasCapability(Capabilities.LOGINDISABLED)) {
|
||||
login()
|
||||
} else {
|
||||
throw MissingCapabilityException(Capabilities.AUTH_PLAIN)
|
||||
}
|
||||
}
|
||||
AuthType.EXTERNAL -> {
|
||||
if (hasCapability(Capabilities.AUTH_EXTERNAL)) {
|
||||
saslAuthExternal()
|
||||
} else {
|
||||
throw MissingCapabilityException(Capabilities.AUTH_EXTERNAL)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
Log.v(e, "Permanent failure during authentication using OAuth token")
|
||||
|
||||
return AuthenticationFailedException(
|
||||
message = "Authentication failed",
|
||||
throwable = e,
|
||||
messageFromServer = e.responseText,
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
Log.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.
|
||||
Log.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) {
|
||||
Log.d(e, "LOGIN fallback failed")
|
||||
throw originalException
|
||||
} catch (e: MessagingException) {
|
||||
Log.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) {
|
||||
throw handleAuthenticationFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
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 = negativeResponseException.responseText,
|
||||
)
|
||||
} else {
|
||||
close()
|
||||
|
||||
negativeResponseException
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableCompressionIfRequested() {
|
||||
if (hasCapability(Capabilities.COMPRESS_DEFLATE) && settings.useCompression) {
|
||||
enableCompression()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendClientInfoIfSupported() {
|
||||
val clientInfo = settings.clientInfo
|
||||
|
||||
if (hasCapability(Capabilities.ID) && clientInfo != null) {
|
||||
val encodedAppName = ImapUtility.encodeString(clientInfo.appName)
|
||||
val encodedAppVersion = ImapUtility.encodeString(clientInfo.appVersion)
|
||||
|
||||
try {
|
||||
executeSimpleCommand("""ID ("name" $encodedAppName "version" $encodedAppVersion)""")
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
Log.d(e, "Ignoring negative response to ID command")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableCompression() {
|
||||
try {
|
||||
executeSimpleCommand(Commands.COMPRESS_DEFLATE)
|
||||
} catch (e: NegativeImapResponseException) {
|
||||
Log.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()) {
|
||||
Log.i("Compression enabled for %s", logId)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
close()
|
||||
Log.e(e, "Error enabling compression")
|
||||
}
|
||||
}
|
||||
|
||||
private fun retrievePathPrefixIfNecessary() {
|
||||
if (settings.pathPrefix != null) return
|
||||
|
||||
if (hasCapability(Capabilities.NAMESPACE)) {
|
||||
if (K9MailLib.isDebug()) {
|
||||
Log.i("pathPrefix is unset and server has NAMESPACE capability")
|
||||
}
|
||||
|
||||
handleNamespace()
|
||||
} else {
|
||||
if (K9MailLib.isDebug()) {
|
||||
Log.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()) {
|
||||
Log.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) {
|
||||
Log.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()) {
|
||||
Log.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 canSendUTF8QuotedStrings(): Boolean {
|
||||
return isUtf8AcceptCapable // later: or IMAP4Rev2 is enabled
|
||||
}
|
||||
|
||||
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()) {
|
||||
Log.v("Connection %s has %d capabilities", logId, capabilities.size)
|
||||
}
|
||||
|
||||
return capabilities.contains(Capabilities.IDLE)
|
||||
}
|
||||
|
||||
override val isUidPlusCapable: Boolean
|
||||
get() = capabilities.contains(Capabilities.UID_PLUS)
|
||||
|
||||
override val isUtf8AcceptCapable: Boolean
|
||||
get() = enabled.contains(Capabilities.UTF8_ACCEPT)
|
||||
|
||||
@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()) {
|
||||
Log.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", logId)
|
||||
} else {
|
||||
Log.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()) {
|
||||
Log.v("%s>>> [Command Hidden, Enable Sensitive Debug Logging To Show]", logId)
|
||||
} else {
|
||||
Log.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) {
|
||||
try {
|
||||
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) {
|
||||
Log.v("%s>>> %s", logId, continuation)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
close()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
Log.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 {
|
||||
Log.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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,204 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.power.WakeLock
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
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 {
|
||||
Log.v("%s.idle()", logTag)
|
||||
|
||||
val folder = imapStore.getFolder(folderServerId).also { this.folder = it }
|
||||
folder.open(OpenMode.READ_ONLY)
|
||||
|
||||
try {
|
||||
return folder.idle().also { idleResult ->
|
||||
Log.v("%s.idle(): result=%s", logTag, idleResult)
|
||||
}
|
||||
} finally {
|
||||
folder.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun refresh() {
|
||||
Log.v("%s.refresh()", logTag)
|
||||
endIdle()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun stop() {
|
||||
Log.v("%s.stop()", logTag)
|
||||
stopIdle = true
|
||||
endIdle()
|
||||
}
|
||||
|
||||
private fun endIdle() {
|
||||
if (idleSent && !doneSent) {
|
||||
idleRefreshTimer?.cancel()
|
||||
|
||||
try {
|
||||
sendDone()
|
||||
} catch (e: IOException) {
|
||||
Log.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) {
|
||||
Log.w("%s: IDLE not supported by server", logTag)
|
||||
return IdleResult.NOT_SUPPORTED
|
||||
}
|
||||
|
||||
stopIdle = false
|
||||
do {
|
||||
synchronized(this) {
|
||||
idleSent = false
|
||||
doneSent = false
|
||||
}
|
||||
|
||||
connection.setSocketDefaultReadTimeout()
|
||||
|
||||
val tag = connection.sendCommand("IDLE", false)
|
||||
|
||||
synchronized(this) {
|
||||
idleSent = true
|
||||
}
|
||||
|
||||
var receivedRelevantResponse = false
|
||||
do {
|
||||
val response = connection.readResponse()
|
||||
if (response.tag == tag) {
|
||||
Log.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) {
|
||||
Log.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) {
|
||||
Log.v("%s.idle(): Received a relevant untagged response during IDLE", logTag)
|
||||
result = IdleResult.SYNC
|
||||
stopIdle = true
|
||||
sendDone()
|
||||
} else if (!response.isTagged) {
|
||||
Log.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)
|
||||
|
||||
connection.setSocketDefaultReadTimeout()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun idleRefresh() {
|
||||
Log.v("%s.idleRefresh()", logTag)
|
||||
|
||||
if (!idleSent || doneSent) {
|
||||
Log.v("%s: Connection is not in a state where it can be refreshed.", logTag)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sendDone()
|
||||
} catch (e: IOException) {
|
||||
Log.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()
|
||||
try {
|
||||
connection.sendContinuation("DONE")
|
||||
} catch (e: IOException) {
|
||||
Log.v(e, "%s: IOException while sending DONE", logTag)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
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.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.isSendClientInfo
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.isUseCompression
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.pathPrefix
|
||||
import java.io.IOException
|
||||
import java.util.Deque
|
||||
import java.util.LinkedList
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
private const val LAST_ASCII_CODE = 127
|
||||
|
||||
internal open class RealImapStore(
|
||||
private val serverSettings: ServerSettings,
|
||||
override 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
|
||||
override val combinedPrefix: String?
|
||||
get() = _combinedPrefix ?: buildCombinedPrefix().also { _combinedPrefix = it }
|
||||
|
||||
private var pathDelimiter: String? = null
|
||||
|
||||
private val permanentFlagsIndex: MutableSet<Flag> = mutableSetOf()
|
||||
private val connections: Deque<ImapConnection> = LinkedList()
|
||||
|
||||
@Volatile
|
||||
private var connectionGeneration = 1
|
||||
|
||||
init {
|
||||
require(serverSettings.type == "imap") { "Expected IMAP ServerSettings" }
|
||||
|
||||
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 fetchImapPrefix() {
|
||||
if (combinedPrefix != null) return
|
||||
|
||||
val connection = createImapConnection()
|
||||
connection.open()
|
||||
if (connection.hasCapability(Capabilities.NAMESPACE)) {
|
||||
val responses = connection.executeSimpleCommand(Commands.NAMESPACE)
|
||||
NamespaceResponse.parse(responses)?.let { response ->
|
||||
pathPrefix = response.prefix
|
||||
pathDelimiter = response.hierarchyDelimiter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCombinedPrefix(): String? {
|
||||
val pathPrefix = pathPrefix ?: return null
|
||||
|
||||
val trimmedPathPrefix = pathPrefix.trim { it <= ' ' }
|
||||
val trimmedPathDelimiter = pathDelimiter?.trim { it <= ' ' }.orEmpty()
|
||||
|
||||
return if (trimmedPathPrefix.endsWith(trimmedPathDelimiter)) {
|
||||
trimmedPathPrefix
|
||||
} else if (trimmedPathPrefix.isNotEmpty()) {
|
||||
trimmedPathPrefix + trimmedPathDelimiter
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@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("${combinedPrefix.orEmpty()}*")
|
||||
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)) {
|
||||
// We always add our own inbox entry to the returned list.
|
||||
continue
|
||||
} else if (listResponse.hasAttribute("\\NoSelect")) {
|
||||
// RFC 3501, section 7.2.2: It is not possible to use this name as a selectable mailbox.
|
||||
continue
|
||||
} else if (listResponse.hasAttribute("\\NonExistent")) {
|
||||
// RFC 5258, section 3: The "\NonExistent" attribute implies "\NoSelect".
|
||||
continue
|
||||
}
|
||||
|
||||
val name = getFolderDisplayName(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, pathDelimiter)
|
||||
}
|
||||
}
|
||||
|
||||
return buildList {
|
||||
add(FolderListItem(RealImapFolder.INBOX, RealImapFolder.INBOX, FolderType.INBOX, pathDelimiter))
|
||||
addAll(folderMap.values)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFolderDisplayName(serverId: String): String {
|
||||
val decodedFolderName = try {
|
||||
if (serverId.all { it.code <= LAST_ASCII_CODE }) {
|
||||
folderNameCodec.decode(serverId)
|
||||
} else {
|
||||
serverId
|
||||
}
|
||||
} catch (e: CharacterCodingException) {
|
||||
Log.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 removePrefixFromFolderName(folderName: String): String? {
|
||||
val prefix = combinedPrefix.orEmpty()
|
||||
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)
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
@Throws(MessagingException::class, IOException::class)
|
||||
override fun checkSettings() {
|
||||
try {
|
||||
val connection = createImapConnection()
|
||||
|
||||
connection.open()
|
||||
connection.close()
|
||||
} catch (e: Exception) {
|
||||
Log.e(e, "Error while checking server settings")
|
||||
throw 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() {
|
||||
Log.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,
|
||||
folderNameCodec,
|
||||
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 = serverSettings.isUseCompression
|
||||
|
||||
override val clientInfo: ImapClientInfo? = config.clientInfo().takeIf { serverSettings.isSendClientInfo }
|
||||
|
||||
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)
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
internal object ResponseCodeExtractor {
|
||||
const val AUTHENTICATION_FAILED: String = "AUTHENTICATIONFAILED"
|
||||
|
||||
@JvmStatic
|
||||
fun getResponseCode(response: ImapResponse): String? {
|
||||
if (response.size < 2 || !response.isList(1)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val responseTextCode = response.getList(1)
|
||||
return if (responseTextCode.size != 1) null else responseTextCode.getString(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
internal object Responses {
|
||||
const val CAPABILITY: String = "CAPABILITY"
|
||||
const val NAMESPACE: String = "NAMESPACE"
|
||||
const val LIST: String = "LIST"
|
||||
const val LSUB: String = "LSUB"
|
||||
const val OK: String = "OK"
|
||||
const val NO: String = "NO"
|
||||
const val BAD: String = "BAD"
|
||||
const val PREAUTH: String = "PREAUTH"
|
||||
const val BYE: String = "BYE"
|
||||
const val EXISTS: String = "EXISTS"
|
||||
const val EXPUNGE: String = "EXPUNGE"
|
||||
const val PERMANENTFLAGS: String = "PERMANENTFLAGS"
|
||||
const val COPYUID: String = "COPYUID"
|
||||
const val SEARCH: String = "SEARCH"
|
||||
const val UIDVALIDITY: String = "UIDVALIDITY"
|
||||
const val ENABLED: String = "ENABLED"
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
internal class SearchResponse private constructor(
|
||||
/**
|
||||
* @return A list of numbers from the SEARCH response(s).
|
||||
*/
|
||||
val numbers: List<Long>,
|
||||
) {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun parse(responses: List<ImapResponse>): SearchResponse {
|
||||
val numbers = mutableListOf<Long>()
|
||||
|
||||
for (response in responses) {
|
||||
parseSingleLine(response, numbers)
|
||||
}
|
||||
|
||||
return SearchResponse(numbers)
|
||||
}
|
||||
|
||||
private fun parseSingleLine(response: ImapResponse, numbers: MutableList<Long>) {
|
||||
if (response.isTagged ||
|
||||
response.size < 2 ||
|
||||
!ImapResponseParser.equalsIgnoreCase(
|
||||
response[0],
|
||||
Responses.SEARCH,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val end = response.size
|
||||
for (i in 1..<end) {
|
||||
try {
|
||||
val number = response.getLong(i)
|
||||
numbers.add(number)
|
||||
} catch (_: NumberFormatException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
internal class UidCopyResponse private constructor(val uidMapping: Map<String, String>) {
|
||||
|
||||
companion object {
|
||||
fun parse(imapResponses: List<ImapResponse>, allowUntaggedResponse: Boolean = false): UidCopyResponse? {
|
||||
val uidMapping = mutableMapOf<String, String>()
|
||||
for (imapResponse in imapResponses) {
|
||||
parseUidCopyResponse(imapResponse, allowUntaggedResponse, uidMapping)
|
||||
}
|
||||
|
||||
return if (uidMapping.isNotEmpty()) {
|
||||
UidCopyResponse(uidMapping)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "ComplexCondition", "MagicNumber")
|
||||
private fun parseUidCopyResponse(
|
||||
response: ImapResponse,
|
||||
allowUntaggedResponse: Boolean,
|
||||
uidMappingOutput: MutableMap<String, String>,
|
||||
) {
|
||||
if (!(allowUntaggedResponse || response.isTagged) ||
|
||||
response.size < 2 ||
|
||||
!ImapResponseParser.equalsIgnoreCase(response[0], Responses.OK) ||
|
||||
!response.isList(1)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val responseTextList = response.getList(1)
|
||||
if (responseTextList.size < 4 ||
|
||||
!ImapResponseParser.equalsIgnoreCase(responseTextList[0], Responses.COPYUID) ||
|
||||
!responseTextList.isString(1) ||
|
||||
!responseTextList.isString(2) ||
|
||||
!responseTextList.isString(3)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val sourceUids = ImapUtility.getImapSequenceValues(responseTextList.getString(2))
|
||||
val destinationUids = ImapUtility.getImapSequenceValues(responseTextList.getString(3))
|
||||
|
||||
val size = sourceUids.size
|
||||
if (size == 0 || size != destinationUids.size) {
|
||||
return
|
||||
}
|
||||
|
||||
for (i in 0 until size) {
|
||||
val sourceUid = sourceUids[i]
|
||||
val destinationUid = destinationUids[i]
|
||||
uidMappingOutput[sourceUid] = destinationUid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
interface UntaggedHandler {
|
||||
void handleAsyncUntaggedResponse(ImapResponse response) throws IOException;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package net.thunderbird.protocols.imap.folder
|
||||
|
||||
import com.fsck.k9.mail.FolderType
|
||||
|
||||
/**
|
||||
* **See Also:**
|
||||
* [RFC-6154: New Mailbox Attributes Identifying Special-Use Mailboxes](https://www.rfc-editor.org/rfc/rfc6154.html#section-2)
|
||||
*/
|
||||
val FolderType.attributeName: String
|
||||
get() = when (this) {
|
||||
FolderType.DRAFTS -> "\\Drafts"
|
||||
FolderType.SENT -> "\\Sent"
|
||||
FolderType.TRASH -> "\\Trash"
|
||||
FolderType.SPAM -> "\\Junk"
|
||||
FolderType.ARCHIVE -> "\\Archive"
|
||||
else -> error("FolderType.$this doesn't have an attribute name")
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
|
||||
public class AlertResponseTest {
|
||||
|
||||
@Test
|
||||
public void getAlertText_withProperAlertResponse() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO [ALERT] Please don't do that");
|
||||
|
||||
String result = AlertResponse.getAlertText(imapResponse);
|
||||
|
||||
assertEquals("Please don't do that", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAlertText_withoutResponseCodeText_shouldReturnNull() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO");
|
||||
|
||||
String result = AlertResponse.getAlertText(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAlertText_withoutAlertText_shouldReturnNull() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO [ALERT]");
|
||||
|
||||
String result = AlertResponse.getAlertText(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAlertText_withoutResponseCodeTextList_shouldReturnNull() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO ALERT ALARM!");
|
||||
|
||||
String result = AlertResponse.getAlertText(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAlertText_withResponseCodeTextContainingTooManyItems_shouldReturnNull() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO [ALERT SOMETHING] ALARM!");
|
||||
|
||||
String result = AlertResponse.getAlertText(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAlertText_withWrongResponseCodeText_shouldReturnNull() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO [ALARM] ALERT!");
|
||||
|
||||
String result = AlertResponse.getAlertText(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.mockito.internal.util.collections.Sets;
|
||||
|
||||
import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
|
||||
public class CapabilityResponseTest {
|
||||
|
||||
@Test
|
||||
public void parse_withProperResponseContainingCapabilityCode() throws Exception {
|
||||
CapabilityResponse result = parse("* OK [CAPABILITY IMAP4rev1 IDLE] Welcome");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(Sets.newSet("IMAP4REV1", "IDLE"), result.getCapabilities());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withTaggedResponse_shouldReturnNull() throws Exception {
|
||||
CapabilityResponse result = parse("1 OK");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutOkResponse_shouldReturnNull() throws Exception {
|
||||
CapabilityResponse result = parse("* BAD Go Away");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withOkResponseWithoutList_shouldReturnNull() throws Exception {
|
||||
CapabilityResponse result = parse("* OK Welcome");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withProperCapabilityResponse() throws Exception {
|
||||
ImapList list = createImapResponse("* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI XPIG-LATIN");
|
||||
|
||||
CapabilityResponse result = CapabilityResponse.parse(list);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(Sets.newSet("IMAP4REV1", "STARTTLS", "AUTH=GSSAPI", "XPIG-LATIN"), result.getCapabilities());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withListInCapabilityResponse_shouldReturnNull() throws Exception {
|
||||
ImapList list = createImapResponse("* CAPABILITY IMAP4rev1 []");
|
||||
|
||||
CapabilityResponse result = CapabilityResponse.parse(list);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutCapabilityResponse_shouldReturnNull() throws Exception {
|
||||
ImapList list = createImapResponse("* EXISTS 1");
|
||||
|
||||
CapabilityResponse result = CapabilityResponse.parse(list);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withEmptyResponseList_shouldReturnNull() throws Exception {
|
||||
List<ImapResponse> responses = Collections.emptyList();
|
||||
|
||||
CapabilityResponse result = CapabilityResponse.parse(responses);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutCapabilityResponseInResponseList_shouldReturnNull() throws Exception {
|
||||
List<ImapResponse> responses = Collections.singletonList(createImapResponse("* EXISTS 42"));
|
||||
|
||||
CapabilityResponse result = CapabilityResponse.parse(responses);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withSingleCapabilityResponseInResponseList() throws Exception {
|
||||
ImapResponse response = createImapResponse("* CAPABILITY IMAP4rev1 LOGINDISABLED STARTTLS");
|
||||
List<ImapResponse> responses = Collections.singletonList(response);
|
||||
|
||||
CapabilityResponse result = CapabilityResponse.parse(responses);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(Sets.newSet("IMAP4REV1", "STARTTLS", "LOGINDISABLED"), result.getCapabilities());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withCapabilityResponseInResponseList() throws Exception {
|
||||
ImapResponse responseOne = createImapResponse("* EXPUNGE 4");
|
||||
ImapResponse responseTwo = createImapResponse("* CAPABILITY IMAP4rev1 IDLE");
|
||||
List<ImapResponse> responses = Arrays.asList(responseOne, responseTwo);
|
||||
|
||||
CapabilityResponse result = CapabilityResponse.parse(responses);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(Sets.newSet("IMAP4REV1", "IDLE"), result.getCapabilities());
|
||||
}
|
||||
|
||||
private CapabilityResponse parse(String responseText) throws IOException {
|
||||
ImapResponse response = createImapResponse(responseText);
|
||||
List<ImapResponse> responses = Collections.singletonList(response);
|
||||
|
||||
return CapabilityResponse.parse(responses);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import kotlin.test.fail
|
||||
|
||||
class FakeImapStore : ImapStore {
|
||||
private var openConnectionCount = 0
|
||||
|
||||
var getFoldersAction: () -> List<FolderListItem> = { fail("getFoldersAction not set") }
|
||||
val hasOpenConnections: Boolean
|
||||
get() = openConnectionCount != 0
|
||||
|
||||
override val combinedPrefix: String?
|
||||
get() = null
|
||||
|
||||
override fun checkSettings() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getFolder(name: String): ImapFolder {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getFolders(): List<FolderListItem> {
|
||||
openConnectionCount++
|
||||
return getFoldersAction()
|
||||
}
|
||||
|
||||
override fun closeAllConnections() {
|
||||
openConnectionCount = 0
|
||||
}
|
||||
|
||||
override fun fetchImapPrefix() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
class FakeImapStoreConfig : ImapStoreConfig {
|
||||
var expungeImmediately = true
|
||||
|
||||
override var logLabel: String = "irrelevant"
|
||||
|
||||
override fun isSubscribedFoldersOnly(): Boolean {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun isExpungeImmediately(): Boolean = expungeImmediately
|
||||
|
||||
override fun clientInfo(): ImapClientInfo {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
|
||||
class FolderNameCodecTest {
|
||||
private var folderNameCode = FolderNameCodec()
|
||||
|
||||
@Test
|
||||
fun `encode() with ASCII argument should return input`() {
|
||||
assertThat(folderNameCode.encode("ASCII")).isEqualTo("ASCII")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode() with non-ASCII argument should return encoded string`() {
|
||||
assertThat(folderNameCode.encode("über")).isEqualTo("&APw-ber")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode() with encoded argument should return decoded string`() {
|
||||
assertThat(folderNameCode.decode("&ANw-bergr&APYA3w-entr&AOQ-ger")).isEqualTo("Übergrößenträger")
|
||||
}
|
||||
|
||||
@Test(expected = CharacterCodingException::class)
|
||||
fun `decode() with invalid encoded argument should throw`() {
|
||||
folderNameCode.decode("&12-foo")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.containsExactlyInAnyOrder
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
|
||||
class IdGrouperTest {
|
||||
@Test
|
||||
fun `groupIds() with single contiguous group`() {
|
||||
val ids = setOf(1L, 2L, 3L)
|
||||
|
||||
val groupedIds = IdGrouper.groupIds(ids)
|
||||
|
||||
assertThat(groupedIds.ids).isEmpty()
|
||||
assertThat(groupedIds.idGroups.mapToString()).containsExactly("1:3")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groupIds() without contiguous group`() {
|
||||
val ids = setOf(23L, 42L, 2L, 5L)
|
||||
|
||||
val groupedIds = IdGrouper.groupIds(ids)
|
||||
|
||||
assertThat(groupedIds.ids).isEqualTo(ids)
|
||||
assertThat(groupedIds.idGroups).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groupIds() with multiple contiguous groups`() {
|
||||
val ids = setOf(1L, 3L, 4L, 5L, 6L, 10L, 12L, 13L, 14L, 23L)
|
||||
|
||||
val groupedIds = IdGrouper.groupIds(ids)
|
||||
|
||||
assertThat(groupedIds.ids).containsExactlyInAnyOrder(1L, 10L, 23L)
|
||||
assertThat(groupedIds.idGroups.mapToString()).containsExactly("3:6", "12:14")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `groupIds() with single ID`() {
|
||||
val ids = setOf(23L)
|
||||
|
||||
val groupedIds = IdGrouper.groupIds(ids)
|
||||
|
||||
assertThat(groupedIds.ids).containsExactlyInAnyOrder(23L)
|
||||
assertThat(groupedIds.idGroups).isEmpty()
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun `groupIds() with empty set should throw`() {
|
||||
IdGrouper.groupIds(emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> List<T>.mapToString() = map { it.toString() }
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isLessThanOrEqualTo
|
||||
import java.util.TreeSet
|
||||
import org.junit.Test
|
||||
|
||||
class ImapCommandSplitterTest {
|
||||
|
||||
@Test
|
||||
fun splitCommand_withManyNonContiguousIds_shouldSplitCommand() {
|
||||
val ids = ImapResponseHelper.createNonContiguousIdSet(10000, 10500, 2)
|
||||
val groupedIds = GroupedIds(ids, emptyList())
|
||||
|
||||
val commands = ImapCommandSplitter.splitCommand(COMMAND_PREFIX, COMMAND_SUFFIX, groupedIds, 980)
|
||||
|
||||
assertThat(commands.size).isEqualTo(2)
|
||||
assertCommandLengthLimit(commands, 980)
|
||||
verifyCommandString(commands[0], ImapResponseHelper.createNonContiguousIdSet(10000, 10316, 2))
|
||||
verifyCommandString(commands[1], ImapResponseHelper.createNonContiguousIdSet(10318, 10500, 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitCommand_withContiguousAndNonContiguousIds_shouldGroupIdsAndSplitCommand() {
|
||||
val idSet: Set<Long> = ImapResponseHelper.createNonContiguousIdSet(10000, 10298, 2) +
|
||||
ImapResponseHelper.createNonContiguousIdSet(10402, 10500, 2)
|
||||
val idGroups = listOf(ContiguousIdGroup(10300L, 10400L))
|
||||
val groupedIds = GroupedIds(idSet, idGroups)
|
||||
|
||||
val commands = ImapCommandSplitter.splitCommand(COMMAND_PREFIX, COMMAND_SUFFIX, groupedIds, 980)
|
||||
|
||||
assertThat(commands.size).isEqualTo(2)
|
||||
assertCommandLengthLimit(commands, 980)
|
||||
verifyCommandString(
|
||||
commands[0],
|
||||
ImapResponseHelper.createNonContiguousIdSet(10000, 10298, 2) +
|
||||
ImapResponseHelper.createNonContiguousIdSet(10402, 10418, 2),
|
||||
)
|
||||
verifyCommandString(commands[1], ImapResponseHelper.createNonContiguousIdSet(10420, 10500, 2), "10300:10400")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun splitCommand_withEmptySuffix_shouldCreateCommandWithoutTrailingSpace() {
|
||||
val ids = ImapResponseHelper.createNonContiguousIdSet(1, 2, 1)
|
||||
val groupedIds = GroupedIds(ids, emptyList())
|
||||
|
||||
val commands = ImapCommandSplitter.splitCommand("UID SEARCH UID", "", groupedIds, 980)
|
||||
|
||||
assertThat(commands.size).isEqualTo(1)
|
||||
assertThat(commands[0]).isEqualTo("UID SEARCH UID 1,2")
|
||||
}
|
||||
|
||||
private fun assertCommandLengthLimit(commands: List<String>, lengthLimit: Int) {
|
||||
for (command in commands) {
|
||||
assertThat(command.length, "Command is too long").isLessThanOrEqualTo(lengthLimit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifyCommandString(actualCommand: String, ids: Set<Long>, idGroupString: String? = null) {
|
||||
val sortedIds: Set<Long> = TreeSet(ids)
|
||||
val expectedCommandBuilder = StringBuilder(COMMAND_PREFIX)
|
||||
.append(" ")
|
||||
.append(ImapUtility.join(",", sortedIds))
|
||||
if (idGroupString != null) {
|
||||
expectedCommandBuilder.append(',').append(idGroupString)
|
||||
}
|
||||
expectedCommandBuilder.append(" ").append(COMMAND_SUFFIX)
|
||||
|
||||
val expectedCommand = expectedCommandBuilder.toString()
|
||||
|
||||
assertThat(actualCommand).isEqualTo(expectedCommand)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val COMMAND_PREFIX = "UID COPY"
|
||||
private const val COMMAND_SUFFIX = "\"Destination\""
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.prop
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.FolderType
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.folders.FolderFetcherException
|
||||
import com.fsck.k9.mail.folders.FolderServerId
|
||||
import com.fsck.k9.mail.folders.RemoteFolder
|
||||
import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList
|
||||
import com.fsck.k9.mail.testing.security.FakeTrustManager
|
||||
import com.fsck.k9.mail.testing.security.SimpleTrustedSocketFactory
|
||||
import kotlin.test.Test
|
||||
|
||||
class ImapFolderFetcherTest {
|
||||
private val fakeTrustManager = FakeTrustManager()
|
||||
private val trustedSocketFactory = SimpleTrustedSocketFactory(fakeTrustManager)
|
||||
private val fakeImapStore = FakeImapStore()
|
||||
private val folderFetcher = ImapFolderFetcher(
|
||||
trustedSocketFactory = trustedSocketFactory,
|
||||
oAuth2TokenProviderFactory = null,
|
||||
clientInfoAppName = "irrelevant",
|
||||
clientInfoAppVersion = "irrelevant",
|
||||
imapStoreFactory = { _, _, _, _ ->
|
||||
fakeImapStore
|
||||
},
|
||||
)
|
||||
private val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = "irrelevant",
|
||||
port = 9999,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = "irrelevant",
|
||||
password = "irrelevant",
|
||||
clientCertificateAlias = null,
|
||||
extra = ImapStoreSettings.createExtra(
|
||||
autoDetectNamespace = true,
|
||||
pathPrefix = null,
|
||||
useCompression = false,
|
||||
sendClientInfo = false,
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Test
|
||||
fun `regular folder list`() {
|
||||
fakeImapStore.getFoldersAction = {
|
||||
listOf(
|
||||
FolderListItem(
|
||||
serverId = "INBOX",
|
||||
name = "INBOX",
|
||||
type = FolderType.INBOX,
|
||||
),
|
||||
FolderListItem(
|
||||
serverId = "[Gmail]/All Mail",
|
||||
name = "[Gmail]/All Mail",
|
||||
type = FolderType.ARCHIVE,
|
||||
),
|
||||
FolderListItem(
|
||||
serverId = "[Gmail]/Drafts",
|
||||
name = "[Gmail]/Drafts",
|
||||
type = FolderType.DRAFTS,
|
||||
),
|
||||
FolderListItem(
|
||||
serverId = "[Gmail]/Important",
|
||||
name = "[Gmail]/Important",
|
||||
type = FolderType.REGULAR,
|
||||
),
|
||||
FolderListItem(
|
||||
serverId = "[Gmail]/Sent Mail",
|
||||
name = "[Gmail]/Sent Mail",
|
||||
type = FolderType.SENT,
|
||||
),
|
||||
FolderListItem(
|
||||
serverId = "[Gmail]/Spam",
|
||||
name = "[Gmail]/Spam",
|
||||
type = FolderType.SPAM,
|
||||
),
|
||||
FolderListItem(
|
||||
serverId = "[Gmail]/Starred",
|
||||
name = "[Gmail]/Starred",
|
||||
type = FolderType.REGULAR,
|
||||
),
|
||||
FolderListItem(
|
||||
serverId = "[Gmail]/Trash",
|
||||
name = "[Gmail]/Trash",
|
||||
type = FolderType.TRASH,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val folders = folderFetcher.getFolders(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(folders).containsExactly(
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId("INBOX"),
|
||||
displayName = "INBOX",
|
||||
type = FolderType.INBOX,
|
||||
),
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId("[Gmail]/All Mail"),
|
||||
displayName = "[Gmail]/All Mail",
|
||||
type = FolderType.ARCHIVE,
|
||||
),
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId("[Gmail]/Drafts"),
|
||||
displayName = "[Gmail]/Drafts",
|
||||
type = FolderType.DRAFTS,
|
||||
),
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId("[Gmail]/Important"),
|
||||
displayName = "[Gmail]/Important",
|
||||
type = FolderType.REGULAR,
|
||||
),
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId("[Gmail]/Sent Mail"),
|
||||
displayName = "[Gmail]/Sent Mail",
|
||||
type = FolderType.SENT,
|
||||
),
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId("[Gmail]/Spam"),
|
||||
displayName = "[Gmail]/Spam",
|
||||
type = FolderType.SPAM,
|
||||
),
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId("[Gmail]/Starred"),
|
||||
displayName = "[Gmail]/Starred",
|
||||
type = FolderType.REGULAR,
|
||||
),
|
||||
RemoteFolder(
|
||||
serverId = FolderServerId("[Gmail]/Trash"),
|
||||
displayName = "[Gmail]/Trash",
|
||||
type = FolderType.TRASH,
|
||||
),
|
||||
)
|
||||
assertThat(fakeImapStore.hasOpenConnections).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authentication error should throw FolderFetcherException with server message`() {
|
||||
fakeImapStore.getFoldersAction = {
|
||||
throw AuthenticationFailedException(message = "Authentication failed", messageFromServer = "Server error")
|
||||
}
|
||||
|
||||
assertFailure {
|
||||
folderFetcher.getFolders(serverSettings, authStateStorage = null)
|
||||
}.isInstanceOf<FolderFetcherException>()
|
||||
.prop(FolderFetcherException::messageFromServer).isEqualTo("Server error")
|
||||
|
||||
assertThat(fakeImapStore.hasOpenConnections).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NegativeImapResponseException should throw FolderFetcherException with reply text as messageFromServer`() {
|
||||
fakeImapStore.getFoldersAction = {
|
||||
throw NegativeImapResponseException(
|
||||
message = "irrelevant",
|
||||
responses = createImapResponseList("x NO [NOPERM] Access denied"),
|
||||
)
|
||||
}
|
||||
|
||||
assertFailure {
|
||||
folderFetcher.getFolders(serverSettings, authStateStorage = null)
|
||||
}.isInstanceOf<FolderFetcherException>()
|
||||
.prop(FolderFetcherException::messageFromServer).isEqualTo("Access denied")
|
||||
|
||||
assertThat(fakeImapStore.hasOpenConnections).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unexpected exception should throw FolderFetcherException`() {
|
||||
fakeImapStore.getFoldersAction = {
|
||||
error("unexpected")
|
||||
}
|
||||
|
||||
assertFailure {
|
||||
folderFetcher.getFolders(serverSettings, authStateStorage = null)
|
||||
}.isInstanceOf<FolderFetcherException>()
|
||||
.prop(FolderFetcherException::messageFromServer).isNull()
|
||||
|
||||
assertThat(fakeImapStore.hasOpenConnections).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Calendar;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
|
||||
public class ImapListTest {
|
||||
|
||||
private ImapList buildSampleList() {
|
||||
ImapList list = new ImapList();
|
||||
list.add("ONE");
|
||||
list.add("TWO");
|
||||
list.add("THREE");
|
||||
return list;
|
||||
}
|
||||
|
||||
@Test public void containsKey_returnsTrueForKeys() throws IOException {
|
||||
ImapList list = buildSampleList();
|
||||
|
||||
assertTrue(list.containsKey("ONE"));
|
||||
assertTrue(list.containsKey("TWO"));
|
||||
assertFalse(list.containsKey("THREE"));
|
||||
assertFalse(list.containsKey("nonexistent"));
|
||||
}
|
||||
|
||||
@Test public void containsKey_returnsFalseForStringThatCantBeKey() throws IOException {
|
||||
ImapList list = buildSampleList();
|
||||
|
||||
assertFalse(list.containsKey("THREE"));
|
||||
}
|
||||
|
||||
@Test public void containsKey_returnsFalseForStringNotInList() throws IOException {
|
||||
ImapList list = buildSampleList();
|
||||
|
||||
assertFalse(list.containsKey("nonexistent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getKeyedValue_providesCorrespondingValues() {
|
||||
ImapList list = buildSampleList();
|
||||
|
||||
assertEquals("TWO", list.getKeyedValue("ONE"));
|
||||
assertEquals("THREE", list.getKeyedValue("TWO"));
|
||||
assertNull(list.getKeyedValue("THREE"));
|
||||
assertNull(list.getKeyedValue("nonexistent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getKeyIndex_providesIndexForKeys() {
|
||||
ImapList list = buildSampleList();
|
||||
|
||||
assertEquals(0, list.getKeyIndex("ONE"));
|
||||
assertEquals(1, list.getKeyIndex("TWO"));
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void getKeyIndex_throwsExceptionForValue() {
|
||||
ImapList list = buildSampleList();
|
||||
|
||||
list.getKeyIndex("THREE");
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void getKeyIndex_throwsExceptionForNonExistantKey() {
|
||||
ImapList list = buildSampleList();
|
||||
|
||||
list.getKeyIndex("nonexistent");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getDate_returnsCorrectDateForValidString() throws MessagingException {
|
||||
ImapList list = new ImapList();
|
||||
list.add("INTERNALDATE");
|
||||
list.add("10-Mar-2000 12:02:01 GMT");
|
||||
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.setTime(list.getDate(1));
|
||||
|
||||
assertEquals(2000, c.get(Calendar.YEAR));
|
||||
assertEquals(Calendar.MARCH, c.get(Calendar.MONTH));
|
||||
assertEquals(10, c.get(Calendar.DAY_OF_MONTH));
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException.class)
|
||||
public void getDate_throwsExceptionForInvalidDate() throws MessagingException {
|
||||
ImapList list = new ImapList();
|
||||
list.add("INTERNALDATE");
|
||||
list.add("InvalidDate");
|
||||
|
||||
list.getDate(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getDate_returnsNullForNIL() throws MessagingException {
|
||||
ImapList list = new ImapList();
|
||||
list.add("INTERNALDATE");
|
||||
list.add("NIL");
|
||||
|
||||
assertNull(list.getDate(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getKeyedDate_returnsCorrectDateForValidString() throws MessagingException {
|
||||
ImapList list = new ImapList();
|
||||
list.add("INTERNALDATE");
|
||||
list.add("10-Mar-2000 12:02:01 GMT");
|
||||
|
||||
Calendar c = Calendar.getInstance();
|
||||
c.setTime(list.getKeyedDate("INTERNALDATE"));
|
||||
|
||||
assertEquals(2000, c.get(Calendar.YEAR));
|
||||
assertEquals(Calendar.MARCH, c.get(Calendar.MONTH));
|
||||
assertEquals(10, c.get(Calendar.DAY_OF_MONTH));
|
||||
}
|
||||
|
||||
@Test(expected = MessagingException.class)
|
||||
public void getKeyedDate_throwsExceptionForInvalidDate() throws MessagingException {
|
||||
ImapList list = new ImapList();
|
||||
list.add("INTERNALDATE");
|
||||
list.add("InvalidDate");
|
||||
|
||||
list.getKeyedDate("INTERNALDATE");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getKeyedDate_returnsNullForNIL() throws MessagingException {
|
||||
ImapList list = new ImapList();
|
||||
list.add("INTERNALDATE");
|
||||
list.add("NIL");
|
||||
|
||||
assertNull(list.getKeyedDate("INTERNALDATE"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import com.fsck.k9.mail.filter.PeekableInputStream;
|
||||
|
||||
|
||||
public class ImapResponseHelper {
|
||||
public static List<ImapResponse> createImapResponseList(String... responses) throws IOException {
|
||||
List<ImapResponse> imapResponses = new ArrayList<>();
|
||||
for (String response : responses) {
|
||||
imapResponses.add(createImapResponse(response));
|
||||
}
|
||||
return imapResponses;
|
||||
}
|
||||
|
||||
public static ImapResponse createImapResponse(String response) throws IOException {
|
||||
return createImapResponse(response, false);
|
||||
}
|
||||
|
||||
public static ImapResponse createImapResponse(String response, boolean utf8) throws IOException {
|
||||
String input = response + "\r\n";
|
||||
PeekableInputStream inputStream = new PeekableInputStream(new ByteArrayInputStream(input.getBytes()));
|
||||
ImapResponseParser parser = new ImapResponseParser(inputStream, new FolderNameCodec());
|
||||
parser.setUtf8Accepted(utf8);
|
||||
|
||||
return parser.readResponse();
|
||||
}
|
||||
|
||||
public static Set<Long> createNonContiguousIdSet(long start, long end, int interval) {
|
||||
Set<Long> ids = new HashSet<>();
|
||||
for (long i = start;i <= end;i += interval) {
|
||||
ids.add(i);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.all
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.cause
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.index
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isSameInstanceAs
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.assertions.prop
|
||||
import com.fsck.k9.mail.filter.FixedLengthInputStream
|
||||
import com.fsck.k9.mail.filter.PeekableInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ImapResponseParserTest {
|
||||
private var peekableInputStream: PeekableInputStream? = null
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Log.logger = TestLogger()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with untagged OK response`() {
|
||||
val parser = createParserWithResponses("* OK")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).containsExactly("OK")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with untagged OK response containing text`() {
|
||||
val parser = createParserWithResponses("* OK Some text here")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).containsExactly("OK", "Some text here")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with untagged OK response containing resp-text code`() {
|
||||
val parser = createParserWithResponses("* OK [UIDVALIDITY 3857529045]")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(2)
|
||||
assertThat(response).index(0).isEqualTo("OK")
|
||||
assertThat(response).index(1).isInstanceOf<ImapList>().containsExactly("UIDVALIDITY", "3857529045")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with untagged OK response containing resp-text code and text`() {
|
||||
val parser = createParserWithResponses("* OK [token1 token2] {x} test [...]")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(3)
|
||||
assertThat(response).index(0).isEqualTo("OK")
|
||||
assertThat(response).index(1).isInstanceOf<ImapList>().containsExactly("token1", "token2")
|
||||
assertThat(response).index(2).isEqualTo("{x} test [...]")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readStatusResponse() with OK response`() {
|
||||
val parser = createParserWithResponses(
|
||||
"* COMMAND BAR\tBAZ",
|
||||
"TAG OK COMMAND completed",
|
||||
)
|
||||
|
||||
val responses = parser.readStatusResponse("TAG", null, null, null)
|
||||
|
||||
assertThat(responses).hasSize(2)
|
||||
assertThat(responses).index(0).containsExactly("COMMAND", "BAR", "BAZ")
|
||||
assertThat(responses).index(1).containsExactly("OK", "COMMAND completed")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readStatusResponse() should only deliver untagged responses to UntaggedHandler`() {
|
||||
val parser = createParserWithResponses(
|
||||
"* UNTAGGED",
|
||||
"A2 OK COMMAND completed",
|
||||
)
|
||||
val untaggedHandler = TestUntaggedHandler()
|
||||
|
||||
parser.readStatusResponse("A2", null, null, untaggedHandler)
|
||||
|
||||
assertThat(untaggedHandler.responses).hasSize(1)
|
||||
assertThat(untaggedHandler.responses).index(0).containsExactly("UNTAGGED")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readStatusResponse() should skip tagged response that does not match tag`() {
|
||||
val parser = createParserWithResponses(
|
||||
"* UNTAGGED",
|
||||
"* 0 EXPUNGE",
|
||||
"* 42 EXISTS",
|
||||
"A1 COMMAND BAR BAZ",
|
||||
"A2 OK COMMAND completed",
|
||||
)
|
||||
val untaggedHandler = TestUntaggedHandler()
|
||||
|
||||
val responses = parser.readStatusResponse("A2", null, null, untaggedHandler)
|
||||
|
||||
assertThat(responses).hasSize(3)
|
||||
assertThat(responses).index(0).containsExactly("0", "EXPUNGE")
|
||||
assertThat(responses).index(1).containsExactly("42", "EXISTS")
|
||||
assertThat(responses).index(2).containsExactly("OK", "COMMAND completed")
|
||||
|
||||
assertThat(untaggedHandler.responses).hasSize(3)
|
||||
assertThat(untaggedHandler.responses).index(0).containsExactly("UNTAGGED")
|
||||
assertThat(untaggedHandler.responses).index(1).containsExactly("0", "EXPUNGE")
|
||||
assertThat(untaggedHandler.responses).index(2).containsExactly("42", "EXISTS")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readStatusResponse() should deliver untagged responses to UntaggedHandler even on negative tagged response`() {
|
||||
val parser = createParserWithResponses(
|
||||
"* untagged",
|
||||
"A2 NO Bad response",
|
||||
)
|
||||
val untaggedHandler = TestUntaggedHandler()
|
||||
|
||||
try {
|
||||
parser.readStatusResponse("A2", null, null, untaggedHandler)
|
||||
} catch (ignored: NegativeImapResponseException) {
|
||||
}
|
||||
|
||||
assertThat(untaggedHandler.responses).hasSize(1)
|
||||
assertThat(untaggedHandler.responses).index(0).containsExactly("untagged")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readStatusResponse() with error response should throw`() {
|
||||
val parser = createParserWithResponses(
|
||||
"* COMMAND BAR BAZ",
|
||||
"TAG ERROR COMMAND errored",
|
||||
)
|
||||
|
||||
assertFailure {
|
||||
parser.readStatusResponse("TAG", null, null, null)
|
||||
}.isInstanceOf<NegativeImapResponseException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with resp-text code containing a list`() {
|
||||
val parser = createParserWithResponses(
|
||||
"""* OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft NonJunk ${"$"}MDNSent \*)] """ +
|
||||
"Flags permitted.",
|
||||
)
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(3)
|
||||
assertThat(response).index(0).isEqualTo("OK")
|
||||
assertThat(response).index(1).isInstanceOf<ImapList>().all {
|
||||
index(0).isEqualTo("PERMANENTFLAGS")
|
||||
index(1).isInstanceOf<ImapList>().containsExactly(
|
||||
"""\Answered""",
|
||||
"""\Flagged""",
|
||||
"""\Deleted""",
|
||||
"""\Seen""",
|
||||
"""\Draft""",
|
||||
"NonJunk",
|
||||
"\$MDNSent",
|
||||
"""\*""",
|
||||
)
|
||||
}
|
||||
|
||||
assertThat(response).index(2).isEqualTo("Flags permitted.")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with untagged EXISTS response`() {
|
||||
val parser = createParserWithResponses("* 23 EXISTS")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(2)
|
||||
assertThat(response).transform { it.getNumber(0) }.isEqualTo(23)
|
||||
assertThat(response).transform { it.getString(1) }.isEqualTo("EXISTS")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() should throw if stream ends before end of line is found`() {
|
||||
val parser = createParserWithData("* OK Some text ")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<IOException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with command continuation`() {
|
||||
val parser = createParserWithResponses("+ Ready for additional command text")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response.isContinuationRequested).isTrue()
|
||||
assertThat(response).containsExactly("Ready for additional command text")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with literal`() {
|
||||
val parser = createParserWithResponses("* {4}\r\ntest")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).containsExactly("test")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with empty literal`() {
|
||||
val parser = createParserWithResponses("* {0}\r\n")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).containsExactly("")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with literal containing negative size`() {
|
||||
val parser = createParserWithResponses("* {-1}")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<ImapResponseParserException>()
|
||||
.hasMessage("Invalid value for size of literal string")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with literal size exceeding Int`() {
|
||||
val parser = createParserWithResponses("* {2147483648}")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<ImapResponseParserException>()
|
||||
.hasMessage("Invalid value for size of literal string")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with invalid characters for literal size`() {
|
||||
val parser = createParserWithResponses("* {invalid}")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<ImapResponseParserException>()
|
||||
.hasMessage("Invalid value for size of literal string")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() should throw when end of stream is reached while reading literal`() {
|
||||
val parser = createParserWithData("* {4}\r\nabc")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<IOException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with literal should include return value of ImapResponseCallback_foundLiteral() in response`() {
|
||||
val parser = createParserWithResponses("* {4}\r\ntest")
|
||||
val callback = TestImapResponseCallback.readBytesAndReturn(4, "replacement value")
|
||||
|
||||
val response = parser.readResponse(callback)
|
||||
|
||||
assertThat(response).containsExactly("replacement value")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with literal should read literal when ImapResponseCallback_foundLiteral() returns null`() {
|
||||
val parser = createParserWithResponses("* {4}\r\ntest")
|
||||
val callback = TestImapResponseCallback.readBytesAndReturn(0, null)
|
||||
|
||||
val response = parser.readResponse(callback)
|
||||
|
||||
assertThat(response).containsExactly("test")
|
||||
assertThat(callback.foundLiteralCalled).isTrue()
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with partly consuming callback returning null should throw`() {
|
||||
val parser = createParserWithResponses("* {4}\r\ntest")
|
||||
val callback = TestImapResponseCallback.readBytesAndReturn(2, null)
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse(callback)
|
||||
}.isInstanceOf<AssertionError>()
|
||||
.hasMessage("Callback consumed some data but returned no result")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with partly consuming callback that throws should read all data and throw`() {
|
||||
val parser = createParserWithResponses("* {4}\r\ntest")
|
||||
val callback = TestImapResponseCallback.readBytesAndThrow(2)
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse(callback)
|
||||
}.isInstanceOf<ImapResponseParserException>()
|
||||
.all {
|
||||
hasMessage("readResponse(): Exception in callback method")
|
||||
cause().isNotNull().isInstanceOf<ImapResponseParserTestException>()
|
||||
}
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with callback that throws repeatedly should consume all input and throw first exception`() {
|
||||
val parser = createParserWithResponses("* {3}\r\none {3}\r\ntwo")
|
||||
val callback = TestImapResponseCallback.readBytesAndThrow(3)
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse(callback)
|
||||
}.isInstanceOf<ImapResponseParserException>()
|
||||
.all {
|
||||
hasMessage("readResponse(): Exception in callback method")
|
||||
cause().isNotNull().isInstanceOf<ImapResponseParserTestException>()
|
||||
.prop(ImapResponseParserTestException::instanceNumber).isEqualTo(0)
|
||||
}
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with callback not consuming the entire literal should skip the rest of the literal`() {
|
||||
val parser = createParserWithResponses("* {3}\r\none two")
|
||||
val callback = TestImapResponseCallback.readBytesAndReturn(2, "replacement value")
|
||||
|
||||
val response = parser.readResponse(callback)
|
||||
|
||||
assertThat(response).containsExactly("replacement value", "two")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with callback not consuming and throwing should read response and throw`() {
|
||||
val parser = createParserWithResponses("* {4}\r\ntest")
|
||||
val callback = TestImapResponseCallback.readBytesAndThrow(0)
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse(callback)
|
||||
}.isInstanceOf<ImapResponseParserException>()
|
||||
.hasMessage("readResponse(): Exception in callback method")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with callback throwing IOException should re-throw that exception`() {
|
||||
val parser = createParserWithResponses("* {4}\r\ntest")
|
||||
val exception = IOException()
|
||||
val callback = ImapResponseCallback { _, _ -> throw exception }
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse(callback)
|
||||
}.isSameInstanceAs(exception)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with quoted string containing an escaped quote character`() {
|
||||
val parser = createParserWithResponses("""* "qu\"oted"""")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).containsExactly("""qu"oted""")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with UTF-8 data in quoted string`() {
|
||||
val parser = createParserWithResponses("""* "quöted"""")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).containsExactly("quöted")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() should throw when end of stream is reached before end of quoted string`() {
|
||||
val parser = createParserWithResponses("* \"abc")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<IOException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() should throw if end of stream is reached before end of atom`() {
|
||||
val parser = createParserWithData("* abc")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<IOException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() should throw if untagged response indicator is not followed by a space`() {
|
||||
val parser = createParserWithResponses("*")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<IOException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with LIST response containing folder name with brackets`() {
|
||||
val parser = createParserWithResponses("""* LIST (\HasNoChildren) "." [FolderName]""")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(4)
|
||||
assertThat(response).index(0).isEqualTo("LIST")
|
||||
assertThat(response).index(1).isInstanceOf<ImapList>().containsExactly("""\HasNoChildren""")
|
||||
assertThat(response).index(2).isEqualTo(".")
|
||||
assertThat(response).index(3).isEqualTo("[FolderName]")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with LIST response containing folder name with UTF8`() {
|
||||
val parser = createParserWithResponses(
|
||||
"""* LIST (\HasNoChildren) "." "萬里長城"""",
|
||||
"""* LIST (\HasNoChildren) "." "A&-B"""",
|
||||
)
|
||||
parser.setUtf8Accepted(true)
|
||||
|
||||
val response = parser.readResponse()
|
||||
assertThat(response).hasSize(4)
|
||||
assertThat(response).index(3).isEqualTo("萬里長城")
|
||||
|
||||
val response2 = parser.readResponse()
|
||||
assertThat(response2).hasSize(4)
|
||||
assertThat(response2).index(3).isEqualTo("A&-B")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with LIST response containing ambiguous folder name`() {
|
||||
val parser = createParserWithResponses("""* LIST (\HasNoChildren) "." "A&-B"""")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(4)
|
||||
assertThat(response).index(3).isEqualTo("A&B")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with LIST response containing folder name with literal UTF8`() {
|
||||
val parser = createParserWithResponses("""* LIST (\hasnochildren) "/" {18}""" + "\r\nИсходящие")
|
||||
|
||||
parser.setUtf8Accepted(true)
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(4)
|
||||
assertThat(response).index(3).isEqualTo("Исходящие")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with LIST response containing folder name with parentheses should throw`() {
|
||||
val parser = createParserWithResponses("""* LIST (\NoInferiors) "/" Root/Folder/Subfolder()""")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<IOException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() should read whole LIST response line`() {
|
||||
val parser = createParserWithResponses(
|
||||
"""* LIST (\HasNoChildren) "." [FolderName]""",
|
||||
"TAG OK [List complete]",
|
||||
)
|
||||
parser.readResponse()
|
||||
|
||||
val responseTwo = parser.readResponse()
|
||||
|
||||
assertThat(responseTwo.tag).isEqualTo("TAG")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with LIST response containing NIL`() {
|
||||
val parser = createParserWithResponses("""* LIST (\NoInferiors) NIL INBOX""")
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(4)
|
||||
assertThat(response).index(0).isEqualTo("LIST")
|
||||
assertThat(response).index(1).isInstanceOf<ImapList>().containsExactly("""\NoInferiors""")
|
||||
assertThat(response).index(2).isNull()
|
||||
assertThat(response).index(3).isEqualTo("INBOX")
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with tagged response missing completion code should throw`() {
|
||||
val parser = createParserWithResponses("tag ")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<ImapResponseParserException>()
|
||||
.hasMessage("Unexpected non-string token")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with list as first token should throw`() {
|
||||
val parser = createParserWithResponses("* [1 2] 3")
|
||||
|
||||
assertFailure {
|
||||
parser.readResponse()
|
||||
}.isInstanceOf<ImapResponseParserException>()
|
||||
.hasMessage("Unexpected non-string token")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readResponse() with FETCH response`() {
|
||||
val parser = createParserWithResponses(
|
||||
"* 1 FETCH (" +
|
||||
"UID 23 " +
|
||||
"""INTERNALDATE "01-Jul-2015 12:34:56 +0200" """ +
|
||||
"RFC822.SIZE 3456 " +
|
||||
"""BODY[HEADER.FIELDS (date subject from)] "<headers>" """ +
|
||||
"""FLAGS (\Seen)""" +
|
||||
")",
|
||||
)
|
||||
|
||||
val response = parser.readResponse()
|
||||
|
||||
assertThat(response).hasSize(3)
|
||||
assertThat(response).index(0).isEqualTo("1")
|
||||
assertThat(response).index(1).isEqualTo("FETCH")
|
||||
assertThat(response).index(2).isInstanceOf<ImapList>().all {
|
||||
hasSize(11)
|
||||
index(0).isEqualTo("UID")
|
||||
index(1).isEqualTo("23")
|
||||
index(2).isEqualTo("INTERNALDATE")
|
||||
index(3).isEqualTo("01-Jul-2015 12:34:56 +0200")
|
||||
index(4).isEqualTo("RFC822.SIZE")
|
||||
index(5).isEqualTo("3456")
|
||||
index(6).isEqualTo("BODY")
|
||||
index(7).isInstanceOf<ImapList>().all {
|
||||
hasSize(2)
|
||||
index(0).isEqualTo("HEADER.FIELDS")
|
||||
index(1).isInstanceOf<ImapList>().containsExactly("date", "subject", "from")
|
||||
}
|
||||
index(8).isEqualTo("<headers>")
|
||||
index(9).isEqualTo("FLAGS")
|
||||
index(10).isInstanceOf<ImapList>().containsExactly("""\Seen""")
|
||||
}
|
||||
assertThatAllInputWasConsumed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readStatusResponse() with NO response should throw`() {
|
||||
val parser = createParserWithResponses("1 NO")
|
||||
|
||||
assertFailure {
|
||||
parser.readStatusResponse("1", "COMMAND", "[logId]", null)
|
||||
}.isInstanceOf<NegativeImapResponseException>()
|
||||
.hasMessage("Command: COMMAND; response: #1# [NO]")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readStatusResponse() with NO response and alert text should throw with alert text`() {
|
||||
val parser = createParserWithResponses("1 NO [ALERT] Access denied\r\n")
|
||||
|
||||
assertFailure {
|
||||
parser.readStatusResponse("1", "COMMAND", "[logId]", null)
|
||||
}.isInstanceOf<NegativeImapResponseException>()
|
||||
.prop(NegativeImapResponseException::alertText).isEqualTo("Access denied")
|
||||
}
|
||||
|
||||
private fun createParserWithResponses(vararg responses: String): ImapResponseParser {
|
||||
val response = responses.joinToString(separator = "\r\n", postfix = "\r\n")
|
||||
return createParserWithData(response)
|
||||
}
|
||||
|
||||
private fun createParserWithData(response: String): ImapResponseParser {
|
||||
val byteArrayInputStream = ByteArrayInputStream(response.toByteArray(Charsets.UTF_8))
|
||||
peekableInputStream = PeekableInputStream(byteArrayInputStream)
|
||||
|
||||
return ImapResponseParser(peekableInputStream, FolderNameCodec())
|
||||
}
|
||||
|
||||
private fun assertThatAllInputWasConsumed() {
|
||||
assertThat(peekableInputStream).isNotNull().prop(PeekableInputStream::available).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
private class TestImapResponseCallback(
|
||||
private val readNumberOfBytes: Long,
|
||||
private val returnValue: Any?,
|
||||
private val throwException: Boolean,
|
||||
) : ImapResponseCallback {
|
||||
private var exceptionCount = 0
|
||||
var foundLiteralCalled = false
|
||||
|
||||
override fun foundLiteral(response: ImapResponse, literal: FixedLengthInputStream): Any? {
|
||||
foundLiteralCalled = true
|
||||
|
||||
var skipBytes = readNumberOfBytes
|
||||
while (skipBytes > 0) {
|
||||
skipBytes -= literal.skip(skipBytes)
|
||||
}
|
||||
|
||||
if (throwException) {
|
||||
throw ImapResponseParserTestException(exceptionCount++)
|
||||
}
|
||||
|
||||
return returnValue
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun readBytesAndReturn(readNumberOfBytes: Int, returnValue: Any?): TestImapResponseCallback {
|
||||
return TestImapResponseCallback(readNumberOfBytes.toLong(), returnValue, false)
|
||||
}
|
||||
|
||||
fun readBytesAndThrow(readNumberOfBytes: Int): TestImapResponseCallback {
|
||||
return TestImapResponseCallback(readNumberOfBytes.toLong(), null, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ImapResponseParserTestException(val instanceNumber: Int) : RuntimeException()
|
||||
|
||||
private class TestUntaggedHandler : UntaggedHandler {
|
||||
val responses = mutableListOf<ImapResponse>()
|
||||
|
||||
override fun handleAsyncUntaggedResponse(response: ImapResponse) {
|
||||
responses.add(response)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.prop
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ClientCertificateError
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import com.fsck.k9.mail.oauth.OAuth2TokenProvider
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidationResult
|
||||
import com.fsck.k9.mail.store.imap.mockserver.MockImapServer
|
||||
import com.fsck.k9.mail.testing.security.FakeTrustManager
|
||||
import com.fsck.k9.mail.testing.security.SimpleTrustedSocketFactory
|
||||
import java.net.UnknownHostException
|
||||
import kotlin.test.Test
|
||||
|
||||
private const val USERNAME = "user"
|
||||
private const val PASSWORD = "password"
|
||||
private const val AUTHORIZATION_STATE = "auth state"
|
||||
private const val AUTHORIZATION_TOKEN = "auth-token"
|
||||
private val CLIENT_CERTIFICATE_ALIAS: String? = null
|
||||
private const val CLIENT_NAME = "clientName"
|
||||
private const val CLIENT_VERSION = "clientVersion"
|
||||
|
||||
class ImapServerSettingsValidatorTest {
|
||||
private val fakeTrustManager = FakeTrustManager()
|
||||
private val trustedSocketFactory = SimpleTrustedSocketFactory(fakeTrustManager)
|
||||
private val serverSettingsValidator = ImapServerSettingsValidator(
|
||||
trustedSocketFactory = trustedSocketFactory,
|
||||
oAuth2TokenProviderFactory = null,
|
||||
clientInfoAppName = CLIENT_NAME,
|
||||
clientInfoAppVersion = CLIENT_VERSION,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `valid server settings with password should return Success`() {
|
||||
val server = startServer {
|
||||
output("* OK IMAP4rev1 server ready")
|
||||
expect("1 CAPABILITY")
|
||||
output("* CAPABILITY IMAP4rev1 AUTH=PLAIN")
|
||||
output("1 OK CAPABILITY Completed")
|
||||
expect("2 AUTHENTICATE PLAIN")
|
||||
output("+")
|
||||
expect("AHVzZXIAcGFzc3dvcmQ=")
|
||||
output("2 OK [CAPABILITY IMAP4rev1 AUTH=PLAIN NAMESPACE ID] LOGIN completed")
|
||||
expect("3 ID (\"name\" \"$CLIENT_NAME\" \"version\" \"$CLIENT_VERSION\")")
|
||||
output("* ID NIL")
|
||||
output("3 OK ID completed")
|
||||
expect("4 NAMESPACE")
|
||||
output("* NAMESPACE ((\"\" \"/\")) NIL NIL")
|
||||
output("4 OK command completed")
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
extra = ImapStoreSettings.createExtra(
|
||||
autoDetectNamespace = true,
|
||||
pathPrefix = null,
|
||||
useCompression = false,
|
||||
sendClientInfo = true,
|
||||
),
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.Success>()
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid server settings with OAuth should return Success`() {
|
||||
val serverSettingsValidator = ImapServerSettingsValidator(
|
||||
trustedSocketFactory = trustedSocketFactory,
|
||||
oAuth2TokenProviderFactory = { authStateStorage ->
|
||||
assertThat(authStateStorage.getAuthorizationState()).isEqualTo(AUTHORIZATION_STATE)
|
||||
FakeOAuth2TokenProvider()
|
||||
},
|
||||
clientInfoAppName = CLIENT_NAME,
|
||||
clientInfoAppVersion = CLIENT_VERSION,
|
||||
)
|
||||
val server = startServer {
|
||||
output("* OK IMAP4rev1 server ready")
|
||||
expect("1 CAPABILITY")
|
||||
output("* CAPABILITY IMAP4rev1 SASL-IR AUTH=PLAIN AUTH=OAUTHBEARER")
|
||||
output("1 OK CAPABILITY Completed")
|
||||
expect("2 AUTHENTICATE OAUTHBEARER bixhPXVzZXIsAWF1dGg9QmVhcmVyIGF1dGgtdG9rZW4BAQ==")
|
||||
output("2 OK [CAPABILITY IMAP4rev1 SASL-IR AUTH=PLAIN AUTH=OAUTHBEARER NAMESPACE ID] LOGIN completed")
|
||||
expect("3 ID (\"name\" \"$CLIENT_NAME\" \"version\" \"$CLIENT_VERSION\")")
|
||||
output("* ID NIL")
|
||||
output("3 OK ID completed")
|
||||
expect("4 NAMESPACE")
|
||||
output("* NAMESPACE ((\"\" \"/\")) NIL NIL")
|
||||
output("4 OK command completed")
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.XOAUTH2,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
extra = ImapStoreSettings.createExtra(
|
||||
autoDetectNamespace = true,
|
||||
pathPrefix = null,
|
||||
useCompression = false,
|
||||
sendClientInfo = true,
|
||||
),
|
||||
)
|
||||
val authStateStorage = FakeAuthStateStorage(authorizationState = AUTHORIZATION_STATE)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.Success>()
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authentication error should return AuthenticationError`() {
|
||||
val server = startServer {
|
||||
output("* OK IMAP4rev1 server ready")
|
||||
expect("1 CAPABILITY")
|
||||
output("* CAPABILITY IMAP4rev1")
|
||||
output("1 OK CAPABILITY Completed")
|
||||
expect("2 LOGIN \"user\" \"password\"")
|
||||
output("2 NO [AUTHENTICATIONFAILED] Authentication failed")
|
||||
closeConnection()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.AuthenticationError>()
|
||||
.prop(ServerSettingsValidationResult.AuthenticationError::serverMessage).isEqualTo("Authentication failed")
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error response should return ServerError`() {
|
||||
val server = startServer {
|
||||
output("* OK IMAP4rev1 server ready")
|
||||
expect("1 CAPABILITY")
|
||||
output("1 BAD Something went wrong")
|
||||
closeConnection()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.ServerError>()
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `certificate error when trying to connect should return CertificateError`() {
|
||||
fakeTrustManager.shouldThrowException = true
|
||||
val server = startServer {
|
||||
output("* OK IMAP4rev1 server ready")
|
||||
expect("1 CAPABILITY")
|
||||
output("* CAPABILITY IMAP4rev1 AUTH=PLAIN STARTTLS")
|
||||
output("1 OK CAPABILITY Completed")
|
||||
expect("2 STARTTLS")
|
||||
output("2 OK Begin TLS negotiation now")
|
||||
startTls()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.CertificateError>()
|
||||
.prop(ServerSettingsValidationResult.CertificateError::certificateChain).hasSize(1)
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing capability should return MissingServerCapabilityError`() {
|
||||
trustedSocketFactory.injectClientCertificateError(ClientCertificateError.RetrievalFailure)
|
||||
val server = startServer {
|
||||
output("* OK IMAP4rev1 server ready")
|
||||
expect("1 CAPABILITY")
|
||||
output("* CAPABILITY IMAP4rev1")
|
||||
output("1 OK CAPABILITY Completed")
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.MissingServerCapabilityError>()
|
||||
.prop(ServerSettingsValidationResult.MissingServerCapabilityError::capabilityName).isEqualTo("STARTTLS")
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client certificate retrieval failure connect should return ClientCertificateRetrievalFailure`() {
|
||||
trustedSocketFactory.injectClientCertificateError(ClientCertificateError.RetrievalFailure)
|
||||
val server = startServer {
|
||||
output("* OK IMAP4rev1 server ready")
|
||||
expect("1 CAPABILITY")
|
||||
output("* CAPABILITY IMAP4rev1 AUTH=PLAIN STARTTLS")
|
||||
output("1 OK CAPABILITY Completed")
|
||||
expect("2 STARTTLS")
|
||||
output("2 OK Begin TLS negotiation now")
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result)
|
||||
.isInstanceOf<ServerSettingsValidationResult.ClientCertificateError.ClientCertificateRetrievalFailure>()
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `client certificate expired should return ClientCertificateExpired`() {
|
||||
trustedSocketFactory.injectClientCertificateError(ClientCertificateError.CertificateExpired)
|
||||
val server = startServer {
|
||||
output("* OK IMAP4rev1 server ready")
|
||||
expect("1 CAPABILITY")
|
||||
output("* CAPABILITY IMAP4rev1 AUTH=PLAIN STARTTLS")
|
||||
output("1 OK CAPABILITY Completed")
|
||||
expect("2 STARTTLS")
|
||||
output("2 OK Begin TLS negotiation now")
|
||||
startTls()
|
||||
}
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = server.host,
|
||||
port = server.port,
|
||||
connectionSecurity = ConnectionSecurity.STARTTLS_REQUIRED,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result)
|
||||
.isInstanceOf<ServerSettingsValidationResult.ClientCertificateError.ClientCertificateExpired>()
|
||||
server.verifyConnectionClosed()
|
||||
server.verifyInteractionCompleted()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-existent hostname should return NetworkError`() {
|
||||
val serverSettings = ServerSettings(
|
||||
type = "imap",
|
||||
host = "domain.invalid",
|
||||
port = 587,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
val result = serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
|
||||
assertThat(result).isInstanceOf<ServerSettingsValidationResult.NetworkError>()
|
||||
.prop(ServerSettingsValidationResult.NetworkError::exception)
|
||||
.isInstanceOf<UnknownHostException>()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServerSettings with wrong type should throw`() {
|
||||
val serverSettings = ServerSettings(
|
||||
type = "wrong",
|
||||
host = "domain.invalid",
|
||||
port = 587,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = USERNAME,
|
||||
password = PASSWORD,
|
||||
clientCertificateAlias = CLIENT_CERTIFICATE_ALIAS,
|
||||
)
|
||||
|
||||
assertFailure {
|
||||
serverSettingsValidator.checkServerSettings(serverSettings, authStateStorage = null)
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
}
|
||||
|
||||
private fun startServer(block: MockImapServer.() -> Unit): MockImapServer {
|
||||
return MockImapServer().apply {
|
||||
block()
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeOAuth2TokenProvider(override val primaryEmail: String? = null) : OAuth2TokenProvider {
|
||||
override fun getToken(timeoutMillis: Long): String {
|
||||
return AUTHORIZATION_TOKEN
|
||||
}
|
||||
|
||||
override fun invalidateToken() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAuthStateStorage(
|
||||
private var authorizationState: String? = null,
|
||||
) : AuthStateStorage {
|
||||
override fun getAuthorizationState(): String? {
|
||||
return authorizationState
|
||||
}
|
||||
|
||||
override fun updateAuthorizationState(authorizationState: String?) {
|
||||
this.authorizationState = authorizationState
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
/*
|
||||
* 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.List;
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
import net.thunderbird.core.logging.testing.TestLogger;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
|
||||
|
||||
public class ImapUtilityTest {
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
Log.logger = new TestLogger();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetImapSequenceValues() {
|
||||
String[] expected;
|
||||
List<String> actual;
|
||||
|
||||
// Test valid sets
|
||||
expected = new String[] {"1"};
|
||||
actual = ImapUtility.getImapSequenceValues("1");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[] {"2147483648"}; // Integer.MAX_VALUE + 1
|
||||
actual = ImapUtility.getImapSequenceValues("2147483648");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[] {"4294967295"}; // 2^32 - 1
|
||||
actual = ImapUtility.getImapSequenceValues("4294967295");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[] {"1", "3", "2"};
|
||||
actual = ImapUtility.getImapSequenceValues("1,3,2");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[] {"4", "5", "6"};
|
||||
actual = ImapUtility.getImapSequenceValues("4:6");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[] {"9", "8", "7"};
|
||||
actual = ImapUtility.getImapSequenceValues("9:7");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[] {"1", "2", "3", "4", "9", "8", "7"};
|
||||
actual = ImapUtility.getImapSequenceValues("1,2:4,9:7");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
// Test numbers larger than Integer.MAX_VALUE (2147483647)
|
||||
expected = new String[] {"2147483646", "2147483647", "2147483648"};
|
||||
actual = ImapUtility.getImapSequenceValues("2147483646:2147483648");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
// Test partially invalid sets
|
||||
expected = new String[] { "1", "5" };
|
||||
actual = ImapUtility.getImapSequenceValues("1,x,5");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[] { "1", "2", "3" };
|
||||
actual = ImapUtility.getImapSequenceValues("a:d,1:3");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
// Test invalid sets
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapSequenceValues("");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapSequenceValues(null);
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapSequenceValues("a");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapSequenceValues("1:x");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
// Test values larger than 2^32 - 1
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapSequenceValues("4294967296:4294967297");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapSequenceValues("4294967296"); // 2^32
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
}
|
||||
|
||||
@Test public void testGetImapRangeValues() {
|
||||
String[] expected;
|
||||
List<String> actual;
|
||||
|
||||
// Test valid ranges
|
||||
expected = new String[] {"1", "2", "3"};
|
||||
actual = ImapUtility.getImapRangeValues("1:3");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[] {"16", "15", "14"};
|
||||
actual = ImapUtility.getImapRangeValues("16:14");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
// Test in-valid ranges
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapRangeValues("");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapRangeValues(null);
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapRangeValues("a");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapRangeValues("6");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapRangeValues("1:3,6");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapRangeValues("1:x");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
|
||||
expected = new String[0];
|
||||
actual = ImapUtility.getImapRangeValues("1:*");
|
||||
assertArrayEquals(expected, actual.toArray());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
|
||||
import static java.util.Arrays.asList;
|
||||
import static java.util.Collections.emptyList;
|
||||
import static java.util.Collections.singletonList;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
|
||||
public class ListResponseTest {
|
||||
@Test
|
||||
public void parseList_withValidResponses_shouldReturnListResponses() throws Exception {
|
||||
List<ImapResponse> responses = asList(
|
||||
createImapResponse("* LIST () \"/\" blurdybloop"),
|
||||
createImapResponse("* LIST (\\Noselect) \"/\" foo"),
|
||||
createImapResponse("* LIST () \"/\" foo/bar"),
|
||||
createImapResponse("* LIST (\\NoInferiors) NIL INBOX"),
|
||||
createImapResponse("X OK LIST completed")
|
||||
);
|
||||
|
||||
List<ListResponse> result = ListResponse.parseList(responses);
|
||||
|
||||
assertEquals(4, result.size());
|
||||
assertListResponseEquals(noAttributes(), "/", "blurdybloop", result.get(0));
|
||||
assertListResponseEquals(singletonList("\\Noselect"), "/", "foo", result.get(1));
|
||||
assertListResponseEquals(noAttributes(), "/", "foo/bar", result.get(2));
|
||||
assertListResponseEquals(singletonList("\\NoInferiors"), null, "INBOX", result.get(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseList_withValidResponse_shouldReturnListResponse() throws Exception {
|
||||
List<ListResponse> result = parseSingle("* LIST () \".\" \"Folder\"");
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertListResponseEquals(noAttributes(), ".", "Folder", result.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseList_withValidResponseContainingAttributes_shouldReturnListResponse() throws Exception {
|
||||
List<ListResponse> result = parseSingle("* LIST (\\HasChildren \\Noselect) \".\" \"Folder\"");
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertListResponseEquals(asList("\\HasChildren", "\\Noselect"), ".", "Folder", result.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseList_withoutListResponse_shouldReturnEmptyList() throws Exception {
|
||||
List<ListResponse> result = parseSingle("* LSUB () \".\" INBOX");
|
||||
|
||||
assertEquals(emptyList(), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseList_withMalformedListResponse1_shouldReturnEmptyList() throws Exception {
|
||||
List<ListResponse> result = parseSingle("* LIST ([inner list]) \"/\" \"Folder\"");
|
||||
|
||||
assertEquals(emptyList(), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseList_withMalformedListResponse2_shouldReturnEmptyList() throws Exception {
|
||||
List<ListResponse> result = parseSingle("* LIST () \"ab\" \"Folder\"");
|
||||
|
||||
assertEquals(emptyList(), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseLsub_withValidResponse_shouldReturnListResponse() throws Exception {
|
||||
List<ImapResponse> responses = singletonList(createImapResponse("* LSUB () \".\" \"Folder\""));
|
||||
|
||||
List<ListResponse> result = ListResponse.parseLsub(responses);
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertListResponseEquals(noAttributes(), ".", "Folder", result.get(0));
|
||||
}
|
||||
|
||||
private List<ListResponse> parseSingle(String response) throws IOException {
|
||||
List<ImapResponse> responses = singletonList(createImapResponse(response));
|
||||
|
||||
return ListResponse.parseList(responses);
|
||||
}
|
||||
|
||||
private List<String> noAttributes() {
|
||||
return emptyList();
|
||||
}
|
||||
|
||||
private void assertListResponseEquals(List<String> attributes, String delimiter, String name,
|
||||
ListResponse listResponse) {
|
||||
assertEquals(attributes, listResponse.getAttributes());
|
||||
assertEquals(delimiter, listResponse.getHierarchyDelimiter());
|
||||
assertEquals(name, listResponse.getName());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
|
||||
public class NamespaceResponseTest {
|
||||
|
||||
@Test
|
||||
public void parse_withProperNamespaceResponse() throws Exception {
|
||||
NamespaceResponse result = parse("* NAMESPACE ((\"\" \"/\")) NIL NIL");
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("", result.getPrefix());
|
||||
assertEquals("/", result.getHierarchyDelimiter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutNamespaceResponse_shouldReturnNull() throws Exception {
|
||||
NamespaceResponse result = parse("* OK Some text here");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withTooShortNamespaceResponse_shouldReturnNull() throws Exception {
|
||||
NamespaceResponse result = parse("* NAMESPACE NIL NIL");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withPersonalNamespacesNotPresent_shouldReturnNull() throws Exception {
|
||||
NamespaceResponse result = parse("* NAMESPACE NIL NIL NIL");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withEmptyListForPersonalNamespaces_shouldReturnNull() throws Exception {
|
||||
NamespaceResponse result = parse("* NAMESPACE () NIL NIL");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withEmptyListForFirstPersonalNamespace_shouldReturnNull() throws Exception {
|
||||
NamespaceResponse result = parse("* NAMESPACE (()) NIL NIL");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withIncompleteFirstPersonalNamespace_shouldReturnNull() throws Exception {
|
||||
NamespaceResponse result = parse("* NAMESPACE ((\"\")) NIL NIL");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withEmptyResponseList() throws Exception {
|
||||
NamespaceResponse result = NamespaceResponse.parse(Collections.<ImapResponse>emptyList());
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withSingleItemInResponseList() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("* NAMESPACE ((\"\" \"/\")) NIL NIL");
|
||||
NamespaceResponse result = NamespaceResponse.parse(Collections.singletonList(imapResponse));
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("", result.getPrefix());
|
||||
assertEquals("/", result.getHierarchyDelimiter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withResponseList() throws Exception {
|
||||
ImapResponse imapResponseOne = createImapResponse("* OK");
|
||||
ImapResponse imapResponseTwo = createImapResponse("* NAMESPACE ((\"INBOX\" \".\")) NIL NIL");
|
||||
NamespaceResponse result = NamespaceResponse.parse(Arrays.asList(imapResponseOne, imapResponseTwo));
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("INBOX", result.getPrefix());
|
||||
assertEquals(".", result.getHierarchyDelimiter());
|
||||
}
|
||||
|
||||
|
||||
private NamespaceResponse parse(String response) throws IOException {
|
||||
ImapResponse imapResponse = createImapResponse(response);
|
||||
|
||||
return NamespaceResponse.parse(imapResponse);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList
|
||||
import kotlin.test.Test
|
||||
|
||||
class NegativeImapResponseExceptionTest {
|
||||
@Test
|
||||
fun `responseText property should contain response text of last response`() {
|
||||
val exception = NegativeImapResponseException(
|
||||
message = "Test",
|
||||
responses = createImapResponseList("x NO [AUTHENTICATIONFAILED] Authentication error #23"),
|
||||
)
|
||||
|
||||
assertThat(exception.responseText).isEqualTo("Authentication error #23")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `responseText property should be null when last response does not contain response text`() {
|
||||
val exception = NegativeImapResponseException(
|
||||
message = "Test",
|
||||
responses = createImapResponseList("x NO"),
|
||||
)
|
||||
|
||||
assertThat(exception.responseText).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alertText property should contain alert text of last response`() {
|
||||
val exception = NegativeImapResponseException(
|
||||
message = "Test",
|
||||
responses = createImapResponseList("x NO [ALERT] Service is shutting down"),
|
||||
)
|
||||
|
||||
assertThat(exception.alertText).isEqualTo("Service is shutting down")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alertText property should be null when last response does not contain alert text`() {
|
||||
val exception = NegativeImapResponseException(
|
||||
message = "Test",
|
||||
responses = createImapResponseList("x NO Not allowed"),
|
||||
)
|
||||
|
||||
assertThat(exception.alertText).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wasByeResponseReceived() should return true when BYE response was received`() {
|
||||
val exception = NegativeImapResponseException(
|
||||
message = "Test",
|
||||
responses = createImapResponseList(
|
||||
"* EXISTS 1",
|
||||
"* BYE",
|
||||
"x OK",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(exception.wasByeResponseReceived()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wasByeResponseReceived() should return false when no BYE response was received`() {
|
||||
val exception = NegativeImapResponseException(
|
||||
message = "Test",
|
||||
responses = createImapResponseList(
|
||||
"* EXISTS 1",
|
||||
"x OK",
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(exception.wasByeResponseReceived()).isFalse()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import com.fsck.k9.mail.Flag;
|
||||
import org.junit.Test;
|
||||
|
||||
import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.mockito.internal.util.collections.Sets.newSet;
|
||||
|
||||
|
||||
public class PermanentFlagsResponseTest {
|
||||
@Test
|
||||
public void parse_withPermanentFlagsResponse_shouldExtractFlags() throws Exception {
|
||||
ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Answered \\Flagged \\Deleted \\Seen " +
|
||||
"\\Draft $forwarded NonJunk $label1 \\*)] Flags permitted.");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(newSet(Flag.ANSWERED, Flag.FLAGGED, Flag.DELETED, Flag.SEEN, Flag.FORWARDED), result.getFlags());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withPermanentFlagsResponseContainingSpecialKeyword_shouldSetCanCreateKeywords() throws Exception {
|
||||
ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Deleted \\*)] Flags permitted.");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(true, result.canCreateKeywords());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withPermanentFlagsResponseNotContainingSpecialKeyword_shouldNotSetCanCreateKeywords()
|
||||
throws Exception {
|
||||
ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Deleted \\Seen)] Flags permitted.");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(false, result.canCreateKeywords());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withTaggedResponse_shouldReturnNull() throws Exception {
|
||||
ImapResponse response = createImapResponse("1 OK [PERMANENTFLAGS (\\Deleted \\Seen)] Flags permitted.");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutOkResponse_shouldReturnNull() throws Exception {
|
||||
ImapResponse response = createImapResponse("* BYE See you");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutResponseText_shouldReturnNull() throws Exception {
|
||||
ImapResponse response = createImapResponse("* OK Success");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withTooShortResponseText_shouldReturnNull() throws Exception {
|
||||
ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS]");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutPermanentFlagsResponse_shouldReturnNull() throws Exception {
|
||||
ImapResponse response = createImapResponse("* OK [UIDNEXT 1]");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutPermanentFlagsList_shouldReturnNull() throws Exception {
|
||||
ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS none]");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withInvalidElementInPermanentFlagsList_shouldReturnNull() throws Exception {
|
||||
ImapResponse response = createImapResponse("* OK [PERMANENTFLAGS (\\Seen ())]");
|
||||
|
||||
PermanentFlagsResponse result = PermanentFlagsResponse.parse(response);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,349 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isGreaterThan
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isTrue
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import java.io.IOException
|
||||
import java.net.SocketException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import net.thunderbird.core.logging.testing.TestLogger
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
private const val FOLDER_SERVER_ID = "folder"
|
||||
private const val TEST_TIMEOUT_SECONDS = 5L
|
||||
private const val IDLE_TIMEOUT_MS = 28 * 60 * 1000L
|
||||
|
||||
class RealImapFolderIdlerTest {
|
||||
private val idleRefreshManager = TestIdleRefreshManager()
|
||||
private val wakeLock = TestWakeLock(timeoutSeconds = TEST_TIMEOUT_SECONDS, isHeld = true)
|
||||
private val imapConnection = TestImapConnection(timeout = TEST_TIMEOUT_SECONDS)
|
||||
private val imapFolder = TestImapFolder(FOLDER_SERVER_ID, imapConnection)
|
||||
private val imapStore = TestImapStore(imapFolder)
|
||||
private val idleRefreshTimeoutProvider = object : IdleRefreshTimeoutProvider {
|
||||
override val idleRefreshTimeoutMs = IDLE_TIMEOUT_MS
|
||||
}
|
||||
private val idler = RealImapFolderIdler(
|
||||
idleRefreshManager,
|
||||
wakeLock,
|
||||
imapStore,
|
||||
imapStore,
|
||||
FOLDER_SERVER_ID,
|
||||
idleRefreshTimeoutProvider,
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Log.logger = TestLogger()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new message during IDLE`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.SYNC)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
imapConnection.enqueueUntaggedServerResponse("1 EXISTS")
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `flag change during IDLE`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.SYNC)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
imapConnection.enqueueUntaggedServerResponse("42 FETCH (FLAGS (\\Seen))")
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expunge during IDLE`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.SYNC)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
imapConnection.enqueueUntaggedServerResponse("23 EXPUNGE")
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refresh IDLE connection`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.SYNC)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
assertThat(wakeLock.isHeld).isTrue()
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
wakeLock.waitForRelease()
|
||||
idleRefreshManager.resetTimers()
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
assertThat(wakeLock.isHeld).isTrue()
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
wakeLock.waitForRelease()
|
||||
imapConnection.enqueueUntaggedServerResponse("1 EXISTS")
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
assertThat(wakeLock.isHeld).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stop ImapFolderIdler while IDLE`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.STOPPED)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
wakeLock.waitForRelease()
|
||||
idler.stop()
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle refresh timeout`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.STOPPED)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
wakeLock.waitForRelease()
|
||||
assertThat(idleRefreshManager.getTimeoutValue()).isEqualTo(IDLE_TIMEOUT_MS)
|
||||
idler.stop()
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `socket read timeouts`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.STOPPED)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
wakeLock.waitForRelease()
|
||||
assertThat(imapConnection.currentSocketReadTimeout).isGreaterThan(IDLE_TIMEOUT_MS.toInt())
|
||||
idler.stop()
|
||||
imapConnection.waitForCommand("DONE")
|
||||
assertThat(imapConnection.currentSocketReadTimeout).isEqualTo(imapConnection.defaultSocketReadTimeout)
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IDLE not supported`() {
|
||||
val latch = CountDownLatch(1)
|
||||
imapConnection.setIdleNotSupported()
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.NOT_SUPPORTED)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authentication error`() {
|
||||
val latch = CountDownLatch(1)
|
||||
imapFolder.throwOnOpen { throw AuthenticationFailedException("Authentication failure for test") }
|
||||
|
||||
thread {
|
||||
assertFailure {
|
||||
idler.idle()
|
||||
}.isInstanceOf<AuthenticationFailedException>()
|
||||
.hasMessage("Authentication failure for test")
|
||||
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network error on folder open`() {
|
||||
val latch = CountDownLatch(1)
|
||||
imapFolder.throwOnOpen { throw IOException("I/O error for test") }
|
||||
|
||||
thread {
|
||||
assertFailure {
|
||||
idler.idle()
|
||||
}.isInstanceOf<IOException>()
|
||||
.hasMessage("I/O error for test")
|
||||
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network error on IDLE`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
assertFailure {
|
||||
idler.idle()
|
||||
}.isInstanceOf<IOException>()
|
||||
.hasMessage("Socket closed during IDLE")
|
||||
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
imapConnection.waitForBlockingRead()
|
||||
imapConnection.throwOnRead { throw SocketException("Socket closed during IDLE") }
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `NO response to IDLE command`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.NOT_SUPPORTED)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueTaggedServerResponse("NO")
|
||||
|
||||
latch.awaitWithTimeout()
|
||||
assertThat(imapFolder.isOpen).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `irrelevant untagged response to IDLE command before continuation request`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.STOPPED)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueUntaggedServerResponse("OK irrelevant")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
|
||||
wakeLock.waitForRelease()
|
||||
idler.stop()
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
latch.awaitWithTimeout()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relevant untagged response to IDLE command before continuation request`() {
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
thread {
|
||||
val idleResult = idler.idle()
|
||||
|
||||
assertThat(idleResult).isEqualTo(IdleResult.SYNC)
|
||||
latch.countDown()
|
||||
}
|
||||
|
||||
imapConnection.waitForCommand("IDLE")
|
||||
imapConnection.enqueueUntaggedServerResponse("1 EXISTS")
|
||||
imapConnection.enqueueContinuationServerResponse()
|
||||
imapConnection.waitForCommand("DONE")
|
||||
imapConnection.enqueueTaggedServerResponse("OK")
|
||||
latch.awaitWithTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
private fun CountDownLatch.awaitWithTimeout() {
|
||||
assertThat(await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS), "Test timed out").isTrue()
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,503 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isSameInstanceAs
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
import com.fsck.k9.mail.FolderType
|
||||
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.ImapResponseHelper.createImapResponse
|
||||
import com.fsck.k9.mail.store.imap.ImapStoreSettings.createExtra
|
||||
import java.io.IOException
|
||||
import java.util.ArrayDeque
|
||||
import java.util.Deque
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
import org.junit.Test
|
||||
import org.mockito.ArgumentMatchers.anyString
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.doThrow
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.stub
|
||||
import org.mockito.kotlin.verify
|
||||
|
||||
class RealImapStoreTest {
|
||||
private val imapStore = createTestImapStore()
|
||||
|
||||
@Test
|
||||
fun `checkSettings() should create ImapConnection and call open()`() {
|
||||
val imapConnection = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
imapStore.checkSettings()
|
||||
|
||||
verify(imapConnection).open()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checkSettings() with open throwing an IOException should pass it through`() {
|
||||
val ioException = IOException()
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { open() } doThrow ioException
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
assertFailure {
|
||||
imapStore.checkSettings()
|
||||
}.isSameInstanceAs(ioException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with SPECIAL-USE capability should return special FolderInfo`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
|
||||
on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf(
|
||||
createImapResponse("""* LIST (\HasNoChildren) "/" "INBOX""""),
|
||||
createImapResponse("""* LIST (\Noselect \HasChildren) "/" "[Gmail]""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \All) "/" "[Gmail]/All Mail""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Drafts) "/" "[Gmail]/Drafts""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Important) "/" "[Gmail]/Important""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Sent) "/" "[Gmail]/Sent Mail""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Junk) "/" "[Gmail]/Spam""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Flagged) "/" "[Gmail]/Starred""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren \Trash) "/" "[Gmail]/Trash""""),
|
||||
createImapResponse("5 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
val foldersMap = folders.map { it.serverId to it.type }
|
||||
assertThat(foldersMap).containsExactly(
|
||||
"INBOX" to FolderType.INBOX,
|
||||
"[Gmail]/All Mail" to FolderType.ARCHIVE,
|
||||
"[Gmail]/Drafts" to FolderType.DRAFTS,
|
||||
"[Gmail]/Important" to FolderType.REGULAR,
|
||||
"[Gmail]/Sent Mail" to FolderType.SENT,
|
||||
"[Gmail]/Spam" to FolderType.SPAM,
|
||||
"[Gmail]/Starred" to FolderType.REGULAR,
|
||||
"[Gmail]/Trash" to FolderType.TRASH,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() without SPECIAL-USE capability should use simple LIST command`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn false
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
imapStore.getFolders()
|
||||
|
||||
verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""")
|
||||
verify(imapConnection).executeSimpleCommand("""LIST "" "*"""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() without LIST-EXTENDED capability should use simple LIST command`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn false
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
imapStore.getFolders()
|
||||
|
||||
verify(imapConnection, never()).executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""")
|
||||
verify(imapConnection).executeSimpleCommand("""LIST "" "*"""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolder() should not corrupt UTF8 folder names`() {
|
||||
val imapStore = createTestImapStore(isSubscribedFoldersOnly = false)
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST () "." "Chèvre"""", true),
|
||||
createImapResponse("6 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.name }).containsExactly("INBOX", "Chèvre")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() should ignore NoSelect entries`() {
|
||||
val imapStore = createTestImapStore(isSubscribedFoldersOnly = false)
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST () "." "INBOX""""),
|
||||
createImapResponse("""* LIST (\Noselect) "." "Folder""""),
|
||||
createImapResponse("""* LIST () "." "Folder.SubFolder""""),
|
||||
createImapResponse("6 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() should ignore NonExistent entries`() {
|
||||
val imapStore = createTestImapStore(isSubscribedFoldersOnly = false)
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
|
||||
on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf(
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""),
|
||||
createImapResponse("""* LIST (\NonExistent \HasChildren) "." "Folder""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""),
|
||||
createImapResponse("6 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with subscribedFoldersOnly = false`() {
|
||||
val imapStore = createTestImapStore(isSubscribedFoldersOnly = false)
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Folder""""),
|
||||
createImapResponse("6 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with subscribedFoldersOnly = true should only return existing subscribed folders`() {
|
||||
val imapStore = createTestImapStore(isSubscribedFoldersOnly = true)
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LSUB "" "*"""") } doReturn listOf(
|
||||
createImapResponse("""* LSUB (\HasNoChildren) "." "INBOX""""),
|
||||
createImapResponse("""* LSUB (\Noselect \HasChildren) "." "Folder""""),
|
||||
createImapResponse("""* LSUB (\HasNoChildren) "." "Folder.SubFolder""""),
|
||||
createImapResponse("""* LSUB (\HasNoChildren) "." "SubscribedFolderThatHasBeenDeleted""""),
|
||||
createImapResponse("5 OK Success"),
|
||||
)
|
||||
on { executeSimpleCommand("""LIST "" "*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "INBOX""""),
|
||||
createImapResponse("""* LIST (\Noselect \HasChildren) "." "Folder""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Folder.SubFolder""""),
|
||||
createImapResponse("6 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "Folder.SubFolder")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with namespace prefix`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST () "." "INBOX""""),
|
||||
createImapResponse("""* LIST () "." "INBOX.FolderOne""""),
|
||||
createImapResponse("""* LIST () "." "INBOX.FolderTwo""""),
|
||||
createImapResponse("5 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
imapStore.setTestCombinedPrefix("INBOX.")
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "INBOX.FolderTwo")
|
||||
assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with folder not matching namespace prefix`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "INBOX.*"""") } doReturn listOf(
|
||||
createImapResponse("""* LIST () "." "INBOX""""),
|
||||
createImapResponse("""* LIST () "." "INBOX.FolderOne""""),
|
||||
createImapResponse("""* LIST () "." "FolderTwo""""),
|
||||
createImapResponse("5 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
imapStore.setTestCombinedPrefix("INBOX.")
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders).isNotNull()
|
||||
assertThat(folders.map { it.serverId }).containsExactly("INBOX", "INBOX.FolderOne", "FolderTwo")
|
||||
assertThat(folders.map { it.name }).containsExactly("INBOX", "FolderOne", "FolderTwo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with duplicate folder names should remove duplicates and keep FolderType`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { hasCapability(Capabilities.LIST_EXTENDED) } doReturn true
|
||||
on { hasCapability(Capabilities.SPECIAL_USE) } doReturn true
|
||||
on { executeSimpleCommand("""LIST "" "*" RETURN (SPECIAL-USE)""") } doReturn listOf(
|
||||
createImapResponse("""* LIST () "." "INBOX""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""),
|
||||
createImapResponse("""* LIST (\Junk) "." "Junk""""),
|
||||
createImapResponse("""* LIST (\HasNoChildren) "." "Junk""""),
|
||||
createImapResponse("5 OK Success"),
|
||||
)
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val folders = imapStore.getFolders()
|
||||
|
||||
assertThat(folders.map { it.serverId to it.type }).containsExactly(
|
||||
"INBOX" to FolderType.INBOX,
|
||||
"Junk" to FolderType.SPAM,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() without exception should leave ImapConnection open`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand(anyString()) } doReturn listOf(createImapResponse("5 OK Success"))
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
imapStore.getFolders()
|
||||
|
||||
verify(imapConnection, never()).close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getFolders() with IOException should close ImapConnection`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { executeSimpleCommand("""LIST "" "*"""") } doThrow IOException::class
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
assertFailure {
|
||||
imapStore.getFolders()
|
||||
}.isInstanceOf<MessagingException>()
|
||||
|
||||
verify(imapConnection).close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() should create ImapConnection`() {
|
||||
val imapConnection = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() called twice without release should create two ImapConnection instances`() {
|
||||
val imapConnectionOne = createMockConnection()
|
||||
val imapConnectionTwo = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
val resultOne = imapStore.getConnection()
|
||||
val resultTwo = imapStore.getConnection()
|
||||
|
||||
assertThat(resultOne).isSameInstanceAs(imapConnectionOne)
|
||||
assertThat(resultTwo).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() called after release should return cached ImapConnection`() {
|
||||
val imapConnection = createMockConnection().stub {
|
||||
on { isConnected } doReturn true
|
||||
}
|
||||
imapStore.enqueueImapConnection(imapConnection)
|
||||
|
||||
val connection = imapStore.getConnection()
|
||||
imapStore.releaseConnection(connection)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() called after release with closed connection should return new ImapConnection instance`() {
|
||||
val imapConnectionOne = createMockConnection()
|
||||
val imapConnectionTwo = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
imapStore.getConnection()
|
||||
imapConnectionOne.stub {
|
||||
on { isConnected } doReturn false
|
||||
}
|
||||
imapStore.releaseConnection(imapConnectionOne)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() with dead connection in pool should return new ImapConnection instance`() {
|
||||
val imapConnectionOne = createMockConnection()
|
||||
val imapConnectionTwo = createMockConnection()
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
imapStore.getConnection()
|
||||
imapConnectionOne.stub {
|
||||
on { isConnected } doReturn true
|
||||
on { executeSimpleCommand(Commands.NOOP) } doThrow IOException::class
|
||||
}
|
||||
imapStore.releaseConnection(imapConnectionOne)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() with connection in pool and closeAllConnections() should return new ImapConnection instance`() {
|
||||
val imapConnectionOne = createMockConnection(1)
|
||||
val imapConnectionTwo = createMockConnection(2)
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
imapStore.getConnection()
|
||||
imapConnectionOne.stub {
|
||||
on { isConnected } doReturn true
|
||||
}
|
||||
imapStore.releaseConnection(imapConnectionOne)
|
||||
imapStore.closeAllConnections()
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getConnection() with connection outside of pool and closeAllConnections() should return new ImapConnection instance`() {
|
||||
val imapConnectionOne = createMockConnection(1)
|
||||
val imapConnectionTwo = createMockConnection(2)
|
||||
imapStore.enqueueImapConnection(imapConnectionOne)
|
||||
imapStore.enqueueImapConnection(imapConnectionTwo)
|
||||
|
||||
imapStore.getConnection()
|
||||
imapConnectionOne.stub {
|
||||
on { isConnected } doReturn true
|
||||
}
|
||||
imapStore.closeAllConnections()
|
||||
imapStore.releaseConnection(imapConnectionOne)
|
||||
|
||||
val result = imapStore.getConnection()
|
||||
|
||||
assertThat(result).isSameInstanceAs(imapConnectionTwo)
|
||||
}
|
||||
|
||||
private fun createMockConnection(connectionGeneration: Int = 1): ImapConnection {
|
||||
return mock {
|
||||
on { this.connectionGeneration } doReturn connectionGeneration
|
||||
}
|
||||
}
|
||||
|
||||
private fun createServerSettings(): ServerSettings {
|
||||
return ServerSettings(
|
||||
type = "imap",
|
||||
host = "imap.example.org",
|
||||
port = 143,
|
||||
connectionSecurity = ConnectionSecurity.NONE,
|
||||
authenticationType = AuthType.PLAIN,
|
||||
username = "user",
|
||||
password = "password",
|
||||
clientCertificateAlias = null,
|
||||
extra = createExtra(
|
||||
autoDetectNamespace = true,
|
||||
pathPrefix = null,
|
||||
useCompression = false,
|
||||
sendClientInfo = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTestImapStore(
|
||||
isSubscribedFoldersOnly: Boolean = false,
|
||||
): TestImapStore {
|
||||
return TestImapStore(
|
||||
serverSettings = createServerSettings(),
|
||||
config = createImapStoreConfig(isSubscribedFoldersOnly),
|
||||
trustedSocketFactory = mock(),
|
||||
oauth2TokenProvider = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createImapStoreConfig(
|
||||
isSubscribedFoldersOnly: Boolean,
|
||||
): ImapStoreConfig {
|
||||
return object : ImapStoreConfig {
|
||||
override val logLabel: String = "irrelevant"
|
||||
override fun isSubscribedFoldersOnly(): Boolean = isSubscribedFoldersOnly
|
||||
override fun isExpungeImmediately(): Boolean = true
|
||||
override fun clientInfo() = ImapClientInfo(appName = "irrelevant", appVersion = "irrelevant")
|
||||
}
|
||||
}
|
||||
|
||||
private class TestImapStore(
|
||||
serverSettings: ServerSettings,
|
||||
config: ImapStoreConfig,
|
||||
trustedSocketFactory: TrustedSocketFactory,
|
||||
oauth2TokenProvider: OAuth2TokenProvider?,
|
||||
) : RealImapStore(
|
||||
serverSettings,
|
||||
config,
|
||||
trustedSocketFactory,
|
||||
oauth2TokenProvider,
|
||||
) {
|
||||
private val imapConnections: Deque<ImapConnection> = ArrayDeque()
|
||||
private var testCombinedPrefix: String? = null
|
||||
|
||||
override fun createImapConnection(): ImapConnection {
|
||||
if (imapConnections.isEmpty()) {
|
||||
throw AssertionError("Unexpectedly tried to create an ImapConnection instance")
|
||||
}
|
||||
|
||||
return imapConnections.pop()
|
||||
}
|
||||
|
||||
fun enqueueImapConnection(imapConnection: ImapConnection) {
|
||||
imapConnections.add(imapConnection)
|
||||
}
|
||||
|
||||
override val combinedPrefix: String?
|
||||
get() = testCombinedPrefix ?: super.combinedPrefix
|
||||
|
||||
fun setTestCombinedPrefix(prefix: String?) {
|
||||
testCombinedPrefix = prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
|
||||
|
||||
public class ResponseCodeExtractorTest {
|
||||
@Test
|
||||
public void getResponseCode_withResponseCode() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED] No sir");
|
||||
|
||||
String result = ResponseCodeExtractor.getResponseCode(imapResponse);
|
||||
|
||||
assertEquals("AUTHENTICATIONFAILED", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getResponseCode_withoutResponseCode() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO Authentication failed");
|
||||
|
||||
String result = ResponseCodeExtractor.getResponseCode(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getResponseCode_withoutSingleItemResponse() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x NO");
|
||||
|
||||
String result = ResponseCodeExtractor.getResponseCode(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse
|
||||
import org.junit.Test
|
||||
|
||||
class ResponseTextExtractorTest {
|
||||
@Test
|
||||
fun `response with response code and response text`() {
|
||||
val imapResponse: ImapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED] Authentication error #23")
|
||||
|
||||
val result = ResponseTextExtractor.getResponseText(imapResponse)
|
||||
|
||||
assertThat(result).isEqualTo("Authentication error #23")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `response with only response text`() {
|
||||
val imapResponse: ImapResponse = createImapResponse("x NO AUTHENTICATE failed")
|
||||
|
||||
val result = ResponseTextExtractor.getResponseText(imapResponse)
|
||||
|
||||
assertThat(result).isEqualTo("AUTHENTICATE failed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `response without response code or text`() {
|
||||
val imapResponse: ImapResponse = createImapResponse("x NO")
|
||||
|
||||
val result = ResponseTextExtractor.getResponseText(imapResponse)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `response with only a response code`() {
|
||||
val imapResponse: ImapResponse = createImapResponse("x NO [AUTHENTICATIONFAILED]")
|
||||
|
||||
val result = ResponseTextExtractor.getResponseText(imapResponse)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.all
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.prop
|
||||
import com.fsck.k9.mail.store.imap.SearchResponse.Companion.parse
|
||||
import jdk.dynalink.linker.support.Guards.isNotNull
|
||||
import kotlin.test.Test
|
||||
import org.junit.Assert
|
||||
|
||||
class SearchResponseTest {
|
||||
@Test
|
||||
fun parse_withSingleSearchResponse_shouldExtractNumbers() {
|
||||
// Arrange
|
||||
val imapResponses = ImapResponseHelper.createImapResponseList(
|
||||
"* SEARCH 1 2 3",
|
||||
"* 23 EXISTS",
|
||||
"* SEARCH 4",
|
||||
"1 OK SEARCH completed",
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = parse(imapResponses)
|
||||
|
||||
// Assert
|
||||
assertThat(result).all {
|
||||
isNotNull()
|
||||
prop("numbers") { it.numbers }
|
||||
.containsExactly(1L, 2L, 3L, 4L)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_withMultipleSearchResponses_shouldExtractNumbers() {
|
||||
// Arrange
|
||||
val imapResponses = ImapResponseHelper.createImapResponseList(
|
||||
"* SEARCH 1 2 3",
|
||||
"* 23 EXISTS",
|
||||
"* SEARCH 4",
|
||||
"1 OK SEARCH completed",
|
||||
"* SEARCH 5 6",
|
||||
"* 19 EXPUNGED",
|
||||
"* SEARCH 7",
|
||||
"2 OK SEARCH completed",
|
||||
"* SEARCH 8",
|
||||
"3 OK SEARCH completed",
|
||||
)
|
||||
|
||||
// Act
|
||||
val result = parse(imapResponses)
|
||||
|
||||
// Assert
|
||||
assertThat(result).all {
|
||||
isNotNull()
|
||||
prop("numbers") { it.numbers }
|
||||
.containsExactly(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_withSingleTaggedSearchResponse_shouldReturnEmptyList() {
|
||||
// Arrange
|
||||
val imapResponses = ImapResponseHelper.createImapResponseList("x SEARCH 7 8 9")
|
||||
|
||||
// Act
|
||||
val result = parse(imapResponses)
|
||||
|
||||
Assert.assertNotNull(result)
|
||||
Assert.assertEquals(emptyList<Any>(), result.numbers)
|
||||
|
||||
// Assert
|
||||
assertThat(result).all {
|
||||
isNotNull()
|
||||
prop("numbers") { it.numbers }
|
||||
.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_withSingleTooShortResponse_shouldReturnEmptyList() {
|
||||
// Arrange
|
||||
val imapResponses = ImapResponseHelper.createImapResponseList("* SEARCH")
|
||||
|
||||
// Act
|
||||
val result = parse(imapResponses)
|
||||
|
||||
// Assert
|
||||
assertThat(result).all {
|
||||
isNotNull()
|
||||
prop("numbers") { it.numbers }
|
||||
.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_withSingleNoSearchResponse_shouldReturnEmptyList() {
|
||||
// Arrange
|
||||
val imapResponses = ImapResponseHelper.createImapResponseList("* 23 EXPUNGE")
|
||||
|
||||
// Act
|
||||
val result = parse(imapResponses)
|
||||
|
||||
// Assert
|
||||
assertThat(result).all {
|
||||
isNotNull()
|
||||
prop("numbers") { it.numbers }
|
||||
.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parse_withSingleSearchResponseContainingInvalidNumber_shouldReturnEmptyList() {
|
||||
// Arrange
|
||||
val imapResponses = ImapResponseHelper.createImapResponseList("* SEARCH A")
|
||||
|
||||
// Act
|
||||
val result = parse(imapResponses)
|
||||
|
||||
// Assert
|
||||
assertThat(result).all {
|
||||
isNotNull()
|
||||
prop("numbers") { it.numbers }
|
||||
.isEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
|
||||
public class SelectOrExamineResponseTest {
|
||||
@Test
|
||||
public void parse_withSelectResponse_shouldReturnOpenModeReadWrite() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x OK [READ-WRITE] Select completed.");
|
||||
|
||||
SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(true, result.hasOpenMode());
|
||||
assertEquals(OpenMode.READ_WRITE, result.getOpenMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withExamineResponse_shouldReturnOpenModeReadOnly() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x OK [READ-ONLY] Examine completed.");
|
||||
|
||||
SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(true, result.hasOpenMode());
|
||||
assertEquals(OpenMode.READ_ONLY, result.getOpenMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutResponseCode_shouldReturnHasOpenModeFalse() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x OK Select completed.");
|
||||
|
||||
SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(false, result.hasOpenMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getOpenMode_withoutResponseCode_shouldThrow() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x OK Select completed.");
|
||||
SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
|
||||
assertNotNull(result);
|
||||
|
||||
try {
|
||||
result.getOpenMode();
|
||||
fail("Expected exception");
|
||||
} catch (IllegalStateException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withInvalidResponseText_shouldReturnHasOpenModeFalse() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x OK [()] Examine completed.");
|
||||
|
||||
SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(false, result.hasOpenMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withUnknownResponseText_shouldReturnHasOpenModeFalse() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x OK [FUNKY] Examine completed.");
|
||||
|
||||
SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(false, result.hasOpenMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withUntaggedResponse_shouldReturnNull() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("* OK [READ-WRITE] Select completed.");
|
||||
|
||||
SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_withoutOkResponse_shouldReturnNull() throws Exception {
|
||||
ImapResponse imapResponse = createImapResponse("x BYE");
|
||||
|
||||
SelectOrExamineResponse result = SelectOrExamineResponse.parse(imapResponse);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.AuthType
|
||||
import com.fsck.k9.mail.ConnectionSecurity
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class SimpleImapSettings(
|
||||
override val host: String,
|
||||
override val port: Int = 0,
|
||||
override val connectionSecurity: ConnectionSecurity = ConnectionSecurity.NONE,
|
||||
override val authType: AuthType,
|
||||
override val username: String,
|
||||
override val password: String? = null,
|
||||
override val useCompression: Boolean = false,
|
||||
override val clientInfo: ImapClientInfo? = null,
|
||||
) : ImapSettings {
|
||||
override val clientCertificateAlias: String? = null
|
||||
|
||||
override var pathPrefix: String? = null
|
||||
override var pathDelimiter: String? = null
|
||||
|
||||
override fun setCombinedPrefix(prefix: String?) = Unit
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
class TestIdleRefreshManager : IdleRefreshManager {
|
||||
private val timers = mutableListOf<TestIdleRefreshTimer>()
|
||||
|
||||
@Synchronized
|
||||
override fun startTimer(timeout: Long, callback: () -> Unit): TestIdleRefreshTimer {
|
||||
return TestIdleRefreshTimer(timeout, callback).also { timer -> timers.add(timer) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun resetTimers() {
|
||||
for (timer in timers) {
|
||||
timer.trigger()
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
|
||||
fun getTimeoutValue(): Long? = timers.map { it.timeout }.minOrNull()
|
||||
}
|
||||
|
||||
class TestIdleRefreshTimer(val timeout: Long, private val callback: () -> Unit) : IdleRefreshTimer {
|
||||
override var isWaiting: Boolean = true
|
||||
private set
|
||||
|
||||
@Synchronized
|
||||
override fun cancel() {
|
||||
isWaiting = false
|
||||
}
|
||||
|
||||
fun trigger() {
|
||||
isWaiting = false
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.LinkedBlockingDeque
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
internal open class TestImapConnection(val timeout: Long, override val connectionGeneration: Int = 1) : ImapConnection {
|
||||
override val logId: String = "testConnection"
|
||||
override var isConnected: Boolean = false
|
||||
protected set
|
||||
override val outputStream: OutputStream
|
||||
get() = TODO("Not yet implemented")
|
||||
override val isUidPlusCapable: Boolean = true
|
||||
override val isUtf8AcceptCapable: Boolean = false
|
||||
override var isIdleCapable: Boolean = true
|
||||
protected set
|
||||
|
||||
val defaultSocketReadTimeout = 30 * 1000
|
||||
var currentSocketReadTimeout = defaultSocketReadTimeout
|
||||
protected set
|
||||
|
||||
@Volatile
|
||||
private var tag: Int = 0
|
||||
|
||||
private val receivedCommands = LinkedBlockingDeque<String>()
|
||||
private val responses = LinkedBlockingDeque<Response>()
|
||||
|
||||
private val readResponseLock = ReentrantLock()
|
||||
private val readResponseLockCondition = readResponseLock.newCondition()
|
||||
|
||||
override fun open() {
|
||||
isConnected = true
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
override fun canSendUTF8QuotedStrings(): Boolean {
|
||||
return false // to be mocked where appropriate
|
||||
}
|
||||
|
||||
override fun hasCapability(capability: String): Boolean {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun executeSimpleCommand(command: String): List<ImapResponse> {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun executeCommandWithIdSet(
|
||||
commandPrefix: String,
|
||||
commandSuffix: String,
|
||||
ids: Set<Long>,
|
||||
): List<ImapResponse> {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun sendCommand(command: String, sensitive: Boolean): String {
|
||||
val tag = ++tag
|
||||
println(">>> $tag $command")
|
||||
|
||||
receivedCommands.add(command)
|
||||
return tag.toString()
|
||||
}
|
||||
|
||||
override fun sendContinuation(continuation: String) {
|
||||
println(">>> $continuation")
|
||||
receivedCommands.add(continuation)
|
||||
}
|
||||
|
||||
override fun readResponse(): ImapResponse {
|
||||
readResponseLock.withLock {
|
||||
readResponseLockCondition.signal()
|
||||
}
|
||||
val imapResponse = when (val response = responses.take()) {
|
||||
is Response.Continuation -> ImapResponseHelper.createImapResponse("+ ${response.text}")
|
||||
is Response.Tagged -> ImapResponseHelper.createImapResponse("$tag ${response.response}")
|
||||
is Response.Untagged -> ImapResponseHelper.createImapResponse("* ${response.response}")
|
||||
is Response.Action -> response.action()
|
||||
}
|
||||
|
||||
println("<<< $imapResponse")
|
||||
|
||||
return imapResponse
|
||||
}
|
||||
|
||||
override fun readResponse(callback: ImapResponseCallback?): ImapResponse {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun setSocketDefaultReadTimeout() {
|
||||
currentSocketReadTimeout = defaultSocketReadTimeout
|
||||
}
|
||||
|
||||
override fun setSocketReadTimeout(timeout: Int) {
|
||||
currentSocketReadTimeout = timeout
|
||||
}
|
||||
|
||||
fun waitForCommand(command: String) {
|
||||
do {
|
||||
val receivedCommand = receivedCommands.poll(timeout, TimeUnit.SECONDS) ?: throw AssertionError("Timeout")
|
||||
} while (receivedCommand != command)
|
||||
}
|
||||
|
||||
fun waitForBlockingRead() {
|
||||
readResponseLock.withLock {
|
||||
readResponseLockCondition.await(timeout, TimeUnit.SECONDS)
|
||||
}
|
||||
}
|
||||
|
||||
fun throwOnRead(block: () -> Nothing) {
|
||||
responses.add(Response.Action(block))
|
||||
}
|
||||
|
||||
fun enqueueTaggedServerResponse(response: String) {
|
||||
responses.add(Response.Tagged(response))
|
||||
}
|
||||
|
||||
fun enqueueUntaggedServerResponse(response: String) {
|
||||
responses.add(Response.Untagged(response))
|
||||
}
|
||||
|
||||
fun enqueueContinuationServerResponse(text: String = "") {
|
||||
responses.add(Response.Continuation(text))
|
||||
}
|
||||
|
||||
fun setIdleNotSupported() {
|
||||
isIdleCapable = false
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Response {
|
||||
class Tagged(val response: String) : Response()
|
||||
class Untagged(val response: String) : Response()
|
||||
class Continuation(val text: String) : Response()
|
||||
class Action(val action: () -> ImapResponse) : Response()
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
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.FolderType
|
||||
import com.fsck.k9.mail.Message
|
||||
import com.fsck.k9.mail.MessageRetrievalListener
|
||||
import com.fsck.k9.mail.Part
|
||||
import java.util.Date
|
||||
|
||||
internal open class TestImapFolder(
|
||||
override val serverId: String,
|
||||
val connection: TestImapConnection,
|
||||
) : ImapFolder {
|
||||
override var mode: OpenMode? = null
|
||||
protected set
|
||||
|
||||
override var messageCount: Int = 0
|
||||
protected set
|
||||
|
||||
override var isOpen: Boolean = false
|
||||
protected set
|
||||
|
||||
private var openAction: () -> Unit = {}
|
||||
|
||||
override fun exists(): Boolean {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun open(mode: OpenMode) {
|
||||
openAction()
|
||||
this.mode = mode
|
||||
connection.open()
|
||||
isOpen = true
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
connection.close()
|
||||
isOpen = false
|
||||
mode = null
|
||||
}
|
||||
|
||||
override fun getUidValidity(): Long? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getMessage(uid: String): ImapMessage {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getUidFromMessageId(messageId: String): String? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getMessages(
|
||||
start: Int,
|
||||
end: Int,
|
||||
earliestDate: Date?,
|
||||
listener: MessageRetrievalListener<ImapMessage>?,
|
||||
): List<ImapMessage> {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun fetch(
|
||||
messages: List<ImapMessage>,
|
||||
fetchProfile: FetchProfile,
|
||||
listener: FetchListener?,
|
||||
maxDownloadSize: Int,
|
||||
) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun fetchPart(
|
||||
message: ImapMessage,
|
||||
part: Part,
|
||||
bodyFactory: BodyFactory,
|
||||
maxDownloadSize: Int,
|
||||
) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun search(
|
||||
queryString: String?,
|
||||
requiredFlags: Set<Flag>?,
|
||||
forbiddenFlags: Set<Flag>?,
|
||||
performFullTextSearch: Boolean,
|
||||
): List<ImapMessage> {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun appendMessages(messages: List<Message>): Map<String, String>? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun setFlagsForAllMessages(flags: Set<Flag>, value: Boolean) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun setFlags(messages: List<ImapMessage>, flags: Set<Flag>, value: Boolean) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun copyMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun moveMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun deleteMessages(messages: List<ImapMessage>) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun deleteAllMessages() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun expunge() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun expungeUids(uids: List<String>) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun create(folderType: FolderType): Boolean {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
fun throwOnOpen(block: () -> Nothing) {
|
||||
openAction = block
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
internal class TestImapStore(private val folder: ImapFolder) : ImapStore, ImapConnectionProvider {
|
||||
override val combinedPrefix: String?
|
||||
get() = throw UnsupportedOperationException("Not yet implemented")
|
||||
|
||||
override fun checkSettings() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getFolder(name: String): ImapFolder {
|
||||
return folder
|
||||
}
|
||||
|
||||
override fun getFolders(): List<FolderListItem> {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun getConnection(folder: ImapFolder): ImapConnection {
|
||||
if (folder !is TestImapFolder) throw AssertionError("getConnection() called with unknown ImapFolder instance")
|
||||
return folder.connection
|
||||
}
|
||||
|
||||
override fun closeAllConnections() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun fetchImapPrefix() {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import com.fsck.k9.mail.power.WakeLock
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
class TestWakeLock(private val timeoutSeconds: Long, isHeld: Boolean = false) : WakeLock {
|
||||
var isHeld = isHeld
|
||||
private set
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
private val lockCondition = lock.newCondition()
|
||||
|
||||
override fun acquire() {
|
||||
lock.withLock {
|
||||
if (isHeld) throw AssertionError("Tried to acquire wakelock we're already holding")
|
||||
|
||||
isHeld = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
lock.withLock {
|
||||
if (!isHeld) throw AssertionError("Tried to release a wakelock we're not holding")
|
||||
|
||||
isHeld = false
|
||||
lockCondition.signal()
|
||||
}
|
||||
}
|
||||
|
||||
override fun acquire(timeout: Long) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
override fun setReferenceCounted(counted: Boolean) {
|
||||
throw UnsupportedOperationException("not implemented")
|
||||
}
|
||||
|
||||
fun waitForRelease() {
|
||||
lock.withLock {
|
||||
if (isHeld) {
|
||||
lockCondition.await(timeoutSeconds, TimeUnit.SECONDS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import com.fsck.k9.mail.store.imap.ImapResponseHelper.createImapResponseList
|
||||
import kotlin.test.Test
|
||||
|
||||
class UidCopyResponseTest {
|
||||
@Test
|
||||
fun `parse() with COPYUID response should return UID mapping`() {
|
||||
val imapResponses = createImapResponseList("x OK [COPYUID 1 1,3:5 7:10] Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
.transform { it.uidMapping }
|
||||
.isEqualTo(
|
||||
mapOf(
|
||||
"1" to "7",
|
||||
"3" to "8",
|
||||
"4" to "9",
|
||||
"5" to "10",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with allowed untagged COPYUID response should return UID mapping`() {
|
||||
val imapResponses = createImapResponseList(
|
||||
"* OK [COPYUID 1 1,3 10:11]",
|
||||
"* 1 EXPUNGE",
|
||||
"* 1 EXPUNGE",
|
||||
"x OK MOVE completed",
|
||||
)
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses, allowUntaggedResponse = true)
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
.transform { it.uidMapping }
|
||||
.isEqualTo(
|
||||
mapOf(
|
||||
"1" to "10",
|
||||
"3" to "11",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with untagged response when not allowed should return null`() {
|
||||
val imapResponses = createImapResponseList("* OK [COPYUID 1 1,3:5 7:10] Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with response containing too few items should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() without OK response should return null`() {
|
||||
val imapResponses = createImapResponseList("x BYE Logout")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() without response text list should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with response text list containing too few items should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK [A B C] Success")
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() without COPYUID response should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK [A B C D] Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with first COPYUID argument not being a string should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK [COPYUID () C D] Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with second COPYUID argument not being a string should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK [COPYUID B () D] Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with third COPYUID argument not being a string should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK [COPYUID B C ()] Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with non-number COPYUID arguments should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK [COPYUID B C D] Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse() with unbalanced COPYUID arguments should return null`() {
|
||||
val imapResponses = createImapResponseList("x OK [COPYUID B 1 1,2] Success")
|
||||
|
||||
val result = UidCopyResponse.parse(imapResponses)
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.fsck.k9.mail.store.imap;
|
||||
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import com.fsck.k9.mail.Flag;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
|
||||
public class UidSearchCommandBuilderTest {
|
||||
|
||||
@Test
|
||||
public void build_withFullTextSearch() {
|
||||
String command = new UidSearchCommandBuilder()
|
||||
.performFullTextSearch(true)
|
||||
.requiredFlags(Collections.singleton(Flag.FLAGGED))
|
||||
.forbiddenFlags(Collections.singleton(Flag.DELETED))
|
||||
.queryString("query")
|
||||
.build();
|
||||
|
||||
assertEquals("UID SEARCH TEXT \"query\" FLAGGED NOT DELETED", command);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void build_withoutFullTextSearch() {
|
||||
String command = new UidSearchCommandBuilder()
|
||||
.performFullTextSearch(false)
|
||||
.requiredFlags(null)
|
||||
.forbiddenFlags(Collections.singleton(Flag.DELETED))
|
||||
.queryString("query")
|
||||
.build();
|
||||
|
||||
assertEquals("UID SEARCH OR OR OR OR SUBJECT \"query\" FROM \"query\" TO \"query\" CC \"query\"" +
|
||||
" BCC \"query\" NOT DELETED", command);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
package com.fsck.k9.mail.store.imap
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.prop
|
||||
import org.junit.Test
|
||||
|
||||
class UidValidityResponseTest {
|
||||
@Test
|
||||
fun validResponseWithText() {
|
||||
val response = ImapResponseHelper.createImapResponse("* OK [UIDVALIDITY 23] UIDs valid")
|
||||
|
||||
val result = UidValidityResponse.parse(response)
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
.prop(UidValidityResponse::uidValidity).isEqualTo(23)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validResponseWithoutText() {
|
||||
val response = ImapResponseHelper.createImapResponse("* OK [UIDVALIDITY 42]")
|
||||
|
||||
val result = UidValidityResponse.parse(response)
|
||||
|
||||
assertThat(result).isNotNull()
|
||||
.prop(UidValidityResponse::uidValidity).isEqualTo(42)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun taggedResponse_shouldReturnNull() {
|
||||
assertNotValid("99 OK [UIDVALIDITY 42]")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noResponse_shouldReturnNull() {
|
||||
assertNotValid("* NO [UIDVALIDITY 42]")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun responseTextWithOnlyOneItem_shouldReturnNull() {
|
||||
assertNotValid("* OK [UIDVALIDITY]")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uidValidityIsNotANumber_shouldReturnNull() {
|
||||
assertNotValid("* OK [UIDVALIDITY fourtytwo]")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negativeUidValidity_shouldReturnNull() {
|
||||
assertNotValid("* OK [UIDVALIDITY -1]")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uidValidityOutsideRange_shouldReturnNull() {
|
||||
assertNotValid("* OK [UIDVALIDITY 4294967296]")
|
||||
}
|
||||
|
||||
private fun assertNotValid(response: String) {
|
||||
val result = UidValidityResponse.parse(ImapResponseHelper.createImapResponse(response))
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
package com.fsck.k9.mail.store.imap.mockserver;
|
||||
|
||||
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.Deque;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.zip.Inflater;
|
||||
import java.util.zip.InflaterInputStream;
|
||||
|
||||
import com.fsck.k9.mail.testing.security.TestKeyStoreProvider;
|
||||
import com.jcraft.jzlib.JZlib;
|
||||
import com.jcraft.jzlib.ZOutputStream;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import okio.BufferedSink;
|
||||
import okio.BufferedSource;
|
||||
import okio.Okio;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
|
||||
|
||||
public class MockImapServer {
|
||||
private static final byte[] CRLF = { '\r', '\n' };
|
||||
|
||||
|
||||
private final Deque<ImapInteraction> interactions = new ConcurrentLinkedDeque<>();
|
||||
private final CountDownLatch waitForConnectionClosed = new CountDownLatch(1);
|
||||
private final CountDownLatch waitForAllExpectedCommands = new CountDownLatch(1);
|
||||
private final TestKeyStoreProvider keyStoreProvider;
|
||||
private final Logger logger;
|
||||
|
||||
private MockServerThread mockServerThread;
|
||||
private String host;
|
||||
private int port;
|
||||
|
||||
|
||||
public MockImapServer() {
|
||||
this(TestKeyStoreProvider.INSTANCE, new DefaultLogger());
|
||||
}
|
||||
|
||||
public MockImapServer(TestKeyStoreProvider keyStoreProvider, Logger logger) {
|
||||
this.keyStoreProvider = keyStoreProvider;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void output(String response) {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new CannedResponse(response));
|
||||
}
|
||||
|
||||
public void expect(String command) {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new ExpectedCommand(command));
|
||||
}
|
||||
|
||||
public void startTls() {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new UpgradeToTls());
|
||||
}
|
||||
|
||||
public void enableCompression() {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new EnableCompression());
|
||||
}
|
||||
|
||||
public void closeConnection() {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new CloseConnection());
|
||||
}
|
||||
|
||||
public void start() throws IOException {
|
||||
checkServerNotRunning();
|
||||
|
||||
InetAddress localAddress = InetAddress.getByName(null);
|
||||
ServerSocket serverSocket = new ServerSocket(0, 1, localAddress);
|
||||
InetSocketAddress localSocketAddress = (InetSocketAddress) serverSocket.getLocalSocketAddress();
|
||||
host = localSocketAddress.getHostString();
|
||||
port = serverSocket.getLocalPort();
|
||||
|
||||
mockServerThread = new MockServerThread(serverSocket, interactions, waitForConnectionClosed,
|
||||
waitForAllExpectedCommands, logger, keyStoreProvider);
|
||||
mockServerThread.start();
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
checkServerRunning();
|
||||
|
||||
mockServerThread.shouldStop();
|
||||
waitForMockServerThread();
|
||||
}
|
||||
|
||||
private void waitForMockServerThread() {
|
||||
try {
|
||||
mockServerThread.join(500L);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public String getHost() {
|
||||
checkServerRunning();
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
checkServerRunning();
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
public void waitForInteractionToComplete() {
|
||||
checkServerRunning();
|
||||
|
||||
try {
|
||||
waitForAllExpectedCommands.await(1000L, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyInteractionCompleted() {
|
||||
shutdown();
|
||||
|
||||
if (!interactions.isEmpty()) {
|
||||
throw new AssertionError("Interactions left: " + interactions.size());
|
||||
}
|
||||
|
||||
UnexpectedCommandException unexpectedCommandException = mockServerThread.getUnexpectedCommandException();
|
||||
if (unexpectedCommandException != null) {
|
||||
throw new AssertionError(unexpectedCommandException.getMessage(), unexpectedCommandException);
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyConnectionStillOpen() {
|
||||
checkServerRunning();
|
||||
|
||||
if (mockServerThread.isClientConnectionClosed()) {
|
||||
throw new AssertionError("Connection closed when it shouldn't be");
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyConnectionClosed() {
|
||||
checkServerRunning();
|
||||
|
||||
try {
|
||||
waitForConnectionClosed.await(300L, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
|
||||
if (!mockServerThread.isClientConnectionClosed()) {
|
||||
throw new AssertionError("Connection open when is shouldn't be");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkServerRunning() {
|
||||
if (mockServerThread == null) {
|
||||
throw new IllegalStateException("Server was never started");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkServerNotRunning() {
|
||||
if (mockServerThread != null) {
|
||||
throw new IllegalStateException("Server was already started");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public interface Logger {
|
||||
void log(String message);
|
||||
void log(String format, Object... args);
|
||||
}
|
||||
|
||||
private interface ImapInteraction {}
|
||||
|
||||
private static class ExpectedCommand implements ImapInteraction {
|
||||
private final String command;
|
||||
|
||||
|
||||
public ExpectedCommand(String command) {
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
public String getCommand() {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CannedResponse implements ImapInteraction {
|
||||
private final String response;
|
||||
|
||||
|
||||
public CannedResponse(String response) {
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public String getResponse() {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CloseConnection implements ImapInteraction {
|
||||
}
|
||||
|
||||
private static class EnableCompression implements ImapInteraction {
|
||||
}
|
||||
|
||||
private static class UpgradeToTls implements ImapInteraction {
|
||||
}
|
||||
|
||||
private static class UnexpectedCommandException extends Exception {
|
||||
public UnexpectedCommandException(String expectedCommand, String receivedCommand) {
|
||||
super("Expected <" + expectedCommand + ">, but received <" + receivedCommand + ">");
|
||||
}
|
||||
}
|
||||
|
||||
private static class MockServerThread extends Thread {
|
||||
private final ServerSocket serverSocket;
|
||||
private final Deque<ImapInteraction> interactions;
|
||||
private final CountDownLatch waitForConnectionClosed;
|
||||
private final CountDownLatch waitForAllExpectedCommands;
|
||||
private final Logger logger;
|
||||
private final TestKeyStoreProvider keyStoreProvider;
|
||||
|
||||
private volatile boolean shouldStop = false;
|
||||
private volatile Socket clientSocket;
|
||||
|
||||
private BufferedSource input;
|
||||
private BufferedSink output;
|
||||
private volatile UnexpectedCommandException unexpectedCommandException;
|
||||
|
||||
|
||||
public MockServerThread(ServerSocket serverSocket, Deque<ImapInteraction> interactions,
|
||||
CountDownLatch waitForConnectionClosed, CountDownLatch waitForAllExpectedCommands, Logger logger,
|
||||
TestKeyStoreProvider keyStoreProvider) {
|
||||
super("MockImapServer");
|
||||
this.serverSocket = serverSocket;
|
||||
this.interactions = interactions;
|
||||
this.waitForConnectionClosed = waitForConnectionClosed;
|
||||
this.waitForAllExpectedCommands = waitForAllExpectedCommands;
|
||||
this.logger = logger;
|
||||
this.keyStoreProvider = keyStoreProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
String hostAddress = serverSocket.getInetAddress().getHostAddress();
|
||||
int port = serverSocket.getLocalPort();
|
||||
logger.log("Listening on %s:%d", hostAddress, port);
|
||||
|
||||
Socket socket = null;
|
||||
try {
|
||||
socket = acceptConnectionAndCloseServerSocket();
|
||||
clientSocket = socket;
|
||||
|
||||
String remoteHostAddress = socket.getInetAddress().getHostAddress();
|
||||
int remotePort = socket.getPort();
|
||||
logger.log("Accepted connection from %s:%d", remoteHostAddress, remotePort);
|
||||
|
||||
input = Okio.buffer(Okio.source(socket));
|
||||
output = Okio.buffer(Okio.sink(socket));
|
||||
|
||||
while (!shouldStop && !interactions.isEmpty()) {
|
||||
handleInteractions(socket);
|
||||
}
|
||||
|
||||
waitForAllExpectedCommands.countDown();
|
||||
|
||||
while (!shouldStop) {
|
||||
readAdditionalCommands();
|
||||
}
|
||||
|
||||
waitForConnectionClosed.countDown();
|
||||
} catch (UnexpectedCommandException e) {
|
||||
unexpectedCommandException = e;
|
||||
} catch (IOException e) {
|
||||
if (!shouldStop) {
|
||||
logger.log("Exception: %s", e);
|
||||
}
|
||||
} catch (KeyStoreException | CertificateException | UnrecoverableKeyException |
|
||||
NoSuchAlgorithmException | KeyManagementException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
IOUtils.closeQuietly(socket);
|
||||
|
||||
logger.log("Exiting");
|
||||
}
|
||||
|
||||
private void handleInteractions(Socket socket) throws IOException, KeyStoreException,
|
||||
NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException, KeyManagementException,
|
||||
UnexpectedCommandException {
|
||||
|
||||
ImapInteraction interaction = interactions.pop();
|
||||
if (interaction instanceof ExpectedCommand) {
|
||||
readExpectedCommand((ExpectedCommand) interaction);
|
||||
} else if (interaction instanceof CannedResponse) {
|
||||
writeCannedResponse((CannedResponse) interaction);
|
||||
} else if (interaction instanceof CloseConnection) {
|
||||
clientSocket.close();
|
||||
} else if (interaction instanceof EnableCompression) {
|
||||
enableCompression(socket);
|
||||
} else if (interaction instanceof UpgradeToTls) {
|
||||
upgradeToTls(socket);
|
||||
}
|
||||
}
|
||||
|
||||
private void readExpectedCommand(ExpectedCommand expectedCommand) throws IOException,
|
||||
UnexpectedCommandException {
|
||||
|
||||
String command = input.readUtf8Line();
|
||||
if (command == null) {
|
||||
throw new EOFException();
|
||||
}
|
||||
|
||||
logger.log("C: %s", command);
|
||||
|
||||
String expected = expectedCommand.getCommand();
|
||||
if (!command.equals(expected)) {
|
||||
logger.log("EXPECTED: %s", expected);
|
||||
throw new UnexpectedCommandException(expected, command);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeCannedResponse(CannedResponse cannedResponse) throws IOException {
|
||||
String response = cannedResponse.getResponse();
|
||||
logger.log("S: %s", response);
|
||||
|
||||
output.writeUtf8(response);
|
||||
output.write(CRLF);
|
||||
output.flush();
|
||||
}
|
||||
|
||||
private void enableCompression(Socket socket) throws IOException {
|
||||
InputStream inputStream = new InflaterInputStream(socket.getInputStream(), new Inflater(true));
|
||||
input = Okio.buffer(Okio.source(inputStream));
|
||||
|
||||
ZOutputStream outputStream = new ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true);
|
||||
outputStream.setFlushMode(JZlib.Z_PARTIAL_FLUSH);
|
||||
output = Okio.buffer(Okio.sink(outputStream));
|
||||
}
|
||||
|
||||
private void upgradeToTls(Socket socket) throws KeyStoreException, IOException, NoSuchAlgorithmException,
|
||||
CertificateException, UnrecoverableKeyException, KeyManagementException {
|
||||
|
||||
KeyStore keyStore = keyStoreProvider.getKeyStore();
|
||||
|
||||
String defaultAlgorithm = KeyManagerFactory.getDefaultAlgorithm();
|
||||
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(defaultAlgorithm);
|
||||
keyManagerFactory.init(keyStore, keyStoreProvider.getPassword());
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(keyManagerFactory.getKeyManagers(), null, null);
|
||||
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
|
||||
|
||||
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(
|
||||
socket, socket.getInetAddress().getHostAddress(), socket.getPort(), true);
|
||||
sslSocket.setUseClientMode(false);
|
||||
sslSocket.startHandshake();
|
||||
|
||||
input = Okio.buffer(Okio.source(sslSocket.getInputStream()));
|
||||
output = Okio.buffer(Okio.sink(sslSocket.getOutputStream()));
|
||||
}
|
||||
|
||||
private void readAdditionalCommands() throws IOException {
|
||||
String command = input.readUtf8Line();
|
||||
if (command == null) {
|
||||
throw new EOFException();
|
||||
}
|
||||
|
||||
logger.log("Received additional command: %s", command);
|
||||
}
|
||||
|
||||
private Socket acceptConnectionAndCloseServerSocket() throws IOException {
|
||||
Socket socket = serverSocket.accept();
|
||||
serverSocket.close();
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
public void shouldStop() {
|
||||
shouldStop = true;
|
||||
|
||||
IOUtils.closeQuietly(clientSocket);
|
||||
}
|
||||
|
||||
public boolean isClientConnectionClosed() {
|
||||
return clientSocket.isClosed();
|
||||
}
|
||||
|
||||
public UnexpectedCommandException getUnexpectedCommandException() {
|
||||
return unexpectedCommandException;
|
||||
}
|
||||
}
|
||||
|
||||
private static class DefaultLogger implements Logger {
|
||||
@Override
|
||||
public void log(String message) {
|
||||
System.out.println("MockImapServer: " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void log(String format, Object... args) {
|
||||
log(String.format(format, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
20
mail/protocols/pop3/build.gradle.kts
Normal file
20
mail/protocols/pop3/build.gradle.kts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.jvm)
|
||||
alias(libs.plugins.android.lint)
|
||||
}
|
||||
|
||||
val testCoverageEnabled: Boolean by extra
|
||||
if (testCoverageEnabled) {
|
||||
apply(plugin = "jacoco")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.mail.common)
|
||||
implementation(projects.core.common)
|
||||
|
||||
testImplementation(projects.core.logging.testing)
|
||||
testImplementation(projects.mail.testing)
|
||||
testImplementation(libs.okio)
|
||||
testImplementation(libs.jzlib)
|
||||
testImplementation(libs.commons.io)
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
class Pop3Capabilities {
|
||||
boolean cramMD5;
|
||||
boolean authPlain;
|
||||
boolean stls;
|
||||
boolean top;
|
||||
boolean uidl;
|
||||
boolean external;
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("CRAM-MD5 %b, PLAIN %b, STLS %b, TOP %b, UIDL %b, EXTERNAL %b",
|
||||
cramMD5,
|
||||
authPlain,
|
||||
stls,
|
||||
top,
|
||||
uidl,
|
||||
external);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package com.fsck.k9.mail.store.pop3
|
||||
|
||||
internal object Pop3Commands {
|
||||
const val STLS_COMMAND = "STLS"
|
||||
const val USER_COMMAND = "USER"
|
||||
const val PASS_COMMAND = "PASS"
|
||||
const val CAPA_COMMAND = "CAPA"
|
||||
const val AUTH_COMMAND = "AUTH"
|
||||
const val STAT_COMMAND = "STAT"
|
||||
const val LIST_COMMAND = "LIST"
|
||||
const val UIDL_COMMAND = "UIDL"
|
||||
const val TOP_COMMAND = "TOP"
|
||||
const val RETR_COMMAND = "RETR"
|
||||
const val DELE_COMMAND = "DELE"
|
||||
const val QUIT_COMMAND = "QUIT"
|
||||
|
||||
const val STLS_CAPABILITY = "STLS"
|
||||
const val UIDL_CAPABILITY = "UIDL"
|
||||
const val TOP_CAPABILITY = "TOP"
|
||||
const val SASL_CAPABILITY = "SASL"
|
||||
const val AUTH_PLAIN_CAPABILITY = "PLAIN"
|
||||
const val AUTH_CRAM_MD5_CAPABILITY = "CRAM-MD5"
|
||||
const val AUTH_EXTERNAL_CAPABILITY = "EXTERNAL"
|
||||
}
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.UnknownHostException;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
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 net.thunderbird.core.common.exception.MessagingException;
|
||||
import com.fsck.k9.mail.MissingCapabilityException;
|
||||
import com.fsck.k9.mail.filter.Base64;
|
||||
import com.fsck.k9.mail.filter.Hex;
|
||||
import com.fsck.k9.mail.ssl.CertificateChainExtractor;
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_POP3;
|
||||
import static com.fsck.k9.mail.NetworkTimeouts.SOCKET_CONNECT_TIMEOUT;
|
||||
import static com.fsck.k9.mail.NetworkTimeouts.SOCKET_READ_TIMEOUT;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.AUTH_CRAM_MD5_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.AUTH_EXTERNAL_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.AUTH_PLAIN_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.CAPA_COMMAND;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.PASS_COMMAND;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.SASL_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.STLS_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.STLS_COMMAND;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.TOP_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.UIDL_CAPABILITY;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.USER_COMMAND;
|
||||
|
||||
|
||||
class Pop3Connection {
|
||||
|
||||
private final Pop3Settings settings;
|
||||
private final TrustedSocketFactory trustedSocketFactory;
|
||||
private Socket socket;
|
||||
private BufferedInputStream in;
|
||||
private BufferedOutputStream out;
|
||||
private Pop3Capabilities capabilities;
|
||||
|
||||
/**
|
||||
* This value is {@code true} if the server supports the CAPA command but doesn't advertise
|
||||
* support for the TOP command OR if the server doesn't support the CAPA command and we
|
||||
* already unsuccessfully tried to use the TOP command.
|
||||
*/
|
||||
private boolean topNotAdvertised;
|
||||
|
||||
Pop3Connection(Pop3Settings settings,
|
||||
TrustedSocketFactory trustedSocketFactory) {
|
||||
this.settings = settings;
|
||||
this.trustedSocketFactory = trustedSocketFactory;
|
||||
}
|
||||
|
||||
void open() throws MessagingException {
|
||||
try {
|
||||
socket = connect();
|
||||
in = new BufferedInputStream(socket.getInputStream(), 1024);
|
||||
out = new BufferedOutputStream(socket.getOutputStream(), 512);
|
||||
|
||||
socket.setSoTimeout(SOCKET_READ_TIMEOUT);
|
||||
|
||||
if (!isOpen()) {
|
||||
throw new MessagingException("Unable to connect socket");
|
||||
}
|
||||
|
||||
String serverGreeting = executeSimpleCommand(null);
|
||||
|
||||
capabilities = getCapabilities();
|
||||
|
||||
if (settings.getConnectionSecurity() == ConnectionSecurity.STARTTLS_REQUIRED) {
|
||||
performStartTlsUpgrade(trustedSocketFactory, settings.getHost(), settings.getPort(), settings.getClientCertificateAlias());
|
||||
}
|
||||
|
||||
performAuthentication(settings.getAuthType(), serverGreeting);
|
||||
} catch (SSLException e) {
|
||||
List<X509Certificate> certificateChain = CertificateChainExtractor.extract(e);
|
||||
if (certificateChain != null) {
|
||||
throw new CertificateValidationException(certificateChain, e);
|
||||
} else {
|
||||
throw new MessagingException("Unable to connect", e);
|
||||
}
|
||||
} catch (GeneralSecurityException gse) {
|
||||
throw new MessagingException(
|
||||
"Unable to open connection to POP server due to security error.", gse);
|
||||
} catch (IOException ioe) {
|
||||
close();
|
||||
throw new MessagingException("Unable to open connection to POP server.", ioe);
|
||||
}
|
||||
}
|
||||
|
||||
private Socket connect()
|
||||
throws IOException, MessagingException, NoSuchAlgorithmException, KeyManagementException {
|
||||
InetAddress[] inetAddresses = InetAddress.getAllByName(settings.getHost());
|
||||
|
||||
IOException connectException = null;
|
||||
for (InetAddress address : inetAddresses) {
|
||||
try {
|
||||
return connectToAddress(address);
|
||||
} catch (IOException e) {
|
||||
Log.w(e, "Could not connect to %s", address);
|
||||
connectException = e;
|
||||
}
|
||||
}
|
||||
|
||||
throw connectException != null ? connectException : new UnknownHostException();
|
||||
}
|
||||
|
||||
private Socket connectToAddress(InetAddress address)
|
||||
throws IOException, MessagingException, NoSuchAlgorithmException, KeyManagementException {
|
||||
if (K9MailLib.isDebug() && K9MailLib.DEBUG_PROTOCOL_POP3) {
|
||||
Log.d("Connecting to %s as %s", settings.getHost(), address);
|
||||
}
|
||||
|
||||
InetSocketAddress socketAddress = new InetSocketAddress(address, settings.getPort());
|
||||
|
||||
final Socket socket;
|
||||
if (settings.getConnectionSecurity() == ConnectionSecurity.SSL_TLS_REQUIRED) {
|
||||
socket = trustedSocketFactory.createSocket(null, settings.getHost(), settings.getPort(),
|
||||
settings.getClientCertificateAlias());
|
||||
} else {
|
||||
socket = new Socket();
|
||||
}
|
||||
|
||||
socket.connect(socketAddress, SOCKET_CONNECT_TIMEOUT);
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
/*
|
||||
* If STARTTLS is not available throws a CertificateValidationException which in K-9
|
||||
* 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.
|
||||
*/
|
||||
private void performStartTlsUpgrade(TrustedSocketFactory trustedSocketFactory,
|
||||
String host, int port, String clientCertificateAlias)
|
||||
throws MessagingException, NoSuchAlgorithmException, KeyManagementException, IOException {
|
||||
if (capabilities.stls) {
|
||||
executeSimpleCommand(STLS_COMMAND);
|
||||
|
||||
socket = trustedSocketFactory.createSocket(
|
||||
socket,
|
||||
host,
|
||||
port,
|
||||
clientCertificateAlias);
|
||||
socket.setSoTimeout(SOCKET_READ_TIMEOUT);
|
||||
in = new BufferedInputStream(socket.getInputStream(), 1024);
|
||||
out = new BufferedOutputStream(socket.getOutputStream(), 512);
|
||||
if (!isOpen()) {
|
||||
throw new MessagingException("Unable to connect socket");
|
||||
}
|
||||
capabilities = getCapabilities();
|
||||
} else {
|
||||
throw new MissingCapabilityException(STLS_CAPABILITY);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void performAuthentication(AuthType authType, String serverGreeting)
|
||||
throws MessagingException, IOException {
|
||||
switch (authType) {
|
||||
case PLAIN:
|
||||
if (capabilities.authPlain) {
|
||||
authPlain();
|
||||
} else {
|
||||
login();
|
||||
}
|
||||
break;
|
||||
|
||||
case CRAM_MD5:
|
||||
if (capabilities.cramMD5) {
|
||||
authCramMD5();
|
||||
} else {
|
||||
authAPOP(serverGreeting);
|
||||
}
|
||||
break;
|
||||
|
||||
case EXTERNAL:
|
||||
if (capabilities.external) {
|
||||
authExternal();
|
||||
} else {
|
||||
throw new MissingCapabilityException(SASL_CAPABILITY + " " + AUTH_EXTERNAL_CAPABILITY);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new MessagingException(
|
||||
"Unhandled authentication method: "+authType+" found in the server settings (bug).");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
boolean isOpen() {
|
||||
return (in != null && out != null && socket != null
|
||||
&& socket.isConnected() && !socket.isClosed());
|
||||
}
|
||||
|
||||
private Pop3Capabilities getCapabilities() throws IOException {
|
||||
Pop3Capabilities capabilities = new Pop3Capabilities();
|
||||
try {
|
||||
executeSimpleCommand(CAPA_COMMAND);
|
||||
String response;
|
||||
while ((response = readLine()) != null) {
|
||||
if (response.equals(".")) {
|
||||
break;
|
||||
}
|
||||
response = response.toUpperCase(Locale.US);
|
||||
if (response.equals(STLS_CAPABILITY)) {
|
||||
capabilities.stls = true;
|
||||
} else if (response.equals(UIDL_CAPABILITY)) {
|
||||
capabilities.uidl = true;
|
||||
} else if (response.equals(TOP_CAPABILITY)) {
|
||||
capabilities.top = true;
|
||||
} else if (response.startsWith(SASL_CAPABILITY)) {
|
||||
List<String> saslAuthMechanisms = Arrays.asList(response.split(" "));
|
||||
if (saslAuthMechanisms.contains(AUTH_PLAIN_CAPABILITY)) {
|
||||
capabilities.authPlain = true;
|
||||
}
|
||||
if (saslAuthMechanisms.contains(AUTH_CRAM_MD5_CAPABILITY)) {
|
||||
capabilities.cramMD5 = true;
|
||||
}
|
||||
if (saslAuthMechanisms.contains(AUTH_EXTERNAL_CAPABILITY)) {
|
||||
capabilities.external = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!capabilities.top) {
|
||||
/*
|
||||
* If the CAPA command is supported but it doesn't advertise support for the
|
||||
* TOP command, we won't check for it manually.
|
||||
*/
|
||||
topNotAdvertised = true;
|
||||
}
|
||||
} catch (MessagingException me) {
|
||||
/*
|
||||
* The server may not support the CAPA command, so we just eat this Exception
|
||||
* and allow the empty capabilities object to be returned.
|
||||
*/
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
private void login() throws MessagingException, IOException {
|
||||
executeSimpleCommand(USER_COMMAND + " " + settings.getUsername());
|
||||
try {
|
||||
executeSimpleCommand(PASS_COMMAND + " " + settings.getPassword(), true);
|
||||
} catch (Pop3ErrorResponse e) {
|
||||
throw new AuthenticationFailedException("USER/PASS failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void authPlain() throws MessagingException, IOException {
|
||||
executeSimpleCommand("AUTH PLAIN");
|
||||
try {
|
||||
byte[] encodedBytes = Base64.encodeBase64(("\000" + settings.getUsername()
|
||||
+ "\000" + settings.getPassword()).getBytes());
|
||||
executeSimpleCommand(new String(encodedBytes), true);
|
||||
} catch (Pop3ErrorResponse e) {
|
||||
throw new AuthenticationFailedException("AUTH PLAIN failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void authAPOP(String serverGreeting) throws MessagingException, IOException {
|
||||
// regex based on RFC 2449 (3.) "Greeting"
|
||||
String timestamp = serverGreeting.replaceFirst(
|
||||
"^\\+OK *(?:\\[[^\\]]+\\])?[^<]*(<[^>]*>)?[^<]*$", "$1");
|
||||
if ("".equals(timestamp)) {
|
||||
throw new MessagingException(
|
||||
"APOP authentication is not supported");
|
||||
}
|
||||
MessageDigest md;
|
||||
try {
|
||||
md = MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new MessagingException(
|
||||
"MD5 failure during POP3 auth APOP", e);
|
||||
}
|
||||
byte[] digest = md.digest((timestamp + settings.getPassword()).getBytes());
|
||||
String hexDigest = Hex.encodeHex(digest);
|
||||
try {
|
||||
executeSimpleCommand("APOP " + settings.getUsername() + " " + hexDigest, true);
|
||||
} catch (Pop3ErrorResponse e) {
|
||||
throw new AuthenticationFailedException("APOP failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void authCramMD5() throws MessagingException, IOException {
|
||||
String b64Nonce = executeSimpleCommand("AUTH CRAM-MD5").replace("+ ", "");
|
||||
|
||||
String b64CRAM = Authentication.computeCramMd5(settings.getUsername(), settings.getPassword(), b64Nonce);
|
||||
try {
|
||||
executeSimpleCommand(b64CRAM, true);
|
||||
} catch (Pop3ErrorResponse e) {
|
||||
throw new AuthenticationFailedException("AUTH CRAM-MD5 failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void authExternal() throws MessagingException, IOException {
|
||||
try {
|
||||
executeSimpleCommand(
|
||||
String.format("AUTH EXTERNAL %s",
|
||||
Base64.encode(settings.getUsername())), false);
|
||||
} catch (Pop3ErrorResponse e) {
|
||||
throw new AuthenticationFailedException("AUTH EXTERNAL failed", e, e.getResponseText());
|
||||
}
|
||||
}
|
||||
|
||||
private void writeLine(String s) throws IOException {
|
||||
out.write(s.getBytes());
|
||||
out.write('\r');
|
||||
out.write('\n');
|
||||
out.flush();
|
||||
}
|
||||
|
||||
String executeSimpleCommand(String command) throws IOException, Pop3ErrorResponse {
|
||||
return executeSimpleCommand(command, false);
|
||||
}
|
||||
|
||||
private String executeSimpleCommand(String command, boolean sensitive) throws IOException, Pop3ErrorResponse {
|
||||
if (command != null) {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
if (sensitive && !K9MailLib.isDebugSensitive()) {
|
||||
Log.d(">>> [Command Hidden, Enable Sensitive Debug Logging To Show]");
|
||||
} else {
|
||||
Log.d(">>> %s", command);
|
||||
}
|
||||
}
|
||||
|
||||
writeLine(command);
|
||||
}
|
||||
|
||||
String response = readLine();
|
||||
if (response.length() == 0 || response.charAt(0) != '+') {
|
||||
throw new Pop3ErrorResponse(response);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
String readLine() throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
int d = in.read();
|
||||
if (d == -1) {
|
||||
throw new IOException("End of stream reached while trying to read line.");
|
||||
}
|
||||
do {
|
||||
if (((char)d) == '\r') {
|
||||
//noinspection UnnecessaryContinue Makes it easier to follow
|
||||
continue;
|
||||
} else if (((char)d) == '\n') {
|
||||
break;
|
||||
} else {
|
||||
sb.append((char)d);
|
||||
}
|
||||
} while ((d = in.read()) != -1);
|
||||
String ret = sb.toString();
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
Log.d("<<< %s", ret);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
void close() {
|
||||
try {
|
||||
in.close();
|
||||
} catch (Exception e) {
|
||||
/*
|
||||
* May fail if the connection is already closed.
|
||||
*/
|
||||
}
|
||||
try {
|
||||
out.close();
|
||||
} catch (Exception e) {
|
||||
/*
|
||||
* May fail if the connection is already closed.
|
||||
*/
|
||||
}
|
||||
try {
|
||||
socket.close();
|
||||
} catch (Exception e) {
|
||||
/*
|
||||
* May fail if the connection is already closed.
|
||||
*/
|
||||
}
|
||||
in = null;
|
||||
out = null;
|
||||
socket = null;
|
||||
}
|
||||
|
||||
boolean supportsTop() {
|
||||
return capabilities.top;
|
||||
}
|
||||
|
||||
boolean isTopNotAdvertised() {
|
||||
return topNotAdvertised;
|
||||
}
|
||||
|
||||
void setSupportsTop(boolean supportsTop) {
|
||||
this.capabilities.top = supportsTop;
|
||||
}
|
||||
|
||||
void setTopNotAdvertised(boolean topNotAdvertised) {
|
||||
this.topNotAdvertised = topNotAdvertised;
|
||||
}
|
||||
|
||||
boolean supportsUidl() {
|
||||
return this.capabilities.uidl;
|
||||
}
|
||||
|
||||
InputStream getInputStream() {
|
||||
return in;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
|
||||
|
||||
/**
|
||||
* Exception that is thrown if the server returns an error response.
|
||||
*/
|
||||
class Pop3ErrorResponse extends MessagingException {
|
||||
private static final long serialVersionUID = 3672087845857867174L;
|
||||
|
||||
public Pop3ErrorResponse(String message) {
|
||||
super(message, true);
|
||||
}
|
||||
|
||||
public String getResponseText() {
|
||||
// TODO: Extract response text from response line
|
||||
return getMessage();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,551 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
import com.fsck.k9.mail.FetchProfile;
|
||||
import com.fsck.k9.mail.Flag;
|
||||
import com.fsck.k9.mail.K9MailLib;
|
||||
import com.fsck.k9.mail.MessageRetrievalListener;
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_POP3;
|
||||
import static com.fsck.k9.mail.store.pop3.Pop3Commands.*;
|
||||
|
||||
|
||||
/**
|
||||
* POP3 only supports one folder, "Inbox". So the folder name is the ID here.
|
||||
*/
|
||||
public class Pop3Folder {
|
||||
public static final String INBOX = "INBOX";
|
||||
|
||||
|
||||
private Pop3Store pop3Store;
|
||||
private Map<String, Pop3Message> uidToMsgMap = new HashMap<>();
|
||||
private Map<Integer, Pop3Message> msgNumToMsgMap = new HashMap<>();
|
||||
private Map<String, Integer> uidToMsgNumMap = new HashMap<>();
|
||||
private String name;
|
||||
private int messageCount;
|
||||
private Pop3Connection connection;
|
||||
|
||||
Pop3Folder(Pop3Store pop3Store, String name) {
|
||||
super();
|
||||
this.pop3Store = pop3Store;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public synchronized void open() throws MessagingException {
|
||||
if (isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!INBOX.equals(name)) {
|
||||
throw new MessagingException("Folder does not exist");
|
||||
}
|
||||
|
||||
connection = pop3Store.createConnection();
|
||||
try {
|
||||
connection.open();
|
||||
|
||||
String response = connection.executeSimpleCommand(STAT_COMMAND);
|
||||
String[] parts = response.split(" ");
|
||||
messageCount = Integer.parseInt(parts[1]);
|
||||
|
||||
uidToMsgMap.clear();
|
||||
msgNumToMsgMap.clear();
|
||||
uidToMsgNumMap.clear();
|
||||
} catch (IOException e) {
|
||||
handleIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isOpen() {
|
||||
return connection != null && connection.isOpen();
|
||||
}
|
||||
|
||||
public void close() {
|
||||
try {
|
||||
if (isOpen()) {
|
||||
connection.executeSimpleCommand(QUIT_COMMAND);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
/*
|
||||
* QUIT may fail if the connection is already closed. We don't care. It's just
|
||||
* being friendly.
|
||||
*/
|
||||
}
|
||||
|
||||
if (connection != null) {
|
||||
connection.close();
|
||||
connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
public String getServerId() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public int getMessageCount() {
|
||||
return messageCount;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public Pop3Message getMessage(String uid) {
|
||||
Pop3Message message = uidToMsgMap.get(uid);
|
||||
if (message == null) {
|
||||
message = new Pop3Message(uid);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
public List<Pop3Message> getMessages(int start, int end, MessageRetrievalListener<Pop3Message> listener)
|
||||
throws MessagingException {
|
||||
if (start < 1 || end < 1 || end < start) {
|
||||
throw new MessagingException(String.format(Locale.US, "Invalid message set %d %d",
|
||||
start, end));
|
||||
}
|
||||
try {
|
||||
indexMsgNums(start, end);
|
||||
} catch (IOException ioe) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
List<Pop3Message> messages = new ArrayList<>();
|
||||
for (int msgNum = start; msgNum <= end; msgNum++) {
|
||||
Pop3Message message = msgNumToMsgMap.get(msgNum);
|
||||
if (message == null) {
|
||||
/*
|
||||
* There could be gaps in the message numbers or malformed
|
||||
* responses which lead to "gaps" in msgNumToMsgMap.
|
||||
*
|
||||
* See issue 2252
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.add(message);
|
||||
if (listener != null) {
|
||||
listener.messageFinished(message);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
public boolean areMoreMessagesAvailable(int indexOfOldestMessage) {
|
||||
return indexOfOldestMessage > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the given message set (from start to end inclusive)
|
||||
* has been queried so that uids are available in the local cache.
|
||||
*/
|
||||
private void indexMsgNums(int start, int end) throws MessagingException, IOException {
|
||||
int unindexedMessageCount = 0;
|
||||
for (int msgNum = start; msgNum <= end; msgNum++) {
|
||||
if (msgNumToMsgMap.get(msgNum) == null) {
|
||||
unindexedMessageCount++;
|
||||
}
|
||||
}
|
||||
if (unindexedMessageCount == 0) {
|
||||
return;
|
||||
}
|
||||
if (unindexedMessageCount < 50 && messageCount > 5000) {
|
||||
/*
|
||||
* In extreme cases we'll do a UIDL command per message instead of a bulk
|
||||
* download.
|
||||
*/
|
||||
for (int msgNum = start; msgNum <= end; msgNum++) {
|
||||
Pop3Message message = msgNumToMsgMap.get(msgNum);
|
||||
if (message == null) {
|
||||
String response = connection.executeSimpleCommand(UIDL_COMMAND + " " + msgNum);
|
||||
// response = "+OK msgNum msgUid"
|
||||
String[] uidParts = response.split(" +");
|
||||
if (uidParts.length < 3 || !"+OK".equals(uidParts[0])) {
|
||||
Log.e("ERR response: %s", response);
|
||||
return;
|
||||
}
|
||||
String msgUid = uidParts[2];
|
||||
message = new Pop3Message(msgUid);
|
||||
indexMessage(msgNum, message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
connection.executeSimpleCommand(UIDL_COMMAND);
|
||||
String response;
|
||||
while ((response = connection.readLine()) != null) {
|
||||
if (response.equals(".")) {
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
* Yet another work-around for buggy server software:
|
||||
* split the response into message number and unique identifier, no matter how many spaces it has
|
||||
*
|
||||
* Example for a malformed response:
|
||||
* 1 2011071307115510400ae3e9e00bmu9
|
||||
*
|
||||
* Note the three spaces between message number and unique identifier.
|
||||
* See issue 3546
|
||||
*/
|
||||
|
||||
String[] uidParts = response.split(" +");
|
||||
if ((uidParts.length >= 3) && "+OK".equals(uidParts[0])) {
|
||||
/*
|
||||
* At least one server software places a "+OK" in
|
||||
* front of every line in the unique-id listing.
|
||||
*
|
||||
* Fix up the array if we detected this behavior.
|
||||
* See Issue 1237
|
||||
*/
|
||||
uidParts[0] = uidParts[1];
|
||||
uidParts[1] = uidParts[2];
|
||||
}
|
||||
if (uidParts.length >= 2) {
|
||||
Integer msgNum = Integer.valueOf(uidParts[0]);
|
||||
String msgUid = uidParts[1];
|
||||
if (msgNum >= start && msgNum <= end) {
|
||||
Pop3Message message = msgNumToMsgMap.get(msgNum);
|
||||
if (message == null) {
|
||||
message = new Pop3Message(msgUid);
|
||||
indexMessage(msgNum, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void indexUids(List<String> uids)
|
||||
throws MessagingException, IOException {
|
||||
Set<String> unindexedUids = new HashSet<>();
|
||||
for (String uid : uids) {
|
||||
if (uidToMsgMap.get(uid) == null) {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
Log.d("Need to index UID %s", uid);
|
||||
}
|
||||
unindexedUids.add(uid);
|
||||
}
|
||||
}
|
||||
if (unindexedUids.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* If we are missing uids in the cache the only sure way to
|
||||
* get them is to do a full UIDL list. A possible optimization
|
||||
* would be trying UIDL for the latest X messages and praying.
|
||||
*/
|
||||
connection.executeSimpleCommand(UIDL_COMMAND);
|
||||
String response;
|
||||
while ((response = connection.readLine()) != null) {
|
||||
if (response.equals(".")) {
|
||||
break;
|
||||
}
|
||||
String[] uidParts = response.split(" +");
|
||||
|
||||
// Ignore messages without a unique-id
|
||||
if (uidParts.length >= 2) {
|
||||
Integer msgNum = Integer.valueOf(uidParts[0]);
|
||||
String msgUid = uidParts[1];
|
||||
if (unindexedUids.contains(msgUid)) {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
Log.d("Got msgNum %d for UID %s", msgNum, msgUid);
|
||||
}
|
||||
|
||||
Pop3Message message = uidToMsgMap.get(msgUid);
|
||||
if (message == null) {
|
||||
message = new Pop3Message(msgUid);
|
||||
}
|
||||
indexMessage(msgNum, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void indexMessage(int msgNum, Pop3Message message) {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
Log.d("Adding index for UID %s to msgNum %d", message.getUid(), msgNum);
|
||||
}
|
||||
msgNumToMsgMap.put(msgNum, message);
|
||||
uidToMsgMap.put(message.getUid(), message);
|
||||
uidToMsgNumMap.put(message.getUid(), msgNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the items contained in the FetchProfile into the given set of
|
||||
* Messages in as efficient a manner as possible.
|
||||
* @param messages Messages to populate
|
||||
* @param fp The contents to populate
|
||||
*/
|
||||
public void fetch(List<Pop3Message> messages, FetchProfile fp,
|
||||
MessageRetrievalListener<Pop3Message> listener, int maxDownloadSize)
|
||||
throws MessagingException {
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<String> uids = new ArrayList<>();
|
||||
for (Pop3Message message : messages) {
|
||||
uids.add(message.getUid());
|
||||
}
|
||||
try {
|
||||
indexUids(uids);
|
||||
} catch (IOException ioe) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
try {
|
||||
if (fp.contains(FetchProfile.Item.ENVELOPE)) {
|
||||
/*
|
||||
* We pass the listener only if there are other things to do in the
|
||||
* FetchProfile. Since fetchEnvelop works in bulk and eveything else
|
||||
* works one at a time if we let fetchEnvelope send events the
|
||||
* event would get sent twice.
|
||||
*/
|
||||
fetchEnvelope(messages, fp.size() == 1 ? listener : null);
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
for (Pop3Message pop3Message : messages) {
|
||||
try {
|
||||
if (fp.contains(FetchProfile.Item.BODY)) {
|
||||
fetchBody(pop3Message, -1);
|
||||
} else if (fp.contains(FetchProfile.Item.BODY_SANE)) {
|
||||
/*
|
||||
* To convert the suggested download size we take the size
|
||||
* divided by the maximum line size (76).
|
||||
*/
|
||||
if (maxDownloadSize > 0) {
|
||||
fetchBody(pop3Message, maxDownloadSize / 76);
|
||||
} else {
|
||||
fetchBody(pop3Message, -1);
|
||||
}
|
||||
} else if (fp.contains(FetchProfile.Item.STRUCTURE)) {
|
||||
/*
|
||||
* If the user is requesting STRUCTURE we are required to set the body
|
||||
* to null since we do not support the function.
|
||||
*/
|
||||
pop3Message.setBody(null);
|
||||
}
|
||||
if (listener != null && !(fp.contains(FetchProfile.Item.ENVELOPE) && fp.size() == 1)) {
|
||||
listener.messageFinished(pop3Message);
|
||||
}
|
||||
} catch (IOException ioe) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void fetchEnvelope(List<Pop3Message> messages,
|
||||
MessageRetrievalListener<Pop3Message> listener) throws IOException, MessagingException {
|
||||
int unsizedMessages = 0;
|
||||
for (Pop3Message message : messages) {
|
||||
if (message.getSize() == -1) {
|
||||
unsizedMessages++;
|
||||
}
|
||||
}
|
||||
if (unsizedMessages == 0) {
|
||||
return;
|
||||
}
|
||||
if (unsizedMessages < 50 && messageCount > 5000) {
|
||||
/*
|
||||
* In extreme cases we'll do a command per message instead of a bulk request
|
||||
* to hopefully save some time and bandwidth.
|
||||
*/
|
||||
for (Pop3Message message : messages) {
|
||||
String response = connection.executeSimpleCommand(
|
||||
String.format(Locale.US, LIST_COMMAND + " %d",
|
||||
uidToMsgNumMap.get(message.getUid())));
|
||||
String[] listParts = response.split(" ");
|
||||
//int msgNum = Integer.parseInt(listParts[1]);
|
||||
int msgSize = Integer.parseInt(listParts[2]);
|
||||
message.setSize(msgSize);
|
||||
if (listener != null) {
|
||||
listener.messageFinished(message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Set<String> msgUidIndex = new HashSet<>();
|
||||
for (Pop3Message message : messages) {
|
||||
msgUidIndex.add(message.getUid());
|
||||
}
|
||||
int i = 0, count = messages.size();
|
||||
connection.executeSimpleCommand(LIST_COMMAND);
|
||||
String response;
|
||||
while ((response = connection.readLine()) != null) {
|
||||
if (response.equals(".")) {
|
||||
break;
|
||||
}
|
||||
String[] listParts = response.split(" ");
|
||||
int msgNum = Integer.parseInt(listParts[0]);
|
||||
int msgSize = Integer.parseInt(listParts[1]);
|
||||
Pop3Message pop3Message = msgNumToMsgMap.get(msgNum);
|
||||
if (pop3Message != null && msgUidIndex.contains(pop3Message.getUid())) {
|
||||
pop3Message.setSize(msgSize);
|
||||
if (listener != null) {
|
||||
listener.messageFinished(pop3Message);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the body of the given message, limiting the downloaded data to the specified
|
||||
* number of lines if possible.
|
||||
*
|
||||
* If lines is -1 the entire message is fetched. This is implemented with RETR for
|
||||
* lines = -1 or TOP for any other value. If the server does not support TOP, RETR is used
|
||||
* instead.
|
||||
*/
|
||||
private void fetchBody(Pop3Message message, int lines)
|
||||
throws IOException, MessagingException {
|
||||
String response = null;
|
||||
|
||||
// Try hard to use the TOP command if we're not asked to download the whole message.
|
||||
if (lines != -1 && (!connection.isTopNotAdvertised() || connection.supportsTop())) {
|
||||
try {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3 && !connection.supportsTop()) {
|
||||
Log.d("This server doesn't support the CAPA command. " +
|
||||
"Checking to see if the TOP command is supported nevertheless.");
|
||||
}
|
||||
|
||||
response = connection.executeSimpleCommand(
|
||||
String.format(Locale.US, TOP_COMMAND + " %d %d",
|
||||
uidToMsgNumMap.get(message.getUid()), lines));
|
||||
// TOP command is supported. Remember this for the next time.
|
||||
connection.setSupportsTop(true);
|
||||
} catch (Pop3ErrorResponse e) {
|
||||
if (connection.supportsTop()) {
|
||||
// The TOP command should be supported but something went wrong.
|
||||
throw e;
|
||||
} else {
|
||||
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_POP3) {
|
||||
Log.d("The server really doesn't support the TOP " +
|
||||
"command. Using RETR instead.");
|
||||
}
|
||||
|
||||
// Don't try to use the TOP command again.
|
||||
connection.setTopNotAdvertised(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
connection.executeSimpleCommand(String.format(Locale.US, RETR_COMMAND + " %d",
|
||||
uidToMsgNumMap.get(message.getUid())));
|
||||
}
|
||||
|
||||
try {
|
||||
message.parse(new Pop3ResponseInputStream(connection.getInputStream()));
|
||||
|
||||
// TODO: if we've received fewer lines than requested we also have the complete message.
|
||||
if (lines == -1 || !connection.supportsTop()) {
|
||||
message.setFlag(Flag.X_DOWNLOADED_FULL, true);
|
||||
}
|
||||
} catch (MessagingException me) {
|
||||
/*
|
||||
* If we're only downloading headers it's possible
|
||||
* we'll get a broken MIME message which we're not
|
||||
* real worried about. If we've downloaded the body
|
||||
* and can't parse it we need to let the user know.
|
||||
*/
|
||||
if (lines == -1) {
|
||||
throw me;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setFlags(List<Pop3Message> messages, final Set<Flag> flags, boolean value)
|
||||
throws MessagingException {
|
||||
if (!value || !flags.contains(Flag.DELETED)) {
|
||||
/*
|
||||
* The only flagging we support is setting the Deleted flag.
|
||||
*/
|
||||
return;
|
||||
}
|
||||
List<String> uids = new ArrayList<>();
|
||||
try {
|
||||
for (Pop3Message message : messages) {
|
||||
uids.add(message.getUid());
|
||||
}
|
||||
|
||||
indexUids(uids);
|
||||
} catch (IOException ioe) {
|
||||
handleIOException(ioe);
|
||||
}
|
||||
for (Pop3Message message : messages) {
|
||||
|
||||
Integer msgNum = uidToMsgNumMap.get(message.getUid());
|
||||
if (msgNum == null) {
|
||||
throw new MessagingException(
|
||||
"Could not delete message " + message.getUid() + " because no msgNum found; permanent error",
|
||||
true
|
||||
);
|
||||
}
|
||||
open();
|
||||
try {
|
||||
connection.executeSimpleCommand(String.format(DELE_COMMAND + " %s", msgNum));
|
||||
} catch (IOException e) {
|
||||
handleIOException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isFlagSupported(Flag flag) {
|
||||
return (flag == Flag.DELETED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (o instanceof Pop3Folder) {
|
||||
return ((Pop3Folder) o).name.equals(name);
|
||||
}
|
||||
return super.equals(o);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return name.hashCode();
|
||||
}
|
||||
|
||||
void requestUidl() throws MessagingException {
|
||||
try {
|
||||
if (!connection.supportsUidl()) {
|
||||
/*
|
||||
* Run an additional test to see if UIDL is supported on the server. If it's not we
|
||||
* can't service this account.
|
||||
*/
|
||||
|
||||
/*
|
||||
* If the server doesn't support UIDL it will return a - response, which causes
|
||||
* executeSimpleCommand to throw a MessagingException, exiting this method.
|
||||
*/
|
||||
connection.executeSimpleCommand(UIDL_COMMAND);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
handleIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleIOException(IOException exception) throws MessagingException {
|
||||
Pop3Connection connection = this.connection;
|
||||
if (connection != null) {
|
||||
connection.close();
|
||||
}
|
||||
|
||||
// For now we wrap IOExceptions in a MessagingException
|
||||
throw new MessagingException("I/O error", exception);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import com.fsck.k9.mail.internet.MimeMessage;
|
||||
|
||||
|
||||
public class Pop3Message extends MimeMessage {
|
||||
Pop3Message(String uid) {
|
||||
mUid = uid;
|
||||
mSize = -1;
|
||||
}
|
||||
|
||||
public void setSize(int size) {
|
||||
mSize = size;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
|
||||
class Pop3ResponseInputStream extends InputStream {
|
||||
private InputStream mIn;
|
||||
private boolean mStartOfLine = true;
|
||||
private boolean mFinished;
|
||||
|
||||
Pop3ResponseInputStream(InputStream in) {
|
||||
mIn = in;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
if (mFinished) {
|
||||
return -1;
|
||||
}
|
||||
int d = mIn.read();
|
||||
if (mStartOfLine && d == '.') {
|
||||
d = mIn.read();
|
||||
if (d == '\r') {
|
||||
mFinished = true;
|
||||
mIn.read();
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
mStartOfLine = (d == '\n');
|
||||
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package com.fsck.k9.mail.store.pop3
|
||||
|
||||
import com.fsck.k9.mail.AuthenticationFailedException
|
||||
import com.fsck.k9.mail.CertificateValidationException
|
||||
import com.fsck.k9.mail.ClientCertificateError.CertificateExpired
|
||||
import com.fsck.k9.mail.ClientCertificateError.RetrievalFailure
|
||||
import com.fsck.k9.mail.ClientCertificateException
|
||||
import com.fsck.k9.mail.MissingCapabilityException
|
||||
import com.fsck.k9.mail.ServerSettings
|
||||
import com.fsck.k9.mail.oauth.AuthStateStorage
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidationResult
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidationResult.ClientCertificateError
|
||||
import com.fsck.k9.mail.server.ServerSettingsValidator
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory
|
||||
import java.io.IOException
|
||||
import net.thunderbird.core.common.exception.MessagingException
|
||||
|
||||
class Pop3ServerSettingsValidator(
|
||||
private val trustedSocketFactory: TrustedSocketFactory,
|
||||
) : ServerSettingsValidator {
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override fun checkServerSettings(
|
||||
serverSettings: ServerSettings,
|
||||
authStateStorage: AuthStateStorage?,
|
||||
): ServerSettingsValidationResult {
|
||||
val store = Pop3Store(serverSettings, trustedSocketFactory)
|
||||
|
||||
return try {
|
||||
store.checkSettings()
|
||||
|
||||
ServerSettingsValidationResult.Success
|
||||
} catch (e: AuthenticationFailedException) {
|
||||
ServerSettingsValidationResult.AuthenticationError(e.messageFromServer)
|
||||
} catch (e: CertificateValidationException) {
|
||||
ServerSettingsValidationResult.CertificateError(e.certificateChain)
|
||||
} catch (e: Pop3ErrorResponse) {
|
||||
ServerSettingsValidationResult.ServerError(e.responseText)
|
||||
} catch (e: MissingCapabilityException) {
|
||||
ServerSettingsValidationResult.MissingServerCapabilityError(e.capabilityName)
|
||||
} catch (e: ClientCertificateException) {
|
||||
when (e.error) {
|
||||
RetrievalFailure -> ClientCertificateError.ClientCertificateRetrievalFailure
|
||||
CertificateExpired -> ClientCertificateError.ClientCertificateExpired
|
||||
}
|
||||
} catch (e: MessagingException) {
|
||||
val cause = e.cause
|
||||
if (cause is IOException) {
|
||||
ServerSettingsValidationResult.NetworkError(cause)
|
||||
} else {
|
||||
ServerSettingsValidationResult.UnknownError(e)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
ServerSettingsValidationResult.NetworkError(e)
|
||||
} catch (e: Exception) {
|
||||
ServerSettingsValidationResult.UnknownError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
|
||||
|
||||
interface Pop3Settings {
|
||||
String getHost();
|
||||
|
||||
int getPort();
|
||||
|
||||
ConnectionSecurity getConnectionSecurity();
|
||||
|
||||
AuthType getAuthType();
|
||||
|
||||
String getUsername();
|
||||
|
||||
String getPassword();
|
||||
|
||||
String getClientCertificateAlias();
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import net.thunderbird.core.logging.legacy.Log;
|
||||
import com.fsck.k9.mail.AuthType;
|
||||
import com.fsck.k9.mail.ConnectionSecurity;
|
||||
import net.thunderbird.core.common.exception.MessagingException;
|
||||
import com.fsck.k9.mail.ServerSettings;
|
||||
import com.fsck.k9.mail.ssl.TrustedSocketFactory;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
|
||||
public class Pop3Store {
|
||||
private final TrustedSocketFactory trustedSocketFactory;
|
||||
private final String host;
|
||||
private final int port;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final String clientCertificateAlias;
|
||||
private final AuthType authType;
|
||||
private final ConnectionSecurity connectionSecurity;
|
||||
|
||||
private Map<String, Pop3Folder> mFolders = new HashMap<>();
|
||||
|
||||
public Pop3Store(ServerSettings serverSettings, TrustedSocketFactory socketFactory) {
|
||||
if (!serverSettings.type.equals("pop3")) {
|
||||
throw new IllegalArgumentException("Expected POP3 ServerSettings");
|
||||
}
|
||||
|
||||
trustedSocketFactory = socketFactory;
|
||||
host = serverSettings.host;
|
||||
port = serverSettings.port;
|
||||
connectionSecurity = serverSettings.connectionSecurity;
|
||||
username = serverSettings.username;
|
||||
password = serverSettings.password;
|
||||
clientCertificateAlias = serverSettings.clientCertificateAlias;
|
||||
authType = serverSettings.authenticationType;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public Pop3Folder getFolder(String name) {
|
||||
Pop3Folder folder = mFolders.get(name);
|
||||
if (folder == null) {
|
||||
folder = new Pop3Folder(this, name);
|
||||
mFolders.put(folder.getServerId(), folder);
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
public void checkSettings() throws MessagingException {
|
||||
Pop3Folder folder = new Pop3Folder(this, Pop3Folder.INBOX);
|
||||
try {
|
||||
folder.open();
|
||||
folder.requestUidl();
|
||||
} catch (Exception e) {
|
||||
Log.e(e, "Error while checking server settings");
|
||||
throw e;
|
||||
} finally {
|
||||
folder.close();
|
||||
}
|
||||
}
|
||||
|
||||
public Pop3Connection createConnection() throws MessagingException {
|
||||
return new Pop3Connection(new StorePop3Settings(), trustedSocketFactory);
|
||||
}
|
||||
|
||||
private class StorePop3Settings implements Pop3Settings {
|
||||
@Override
|
||||
public String getHost() {
|
||||
return host;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConnectionSecurity getConnectionSecurity() {
|
||||
return connectionSecurity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthType getAuthType() {
|
||||
return authType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getClientCertificateAlias() {
|
||||
return clientCertificateAlias;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,420 @@
|
|||
package com.fsck.k9.mail.store.pop3;
|
||||
|
||||
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetAddress;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.Deque;
|
||||
import java.util.concurrent.ConcurrentLinkedDeque;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.zip.Inflater;
|
||||
import java.util.zip.InflaterInputStream;
|
||||
|
||||
import com.fsck.k9.mail.testing.security.TestKeyStoreProvider;
|
||||
import com.jcraft.jzlib.JZlib;
|
||||
import com.jcraft.jzlib.ZOutputStream;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import okio.BufferedSink;
|
||||
import okio.BufferedSource;
|
||||
import okio.Okio;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
|
||||
|
||||
public class MockPop3Server {
|
||||
private static final byte[] CRLF = { '\r', '\n' };
|
||||
|
||||
|
||||
private final Deque<ImapInteraction> interactions = new ConcurrentLinkedDeque<>();
|
||||
private final CountDownLatch waitForConnectionClosed = new CountDownLatch(1);
|
||||
private final CountDownLatch waitForAllExpectedCommands = new CountDownLatch(1);
|
||||
private final TestKeyStoreProvider keyStoreProvider;
|
||||
private final Logger logger;
|
||||
|
||||
private MockServerThread mockServerThread;
|
||||
private String host;
|
||||
private int port;
|
||||
|
||||
|
||||
public MockPop3Server() {
|
||||
this(TestKeyStoreProvider.INSTANCE, new DefaultLogger());
|
||||
}
|
||||
|
||||
public MockPop3Server(TestKeyStoreProvider keyStoreProvider, Logger logger) {
|
||||
this.keyStoreProvider = keyStoreProvider;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public void output(String response) {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new CannedResponse(response));
|
||||
}
|
||||
|
||||
public void expect(String command) {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new ExpectedCommand(command));
|
||||
}
|
||||
|
||||
public void startTls() {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new UpgradeToTls());
|
||||
}
|
||||
|
||||
public void enableCompression() {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new EnableCompression());
|
||||
}
|
||||
|
||||
public void closeConnection() {
|
||||
checkServerNotRunning();
|
||||
interactions.add(new CloseConnection());
|
||||
}
|
||||
|
||||
public void start() throws IOException {
|
||||
checkServerNotRunning();
|
||||
|
||||
InetAddress localAddress = InetAddress.getByName(null);
|
||||
ServerSocket serverSocket = new ServerSocket(0, 1, localAddress);
|
||||
InetSocketAddress localSocketAddress = (InetSocketAddress) serverSocket.getLocalSocketAddress();
|
||||
host = localSocketAddress.getHostString();
|
||||
port = serverSocket.getLocalPort();
|
||||
|
||||
mockServerThread = new MockServerThread(serverSocket, interactions, waitForConnectionClosed,
|
||||
waitForAllExpectedCommands, logger, keyStoreProvider);
|
||||
mockServerThread.start();
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
checkServerRunning();
|
||||
|
||||
mockServerThread.shouldStop();
|
||||
waitForMockServerThread();
|
||||
}
|
||||
|
||||
private void waitForMockServerThread() {
|
||||
try {
|
||||
mockServerThread.join(500L);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public String getHost() {
|
||||
checkServerRunning();
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
checkServerRunning();
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
public void waitForInteractionToComplete() {
|
||||
checkServerRunning();
|
||||
|
||||
try {
|
||||
waitForAllExpectedCommands.await(1000L, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyInteractionCompleted() {
|
||||
shutdown();
|
||||
|
||||
if (!interactions.isEmpty()) {
|
||||
throw new AssertionError("Interactions left: " + interactions.size());
|
||||
}
|
||||
|
||||
UnexpectedCommandException unexpectedCommandException = mockServerThread.getUnexpectedCommandException();
|
||||
if (unexpectedCommandException != null) {
|
||||
throw new AssertionError(unexpectedCommandException.getMessage(), unexpectedCommandException);
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyConnectionStillOpen() {
|
||||
checkServerRunning();
|
||||
|
||||
if (mockServerThread.isClientConnectionClosed()) {
|
||||
throw new AssertionError("Connection closed when it shouldn't be");
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyConnectionClosed() {
|
||||
checkServerRunning();
|
||||
|
||||
try {
|
||||
waitForConnectionClosed.await(300L, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
|
||||
if (!mockServerThread.isClientConnectionClosed()) {
|
||||
throw new AssertionError("Connection open when is shouldn't be");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkServerRunning() {
|
||||
if (mockServerThread == null) {
|
||||
throw new IllegalStateException("Server was never started");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkServerNotRunning() {
|
||||
if (mockServerThread != null) {
|
||||
throw new IllegalStateException("Server was already started");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public interface Logger {
|
||||
void log(String message);
|
||||
void log(String format, Object... args);
|
||||
}
|
||||
|
||||
private interface ImapInteraction {}
|
||||
|
||||
private static class ExpectedCommand implements ImapInteraction {
|
||||
private final String command;
|
||||
|
||||
|
||||
public ExpectedCommand(String command) {
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
public String getCommand() {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CannedResponse implements ImapInteraction {
|
||||
private final String response;
|
||||
|
||||
|
||||
public CannedResponse(String response) {
|
||||
this.response = response;
|
||||
}
|
||||
|
||||
public String getResponse() {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
private static class CloseConnection implements ImapInteraction {
|
||||
}
|
||||
|
||||
private static class EnableCompression implements ImapInteraction {
|
||||
}
|
||||
|
||||
private static class UpgradeToTls implements ImapInteraction {
|
||||
}
|
||||
|
||||
private static class UnexpectedCommandException extends Exception {
|
||||
public UnexpectedCommandException(String expectedCommand, String receivedCommand) {
|
||||
super("Expected <" + expectedCommand + ">, but received <" + receivedCommand + ">");
|
||||
}
|
||||
}
|
||||
|
||||
private static class MockServerThread extends Thread {
|
||||
private final ServerSocket serverSocket;
|
||||
private final Deque<ImapInteraction> interactions;
|
||||
private final CountDownLatch waitForConnectionClosed;
|
||||
private final CountDownLatch waitForAllExpectedCommands;
|
||||
private final Logger logger;
|
||||
private final TestKeyStoreProvider keyStoreProvider;
|
||||
|
||||
private volatile boolean shouldStop = false;
|
||||
private volatile Socket clientSocket;
|
||||
|
||||
private BufferedSource input;
|
||||
private BufferedSink output;
|
||||
private volatile UnexpectedCommandException unexpectedCommandException;
|
||||
|
||||
|
||||
public MockServerThread(ServerSocket serverSocket, Deque<ImapInteraction> interactions,
|
||||
CountDownLatch waitForConnectionClosed, CountDownLatch waitForAllExpectedCommands, Logger logger,
|
||||
TestKeyStoreProvider keyStoreProvider) {
|
||||
super("MockImapServer");
|
||||
this.serverSocket = serverSocket;
|
||||
this.interactions = interactions;
|
||||
this.waitForConnectionClosed = waitForConnectionClosed;
|
||||
this.waitForAllExpectedCommands = waitForAllExpectedCommands;
|
||||
this.logger = logger;
|
||||
this.keyStoreProvider = keyStoreProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
String hostAddress = serverSocket.getInetAddress().getHostAddress();
|
||||
int port = serverSocket.getLocalPort();
|
||||
logger.log("Listening on %s:%d", hostAddress, port);
|
||||
|
||||
Socket socket = null;
|
||||
try {
|
||||
socket = acceptConnectionAndCloseServerSocket();
|
||||
clientSocket = socket;
|
||||
|
||||
String remoteHostAddress = socket.getInetAddress().getHostAddress();
|
||||
int remotePort = socket.getPort();
|
||||
logger.log("Accepted connection from %s:%d", remoteHostAddress, remotePort);
|
||||
|
||||
input = Okio.buffer(Okio.source(socket));
|
||||
output = Okio.buffer(Okio.sink(socket));
|
||||
|
||||
while (!shouldStop && !interactions.isEmpty()) {
|
||||
handleInteractions(socket);
|
||||
}
|
||||
|
||||
waitForAllExpectedCommands.countDown();
|
||||
|
||||
while (!shouldStop) {
|
||||
readAdditionalCommands();
|
||||
}
|
||||
|
||||
waitForConnectionClosed.countDown();
|
||||
} catch (UnexpectedCommandException e) {
|
||||
unexpectedCommandException = e;
|
||||
} catch (IOException e) {
|
||||
if (!shouldStop) {
|
||||
logger.log("Exception: %s", e);
|
||||
}
|
||||
} catch (KeyStoreException | CertificateException | UnrecoverableKeyException |
|
||||
NoSuchAlgorithmException | KeyManagementException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
|
||||
IOUtils.closeQuietly(socket);
|
||||
|
||||
logger.log("Exiting");
|
||||
}
|
||||
|
||||
private void handleInteractions(Socket socket) throws IOException, KeyStoreException,
|
||||
NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException, KeyManagementException,
|
||||
UnexpectedCommandException {
|
||||
|
||||
ImapInteraction interaction = interactions.pop();
|
||||
if (interaction instanceof ExpectedCommand) {
|
||||
readExpectedCommand((ExpectedCommand) interaction);
|
||||
} else if (interaction instanceof CannedResponse) {
|
||||
writeCannedResponse((CannedResponse) interaction);
|
||||
} else if (interaction instanceof CloseConnection) {
|
||||
clientSocket.close();
|
||||
} else if (interaction instanceof EnableCompression) {
|
||||
enableCompression(socket);
|
||||
} else if (interaction instanceof UpgradeToTls) {
|
||||
upgradeToTls(socket);
|
||||
}
|
||||
}
|
||||
|
||||
private void readExpectedCommand(ExpectedCommand expectedCommand) throws IOException,
|
||||
UnexpectedCommandException {
|
||||
|
||||
String command = input.readUtf8Line();
|
||||
if (command == null) {
|
||||
throw new EOFException();
|
||||
}
|
||||
|
||||
logger.log("C: %s", command);
|
||||
|
||||
String expected = expectedCommand.getCommand();
|
||||
if (!command.equals(expected)) {
|
||||
logger.log("EXPECTED: %s", expected);
|
||||
throw new UnexpectedCommandException(expected, command);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeCannedResponse(CannedResponse cannedResponse) throws IOException {
|
||||
String response = cannedResponse.getResponse();
|
||||
logger.log("S: %s", response);
|
||||
|
||||
output.writeUtf8(response);
|
||||
output.write(CRLF);
|
||||
output.flush();
|
||||
}
|
||||
|
||||
private void enableCompression(Socket socket) throws IOException {
|
||||
InputStream inputStream = new InflaterInputStream(socket.getInputStream(), new Inflater(true));
|
||||
input = Okio.buffer(Okio.source(inputStream));
|
||||
|
||||
ZOutputStream outputStream = new ZOutputStream(socket.getOutputStream(), JZlib.Z_BEST_SPEED, true);
|
||||
outputStream.setFlushMode(JZlib.Z_PARTIAL_FLUSH);
|
||||
output = Okio.buffer(Okio.sink(outputStream));
|
||||
}
|
||||
|
||||
private void upgradeToTls(Socket socket) throws KeyStoreException, IOException, NoSuchAlgorithmException,
|
||||
CertificateException, UnrecoverableKeyException, KeyManagementException {
|
||||
|
||||
KeyStore keyStore = keyStoreProvider.getKeyStore();
|
||||
|
||||
String defaultAlgorithm = KeyManagerFactory.getDefaultAlgorithm();
|
||||
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(defaultAlgorithm);
|
||||
keyManagerFactory.init(keyStore, keyStoreProvider.getPassword());
|
||||
|
||||
SSLContext sslContext = SSLContext.getInstance("TLS");
|
||||
sslContext.init(keyManagerFactory.getKeyManagers(), null, null);
|
||||
SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();
|
||||
|
||||
SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(
|
||||
socket, socket.getInetAddress().getHostAddress(), socket.getPort(), true);
|
||||
sslSocket.setUseClientMode(false);
|
||||
sslSocket.startHandshake();
|
||||
|
||||
input = Okio.buffer(Okio.source(sslSocket.getInputStream()));
|
||||
output = Okio.buffer(Okio.sink(sslSocket.getOutputStream()));
|
||||
}
|
||||
|
||||
private void readAdditionalCommands() throws IOException {
|
||||
String command = input.readUtf8Line();
|
||||
if (command == null) {
|
||||
throw new EOFException();
|
||||
}
|
||||
|
||||
logger.log("Received additional command: %s", command);
|
||||
}
|
||||
|
||||
private Socket acceptConnectionAndCloseServerSocket() throws IOException {
|
||||
Socket socket = serverSocket.accept();
|
||||
serverSocket.close();
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
public void shouldStop() {
|
||||
shouldStop = true;
|
||||
|
||||
IOUtils.closeQuietly(clientSocket);
|
||||
}
|
||||
|
||||
public boolean isClientConnectionClosed() {
|
||||
return clientSocket.isClosed();
|
||||
}
|
||||
|
||||
public UnexpectedCommandException getUnexpectedCommandException() {
|
||||
return unexpectedCommandException;
|
||||
}
|
||||
}
|
||||
|
||||
private static class DefaultLogger implements Logger {
|
||||
@Override
|
||||
public void log(String message) {
|
||||
System.out.println("MockPop3Server: " + message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void log(String format, Object... args) {
|
||||
log(String.format(format, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue