Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.
<?php
/**
 * Copyright (c) 2001-2010, Richard Heyes
 * Copyright 2011-2017 Horde LLC (http://www.horde.org/)
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * o Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 * o Redistributions in binary form must reproduce the above copyright
 *   notice, this list of conditions and the following disclaimer in the
 *   documentation and/or other materials provided with the distribution.
 * o The names of the authors may not be used to endorse or promote
 *   products derived from this software without specific prior written
 *   permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * RFC822 parsing code adapted from message-address.c and rfc822-parser.c
 *   (Dovecot 2.1rc5)
 *   Original code released under LGPL-2.1
 *   Copyright (c) 2002-2011 Timo Sirainen <tss@iki.fi>
 *
 * @category  Horde
 * @copyright 2001-2010 Richard Heyes
 * @copyright 2002-2011 Timo Sirainen
 * @copyright 2011-2017 Horde LLC
 * @license   http://www.horde.org/licenses/bsd New BSD License
 * @package   Mail
 */

/**
 * RFC 822/2822/3490/5322 Email parser/validator.
 *
 * @author    Richard Heyes <richard@phpguru.org>
 * @author    Chuck Hagenbuch <chuck@horde.org>
 * @author    Michael Slusarz <slusarz@horde.org>
 * @author    Timo Sirainen <tss@iki.fi>
 * @category  Horde
 * @copyright 2001-2010 Richard Heyes
 * @copyright 2002-2011 Timo Sirainen
 * @copyright 2011-2017 Horde LLC
 * @license   http://www.horde.org/licenses/bsd New BSD License
 * @package   Mail
 */
class Horde_Mail_Rfc822
{
    /**
     * Valid atext characters.
     *
     * @deprecated
     * @since 2.0.3
     */
    const ATEXT = '!#$%&\'*+-./0123456789=?ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`abcdefghijklmnopqrstuvwxyz{|}~';

    /**
     * Excluded (in ASCII decimal): 0-8, 10-31, 34, 40-41, 44, 58-60, 62, 64,
     * 91-93, 127
     *
     * @since 2.0.3
     */
    const ENCODE_FILTER = "\0\1\2\3\4\5\6\7\10\12\13\14\15\16\17\20\21\22\23\24\25\26\27\30\31\32\33\34\35\36\37\"(),:;<>@[\\]\177";

    /**
     * The address string to parse.
     *
     * @var string
     */
    protected $_data;

    /**
     * Length of the address string.
     *
     * @var integer
     */
    protected $_datalen;

    /**
     * Comment cache.
     *
     * @var string
     */
    protected $_comments = array();

    /**
     * List object to return in parseAddressList().
     *
     * @var Horde_Mail_Rfc822_List
     */
    protected $_listob;

    /**
     * Configuration parameters.
     *
     * @var array
     */
    protected $_params = array();

    /**
     * Data pointer.
     *
     * @var integer
     */
    protected $_ptr;

    /**
     * Starts the whole process.
     *
     * @param mixed $address   The address(es) to validate. Either a string,
     *                         a Horde_Mail_Rfc822_Object, or an array of
     *                         strings and/or Horde_Mail_Rfc822_Objects.
     * @param array $params    Optional parameters:
     *   - default_domain: (string) Default domain/host.
     *                     DEFAULT: None
     *   - group: (boolean) Return a GroupList object instead of a List object?
     *            DEFAULT: false
     *   - limit: (integer) Stop processing after this many addresses.
     *            DEFAULT: No limit (0)
     *   - validate: (mixed) Strict validation of personal part data? If
     *               false, attempts to allow non-ASCII characters and
     *               non-quoted strings in the personal data, and will
     *               silently abort if an unparseable address is found.
     *               If true, does strict RFC 5322 (ASCII-only) parsing. If
     *               'eai' (@since 2.5.0), allows RFC 6532 (EAI/UTF-8)
     *               addresses.
     *               DEFAULT: false
     *
     * @return Horde_Mail_Rfc822_List  A list object.
     *
     * @throws Horde_Mail_Exception
     */
    public function parseAddressList($address, array $params = array())
    {
        if ($address instanceof Horde_Mail_Rfc822_List) {
            return $address;
        }

        if (empty($params['limit'])) {
            $params['limit'] = -1;
        }

        $this->_params = array_merge(array(
            'default_domain' => null,
            'validate' => false
        ), $params);

        $this->_listob = empty($this->_params['group'])
            ? new Horde_Mail_Rfc822_List()
            : new Horde_Mail_Rfc822_GroupList();

        if (!is_array($address)) {
            $address = array($address);
        }

        $tmp = array();
        foreach ($address as $val) {
            if ($val instanceof Horde_Mail_Rfc822_Object) {
                $this->_listob->add($val);
            } else {
                $tmp[] = rtrim(trim($val), ',');
            }
        }

        if (!empty($tmp)) {
            $this->_data = implode(',', $tmp);
            $this->_datalen = strlen($this->_data);
            $this->_ptr = 0;

            $this->_parseAddressList();
        }

        $ret = $this->_listob;
        unset($this->_listob);

        return $ret;
    }

   /**
     * Quotes and escapes the given string if necessary using rules contained
     * in RFC 2822 [3.2.5].
     *
     * @param string $str   The string to be quoted and escaped.
     * @param string $type  Either 'address', 'comment' (@since 2.6.0), or
     *                      'personal'.
     *
     * @return string  The correctly quoted and escaped string.
     */
    public function encode($str, $type = 'address')
    {
        switch ($type) {
        case 'comment':
            // RFC 5322 [3.2.2]: Filter out non-printable US-ASCII and ( ) \
            $filter = "\0\1\2\3\4\5\6\7\10\12\13\14\15\16\17\20\21\22\23\24\25\26\27\30\31\32\33\34\35\36\37\50\51\134\177";
            break;

        case 'personal':
            // RFC 2822 [3.4]: Period not allowed in display name
            $filter = self::ENCODE_FILTER . '.';
            break;

        case 'address':
        default:
            // RFC 2822 [3.4.1]: (HTAB, SPACE) not allowed in address
            $filter = self::ENCODE_FILTER . "\11\40";
            break;
        }

        // Strip double quotes if they are around the string already.
        // If quoted, we know that the contents are already escaped, so
        // unescape now.
        $str = trim($str);
        if ($str && ($str[0] === '"') && (substr($str, -1) === '"')) {
            $str = stripslashes(substr($str, 1, -1));
        }

        return (strcspn($str, $filter) != strlen($str))
            ? '"' . addcslashes($str, '\\"') . '"'
            : $str;
    }

    /**
     * If an email address has no personal information, get rid of any angle
     * brackets (<>) around it.
     *
     * @param string $address  The address to trim.
     *
     * @return string  The trimmed address.
     */
    public function trimAddress($address)
    {
        $address = trim($address);

        return (($address[0] == '<') && (substr($address, -1) == '>'))
            ? substr($address, 1, -1)
            : $address;
    }

    /* RFC 822 parsing methods. */

    /**
     * address-list = (address *("," address)) / obs-addr-list
     */
    protected function _parseAddressList()
    {
        $limit = $this->_params['limit'];

        while (($this->_curr() !== false) && ($limit-- !== 0)) {
            try {
                $this->_parseAddress();
            } catch (Horde_Mail_Exception $e) {
               if ($this->_params['validate']) {
                   throw $e;
               }
               ++$this->_ptr;
            }

            switch ($this->_curr()) {
            case ',':
                $this->_rfc822SkipLwsp(true);
                break;

            case false:
                // No-op
                break;

            default:
               if ($this->_params['validate']) {
                    throw new Horde_Mail_Exception('Error when parsing address list.');
               }
               break;
            }
        }
    }

    /**
     * address = mailbox / group
     */
    protected function _parseAddress()
    {
        $start = $this->_ptr;
        if (!$this->_parseGroup()) {
            $this->_ptr = $start;
            if ($mbox = $this->_parseMailbox()) {
                $this->_listob->add($mbox);
            }
        }
    }

    /**
     * group           = display-name ":" [mailbox-list / CFWS] ";" [CFWS]
     * display-name    = phrase
     *
     * @return boolean  True if a group was parsed.
     *
     * @throws Horde_Mail_Exception
     */
    protected function _parseGroup()
    {
        $this->_rfc822ParsePhrase($groupname);

        if ($this->_curr(true) != ':') {
            return false;
        }

        $addresses = new Horde_Mail_Rfc822_GroupList();

        $this->_rfc822SkipLwsp();

        while (($chr = $this->_curr()) !== false) {
            if ($chr == ';') {
                ++$this->_ptr;

                if (count($addresses)) {
                    $this->_listob->add(new Horde_Mail_Rfc822_Group($groupname, $addresses));
                }

                return true;
            }

            /* mailbox-list = (mailbox *("," mailbox)) / obs-mbox-list */
            $addresses->add($this->_parseMailbox());

            switch ($this->_curr()) {
            case ',':
                $this->_rfc822SkipLwsp(true);
                break;

            case ';':
                // No-op
                break;

            default:
                break 2;
            }
        }

        throw new Horde_Mail_Exception('Error when parsing group.');
    }

    /**
     * mailbox = name-addr / addr-spec
     *
     * @return mixed  Mailbox object if mailbox was parsed, or false.
     */
    protected function _parseMailbox()
    {
        $this->_comments = array();
        $start = $this->_ptr;

        if (!($ob = $this->_parseNameAddr())) {
            $this->_comments = array();
            $this->_ptr = $start;
            $ob = $this->_parseAddrSpec();
        }

        if ($ob) {
            $ob->comment = $this->_comments;
        }

        return $ob;
    }

    /**
     * name-addr    = [display-name] angle-addr
     * display-name = phrase
     *
     * @return mixed  Mailbox object, or false.
     */
    protected function _parseNameAddr()
    {
        $this->_rfc822ParsePhrase($personal);

        if ($ob = $this->_parseAngleAddr()) {
            $ob->personal = $personal;
            return $ob;
        }

        return false;
    }

    /**
     * addr-spec = local-part "@" domain
     *
     * @return mixed  Mailbox object.
     *
     * @throws Horde_Mail_Exception
     */
    protected function _parseAddrSpec()
    {
        $ob = new Horde_Mail_Rfc822_Address();
        $ob->mailbox = $this->_parseLocalPart();

        if ($this->_curr() == '@') {
            try {
                $this->_rfc822ParseDomain($host);
< if (strlen($host)) {
> if (!empty($host)) {
$ob->host = $host; } } catch (Horde_Mail_Exception $e) { if (!empty($this->_params['validate'])) { throw $e; } } } if (is_null($ob->host)) { if (!is_null($this->_params['default_domain'])) { $ob->host = $this->_params['default_domain']; } elseif (!empty($this->_params['validate'])) { throw new Horde_Mail_Exception('Address is missing domain.'); } } return $ob; } /** * local-part = dot-atom / quoted-string / obs-local-part * obs-local-part = word *("." word) * * @return string The local part. * * @throws Horde_Mail_Exception */ protected function _parseLocalPart() { if (($curr = $this->_curr()) === false) { throw new Horde_Mail_Exception('Error when parsing local part.'); } if ($curr == '"') { $this->_rfc822ParseQuotedString($str); } else { $this->_rfc822ParseDotAtom($str, ',;@'); } return $str; } /** * "<" [ "@" route ":" ] local-part "@" domain ">" * * @return mixed Mailbox object, or false. * * @throws Horde_Mail_Exception */ protected function _parseAngleAddr() { if ($this->_curr() != '<') { return false; } $this->_rfc822SkipLwsp(true); if ($this->_curr() == '@') { // Route information is ignored. $this->_parseDomainList(); if ($this->_curr() != ':') { throw new Horde_Mail_Exception('Invalid route.'); } $this->_rfc822SkipLwsp(true); } $ob = $this->_parseAddrSpec(); if ($this->_curr() != '>') { throw new Horde_Mail_Exception('Error when parsing angle address.'); } $this->_rfc822SkipLwsp(true); return $ob; } /** * obs-domain-list = "@" domain *(*(CFWS / "," ) [CFWS] "@" domain) * * @return array Routes. * * @throws Horde_Mail_Exception */ protected function _parseDomainList() { $route = array(); while ($this->_curr() !== false) { $this->_rfc822ParseDomain($str); $route[] = '@' . $str; $this->_rfc822SkipLwsp(); if ($this->_curr() != ',') { return $route; } ++$this->_ptr; } throw new Horde_Mail_Exception('Invalid domain list.'); } /* RFC 822 parsing methods. */ /** * phrase = 1*word / obs-phrase * word = atom / quoted-string * obs-phrase = word *(word / "." / CFWS) * * @param string &$phrase The phrase data. * * @throws Horde_Mail_Exception */ protected function _rfc822ParsePhrase(&$phrase) { $curr = $this->_curr(); if (($curr === false) || ($curr == '.')) { throw new Horde_Mail_Exception('Error when parsing a group.'); } do { if ($curr == '"') { $this->_rfc822ParseQuotedString($phrase); } else { $this->_rfc822ParseAtomOrDot($phrase); } $curr = $this->_curr(); if (($curr != '"') && ($curr != '.') && !$this->_rfc822IsAtext($curr)) { break; } $phrase .= ' '; } while ($this->_ptr < $this->_datalen); $this->_rfc822SkipLwsp(); } /** * @param string &$phrase The quoted string data. * * @throws Horde_Mail_Exception */ protected function _rfc822ParseQuotedString(&$str) { if ($this->_curr(true) != '"') { throw new Horde_Mail_Exception('Error when parsing a quoted string.'); } while (($chr = $this->_curr(true)) !== false) { switch ($chr) { case '"': $this->_rfc822SkipLwsp(); return; case "\n": /* Folding whitespace, remove the (CR)LF. */ if (substr($str, -1) == "\r") { $str = substr($str, 0, -1); } continue 2; case '\\': if (($chr = $this->_curr(true)) === false) { break 2; } break; } $str .= $chr; } /* Missing trailing '"', or partial quoted character. */ throw new Horde_Mail_Exception('Error when parsing a quoted string.'); } /** * dot-atom = [CFWS] dot-atom-text [CFWS] * dot-atom-text = 1*atext *("." 1*atext) * * atext = ; Any character except controls, SP, and specials. * * For RFC-822 compatibility allow LWSP around '.'. * * * @param string &$str The atom/dot data. * @param string $validate Use these characters as delimiter. * * @throws Horde_Mail_Exception */ protected function _rfc822ParseDotAtom(&$str, $validate = null) { $valid = false; while ($this->_ptr < $this->_datalen) { $chr = $this->_data[$this->_ptr]; /* TODO: Optimize by duplicating rfc822IsAtext code here */ if ($this->_rfc822IsAtext($chr, $validate)) { $str .= $chr; ++$this->_ptr; } elseif (!$valid) { throw new Horde_Mail_Exception('Error when parsing dot-atom.'); } else { $this->_rfc822SkipLwsp(); if ($this->_curr() != '.') { return; } $str .= $chr; $this->_rfc822SkipLwsp(true); } $valid = true; } } /** * atom = [CFWS] 1*atext [CFWS] * atext = ; Any character except controls, SP, and specials. * * This method doesn't just silently skip over WS. * * @param string &$str The atom/dot data. * * @throws Horde_Mail_Exception */ protected function _rfc822ParseAtomOrDot(&$str) { while ($this->_ptr < $this->_datalen) { $chr = $this->_data[$this->_ptr]; if (($chr != '.') && /* TODO: Optimize by duplicating rfc822IsAtext code here */ !$this->_rfc822IsAtext($chr, ',<:')) { $this->_rfc822SkipLwsp();
< if (!$this->_params['validate']) {
> if (!$this->_params['validate'] && $str !== null) {
$str = trim($str); } return; } $str .= $chr; ++$this->_ptr; } } /** * domain = dot-atom / domain-literal / obs-domain * domain-literal = [CFWS] "[" *([FWS] dcontent) [FWS] "]" [CFWS] * obs-domain = atom *("." atom) * * @param string &$str The domain string. * * @throws Horde_Mail_Exception */ protected function _rfc822ParseDomain(&$str) { if ($this->_curr(true) != '@') { throw new Horde_Mail_Exception('Error when parsing domain.'); } $this->_rfc822SkipLwsp(); if ($this->_curr() == '[') { $this->_rfc822ParseDomainLiteral($str); } else { $this->_rfc822ParseDotAtom($str, ';,> '); } } /** * domain-literal = [CFWS] "[" *([FWS] dcontent) [FWS] "]" [CFWS] * dcontent = dtext / quoted-pair * dtext = NO-WS-CTL / ; Non white space controls * %d33-90 / ; The rest of the US-ASCII * %d94-126 ; characters not including "[", * ; "]", or "\" * * @param string &$str The domain string. * * @throws Horde_Mail_Exception */ protected function _rfc822ParseDomainLiteral(&$str) { if ($this->_curr(true) != '[') { throw new Horde_Mail_Exception('Error parsing domain literal.'); } while (($chr = $this->_curr(true)) !== false) { switch ($chr) { case '\\': if (($chr = $this->_curr(true)) === false) { break 2; } break; case ']': $this->_rfc822SkipLwsp(); return; } $str .= $chr; } throw new Horde_Mail_Exception('Error parsing domain literal.'); } /** * @param boolean $advance Advance cursor? * * @throws Horde_Mail_Exception */ protected function _rfc822SkipLwsp($advance = false) { if ($advance) { ++$this->_ptr; } while (($chr = $this->_curr()) !== false) { switch ($chr) { case ' ': case "\n": case "\r": case "\t": ++$this->_ptr; continue 2; case '(': $this->_rfc822SkipComment(); break; default: return; } } } /** * @throws Horde_Mail_Exception */ protected function _rfc822SkipComment() { if ($this->_curr(true) != '(') { throw new Horde_Mail_Exception('Error when parsing a comment.'); } $comment = ''; $level = 1; while (($chr = $this->_curr(true)) !== false) { switch ($chr) { case '(': ++$level; continue 2; case ')': if (--$level == 0) { $this->_comments[] = $comment; return; } break; case '\\': if (($chr = $this->_curr(true)) === false) { break 2; } break; } $comment .= $chr; } throw new Horde_Mail_Exception('Error when parsing a comment.'); } /** * Check if data is an atom. * * @param string $chr The character to check. * @param string $validate If in non-validate mode, use these characters * as the non-atom delimiters. * * @return boolean True if a valid atom. */ protected function _rfc822IsAtext($chr, $validate = null) { if (!$this->_params['validate'] && !is_null($validate)) { return strcspn($chr, $validate); } $ord = ord($chr); /* UTF-8 characters check. */ if ($ord > 127) { return ($this->_params['validate'] === 'eai'); } /* Check for DISALLOWED characters under both RFCs 5322 and 6532. */ /* Unprintable characters && [SPACE] */ if ($ord <= 32) { return false; } /* "(),:;<>@[\] [DEL] */ switch ($ord) { case 34: case 40: case 41: case 44: case 58: case 59: case 60: case 62: case 64: case 91: case 92: case 93: case 127: return false; } return true; } /* Helper methods. */ /** * Return current character. * * @param boolean $advance If true, advance the cursor. * * @return string The current character (false if EOF reached). */ protected function _curr($advance = false) { return ($this->_ptr >= $this->_datalen) ? false : $this->_data[$advance ? $this->_ptr++ : $this->_ptr]; } /* Other public methods. */ /** * Returns an approximate count of how many addresses are in the string. * This is APPROXIMATE as it only splits based on a comma which has no * preceding backslash. * * @param string $data Addresses to count. * * @return integer Approximate count. */ public function approximateCount($data) { return count(preg_split('/(?<!\\\\),/', $data)); } /** * Validates whether an email is of the common internet form: * <user>@<domain>. This can be sufficient for most people. * * Optional stricter mode can be utilized which restricts mailbox * characters allowed to: alphanumeric, full stop, hyphen, and underscore. * * @param string $data Address to check. * @param boolean $strict Strict check? * * @return mixed False if it fails, an indexed array username/domain if * it matches. */ public function isValidInetAddress($data, $strict = false) { $regex = $strict ? '/^([.0-9a-z_+-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})$/i' : '/^([*+!.&#$|\'\\%\/0-9a-z^_`{}=?~:-]+)@(([0-9a-z-]+\.)+[0-9a-z]{2,})$/i'; return preg_match($regex, trim($data), $matches) ? array($matches[1], $matches[2]) : false; } }