Merge pull request #6938 from thundernest/email_address

Add proper email address parser
This commit is contained in:
cketti 2023-06-01 14:39:12 +02:00 committed by GitHub
commit 2daea4dc30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 1065 additions and 50 deletions

View file

@ -1,7 +1,7 @@
package com.fsck.k9.helper package com.fsck.k9.helper
import app.k9mail.core.android.common.contact.ContactRepository import app.k9mail.core.android.common.contact.ContactRepository
import app.k9mail.core.common.mail.EmailAddress import app.k9mail.core.common.mail.toEmailAddress
interface ContactNameProvider { interface ContactNameProvider {
fun getNameForAddress(address: String): String? fun getNameForAddress(address: String): String?
@ -11,6 +11,6 @@ class RealContactNameProvider(
private val contactRepository: ContactRepository, private val contactRepository: ContactRepository,
) : ContactNameProvider { ) : ContactNameProvider {
override fun getNameForAddress(address: String): String? { override fun getNameForAddress(address: String): String? {
return contactRepository.getContactFor(EmailAddress(address))?.name return contactRepository.getContactFor(address.toEmailAddress())?.name
} }
} }

View file

@ -6,7 +6,7 @@ import android.text.SpannableStringBuilder
import android.text.TextUtils import android.text.TextUtils
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import app.k9mail.core.android.common.contact.ContactRepository 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.CoreResourceProvider
import com.fsck.k9.K9.contactNameColor import com.fsck.k9.K9.contactNameColor
import com.fsck.k9.K9.isChangeContactNameColor import com.fsck.k9.K9.isChangeContactNameColor
@ -101,7 +101,7 @@ class MessageHelper(
if (!showCorrespondentNames) { if (!showCorrespondentNames) {
return address.address return address.address
} else if (contactRepository != null) { } else if (contactRepository != null) {
val name = contactRepository.getContactFor(EmailAddress(address.address))?.name val name = contactRepository.getContactFor(address.address.toEmailAddress())?.name
if (name != null) { if (name != null) {
return if (changeContactNameColor) { return if (changeContactNameColor) {
val coloredName = SpannableString(name) val coloredName = SpannableString(name)

View file

@ -5,6 +5,7 @@ import android.text.SpannableString
import app.k9mail.core.android.common.contact.Contact import app.k9mail.core.android.common.contact.Contact
import app.k9mail.core.android.common.contact.ContactRepository import app.k9mail.core.android.common.contact.ContactRepository
import app.k9mail.core.common.mail.EmailAddress import app.k9mail.core.common.mail.EmailAddress
import app.k9mail.core.common.mail.toEmailAddress
import assertk.assertThat import assertk.assertThat
import assertk.assertions.isEqualTo import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf import assertk.assertions.isInstanceOf
@ -142,6 +143,6 @@ class MessageHelperTest : RobolectricTest() {
} }
private companion object { private companion object {
val EMAIL_ADDRESS = EmailAddress("test@testor.com") val EMAIL_ADDRESS = "test@testor.com".toEmailAddress()
} }
} }

View file

@ -1,7 +1,7 @@
package com.fsck.k9.notification package com.fsck.k9.notification
import app.k9mail.core.android.common.contact.ContactRepository 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
import com.fsck.k9.K9 import com.fsck.k9.K9
import com.fsck.k9.mail.Flag import com.fsck.k9.mail.Flag
@ -88,7 +88,7 @@ class K9NotificationStrategy(
} }
if (account.isNotifyContactsMailOnly && 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") Timber.v("No notification: Message is not from a known contact")
return false return false

View file

@ -4,7 +4,7 @@ import android.content.ContentResolver
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import app.k9mail.core.android.common.contact.ContactRepository 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 import timber.log.Timber
internal class ContactPhotoLoader( internal class ContactPhotoLoader(
@ -12,7 +12,7 @@ internal class ContactPhotoLoader(
private val contactRepository: ContactRepository, private val contactRepository: ContactRepository,
) { ) {
fun loadContactPhoto(emailAddress: String): Bitmap? { 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 { return try {
contentResolver.openInputStream(photoUri).use { inputStream -> contentResolver.openInputStream(photoUri).use { inputStream ->
BitmapFactory.decodeStream(inputStream) BitmapFactory.decodeStream(inputStream)

View file

@ -7,7 +7,7 @@ import androidx.lifecycle.viewModelScope
import app.k9mail.core.android.common.contact.CachingRepository import app.k9mail.core.android.common.contact.CachingRepository
import app.k9mail.core.android.common.contact.ContactPermissionResolver import app.k9mail.core.android.common.contact.ContactPermissionResolver
import app.k9mail.core.android.common.contact.ContactRepository 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
import com.fsck.k9.controller.MessageReference import com.fsck.k9.controller.MessageReference
import com.fsck.k9.helper.ClipboardManager import com.fsck.k9.helper.ClipboardManager
@ -130,7 +130,7 @@ internal class MessageDetailsViewModel(
Participant( Participant(
displayName = displayName, displayName = displayName,
emailAddress = emailAddress, emailAddress = emailAddress,
contactLookupUri = contactRepository.getContactFor(EmailAddress(emailAddress))?.uri, contactLookupUri = contactRepository.getContactFor(emailAddress.toEmailAddress())?.uri,
) )
} }
} }

View file

@ -18,7 +18,7 @@ import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import app.k9mail.core.android.common.contact.ContactRepository 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
import com.fsck.k9.Account.ShowPictures import com.fsck.k9.Account.ShowPictures
import com.fsck.k9.mail.Message import com.fsck.k9.mail.Message
@ -262,7 +262,7 @@ class MessageTopView(
return false return false
} }
val senderEmailAddress = getSenderEmailAddress(message) ?: return false val senderEmailAddress = getSenderEmailAddress(message) ?: return false
return contactRepository.hasContactFor(EmailAddress(senderEmailAddress)) return contactRepository.hasContactFor(senderEmailAddress.toEmailAddress())
} }
private fun getSenderEmailAddress(message: Message): String? { private fun getSenderEmailAddress(message: Message): String? {

View file

@ -1,12 +1,12 @@
package app.k9mail.core.android.common.contact package app.k9mail.core.android.common.contact
import android.net.Uri 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_ID = 123L
const val CONTACT_NAME = "user name" const val CONTACT_NAME = "user name"
const val CONTACT_LOOKUP_KEY = "0r1-4F314D4F2F294F29" 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_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") val CONTACT_PHOTO_URI: Uri = Uri.parse("content://com.android.contacts/display_photo/$CONTACT_ID")

View file

@ -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 <P : AbstractParser, T> withParser(parser: P, block: P.() -> T): T {
try {
return block(parser)
} finally {
currentIndex = parser.position
}
}
@Suppress("NOTHING_TO_INLINE")
protected inline fun parserError(
error: EmailAddressParserError,
position: Int = currentIndex,
message: String = error.message,
): Nothing {
throw EmailAddressParserException(message, error, input, position)
}
}

View file

@ -1,10 +1,90 @@
package app.k9mail.core.common.mail 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<Warning>
init { 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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(LocalPartRequiresQuotedString)
prop(EmailAddressParserException::position).isEqualTo(0)
hasMessage("Local part requiring the use of a quoted string is not allowed by config")
}
}
@Test
fun `unnecessarily quoted local part`() {
val emailAddress = parseEmailAddress(
address = "\"user\"@domain.example",
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<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(QuotedStringInLocalPart)
prop(EmailAddressParserException::position).isEqualTo(0)
hasMessage("Quoted string in local part is not allowed by config")
}
}
@Test
fun `quoted local part containing double quote character`() {
val emailAddress = parseEmailAddress(""""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<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(EmptyLocalPart)
prop(EmailAddressParserException::position).isEqualTo(1)
hasMessage("Empty local part is not allowed by config")
}
}
@Test
fun `IPv4 address literal`() {
assertFailure {
parseEmailAddress("user@[255.0.100.23]")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(AddressLiteralsNotSupported)
prop(EmailAddressParserException::position).isEqualTo(5)
hasMessage("Address literals are not supported")
}
}
@Test
fun `IPv6 address literal`() {
assertFailure {
parseEmailAddress("user@[IPv6:2001:0db8:0000:0000:0000:ff00:0042:8329]")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(AddressLiteralsNotSupported)
prop(EmailAddressParserException::position).isEqualTo(5)
hasMessage("Address literals are not supported")
}
}
@Test
fun `domain part starts with unsupported value`() {
assertFailure {
parseEmailAddress("user@ä")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(InvalidDomainPart)
prop(EmailAddressParserException::position).isEqualTo(5)
hasMessage("Expected 'Domain' or 'address-literal'")
}
}
@Test
fun `obsolete syntax`() {
assertFailure {
parseEmailAddress("\"quoted\".atom@domain.example", allowLocalPartRequiringQuotedString = true)
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
prop(EmailAddressParserException::position).isEqualTo(8)
hasMessage("Expected '@' (64)")
}
}
@Test
fun `local part starting with dot`() {
assertFailure {
parseEmailAddress(".invalid@domain.example")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(InvalidLocalPart)
prop(EmailAddressParserException::position).isEqualTo(0)
hasMessage("Expected 'Dot-string' or 'Quoted-string'")
}
}
@Test
fun `local part ending with dot`() {
assertFailure {
parseEmailAddress("invalid.@domain.example")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(InvalidDotString)
prop(EmailAddressParserException::position).isEqualTo(8)
hasMessage("Expected 'Dot-string'")
}
}
@Test
fun `quoted local part missing closing double quote`() {
assertFailure {
parseEmailAddress("\"invalid@domain.example", allowLocalPartRequiringQuotedString = true)
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
prop(EmailAddressParserException::position).isEqualTo(23)
hasMessage("Expected '\"' (34)")
}
}
@Test
fun `quoted text containing unsupported character`() {
assertFailure {
parseEmailAddress("\"ä\"@domain.example", allowLocalPartRequiringQuotedString = true)
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString)
prop(EmailAddressParserException::position).isEqualTo(1)
hasMessage("Expected 'Quoted-string'")
}
}
@Test
fun `quoted text containing unsupported escaped character`() {
assertFailure {
parseEmailAddress(""""\ä"@domain.example""", allowLocalPartRequiringQuotedString = true)
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString)
prop(EmailAddressParserException::position).isEqualTo(3)
hasMessage("Expected 'Quoted-string'")
}
}
@Test
fun `local part exceeds maximum size`() {
assertFailure {
parseEmailAddress("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345@domain.example")
}.isInstanceOf<EmailAddressParserException>().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<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(TotalLengthExceeded)
prop(EmailAddressParserException::position).isEqualTo(255)
hasMessage("The email address exceeds the maximum length of 254 characters")
}
}
@Test
fun `input contains additional character`() {
assertFailure {
parseEmailAddress("test@domain.example#")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput)
prop(EmailAddressParserException::position).isEqualTo(19)
hasMessage("Expected end of input")
}
}
private fun parseEmailAddress(
address: String,
allowEmptyLocalPart: Boolean = false,
allowLocalPartRequiringQuotedString: Boolean = allowEmptyLocalPart,
allowQuotedLocalPart: Boolean = allowLocalPartRequiringQuotedString,
): EmailAddress {
val config = EmailAddressParserConfig(
allowQuotedLocalPart,
allowLocalPartRequiringQuotedString,
allowEmptyLocalPart,
)
return EmailAddressParser(address, config).parse()
}
}

View file

@ -1,32 +1,60 @@
package app.k9mail.core.common.mail package app.k9mail.core.common.mail
import assertk.assertFailure import app.k9mail.core.common.mail.EmailAddress.Warning
import assertk.assertThat import assertk.assertThat
import assertk.assertions.hasMessage import assertk.assertions.containsExactlyInAnyOrder
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf import assertk.assertions.isSameAs
import kotlin.test.Test import kotlin.test.Test
internal class EmailAddressTest { class EmailAddressTest {
@Test @Test
fun `should reject blank email address`() { fun `simple email address`() {
assertFailure { val domain = EmailDomain("DOMAIN.example")
EmailAddress("") val emailAddress = EmailAddress(localPart = "user", domain = domain)
}.isInstanceOf<IllegalArgumentException>()
.hasMessage("Email address must not be blank") 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 @Test
fun `should return email address`() { fun `local part that requires use of quoted string`() {
val emailAddress = EmailAddress(EMAIL_ADDRESS) val emailAddress = EmailAddress(localPart = "foo bar", domain = EmailDomain("domain.example"))
val address = emailAddress.address assertThat(emailAddress.localPart).isEqualTo("foo bar")
assertThat(emailAddress.encodedLocalPart).isEqualTo("\"foo bar\"")
assertThat(address).isEqualTo(EMAIL_ADDRESS) 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 { @Test
private const val EMAIL_ADDRESS = "email@example.com" fun `empty local part`() {
val emailAddress = EmailAddress(localPart = "", domain = EmailDomain("domain.example"))
assertThat(emailAddress.localPart).isEqualTo("")
assertThat(emailAddress.encodedLocalPart).isEqualTo("\"\"")
assertThat(emailAddress.address).isEqualTo("\"\"@domain.example")
assertThat(emailAddress.normalizedAddress).isEqualTo("\"\"@domain.example")
assertThat(emailAddress.toString()).isEqualTo("\"\"@domain.example")
assertThat(emailAddress.warnings).containsExactlyInAnyOrder(
Warning.QuotedStringInLocalPart,
Warning.EmptyLocalPart,
)
}
@Test
fun `equals() does case-insensitive domain comparison`() {
val emailAddress1 = EmailAddress(localPart = "user", domain = EmailDomain("domain.example"))
val emailAddress2 = EmailAddress(localPart = "user", domain = EmailDomain("DOMAIN.example"))
assertThat(emailAddress2).isEqualTo(emailAddress1)
} }
} }

View file

@ -0,0 +1,88 @@
package 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<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
prop(EmailAddressParserException::position).isEqualTo(0)
hasMessage("Expected 'Let-dig'")
}
}
@Test
fun `label ending with hyphen`() {
assertFailure {
parseEmailDomain("domain-.example")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
prop(EmailAddressParserException::position).isEqualTo(7)
hasMessage("Expected 'Let-dig'")
}
}
@Test
fun `label exceeds maximum size`() {
assertFailure {
parseEmailDomain("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234.example")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(DnsLabelLengthExceeded)
prop(EmailAddressParserException::position).isEqualTo(64)
hasMessage("DNS labels exceeds maximum length of 63 characters")
}
}
@Test
fun `domain exceeds maximum size`() {
assertFailure {
parseEmailDomain(
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12",
)
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(DomainLengthExceeded)
prop(EmailAddressParserException::position).isEqualTo(254)
hasMessage("Domain exceeds maximum length of 253 characters")
}
}
@Test
fun `input contains additional character`() {
assertFailure {
parseEmailDomain("domain.example#")
}.isInstanceOf<EmailAddressParserException>().all {
prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput)
prop(EmailAddressParserException::position).isEqualTo(14)
hasMessage("Expected end of input")
}
}
private fun parseEmailDomain(domain: String): EmailDomain {
return EmailDomainParser(domain).parseDomain()
}
}

View file

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

View file

@ -4,8 +4,8 @@ import app.k9mail.autodiscovery.api.AutoDiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryResult import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import app.k9mail.core.common.mail.EmailAddress import app.k9mail.core.common.mail.EmailAddress
import app.k9mail.core.common.mail.toDomain
import app.k9mail.core.common.net.toDomain import app.k9mail.core.common.net.toDomain
import com.fsck.k9.helper.EmailHelper
import com.fsck.k9.logging.Timber import com.fsck.k9.logging.Timber
import java.io.IOException import java.io.IOException
import okhttp3.HttpUrl import okhttp3.HttpUrl
@ -18,9 +18,7 @@ class AutoconfigDiscovery internal constructor(
) : AutoDiscovery { ) : AutoDiscovery {
override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> { override fun initDiscovery(email: EmailAddress): List<AutoDiscoveryRunnable> {
val domain = requireNotNull(EmailHelper.getDomainFromEmailAddress(email.address)?.toDomain()) { val domain = email.domain.toDomain()
"Couldn't extract domain from email address: $email"
}
val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email) val autoconfigUrls = urlProvider.getAutoconfigUrls(domain, email)

View file

@ -4,9 +4,9 @@ import app.k9mail.autodiscovery.api.AutoDiscovery
import app.k9mail.autodiscovery.api.AutoDiscoveryResult import app.k9mail.autodiscovery.api.AutoDiscoveryResult
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
import app.k9mail.core.common.mail.EmailAddress 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.Domain
import app.k9mail.core.common.net.toDomain import app.k9mail.core.common.net.toDomain
import com.fsck.k9.helper.EmailHelper
import com.fsck.k9.logging.Timber import com.fsck.k9.logging.Timber
import java.io.IOException import java.io.IOException
import okhttp3.HttpUrl import okhttp3.HttpUrl
@ -31,9 +31,7 @@ class MxLookupAutoconfigDiscovery internal constructor(
@Suppress("ReturnCount") @Suppress("ReturnCount")
private suspend fun mxLookupAutoconfig(email: EmailAddress): AutoDiscoveryResult? { private suspend fun mxLookupAutoconfig(email: EmailAddress): AutoDiscoveryResult? {
val domain = requireNotNull(EmailHelper.getDomainFromEmailAddress(email.address)?.toDomain()) { val domain = email.domain.toDomain()
"Couldn't extract domain from email address: ${email.address}"
}
val mxHostName = mxLookup(domain) ?: return null val mxHostName = mxLookup(domain) ?: return null

View file

@ -18,7 +18,6 @@ import app.k9mail.core.common.net.Hostname
import app.k9mail.core.common.net.Port import app.k9mail.core.common.net.Port
import app.k9mail.core.common.net.toHostname import app.k9mail.core.common.net.toHostname
import app.k9mail.core.common.net.toPort import app.k9mail.core.common.net.toPort
import com.fsck.k9.helper.EmailHelper
import com.fsck.k9.logging.Timber import com.fsck.k9.logging.Timber
import java.io.InputStream import java.io.InputStream
import java.io.InputStreamReader import java.io.InputStreamReader
@ -37,7 +36,7 @@ private typealias ServerSettingsFactory<T> = (
internal class RealAutoconfigParser : AutoconfigParser { internal class RealAutoconfigParser : AutoconfigParser {
override fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoDiscoveryResult? { override fun parseSettings(inputStream: InputStream, email: EmailAddress): AutoDiscoveryResult? {
return try { return try {
ClientConfigParser(inputStream, email.address).parse() ClientConfigParser(inputStream, email).parse()
} catch (e: XmlPullParserException) { } catch (e: XmlPullParserException) {
throw AutoconfigParserException("Error parsing Autoconfig XML", e) throw AutoconfigParserException("Error parsing Autoconfig XML", e)
} }
@ -47,10 +46,10 @@ internal class RealAutoconfigParser : AutoconfigParser {
@Suppress("TooManyFunctions") @Suppress("TooManyFunctions")
private class ClientConfigParser( private class ClientConfigParser(
private val inputStream: InputStream, private val inputStream: InputStream,
private val email: String, private val email: EmailAddress,
) { ) {
private val localPart = requireNotNull(EmailHelper.getLocalPartFromEmailAddress(email)) { "Invalid email address" } private val localPart = email.localPart
private val domain = requireNotNull(EmailHelper.getDomainFromEmailAddress(email)) { "Invalid email address" } private val domain = email.domain.normalized
private val pullParser: XmlPullParser = XmlPullParserFactory.newInstance().newPullParser().apply { private val pullParser: XmlPullParser = XmlPullParserFactory.newInstance().newPullParser().apply {
setInput(InputStreamReader(inputStream)) setInput(InputStreamReader(inputStream))
@ -288,7 +287,7 @@ private class ClientConfigParser(
private fun String.replaceVariables(): String { private fun String.replaceVariables(): String {
return replace("%EMAILDOMAIN%", domain) return replace("%EMAILDOMAIN%", domain)
.replace("%EMAILLOCALPART%", localPart) .replace("%EMAILLOCALPART%", localPart)
.replace("%EMAILADDRESS%", email) .replace("%EMAILADDRESS%", email.address)
} }
private fun createImapServerSettings( private fun createImapServerSettings(