Add proper email address parser

This commit is contained in:
cketti 2023-05-28 16:09:11 +02:00
parent fa487b2016
commit eb4a414c58
13 changed files with 1040 additions and 21 deletions

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

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

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