1: <?php
2: /**
3: * This class contains functions related to handling the headers of MIME data.
4: *
5: * Copyright 2002-2012 Horde LLC (http://www.horde.org/)
6: *
7: * See the enclosed file COPYING for license information (LGPL). If you
8: * did not receive this file, see http://www.horde.org/licenses/lgpl21.
9: *
10: * @author Michael Slusarz <slusarz@horde.org>
11: * @category Horde
12: * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
13: * @package Mime
14: */
15: class Horde_Mime_Headers implements Serializable
16: {
17: /* Serialized version. */
18: const VERSION = 1;
19:
20: /* Constants for getValue(). */
21: const VALUE_STRING = 1;
22: const VALUE_BASE = 2;
23: const VALUE_PARAMS = 3;
24:
25: /**
26: * The default charset to use when parsing text parts with no charset
27: * information.
28: *
29: * @var string
30: */
31: static public $defaultCharset = 'us-ascii';
32:
33: /**
34: * The internal headers array.
35: *
36: * Keys are the lowercase header name.
37: * Values are:
38: * - h: The case-sensitive header name.
39: * - p: Parameters for this header.
40: * - v: The value of the header. Values are stored in UTF-8.
41: *
42: * @var array
43: */
44: protected $_headers = array();
45:
46: /**
47: * The sequence to use as EOL for the headers.
48: * The default is currently to output the EOL sequence internally as
49: * just "\n" instead of the canonical "\r\n" required in RFC 822 & 2045.
50: * To be RFC complaint, the full <CR><LF> EOL combination should be used
51: * when sending a message.
52: *
53: * @var string
54: */
55: protected $_eol = "\n";
56:
57: /**
58: * The User-Agent string to use.
59: *
60: * @var string
61: */
62: protected $_agent = null;
63:
64: /**
65: * List of single header fields.
66: *
67: * @var array
68: */
69: protected $_singleFields = array(
70: // Mail: RFC 5322
71: 'to', 'from', 'cc', 'bcc', 'date', 'sender', 'reply-to',
72: 'message-id', 'in-reply-to', 'references', 'subject', 'x-priority',
73: // MIME: RFC 1864
74: 'content-md5',
75: // MIME: RFC 2045
76: 'mime-version', 'content-type', 'content-transfer-encoding',
77: 'content-id', 'content-description',
78: // MIME: RFC 2110
79: 'content-base',
80: // MIME: RFC 2183
81: 'content-disposition',
82: // MIME: RFC 2424
83: 'content-duration',
84: // MIME: RFC 2557
85: 'content-location',
86: // MIME: RFC 2912 [3]
87: 'content-features',
88: // MIME: RFC 3282
89: 'content-language',
90: // MIME: RFC 3297
91: 'content-alternative'
92: );
93:
94: /**
95: * Returns the internal header array in array format.
96: *
97: * @param array $options Optional parameters:
98: * - canonical: (boolean) Use canonical (RFC 822/2045) line endings?
99: * DEFAULT: Uses $this->_eol
100: * - charset: (string) Encodes the headers using this charset. If empty,
101: * encodes using internal charset (UTF-8).
102: * DEFAULT: No encoding.
103: * - defserver: (string) The default domain to append to mailboxes.
104: * DEFAULT: No default name.
105: * - nowrap: (integer) Don't wrap the headers.
106: * DEFAULT: Headers are wrapped.
107: *
108: * @return array The headers in array format.
109: */
110: public function toArray(array $options = array())
111: {
112: $address_keys = $this->addressFields();
113: $charset = array_key_exists('charset', $options)
114: ? (empty($options['charset']) ? 'UTF-8' : $options['charset'])
115: : null;
116: $eol = empty($options['canonical'])
117: ? $this->_eol
118: : "\r\n";
119: $mime = $this->mimeParamFields();
120: $ret = array();
121:
122: foreach ($this->_headers as $header => $ob) {
123: $val = is_array($ob['v']) ? $ob['v'] : array($ob['v']);
124:
125: foreach (array_keys($val) as $key) {
126: if (in_array($header, $address_keys) ) {
127: /* Address encoded headers. */
128: try {
129: $text = Horde_Mime::encodeAddress(Horde_String::convertCharset($val[$key], 'UTF-8', $charset), $charset, empty($options['defserver']) ? null : $options['defserver']);
130: } catch (Horde_Mime_Exception $e) {
131: $text = $val[$key];
132: }
133: } elseif (in_array($header, $mime) && !empty($ob['p'])) {
134: /* MIME encoded headers (RFC 2231). */
135: $text = $val[$key];
136: foreach ($ob['p'] as $name => $param) {
137: foreach (Horde_Mime::encodeParam($name, Horde_String::convertCharset($param, 'UTF-8', $charset), $charset, array('escape' => true)) as $name2 => $param2) {
138: $text .= '; ' . $name2 . '=' . $param2;
139: }
140: }
141: } else {
142: $text = $charset
143: ? Horde_Mime::encode(Horde_String::convertCharset($val[$key], 'UTF-8', $charset), $charset)
144: : $val[$key];
145: }
146:
147: if (empty($options['nowrap'])) {
148: /* Remove any existing linebreaks and wrap the line. */
149: $header_text = $ob['h'] . ': ';
150: $text = ltrim(substr(wordwrap($header_text . strtr(trim($text), array("\r" => '', "\n" => '')), 76, $eol . ' '), strlen($header_text)));
151: }
152:
153: $val[$key] = $text;
154: }
155:
156: $ret[$ob['h']] = (count($val) == 1) ? reset($val) : $val;
157: }
158:
159: return $ret;
160: }
161:
162: /**
163: * Returns the internal header array in string format.
164: *
165: * @param array $options Optional parameters:
166: * - canonical: (boolean) Use canonical (RFC 822/2045) line endings?
167: * DEFAULT: Uses $this->_eol
168: * - charset: (string) Encodes the headers using this charset.
169: * DEFAULT: No encoding.
170: * - defserver: (string) The default domain to append to mailboxes.
171: * DEFAULT: No default name.
172: * - nowrap: (integer) Don't wrap the headers.
173: * DEFAULT: Headers are wrapped.
174: *
175: * @return string The headers in string format.
176: */
177: public function toString(array $options = array())
178: {
179: $eol = empty($options['canonical'])
180: ? $this->_eol
181: : "\r\n";
182: $text = '';
183:
184: foreach ($this->toArray($options) as $key => $val) {
185: if (!is_array($val)) {
186: $val = array($val);
187: }
188: foreach ($val as $entry) {
189: $text .= $key . ': ' . $entry . $eol;
190: }
191: }
192:
193: return $text . $eol;
194: }
195:
196: /**
197: * Generate the 'Received' header for the Web browser->Horde hop
198: * (attempts to conform to guidelines in RFC 5321 [4.4]).
199: *
200: * @param array $options Additional options:
201: * - dns: (Net_DNS2_Resolver) Use the DNS resolver object to lookup
202: * hostnames.
203: * DEFAULT: Use gethostbyaddr() function.
204: * - server: (string) Use this server name.
205: * DEFAULT: Auto-detect using current PHP values.
206: */
207: public function addReceivedHeader($options = array())
208: {
209: $old_error = error_reporting(0);
210: if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
211: /* This indicates the user is connecting through a proxy. */
212: $remote_path = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
213: $remote_addr = $remote_path[0];
214: if (!empty($options['dns'])) {
215: $remote = $remote_addr;
216: try {
217: if ($response = $options['dns']->query($remote_addr, 'PTR')) {
218: foreach ($response->answer as $val) {
219: if (isset($val->ptrdname)) {
220: $remote = $val->ptrdname;
221: break;
222: }
223: }
224: }
225: } catch (Net_DNS2_Exception $e) {}
226: } else {
227: $remote = gethostbyaddr($remote_addr);
228: }
229: } else {
230: $remote_addr = $_SERVER['REMOTE_ADDR'];
231: if (empty($_SERVER['REMOTE_HOST'])) {
232: if (!empty($options['dns'])) {
233: $remote = $remote_addr;
234: try {
235: if ($response = $options['dns']->query($remote_addr, 'PTR')) {
236: foreach ($response->answer as $val) {
237: if (isset($val->ptrdname)) {
238: $remote = $val->ptrdname;
239: break;
240: }
241: }
242: }
243: } catch (Net_DNS2_Exception $e) {}
244: } else {
245: $remote = gethostbyaddr($remote_addr);
246: }
247: } else {
248: $remote = $_SERVER['REMOTE_HOST'];
249: }
250: }
251: error_reporting($old_error);
252:
253: if (!empty($_SERVER['REMOTE_IDENT'])) {
254: $remote_ident = $_SERVER['REMOTE_IDENT'] . '@' . $remote . ' ';
255: } elseif ($remote != $_SERVER['REMOTE_ADDR']) {
256: $remote_ident = $remote . ' ';
257: } else {
258: $remote_ident = '';
259: }
260:
261: if (!empty($options['server'])) {
262: $server_name = $options['server'];
263: } elseif (!empty($_SERVER['SERVER_NAME'])) {
264: $server_name = $_SERVER['SERVER_NAME'];
265: } elseif (!empty($_SERVER['HTTP_HOST'])) {
266: $server_name = $_SERVER['HTTP_HOST'];
267: } else {
268: $server_name = 'unknown';
269: }
270:
271: $received = 'from ' . $remote . ' (' . $remote_ident .
272: '[' . $remote_addr . ']) ' .
273: 'by ' . $server_name . ' (Horde Framework) with HTTP; ' .
274: date('r');
275:
276: $this->addHeader('Received', $received);
277: }
278:
279: /**
280: * Generate the 'Message-ID' header.
281: */
282: public function addMessageIdHeader()
283: {
284: $this->addHeader('Message-ID', Horde_Mime::generateMessageId());
285: }
286:
287: /**
288: * Generate the user agent description header.
289: */
290: public function addUserAgentHeader()
291: {
292: $this->addHeader('User-Agent', $this->getUserAgent());
293: }
294:
295: /**
296: * Returns the user agent description header.
297: *
298: * @return string The user agent header.
299: */
300: public function getUserAgent()
301: {
302: if (is_null($this->_agent)) {
303: $this->_agent = 'Horde Application Framework 4';
304: }
305: return $this->_agent;
306: }
307:
308: /**
309: * Explicitly sets the User-Agent string.
310: *
311: * @param string $agent The User-Agent string to use.
312: */
313: public function setUserAgent($agent)
314: {
315: $this->_agent = $agent;
316: }
317:
318: /**
319: * Add a header to the header array.
320: *
321: * @param string $header The header name.
322: * @param string $value The header value.
323: * @param array $options Additional options:
324: * - charset: (string) Charset of the header value.
325: * DEFAULT: UTF-8
326: * - decode: (boolean) MIME decode the value?
327: * DEFAULT: false
328: * - params: (array) MIME parameters for Content-Type or
329: * Content-Disposition.
330: * DEFAULT: None
331: */
332: public function addHeader($header, $value, array $options = array())
333: {
334: $header = trim($header);
335: $lcHeader = Horde_String::lower($header);
336:
337: if (!isset($this->_headers[$lcHeader])) {
338: $this->_headers[$lcHeader] = array(
339: 'h' => $header
340: );
341: }
342: $ptr = &$this->_headers[$lcHeader];
343:
344: if (empty($options['decode'])) {
345: if (!empty($options['charset'])) {
346: $value = Horde_String::convertCharset($value, $options['charset'], 'UTF-8');
347: }
348: } else {
349: // Fields defined in RFC 2822 that contain address information
350: if (in_array($lcHeader, $this->addressFields())) {
351: try {
352: $value = Horde_Mime::decodeAddrString($value, empty($options['charset']) ? 'UTF-8' : $options['charset']);
353: } catch (Horde_Mime_Exception $e) {
354: $value = '';
355: }
356: } else {
357: $value = Horde_Mime::decode($value, empty($options['charset']) ? 'UTF-8' : $options['charset']);
358: }
359: }
360:
361: if (isset($ptr['v'])) {
362: if (!is_array($ptr['v'])) {
363: $ptr['v'] = array($ptr['v']);
364: }
365: $ptr['v'][] = $value;
366: } else {
367: $ptr['v'] = $value;
368: }
369:
370: if (!empty($options['params'])) {
371: $ptr['p'] = $options['params'];
372: }
373: }
374:
375: /**
376: * Remove a header from the header array.
377: *
378: * @param string $header The header name.
379: */
380: public function removeHeader($header)
381: {
382: unset($this->_headers[Horde_String::lower(trim($header))]);
383: }
384:
385: /**
386: * Replace a value of a header.
387: *
388: * @param string $header The header name.
389: * @param string $value The header value.
390: * @param array $options Additional options:
391: * - charset: (string) Charset of the header value.
392: * DEFAULT: UTF-8
393: * - decode: (boolean) MIME decode the value?
394: * DEFAULT: false
395: * - params: (array) MIME parameters for Content-Type or
396: * Content-Disposition.
397: * DEFAULT: None
398: */
399: public function replaceHeader($header, $value, $options = array())
400: {
401: $this->removeHeader($header);
402: $this->addHeader($header, $value, $options);
403: }
404:
405: /**
406: * Set a value for a particular header ONLY if that header is set.
407: *
408: * @param string $header The header name.
409: * @param string $value The header value.
410: * @param array $options Additional options:
411: * - charset: (string) Charset of the header value.
412: * DEFAULT: UTF-8
413: * - decode: (boolean) MIME decode the value?
414: * - params: (array) MIME parameters for Content-Type or
415: * Content-Disposition.
416: *
417: * @return boolean True if value was set.
418: */
419: public function setValue($header, $value, $options = array())
420: {
421: if (isset($this->_headers[Horde_String::lower($header)])) {
422: $this->addHeader($header, $value, $options);
423: return true;
424: }
425:
426: return false;
427: }
428:
429: /**
430: * Attempts to return the header in the correct case.
431: *
432: * @param string $header The header to search for.
433: *
434: * @return string The value for the given header.
435: * If the header is not found, returns null.
436: */
437: public function getString($header)
438: {
439: $lcHeader = Horde_String::lower($header);
440: return (isset($this->_headers[$lcHeader]))
441: ? $this->_headers[$lcHeader]['h']
442: : null;
443: }
444:
445: /**
446: * Attempt to return the value for a given header.
447: * The following header fields can only have 1 entry, so if duplicate
448: * entries exist, the first value will be used:
449: * * To, From, Cc, Bcc, Date, Sender, Reply-to, Message-ID, In-Reply-To,
450: * References, Subject (RFC 2822 [3.6])
451: * * All List Headers (RFC 2369 [3])
452: * The values are not MIME encoded.
453: *
454: * @param string $header The header to search for.
455: * @param integer $type The type of return:
456: * - VALUE_STRING: Returns a string representation of the entire header.
457: * - VALUE_BASE: Returns a string representation of the base value of
458: * the header. If this is not a header that allows
459: * parameters, this will be equivalent to VALUE_STRING.
460: * - VALUE_PARAMS: Returns the list of parameters for this header. If
461: * this is not a header that allows parameters, this
462: * will be an empty array.
463: *
464: * @return mixed The value for the given header.
465: * If the header is not found, returns null.
466: */
467: public function getValue($header, $type = self::VALUE_STRING)
468: {
469: $header = Horde_String::lower($header);
470:
471: if (!isset($this->_headers[$header])) {
472: return null;
473: }
474:
475: $ptr = &$this->_headers[$header];
476: if (is_array($ptr['v']) &&
477: in_array($header, $this->singleFields(true))) {
478: if (in_array($header, $this->addressFields())) {
479: $base = str_replace(';,', ';', implode(', ', $ptr['v']));
480: } else {
481: $base = $ptr['v'][0];
482: }
483: } else {
484: $base = $ptr['v'];
485: }
486: $params = isset($ptr['p']) ? $ptr['p'] : array();
487:
488: switch ($type) {
489: case self::VALUE_BASE:
490: return $base;
491:
492: case self::VALUE_PARAMS:
493: return $params;
494:
495: case self::VALUE_STRING:
496: foreach ($params as $key => $val) {
497: $base .= '; ' . $key . '=' . $val;
498: }
499: return $base;
500: }
501: }
502:
503: /**
504: * Returns the list of RFC defined header fields that contain address
505: * info.
506: *
507: * @return array The list of headers, in lowercase.
508: */
509: static public function addressFields()
510: {
511: return array(
512: 'from', 'to', 'cc', 'bcc', 'reply-to', 'resent-to', 'resent-cc',
513: 'resent-bcc', 'resent-from', 'sender'
514: );
515: }
516:
517: /**
518: * Returns the list of RFC defined header fields that can only contain
519: * a single value.
520: *
521: * @param boolean $list Return list-related headers also?
522: *
523: * @return array The list of headers, in lowercase.
524: */
525: public function singleFields($list = true)
526: {
527: return $list
528: ? array_merge($this->_singleFields, array_keys($this->listHeaders()))
529: : $this->_singleFields;
530: }
531:
532: /**
533: * Returns the list of RFC defined MIME header fields that may contain
534: * parameter info.
535: *
536: * @return array The list of headers, in lowercase.
537: */
538: static public function mimeParamFields()
539: {
540: return array('content-type', 'content-disposition');
541: }
542:
543: /**
544: * Returns the list of valid mailing list headers.
545: *
546: * @return array The list of valid mailing list headers.
547: */
548: static public function listHeaders()
549: {
550: return array(
551: /* RFC 2369 */
552: 'list-help' => Horde_Mime_Translation::t("List-Help"),
553: 'list-unsubscribe' => Horde_Mime_Translation::t("List-Unsubscribe"),
554: 'list-subscribe' => Horde_Mime_Translation::t("List-Subscribe"),
555: 'list-owner' => Horde_Mime_Translation::t("List-Owner"),
556: 'list-post' => Horde_Mime_Translation::t("List-Post"),
557: 'list-archive' => Horde_Mime_Translation::t("List-Archive"),
558: /* RFC 2919 */
559: 'list-id' => Horde_Mime_Translation::t("List-Id")
560: );
561: }
562:
563: /**
564: * Do any mailing list headers exist?
565: *
566: * @return boolean True if any mailing list headers exist.
567: */
568: public function listHeadersExist()
569: {
570: return (bool)count(array_intersect(array_keys($this->listHeaders()), array_keys($this->_headers)));
571: }
572:
573: /**
574: * Sets a new string to use for EOLs.
575: *
576: * @param string $eol The string to use for EOLs.
577: */
578: public function setEOL($eol)
579: {
580: $this->_eol = $eol;
581: }
582:
583: /**
584: * Get the string to use for EOLs.
585: *
586: * @return string The string to use for EOLs.
587: */
588: public function getEOL()
589: {
590: return $this->_eol;
591: }
592:
593: /**
594: * Returns a header from the header object.
595: *
596: * @param string $field The header to return as an object.
597: *
598: * @return array The object for the field requested.
599: * @see Horde_Mime_Address::parseAddressList()
600: */
601: public function getOb($field)
602: {
603: $val = $this->getValue($field);
604: if (!is_null($val)) {
605: try {
606: return Horde_Mime_Address::parseAddressList($val);
607: } catch (Horde_Mime_Exception $e) {}
608: }
609: return array();
610: }
611:
612: /**
613: * Builds a Horde_Mime_Headers object from header text.
614: * This function can be called statically:
615: * $headers = Horde_Mime_Headers::parseHeaders().
616: *
617: * @param string $text A text string containing the headers.
618: *
619: * @return Horde_Mime_Headers A new Horde_Mime_Headers object.
620: */
621: static public function parseHeaders($text)
622: {
623: $currheader = $currtext = null;
624: $mime = self::mimeParamFields();
625: $to_process = array();
626:
627: foreach (explode("\n", $text) as $val) {
628: $val = rtrim($val);
629: if (empty($val)) {
630: break;
631: }
632:
633: if (($val[0] == ' ') || ($val[0] == "\t")) {
634: $currtext .= ' ' . ltrim($val);
635: } else {
636: if (!is_null($currheader)) {
637: $to_process[] = array($currheader, $currtext);
638: }
639:
640: $pos = strpos($val, ':');
641: $currheader = substr($val, 0, $pos);
642: $currtext = ltrim(substr($val, $pos + 1));
643: }
644: }
645:
646: if (!is_null($currheader)) {
647: $to_process[] = array($currheader, $currtext);
648: }
649:
650: $headers = new Horde_Mime_Headers();
651:
652: reset($to_process);
653: while (list(,$val) = each($to_process)) {
654: /* Ignore empty headers. */
655: if (!strlen($val[1])) {
656: continue;
657: }
658:
659: $val[1] = self::sanityCheck($val[0], $val[1]);
660:
661: if (in_array(Horde_String::lower($val[0]), $mime)) {
662: $res = Horde_Mime::decodeParam($val[0], $val[1], 'UTF-8');
663: $headers->addHeader($val[0], $res['val'], array(
664: 'decode' => true,
665: 'params' => $res['params']
666: ));
667: } else {
668: $headers->addHeader($val[0], $val[1], array(
669: 'decode' => true
670: ));
671: }
672: }
673:
674: return $headers;
675: }
676:
677: /**
678: * Perform sanity checking on a raw header (e.g. handle 8-bit characters).
679: * This function can be called statically:
680: * $headers = Horde_Mime_Headers::sanityCheck().
681: *
682: * @since Horde_Mime 1.4.0
683: *
684: * @param string $header The header.
685: * @param string $data The header data.
686: * @param array $opts Optional parameters:
687: * - encode: (boolean) If true, will MIME encode any 8-bit characters.
688: * If false (default), converts the text to UTF-8.
689: *
690: * @return string The cleaned header data.
691: */
692: static public function sanityCheck($header, $data, array $opts = array())
693: {
694: $charset_test = array(
695: 'UTF-8',
696: 'windows-1252',
697: self::$defaultCharset
698: );
699:
700: if (Horde_Mime::is8bit($data)) {
701: /* Assumption: broken charset in headers is generally either
702: * UTF-8 or ISO-8859-1/Windows-1252. Test these charsets
703: * first before using default charset. This may be a
704: * Western-centric approach, but it's better than nothing. */
705: foreach ($charset_test as $charset) {
706: $tmp = Horde_String::convertCharset($data, $charset, 'UTF-8');
707: if (Horde_String::validUtf8($tmp)) {
708: break;
709: }
710: }
711:
712: if (empty($opts['encode'])) {
713: $data = $tmp;
714: } else {
715: $header = Horde_String::lower($header);
716: if (in_array($header, self::addressFields())) {
717: $data = Horde_Mime::encodeAddress($tmp, $charset);
718: } elseif (in_array($header, self::mimeParamFields())) {
719: $res = Horde_Mime::decodeParam($header, $tmp, 'UTF-8');
720: $data = $res['val'];
721: foreach ($res['params'] as $name => $param) {
722: foreach (Horde_Mime::encodeParam($name, $param, 'UTF-8', array('escape' => true)) as $name2 => $param2) {
723: $data .= '; ' . $name2 . '=' . $param2;
724: }
725: }
726: } else {
727: $data = Horde_Mime::encode($tmp, 'UTF-8');
728: }
729: }
730: }
731:
732: return $data;
733: }
734:
735: /* Serializable methods. */
736:
737: /**
738: * Serialization.
739: *
740: * @return string Serialized data.
741: */
742: public function serialize()
743: {
744: $data = array(
745: // Serialized data ID.
746: self::VERSION,
747: $this->_headers,
748: $this->_eol
749: );
750:
751: if (!is_null($this->_agent)) {
752: $data[] = $this->_agent;
753: }
754:
755: return serialize($data);
756: }
757:
758: /**
759: * Unserialization.
760: *
761: * @param string $data Serialized data.
762: *
763: * @throws Exception
764: */
765: public function unserialize($data)
766: {
767: $data = @unserialize($data);
768: if (!is_array($data) ||
769: !isset($data[0]) ||
770: ($data[0] != self::VERSION)) {
771: throw new Horde_Mime_Exception('Cache version change');
772: }
773:
774: $this->_headers = $data[1];
775: $this->_eol = $data[2];
776: if (isset($data[3])) {
777: $this->_agent = $data[3];
778: }
779: }
780:
781: }
782: