1<?php
2
3/**
4 * @see       https://github.com/laminas/laminas-mail for the canonical source repository
5 * @copyright https://github.com/laminas/laminas-mail/blob/master/COPYRIGHT.md
6 * @license   https://github.com/laminas/laminas-mail/blob/master/LICENSE.md New BSD License
7 */
8
9namespace Laminas\Mail\Header;
10
11use Laminas\Mail\Address;
12use Laminas\Mail\AddressList;
13use Laminas\Mail\Headers;
14use TrueBV\Exception\OutOfBoundsException;
15use TrueBV\Punycode;
16
17/**
18 * Base class for headers composing address lists (to, from, cc, bcc, reply-to)
19 */
20abstract class AbstractAddressList implements HeaderInterface
21{
22    /**
23     * @var AddressList
24     */
25    protected $addressList;
26
27    /**
28     * @var string Normalized field name
29     */
30    protected $fieldName;
31
32    /**
33     * Header encoding
34     *
35     * @var string
36     */
37    protected $encoding = 'ASCII';
38
39    /**
40     * @var string lower case field name
41     */
42    protected static $type;
43
44    /**
45     * @var Punycode|null
46     */
47    private static $punycode;
48
49    public static function fromString($headerLine)
50    {
51        list($fieldName, $fieldValue) = GenericHeader::splitHeaderLine($headerLine);
52        if (strtolower($fieldName) !== static::$type) {
53            throw new Exception\InvalidArgumentException(sprintf(
54                'Invalid header line for "%s" string',
55                __CLASS__
56            ));
57        }
58
59        // split value on ","
60        $fieldValue = str_replace(Headers::FOLDING, ' ', $fieldValue);
61        $fieldValue = preg_replace('/[^:]+:([^;]*);/', '$1,', $fieldValue);
62        $values = ListParser::parse($fieldValue);
63
64        $wasEncoded = false;
65        $addresses = array_map(
66            function ($value) use (&$wasEncoded) {
67                $decodedValue = HeaderWrap::mimeDecodeValue($value);
68                $wasEncoded = $wasEncoded || ($decodedValue !== $value);
69
70                $value = trim($decodedValue);
71
72                $comments = self::getComments($value);
73                $value = self::stripComments($value);
74
75                $value = preg_replace(
76                    [
77                        '#(?<!\\\)"(.*)(?<!\\\)"#',            // quoted-text
78                        '#\\\([\x01-\x09\x0b\x0c\x0e-\x7f])#', // quoted-pair
79                    ],
80                    [
81                        '\\1',
82                        '\\1',
83                    ],
84                    $value
85                );
86
87                return empty($value) ? null : Address::fromString($value, $comments);
88            },
89            $values
90        );
91        $addresses = array_filter($addresses);
92
93        $header = new static();
94        if ($wasEncoded) {
95            $header->setEncoding('UTF-8');
96        }
97
98        /** @var AddressList $addressList */
99        $addressList = $header->getAddressList();
100        foreach ($addresses as $address) {
101            $addressList->add($address);
102        }
103
104        return $header;
105    }
106
107    public function getFieldName()
108    {
109        return $this->fieldName;
110    }
111
112    /**
113     * Safely convert UTF-8 encoded domain name to ASCII
114     * @param string $domainName the UTF-8 encoded email
115     * @return string
116     */
117    protected function idnToAscii($domainName)
118    {
119        if (null === self::$punycode) {
120            self::$punycode = new Punycode();
121        }
122        try {
123            return self::$punycode->encode($domainName);
124        } catch (OutOfBoundsException $e) {
125            return $domainName;
126        }
127    }
128
129    public function getFieldValue($format = HeaderInterface::FORMAT_RAW)
130    {
131        $emails   = [];
132        $encoding = $this->getEncoding();
133
134        foreach ($this->getAddressList() as $address) {
135            $email = $address->getEmail();
136            $name  = $address->getName();
137
138            // quote $name if value requires so
139            if (! empty($name) && (false !== strpos($name, ',') || false !== strpos($name, ';'))) {
140                // FIXME: what if name contains double quote?
141                $name = sprintf('"%s"', $name);
142            }
143
144            if ($format === HeaderInterface::FORMAT_ENCODED
145                && 'ASCII' !== $encoding
146            ) {
147                if (! empty($name)) {
148                    $name = HeaderWrap::mimeEncodeValue($name, $encoding);
149                }
150
151                if (preg_match('/^(.+)@([^@]+)$/', $email, $matches)) {
152                    $localPart = $matches[1];
153                    $hostname  = $this->idnToAscii($matches[2]);
154                    $email = sprintf('%s@%s', $localPart, $hostname);
155                }
156            }
157
158            if (empty($name)) {
159                $emails[] = $email;
160            } else {
161                $emails[] = sprintf('%s <%s>', $name, $email);
162            }
163        }
164
165        // Ensure the values are valid before sending them.
166        if ($format !== HeaderInterface::FORMAT_RAW) {
167            foreach ($emails as $email) {
168                HeaderValue::assertValid($email);
169            }
170        }
171
172        return implode(',' . Headers::FOLDING, $emails);
173    }
174
175    public function setEncoding($encoding)
176    {
177        $this->encoding = $encoding;
178        return $this;
179    }
180
181    public function getEncoding()
182    {
183        return $this->encoding;
184    }
185
186    /**
187     * Set address list for this header
188     *
189     * @param  AddressList $addressList
190     */
191    public function setAddressList(AddressList $addressList)
192    {
193        $this->addressList = $addressList;
194    }
195
196    /**
197     * Get address list managed by this header
198     *
199     * @return AddressList
200     */
201    public function getAddressList()
202    {
203        if (null === $this->addressList) {
204            $this->setAddressList(new AddressList());
205        }
206        return $this->addressList;
207    }
208
209    public function toString()
210    {
211        $name  = $this->getFieldName();
212        $value = $this->getFieldValue(HeaderInterface::FORMAT_ENCODED);
213        return (empty($value)) ? '' : sprintf('%s: %s', $name, $value);
214    }
215
216    /**
217     * Retrieve comments from value, if any.
218     *
219     * Supposed to be private, protected as a workaround for PHP bug 68194
220     *
221     * @param string $value
222     * @return string
223     */
224    protected static function getComments($value)
225    {
226        $matches = [];
227        preg_match_all(
228            '/\\(
229                (?P<comment>(
230                    \\\\.|
231                    [^\\\\)]
232                )+)
233            \\)/x',
234            $value,
235            $matches
236        );
237        return isset($matches['comment']) ? implode(', ', $matches['comment']) : '';
238    }
239
240    /**
241     * Strip all comments from value, if any.
242     *
243     * Supposed to be private, protected as a workaround for PHP bug 68194
244     *
245     * @param string $value
246     * @return string
247     */
248    protected static function stripComments($value)
249    {
250        return preg_replace(
251            '/\\(
252                (
253                    \\\\.|
254                    [^\\\\)]
255                )+
256            \\)/x',
257            '',
258            $value
259        );
260    }
261}
262