Repo created

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

View file

@ -0,0 +1,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",
),
)
}
}

View file

@ -0,0 +1,4 @@
package net.thunderbird.core.common.resources
actual typealias StringRes = androidx.annotation.StringRes
actual typealias PluralsRes = androidx.annotation.PluralsRes

View file

@ -0,0 +1,3 @@
package net.thunderbird.core.common.resources
actual typealias ResourceNotFoundException = android.content.res.Resources.NotFoundException

View file

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

View file

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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
package net.thunderbird.core.common.domain.usecase.validation
interface ValidationError

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
package net.thunderbird.core.common.mail
object Protocols {
const val IMAP = "imap"
const val POP3 = "pop3"
const val SMTP = "smtp"
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
package net.thunderbird.core.common.oauth
fun interface OAuthConfigurationFactory {
fun createConfigurations(): Map<List<String>, OAuthConfiguration>
}

View file

@ -0,0 +1,5 @@
package net.thunderbird.core.common.oauth
fun interface OAuthConfigurationProvider {
fun getConfiguration(hostname: String): OAuthConfiguration?
}

View file

@ -0,0 +1,8 @@
package net.thunderbird.core.common.provider
/**
* Provides the application name.
*/
interface AppNameProvider {
val appName: String
}

View file

@ -0,0 +1,8 @@
package net.thunderbird.core.common.provider
/**
* Provides the brand name, e.g. Thunderbird.
*/
interface BrandNameProvider {
val brandName: String
}

View file

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

View file

@ -0,0 +1,4 @@
package net.thunderbird.core.common.resources
expect annotation class StringRes()
expect annotation class PluralsRes()

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,4 @@
package net.thunderbird.core.common.resources
actual typealias StringRes = androidx.annotation.StringRes
actual typealias PluralsRes = androidx.annotation.PluralsRes

View file

@ -0,0 +1,3 @@
package net.thunderbird.core.common.resources
actual typealias ResourceNotFoundException = java.lang.Exception