Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
31
core/common/build.gradle.kts
Normal file
31
core/common/build.gradle.kts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.kmp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.thunderbird.core.common"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(projects.core.logging.implLegacy)
|
||||
implementation(projects.core.logging.api)
|
||||
implementation(projects.core.logging.implFile)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(projects.core.testing)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.androidx.annotation)
|
||||
}
|
||||
}
|
||||
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
listOf(
|
||||
"-Xexpect-actual-classes",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
actual typealias StringRes = androidx.annotation.StringRes
|
||||
actual typealias PluralsRes = androidx.annotation.PluralsRes
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
actual typealias ResourceNotFoundException = android.content.res.Resources.NotFoundException
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package net.thunderbird.core.common
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import net.thunderbird.core.common.oauth.InMemoryOAuthConfigurationProvider
|
||||
import net.thunderbird.core.common.oauth.OAuthConfigurationProvider
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.dsl.module
|
||||
|
||||
val coreCommonModule: Module = module {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
single<Clock> { Clock.System }
|
||||
|
||||
single<OAuthConfigurationProvider> {
|
||||
InMemoryOAuthConfigurationProvider(
|
||||
configurationFactory = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package net.thunderbird.core.common.action
|
||||
|
||||
enum class SwipeAction(val removesItem: Boolean) {
|
||||
None(removesItem = false),
|
||||
ToggleSelection(removesItem = false),
|
||||
ToggleRead(removesItem = false),
|
||||
ToggleStar(removesItem = false),
|
||||
Archive(removesItem = true),
|
||||
ArchiveDisabled(removesItem = false),
|
||||
ArchiveSetupArchiveFolder(removesItem = false),
|
||||
Delete(removesItem = true),
|
||||
Spam(removesItem = true),
|
||||
Move(removesItem = true),
|
||||
}
|
||||
|
||||
data class SwipeActions(
|
||||
val leftAction: SwipeAction,
|
||||
val rightAction: SwipeAction,
|
||||
) {
|
||||
companion object {
|
||||
const val KEY_SWIPE_ACTION_LEFT = "swipeLeftAction"
|
||||
const val KEY_SWIPE_ACTION_RIGHT = "swipeRightAction"
|
||||
}
|
||||
}
|
||||
12
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/Cache.kt
vendored
Normal file
12
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/Cache.kt
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package net.thunderbird.core.common.cache
|
||||
|
||||
interface Cache<KEY : Any, VALUE : Any?> {
|
||||
|
||||
operator fun get(key: KEY): VALUE?
|
||||
|
||||
operator fun set(key: KEY, value: VALUE)
|
||||
|
||||
fun hasKey(key: KEY): Boolean
|
||||
|
||||
fun clear()
|
||||
}
|
||||
51
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/ExpiringCache.kt
vendored
Normal file
51
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/ExpiringCache.kt
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package net.thunderbird.core.common.cache
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
class ExpiringCache<KEY : Any, VALUE : Any?>
|
||||
@OptIn(ExperimentalTime::class)
|
||||
constructor(
|
||||
private val clock: Clock,
|
||||
private val delegateCache: Cache<KEY, VALUE> = InMemoryCache(),
|
||||
private var lastClearTime: Instant = clock.now(),
|
||||
private val cacheTimeValidity: Long = CACHE_TIME_VALIDITY_IN_MILLIS,
|
||||
) : Cache<KEY, VALUE> {
|
||||
|
||||
override fun get(key: KEY): VALUE? {
|
||||
recycle()
|
||||
return delegateCache[key]
|
||||
}
|
||||
|
||||
override fun set(key: KEY, value: VALUE) {
|
||||
recycle()
|
||||
delegateCache[key] = value
|
||||
}
|
||||
|
||||
override fun hasKey(key: KEY): Boolean {
|
||||
recycle()
|
||||
return delegateCache.hasKey(key)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
lastClearTime = clock.now()
|
||||
delegateCache.clear()
|
||||
}
|
||||
|
||||
private fun recycle() {
|
||||
if (isExpired()) {
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isExpired(): Boolean {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
return (clock.now() - lastClearTime).inWholeMilliseconds >= cacheTimeValidity
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CACHE_TIME_VALIDITY_IN_MILLIS = 30_000L
|
||||
}
|
||||
}
|
||||
21
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/InMemoryCache.kt
vendored
Normal file
21
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/InMemoryCache.kt
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package net.thunderbird.core.common.cache
|
||||
|
||||
class InMemoryCache<KEY : Any, VALUE : Any?>(
|
||||
private val cache: MutableMap<KEY, VALUE> = mutableMapOf(),
|
||||
) : Cache<KEY, VALUE> {
|
||||
override fun get(key: KEY): VALUE? {
|
||||
return cache[key]
|
||||
}
|
||||
|
||||
override fun set(key: KEY, value: VALUE) {
|
||||
cache[key] = value
|
||||
}
|
||||
|
||||
override fun hasKey(key: KEY): Boolean {
|
||||
return cache.containsKey(key)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
30
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/SynchronizedCache.kt
vendored
Normal file
30
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/SynchronizedCache.kt
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package net.thunderbird.core.common.cache
|
||||
|
||||
class SynchronizedCache<KEY : Any, VALUE : Any?>(
|
||||
private val delegateCache: Cache<KEY, VALUE>,
|
||||
) : Cache<KEY, VALUE> {
|
||||
|
||||
override fun get(key: KEY): VALUE? {
|
||||
synchronized(delegateCache) {
|
||||
return delegateCache[key]
|
||||
}
|
||||
}
|
||||
|
||||
override fun set(key: KEY, value: VALUE) {
|
||||
synchronized(delegateCache) {
|
||||
delegateCache[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
override fun hasKey(key: KEY): Boolean {
|
||||
synchronized(delegateCache) {
|
||||
return delegateCache.hasKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
synchronized(delegateCache) {
|
||||
delegateCache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
62
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/TimeLimitedCache.kt
vendored
Normal file
62
core/common/src/commonMain/kotlin/net/thunderbird/core/common/cache/TimeLimitedCache.kt
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
@file:OptIn(ExperimentalTime::class)
|
||||
|
||||
package net.thunderbird.core.common.cache
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
class TimeLimitedCache<TKey : Any, TValue : Any?>(
|
||||
private val clock: Clock = Clock.System,
|
||||
private val cache: MutableMap<TKey, Entry<TValue>> = mutableMapOf(),
|
||||
) : Cache<TKey, TimeLimitedCache.Entry<TValue>> {
|
||||
companion object {
|
||||
private val DEFAULT_EXPIRATION_TIME = 1.hours
|
||||
}
|
||||
|
||||
override fun get(key: TKey): Entry<TValue>? {
|
||||
recycle(key)
|
||||
return cache[key]
|
||||
}
|
||||
|
||||
fun getValue(key: TKey): TValue? = get(key)?.value
|
||||
|
||||
fun set(key: TKey, value: TValue, expiresIn: Duration = DEFAULT_EXPIRATION_TIME) {
|
||||
set(key, Entry(value, creationTime = clock.now(), expiresIn))
|
||||
}
|
||||
|
||||
override fun set(key: TKey, value: Entry<TValue>) {
|
||||
cache[key] = value
|
||||
}
|
||||
|
||||
override fun hasKey(key: TKey): Boolean {
|
||||
recycle(key)
|
||||
return key in cache
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
fun clearExpired() {
|
||||
cache.entries.removeAll { (_, entry) ->
|
||||
entry.expiresAt < clock.now()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recycle(key: TKey) {
|
||||
val entry = cache[key] ?: return
|
||||
if (entry.expiresAt < clock.now()) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
data class Entry<TValue : Any?>(
|
||||
val value: TValue,
|
||||
val creationTime: Instant,
|
||||
val expiresIn: Duration,
|
||||
val expiresAt: Instant = creationTime + expiresIn,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.thunderbird.core.common.domain.usecase.validation
|
||||
|
||||
interface ValidationError
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package net.thunderbird.core.common.domain.usecase.validation
|
||||
|
||||
sealed interface ValidationResult {
|
||||
data object Success : ValidationResult
|
||||
|
||||
data class Failure(val error: ValidationError) : ValidationResult
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package net.thunderbird.core.common.exception
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.thunderbird.core.logging.file.FileLogSink
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.qualifier.named
|
||||
|
||||
class ExceptionHandler(
|
||||
private val defaultHandler: Thread.UncaughtExceptionHandler?,
|
||||
) : Thread.UncaughtExceptionHandler, KoinComponent {
|
||||
private val syncDebugFileLogSink: FileLogSink by inject(named("syncDebug"))
|
||||
|
||||
override fun uncaughtException(t: Thread, e: Throwable) {
|
||||
Log.e("UncaughtException", e.toString(), e)
|
||||
runBlocking {
|
||||
syncDebugFileLogSink.flushAndCloseBuffer()
|
||||
}
|
||||
defaultHandler?.uncaughtException(t, e)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package net.thunderbird.core.common.exception
|
||||
|
||||
open class MessagingException(
|
||||
override val message: String?,
|
||||
val isPermanentFailure: Boolean,
|
||||
override val cause: Throwable?,
|
||||
) : Exception(message, cause) {
|
||||
|
||||
constructor(cause: Throwable?) : this(message = null, cause = cause, isPermanentFailure = false)
|
||||
constructor(message: String?) : this(message = message, cause = null, isPermanentFailure = false)
|
||||
constructor(message: String?, isPermanentFailure: Boolean) : this(
|
||||
message = message,
|
||||
cause = null,
|
||||
isPermanentFailure = isPermanentFailure,
|
||||
)
|
||||
|
||||
constructor(message: String?, cause: Throwable?) : this(
|
||||
message = message,
|
||||
cause = cause,
|
||||
isPermanentFailure = false,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val serialVersionUID = -1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
@file:JvmName("ThrowableExtensions")
|
||||
|
||||
package net.thunderbird.core.common.exception
|
||||
|
||||
val Throwable.rootCauseMassage: String?
|
||||
get() {
|
||||
var rootCause = this
|
||||
var nextCause: Throwable? = null
|
||||
do {
|
||||
nextCause = rootCause.cause?.also {
|
||||
rootCause = it
|
||||
}
|
||||
} while (nextCause != null)
|
||||
|
||||
if (rootCause is MessagingException) {
|
||||
return rootCause.message
|
||||
}
|
||||
|
||||
// Remove the namespace on the exception so we have a fighting chance of seeing more
|
||||
// of the error in the notification.
|
||||
val simpleName = rootCause::class.simpleName
|
||||
val message = rootCause.localizedMessage
|
||||
return if (message.isNullOrBlank()) {
|
||||
simpleName
|
||||
} else {
|
||||
"$simpleName: $message"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
package net.thunderbird.core.common.inject
|
||||
|
||||
import org.koin.core.definition.Definition
|
||||
import org.koin.core.module.KoinDslMarker
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koin.core.qualifier.Qualifier
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.core.scope.Scope
|
||||
|
||||
// This file must be deleted once https://github.com/InsertKoinIO/koin/pull/1951 is merged to
|
||||
// Koin and released on 4.2.0
|
||||
|
||||
/**
|
||||
* Defines a singleton list of elements of type [T].
|
||||
*
|
||||
* This function creates a singleton definition for a mutable list of elements.
|
||||
* Each element in the list is resolved from the provided [items] definitions.
|
||||
*
|
||||
* @param T The type of elements in the list.
|
||||
* @param items Vararg of [Definition]s that will be resolved and added to the list.
|
||||
* @param qualifier Optional [Qualifier] to distinguish this list from others of the same type.
|
||||
* If null, a default qualifier based on the type [T] will be used.
|
||||
*/
|
||||
@KoinDslMarker
|
||||
inline fun <reified T> Module.singleListOf(vararg items: Definition<T>, qualifier: Qualifier? = null) {
|
||||
single(qualifier ?: defaultListQualifier<T>(), createdAtStart = true) {
|
||||
items.map { definition -> definition(this, parametersOf()) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a [List] of instances of type [T].
|
||||
* This is a helper function for Koin's multibinding feature.
|
||||
*
|
||||
* It uses the [defaultListQualifier] if no [qualifier] is provided.
|
||||
*
|
||||
* @param T The type of instances in the list.
|
||||
* @param qualifier An optional [Qualifier] to distinguish between different lists of the same type.
|
||||
* @return The resolved [MutableList] of instances of type [T].
|
||||
*/
|
||||
inline fun <reified T> Scope.getList(qualifier: Qualifier? = null) =
|
||||
get<List<T>>(qualifier ?: defaultListQualifier<T>())
|
||||
|
||||
/**
|
||||
* Creates a qualifier for a set of a specific type.
|
||||
*
|
||||
* This is used to differentiate between different sets of the same type when injecting dependencies.
|
||||
*
|
||||
* @param T The type of the elements in the set.
|
||||
* @return A qualifier for the set.
|
||||
*/
|
||||
inline fun <reified T> defaultListQualifier() =
|
||||
defaultCollectionQualifier<List<T>, T>()
|
||||
|
||||
/**
|
||||
* Creates a default [Qualifier] for a collection binding.
|
||||
*
|
||||
* @param TCollection The type of the collection (e.g., `List`, `List`).
|
||||
* @param T The type of the elements in the collection.
|
||||
* @return A [Qualifier] that can be used to identify the specific collection binding.
|
||||
*/
|
||||
inline fun <reified TCollection : Collection<T>, reified T> defaultCollectionQualifier() =
|
||||
named("${TCollection::class.qualifiedName}<${T::class.qualifiedName}>")
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.UnexpectedCharacter
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.UnexpectedEndOfInput
|
||||
|
||||
@Suppress("UnnecessaryAbstractClass")
|
||||
internal abstract class AbstractParser(val input: String, startIndex: Int = 0, val endIndex: Int = input.length) {
|
||||
protected var currentIndex = startIndex
|
||||
|
||||
val position: Int
|
||||
get() = currentIndex
|
||||
|
||||
fun endReached() = currentIndex >= endIndex
|
||||
|
||||
fun peek(): Char {
|
||||
if (currentIndex >= endIndex) {
|
||||
parserError(UnexpectedEndOfInput)
|
||||
}
|
||||
|
||||
return input[currentIndex]
|
||||
}
|
||||
|
||||
fun read(): Char {
|
||||
if (currentIndex >= endIndex) {
|
||||
parserError(UnexpectedEndOfInput)
|
||||
}
|
||||
|
||||
return input[currentIndex].also { currentIndex++ }
|
||||
}
|
||||
|
||||
fun expect(character: Char) {
|
||||
if (!endReached() && peek() == character) {
|
||||
currentIndex++
|
||||
} else {
|
||||
parserError(UnexpectedCharacter, message = "Expected '$character' (${character.code})")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("SameParameterValue")
|
||||
protected inline fun expect(displayInError: String, predicate: (Char) -> Boolean) {
|
||||
if (!endReached() && predicate(peek())) {
|
||||
skip()
|
||||
} else {
|
||||
parserError(UnexpectedCharacter, message = "Expected $displayInError")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
protected inline fun skip() {
|
||||
currentIndex++
|
||||
}
|
||||
|
||||
protected inline fun skipWhile(crossinline predicate: (Char) -> Boolean) {
|
||||
while (!endReached() && predicate(input[currentIndex])) {
|
||||
currentIndex++
|
||||
}
|
||||
}
|
||||
|
||||
protected inline fun readString(block: () -> Unit): String {
|
||||
val startIndex = currentIndex
|
||||
block()
|
||||
return input.substring(startIndex, currentIndex)
|
||||
}
|
||||
|
||||
protected inline fun <P : AbstractParser, T> withParser(parser: P, block: P.() -> T): T {
|
||||
try {
|
||||
return block(parser)
|
||||
} finally {
|
||||
currentIndex = parser.position
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
protected inline fun parserError(
|
||||
error: EmailAddressParserError,
|
||||
position: Int = currentIndex,
|
||||
message: String = error.message,
|
||||
): Nothing {
|
||||
throw EmailAddressParserException(message, error, input, position)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import kotlin.text.iterator
|
||||
|
||||
// See RFC 5321, 4.5.3.1.3.
|
||||
// The maximum length of 'Path' indirectly limits the length of 'Mailbox'.
|
||||
internal const val MAXIMUM_EMAIL_ADDRESS_LENGTH = 254
|
||||
|
||||
// See RFC 5321, 4.5.3.1.1.
|
||||
internal const val MAXIMUM_LOCAL_PART_LENGTH = 64
|
||||
|
||||
/**
|
||||
* Represents an email address.
|
||||
*
|
||||
* This class currently doesn't support internationalized domain names (RFC 5891) or non-ASCII local parts (RFC 6532).
|
||||
*/
|
||||
class EmailAddress internal constructor(
|
||||
val localPart: String,
|
||||
val domain: EmailDomain,
|
||||
) {
|
||||
val encodedLocalPart: String = if (localPart.isDotString) localPart else quoteString(localPart)
|
||||
|
||||
val warnings: Set<Warning>
|
||||
|
||||
init {
|
||||
warnings = buildSet {
|
||||
if (localPart.length > MAXIMUM_LOCAL_PART_LENGTH) {
|
||||
add(Warning.LocalPartExceedsLengthLimit)
|
||||
}
|
||||
|
||||
if (address.length > MAXIMUM_EMAIL_ADDRESS_LENGTH) {
|
||||
add(Warning.EmailAddressExceedsLengthLimit)
|
||||
}
|
||||
|
||||
if (localPart.isEmpty()) {
|
||||
add(Warning.EmptyLocalPart)
|
||||
}
|
||||
|
||||
if (!localPart.isDotString) {
|
||||
add(Warning.QuotedStringInLocalPart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val address: String
|
||||
get() = "$encodedLocalPart@$domain"
|
||||
|
||||
val normalizedAddress: String
|
||||
get() = "$encodedLocalPart@${domain.normalized}"
|
||||
|
||||
override fun toString(): String {
|
||||
return address
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
|
||||
other as EmailAddress
|
||||
|
||||
if (localPart != other.localPart) return false
|
||||
return domain == other.domain
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = localPart.hashCode()
|
||||
result = 31 * result + domain.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun quoteString(input: String): String {
|
||||
return buildString {
|
||||
append(DQUOTE)
|
||||
for (character in input) {
|
||||
if (!character.isQtext) {
|
||||
append(BACKSLASH)
|
||||
}
|
||||
append(character)
|
||||
}
|
||||
append(DQUOTE)
|
||||
}
|
||||
}
|
||||
|
||||
enum class Warning {
|
||||
/**
|
||||
* The local part exceeds the length limit (see RFC 5321, 4.5.3.1.1.).
|
||||
*/
|
||||
LocalPartExceedsLengthLimit,
|
||||
|
||||
/**
|
||||
* The email address exceeds the length limit (see RFC 5321, 4.5.3.1.3.; The maximum length of 'Path'
|
||||
* indirectly limits the length of 'Mailbox').
|
||||
*/
|
||||
EmailAddressExceedsLengthLimit,
|
||||
|
||||
/**
|
||||
* The local part requires using a quoted string.
|
||||
*
|
||||
* This is valid, but very uncommon. Using such a local part should be avoided whenever possible.
|
||||
*/
|
||||
QuotedStringInLocalPart,
|
||||
|
||||
/**
|
||||
* The local part is the empty string.
|
||||
*
|
||||
* Even if you want to allow quoted strings, you probably don't want to allow this.
|
||||
*/
|
||||
EmptyLocalPart,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(address: String, config: EmailAddressParserConfig = EmailAddressParserConfig.RELAXED): EmailAddress {
|
||||
return EmailAddressParser(address, config).parse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this string to an [EmailAddress] instance using [EmailAddressParserConfig.RELAXED].
|
||||
*/
|
||||
fun String.toEmailAddressOrThrow() = EmailAddress.parse(this, EmailAddressParserConfig.RELAXED)
|
||||
|
||||
/**
|
||||
* Converts this string to an [EmailAddress] instance using [EmailAddressParserConfig.RELAXED].
|
||||
*/
|
||||
@Suppress("SwallowedException")
|
||||
fun String.toEmailAddressOrNull(): EmailAddress? {
|
||||
return try {
|
||||
EmailAddress.parse(this, EmailAddressParserConfig.RELAXED)
|
||||
} catch (e: EmailAddressParserException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this string into an [EmailAddress] instance using [EmailAddressParserConfig.LIMITED].
|
||||
*
|
||||
* Use this when validating the email address a user wants to add to an account/identity.
|
||||
*/
|
||||
fun String.toUserEmailAddress() = EmailAddress.parse(this, EmailAddressParserConfig.LIMITED)
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import net.thunderbird.core.common.mail.EmailAddress.Warning
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.AddressLiteralsNotSupported
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.EmptyLocalPart
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.ExpectedEndOfInput
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidDomainPart
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidDotString
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidLocalPart
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidQuotedString
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.LocalPartLengthExceeded
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.LocalPartRequiresQuotedString
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.QuotedStringInLocalPart
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.TotalLengthExceeded
|
||||
|
||||
/**
|
||||
* Parse an email address.
|
||||
*
|
||||
* This class currently doesn't support internationalized domain names (RFC 5891) or non-ASCII local parts (RFC 6532).
|
||||
*
|
||||
* From RFC 5321:
|
||||
* ```
|
||||
* Mailbox = Local-part "@" ( Domain / address-literal )
|
||||
*
|
||||
* Local-part = Dot-string / Quoted-string
|
||||
* Dot-string = Atom *("." Atom)
|
||||
* Quoted-string = DQUOTE *QcontentSMTP DQUOTE
|
||||
* QcontentSMTP = qtextSMTP / quoted-pairSMTP
|
||||
* qtextSMTP = %d32-33 / %d35-91 / %d93-126
|
||||
* quoted-pairSMTP = %d92 %d32-126
|
||||
*
|
||||
* Domain - see DomainParser
|
||||
* address-literal - We intentionally don't support address literals
|
||||
* ```
|
||||
*/
|
||||
internal class EmailAddressParser(
|
||||
input: String,
|
||||
private val config: EmailAddressParserConfig,
|
||||
) : AbstractParser(input) {
|
||||
|
||||
fun parse(): EmailAddress {
|
||||
val emailAddress = readEmailAddress()
|
||||
|
||||
if (!endReached()) {
|
||||
parserError(ExpectedEndOfInput)
|
||||
}
|
||||
|
||||
if (
|
||||
config.isEmailAddressLengthCheckEnabled && Warning.EmailAddressExceedsLengthLimit in emailAddress.warnings
|
||||
) {
|
||||
parserError(TotalLengthExceeded)
|
||||
}
|
||||
|
||||
if (config.isLocalPartLengthCheckEnabled && Warning.LocalPartExceedsLengthLimit in emailAddress.warnings) {
|
||||
parserError(LocalPartLengthExceeded, position = input.lastIndexOf('@'))
|
||||
}
|
||||
|
||||
if (
|
||||
!config.isLocalPartRequiringQuotedStringAllowed && Warning.QuotedStringInLocalPart in emailAddress.warnings
|
||||
) {
|
||||
parserError(LocalPartRequiresQuotedString, position = 0)
|
||||
}
|
||||
|
||||
if (!config.isEmptyLocalPartAllowed && Warning.EmptyLocalPart in emailAddress.warnings) {
|
||||
parserError(EmptyLocalPart, position = 1)
|
||||
}
|
||||
|
||||
return emailAddress
|
||||
}
|
||||
|
||||
private fun readEmailAddress(): EmailAddress {
|
||||
val localPart = readLocalPart()
|
||||
|
||||
expect(AT)
|
||||
val domain = readDomainPart()
|
||||
|
||||
return EmailAddress(localPart, domain)
|
||||
}
|
||||
|
||||
private fun readLocalPart(): String {
|
||||
val character = peek()
|
||||
val localPart = when {
|
||||
character.isAtext -> {
|
||||
readDotString()
|
||||
}
|
||||
character == DQUOTE -> {
|
||||
if (config.isQuotedLocalPartAllowed) {
|
||||
readQuotedString()
|
||||
} else {
|
||||
parserError(QuotedStringInLocalPart)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
parserError(InvalidLocalPart)
|
||||
}
|
||||
}
|
||||
|
||||
return localPart
|
||||
}
|
||||
|
||||
private fun readDotString(): String {
|
||||
return buildString {
|
||||
appendAtom()
|
||||
|
||||
while (!endReached() && peek() == DOT) {
|
||||
expect(DOT)
|
||||
append(DOT)
|
||||
appendAtom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendAtom() {
|
||||
val startIndex = currentIndex
|
||||
skipWhile { it.isAtext }
|
||||
|
||||
if (startIndex == currentIndex) {
|
||||
parserError(InvalidDotString)
|
||||
}
|
||||
|
||||
append(input, startIndex, currentIndex)
|
||||
}
|
||||
|
||||
private fun readQuotedString(): String {
|
||||
return buildString {
|
||||
expect(DQUOTE)
|
||||
|
||||
while (!endReached()) {
|
||||
val character = peek()
|
||||
when {
|
||||
character.isQtext -> append(read())
|
||||
character == BACKSLASH -> {
|
||||
expect(BACKSLASH)
|
||||
val escapedCharacter = read()
|
||||
if (!escapedCharacter.isQuotedChar) {
|
||||
parserError(InvalidQuotedString)
|
||||
}
|
||||
append(escapedCharacter)
|
||||
}
|
||||
|
||||
character == DQUOTE -> break
|
||||
else -> parserError(InvalidQuotedString)
|
||||
}
|
||||
}
|
||||
|
||||
expect(DQUOTE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readDomainPart(): EmailDomain {
|
||||
val character = peek()
|
||||
return when {
|
||||
character.isLetDig -> readDomain()
|
||||
character == '[' -> parserError(AddressLiteralsNotSupported)
|
||||
else -> parserError(InvalidDomainPart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readDomain(): EmailDomain {
|
||||
return withParser(EmailDomainParser(input, currentIndex)) {
|
||||
readDomain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
/**
|
||||
* Configuration to control the behavior when parsing an email address into [EmailAddress].
|
||||
*
|
||||
* @param isLocalPartLengthCheckEnabled When this is `true` the length of the local part is checked to make sure it
|
||||
* doesn't exceed the specified limit (see RFC 5321, 4.5.3.1.1.).
|
||||
*
|
||||
* @param isEmailAddressLengthCheckEnabled When this is `true` the length of the whole email address is checked to make
|
||||
* sure it doesn't exceed the specified limit (see RFC 5321, 4.5.3.1.3.; The maximum length of 'Path' indirectly limits
|
||||
* the length of 'Mailbox').
|
||||
*
|
||||
* @param isQuotedLocalPartAllowed When this is `true`, the parsing step allows email addresses with a local part
|
||||
* encoded as quoted string, e.g. `"foo bar"@domain.example`. Otherwise, the parser will throw an
|
||||
* [EmailAddressParserException] as soon as a quoted string is encountered.
|
||||
* Quoted strings in local parts are not widely used. It's recommended to disallow them whenever possible.
|
||||
*
|
||||
* @param isLocalPartRequiringQuotedStringAllowed Email addresses whose local part requires the use of a quoted string
|
||||
* are only allowed when this is `true`. This is separate from [isQuotedLocalPartAllowed] because one might want to
|
||||
* allow email addresses that unnecessarily use a quoted string, e.g. `"test"@domain.example`
|
||||
* ([isQuotedLocalPartAllowed] = `true`, [isLocalPartRequiringQuotedStringAllowed] = `false`; [EmailAddress] will not
|
||||
* retain the original form and treat this address exactly like `test@domain.example`). When allowing this, remember to
|
||||
* use the value of [EmailAddress.address] instead of retaining the original user input.
|
||||
*
|
||||
* The value of this property is ignored if [isQuotedLocalPartAllowed] is `false`.
|
||||
*
|
||||
* @param isEmptyLocalPartAllowed Email addresses with an empty local part (e.g. `""@domain.example`) are only allowed
|
||||
* if this value is `true`.
|
||||
*
|
||||
* The value of this property is ignored if at least one of [isQuotedLocalPartAllowed] and
|
||||
* [isLocalPartRequiringQuotedStringAllowed] is `false`.
|
||||
*/
|
||||
data class EmailAddressParserConfig(
|
||||
val isLocalPartLengthCheckEnabled: Boolean,
|
||||
val isEmailAddressLengthCheckEnabled: Boolean,
|
||||
val isQuotedLocalPartAllowed: Boolean,
|
||||
val isLocalPartRequiringQuotedStringAllowed: Boolean,
|
||||
val isEmptyLocalPartAllowed: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* This allows local parts requiring quoted strings and disables length checks for the local part and the
|
||||
* whole email address.
|
||||
*/
|
||||
val RELAXED = EmailAddressParserConfig(
|
||||
isLocalPartLengthCheckEnabled = false,
|
||||
isEmailAddressLengthCheckEnabled = false,
|
||||
isQuotedLocalPartAllowed = true,
|
||||
isLocalPartRequiringQuotedStringAllowed = true,
|
||||
isEmptyLocalPartAllowed = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* This only allows a subset of valid email addresses. Use this when validating the email address a user wants
|
||||
* to add to an account/identity.
|
||||
*/
|
||||
val LIMITED = EmailAddressParserConfig(
|
||||
isLocalPartLengthCheckEnabled = true,
|
||||
isEmailAddressLengthCheckEnabled = true,
|
||||
isQuotedLocalPartAllowed = false,
|
||||
isLocalPartRequiringQuotedStringAllowed = false,
|
||||
isEmptyLocalPartAllowed = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
enum class EmailAddressParserError(internal val message: String) {
|
||||
UnexpectedEndOfInput("End of input reached unexpectedly"),
|
||||
ExpectedEndOfInput("Expected end of input"),
|
||||
InvalidLocalPart("Expected 'Dot-string' or 'Quoted-string'"),
|
||||
InvalidDotString("Expected 'Dot-string'"),
|
||||
InvalidQuotedString("Expected 'Quoted-string'"),
|
||||
InvalidDomainPart("Expected 'Domain' or 'address-literal'"),
|
||||
AddressLiteralsNotSupported("Address literals are not supported"),
|
||||
|
||||
LocalPartLengthExceeded("Local part exceeds maximum length of $MAXIMUM_LOCAL_PART_LENGTH characters"),
|
||||
DnsLabelLengthExceeded("DNS labels exceeds maximum length of $MAXIMUM_DNS_LABEL_LENGTH characters"),
|
||||
DomainLengthExceeded("Domain exceeds maximum length of $MAXIMUM_DOMAIN_LENGTH characters"),
|
||||
TotalLengthExceeded("The email address exceeds the maximum length of $MAXIMUM_EMAIL_ADDRESS_LENGTH characters"),
|
||||
|
||||
QuotedStringInLocalPart("Quoted string in local part is not allowed by config"),
|
||||
LocalPartRequiresQuotedString("Local part requiring the use of a quoted string is not allowed by config"),
|
||||
EmptyLocalPart("Empty local part is not allowed by config"),
|
||||
|
||||
UnexpectedCharacter("Caller needs to provide message"),
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
class EmailAddressParserException internal constructor(
|
||||
message: String,
|
||||
val error: EmailAddressParserError,
|
||||
val input: String,
|
||||
val position: Int,
|
||||
) : RuntimeException(message)
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import net.thunderbird.core.common.net.Domain
|
||||
|
||||
/**
|
||||
* The domain part of an email address.
|
||||
*
|
||||
* @param value String representation of the email domain with the original capitalization.
|
||||
*/
|
||||
class EmailDomain internal constructor(val value: String) {
|
||||
/**
|
||||
* The normalized (converted to lower case) string representation of this email domain.
|
||||
*/
|
||||
val normalized: String = value.lowercase()
|
||||
|
||||
/**
|
||||
* Returns this email domain with the original capitalization.
|
||||
*
|
||||
* @see value
|
||||
*/
|
||||
override fun toString(): String = value
|
||||
|
||||
/**
|
||||
* Compares the normalized string representations of two [EmailDomain] instances.
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
|
||||
other as EmailDomain
|
||||
|
||||
return normalized == other.normalized
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return normalized.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parses the string representation of an email domain.
|
||||
*
|
||||
* @throws EmailAddressParserException in case of an error.
|
||||
*/
|
||||
fun parse(domain: String): EmailDomain {
|
||||
return EmailDomainParser(domain).parseDomain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun EmailDomain.toDomain() = Domain(value)
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.DnsLabelLengthExceeded
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.DomainLengthExceeded
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.ExpectedEndOfInput
|
||||
|
||||
// See RFC 1035, 2.3.4.
|
||||
// For the string representation used in emails (labels separated by dots, no final dot allowed), we end up with a
|
||||
// maximum of 253 characters.
|
||||
internal const val MAXIMUM_DOMAIN_LENGTH = 253
|
||||
|
||||
// See RFC 1035, 2.3.4.
|
||||
internal const val MAXIMUM_DNS_LABEL_LENGTH = 63
|
||||
|
||||
/**
|
||||
* Parser for domain names in email addresses.
|
||||
*
|
||||
* From RFC 5321:
|
||||
* ```
|
||||
* Domain = sub-domain *("." sub-domain)
|
||||
* sub-domain = Let-dig [Ldh-str]
|
||||
* Let-dig = ALPHA / DIGIT
|
||||
* Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig
|
||||
* ```
|
||||
*/
|
||||
internal class EmailDomainParser(
|
||||
input: String,
|
||||
startIndex: Int = 0,
|
||||
endIndex: Int = input.length,
|
||||
) : AbstractParser(input, startIndex, endIndex) {
|
||||
|
||||
fun parseDomain(): EmailDomain {
|
||||
val domain = readDomain()
|
||||
|
||||
if (!endReached()) {
|
||||
parserError(ExpectedEndOfInput)
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
fun readDomain(): EmailDomain {
|
||||
val domain = readString {
|
||||
expectSubDomain()
|
||||
|
||||
while (!endReached() && peek() == DOT) {
|
||||
expect(DOT)
|
||||
expectSubDomain()
|
||||
}
|
||||
}
|
||||
|
||||
if (domain.length > MAXIMUM_DOMAIN_LENGTH) {
|
||||
parserError(DomainLengthExceeded)
|
||||
}
|
||||
|
||||
return EmailDomain(domain)
|
||||
}
|
||||
|
||||
private fun expectSubDomain() {
|
||||
val startIndex = currentIndex
|
||||
|
||||
expectLetDig()
|
||||
|
||||
var requireLetDig = false
|
||||
while (!endReached()) {
|
||||
val character = peek()
|
||||
when {
|
||||
character == HYPHEN -> {
|
||||
requireLetDig = true
|
||||
expect(HYPHEN)
|
||||
}
|
||||
character.isLetDig -> {
|
||||
requireLetDig = false
|
||||
expectLetDig()
|
||||
}
|
||||
else -> break
|
||||
}
|
||||
}
|
||||
|
||||
if (requireLetDig) {
|
||||
expectLetDig()
|
||||
}
|
||||
|
||||
if (currentIndex - startIndex > MAXIMUM_DNS_LABEL_LENGTH) {
|
||||
parserError(DnsLabelLengthExceeded)
|
||||
}
|
||||
}
|
||||
|
||||
private fun expectLetDig() {
|
||||
expect("'Let-dig'") { it.isLetDig }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
object Protocols {
|
||||
const val IMAP = "imap"
|
||||
const val POP3 = "pop3"
|
||||
const val SMTP = "smtp"
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package net.thunderbird.core.common.mail
|
||||
|
||||
internal const val DQUOTE = '"'
|
||||
internal const val DOT = '.'
|
||||
internal const val AT = '@'
|
||||
internal const val BACKSLASH = '\\'
|
||||
internal const val HYPHEN = '-'
|
||||
|
||||
internal val ATEXT_EXTRA = charArrayOf(
|
||||
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~',
|
||||
)
|
||||
|
||||
// RFC 5234: ALPHA = %x41-5A / %x61-7A ; A-Z / a-z
|
||||
internal val Char.isALPHA
|
||||
get() = this in 'A'..'Z' || this in 'a'..'z'
|
||||
|
||||
// RFC 5234: DIGIT = %x30-39 ; 0-9
|
||||
internal val Char.isDIGIT
|
||||
get() = this in '0'..'9'
|
||||
|
||||
// RFC 5322:
|
||||
// atext = ALPHA / DIGIT / ; Printable US-ASCII
|
||||
// "!" / "#" / ; characters not including
|
||||
// "$" / "%" / ; specials. Used for atoms.
|
||||
// "&" / "'" /
|
||||
// "*" / "+" /
|
||||
// "-" / "/" /
|
||||
// "=" / "?" /
|
||||
// "^" / "_" /
|
||||
// "`" / "{" /
|
||||
// "|" / "}" /
|
||||
// "~"
|
||||
internal val Char.isAtext
|
||||
get() = isALPHA || isDIGIT || this in ATEXT_EXTRA
|
||||
|
||||
// RFC 5321: qtextSMTP = %d32-33 / %d35-91 / %d93-126
|
||||
internal val Char.isQtext
|
||||
get() = code.let { it in 32..33 || it in 35..91 || it in 93..126 }
|
||||
|
||||
// RFC 5321: second character of quoted-pairSMTP = %d92 %d32-126
|
||||
internal val Char.isQuotedChar
|
||||
get() = code in 32..126
|
||||
|
||||
// RFC 5321:
|
||||
// Dot-string = Atom *("." Atom)
|
||||
// Atom = 1*atext
|
||||
internal val String.isDotString: Boolean
|
||||
get() {
|
||||
if (isEmpty() || this[0] == DOT || this[lastIndex] == DOT) return false
|
||||
for (i in 0..lastIndex) {
|
||||
val character = this[i]
|
||||
when {
|
||||
character == DOT -> if (this[i - 1] == DOT) return false
|
||||
character.isAtext -> Unit
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// RFC 5321: Let-dig = ALPHA / DIGIT
|
||||
internal val Char.isLetDig
|
||||
get() = isALPHA || isDIGIT
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package net.thunderbird.core.common.net
|
||||
|
||||
@JvmInline
|
||||
value class Domain(val value: String) {
|
||||
init {
|
||||
requireNotNull(HostNameUtils.isLegalHostName(value)) { "Not a valid domain name: '$value'" }
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toDomain() = Domain(this)
|
||||
|
||||
@Suppress("SwallowedException")
|
||||
fun String.toDomainOrNull(): Domain? {
|
||||
return try {
|
||||
toDomain()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
package net.thunderbird.core.common.net
|
||||
|
||||
/**
|
||||
* Code to check the validity of host names and IP addresses.
|
||||
*
|
||||
* Based on
|
||||
* [mailnews/base/src/hostnameUtils.jsm](https://searchfox.org/comm-central/source/mailnews/base/src/hostnameUtils.jsm)
|
||||
*
|
||||
* Note: The naming of these functions is inconsistent with the rest of the Android project to match the original
|
||||
* source. Please use more appropriate names when refactoring this code.
|
||||
*/
|
||||
@Suppress("MagicNumber", "ReturnCount")
|
||||
object HostNameUtils {
|
||||
/**
|
||||
* Check if `hostName` is an IP address or a valid hostname.
|
||||
*
|
||||
* @return Unobscured host name if `hostName` is valid.
|
||||
*/
|
||||
fun isLegalHostNameOrIP(hostName: String): String? {
|
||||
/*
|
||||
RFC 1123:
|
||||
Whenever a user inputs the identity of an Internet host, it SHOULD
|
||||
be possible to enter either (1) a host domain name or (2) an IP
|
||||
address in dotted-decimal ("#.#.#.#") form. The host SHOULD check
|
||||
the string syntactically for a dotted-decimal number before
|
||||
looking it up in the Domain Name System.
|
||||
*/
|
||||
|
||||
return isLegalIPAddress(hostName) ?: isLegalHostName(hostName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if `hostName` is a valid IP address (IPv4 or IPv6).
|
||||
*
|
||||
* @return Unobscured canonicalized IPv4 or IPv6 address if it is valid, otherwise `null`.
|
||||
*/
|
||||
fun isLegalIPAddress(hostName: String): String? {
|
||||
return isLegalIPv4Address(hostName) ?: isLegalIPv6Address(hostName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if `hostName` is a valid IPv4 address.
|
||||
*
|
||||
* @return Unobscured canonicalized address if `hostName` is an IPv4 address. Returns `null` if it's not.
|
||||
*/
|
||||
fun isLegalIPv4Address(hostName: String): String? {
|
||||
// Break the IP address down into individual components.
|
||||
val ipComponentStrings = hostName.split(".")
|
||||
if (ipComponentStrings.size != 4) {
|
||||
return null
|
||||
}
|
||||
|
||||
val ipComponents = ipComponentStrings.map { toIPv4NumericComponent(it) }
|
||||
|
||||
if (ipComponents.any { it == null }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// First component of zero is not valid.
|
||||
if (ipComponents.first() == 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return hostName
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an IPv4 address component to a number if it is valid. Returns `null` otherwise.
|
||||
*/
|
||||
private fun toIPv4NumericComponent(value: String): Int? {
|
||||
return if (IPV4_COMPONENT_PATTERN.matches(value)) {
|
||||
value.toInt(radix = 10).takeIf { it in 0..255 }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if `hostName` is a valid IPv6 address.
|
||||
*
|
||||
* @returns Unobscured canonicalized address if `hostName` is an IPv6 address. Returns `null` if it's not.
|
||||
*/
|
||||
fun isLegalIPv6Address(hostName: String): String? {
|
||||
// Break the IP address down into individual components.
|
||||
val ipComponentStrings = hostName.lowercase().split(":")
|
||||
|
||||
// Make sure there are at least 3 components.
|
||||
if (ipComponentStrings.size < 3) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Take care if the last part is written in decimal using dots as separators.
|
||||
val lastPart = isLegalIPv4Address(ipComponentStrings.last())
|
||||
val ipComponentHexStrings = if (lastPart != null) {
|
||||
val lastPartComponents = lastPart.split(".").map { it.toInt(radix = 10) }
|
||||
// Convert it into standard IPv6 components.
|
||||
val part1 = ((lastPartComponents[0] shl 8) or lastPartComponents[1]).toString(radix = 16)
|
||||
val part2 = ((lastPartComponents[2] shl 8) or lastPartComponents[3]).toString(radix = 16)
|
||||
|
||||
ipComponentStrings.subList(0, ipComponentStrings.lastIndex) + part1 + part2
|
||||
} else {
|
||||
ipComponentStrings
|
||||
}
|
||||
|
||||
// Make sure that there is only one empty component.
|
||||
var emptyIndex = -1
|
||||
for (index in 1 until ipComponentHexStrings.lastIndex) {
|
||||
if (ipComponentHexStrings[index] == "") {
|
||||
// If we already found an empty component return null.
|
||||
if (emptyIndex != -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
emptyIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
// If we found an empty component, extend it.
|
||||
val fullIpComponentStrings = if (emptyIndex != -1) {
|
||||
buildList(capacity = 8) {
|
||||
for (i in 0 until emptyIndex) {
|
||||
add(ipComponentHexStrings[i])
|
||||
}
|
||||
|
||||
repeat(8 - ipComponentHexStrings.size + 1) {
|
||||
add("0")
|
||||
}
|
||||
|
||||
for (i in (emptyIndex + 1)..ipComponentHexStrings.lastIndex) {
|
||||
add(ipComponentHexStrings[i])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ipComponentHexStrings
|
||||
}
|
||||
|
||||
// Make sure there are 8 components.
|
||||
if (fullIpComponentStrings.size != 8) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Format all components to 4 character hex value.
|
||||
val ipComponents = fullIpComponentStrings.map { ipComponentString ->
|
||||
if (ipComponentString == "") {
|
||||
0
|
||||
} else if (IPV6_COMPONENT_PATTERN.matches(ipComponentString)) {
|
||||
ipComponentString.toInt(radix = 16)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Treat 0000:0000:0000:0000:0000:0000:0000:0000 as an invalid IPv6 address.
|
||||
if (ipComponents.all { it == 0 }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Pad the component with 0:s.
|
||||
val canonicalIpComponents = ipComponents.map { it.toString(radix = 16).padStart(4, '0') }
|
||||
|
||||
// TODO: support Zone indices in Link-local addresses? Currently they are rejected.
|
||||
// http://en.wikipedia.org/wiki/IPv6_address#Link-local_addresses_and_zone_indices
|
||||
|
||||
return canonicalIpComponents.joinToString(":")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if `hostName` is a valid hostname.
|
||||
*
|
||||
* @returns The host name if it is valid. Returns `null` if it's not.
|
||||
*/
|
||||
fun isLegalHostName(hostName: String): String? {
|
||||
/*
|
||||
RFC 952:
|
||||
A "name" (Net, Host, Gateway, or Domain name) is a text string up
|
||||
to 24 characters drawn from the alphabet (A-Z), digits (0-9), minus
|
||||
sign (-), and period (.). Note that periods are only allowed when
|
||||
they serve to delimit components of "domain style names". (See
|
||||
RFC-921, "Domain Name System Implementation Schedule", for
|
||||
background). No blank or space characters are permitted as part of a
|
||||
name. No distinction is made between upper and lower case. The first
|
||||
character must be an alpha character. The last character must not be
|
||||
a minus sign or period.
|
||||
|
||||
RFC 1123:
|
||||
The syntax of a legal Internet host name was specified in RFC-952
|
||||
[DNS:4]. One aspect of host name syntax is hereby changed: the
|
||||
restriction on the first character is relaxed to allow either a
|
||||
letter or a digit. Host software MUST support this more liberal
|
||||
syntax.
|
||||
|
||||
Host software MUST handle host names of up to 63 characters and
|
||||
SHOULD handle host names of up to 255 characters.
|
||||
|
||||
RFC 1034:
|
||||
Relative names are either taken relative to a well known origin, or to a
|
||||
list of domains used as a search list. Relative names appear mostly at
|
||||
the user interface, where their interpretation varies from
|
||||
implementation to implementation, and in master files, where they are
|
||||
relative to a single origin domain name. The most common interpretation
|
||||
uses the root "." as either the single origin or as one of the members
|
||||
of the search list, so a multi-label relative name is often one where
|
||||
the trailing dot has been omitted to save typing.
|
||||
|
||||
Since a complete domain name ends with the root label, this leads to
|
||||
a printed form which ends in a dot.
|
||||
*/
|
||||
|
||||
return hostName.takeIf { hostName.length <= 255 && HOST_PATTERN.matches(hostName) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the hostname or IP. Usually used to sanitize a value input by the user.
|
||||
* It is usually applied before we know if the hostname is even valid.
|
||||
*/
|
||||
fun cleanUpHostName(hostName: String): String {
|
||||
return hostName.trim()
|
||||
}
|
||||
|
||||
private const val LDH_LABEL = "([a-z0-9]|[a-z0-9][a-z0-9\\-]{0,61}[a-z0-9])"
|
||||
private val HOST_PATTERN = """($LDH_LABEL\.)*$LDH_LABEL\.?""".toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
private val IPV4_COMPONENT_PATTERN = "(0|([1-9][0-9]{0,2}))".toRegex()
|
||||
private val IPV6_COMPONENT_PATTERN = "[0-9a-f]{1,4}".toRegex()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package net.thunderbird.core.common.net
|
||||
|
||||
/**
|
||||
* Represents a hostname, IPv4, or IPv6 address.
|
||||
*/
|
||||
@JvmInline
|
||||
value class Hostname(val value: String) {
|
||||
init {
|
||||
requireNotNull(HostNameUtils.isLegalHostNameOrIP(value)) { "Not a valid domain or IP: '$value'" }
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toHostname() = Hostname(this)
|
||||
|
||||
fun Hostname.isIpAddress(): Boolean = HostNameUtils.isLegalIPAddress(value) != null
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package net.thunderbird.core.common.net
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@JvmInline
|
||||
value class Port(val value: Int) {
|
||||
init {
|
||||
require(value in 1..65535) { "Not a valid port number: $value" }
|
||||
}
|
||||
}
|
||||
|
||||
fun Int.toPort() = Port(this)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package net.thunderbird.core.common.oauth
|
||||
|
||||
import kotlin.collections.iterator
|
||||
|
||||
internal class InMemoryOAuthConfigurationProvider(
|
||||
private val configurationFactory: OAuthConfigurationFactory,
|
||||
) : OAuthConfigurationProvider {
|
||||
|
||||
private val hostnameMapping: Map<String, OAuthConfiguration> = buildMap {
|
||||
for ((hostnames, configuration) in configurationFactory.createConfigurations()) {
|
||||
for (hostname in hostnames) {
|
||||
put(hostname.lowercase(), configuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getConfiguration(hostname: String): OAuthConfiguration? {
|
||||
return hostnameMapping[hostname.lowercase()]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package net.thunderbird.core.common.oauth
|
||||
|
||||
data class OAuthConfiguration(
|
||||
val clientId: String,
|
||||
val scopes: List<String>,
|
||||
val authorizationEndpoint: String,
|
||||
val tokenEndpoint: String,
|
||||
val redirectUri: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.thunderbird.core.common.oauth
|
||||
|
||||
fun interface OAuthConfigurationFactory {
|
||||
fun createConfigurations(): Map<List<String>, OAuthConfiguration>
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.thunderbird.core.common.oauth
|
||||
|
||||
fun interface OAuthConfigurationProvider {
|
||||
fun getConfiguration(hostname: String): OAuthConfiguration?
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package net.thunderbird.core.common.provider
|
||||
|
||||
/**
|
||||
* Provides the application name.
|
||||
*/
|
||||
interface AppNameProvider {
|
||||
val appName: String
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package net.thunderbird.core.common.provider
|
||||
|
||||
/**
|
||||
* Provides the brand name, e.g. Thunderbird.
|
||||
*/
|
||||
interface BrandNameProvider {
|
||||
val brandName: String
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
// TODO: Add support for Multiplatform resources. See https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources.html
|
||||
interface PluralsResourceManager {
|
||||
/**
|
||||
* Formats the string necessary for grammatically correct pluralization
|
||||
* of the given resource ID for the given quantity, using the given arguments.
|
||||
* Note that the string is selected based solely on grammatical necessity,
|
||||
* and that such rules differ between languages. Do not assume you know which string
|
||||
* will be returned for a given quantity. See
|
||||
* <a href="{@docRoot}guide/topics/resources/string-resource.html#Plurals">String Resources</a>
|
||||
* for more detail.
|
||||
*
|
||||
* <p>Substitution of format arguments works as if using
|
||||
* {@link java.util.Formatter} and {@link java.lang.String#format}.
|
||||
* The resulting string will be stripped of any styled text information.
|
||||
*
|
||||
* @param resourceId The desired resource identifier, as generated by the aapt tool. This integer
|
||||
* encodes the package, type, and resource entry. The value 0 is an invalid identifier.
|
||||
* @param quantity The number used to get the correct string for the current language's plural rules.
|
||||
* @param formatArgs The format arguments that will be used for substitution.
|
||||
* @throws net.thunderbird.core.common.resources.ResourceNotFoundException Throws NotFoundException if the given ID
|
||||
* does not exist.
|
||||
* @return String The string data associated with the resource,
|
||||
* stripped of styled text information.
|
||||
*/
|
||||
fun pluralsString(@PluralsRes resourceId: Int, quantity: Int, vararg formatArgs: Any?): String
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
expect annotation class StringRes()
|
||||
expect annotation class PluralsRes()
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
/**
|
||||
* Represents a comprehensive resource manager that combines string and plural resource handling.
|
||||
*
|
||||
* This interface extends both [StringsResourceManager] and [PluralsResourceManager], providing a unified
|
||||
* interface for accessing different types of string-based resources within the application.
|
||||
*
|
||||
* Implementations of this interface should provide concrete implementations for fetching both
|
||||
* simple strings and pluralized strings based on their respective resource IDs.
|
||||
*/
|
||||
interface ResourceManager : StringsResourceManager, PluralsResourceManager
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
/**
|
||||
* Exception thrown when a requested resource cannot be found.
|
||||
* This can occur, for example, when trying to access a file or data
|
||||
* that does not exist at the specified location.
|
||||
*/
|
||||
expect class ResourceNotFoundException
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
// TODO: Add support for Multiplatform resources. See https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources.html
|
||||
interface StringsResourceManager {
|
||||
/**
|
||||
* Return the string value associated with a particular resource ID. It
|
||||
* will be stripped of any styled text information.
|
||||
*
|
||||
* @param resourceId The desired resource identifier, as generated by the aapt tool.
|
||||
* This integer encodes the package, type, and resource entry. The value 0 is an
|
||||
* invalid identifier.
|
||||
* @throws net.thunderbird.core.common.resources.ResourceNotFoundException Throws NotFoundException if the given ID
|
||||
* does not exist.
|
||||
* @return String The string data associated with the resource, stripped of styled
|
||||
* text information.
|
||||
*/
|
||||
fun stringResource(@StringRes resourceId: Int): String
|
||||
|
||||
/**
|
||||
* Return the string value associated with a particular resource ID,
|
||||
* substituting the format arguments as defined in {@link java.util.Formatter}
|
||||
* and {@link java.lang.String#format}. It will be stripped of any styled text
|
||||
* information.
|
||||
*
|
||||
* @param resourceId The desired resource identifier, as generated by the aapt tool.
|
||||
* This integer encodes the package, type, and resource entry. The value 0 is an invalid
|
||||
* identifier.
|
||||
* @param formatArgs The format arguments that will be used for substitution.
|
||||
* @throws net.thunderbird.core.common.resources.ResourceNotFoundException Throws NotFoundException if the given ID
|
||||
* does not exist.
|
||||
* @return String The string data associated with the resource, stripped of styled text
|
||||
* information.
|
||||
*/
|
||||
fun stringResource(@StringRes resourceId: Int, vararg formatArgs: Any?): String
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package net.thunderbird.core.common
|
||||
|
||||
import net.thunderbird.core.common.oauth.OAuthConfigurationFactory
|
||||
import org.junit.Test
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
import org.koin.test.verify.verify
|
||||
|
||||
internal class CoreCommonModuleKtTest {
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@Test
|
||||
fun `should have a valid di module`() {
|
||||
coreCommonModule.verify(
|
||||
extraTypes = listOf(
|
||||
OAuthConfigurationFactory::class,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
85
core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/CacheTest.kt
vendored
Normal file
85
core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/CacheTest.kt
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package net.thunderbird.core.common.cache
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.ExperimentalTime
|
||||
import net.thunderbird.core.testing.TestClock
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
data class CacheTestData<KEY : Any, VALUE : Any?>(
|
||||
val name: String,
|
||||
val createCache: () -> Cache<KEY, VALUE>,
|
||||
) {
|
||||
override fun toString(): String = name
|
||||
}
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class CacheTest(data: CacheTestData<Any, Any?>) {
|
||||
|
||||
private val testSubject = data.createCache()
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{0}")
|
||||
fun data(): Collection<CacheTestData<Any, Any?>> {
|
||||
return listOf(
|
||||
CacheTestData("InMemoryCache") { InMemoryCache() },
|
||||
CacheTestData("ExpiringCache") {
|
||||
@OptIn(ExperimentalTime::class)
|
||||
ExpiringCache(TestClock(), InMemoryCache())
|
||||
},
|
||||
CacheTestData("SynchronizedCache") { SynchronizedCache(InMemoryCache()) },
|
||||
)
|
||||
}
|
||||
|
||||
const val KEY = "key"
|
||||
const val VALUE = "value"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get should return null with empty cache`() {
|
||||
assertThat(testSubject[KEY]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set should add entry with empty cache`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
assertThat(testSubject[KEY]).isEqualTo(VALUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set should overwrite entry when already present`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
testSubject[KEY] = "$VALUE changed"
|
||||
|
||||
assertThat(testSubject[KEY]).isEqualTo("$VALUE changed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasKey should answer no with empty cache`() {
|
||||
assertThat(testSubject.hasKey(KEY)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasKey should answer yes when cache has entry`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
assertThat(testSubject.hasKey(KEY)).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear should empty cache`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
testSubject.clear()
|
||||
|
||||
assertThat(testSubject[KEY]).isNull()
|
||||
}
|
||||
}
|
||||
74
core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/ExpiringCacheTest.kt
vendored
Normal file
74
core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/ExpiringCacheTest.kt
vendored
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package net.thunderbird.core.common.cache
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.ExperimentalTime
|
||||
import net.thunderbird.core.common.cache.Cache
|
||||
import net.thunderbird.core.common.cache.ExpiringCache
|
||||
import net.thunderbird.core.common.cache.InMemoryCache
|
||||
import net.thunderbird.core.testing.TestClock
|
||||
|
||||
class ExpiringCacheTest {
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private val clock = TestClock()
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private val testSubject: Cache<String, String> = ExpiringCache(clock, InMemoryCache())
|
||||
|
||||
@Test
|
||||
fun `get should return null when entry present and cache expired`() {
|
||||
testSubject[KEY] = VALUE
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION)
|
||||
|
||||
val result = testSubject[KEY]
|
||||
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set should clear cache and add new entry when cache expired`() {
|
||||
testSubject[KEY] = VALUE
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION)
|
||||
|
||||
testSubject[KEY + 1] = "$VALUE changed"
|
||||
|
||||
assertThat(testSubject[KEY]).isNull()
|
||||
assertThat(testSubject[KEY + 1]).isEqualTo("$VALUE changed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasKey should answer no when cache has entry and validity expired`() {
|
||||
testSubject[KEY] = VALUE
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION)
|
||||
|
||||
assertThat(testSubject.hasKey(KEY)).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should keep cache when time progresses within expiration`() {
|
||||
testSubject[KEY] = VALUE
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION.minus(1L.milliseconds))
|
||||
|
||||
assertThat(testSubject[KEY]).isEqualTo(VALUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should empty cache after time progresses to expiration`() {
|
||||
testSubject[KEY] = VALUE
|
||||
|
||||
clock.advanceTimeBy(CACHE_TIME_VALIDITY_DURATION)
|
||||
|
||||
assertThat(testSubject[KEY]).isNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY = "key"
|
||||
const val VALUE = "value"
|
||||
val CACHE_TIME_VALIDITY_DURATION = 30_000L.milliseconds
|
||||
}
|
||||
}
|
||||
97
core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/TimeLimitedCacheTest.kt
vendored
Normal file
97
core/common/src/commonTest/kotlin/net/thunderbird/core/common/cache/TimeLimitedCacheTest.kt
vendored
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package net.thunderbird.core.common.cache
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isFalse
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import kotlin.test.Test
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.ExperimentalTime
|
||||
import net.thunderbird.core.testing.TestClock
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
class TimeLimitedCacheTest {
|
||||
|
||||
private val clock = TestClock()
|
||||
private val cache = TimeLimitedCache<String, String>(clock = clock)
|
||||
|
||||
@Test
|
||||
fun `getValue should return null when entry present and expired`() {
|
||||
// Arrange
|
||||
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
|
||||
clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds)
|
||||
|
||||
// Act
|
||||
val result = cache.getValue(KEY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasKey should answer false when cache has entry and validity expired`() {
|
||||
// Arrange
|
||||
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
|
||||
clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds)
|
||||
|
||||
// Act
|
||||
val result = cache.hasKey(KEY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should keep cache when time progresses within expiration`() {
|
||||
// Arrange
|
||||
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
|
||||
clock.advanceTimeBy(EXPIRES_IN - 1.milliseconds)
|
||||
|
||||
// Act
|
||||
val result = cache.getValue(KEY)
|
||||
|
||||
// Assert
|
||||
assertThat(result).isEqualTo(VALUE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearExpired should remove only expired entries`() {
|
||||
// Arrange
|
||||
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
|
||||
cache.set(KEY_2, VALUE_2, expiresIn = EXPIRES_IN * 2)
|
||||
clock.advanceTimeBy(EXPIRES_IN + 1.milliseconds)
|
||||
|
||||
// Act
|
||||
cache.clearExpired()
|
||||
|
||||
// Assert
|
||||
assertThat(cache.getValue(KEY)).isNull()
|
||||
assertThat(cache.getValue(KEY_2)).isEqualTo(VALUE_2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get should return Entry with correct metadata when not expired`() {
|
||||
// Arrange
|
||||
cache.set(KEY, VALUE, expiresIn = EXPIRES_IN)
|
||||
|
||||
// Act
|
||||
val entry = cache[KEY]
|
||||
|
||||
// Assert
|
||||
assertThat(entry).isNotNull()
|
||||
entry!!
|
||||
assertThat(entry.value).isEqualTo(VALUE)
|
||||
assertThat(entry.expiresIn).isEqualTo(EXPIRES_IN)
|
||||
assertThat(entry.expiresAt).isEqualTo(entry.creationTime + EXPIRES_IN)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY = "key"
|
||||
const val KEY_2 = "key2"
|
||||
const val VALUE = "value"
|
||||
const val VALUE_2 = "value2"
|
||||
val EXPIRES_IN: Duration = 500.milliseconds
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import assertk.all
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.prop
|
||||
import kotlin.test.Test
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.AddressLiteralsNotSupported
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.EmptyLocalPart
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.ExpectedEndOfInput
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidDomainPart
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidDotString
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidLocalPart
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.InvalidQuotedString
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.LocalPartLengthExceeded
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.LocalPartRequiresQuotedString
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.QuotedStringInLocalPart
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.TotalLengthExceeded
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.UnexpectedCharacter
|
||||
|
||||
class EmailAddressParserTest {
|
||||
@Test
|
||||
fun `simple address`() {
|
||||
val emailAddress = parseEmailAddress("alice@domain.example")
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("alice")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part containing dot`() {
|
||||
val emailAddress = parseEmailAddress("alice.lastname@domain.example")
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("alice.lastname")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted local part`() {
|
||||
val emailAddress = parseEmailAddress(
|
||||
address = "\"one two\"@domain.example",
|
||||
isLocalPartRequiringQuotedStringAllowed = true,
|
||||
)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("one two")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted local part not allowed`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(
|
||||
address = "\"one two\"@domain.example",
|
||||
isQuotedLocalPartAllowed = true,
|
||||
isLocalPartRequiringQuotedStringAllowed = false,
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(LocalPartRequiresQuotedString)
|
||||
prop(EmailAddressParserException::position).isEqualTo(0)
|
||||
hasMessage("Local part requiring the use of a quoted string is not allowed by config")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unnecessarily quoted local part`() {
|
||||
val emailAddress = parseEmailAddress(
|
||||
address = "\"user\"@domain.example",
|
||||
isQuotedLocalPartAllowed = true,
|
||||
isLocalPartRequiringQuotedStringAllowed = false,
|
||||
)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("user")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
assertThat(emailAddress.address).isEqualTo("user@domain.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unnecessarily quoted local part not allowed`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("\"user\"@domain.example", isQuotedLocalPartAllowed = false)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(QuotedStringInLocalPart)
|
||||
prop(EmailAddressParserException::position).isEqualTo(0)
|
||||
hasMessage("Quoted string in local part is not allowed by config")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted local part containing double quote character`() {
|
||||
val emailAddress = parseEmailAddress(
|
||||
address = """"a\"b"@domain.example""",
|
||||
isLocalPartRequiringQuotedStringAllowed = true,
|
||||
)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("a\"b")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
assertThat(emailAddress.address).isEqualTo(""""a\"b"@domain.example""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty local part`() {
|
||||
val emailAddress = parseEmailAddress("\"\"@domain.example", isEmptyLocalPartAllowed = true)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
assertThat(emailAddress.address).isEqualTo("\"\"@domain.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty local part not allowed`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(
|
||||
address = "\"\"@domain.example",
|
||||
isLocalPartRequiringQuotedStringAllowed = true,
|
||||
isEmptyLocalPartAllowed = false,
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(EmptyLocalPart)
|
||||
prop(EmailAddressParserException::position).isEqualTo(1)
|
||||
hasMessage("Empty local part is not allowed by config")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 address literal`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("user@[255.0.100.23]")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(AddressLiteralsNotSupported)
|
||||
prop(EmailAddressParserException::position).isEqualTo(5)
|
||||
hasMessage("Address literals are not supported")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv6 address literal`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("user@[IPv6:2001:0db8:0000:0000:0000:ff00:0042:8329]")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(AddressLiteralsNotSupported)
|
||||
prop(EmailAddressParserException::position).isEqualTo(5)
|
||||
hasMessage("Address literals are not supported")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `domain part starts with unsupported value`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("user@ä")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidDomainPart)
|
||||
prop(EmailAddressParserException::position).isEqualTo(5)
|
||||
hasMessage("Expected 'Domain' or 'address-literal'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `obsolete syntax`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("\"quoted\".atom@domain.example", isLocalPartRequiringQuotedStringAllowed = true)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
|
||||
prop(EmailAddressParserException::position).isEqualTo(8)
|
||||
hasMessage("Expected '@' (64)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part starting with dot`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(".invalid@domain.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidLocalPart)
|
||||
prop(EmailAddressParserException::position).isEqualTo(0)
|
||||
hasMessage("Expected 'Dot-string' or 'Quoted-string'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part ending with dot`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("invalid.@domain.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidDotString)
|
||||
prop(EmailAddressParserException::position).isEqualTo(8)
|
||||
hasMessage("Expected 'Dot-string'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted local part missing closing double quote`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("\"invalid@domain.example", isLocalPartRequiringQuotedStringAllowed = true)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
|
||||
prop(EmailAddressParserException::position).isEqualTo(23)
|
||||
hasMessage("Expected '\"' (34)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted text containing unsupported character`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("\"ä\"@domain.example", isLocalPartRequiringQuotedStringAllowed = true)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString)
|
||||
prop(EmailAddressParserException::position).isEqualTo(1)
|
||||
hasMessage("Expected 'Quoted-string'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted text containing unsupported escaped character`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(""""\ä"@domain.example""", isLocalPartRequiringQuotedStringAllowed = true)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString)
|
||||
prop(EmailAddressParserException::position).isEqualTo(3)
|
||||
hasMessage("Expected 'Quoted-string'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part exceeds maximum size with length check enabled`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(
|
||||
address = "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345@domain.example",
|
||||
isLocalPartLengthCheckEnabled = true,
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(LocalPartLengthExceeded)
|
||||
prop(EmailAddressParserException::position).isEqualTo(65)
|
||||
hasMessage("Local part exceeds maximum length of 64 characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part exceeds maximum size with length check disabled`() {
|
||||
val input = "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345@domain.example"
|
||||
|
||||
val emailAddress = parseEmailAddress(address = input, isLocalPartLengthCheckEnabled = false)
|
||||
|
||||
assertThat(emailAddress.localPart)
|
||||
.isEqualTo("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
assertThat(emailAddress.address).isEqualTo(input)
|
||||
assertThat(emailAddress.warnings).contains(EmailAddress.Warning.LocalPartExceedsLengthLimit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `email exceeds maximum size with length check enabled`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(
|
||||
address = "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234@" +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12",
|
||||
isEmailAddressLengthCheckEnabled = true,
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(TotalLengthExceeded)
|
||||
prop(EmailAddressParserException::position).isEqualTo(255)
|
||||
hasMessage("The email address exceeds the maximum length of 254 characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `email exceeds maximum size with length check disabled`() {
|
||||
val input = "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234@" +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12"
|
||||
|
||||
val emailAddress = parseEmailAddress(address = input, isEmailAddressLengthCheckEnabled = false)
|
||||
|
||||
assertThat(emailAddress.localPart)
|
||||
.isEqualTo("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234")
|
||||
assertThat(emailAddress.domain).isEqualTo(
|
||||
EmailDomain(
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12",
|
||||
),
|
||||
)
|
||||
assertThat(emailAddress.address).isEqualTo(input)
|
||||
assertThat(emailAddress.warnings).contains(EmailAddress.Warning.EmailAddressExceedsLengthLimit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input contains additional character`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("test@domain.example#")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput)
|
||||
prop(EmailAddressParserException::position).isEqualTo(19)
|
||||
hasMessage("Expected end of input")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEmailAddress(
|
||||
address: String,
|
||||
isLocalPartLengthCheckEnabled: Boolean = false,
|
||||
isEmailAddressLengthCheckEnabled: Boolean = false,
|
||||
isEmptyLocalPartAllowed: Boolean = false,
|
||||
isLocalPartRequiringQuotedStringAllowed: Boolean = isEmptyLocalPartAllowed,
|
||||
isQuotedLocalPartAllowed: Boolean = isLocalPartRequiringQuotedStringAllowed,
|
||||
): EmailAddress {
|
||||
val config = EmailAddressParserConfig(
|
||||
isLocalPartLengthCheckEnabled,
|
||||
isEmailAddressLengthCheckEnabled,
|
||||
isQuotedLocalPartAllowed,
|
||||
isLocalPartRequiringQuotedStringAllowed,
|
||||
isEmptyLocalPartAllowed,
|
||||
)
|
||||
return EmailAddressParser(address, config).parse()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactlyInAnyOrder
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isSameInstanceAs
|
||||
import kotlin.test.Test
|
||||
import net.thunderbird.core.common.mail.EmailAddress.Warning
|
||||
|
||||
class EmailAddressTest {
|
||||
@Test
|
||||
fun `simple email address`() {
|
||||
val domain = EmailDomain("DOMAIN.example")
|
||||
val emailAddress = EmailAddress(localPart = "user", domain = domain)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("user")
|
||||
assertThat(emailAddress.encodedLocalPart).isEqualTo("user")
|
||||
assertThat(emailAddress.domain).isSameInstanceAs(domain)
|
||||
assertThat(emailAddress.address).isEqualTo("user@DOMAIN.example")
|
||||
assertThat(emailAddress.normalizedAddress).isEqualTo("user@domain.example")
|
||||
assertThat(emailAddress.toString()).isEqualTo("user@DOMAIN.example")
|
||||
assertThat(emailAddress.warnings).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part that requires use of quoted string`() {
|
||||
val emailAddress = EmailAddress(localPart = "foo bar", domain = EmailDomain("domain.example"))
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("foo bar")
|
||||
assertThat(emailAddress.encodedLocalPart).isEqualTo("\"foo bar\"")
|
||||
assertThat(emailAddress.address).isEqualTo("\"foo bar\"@domain.example")
|
||||
assertThat(emailAddress.normalizedAddress).isEqualTo("\"foo bar\"@domain.example")
|
||||
assertThat(emailAddress.toString()).isEqualTo("\"foo bar\"@domain.example")
|
||||
assertThat(emailAddress.warnings).containsExactlyInAnyOrder(Warning.QuotedStringInLocalPart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty local part`() {
|
||||
val emailAddress = EmailAddress(localPart = "", domain = EmailDomain("domain.example"))
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("")
|
||||
assertThat(emailAddress.encodedLocalPart).isEqualTo("\"\"")
|
||||
assertThat(emailAddress.address).isEqualTo("\"\"@domain.example")
|
||||
assertThat(emailAddress.normalizedAddress).isEqualTo("\"\"@domain.example")
|
||||
assertThat(emailAddress.toString()).isEqualTo("\"\"@domain.example")
|
||||
assertThat(emailAddress.warnings).containsExactlyInAnyOrder(
|
||||
Warning.QuotedStringInLocalPart,
|
||||
Warning.EmptyLocalPart,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equals() does case-insensitive domain comparison`() {
|
||||
val emailAddress1 = EmailAddress(localPart = "user", domain = EmailDomain("domain.example"))
|
||||
val emailAddress2 = EmailAddress(localPart = "user", domain = EmailDomain("DOMAIN.example"))
|
||||
|
||||
assertThat(emailAddress2).isEqualTo(emailAddress1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import assertk.all
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.prop
|
||||
import kotlin.test.Test
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.DnsLabelLengthExceeded
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.DomainLengthExceeded
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.ExpectedEndOfInput
|
||||
import net.thunderbird.core.common.mail.EmailAddressParserError.UnexpectedCharacter
|
||||
|
||||
class EmailDomainParserTest {
|
||||
@Test
|
||||
fun `simple domain`() {
|
||||
val emailDomain = parseEmailDomain("DOMAIN.example")
|
||||
|
||||
assertThat(emailDomain.value).isEqualTo("DOMAIN.example")
|
||||
assertThat(emailDomain.normalized).isEqualTo("domain.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label starting with hyphen`() {
|
||||
assertFailure {
|
||||
parseEmailDomain("-domain.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
|
||||
prop(EmailAddressParserException::position).isEqualTo(0)
|
||||
hasMessage("Expected 'Let-dig'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label ending with hyphen`() {
|
||||
assertFailure {
|
||||
parseEmailDomain("domain-.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
|
||||
prop(EmailAddressParserException::position).isEqualTo(7)
|
||||
hasMessage("Expected 'Let-dig'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label exceeds maximum size`() {
|
||||
assertFailure {
|
||||
parseEmailDomain("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(DnsLabelLengthExceeded)
|
||||
prop(EmailAddressParserException::position).isEqualTo(64)
|
||||
hasMessage("DNS labels exceeds maximum length of 63 characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `domain exceeds maximum size`() {
|
||||
assertFailure {
|
||||
parseEmailDomain(
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12",
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(DomainLengthExceeded)
|
||||
prop(EmailAddressParserException::position).isEqualTo(254)
|
||||
hasMessage("Domain exceeds maximum length of 253 characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input contains additional character`() {
|
||||
assertFailure {
|
||||
parseEmailDomain("domain.example#")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput)
|
||||
prop(EmailAddressParserException::position).isEqualTo(14)
|
||||
hasMessage("Expected end of input")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEmailDomain(domain: String): EmailDomain {
|
||||
return EmailDomainParser(domain).parseDomain()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package net.thunderbird.core.common.mail
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import kotlin.test.Test
|
||||
|
||||
class EmailDomainTest {
|
||||
@Test
|
||||
fun `simple domain`() {
|
||||
val domain = EmailDomain("DOMAIN.example")
|
||||
|
||||
assertThat(domain.value).isEqualTo("DOMAIN.example")
|
||||
assertThat(domain.normalized).isEqualTo("domain.example")
|
||||
assertThat(domain.toString()).isEqualTo("DOMAIN.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equals() does case-insensitive comparison`() {
|
||||
val domain1 = EmailDomain("domain.example")
|
||||
val domain2 = EmailDomain("DOMAIN.example")
|
||||
|
||||
assertThat(domain2).isEqualTo(domain1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package net.thunderbird.core.common.net
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import org.junit.Test
|
||||
|
||||
class DomainTest {
|
||||
@Test
|
||||
fun `valid domain`() {
|
||||
val domain = Domain("domain.example")
|
||||
|
||||
assertThat(domain.value).isEqualTo("domain.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid domain should throw`() {
|
||||
assertFailure {
|
||||
Domain("invalid domain")
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
.hasMessage("Not a valid domain name: 'invalid domain'")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
package net.thunderbird.core.common.net
|
||||
|
||||
import assertk.Assert
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Test data copied from `mailnews/base/test/unit/test_hostnameUtils.js`
|
||||
*/
|
||||
class HostNameUtilsTest {
|
||||
@Test
|
||||
fun `valid host names`() {
|
||||
assertThat("localhost").isLegalHostName()
|
||||
assertThat("some-server").isLegalHostName()
|
||||
assertThat("server.company.invalid").isLegalHostName()
|
||||
assertThat("server.comp-any.invalid").isLegalHostName()
|
||||
assertThat("server.123.invalid").isLegalHostName()
|
||||
assertThat("1server.123.invalid").isLegalHostName()
|
||||
assertThat("1.2.3.4.5").isLegalHostName()
|
||||
assertThat("very.log.sub.domain.name.invalid").isLegalHostName()
|
||||
assertThat("1234567890").isLegalHostName()
|
||||
assertThat("1234567890.").isLegalHostName()
|
||||
assertThat("server.company.invalid.").isLegalHostName()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid host names`() {
|
||||
assertThat("").isNotLegalHostName()
|
||||
assertThat("server.badcompany!.invalid").isNotLegalHostName()
|
||||
assertThat("server._badcompany.invalid").isNotLegalHostName()
|
||||
assertThat("server.bad_company.invalid").isNotLegalHostName()
|
||||
assertThat("server.badcompany-.invalid").isNotLegalHostName()
|
||||
assertThat("server.bad company.invalid").isNotLegalHostName()
|
||||
assertThat("server.b…dcompany.invalid").isNotLegalHostName()
|
||||
assertThat(".server.badcompany.invalid").isNotLegalHostName()
|
||||
assertThat("make-this-a-long-host-name-component-that-is-over-63-characters-long.invalid").isNotLegalHostName()
|
||||
assertThat(
|
||||
"append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." +
|
||||
"append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." +
|
||||
"append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid." +
|
||||
"append-strings-to-make-this-a-too-long-host-name.that-is-really-over-255-characters-long.invalid",
|
||||
).isNotLegalHostName()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid IPv4 addresses`() {
|
||||
assertThat("1.2.3.4").isLegalIPv4Address()
|
||||
assertThat("123.245.111.222").isLegalIPv4Address()
|
||||
assertThat("255.255.255.255").isLegalIPv4Address()
|
||||
assertThat("1.2.0.4").isLegalIPv4Address()
|
||||
assertThat("1.2.3.4").isLegalIPv4Address()
|
||||
assertThat("127.1.2.3").isLegalIPv4Address()
|
||||
assertThat("10.1.2.3").isLegalIPv4Address()
|
||||
assertThat("192.168.2.3").isLegalIPv4Address()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid IPv4 addresses`() {
|
||||
assertThat("1.2.3.4.5").isNotLegalIPv4Address()
|
||||
assertThat("1.2.3").isNotLegalIPv4Address()
|
||||
assertThat("1.2.3.").isNotLegalIPv4Address()
|
||||
assertThat(".1.2.3").isNotLegalIPv4Address()
|
||||
assertThat("1.2.3.256").isNotLegalIPv4Address()
|
||||
assertThat("1.2.3.12345").isNotLegalIPv4Address()
|
||||
assertThat("1.2..123").isNotLegalIPv4Address()
|
||||
assertThat("1").isNotLegalIPv4Address()
|
||||
assertThat("").isNotLegalIPv4Address()
|
||||
assertThat("0.1.2.3").isNotLegalIPv4Address()
|
||||
assertThat("0.0.2.3").isNotLegalIPv4Address()
|
||||
assertThat("0.0.0.0").isNotLegalIPv4Address()
|
||||
assertThat("1.2.3.d").isNotLegalIPv4Address()
|
||||
assertThat("a.b.c.d").isNotLegalIPv4Address()
|
||||
assertThat("a.b.c.d").isNotLegalIPv4Address()
|
||||
|
||||
// Extended formats of IPv4, hex, octal, decimal up to DWORD
|
||||
// We intentionally don't support any of these.
|
||||
assertThat("0xff.0x12.0x45.0x78").isNotLegalIPv4Address()
|
||||
assertThat("01.0123.056.077").isNotLegalIPv4Address()
|
||||
assertThat("0xff.2.3.4").isNotLegalIPv4Address()
|
||||
assertThat("0xff.2.3.077").isNotLegalIPv4Address()
|
||||
assertThat("0x7f.2.3.077").isNotLegalIPv4Address()
|
||||
assertThat("0xZZ.1.2.3").isNotLegalIPv4Address()
|
||||
assertThat("0x00.0123.056.077").isNotLegalIPv4Address()
|
||||
assertThat("0x11.0123.056.078").isNotLegalIPv4Address()
|
||||
assertThat("0x11.0123.056.0789").isNotLegalIPv4Address()
|
||||
assertThat("1234566945").isNotLegalIPv4Address()
|
||||
assertThat("12345").isNotLegalIPv4Address()
|
||||
assertThat("123456789123456").isNotLegalIPv4Address()
|
||||
assertThat("127.1").isNotLegalIPv4Address()
|
||||
assertThat("0x7f.100").isNotLegalIPv4Address()
|
||||
assertThat("0x7f.100.1000").isNotLegalIPv4Address()
|
||||
assertThat("0xff.100.1024").isNotLegalIPv4Address()
|
||||
assertThat("0xC0.0xA8.0x2A48").isNotLegalIPv4Address()
|
||||
assertThat("0xC0.0xA82A48").isNotLegalIPv4Address()
|
||||
assertThat("0xC0A82A48").isNotLegalIPv4Address()
|
||||
assertThat("0324.062477106").isNotLegalIPv4Address()
|
||||
assertThat("0.0.1000").isNotLegalIPv4Address()
|
||||
assertThat("0324.06247710677").isNotLegalIPv4Address()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid IPv6 addresses`() {
|
||||
assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:7334").isNormalizedTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
|
||||
assertThat("2001:db8:85a3:0:0:8a2e:370:7334").isNormalizedTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
|
||||
assertThat("2001:db8:85a3::8a2e:370:7334").isNormalizedTo("2001:0db8:85a3:0000:0000:8a2e:0370:7334")
|
||||
assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:").isNormalizedTo("2001:0db8:85a3:0000:0000:8a2e:0370:0000")
|
||||
assertThat("::ffff:c000:0280").isNormalizedTo("0000:0000:0000:0000:0000:ffff:c000:0280")
|
||||
assertThat("::ffff:192.0.2.128").isNormalizedTo("0000:0000:0000:0000:0000:ffff:c000:0280")
|
||||
assertThat("2001:db8::1").isNormalizedTo("2001:0db8:0000:0000:0000:0000:0000:0001")
|
||||
assertThat("2001:DB8::1").isNormalizedTo("2001:0db8:0000:0000:0000:0000:0000:0001")
|
||||
assertThat("1:2:3:4:5:6:7:8").isNormalizedTo("0001:0002:0003:0004:0005:0006:0007:0008")
|
||||
assertThat("::1").isNormalizedTo("0000:0000:0000:0000:0000:0000:0000:0001")
|
||||
assertThat("::0000:0000:1").isNormalizedTo("0000:0000:0000:0000:0000:0000:0000:0001")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid IPv6 addresses`() {
|
||||
assertThat("::").isNotLegalIPv6Address()
|
||||
assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:73346").isNotLegalIPv6Address()
|
||||
assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:7334:1").isNotLegalIPv6Address()
|
||||
assertThat("2001:0db8:85a3:0000:0000:8a2e:0370:7334x").isNotLegalIPv6Address()
|
||||
assertThat("2001:0db8:85a3:0000:0000:8a2e:03707334").isNotLegalIPv6Address()
|
||||
assertThat("2001:0db8:85a3:0000:0000x8a2e:0370:7334").isNotLegalIPv6Address()
|
||||
assertThat("2001:0db8:85a3:0000:0000:::1").isNotLegalIPv6Address()
|
||||
assertThat("2001:0db8:85a3:0000:0000:0000:some:junk").isNotLegalIPv6Address()
|
||||
assertThat("2001:0db8:85a3:0000:0000:0000::192.0.2.359").isNotLegalIPv6Address()
|
||||
assertThat("some::junk").isNotLegalIPv6Address()
|
||||
assertThat("some_junk").isNotLegalIPv6Address()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cleanUpHostName() {
|
||||
assertThat(HostNameUtils.cleanUpHostName("imap.domain.example ")).isEqualTo("imap.domain.example")
|
||||
}
|
||||
}
|
||||
|
||||
private fun Assert<String>.isLegalHostName() = given { actual ->
|
||||
assertThat(HostNameUtils.isLegalHostName(actual)).isNotNull()
|
||||
assertThat(HostNameUtils.isLegalHostNameOrIP(actual)).isNotNull()
|
||||
}
|
||||
|
||||
private fun Assert<String>.isNotLegalHostName() = given { actual ->
|
||||
assertThat(HostNameUtils.isLegalHostName(actual)).isNull()
|
||||
}
|
||||
|
||||
private fun Assert<String>.isLegalIPv4Address() = given { actual ->
|
||||
assertThat(HostNameUtils.isLegalIPv4Address(actual)).isNotNull()
|
||||
assertThat(HostNameUtils.isLegalIPAddress(actual)).isNotNull()
|
||||
}
|
||||
|
||||
private fun Assert<String>.isNotLegalIPv4Address() = given { actual ->
|
||||
assertThat(HostNameUtils.isLegalIPv4Address(actual)).isNull()
|
||||
}
|
||||
|
||||
private fun Assert<String>.isNormalizedTo(normalized: String) = given { actual ->
|
||||
assertThat(HostNameUtils.isLegalIPv6Address(actual)).isEqualTo(normalized)
|
||||
assertThat(HostNameUtils.isLegalHostNameOrIP(actual)).isEqualTo(normalized)
|
||||
}
|
||||
|
||||
private fun Assert<String>.isNotLegalIPv6Address() = given { actual ->
|
||||
assertThat(HostNameUtils.isLegalIPv6Address(actual)).isNull()
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package net.thunderbird.core.common.net
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import org.junit.Test
|
||||
|
||||
class HostnameTest {
|
||||
@Test
|
||||
fun `valid domain`() {
|
||||
val hostname = Hostname("domain.example")
|
||||
|
||||
assertThat(hostname.value).isEqualTo("domain.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid IPv4`() {
|
||||
val hostname = Hostname("127.0.0.1")
|
||||
|
||||
assertThat(hostname.value).isEqualTo("127.0.0.1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid IPv6`() {
|
||||
val hostname = Hostname("fc00::1")
|
||||
|
||||
assertThat(hostname.value).isEqualTo("fc00::1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid domain should throw`() {
|
||||
assertFailure {
|
||||
Hostname("invalid domain")
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
.hasMessage("Not a valid domain or IP: 'invalid domain'")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid IPv6 should throw`() {
|
||||
assertFailure {
|
||||
Hostname("fc00:1")
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
.hasMessage("Not a valid domain or IP: 'fc00:1'")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package net.thunderbird.core.common.net
|
||||
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import org.junit.Test
|
||||
|
||||
class PortTest {
|
||||
@Test
|
||||
fun `valid port number`() {
|
||||
val port = Port(993)
|
||||
|
||||
assertThat(port.value).isEqualTo(993)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative port number should throw`() {
|
||||
assertFailure {
|
||||
Port(-1)
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
.hasMessage("Not a valid port number: -1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `port number exceeding valid range should throw`() {
|
||||
assertFailure {
|
||||
Port(65536)
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
.hasMessage("Not a valid port number: 65536")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
actual typealias StringRes = androidx.annotation.StringRes
|
||||
actual typealias PluralsRes = androidx.annotation.PluralsRes
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.thunderbird.core.common.resources
|
||||
|
||||
actual typealias ResourceNotFoundException = java.lang.Exception
|
||||
Loading…
Add table
Add a link
Reference in a new issue