From fa487b2016c207e55cef860a4adfc2cdf6763b3a Mon Sep 17 00:00:00 2001 From: cketti Date: Sun, 28 May 2023 16:12:34 +0200 Subject: [PATCH 1/3] Always use extension function to create `EmailAddress` instance --- .../src/main/java/com/fsck/k9/helper/ContactNameProvider.kt | 4 ++-- app/core/src/main/java/com/fsck/k9/helper/MessageHelper.kt | 4 ++-- .../src/test/java/com/fsck/k9/helper/MessageHelperTest.kt | 3 ++- .../java/com/fsck/k9/notification/K9NotificationStrategy.kt | 4 ++-- .../src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt | 4 ++-- .../com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt | 4 ++-- .../main/java/com/fsck/k9/ui/messageview/MessageTopView.kt | 4 ++-- .../app/k9mail/core/android/common/contact/ContactFixture.kt | 4 ++-- 8 files changed, 16 insertions(+), 15 deletions(-) diff --git a/app/core/src/main/java/com/fsck/k9/helper/ContactNameProvider.kt b/app/core/src/main/java/com/fsck/k9/helper/ContactNameProvider.kt index 4d16061b9..5ed06cdbf 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/ContactNameProvider.kt +++ b/app/core/src/main/java/com/fsck/k9/helper/ContactNameProvider.kt @@ -1,7 +1,7 @@ package com.fsck.k9.helper import app.k9mail.core.android.common.contact.ContactRepository -import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toEmailAddress interface ContactNameProvider { fun getNameForAddress(address: String): String? @@ -11,6 +11,6 @@ class RealContactNameProvider( private val contactRepository: ContactRepository, ) : ContactNameProvider { override fun getNameForAddress(address: String): String? { - return contactRepository.getContactFor(EmailAddress(address))?.name + return contactRepository.getContactFor(address.toEmailAddress())?.name } } diff --git a/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.kt b/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.kt index f5a7a160d..2fe1f5542 100644 --- a/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.kt +++ b/app/core/src/main/java/com/fsck/k9/helper/MessageHelper.kt @@ -6,7 +6,7 @@ import android.text.SpannableStringBuilder import android.text.TextUtils import android.text.style.ForegroundColorSpan import app.k9mail.core.android.common.contact.ContactRepository -import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toEmailAddress import com.fsck.k9.CoreResourceProvider import com.fsck.k9.K9.contactNameColor import com.fsck.k9.K9.isChangeContactNameColor @@ -101,7 +101,7 @@ class MessageHelper( if (!showCorrespondentNames) { return address.address } else if (contactRepository != null) { - val name = contactRepository.getContactFor(EmailAddress(address.address))?.name + val name = contactRepository.getContactFor(address.address.toEmailAddress())?.name if (name != null) { return if (changeContactNameColor) { val coloredName = SpannableString(name) diff --git a/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.kt b/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.kt index 4f509f040..4aafc2e72 100644 --- a/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.kt +++ b/app/core/src/test/java/com/fsck/k9/helper/MessageHelperTest.kt @@ -5,6 +5,7 @@ import android.text.SpannableString import app.k9mail.core.android.common.contact.Contact import app.k9mail.core.android.common.contact.ContactRepository import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toEmailAddress import assertk.assertThat import assertk.assertions.isEqualTo import assertk.assertions.isInstanceOf @@ -142,6 +143,6 @@ class MessageHelperTest : RobolectricTest() { } private companion object { - val EMAIL_ADDRESS = EmailAddress("test@testor.com") + val EMAIL_ADDRESS = "test@testor.com".toEmailAddress() } } diff --git a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt index 242569abf..77967e14e 100644 --- a/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt +++ b/app/k9mail/src/main/java/com/fsck/k9/notification/K9NotificationStrategy.kt @@ -1,7 +1,7 @@ package com.fsck.k9.notification import app.k9mail.core.android.common.contact.ContactRepository -import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toEmailAddress import com.fsck.k9.Account import com.fsck.k9.K9 import com.fsck.k9.mail.Flag @@ -88,7 +88,7 @@ class K9NotificationStrategy( } if (account.isNotifyContactsMailOnly && - !contactRepository.hasAnyContactFor(message.from.asList().mapNotNull { EmailAddress(it.address) }) + !contactRepository.hasAnyContactFor(message.from.asList().mapNotNull { it.address.toEmailAddress() }) ) { Timber.v("No notification: Message is not from a known contact") return false diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt b/app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt index 29b88aeed..ac7012219 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/contacts/ContactPhotoLoader.kt @@ -4,7 +4,7 @@ import android.content.ContentResolver import android.graphics.Bitmap import android.graphics.BitmapFactory import app.k9mail.core.android.common.contact.ContactRepository -import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toEmailAddress import timber.log.Timber internal class ContactPhotoLoader( @@ -12,7 +12,7 @@ internal class ContactPhotoLoader( private val contactRepository: ContactRepository, ) { fun loadContactPhoto(emailAddress: String): Bitmap? { - val photoUri = contactRepository.getContactFor(EmailAddress(emailAddress))?.photoUri ?: return null + val photoUri = contactRepository.getContactFor(emailAddress.toEmailAddress())?.photoUri ?: return null return try { contentResolver.openInputStream(photoUri).use { inputStream -> BitmapFactory.decodeStream(inputStream) diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt index 9b3b5faa4..060a2a5dd 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messagedetails/MessageDetailsViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.viewModelScope import app.k9mail.core.android.common.contact.CachingRepository import app.k9mail.core.android.common.contact.ContactPermissionResolver import app.k9mail.core.android.common.contact.ContactRepository -import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toEmailAddress import com.fsck.k9.Account import com.fsck.k9.controller.MessageReference import com.fsck.k9.helper.ClipboardManager @@ -130,7 +130,7 @@ internal class MessageDetailsViewModel( Participant( displayName = displayName, emailAddress = emailAddress, - contactLookupUri = contactRepository.getContactFor(EmailAddress(emailAddress))?.uri, + contactLookupUri = contactRepository.getContactFor(emailAddress.toEmailAddress())?.uri, ) } } diff --git a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt index 35352bfa2..85d771323 100644 --- a/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt +++ b/app/ui/legacy/src/main/java/com/fsck/k9/ui/messageview/MessageTopView.kt @@ -18,7 +18,7 @@ import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView import app.k9mail.core.android.common.contact.ContactRepository -import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toEmailAddress import com.fsck.k9.Account import com.fsck.k9.Account.ShowPictures import com.fsck.k9.mail.Message @@ -262,7 +262,7 @@ class MessageTopView( return false } val senderEmailAddress = getSenderEmailAddress(message) ?: return false - return contactRepository.hasContactFor(EmailAddress(senderEmailAddress)) + return contactRepository.hasContactFor(senderEmailAddress.toEmailAddress()) } private fun getSenderEmailAddress(message: Message): String? { diff --git a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt index 600ca3168..89d71831d 100644 --- a/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt +++ b/core/android/common/src/test/kotlin/app/k9mail/core/android/common/contact/ContactFixture.kt @@ -1,12 +1,12 @@ package app.k9mail.core.android.common.contact import android.net.Uri -import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toEmailAddress const val CONTACT_ID = 123L const val CONTACT_NAME = "user name" const val CONTACT_LOOKUP_KEY = "0r1-4F314D4F2F294F29" -val CONTACT_EMAIL_ADDRESS = EmailAddress("user@example.com") +val CONTACT_EMAIL_ADDRESS = "user@example.com".toEmailAddress() val CONTACT_URI: Uri = Uri.parse("content://com.android.contacts/contacts/lookup/$CONTACT_LOOKUP_KEY/$CONTACT_ID") val CONTACT_PHOTO_URI: Uri = Uri.parse("content://com.android.contacts/display_photo/$CONTACT_ID") From eb4a414c581ea41bc9b1cef87745f296459cfff3 Mon Sep 17 00:00:00 2001 From: cketti Date: Sun, 28 May 2023 16:09:11 +0200 Subject: [PATCH 2/3] Add proper email address parser --- .../k9mail/core/common/mail/AbstractParser.kt | 81 ++++++ .../k9mail/core/common/mail/EmailAddress.kt | 88 +++++- .../core/common/mail/EmailAddressParser.kt | 167 +++++++++++ .../common/mail/EmailAddressParserConfig.kt | 41 +++ .../common/mail/EmailAddressParserError.kt | 22 ++ .../mail/EmailAddressParserException.kt | 8 + .../k9mail/core/common/mail/EmailDomain.kt | 51 ++++ .../core/common/mail/EmailDomainParser.kt | 92 ++++++ .../app/k9mail/core/common/mail/Tokens.kt | 66 +++++ .../common/mail/EmailAddressParserTest.kt | 271 ++++++++++++++++++ .../core/common/mail/EmailAddressTest.kt | 62 ++-- .../core/common/mail/EmailDomainParserTest.kt | 88 ++++++ .../core/common/mail/EmailDomainTest.kt | 24 ++ 13 files changed, 1040 insertions(+), 21 deletions(-) create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/AbstractParser.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParser.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserConfig.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserError.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserException.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailDomain.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailDomainParser.kt create mode 100644 core/common/src/main/kotlin/app/k9mail/core/common/mail/Tokens.kt create mode 100644 core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressParserTest.kt create mode 100644 core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailDomainParserTest.kt create mode 100644 core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailDomainTest.kt diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/AbstractParser.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/AbstractParser.kt new file mode 100644 index 000000000..5d32e34bb --- /dev/null +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/AbstractParser.kt @@ -0,0 +1,81 @@ +package app.k9mail.core.common.mail + +import app.k9mail.core.common.mail.EmailAddressParserError.UnexpectedCharacter +import app.k9mail.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

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) + } +} diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddress.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddress.kt index 04d8e5869..c164c02bc 100644 --- a/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddress.kt +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddress.kt @@ -1,10 +1,90 @@ package app.k9mail.core.common.mail -@JvmInline -value class EmailAddress(val address: String) { +/** + * 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 + init { - require(address.isNotBlank()) { "Email address must not be blank" } + warnings = buildSet { + 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 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.DEFAULT): EmailAddress { + return EmailAddressParser(address, config).parse() + } } } -fun String.toEmailAddress() = EmailAddress(this) +fun String.toEmailAddress() = EmailAddress.parse(this) diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParser.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParser.kt new file mode 100644 index 000000000..8c0ed4946 --- /dev/null +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParser.kt @@ -0,0 +1,167 @@ +package app.k9mail.core.common.mail + +import app.k9mail.core.common.mail.EmailAddress.Warning +import app.k9mail.core.common.mail.EmailAddressParserError.AddressLiteralsNotSupported +import app.k9mail.core.common.mail.EmailAddressParserError.EmptyLocalPart +import app.k9mail.core.common.mail.EmailAddressParserError.ExpectedEndOfInput +import app.k9mail.core.common.mail.EmailAddressParserError.InvalidDomainPart +import app.k9mail.core.common.mail.EmailAddressParserError.InvalidDotString +import app.k9mail.core.common.mail.EmailAddressParserError.InvalidLocalPart +import app.k9mail.core.common.mail.EmailAddressParserError.InvalidQuotedString +import app.k9mail.core.common.mail.EmailAddressParserError.LocalPartLengthExceeded +import app.k9mail.core.common.mail.EmailAddressParserError.LocalPartRequiresQuotedString +import app.k9mail.core.common.mail.EmailAddressParserError.QuotedStringInLocalPart +import app.k9mail.core.common.mail.EmailAddressParserError.TotalLengthExceeded + +// 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 + +/** + * 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 (emailAddress.address.length > MAXIMUM_EMAIL_ADDRESS_LENGTH) { + parserError(TotalLengthExceeded) + } + + if (!config.allowLocalPartRequiringQuotedString && Warning.QuotedStringInLocalPart in emailAddress.warnings) { + parserError(LocalPartRequiresQuotedString, position = 0) + } + + if (!config.allowEmptyLocalPart && 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.allowQuotedLocalPart) { + readQuotedString() + } else { + parserError(QuotedStringInLocalPart) + } + } + else -> { + parserError(InvalidLocalPart) + } + } + + if (localPart.length > MAXIMUM_LOCAL_PART_LENGTH) { + parserError(LocalPartLengthExceeded) + } + + 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() + } + } +} diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserConfig.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserConfig.kt new file mode 100644 index 000000000..2a19dfbba --- /dev/null +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserConfig.kt @@ -0,0 +1,41 @@ +package app.k9mail.core.common.mail + +/** + * Configuration to control the behavior when parsing an email address into [EmailAddress]. + * + * @param allowQuotedLocalPart 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 allowLocalPartRequiringQuotedString Email addresses whose local part requires the use of a quoted string are + * only allowed when this is `true`. This is separate from [allowQuotedLocalPart] because one might want to allow email + * addresses that unnecessarily use a quoted string, e.g. `"test"@domain.example` ([allowQuotedLocalPart] = `true`, + * [allowLocalPartRequiringQuotedString] = `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 [allowQuotedLocalPart] is `false`. + * + * @param allowEmptyLocalPart 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 [allowQuotedLocalPart] and + * [allowLocalPartRequiringQuotedString] is `false`. + */ +data class EmailAddressParserConfig( + val allowQuotedLocalPart: Boolean, + val allowLocalPartRequiringQuotedString: Boolean, + val allowEmptyLocalPart: Boolean = false, +) { + companion object { + /** + * This configuration should match what `EmailAddressValidator` currently allows. + */ + val DEFAULT = EmailAddressParserConfig( + allowQuotedLocalPart = true, + allowLocalPartRequiringQuotedString = true, + allowEmptyLocalPart = false, + ) + } +} diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserError.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserError.kt new file mode 100644 index 000000000..61933cff0 --- /dev/null +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserError.kt @@ -0,0 +1,22 @@ +package app.k9mail.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"), +} diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserException.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserException.kt new file mode 100644 index 000000000..074b87c98 --- /dev/null +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailAddressParserException.kt @@ -0,0 +1,8 @@ +package app.k9mail.core.common.mail + +class EmailAddressParserException internal constructor( + message: String, + val error: EmailAddressParserError, + val input: String, + val position: Int, +) : RuntimeException(message) diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailDomain.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailDomain.kt new file mode 100644 index 000000000..9c8800821 --- /dev/null +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailDomain.kt @@ -0,0 +1,51 @@ +package app.k9mail.core.common.mail + +import app.k9mail.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) diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailDomainParser.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailDomainParser.kt new file mode 100644 index 000000000..d7a9badf4 --- /dev/null +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/EmailDomainParser.kt @@ -0,0 +1,92 @@ +package app.k9mail.core.common.mail + +import app.k9mail.core.common.mail.EmailAddressParserError.DnsLabelLengthExceeded +import app.k9mail.core.common.mail.EmailAddressParserError.DomainLengthExceeded +import app.k9mail.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 } + } +} diff --git a/core/common/src/main/kotlin/app/k9mail/core/common/mail/Tokens.kt b/core/common/src/main/kotlin/app/k9mail/core/common/mail/Tokens.kt new file mode 100644 index 000000000..079556642 --- /dev/null +++ b/core/common/src/main/kotlin/app/k9mail/core/common/mail/Tokens.kt @@ -0,0 +1,66 @@ +@file:Suppress("MagicNumber") + +package app.k9mail.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 diff --git a/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressParserTest.kt b/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressParserTest.kt new file mode 100644 index 000000000..f506ad823 --- /dev/null +++ b/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressParserTest.kt @@ -0,0 +1,271 @@ +package app.k9mail.core.common.mail + +import app.k9mail.core.common.mail.EmailAddressParserError.AddressLiteralsNotSupported +import app.k9mail.core.common.mail.EmailAddressParserError.EmptyLocalPart +import app.k9mail.core.common.mail.EmailAddressParserError.ExpectedEndOfInput +import app.k9mail.core.common.mail.EmailAddressParserError.InvalidDomainPart +import app.k9mail.core.common.mail.EmailAddressParserError.InvalidDotString +import app.k9mail.core.common.mail.EmailAddressParserError.InvalidLocalPart +import app.k9mail.core.common.mail.EmailAddressParserError.InvalidQuotedString +import app.k9mail.core.common.mail.EmailAddressParserError.LocalPartLengthExceeded +import app.k9mail.core.common.mail.EmailAddressParserError.LocalPartRequiresQuotedString +import app.k9mail.core.common.mail.EmailAddressParserError.QuotedStringInLocalPart +import app.k9mail.core.common.mail.EmailAddressParserError.TotalLengthExceeded +import app.k9mail.core.common.mail.EmailAddressParserError.UnexpectedCharacter +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 + +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("\"one two\"@domain.example", allowLocalPartRequiringQuotedString = 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", + allowQuotedLocalPart = true, + allowLocalPartRequiringQuotedString = false, + ) + }.isInstanceOf().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", + allowQuotedLocalPart = true, + allowLocalPartRequiringQuotedString = 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", allowQuotedLocalPart = false) + }.isInstanceOf().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(""""a\"b"@domain.example""", allowLocalPartRequiringQuotedString = 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", allowEmptyLocalPart = 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", + allowLocalPartRequiringQuotedString = true, + allowEmptyLocalPart = false, + ) + }.isInstanceOf().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().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().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().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", allowLocalPartRequiringQuotedString = true) + }.isInstanceOf().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().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().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", allowLocalPartRequiringQuotedString = true) + }.isInstanceOf().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", allowLocalPartRequiringQuotedString = true) + }.isInstanceOf().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""", allowLocalPartRequiringQuotedString = true) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString) + prop(EmailAddressParserException::position).isEqualTo(3) + hasMessage("Expected 'Quoted-string'") + } + } + + @Test + fun `local part exceeds maximum size`() { + assertFailure { + parseEmailAddress("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345@domain.example") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(LocalPartLengthExceeded) + prop(EmailAddressParserException::position).isEqualTo(65) + hasMessage("Local part exceeds maximum length of 64 characters") + } + } + + @Test + fun `email exceeds maximum size`() { + assertFailure { + parseEmailAddress( + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234@" + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." + + "1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12", + ) + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(TotalLengthExceeded) + prop(EmailAddressParserException::position).isEqualTo(255) + hasMessage("The email address exceeds the maximum length of 254 characters") + } + } + + @Test + fun `input contains additional character`() { + assertFailure { + parseEmailAddress("test@domain.example#") + }.isInstanceOf().all { + prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput) + prop(EmailAddressParserException::position).isEqualTo(19) + hasMessage("Expected end of input") + } + } + + private fun parseEmailAddress( + address: String, + allowEmptyLocalPart: Boolean = false, + allowLocalPartRequiringQuotedString: Boolean = allowEmptyLocalPart, + allowQuotedLocalPart: Boolean = allowLocalPartRequiringQuotedString, + ): EmailAddress { + val config = EmailAddressParserConfig( + allowQuotedLocalPart, + allowLocalPartRequiringQuotedString, + allowEmptyLocalPart, + ) + return EmailAddressParser(address, config).parse() + } +} diff --git a/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressTest.kt b/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressTest.kt index 7d2382efe..4567aba0e 100644 --- a/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressTest.kt +++ b/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailAddressTest.kt @@ -1,32 +1,60 @@ package app.k9mail.core.common.mail -import assertk.assertFailure +import app.k9mail.core.common.mail.EmailAddress.Warning import assertk.assertThat -import assertk.assertions.hasMessage +import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo -import assertk.assertions.isInstanceOf +import assertk.assertions.isSameAs import kotlin.test.Test -internal class EmailAddressTest { - +class EmailAddressTest { @Test - fun `should reject blank email address`() { - assertFailure { - EmailAddress("") - }.isInstanceOf() - .hasMessage("Email address must not be blank") + 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).isSameAs(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 `should return email address`() { - val emailAddress = EmailAddress(EMAIL_ADDRESS) + fun `local part that requires use of quoted string`() { + val emailAddress = EmailAddress(localPart = "foo bar", domain = EmailDomain("domain.example")) - val address = emailAddress.address - - assertThat(address).isEqualTo(EMAIL_ADDRESS) + 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) } - private companion object { - private const val EMAIL_ADDRESS = "email@example.com" + @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) } } diff --git a/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailDomainParserTest.kt b/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailDomainParserTest.kt new file mode 100644 index 000000000..c157dc4b2 --- /dev/null +++ b/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailDomainParserTest.kt @@ -0,0 +1,88 @@ +package app.k9mail.core.common.mail + +import app.k9mail.core.common.mail.EmailAddressParserError.DnsLabelLengthExceeded +import app.k9mail.core.common.mail.EmailAddressParserError.DomainLengthExceeded +import app.k9mail.core.common.mail.EmailAddressParserError.ExpectedEndOfInput +import app.k9mail.core.common.mail.EmailAddressParserError.UnexpectedCharacter +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 + +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().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().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().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().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().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() + } +} diff --git a/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailDomainTest.kt b/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailDomainTest.kt new file mode 100644 index 000000000..f3abd1f59 --- /dev/null +++ b/core/common/src/test/kotlin/app/k9mail/core/common/mail/EmailDomainTest.kt @@ -0,0 +1,24 @@ +package app.k9mail.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) + } +} From 27ba2b5d8d0d64b6e4046c1c805b1401dad6f38d Mon Sep 17 00:00:00 2001 From: cketti Date: Thu, 1 Jun 2023 14:18:49 +0200 Subject: [PATCH 3/3] Remove use of `EmailHelper` from `:feature:autodiscovery:autoconfig` --- .../autodiscovery/autoconfig/AutoconfigDiscovery.kt | 6 ++---- .../autoconfig/MxLookupAutoconfigDiscovery.kt | 6 ++---- .../autodiscovery/autoconfig/RealAutoconfigParser.kt | 11 +++++------ 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscovery.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscovery.kt index 7fd7141f8..c2aae2585 100644 --- a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscovery.kt +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/AutoconfigDiscovery.kt @@ -4,8 +4,8 @@ import app.k9mail.autodiscovery.api.AutoDiscovery import app.k9mail.autodiscovery.api.AutoDiscoveryResult import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toDomain import app.k9mail.core.common.net.toDomain -import com.fsck.k9.helper.EmailHelper import com.fsck.k9.logging.Timber import java.io.IOException import okhttp3.HttpUrl @@ -18,9 +18,7 @@ class AutoconfigDiscovery internal constructor( ) : AutoDiscovery { override fun initDiscovery(email: EmailAddress): List { - val domain = requireNotNull(EmailHelper.getDomainFromEmailAddress(email.address)?.toDomain()) { - "Couldn't extract domain from email address: $email" - } + val domain = email.domain.toDomain() val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email) diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscovery.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscovery.kt index 9a163f021..4f6491623 100644 --- a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscovery.kt +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/MxLookupAutoconfigDiscovery.kt @@ -4,9 +4,9 @@ import app.k9mail.autodiscovery.api.AutoDiscovery import app.k9mail.autodiscovery.api.AutoDiscoveryResult import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable import app.k9mail.core.common.mail.EmailAddress +import app.k9mail.core.common.mail.toDomain import app.k9mail.core.common.net.Domain import app.k9mail.core.common.net.toDomain -import com.fsck.k9.helper.EmailHelper import com.fsck.k9.logging.Timber import java.io.IOException import okhttp3.HttpUrl @@ -31,9 +31,7 @@ class MxLookupAutoconfigDiscovery internal constructor( @Suppress("ReturnCount") private suspend fun mxLookupAutoconfig(email: EmailAddress): AutoDiscoveryResult? { - val domain = requireNotNull(EmailHelper.getDomainFromEmailAddress(email.address)?.toDomain()) { - "Couldn't extract domain from email address: ${email.address}" - } + val domain = email.domain.toDomain() val mxHostName = mxLookup(domain) ?: return null diff --git a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParser.kt b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParser.kt index 306d7f75a..aade825e8 100644 --- a/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParser.kt +++ b/feature/autodiscovery/autoconfig/src/main/kotlin/app/k9mail/autodiscovery/autoconfig/RealAutoconfigParser.kt @@ -18,7 +18,6 @@ import app.k9mail.core.common.net.Hostname import app.k9mail.core.common.net.Port import app.k9mail.core.common.net.toHostname import app.k9mail.core.common.net.toPort -import com.fsck.k9.helper.EmailHelper import com.fsck.k9.logging.Timber import java.io.InputStream import java.io.InputStreamReader @@ -37,7 +36,7 @@ private typealias ServerSettingsFactory = ( internal class RealAutoconfigParser : AutoconfigParser { override fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoDiscoveryResult? { return try { - ClientConfigParser(inputStream, email.address).parse() + ClientConfigParser(inputStream, email).parse() } catch (e: XmlPullParserException) { throw AutoconfigParserException("Error parsing Autoconfig XML", e) } @@ -47,10 +46,10 @@ internal class RealAutoconfigParser : AutoconfigParser { @Suppress("TooManyFunctions") private class ClientConfigParser( private val inputStream: InputStream, - private val email: String, + private val email: EmailAddress, ) { - private val localPart = requireNotNull(EmailHelper.getLocalPartFromEmailAddress(email)) { "Invalid email address" } - private val domain = requireNotNull(EmailHelper.getDomainFromEmailAddress(email)) { "Invalid email address" } + private val localPart = email.localPart + private val domain = email.domain.normalized private val pullParser: XmlPullParser = XmlPullParserFactory.newInstance().newPullParser().apply { setInput(InputStreamReader(inputStream)) @@ -288,7 +287,7 @@ private class ClientConfigParser( private fun String.replaceVariables(): String { return replace("%EMAILDOMAIN%", domain) .replace("%EMAILLOCALPART%", localPart) - .replace("%EMAILADDRESS%", email) + .replace("%EMAILADDRESS%", email.address) } private fun createImapServerSettings(