1<?php
2namespace TYPO3\CMS\Core\Mail;
3
4/**
5 * RFC 822 Email address list validation Utility
6 *
7 * PHP versions 4 and 5
8 *
9 * LICENSE:
10 *
11 * Copyright (c) 2001-2010, Richard Heyes
12 * All rights reserved.
13 *
14 * Redistribution and use in source and binary forms, with or without
15 * modification, are permitted provided that the following conditions
16 * are met:
17 *
18 * o Redistributions of source code must retain the above copyright
19 * notice, this list of conditions and the following disclaimer.
20 * o Redistributions in binary form must reproduce the above copyright
21 * notice, this list of conditions and the following disclaimer in the
22 * documentation and/or other materials provided with the distribution.
23 * o The names of the authors may not be used to endorse or promote
24 * products derived from this software without specific prior written
25 * permission.
26 *
27 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
28 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
29 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
30 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
31 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
32 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
33 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
34 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
35 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
36 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
37 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
38 *
39 * @category Mail
40 * @copyright 2001-2010 Richard Heyes
41 * @license http://opensource.org/licenses/bsd-license.php New BSD License
42 * @link http://pear.php.net/package/Mail/
43 */
44/**
45 * RFC 822 Email address list validation Utility
46 *
47 * What is it?
48 *
49 * This class will take an address string, and parse it into it's consituent
50 * parts, be that either addresses, groups, or combinations. Nested groups
51 * are not supported. The structure it returns is pretty straight forward,
52 * and is similar to that provided by the imap_rfc822_parse_adrlist(). Use
53 * print_r() to view the structure.
54 *
55 * How do I use it?
56 *
57 * $address_string = 'My Group: "Richard" <richard@localhost> (A comment), ted@example.com (Ted Bloggs), Barney;';
58 * $structure = Mail_RFC822::parseAddressList($address_string, 'example.com', TRUE)
59 * print_r($structure);
60 * @version $Revision: 294749 $
61 * @license BSD
62 */
63class Rfc822AddressesParser
64{
65    /**
66     * The address being parsed by the RFC822 object.
67     *
68     * @var string $address
69     */
70    private $address = '';
71
72    /**
73     * The default domain to use for unqualified addresses.
74     *
75     * @var string $default_domain
76     */
77    private $default_domain = 'localhost';
78
79    /**
80     * Whether or not to validate atoms for non-ascii characters.
81     *
82     * @var bool $validate
83     */
84    private $validate = true;
85
86    /**
87     * The array of raw addresses built up as we parse.
88     *
89     * @var array $addresses
90     */
91    private $addresses = [];
92
93    /**
94     * The final array of parsed address information that we build up.
95     *
96     * @var array $structure
97     */
98    private $structure = [];
99
100    /**
101     * The current error message, if any.
102     *
103     * @var string $error
104     */
105    private $error;
106
107    /**
108     * An internal counter/pointer.
109     *
110     * @var int $index
111     */
112    private $index;
113
114    /**
115     * The number of groups that have been found in the address list.
116     *
117     * @var int $num_groups
118     */
119    private $num_groups = 0;
120
121    /**
122     * A limit after which processing stops
123     *
124     * @var int $limit
125     */
126    private $limit;
127
128    /**
129     * Sets up the object.
130     *
131     * @param string $address The address(es) to validate.
132     * @param string $default_domain Default domain/host etc. If not supplied, will be set to localhost.
133     * @param bool $validate Whether to validate atoms. Turn this off if you need to run addresses through before encoding the personal names, for instance.
134     * @param int $limit
135     */
136    public function __construct($address = null, $default_domain = null, $validate = null, $limit = null)
137    {
138        if (isset($address)) {
139            $this->address = $address;
140        }
141        if (isset($default_domain)) {
142            $this->default_domain = $default_domain;
143        }
144        if (isset($validate)) {
145            $this->validate = $validate;
146        }
147        if (isset($limit)) {
148            $this->limit = $limit;
149        }
150    }
151
152    /**
153     * Starts the whole process. The address must either be set here
154     * or when creating the object. One or the other.
155     *
156     * @param string $address The address(es) to validate.
157     * @param string $default_domain Default domain/host etc.
158     * @param bool $validate Whether to validate atoms. Turn this off if you need to run addresses through before encoding the personal names, for instance.
159     * @param int $limit
160     * @return array A structured array of addresses.
161     */
162    public function parseAddressList($address = null, $default_domain = null, $validate = null, $limit = null)
163    {
164        if (isset($address)) {
165            $this->address = $address;
166        }
167        if (isset($default_domain)) {
168            $this->default_domain = $default_domain;
169        }
170        if (isset($validate)) {
171            $this->validate = $validate;
172        }
173        if (isset($limit)) {
174            $this->limit = $limit;
175        }
176        $this->structure = [];
177        $this->addresses = [];
178        $this->error = null;
179        $this->index = null;
180        // Unfold any long lines in $this->address.
181        $this->address = preg_replace('/\\r?\\n/', '
182', $this->address);
183        $this->address = preg_replace('/\\r\\n(\\t| )+/', ' ', $this->address);
184        while ($this->address = $this->_splitAddresses($this->address)) {
185        }
186        if ($this->address === false || isset($this->error)) {
187            throw new \InvalidArgumentException($this->error, 1294681466);
188        }
189        // Validate each address individually.  If we encounter an invalid
190        // address, stop iterating and return an error immediately.
191        foreach ($this->addresses as $address) {
192            $valid = $this->_validateAddress($address);
193            if ($valid === false || isset($this->error)) {
194                throw new \InvalidArgumentException($this->error, 1294681467);
195            }
196            $this->structure = array_merge($this->structure, $valid);
197        }
198        return $this->structure;
199    }
200
201    /**
202     * Splits an address into separate addresses.
203     *
204     * @internal
205     * @param string $address The addresses to split.
206     * @return bool Success or failure.
207     */
208    protected function _splitAddresses($address)
209    {
210        if (!empty($this->limit) && count($this->addresses) == $this->limit) {
211            return '';
212        }
213        if ($this->_isGroup($address) && !isset($this->error)) {
214            $split_char = ';';
215            $is_group = true;
216        } elseif (!isset($this->error)) {
217            $split_char = ',';
218            $is_group = false;
219        } elseif (isset($this->error)) {
220            return false;
221        }
222        // Split the string based on the above ten or so lines.
223        $parts = explode($split_char, $address);
224        $string = $this->_splitCheck($parts, $split_char);
225        // If a group...
226        if ($is_group) {
227            // If $string does not contain a colon outside of
228            // brackets/quotes etc then something's fubar.
229            // First check there's a colon at all:
230            if (strpos($string, ':') === false) {
231                $this->error = 'Invalid address: ' . $string;
232                return false;
233            }
234            // Now check it's outside of brackets/quotes:
235            if (!$this->_splitCheck(explode(':', $string), ':')) {
236                return false;
237            }
238            // We must have a group at this point, so increase the counter:
239            $this->num_groups++;
240        }
241        // $string now contains the first full address/group.
242        // Add to the addresses array.
243        $this->addresses[] = [
244            'address' => trim($string),
245            'group' => $is_group
246        ];
247        // Remove the now stored address from the initial line, the +1
248        // is to account for the explode character.
249        $address = trim(substr($address, strlen($string) + 1));
250        // If the next char is a comma and this was a group, then
251        // there are more addresses, otherwise, if there are any more
252        // chars, then there is another address.
253        if ($is_group && $address[0] === ',') {
254            $address = trim(substr($address, 1));
255            return $address;
256        }
257        if ($address !== '') {
258            return $address;
259        }
260        return '';
261    }
262
263    /**
264     * Checks for a group at the start of the string.
265     *
266     * @internal
267     * @param string $address The address to check.
268     * @return bool Whether or not there is a group at the start of the string.
269     */
270    protected function _isGroup($address)
271    {
272        // First comma not in quotes, angles or escaped:
273        $parts = explode(',', $address);
274        $string = $this->_splitCheck($parts, ',');
275        // Now we have the first address, we can reliably check for a
276        // group by searching for a colon that's not escaped or in
277        // quotes or angle brackets.
278        if (count($parts = explode(':', $string)) > 1) {
279            $string2 = $this->_splitCheck($parts, ':');
280            return $string2 !== $string;
281        }
282        return false;
283    }
284
285    /**
286     * A common function that will check an exploded string.
287     *
288     * @internal
289     * @param array $parts The exloded string.
290     * @param string $char  The char that was exploded on.
291     * @return mixed False if the string contains unclosed quotes/brackets, or the string on success.
292     */
293    protected function _splitCheck($parts, $char)
294    {
295        $string = $parts[0];
296        $partsCounter = count($parts);
297        for ($i = 0; $i < $partsCounter; $i++) {
298            if ($this->_hasUnclosedQuotes($string) || $this->_hasUnclosedBrackets($string, '<>') || $this->_hasUnclosedBrackets($string, '[]') || $this->_hasUnclosedBrackets($string, '()') || substr($string, -1) === '\\') {
299                if (isset($parts[$i + 1])) {
300                    $string = $string . $char . $parts[$i + 1];
301                } else {
302                    $this->error = 'Invalid address spec. Unclosed bracket or quotes';
303                    return false;
304                }
305            } else {
306                $this->index = $i;
307                break;
308            }
309        }
310        return $string;
311    }
312
313    /**
314     * Checks if a string has unclosed quotes or not.
315     *
316     * @internal
317     * @param string $string  The string to check.
318     * @return bool TRUE if there are unclosed quotes inside the string,
319     */
320    protected function _hasUnclosedQuotes($string)
321    {
322        $string = trim($string);
323        $iMax = strlen($string);
324        $in_quote = false;
325        $i = ($slashes = 0);
326        for (; $i < $iMax; ++$i) {
327            switch ($string[$i]) {
328                case '\\':
329                    ++$slashes;
330                    break;
331                case '"':
332                    if ($slashes % 2 == 0) {
333                        $in_quote = !$in_quote;
334                    }
335                    // no break
336                default:
337                    $slashes = 0;
338            }
339        }
340        return $in_quote;
341    }
342
343    /**
344     * Checks if a string has an unclosed brackets or not. IMPORTANT:
345     * This function handles both angle brackets and square brackets;
346     *
347     * @internal
348     * @param string $string The string to check.
349     * @param string $chars  The characters to check for.
350     * @return bool TRUE if there are unclosed brackets inside the string, FALSE otherwise.
351     */
352    protected function _hasUnclosedBrackets($string, $chars)
353    {
354        $num_angle_start = substr_count($string, $chars[0]);
355        $num_angle_end = substr_count($string, $chars[1]);
356        $this->_hasUnclosedBracketsSub($string, $num_angle_start, $chars[0]);
357        $this->_hasUnclosedBracketsSub($string, $num_angle_end, $chars[1]);
358        if ($num_angle_start < $num_angle_end) {
359            $this->error = 'Invalid address spec. Unmatched quote or bracket (' . $chars . ')';
360            return false;
361        }
362        return $num_angle_start > $num_angle_end;
363    }
364
365    /**
366     * Sub function that is used only by hasUnclosedBrackets().
367     *
368     * @internal
369     * @param string $string The string to check.
370     * @param int &$num	The number of occurrences.
371     * @param string $char   The character to count.
372     * @return int The number of occurrences of $char in $string, adjusted for backslashes.
373     */
374    protected function _hasUnclosedBracketsSub($string, &$num, $char)
375    {
376        $parts = explode($char, $string);
377        $partsCounter = count($parts);
378        for ($i = 0; $i < $partsCounter; $i++) {
379            if (substr($parts[$i], -1) === '\\' || $this->_hasUnclosedQuotes($parts[$i])) {
380                $num--;
381            }
382            if (isset($parts[$i + 1])) {
383                $parts[$i + 1] = $parts[$i] . $char . $parts[$i + 1];
384            }
385        }
386        return $num;
387    }
388
389    /**
390     * Function to begin checking the address.
391     *
392     * @internal
393     * @param string $address The address to validate.
394     * @return mixed False on failure, or a structured array of address information on success.
395     */
396    protected function _validateAddress($address)
397    {
398        $is_group = false;
399        $addresses = [];
400        if ($address['group']) {
401            $is_group = true;
402            // Get the group part of the name
403            $parts = explode(':', $address['address']);
404            $groupname = $this->_splitCheck($parts, ':');
405            $structure = [];
406            // And validate the group part of the name.
407            if (!$this->_validatePhrase($groupname)) {
408                $this->error = 'Group name did not validate.';
409                return false;
410            }
411            $address['address'] = ltrim(substr($address['address'], strlen($groupname . ':')));
412        }
413        // If a group then split on comma and put into an array.
414        // Otherwise, Just put the whole address in an array.
415        if ($is_group) {
416            while ($address['address'] !== '') {
417                $parts = explode(',', $address['address']);
418                $addresses[] = $this->_splitCheck($parts, ',');
419                $address['address'] = trim(substr($address['address'], strlen(end($addresses) . ',')));
420            }
421        } else {
422            $addresses[] = $address['address'];
423        }
424        // Check that $addresses is set, if address like this:
425        // Groupname:;
426        // Then errors were appearing.
427        if (empty($addresses)) {
428            $this->error = 'Empty group.';
429            return false;
430        }
431        // Trim the whitespace from all of the address strings.
432        array_map('trim', $addresses);
433        // Validate each mailbox.
434        // Format could be one of: name <geezer@domain.com>
435        //                         geezer@domain.com
436        //                         geezer
437        // ... or any other format valid by RFC 822.
438        $addressesCount = count($addresses);
439        for ($i = 0; $i < $addressesCount; $i++) {
440            if (!$this->validateMailbox($addresses[$i])) {
441                if (empty($this->error)) {
442                    $this->error = 'Validation failed for: ' . $addresses[$i];
443                }
444                return false;
445            }
446        }
447        if ($is_group) {
448            $structure = array_merge($structure, $addresses);
449        } else {
450            $structure = $addresses;
451        }
452        return $structure;
453    }
454
455    /**
456     * Function to validate a phrase.
457     *
458     * @internal
459     * @param string $phrase The phrase to check.
460     * @return bool Success or failure.
461     */
462    protected function _validatePhrase($phrase)
463    {
464        // Splits on one or more Tab or space.
465        $parts = preg_split('/[ \\x09]+/', $phrase, -1, PREG_SPLIT_NO_EMPTY);
466        $phrase_parts = [];
467        while (!empty($parts)) {
468            $phrase_parts[] = $this->_splitCheck($parts, ' ');
469            for ($i = 0; $i < $this->index + 1; $i++) {
470                array_shift($parts);
471            }
472        }
473        foreach ($phrase_parts as $part) {
474            // If quoted string:
475            if ($part[0] === '"') {
476                if (!$this->_validateQuotedString($part)) {
477                    return false;
478                }
479                continue;
480            }
481            // Otherwise it's an atom:
482            if (!$this->_validateAtom($part)) {
483                return false;
484            }
485        }
486        return true;
487    }
488
489    /**
490     * Function to validate an atom which from rfc822 is:
491     * atom = 1*<any CHAR except specials, SPACE and CTLs>
492     *
493     * If validation ($this->validate) has been turned off, then
494     * validateAtom() doesn't actually check anything. This is so that you
495     * can split a list of addresses up before encoding personal names
496     * (umlauts, etc.), for example.
497     *
498     * @internal
499     * @param string $atom The string to check.
500     * @return bool Success or failure.
501     */
502    protected function _validateAtom($atom)
503    {
504        if (!$this->validate) {
505            // Validation has been turned off; assume the atom is okay.
506            return true;
507        }
508        // Check for any char from ASCII 0 - ASCII 127
509        if (!preg_match('/^[\\x00-\\x7E]+$/i', $atom, $matches)) {
510            return false;
511        }
512        // Check for specials:
513        if (preg_match('/[][()<>@,;\\:". ]/', $atom)) {
514            return false;
515        }
516        // Check for control characters (ASCII 0-31):
517        if (preg_match('/[\\x00-\\x1F]+/', $atom)) {
518            return false;
519        }
520        return true;
521    }
522
523    /**
524     * Function to validate quoted string, which is:
525     * quoted-string = <"> *(qtext/quoted-pair) <">
526     *
527     * @internal
528     * @param string $qstring The string to check
529     * @return bool Success or failure.
530     */
531    protected function _validateQuotedString($qstring)
532    {
533        // Leading and trailing "
534        $qstring = substr($qstring, 1, -1);
535        // Perform check, removing quoted characters first.
536        return !preg_match('/[\\x0D\\\\"]/', preg_replace('/\\\\./', '', $qstring));
537    }
538
539    /**
540     * Function to validate a mailbox, which is:
541     * mailbox =   addr-spec		 ; simple address
542     * phrase route-addr ; name and route-addr
543     *
544     * @param string &$mailbox The string to check.
545     * @return bool Success or failure.
546     */
547    protected function validateMailbox(&$mailbox)
548    {
549        // A couple of defaults.
550        $phrase = '';
551        $comments = [];
552        // Catch any RFC822 comments and store them separately.
553        $_mailbox = $mailbox;
554        while (trim($_mailbox) !== '') {
555            $parts = explode('(', $_mailbox);
556            $before_comment = $this->_splitCheck($parts, '(');
557            if ($before_comment != $_mailbox) {
558                // First char should be a (.
559                $comment = substr(str_replace($before_comment, '', $_mailbox), 1);
560                $parts = explode(')', $comment);
561                $comment = $this->_splitCheck($parts, ')');
562                $comments[] = $comment;
563                // +2 is for the brackets
564                $_mailbox = substr($_mailbox, strpos($_mailbox, '(' . $comment) + strlen($comment) + 2);
565            } else {
566                break;
567            }
568        }
569        foreach ($comments as $comment) {
570            $mailbox = str_replace('(' . $comment . ')', '', $mailbox);
571        }
572        $mailbox = trim($mailbox);
573        // Check for name + route-addr
574        if (substr($mailbox, -1) === '>' && $mailbox[0] !== '<') {
575            $parts = explode('<', $mailbox);
576            $name = $this->_splitCheck($parts, '<');
577            $phrase = trim($name);
578            $route_addr = trim(substr($mailbox, strlen($name . '<'), -1));
579            if ($this->_validatePhrase($phrase) === false || ($route_addr = $this->_validateRouteAddr($route_addr)) === false) {
580                return false;
581            }
582        } else {
583            // First snip angle brackets if present.
584            if ($mailbox[0] === '<' && substr($mailbox, -1) === '>') {
585                $addr_spec = substr($mailbox, 1, -1);
586            } else {
587                $addr_spec = $mailbox;
588            }
589            if (($addr_spec = $this->_validateAddrSpec($addr_spec)) === false) {
590                return false;
591            }
592        }
593        // Construct the object that will be returned.
594        $mbox = new \stdClass();
595        // Add the phrase (even if empty) and comments
596        $mbox->personal = $phrase;
597        $mbox->comment = $comments ?? [];
598        if (isset($route_addr)) {
599            $mbox->mailbox = $route_addr['local_part'];
600            $mbox->host = $route_addr['domain'];
601            $route_addr['adl'] !== '' ? ($mbox->adl = $route_addr['adl']) : '';
602        } else {
603            $mbox->mailbox = $addr_spec['local_part'];
604            $mbox->host = $addr_spec['domain'];
605        }
606        $mailbox = $mbox;
607        return true;
608    }
609
610    /**
611     * This function validates a route-addr which is:
612     * route-addr = "<" [route] addr-spec ">"
613     *
614     * Angle brackets have already been removed at the point of
615     * getting to this function.
616     *
617     * @internal
618     * @param string $route_addr The string to check.
619     * @return mixed False on failure, or an array containing validated address/route information on success.
620     */
621    protected function _validateRouteAddr($route_addr)
622    {
623        // Check for colon.
624        if (strpos($route_addr, ':') !== false) {
625            $parts = explode(':', $route_addr);
626            $route = $this->_splitCheck($parts, ':');
627        } else {
628            $route = $route_addr;
629        }
630        // If $route is same as $route_addr then the colon was in
631        // quotes or brackets or, of course, non existent.
632        if ($route === $route_addr) {
633            unset($route);
634            $addr_spec = $route_addr;
635            if (($addr_spec = $this->_validateAddrSpec($addr_spec)) === false) {
636                return false;
637            }
638        } else {
639            // Validate route part.
640            if (($route = $this->_validateRoute($route)) === false) {
641                return false;
642            }
643            $addr_spec = substr($route_addr, strlen($route . ':'));
644            // Validate addr-spec part.
645            if (($addr_spec = $this->_validateAddrSpec($addr_spec)) === false) {
646                return false;
647            }
648        }
649        if (isset($route)) {
650            $return['adl'] = $route;
651        } else {
652            $return['adl'] = '';
653        }
654        $return = array_merge($return, $addr_spec);
655        return $return;
656    }
657
658    /**
659     * Function to validate a route, which is:
660     * route = 1#("@" domain) ":"
661     *
662     * @internal
663     * @param string $route The string to check.
664     * @return mixed False on failure, or the validated $route on success.
665     */
666    protected function _validateRoute($route)
667    {
668        // Split on comma.
669        $domains = explode(',', trim($route));
670        foreach ($domains as $domain) {
671            $domain = str_replace('@', '', trim($domain));
672            if (!$this->_validateDomain($domain)) {
673                return false;
674            }
675        }
676        return $route;
677    }
678
679    /**
680     * Function to validate a domain, though this is not quite what
681     * you expect of a strict internet domain.
682     *
683     * domain = sub-domain *("." sub-domain)
684     *
685     * @internal
686     * @param string $domain The string to check.
687     * @return mixed False on failure, or the validated domain on success.
688     */
689    protected function _validateDomain($domain)
690    {
691        // Note the different use of $subdomains and $sub_domains
692        $subdomains = explode('.', $domain);
693        while (!empty($subdomains)) {
694            $sub_domains[] = $this->_splitCheck($subdomains, '.');
695            for ($i = 0; $i < $this->index + 1; $i++) {
696                array_shift($subdomains);
697            }
698        }
699        foreach ($sub_domains as $sub_domain) {
700            if (!$this->_validateSubdomain(trim($sub_domain))) {
701                return false;
702            }
703        }
704        // Managed to get here, so return input.
705        return $domain;
706    }
707
708    /**
709     * Function to validate a subdomain:
710     * subdomain = domain-ref / domain-literal
711     *
712     * @internal
713     * @param string $subdomain The string to check.
714     * @return bool Success or failure.
715     */
716    protected function _validateSubdomain($subdomain)
717    {
718        if (preg_match('|^\\[(.*)]$|', $subdomain, $arr)) {
719            if (!$this->_validateDliteral($arr[1])) {
720                return false;
721            }
722        } else {
723            if (!$this->_validateAtom($subdomain)) {
724                return false;
725            }
726        }
727        // Got here, so return successful.
728        return true;
729    }
730
731    /**
732     * Function to validate a domain literal:
733     * domain-literal =  "[" *(dtext / quoted-pair) "]"
734     *
735     * @internal
736     * @param string $dliteral The string to check.
737     * @return bool Success or failure.
738     */
739    protected function _validateDliteral($dliteral)
740    {
741        return !preg_match('/(.)[][\\x0D\\\\]/', $dliteral, $matches) && $matches[1] !== '\\';
742    }
743
744    /**
745     * Function to validate an addr-spec.
746     *
747     * addr-spec = local-part "@" domain
748     *
749     * @internal
750     * @param string $addr_spec The string to check.
751     * @return mixed False on failure, or the validated addr-spec on success.
752     */
753    protected function _validateAddrSpec($addr_spec)
754    {
755        $addr_spec = trim($addr_spec);
756        // Split on @ sign if there is one.
757        if (strpos($addr_spec, '@') !== false) {
758            $parts = explode('@', $addr_spec);
759            $local_part = $this->_splitCheck($parts, '@');
760            $domain = substr($addr_spec, strlen($local_part . '@'));
761        } else {
762            $local_part = $addr_spec;
763            $domain = $this->default_domain;
764        }
765        if (($local_part = $this->_validateLocalPart($local_part)) === false) {
766            return false;
767        }
768        if (($domain = $this->_validateDomain($domain)) === false) {
769            return false;
770        }
771        // Got here so return successful.
772        return ['local_part' => $local_part, 'domain' => $domain];
773    }
774
775    /**
776     * Function to validate the local part of an address:
777     * local-part = word *("." word)
778     *
779     * @internal
780     * @param string $local_part
781     * @return mixed False on failure, or the validated local part on success.
782     */
783    protected function _validateLocalPart($local_part)
784    {
785        $parts = explode('.', $local_part);
786        $words = [];
787        // Split the local_part into words.
788        while (!empty($parts)) {
789            $words[] = $this->_splitCheck($parts, '.');
790            for ($i = 0; $i < $this->index + 1; $i++) {
791                array_shift($parts);
792            }
793        }
794        // Validate each word.
795        foreach ($words as $word) {
796            // If this word contains an unquoted space, it is invalid. (6.2.4)
797            if (strpos($word, ' ') && $word[0] !== '"') {
798                return false;
799            }
800            if ($this->_validatePhrase(trim($word)) === false) {
801                return false;
802            }
803        }
804        // Managed to get here, so return the input.
805        return $local_part;
806    }
807}
808