1<?php
2/**
3 * @see       https://github.com/zendframework/zend-mail for the canonical source repository
4 * @copyright Copyright (c) 2005-2018 Zend Technologies USA Inc. (https://www.zend.com)
5 * @license   https://github.com/zendframework/zend-mail/blob/master/LICENSE.md New BSD License
6 */
7
8namespace Zend\Mail;
9
10use ArrayIterator;
11use Countable;
12use Iterator;
13use Traversable;
14use Zend\Loader\PluginClassLocator;
15use Zend\Mail\Header\GenericHeader;
16use Zend\Mail\Header\HeaderInterface;
17
18/**
19 * Basic mail headers collection functionality
20 *
21 * Handles aggregation of headers
22 */
23class Headers implements Countable, Iterator
24{
25    /** @var string End of Line for fields */
26    const EOL = "\r\n";
27
28    /** @var string Start of Line when folding */
29    const FOLDING = "\r\n ";
30
31    /**
32     * @var \Zend\Loader\PluginClassLoader
33     */
34    protected $pluginClassLoader = null;
35
36    /**
37     * @var array key names for $headers array
38     */
39    protected $headersKeys = [];
40
41    /**
42     * @var  Header\HeaderInterface[] instances
43     */
44    protected $headers = [];
45
46    /**
47     * Header encoding; defaults to ASCII
48     *
49     * @var string
50     */
51    protected $encoding = 'ASCII';
52
53    /**
54     * Populates headers from string representation
55     *
56     * Parses a string for headers, and aggregates them, in order, in the
57     * current instance, primarily as strings until they are needed (they
58     * will be lazy loaded)
59     *
60     * @param  string $string
61     * @param  string $EOL EOL string; defaults to {@link EOL}
62     * @throws Exception\RuntimeException
63     * @return Headers
64     */
65    public static function fromString($string, $EOL = self::EOL)
66    {
67        $headers     = new static();
68        $currentLine = '';
69        $emptyLine   = 0;
70
71        // iterate the header lines, some might be continuations
72        $lines = explode($EOL, $string);
73        $total = count($lines);
74        for ($i = 0; $i < $total; $i += 1) {
75            $line = $lines[$i];
76
77            // Empty line indicates end of headers
78            // EXCEPT if there are more lines, in which case, there's a possible error condition
79            if (preg_match('/^\s*$/', $line)) {
80                $emptyLine += 1;
81                if ($emptyLine > 2) {
82                    throw new Exception\RuntimeException('Malformed header detected');
83                }
84                continue;
85            }
86
87            if ($emptyLine > 1) {
88                throw new Exception\RuntimeException('Malformed header detected');
89            }
90
91            // check if a header name is present
92            if (preg_match('/^[\x21-\x39\x3B-\x7E]+:.*$/', $line)) {
93                if ($currentLine) {
94                    // a header name was present, then store the current complete line
95                    $headers->addHeaderLine($currentLine);
96                }
97                $currentLine = trim($line);
98                continue;
99            }
100
101            // continuation: append to current line
102            // recover the whitespace that break the line (unfolding, rfc2822#section-2.2.3)
103            if (preg_match('/^\s+.*$/', $line)) {
104                $currentLine .= ' ' . trim($line);
105                continue;
106            }
107
108            // Line does not match header format!
109            throw new Exception\RuntimeException(sprintf(
110                'Line "%s" does not match header format!',
111                $line
112            ));
113        }
114        if ($currentLine) {
115            $headers->addHeaderLine($currentLine);
116        }
117        return $headers;
118    }
119
120    /**
121     * Set an alternate implementation for the PluginClassLoader
122     *
123     * @param  PluginClassLocator $pluginClassLoader
124     * @return Headers
125     */
126    public function setPluginClassLoader(PluginClassLocator $pluginClassLoader)
127    {
128        $this->pluginClassLoader = $pluginClassLoader;
129        return $this;
130    }
131
132    /**
133     * Return an instance of a PluginClassLocator, lazyload and inject map if necessary
134     *
135     * @return PluginClassLocator
136     */
137    public function getPluginClassLoader()
138    {
139        if ($this->pluginClassLoader === null) {
140            $this->pluginClassLoader = new Header\HeaderLoader();
141        }
142        return $this->pluginClassLoader;
143    }
144
145    /**
146     * Set the header encoding
147     *
148     * @param  string $encoding
149     * @return Headers
150     */
151    public function setEncoding($encoding)
152    {
153        $this->encoding = $encoding;
154        foreach ($this as $header) {
155            $header->setEncoding($encoding);
156        }
157        return $this;
158    }
159
160    /**
161     * Get the header encoding
162     *
163     * @return string
164     */
165    public function getEncoding()
166    {
167        return $this->encoding;
168    }
169
170    /**
171     * Add many headers at once
172     *
173     * Expects an array (or Traversable object) of type/value pairs.
174     *
175     * @param  array|Traversable $headers
176     * @throws Exception\InvalidArgumentException
177     * @return Headers
178     */
179    public function addHeaders($headers)
180    {
181        if (! is_array($headers) && ! $headers instanceof Traversable) {
182            throw new Exception\InvalidArgumentException(sprintf(
183                'Expected array or Traversable; received "%s"',
184                (is_object($headers) ? get_class($headers) : gettype($headers))
185            ));
186        }
187
188        foreach ($headers as $name => $value) {
189            if (is_int($name)) {
190                if (is_string($value)) {
191                    $this->addHeaderLine($value);
192                } elseif (is_array($value) && count($value) == 1) {
193                    $this->addHeaderLine(key($value), current($value));
194                } elseif (is_array($value) && count($value) == 2) {
195                    $this->addHeaderLine($value[0], $value[1]);
196                } elseif ($value instanceof Header\HeaderInterface) {
197                    $this->addHeader($value);
198                }
199            } elseif (is_string($name)) {
200                $this->addHeaderLine($name, $value);
201            }
202        }
203
204        return $this;
205    }
206
207    /**
208     * Add a raw header line, either in name => value, or as a single string 'name: value'
209     *
210     * This method allows for lazy-loading in that the parsing and instantiation of HeaderInterface object
211     * will be delayed until they are retrieved by either get() or current()
212     *
213     * @throws Exception\InvalidArgumentException
214     * @param  string $headerFieldNameOrLine
215     * @param  string $fieldValue optional
216     * @return Headers
217     */
218    public function addHeaderLine($headerFieldNameOrLine, $fieldValue = null)
219    {
220        if (! is_string($headerFieldNameOrLine)) {
221            throw new Exception\InvalidArgumentException(sprintf(
222                '%s expects its first argument to be a string; received "%s"',
223                __METHOD__,
224                (is_object($headerFieldNameOrLine)
225                ? get_class($headerFieldNameOrLine)
226                : gettype($headerFieldNameOrLine))
227            ));
228        }
229
230        if ($fieldValue === null) {
231            $headers = $this->loadHeader($headerFieldNameOrLine);
232            $headers = is_array($headers) ? $headers : [$headers];
233            foreach ($headers as $header) {
234                $this->addHeader($header);
235            }
236        } elseif (is_array($fieldValue)) {
237            foreach ($fieldValue as $i) {
238                $this->addHeader(Header\GenericMultiHeader::fromString($headerFieldNameOrLine . ':' . $i));
239            }
240        } else {
241            $this->addHeader(Header\GenericHeader::fromString($headerFieldNameOrLine . ':' . $fieldValue));
242        }
243
244        return $this;
245    }
246
247    /**
248     * Add a Header\Interface to this container, for raw values see {@link addHeaderLine()} and {@link addHeaders()}
249     *
250     * @param  Header\HeaderInterface $header
251     * @return Headers
252     */
253    public function addHeader(Header\HeaderInterface $header)
254    {
255        $key = $this->normalizeFieldName($header->getFieldName());
256        $this->headersKeys[] = $key;
257        $this->headers[] = $header;
258        if ($this->getEncoding() !== 'ASCII') {
259            $header->setEncoding($this->getEncoding());
260        }
261        return $this;
262    }
263
264    /**
265     * Remove a Header from the container
266     *
267     * @param  string|Header\HeaderInterface field name or specific header instance to remove
268     * @return bool
269     */
270    public function removeHeader($instanceOrFieldName)
271    {
272        if ($instanceOrFieldName instanceof Header\HeaderInterface) {
273            $indexes = array_keys($this->headers, $instanceOrFieldName, true);
274        } else {
275            $key = $this->normalizeFieldName($instanceOrFieldName);
276            $indexes = array_keys($this->headersKeys, $key, true);
277        }
278
279        if (! empty($indexes)) {
280            foreach ($indexes as $index) {
281                unset($this->headersKeys[$index]);
282                unset($this->headers[$index]);
283            }
284            return true;
285        }
286
287        return false;
288    }
289
290    /**
291     * Clear all headers
292     *
293     * Removes all headers from queue
294     *
295     * @return Headers
296     */
297    public function clearHeaders()
298    {
299        $this->headers = $this->headersKeys = [];
300        return $this;
301    }
302
303    /**
304     * Get all headers of a certain name/type
305     *
306     * @param  string $name
307     * @return bool|ArrayIterator|Header\HeaderInterface Returns false if there is no headers with $name in this
308     * contain, an ArrayIterator if the header is a MultipleHeadersInterface instance and finally returns
309     * HeaderInterface for the rest of cases.
310     */
311    public function get($name)
312    {
313        $key = $this->normalizeFieldName($name);
314        $results = [];
315
316        foreach (array_keys($this->headersKeys, $key) as $index) {
317            if ($this->headers[$index] instanceof Header\GenericHeader) {
318                $results[] = $this->lazyLoadHeader($index);
319            } else {
320                $results[] = $this->headers[$index];
321            }
322        }
323
324        switch (count($results)) {
325            case 0:
326                return false;
327            case 1:
328                if ($results[0] instanceof Header\MultipleHeadersInterface) {
329                    return new ArrayIterator($results);
330                } else {
331                    return $results[0];
332                }
333                //fall-trough
334            default:
335                return new ArrayIterator($results);
336        }
337    }
338
339    /**
340     * Test for existence of a type of header
341     *
342     * @param  string $name
343     * @return bool
344     */
345    public function has($name)
346    {
347        $name = $this->normalizeFieldName($name);
348        return in_array($name, $this->headersKeys);
349    }
350
351    /**
352     * Advance the pointer for this object as an iterator
353     *
354     */
355    public function next()
356    {
357        next($this->headers);
358    }
359
360    /**
361     * Return the current key for this object as an iterator
362     *
363     * @return mixed
364     */
365    public function key()
366    {
367        return key($this->headers);
368    }
369
370    /**
371     * Is this iterator still valid?
372     *
373     * @return bool
374     */
375    public function valid()
376    {
377        return (current($this->headers) !== false);
378    }
379
380    /**
381     * Reset the internal pointer for this object as an iterator
382     *
383     */
384    public function rewind()
385    {
386        reset($this->headers);
387    }
388
389    /**
390     * Return the current value for this iterator, lazy loading it if need be
391     *
392     * @return Header\HeaderInterface
393     */
394    public function current()
395    {
396        $current = current($this->headers);
397        if ($current instanceof Header\GenericHeader) {
398            $current = $this->lazyLoadHeader(key($this->headers));
399        }
400        return $current;
401    }
402
403    /**
404     * Return the number of headers in this contain, if all headers have not been parsed, actual count could
405     * increase if MultipleHeader objects exist in the Request/Response.  If you need an exact count, iterate
406     *
407     * @return int count of currently known headers
408     */
409    public function count()
410    {
411        return count($this->headers);
412    }
413
414    /**
415     * Render all headers at once
416     *
417     * This method handles the normal iteration of headers; it is up to the
418     * concrete classes to prepend with the appropriate status/request line.
419     *
420     * @return string
421     */
422    public function toString()
423    {
424        $headers = '';
425        foreach ($this as $header) {
426            if ($str = $header->toString()) {
427                $headers .= $str . self::EOL;
428            }
429        }
430
431        return $headers;
432    }
433
434    /**
435     * Return the headers container as an array
436     *
437     * @param  bool $format Return the values in Mime::Encoded or in Raw format
438     * @return array
439     * @todo determine how to produce single line headers, if they are supported
440     */
441    public function toArray($format = Header\HeaderInterface::FORMAT_RAW)
442    {
443        $headers = [];
444        /* @var $header Header\HeaderInterface */
445        foreach ($this->headers as $header) {
446            if ($header instanceof Header\MultipleHeadersInterface) {
447                $name = $header->getFieldName();
448                if (! isset($headers[$name])) {
449                    $headers[$name] = [];
450                }
451                $headers[$name][] = $header->getFieldValue($format);
452            } else {
453                $headers[$header->getFieldName()] = $header->getFieldValue($format);
454            }
455        }
456        return $headers;
457    }
458
459    /**
460     * By calling this, it will force parsing and loading of all headers, after this count() will be accurate
461     *
462     * @return bool
463     */
464    public function forceLoading()
465    {
466        foreach ($this as $item) {
467            // $item should now be loaded
468        }
469        return true;
470    }
471
472    /**
473     * Create Header object from header line
474     *
475     * @param string $headerLine
476     * @return Header\HeaderInterface|Header\HeaderInterface[]
477     */
478    public function loadHeader($headerLine)
479    {
480        list($name, ) = Header\GenericHeader::splitHeaderLine($headerLine);
481
482        /** @var HeaderInterface $class */
483        $class = $this->getPluginClassLoader()->load($name) ?: Header\GenericHeader::class;
484        return $class::fromString($headerLine);
485    }
486
487    /**
488     * @param $index
489     * @return mixed
490     */
491    protected function lazyLoadHeader($index)
492    {
493        $current = $this->headers[$index];
494
495        $key   = $this->headersKeys[$index];
496
497        /** @var GenericHeader $class */
498        $class = ($this->getPluginClassLoader()->load($key)) ?: 'Zend\Mail\Header\GenericHeader';
499
500        $encoding = $current->getEncoding();
501        $headers  = $class::fromString($current->toString());
502        if (is_array($headers)) {
503            $current = array_shift($headers);
504            $current->setEncoding($encoding);
505            $this->headers[$index] = $current;
506            foreach ($headers as $header) {
507                $header->setEncoding($encoding);
508                $this->headersKeys[] = $key;
509                $this->headers[]     = $header;
510            }
511            return $current;
512        }
513
514        $current = $headers;
515        $current->setEncoding($encoding);
516        $this->headers[$index] = $current;
517        return $current;
518    }
519
520    /**
521     * Normalize a field name
522     *
523     * @param  string $fieldName
524     * @return string
525     */
526    protected function normalizeFieldName($fieldName)
527    {
528        return str_replace(['-', '_', ' ', '.'], '', strtolower($fieldName));
529    }
530}
531