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