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