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