Add proper email address parser
This commit is contained in:
parent
fa487b2016
commit
eb4a414c58
13 changed files with 1040 additions and 21 deletions
|
@ -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
|
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)
|
||||||
|
|
|
@ -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
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue