diff options
-rw-r--r-- | it_mail.class | 150 | ||||
-rwxr-xr-x | test/it_mail.t | 23 |
2 files changed, 140 insertions, 33 deletions
diff --git a/it_mail.class b/it_mail.class index 4119994..94801a5 100644 --- a/it_mail.class +++ b/it_mail.class @@ -301,6 +301,70 @@ function send($p = array()) /** + * INTERNAL: Split address lists in headers like From, To, Cc, Bcc into + * name/email pairs + * @param $string String containing address list + * @return Generator returning name/email tuples + */ +static function addrlist_split($string) +{ + $quoted = false; + foreach (str_split($string) as $char) + { + if ($char == '"') + $quoted = !$quoted; + + if ($char == ',' && !$quoted) + $n++; + else + $mailboxes[$n] .= $char; + } + + foreach ((array)$mailboxes as $mailbox) + { + $mode = 'name'; + $name = $email = ''; + $quoted = false; + foreach (str_split($mailbox) as $char) + { + if ($char == '"') + $quoted = !$quoted; + switch ($mode) + { + case 'name': + if ($char == '<' && !$quoted) + $mode = 'email'; + else + $name .= $char; + break; + case 'email': + if ($char == '>' && !$quoted) + { + $mode = 'finish'; + break 2; + } + else + $email .= $char; + break; + } + } + if ($mode == 'email') + { + $name = $name . '<' . $email; + $email = ''; + $mode = 'name'; + } + if ($mode == 'name' && !$email) + { + $email = it::replace(['^"(.*)"$' => '$1'], trim($name)); + $name = ''; + } + yield [$name, trim($email)]; + } +} + + +/** * INTERNAL: Escape header line with ?=?<charset>?Q if necessary, e.g. in Subject line * @param $string String to be escaped * @return String escape suitable for sending in header line @@ -328,23 +392,13 @@ function addrlist_escape($string) { # Exclude e-mail addresses from being encoded as # e.g. GMail or Exchange have problems with that - foreach (str_split($string) as $char) + foreach (self::addrlist_split($string) as list($name, $email)) { - if ($char == '"') - $quoted = !$quoted; - - if ($char == ',' && !$quoted) - $n++; - else - $mailboxes[$n] .= $char; - } - - foreach ((array)$mailboxes as $mailbox) - { - if (preg_match('/^(.*)(\s+?<[^>]+@[^>]+>\s*)$/', $mailbox, $matches)) - $result[] = $this->header_escape(trim($matches[1])) . $matches[2]; - else - $result[] = trim($mailbox); + $email = self::email_escape($email); + if ($name && $email) + $result[] = $this->header_escape(trim($name)) . " <$email>"; + else if (!$name && $email) + $result[] = $email; } return implode(', ', $result); @@ -364,6 +418,48 @@ static function fullname_escape($string) /** + * INTERNAL: Split email-address into local part and doomain + * @return Array with to elements: local part and domain + */ +static function email_split($email) +{ + $mode = 'local'; + $local = $domain = ''; + $quoted = false; + foreach (str_split($email) as $char) + { + if ($char == '"') + $quoted = !$quoted; + switch ($mode) + { + case 'local': + if ($char == '@' && !$quoted) + $mode = 'domain'; + else + $local .= $char; + break; + case 'domain': + $domain .= $char; + } + } + + return [$local, $domain]; +} + + +/** + * INTERNAL: Convert domain part of email address into ascii idn form + * @return String with converted email address + */ +static function email_escape($email) +{ + list($local, $domain) = self::email_split($email); + + return $local . ($domain ? '@' . idn_to_ascii($domain) : ''); +} + + +/** * INTERNAL: Send SMTP command and return true if result is 2XX * @param $fp Connection to SMTP server opened using fsockopen * @param $command Command to send, e.g. HELO gna.ch @@ -408,20 +504,12 @@ static function send_smtp_cmd($fp, $cmd, &$answer, $timeoutok = false, $failcode */ static function address_error($addresslist) { - foreach (str_split($addresslist) as $char) + foreach (self::addrlist_split($addresslist) as list($dummy, $email)) { - if ($char == '"') - $quoted = !$quoted; - else if ($char == ',' && !$quoted) - $n++; - else if (!$quoted) - $addresses[$n] .= $char; + $email = self::email_escape($email); + if (filter_var($email, FILTER_VALIDATE_EMAIL) === false && !it::match('^\s*[a-z][-a-z0-9]*\s*$', $email)) + return "invalid format on $email"; } - - foreach ($addresses as $address) - if (($email = it::match('<([^>]*)>', $address) ?? trim($address))) - if (filter_var($email, FILTER_VALIDATE_EMAIL) === false && !it::match('^\s*[a-z][-a-z0-9]*\s*$', $email)) - return "invalid format on $email"; } @@ -438,11 +526,11 @@ static function check_email($email, $checkmailbox = false) { $result = IT_MAIL_CHECKEMAIL_INVALID; + $email = self::email_escape($email); + list($dummy, $domain) = self::email_split($email); /* Check if username starts with www. or not well-formed => reject */ - if (!preg_match('/^www\./', $email) && preg_match('/^[a-z0-9&_+.-]+@([a-z0-9.-]+\.[a-z]+)$/i', $email, $regs)) + if (!preg_match('/^www\./', $email) && filter_var($email, FILTER_VALIDATE_EMAIL) !== false && $domain) { - $domain = $regs[1]; - if (!getmxrr($domain, $mxhosts, $weights)) { /* If we find no MX we check if domain exists */ diff --git a/test/it_mail.t b/test/it_mail.t index 7e892c2..6aefd84 100755 --- a/test/it_mail.t +++ b/test/it_mail.t @@ -56,6 +56,12 @@ is( "Don't remove quoting characters from realname when it contains a quotable character" ); +is( + $mail->addrlist_escape('"Alfred E. Neuman" <t€st@sör.ch>', true), + '"Alfred E. Neuman" <t€st@xn--sr-fka.ch>', + "Convert domain in email to idn version" +); + $mail = new it_mail([ 'From' => 'Someone Ïmportant <ïmportant@search.ch>', 'To' => 'éxample@example.com, example@example.com, Sömeone Ëlse <sömeone@example.com>, "Alfred E. Neuman" <neuman@example.com>, "Schmitt, Sören" <schmitt@example.com>', @@ -95,10 +101,23 @@ is( ); is( - it_mail::address_error('neuman@example.com, <neuman@example.com>, "Neuman, Alfred E." <neuman@example.com>, aneuman, "<neuman@example>" <neuman@example.com>', " neumann@example.com"), + it_mail::address_error('neuman@example.com, <neuman@example.com>, "Neuman, Alfred E." <neuman@example.com>, aneuman, "<neuman@example>" <neuman@example.com>, " neumann@example.com", "www@search.ch".foo.bar@local.ch, test@sör.ch'), null, "Accept valid e-mail addresses" ); -foreach (['neuman@example', '"neuman@example.com" <neuman@example>', '<neuma@example.com'] as $email) +foreach (['neuman@example', '"neuman@example.com" <neuman@example>', '<neuma@example.com', 'shaqroot.@yahoo.com'] as $email) +{ isnt(it_mail::address_error($email), null, "Reject invalid e-mail address $email"); + foreach (it_mail::addrlist_split($email) as list($dummy, $email)) + is(it_mail::check_email($email), IT_MAIL_CHECKEMAIL_INVALID, "check_email rejects invalid email address $email too"); +} + +is(it_mail::check_email('test@sör.ch'), IT_MAIL_CHECKEMAIL_OK, "check_email accepts valid email address with idn domain"); + +is(it_mail::email_split('test@sör.ch'), ['test', 'sör.ch'], 'email_split test@sör.ch'); +is(it_mail::email_split('"test@local.ch".foo.bar@search.ch'), ['"test@local.ch".foo.bar', 'search.ch'], 'email_split "test@local.ch".foo.bar@search.ch'); +is(it_mail::email_split('aneuman'), ['aneuman', ''], 'email_split aneuman'); + +is(it_mail::email_escape('test@sör.ch'), 'test@xn--sr-fka.ch', 'idn encode domain sör.ch'); +is(it_mail::email_escape('أحمد@مثال.آزمایشی'), 'أحمد@xn--mgbh0fb.xn--hgbk6aj7f53bba', 'idn encode domain ﻢﺛﺎﻟ.ﺁﺰﻣﺎﯿﺸﯾ'); |