Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View 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)
}

View file

@ -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
}
}
}

View file

@ -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"
}

View file

@ -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;
}
}

View file

@ -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"
}

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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,
)

View file

@ -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()
}
}

View file

@ -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"
}
}

View file

@ -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()
}

View file

@ -0,0 +1,5 @@
package com.fsck.k9.mail.store.imap
interface IdleRefreshTimeoutProvider {
val idleRefreshTimeoutMs: Long
}

View file

@ -0,0 +1,6 @@
package com.fsck.k9.mail.store.imap
data class ImapClientInfo(
val appName: String,
val appVersion: String,
)

View file

@ -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;
}
}

View file

@ -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)
}

View file

@ -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?)
}

View file

@ -0,0 +1,5 @@
package com.fsck.k9.mail.store.imap
internal interface ImapConnectionProvider {
fun getConnection(folder: ImapFolder): ImapConnection?
}

View file

@ -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)
}

View file

@ -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)
}
}
}

View file

@ -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,
}

View file

@ -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);
}
}
}
}
}
}

View file

@ -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
}
}

View file

@ -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()}"
}
}

View file

@ -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;
}

View file

@ -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");
}
}
}

View file

@ -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)

View file

@ -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)
}
}
}

View file

@ -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?)
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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(),
)
}
}

View file

@ -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();
}
}

View file

@ -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>
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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)
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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)
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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"
}

View file

@ -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
}
}
}
}
}

View file

@ -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;
}
}

View file

@ -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
}
}
}
}

View file

@ -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);
}
}
}
}
}

View file

@ -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)
}
}
}

View file

@ -0,0 +1,7 @@
package com.fsck.k9.mail.store.imap;
import java.io.IOException;
interface UntaggedHandler {
void handleAsyncUntaggedResponse(ImapResponse response) throws IOException;
}

View file

@ -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")
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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")
}
}

View file

@ -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")
}
}

View file

@ -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")
}
}

View file

@ -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() }

View file

@ -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\""
}
}

View file

@ -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()
}
}

View file

@ -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"));
}
}

View file

@ -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;
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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()
}
}

View file

@ -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);
}
}

View file

@ -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()
}

View file

@ -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
}
}
}

View file

@ -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);
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}
}

View file

@ -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);
}
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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()
}

View file

@ -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
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}
}
}

View file

@ -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()
}
}

View file

@ -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);
}
}

View file

@ -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()
}
}

View file

@ -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));
}
}
}

View 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)
}

View file

@ -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);
}
}

View file

@ -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"
}

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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)
}
}
}

View file

@ -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();
}

View file

@ -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;
}
}
}

View file

@ -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