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
|
||||
|
||||
@JvmInline
|
||||
value class EmailAddress(val address: String) {
|
||||
/**
|
||||
* Represents an email address.
|
||||
*
|
||||
* This class currently doesn't support internationalized domain names (RFC 5891) or non-ASCII local parts (RFC 6532).
|
||||
*/
|
||||
class EmailAddress internal constructor(
|
||||
val localPart: String,
|
||||
val domain: EmailDomain,
|
||||
) {
|
||||
val encodedLocalPart: String = if (localPart.isDotString) localPart else quoteString(localPart)
|
||||
|
||||
val warnings: Set<Warning>
|
||||
|
||||
init {
|
||||
require(address.isNotBlank()) { "Email address must not be blank" }
|
||||
warnings = buildSet {
|
||||
if (localPart.isEmpty()) {
|
||||
add(Warning.EmptyLocalPart)
|
||||
}
|
||||
|
||||
if (!localPart.isDotString) {
|
||||
add(Warning.QuotedStringInLocalPart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val address: String
|
||||
get() = "$encodedLocalPart@$domain"
|
||||
|
||||
val normalizedAddress: String
|
||||
get() = "$encodedLocalPart@${domain.normalized}"
|
||||
|
||||
override fun toString(): String {
|
||||
return address
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
|
||||
other as EmailAddress
|
||||
|
||||
if (localPart != other.localPart) return false
|
||||
return domain == other.domain
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = localPart.hashCode()
|
||||
result = 31 * result + domain.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
private fun quoteString(input: String): String {
|
||||
return buildString {
|
||||
append(DQUOTE)
|
||||
for (character in input) {
|
||||
if (!character.isQtext) {
|
||||
append(BACKSLASH)
|
||||
}
|
||||
append(character)
|
||||
}
|
||||
append(DQUOTE)
|
||||
}
|
||||
}
|
||||
|
||||
enum class Warning {
|
||||
/**
|
||||
* The local part requires using a quoted string.
|
||||
*
|
||||
* This is valid, but very uncommon. Using such a local part should be avoided whenever possible.
|
||||
*/
|
||||
QuotedStringInLocalPart,
|
||||
|
||||
/**
|
||||
* The local part is the empty string.
|
||||
*
|
||||
* Even if you want to allow quoted strings, you probably don't want to allow this.
|
||||
*/
|
||||
EmptyLocalPart,
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun parse(address: String, config: EmailAddressParserConfig = EmailAddressParserConfig.DEFAULT): EmailAddress {
|
||||
return EmailAddressParser(address, config).parse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun String.toEmailAddress() = EmailAddress(this)
|
||||
fun String.toEmailAddress() = EmailAddress.parse(this)
|
||||
|
|
|
@ -0,0 +1,167 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
import app.k9mail.core.common.mail.EmailAddress.Warning
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.AddressLiteralsNotSupported
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.EmptyLocalPart
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.ExpectedEndOfInput
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.InvalidDomainPart
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.InvalidDotString
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.InvalidLocalPart
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.InvalidQuotedString
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.LocalPartLengthExceeded
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.LocalPartRequiresQuotedString
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.QuotedStringInLocalPart
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.TotalLengthExceeded
|
||||
|
||||
// See RFC 5321, 4.5.3.1.3.
|
||||
// The maximum length of 'Path' indirectly limits the length of 'Mailbox'.
|
||||
internal const val MAXIMUM_EMAIL_ADDRESS_LENGTH = 254
|
||||
|
||||
// See RFC 5321, 4.5.3.1.1.
|
||||
internal const val MAXIMUM_LOCAL_PART_LENGTH = 64
|
||||
|
||||
/**
|
||||
* Parse an email address.
|
||||
*
|
||||
* This class currently doesn't support internationalized domain names (RFC 5891) or non-ASCII local parts (RFC 6532).
|
||||
*
|
||||
* From RFC 5321:
|
||||
* ```
|
||||
* Mailbox = Local-part "@" ( Domain / address-literal )
|
||||
*
|
||||
* Local-part = Dot-string / Quoted-string
|
||||
* Dot-string = Atom *("." Atom)
|
||||
* Quoted-string = DQUOTE *QcontentSMTP DQUOTE
|
||||
* QcontentSMTP = qtextSMTP / quoted-pairSMTP
|
||||
* qtextSMTP = %d32-33 / %d35-91 / %d93-126
|
||||
* quoted-pairSMTP = %d92 %d32-126
|
||||
*
|
||||
* Domain - see DomainParser
|
||||
* address-literal - We intentionally don't support address literals
|
||||
* ```
|
||||
*/
|
||||
internal class EmailAddressParser(
|
||||
input: String,
|
||||
private val config: EmailAddressParserConfig,
|
||||
) : AbstractParser(input) {
|
||||
|
||||
fun parse(): EmailAddress {
|
||||
val emailAddress = readEmailAddress()
|
||||
|
||||
if (!endReached()) {
|
||||
parserError(ExpectedEndOfInput)
|
||||
}
|
||||
|
||||
if (emailAddress.address.length > MAXIMUM_EMAIL_ADDRESS_LENGTH) {
|
||||
parserError(TotalLengthExceeded)
|
||||
}
|
||||
|
||||
if (!config.allowLocalPartRequiringQuotedString && Warning.QuotedStringInLocalPart in emailAddress.warnings) {
|
||||
parserError(LocalPartRequiresQuotedString, position = 0)
|
||||
}
|
||||
|
||||
if (!config.allowEmptyLocalPart && Warning.EmptyLocalPart in emailAddress.warnings) {
|
||||
parserError(EmptyLocalPart, position = 1)
|
||||
}
|
||||
|
||||
return emailAddress
|
||||
}
|
||||
|
||||
private fun readEmailAddress(): EmailAddress {
|
||||
val localPart = readLocalPart()
|
||||
|
||||
expect(AT)
|
||||
val domain = readDomainPart()
|
||||
|
||||
return EmailAddress(localPart, domain)
|
||||
}
|
||||
|
||||
private fun readLocalPart(): String {
|
||||
val character = peek()
|
||||
val localPart = when {
|
||||
character.isAtext -> {
|
||||
readDotString()
|
||||
}
|
||||
character == DQUOTE -> {
|
||||
if (config.allowQuotedLocalPart) {
|
||||
readQuotedString()
|
||||
} else {
|
||||
parserError(QuotedStringInLocalPart)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
parserError(InvalidLocalPart)
|
||||
}
|
||||
}
|
||||
|
||||
if (localPart.length > MAXIMUM_LOCAL_PART_LENGTH) {
|
||||
parserError(LocalPartLengthExceeded)
|
||||
}
|
||||
|
||||
return localPart
|
||||
}
|
||||
|
||||
private fun readDotString(): String {
|
||||
return buildString {
|
||||
appendAtom()
|
||||
|
||||
while (!endReached() && peek() == DOT) {
|
||||
expect(DOT)
|
||||
append(DOT)
|
||||
appendAtom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun StringBuilder.appendAtom() {
|
||||
val startIndex = currentIndex
|
||||
skipWhile { it.isAtext }
|
||||
|
||||
if (startIndex == currentIndex) {
|
||||
parserError(InvalidDotString)
|
||||
}
|
||||
|
||||
append(input, startIndex, currentIndex)
|
||||
}
|
||||
|
||||
private fun readQuotedString(): String {
|
||||
return buildString {
|
||||
expect(DQUOTE)
|
||||
|
||||
while (!endReached()) {
|
||||
val character = peek()
|
||||
when {
|
||||
character.isQtext -> append(read())
|
||||
character == BACKSLASH -> {
|
||||
expect(BACKSLASH)
|
||||
val escapedCharacter = read()
|
||||
if (!escapedCharacter.isQuotedChar) {
|
||||
parserError(InvalidQuotedString)
|
||||
}
|
||||
append(escapedCharacter)
|
||||
}
|
||||
|
||||
character == DQUOTE -> break
|
||||
else -> parserError(InvalidQuotedString)
|
||||
}
|
||||
}
|
||||
|
||||
expect(DQUOTE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readDomainPart(): EmailDomain {
|
||||
val character = peek()
|
||||
return when {
|
||||
character.isLetDig -> readDomain()
|
||||
character == '[' -> parserError(AddressLiteralsNotSupported)
|
||||
else -> parserError(InvalidDomainPart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readDomain(): EmailDomain {
|
||||
return withParser(EmailDomainParser(input, currentIndex)) {
|
||||
readDomain()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
/**
|
||||
* Configuration to control the behavior when parsing an email address into [EmailAddress].
|
||||
*
|
||||
* @param allowQuotedLocalPart When this is `true`, the parsing step allows email addresses with a local part encoded
|
||||
* as quoted string, e.g. `"foo bar"@domain.example`. Otherwise, the parser will throw an [EmailAddressParserException]
|
||||
* as soon as a quoted string is encountered.
|
||||
* Quoted strings in local parts are not widely used. It's recommended to disallow them whenever possible.
|
||||
*
|
||||
* @param allowLocalPartRequiringQuotedString Email addresses whose local part requires the use of a quoted string are
|
||||
* only allowed when this is `true`. This is separate from [allowQuotedLocalPart] because one might want to allow email
|
||||
* addresses that unnecessarily use a quoted string, e.g. `"test"@domain.example` ([allowQuotedLocalPart] = `true`,
|
||||
* [allowLocalPartRequiringQuotedString] = `false`; [EmailAddress] will not retain the original form and treat this
|
||||
* address exactly like `test@domain.example`). When allowing this, remember to use the value of [EmailAddress.address]
|
||||
* instead of retaining the original user input.
|
||||
*
|
||||
* The value of this property is ignored if [allowQuotedLocalPart] is `false`.
|
||||
*
|
||||
* @param allowEmptyLocalPart Email addresses with an empty local part (e.g. `""@domain.example`) are only allowed if
|
||||
* this value is `true`.
|
||||
*
|
||||
* The value of this property is ignored if at least one of [allowQuotedLocalPart] and
|
||||
* [allowLocalPartRequiringQuotedString] is `false`.
|
||||
*/
|
||||
data class EmailAddressParserConfig(
|
||||
val allowQuotedLocalPart: Boolean,
|
||||
val allowLocalPartRequiringQuotedString: Boolean,
|
||||
val allowEmptyLocalPart: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* This configuration should match what `EmailAddressValidator` currently allows.
|
||||
*/
|
||||
val DEFAULT = EmailAddressParserConfig(
|
||||
allowQuotedLocalPart = true,
|
||||
allowLocalPartRequiringQuotedString = true,
|
||||
allowEmptyLocalPart = false,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
enum class EmailAddressParserError(internal val message: String) {
|
||||
UnexpectedEndOfInput("End of input reached unexpectedly"),
|
||||
ExpectedEndOfInput("Expected end of input"),
|
||||
InvalidLocalPart("Expected 'Dot-string' or 'Quoted-string'"),
|
||||
InvalidDotString("Expected 'Dot-string'"),
|
||||
InvalidQuotedString("Expected 'Quoted-string'"),
|
||||
InvalidDomainPart("Expected 'Domain' or 'address-literal'"),
|
||||
AddressLiteralsNotSupported("Address literals are not supported"),
|
||||
|
||||
LocalPartLengthExceeded("Local part exceeds maximum length of $MAXIMUM_LOCAL_PART_LENGTH characters"),
|
||||
DnsLabelLengthExceeded("DNS labels exceeds maximum length of $MAXIMUM_DNS_LABEL_LENGTH characters"),
|
||||
DomainLengthExceeded("Domain exceeds maximum length of $MAXIMUM_DOMAIN_LENGTH characters"),
|
||||
TotalLengthExceeded("The email address exceeds the maximum length of $MAXIMUM_EMAIL_ADDRESS_LENGTH characters"),
|
||||
|
||||
QuotedStringInLocalPart("Quoted string in local part is not allowed by config"),
|
||||
LocalPartRequiresQuotedString("Local part requiring the use of a quoted string is not allowed by config"),
|
||||
EmptyLocalPart("Empty local part is not allowed by config"),
|
||||
|
||||
UnexpectedCharacter("Caller needs to provide message"),
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
class EmailAddressParserException internal constructor(
|
||||
message: String,
|
||||
val error: EmailAddressParserError,
|
||||
val input: String,
|
||||
val position: Int,
|
||||
) : RuntimeException(message)
|
|
@ -0,0 +1,51 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
import app.k9mail.core.common.net.Domain
|
||||
|
||||
/**
|
||||
* The domain part of an email address.
|
||||
*
|
||||
* @param value String representation of the email domain with the original capitalization.
|
||||
*/
|
||||
class EmailDomain internal constructor(val value: String) {
|
||||
/**
|
||||
* The normalized (converted to lower case) string representation of this email domain.
|
||||
*/
|
||||
val normalized: String = value.lowercase()
|
||||
|
||||
/**
|
||||
* Returns this email domain with the original capitalization.
|
||||
*
|
||||
* @see value
|
||||
*/
|
||||
override fun toString(): String = value
|
||||
|
||||
/**
|
||||
* Compares the normalized string representations of two [EmailDomain] instances.
|
||||
*/
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
|
||||
other as EmailDomain
|
||||
|
||||
return normalized == other.normalized
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return normalized.hashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parses the string representation of an email domain.
|
||||
*
|
||||
* @throws EmailAddressParserException in case of an error.
|
||||
*/
|
||||
fun parse(domain: String): EmailDomain {
|
||||
return EmailDomainParser(domain).parseDomain()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun EmailDomain.toDomain() = Domain(value)
|
|
@ -0,0 +1,92 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.DnsLabelLengthExceeded
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.DomainLengthExceeded
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.ExpectedEndOfInput
|
||||
|
||||
// See RFC 1035, 2.3.4.
|
||||
// For the string representation used in emails (labels separated by dots, no final dot allowed), we end up with a
|
||||
// maximum of 253 characters.
|
||||
internal const val MAXIMUM_DOMAIN_LENGTH = 253
|
||||
|
||||
// See RFC 1035, 2.3.4.
|
||||
internal const val MAXIMUM_DNS_LABEL_LENGTH = 63
|
||||
|
||||
/**
|
||||
* Parser for domain names in email addresses.
|
||||
*
|
||||
* From RFC 5321:
|
||||
* ```
|
||||
* Domain = sub-domain *("." sub-domain)
|
||||
* sub-domain = Let-dig [Ldh-str]
|
||||
* Let-dig = ALPHA / DIGIT
|
||||
* Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig
|
||||
* ```
|
||||
*/
|
||||
internal class EmailDomainParser(
|
||||
input: String,
|
||||
startIndex: Int = 0,
|
||||
endIndex: Int = input.length,
|
||||
) : AbstractParser(input, startIndex, endIndex) {
|
||||
|
||||
fun parseDomain(): EmailDomain {
|
||||
val domain = readDomain()
|
||||
|
||||
if (!endReached()) {
|
||||
parserError(ExpectedEndOfInput)
|
||||
}
|
||||
|
||||
return domain
|
||||
}
|
||||
|
||||
fun readDomain(): EmailDomain {
|
||||
val domain = readString {
|
||||
expectSubDomain()
|
||||
|
||||
while (!endReached() && peek() == DOT) {
|
||||
expect(DOT)
|
||||
expectSubDomain()
|
||||
}
|
||||
}
|
||||
|
||||
if (domain.length > MAXIMUM_DOMAIN_LENGTH) {
|
||||
parserError(DomainLengthExceeded)
|
||||
}
|
||||
|
||||
return EmailDomain(domain)
|
||||
}
|
||||
|
||||
private fun expectSubDomain() {
|
||||
val startIndex = currentIndex
|
||||
|
||||
expectLetDig()
|
||||
|
||||
var requireLetDig = false
|
||||
while (!endReached()) {
|
||||
val character = peek()
|
||||
when {
|
||||
character == HYPHEN -> {
|
||||
requireLetDig = true
|
||||
expect(HYPHEN)
|
||||
}
|
||||
character.isLetDig -> {
|
||||
requireLetDig = false
|
||||
expectLetDig()
|
||||
}
|
||||
else -> break
|
||||
}
|
||||
}
|
||||
|
||||
if (requireLetDig) {
|
||||
expectLetDig()
|
||||
}
|
||||
|
||||
if (currentIndex - startIndex > MAXIMUM_DNS_LABEL_LENGTH) {
|
||||
parserError(DnsLabelLengthExceeded)
|
||||
}
|
||||
}
|
||||
|
||||
private fun expectLetDig() {
|
||||
expect("'Let-dig'") { it.isLetDig }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
@file:Suppress("MagicNumber")
|
||||
|
||||
package app.k9mail.core.common.mail
|
||||
|
||||
internal const val DQUOTE = '"'
|
||||
internal const val DOT = '.'
|
||||
internal const val AT = '@'
|
||||
internal const val BACKSLASH = '\\'
|
||||
internal const val HYPHEN = '-'
|
||||
|
||||
internal val ATEXT_EXTRA = charArrayOf(
|
||||
'!', '#', '$', '%', '&', '\'', '*', '+', '-', '/', '=', '?', '^', '_', '`', '{', '|', '}', '~',
|
||||
)
|
||||
|
||||
// RFC 5234: ALPHA = %x41-5A / %x61-7A ; A-Z / a-z
|
||||
internal val Char.isALPHA
|
||||
get() = this in 'A'..'Z' || this in 'a'..'z'
|
||||
|
||||
// RFC 5234: DIGIT = %x30-39 ; 0-9
|
||||
internal val Char.isDIGIT
|
||||
get() = this in '0'..'9'
|
||||
|
||||
// RFC 5322:
|
||||
// atext = ALPHA / DIGIT / ; Printable US-ASCII
|
||||
// "!" / "#" / ; characters not including
|
||||
// "$" / "%" / ; specials. Used for atoms.
|
||||
// "&" / "'" /
|
||||
// "*" / "+" /
|
||||
// "-" / "/" /
|
||||
// "=" / "?" /
|
||||
// "^" / "_" /
|
||||
// "`" / "{" /
|
||||
// "|" / "}" /
|
||||
// "~"
|
||||
internal val Char.isAtext
|
||||
get() = isALPHA || isDIGIT || this in ATEXT_EXTRA
|
||||
|
||||
// RFC 5321: qtextSMTP = %d32-33 / %d35-91 / %d93-126
|
||||
internal val Char.isQtext
|
||||
get() = code.let { it in 32..33 || it in 35..91 || it in 93..126 }
|
||||
|
||||
// RFC 5321: second character of quoted-pairSMTP = %d92 %d32-126
|
||||
internal val Char.isQuotedChar
|
||||
get() = code in 32..126
|
||||
|
||||
// RFC 5321:
|
||||
// Dot-string = Atom *("." Atom)
|
||||
// Atom = 1*atext
|
||||
internal val String.isDotString: Boolean
|
||||
get() {
|
||||
if (isEmpty() || this[0] == DOT || this[lastIndex] == DOT) return false
|
||||
for (i in 0..lastIndex) {
|
||||
val character = this[i]
|
||||
when {
|
||||
character == DOT -> if (this[i - 1] == DOT) return false
|
||||
character.isAtext -> Unit
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// RFC 5321: Let-dig = ALPHA / DIGIT
|
||||
internal val Char.isLetDig
|
||||
get() = isALPHA || isDIGIT
|
|
@ -0,0 +1,271 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.AddressLiteralsNotSupported
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.EmptyLocalPart
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.ExpectedEndOfInput
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.InvalidDomainPart
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.InvalidDotString
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.InvalidLocalPart
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.InvalidQuotedString
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.LocalPartLengthExceeded
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.LocalPartRequiresQuotedString
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.QuotedStringInLocalPart
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.TotalLengthExceeded
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.UnexpectedCharacter
|
||||
import assertk.all
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.prop
|
||||
import kotlin.test.Test
|
||||
|
||||
class EmailAddressParserTest {
|
||||
@Test
|
||||
fun `simple address`() {
|
||||
val emailAddress = parseEmailAddress("alice@domain.example")
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("alice")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part containing dot`() {
|
||||
val emailAddress = parseEmailAddress("alice.lastname@domain.example")
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("alice.lastname")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted local part`() {
|
||||
val emailAddress = parseEmailAddress("\"one two\"@domain.example", allowLocalPartRequiringQuotedString = true)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("one two")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted local part not allowed`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(
|
||||
address = "\"one two\"@domain.example",
|
||||
allowQuotedLocalPart = true,
|
||||
allowLocalPartRequiringQuotedString = false,
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(LocalPartRequiresQuotedString)
|
||||
prop(EmailAddressParserException::position).isEqualTo(0)
|
||||
hasMessage("Local part requiring the use of a quoted string is not allowed by config")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unnecessarily quoted local part`() {
|
||||
val emailAddress = parseEmailAddress(
|
||||
address = "\"user\"@domain.example",
|
||||
allowQuotedLocalPart = true,
|
||||
allowLocalPartRequiringQuotedString = false,
|
||||
)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("user")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
assertThat(emailAddress.address).isEqualTo("user@domain.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unnecessarily quoted local part not allowed`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("\"user\"@domain.example", allowQuotedLocalPart = false)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(QuotedStringInLocalPart)
|
||||
prop(EmailAddressParserException::position).isEqualTo(0)
|
||||
hasMessage("Quoted string in local part is not allowed by config")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted local part containing double quote character`() {
|
||||
val emailAddress = parseEmailAddress(""""a\"b"@domain.example""", allowLocalPartRequiringQuotedString = true)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("a\"b")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
assertThat(emailAddress.address).isEqualTo(""""a\"b"@domain.example""")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty local part`() {
|
||||
val emailAddress = parseEmailAddress("\"\"@domain.example", allowEmptyLocalPart = true)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("")
|
||||
assertThat(emailAddress.domain).isEqualTo(EmailDomain("domain.example"))
|
||||
assertThat(emailAddress.address).isEqualTo("\"\"@domain.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty local part not allowed`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(
|
||||
address = "\"\"@domain.example",
|
||||
allowLocalPartRequiringQuotedString = true,
|
||||
allowEmptyLocalPart = false,
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(EmptyLocalPart)
|
||||
prop(EmailAddressParserException::position).isEqualTo(1)
|
||||
hasMessage("Empty local part is not allowed by config")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv4 address literal`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("user@[255.0.100.23]")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(AddressLiteralsNotSupported)
|
||||
prop(EmailAddressParserException::position).isEqualTo(5)
|
||||
hasMessage("Address literals are not supported")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `IPv6 address literal`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("user@[IPv6:2001:0db8:0000:0000:0000:ff00:0042:8329]")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(AddressLiteralsNotSupported)
|
||||
prop(EmailAddressParserException::position).isEqualTo(5)
|
||||
hasMessage("Address literals are not supported")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `domain part starts with unsupported value`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("user@ä")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidDomainPart)
|
||||
prop(EmailAddressParserException::position).isEqualTo(5)
|
||||
hasMessage("Expected 'Domain' or 'address-literal'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `obsolete syntax`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("\"quoted\".atom@domain.example", allowLocalPartRequiringQuotedString = true)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
|
||||
prop(EmailAddressParserException::position).isEqualTo(8)
|
||||
hasMessage("Expected '@' (64)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part starting with dot`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(".invalid@domain.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidLocalPart)
|
||||
prop(EmailAddressParserException::position).isEqualTo(0)
|
||||
hasMessage("Expected 'Dot-string' or 'Quoted-string'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part ending with dot`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("invalid.@domain.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidDotString)
|
||||
prop(EmailAddressParserException::position).isEqualTo(8)
|
||||
hasMessage("Expected 'Dot-string'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted local part missing closing double quote`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("\"invalid@domain.example", allowLocalPartRequiringQuotedString = true)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
|
||||
prop(EmailAddressParserException::position).isEqualTo(23)
|
||||
hasMessage("Expected '\"' (34)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted text containing unsupported character`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("\"ä\"@domain.example", allowLocalPartRequiringQuotedString = true)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString)
|
||||
prop(EmailAddressParserException::position).isEqualTo(1)
|
||||
hasMessage("Expected 'Quoted-string'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoted text containing unsupported escaped character`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(""""\ä"@domain.example""", allowLocalPartRequiringQuotedString = true)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(InvalidQuotedString)
|
||||
prop(EmailAddressParserException::position).isEqualTo(3)
|
||||
hasMessage("Expected 'Quoted-string'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local part exceeds maximum size`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12345@domain.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(LocalPartLengthExceeded)
|
||||
prop(EmailAddressParserException::position).isEqualTo(65)
|
||||
hasMessage("Local part exceeds maximum length of 64 characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `email exceeds maximum size`() {
|
||||
assertFailure {
|
||||
parseEmailAddress(
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234@" +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12",
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(TotalLengthExceeded)
|
||||
prop(EmailAddressParserException::position).isEqualTo(255)
|
||||
hasMessage("The email address exceeds the maximum length of 254 characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input contains additional character`() {
|
||||
assertFailure {
|
||||
parseEmailAddress("test@domain.example#")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput)
|
||||
prop(EmailAddressParserException::position).isEqualTo(19)
|
||||
hasMessage("Expected end of input")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEmailAddress(
|
||||
address: String,
|
||||
allowEmptyLocalPart: Boolean = false,
|
||||
allowLocalPartRequiringQuotedString: Boolean = allowEmptyLocalPart,
|
||||
allowQuotedLocalPart: Boolean = allowLocalPartRequiringQuotedString,
|
||||
): EmailAddress {
|
||||
val config = EmailAddressParserConfig(
|
||||
allowQuotedLocalPart,
|
||||
allowLocalPartRequiringQuotedString,
|
||||
allowEmptyLocalPart,
|
||||
)
|
||||
return EmailAddressParser(address, config).parse()
|
||||
}
|
||||
}
|
|
@ -1,32 +1,60 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
import assertk.assertFailure
|
||||
import app.k9mail.core.common.mail.EmailAddress.Warning
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.containsExactlyInAnyOrder
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isSameAs
|
||||
import kotlin.test.Test
|
||||
|
||||
internal class EmailAddressTest {
|
||||
|
||||
class EmailAddressTest {
|
||||
@Test
|
||||
fun `should reject blank email address`() {
|
||||
assertFailure {
|
||||
EmailAddress("")
|
||||
}.isInstanceOf<IllegalArgumentException>()
|
||||
.hasMessage("Email address must not be blank")
|
||||
fun `simple email address`() {
|
||||
val domain = EmailDomain("DOMAIN.example")
|
||||
val emailAddress = EmailAddress(localPart = "user", domain = domain)
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("user")
|
||||
assertThat(emailAddress.encodedLocalPart).isEqualTo("user")
|
||||
assertThat(emailAddress.domain).isSameAs(domain)
|
||||
assertThat(emailAddress.address).isEqualTo("user@DOMAIN.example")
|
||||
assertThat(emailAddress.normalizedAddress).isEqualTo("user@domain.example")
|
||||
assertThat(emailAddress.toString()).isEqualTo("user@DOMAIN.example")
|
||||
assertThat(emailAddress.warnings).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return email address`() {
|
||||
val emailAddress = EmailAddress(EMAIL_ADDRESS)
|
||||
fun `local part that requires use of quoted string`() {
|
||||
val emailAddress = EmailAddress(localPart = "foo bar", domain = EmailDomain("domain.example"))
|
||||
|
||||
val address = emailAddress.address
|
||||
|
||||
assertThat(address).isEqualTo(EMAIL_ADDRESS)
|
||||
assertThat(emailAddress.localPart).isEqualTo("foo bar")
|
||||
assertThat(emailAddress.encodedLocalPart).isEqualTo("\"foo bar\"")
|
||||
assertThat(emailAddress.address).isEqualTo("\"foo bar\"@domain.example")
|
||||
assertThat(emailAddress.normalizedAddress).isEqualTo("\"foo bar\"@domain.example")
|
||||
assertThat(emailAddress.toString()).isEqualTo("\"foo bar\"@domain.example")
|
||||
assertThat(emailAddress.warnings).containsExactlyInAnyOrder(Warning.QuotedStringInLocalPart)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val EMAIL_ADDRESS = "email@example.com"
|
||||
@Test
|
||||
fun `empty local part`() {
|
||||
val emailAddress = EmailAddress(localPart = "", domain = EmailDomain("domain.example"))
|
||||
|
||||
assertThat(emailAddress.localPart).isEqualTo("")
|
||||
assertThat(emailAddress.encodedLocalPart).isEqualTo("\"\"")
|
||||
assertThat(emailAddress.address).isEqualTo("\"\"@domain.example")
|
||||
assertThat(emailAddress.normalizedAddress).isEqualTo("\"\"@domain.example")
|
||||
assertThat(emailAddress.toString()).isEqualTo("\"\"@domain.example")
|
||||
assertThat(emailAddress.warnings).containsExactlyInAnyOrder(
|
||||
Warning.QuotedStringInLocalPart,
|
||||
Warning.EmptyLocalPart,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equals() does case-insensitive domain comparison`() {
|
||||
val emailAddress1 = EmailAddress(localPart = "user", domain = EmailDomain("domain.example"))
|
||||
val emailAddress2 = EmailAddress(localPart = "user", domain = EmailDomain("DOMAIN.example"))
|
||||
|
||||
assertThat(emailAddress2).isEqualTo(emailAddress1)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.DnsLabelLengthExceeded
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.DomainLengthExceeded
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.ExpectedEndOfInput
|
||||
import app.k9mail.core.common.mail.EmailAddressParserError.UnexpectedCharacter
|
||||
import assertk.all
|
||||
import assertk.assertFailure
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.prop
|
||||
import kotlin.test.Test
|
||||
|
||||
class EmailDomainParserTest {
|
||||
@Test
|
||||
fun `simple domain`() {
|
||||
val emailDomain = parseEmailDomain("DOMAIN.example")
|
||||
|
||||
assertThat(emailDomain.value).isEqualTo("DOMAIN.example")
|
||||
assertThat(emailDomain.normalized).isEqualTo("domain.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label starting with hyphen`() {
|
||||
assertFailure {
|
||||
parseEmailDomain("-domain.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
|
||||
prop(EmailAddressParserException::position).isEqualTo(0)
|
||||
hasMessage("Expected 'Let-dig'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label ending with hyphen`() {
|
||||
assertFailure {
|
||||
parseEmailDomain("domain-.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(UnexpectedCharacter)
|
||||
prop(EmailAddressParserException::position).isEqualTo(7)
|
||||
hasMessage("Expected 'Let-dig'")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label exceeds maximum size`() {
|
||||
assertFailure {
|
||||
parseEmailDomain("1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx1234.example")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(DnsLabelLengthExceeded)
|
||||
prop(EmailAddressParserException::position).isEqualTo(64)
|
||||
hasMessage("DNS labels exceeds maximum length of 63 characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `domain exceeds maximum size`() {
|
||||
assertFailure {
|
||||
parseEmailDomain(
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx123." +
|
||||
"1xxxxxxxxx2xxxxxxxxx3xxxxxxxxx4xxxxxxxxx5xxxxxxxxx6xxxxxxxxx12",
|
||||
)
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(DomainLengthExceeded)
|
||||
prop(EmailAddressParserException::position).isEqualTo(254)
|
||||
hasMessage("Domain exceeds maximum length of 253 characters")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `input contains additional character`() {
|
||||
assertFailure {
|
||||
parseEmailDomain("domain.example#")
|
||||
}.isInstanceOf<EmailAddressParserException>().all {
|
||||
prop(EmailAddressParserException::error).isEqualTo(ExpectedEndOfInput)
|
||||
prop(EmailAddressParserException::position).isEqualTo(14)
|
||||
hasMessage("Expected end of input")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEmailDomain(domain: String): EmailDomain {
|
||||
return EmailDomainParser(domain).parseDomain()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package app.k9mail.core.common.mail
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import kotlin.test.Test
|
||||
|
||||
class EmailDomainTest {
|
||||
@Test
|
||||
fun `simple domain`() {
|
||||
val domain = EmailDomain("DOMAIN.example")
|
||||
|
||||
assertThat(domain.value).isEqualTo("DOMAIN.example")
|
||||
assertThat(domain.normalized).isEqualTo("domain.example")
|
||||
assertThat(domain.toString()).isEqualTo("DOMAIN.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `equals() does case-insensitive comparison`() {
|
||||
val domain1 = EmailDomain("domain.example")
|
||||
val domain2 = EmailDomain("DOMAIN.example")
|
||||
|
||||
assertThat(domain2).isEqualTo(domain1)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue