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