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