Merge pull request #6938 from thundernest/email_address
Add proper email address parser
This commit is contained in:
commit
2daea4dc30
24 changed files with 1065 additions and 50 deletions
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<Warning>
|
||||
|
||||
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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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"),
|
||||
}
|
|
@ -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)
|
|
@ -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)
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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<IllegalArgumentException>()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<AutoDiscoveryRunnable> {
|
||||
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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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<T> = (
|
|||
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(
|
||||
|
|
Loading…
Reference in a new issue