1: <?php
2: /**
3: * This class provides an object-oriented representation of a MIME part
4: * (defined by RFC 2045).
5: *
6: * Copyright 1999-2012 Horde LLC (http://www.horde.org/)
7: *
8: * See the enclosed file COPYING for license information (LGPL). If you
9: * did not receive this file, see http://www.horde.org/licenses/lgpl21.
10: *
11: * @author Chuck Hagenbuch <chuck@horde.org>
12: * @author Michael Slusarz <slusarz@horde.org>
13: * @category Horde
14: * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
15: * @package Mime
16: */
17: class Horde_Mime_Part implements ArrayAccess, Countable, Serializable
18: {
19: /* Serialized version. */
20: const VERSION = 1;
21:
22: /* The character(s) used internally for EOLs. */
23: const EOL = "\n";
24:
25: /* The character string designated by RFC 2045 to designate EOLs in MIME
26: * messages. */
27: const RFC_EOL = "\r\n";
28:
29: /* The default encoding. */
30: const DEFAULT_ENCODING = 'binary';
31:
32: /* Constants indicating the valid transfer encoding allowed. */
33: const ENCODE_7BIT = 1;
34: const ENCODE_8BIT = 2;
35: const ENCODE_BINARY = 4;
36:
37: /* Unknown types. */
38: const UNKNOWN = 'x-unknown';
39:
40: /**
41: * The default charset to use when parsing text parts with no charset
42: * information.
43: *
44: * @var string
45: */
46: static public $defaultCharset = 'us-ascii';
47:
48: /**
49: * Valid encoding types.
50: *
51: * @var array
52: */
53: static public $encodingTypes = array(
54: '7bit', '8bit', 'base64', 'binary', 'quoted-printable',
55: // Non-RFC types, but old mailers may still use
56: 'uuencode', 'x-uuencode', 'x-uue'
57: );
58:
59: /**
60: * The memory limit for use with the PHP temp stream.
61: *
62: * @var integer
63: */
64: static public $memoryLimit = 2097152;
65:
66: /**
67: * Valid MIME types.
68: *
69: * @var array
70: */
71: static public $mimeTypes = array(
72: 'text', 'multipart', 'message', 'application', 'audio', 'image',
73: 'video', 'model'
74: );
75:
76: /**
77: * The type (ex.: text) of this part.
78: * Per RFC 2045, the default is 'application'.
79: *
80: * @var string
81: */
82: protected $_type = 'application';
83:
84: /**
85: * The subtype (ex.: plain) of this part.
86: * Per RFC 2045, the default is 'octet-stream'.
87: *
88: * @var string
89: */
90: protected $_subtype = 'octet-stream';
91:
92: /**
93: * The body of the part. Always stored in binary format.
94: *
95: * @var resource
96: */
97: protected $_contents;
98:
99: /**
100: * The desired transfer encoding of this part.
101: *
102: * @var string
103: */
104: protected $_transferEncoding = self::DEFAULT_ENCODING;
105:
106: /**
107: * The language(s) of this part.
108: *
109: * @var array
110: */
111: protected $_language = array();
112:
113: /**
114: * The description of this part.
115: *
116: * @var string
117: */
118: protected $_description = '';
119:
120: /**
121: * The disposition of this part (inline or attachment).
122: *
123: * @var string
124: */
125: protected $_disposition = '';
126:
127: /**
128: * The disposition parameters of this part.
129: *
130: * @var array
131: */
132: protected $_dispParams = array();
133:
134: /**
135: * The content type parameters of this part.
136: *
137: * @var array
138: */
139: protected $_contentTypeParams = array();
140:
141: /**
142: * The subparts of this part.
143: *
144: * @var array
145: */
146: protected $_parts = array();
147:
148: /**
149: * The MIME ID of this part.
150: *
151: * @var string
152: */
153: protected $_mimeid = null;
154:
155: /**
156: * The sequence to use as EOL for this part.
157: * The default is currently to output the EOL sequence internally as
158: * just "\n" instead of the canonical "\r\n" required in RFC 822 & 2045.
159: * To be RFC complaint, the full <CR><LF> EOL combination should be used
160: * when sending a message.
161: * It is not crucial here since the PHP/PEAR mailing functions will handle
162: * the EOL details.
163: *
164: * @var string
165: */
166: protected $_eol = self::EOL;
167:
168: /**
169: * Internal temp array.
170: *
171: * @var array
172: */
173: protected $_temp = array();
174:
175: /**
176: * Metadata.
177: *
178: * @var array
179: */
180: protected $_metadata = array();
181:
182: /**
183: * Unique Horde_Mime_Part boundary string.
184: *
185: * @var string
186: */
187: protected $_boundary = null;
188:
189: /**
190: * Default value for this Part's size.
191: *
192: * @var integer
193: */
194: protected $_bytes;
195:
196: /**
197: * The content-ID for this part.
198: *
199: * @var string
200: */
201: protected $_contentid = null;
202:
203: /**
204: * The duration of this part's media data (RFC 3803).
205: *
206: * @var integer
207: */
208: protected $_duration;
209:
210: /**
211: * Do we need to reindex the current part?
212: *
213: * @var boolean
214: */
215: protected $_reindex = false;
216:
217: /**
218: * Is this the base MIME part?
219: *
220: * @var boolean
221: */
222: protected $_basepart = false;
223:
224: /**
225: * The charset to output the headers in.
226: *
227: * @var string
228: */
229: protected $_hdrCharset = null;
230:
231: /**
232: * The list of member variables to serialize.
233: *
234: * @var array
235: */
236: protected $_serializedVars = array(
237: '_type',
238: '_subtype',
239: '_transferEncoding',
240: '_language',
241: '_description',
242: '_disposition',
243: '_dispParams',
244: '_contentTypeParams',
245: '_parts',
246: '_mimeid',
247: '_eol',
248: '_metadata',
249: '_boundary',
250: '_bytes',
251: '_contentid',
252: '_duration',
253: '_reindex',
254: '_basepart',
255: '_hdrCharset'
256: );
257:
258: /**
259: * Function to run on clone.
260: */
261: public function __clone()
262: {
263: reset($this->_parts);
264: while (list($k, $v) = each($this->_parts)) {
265: $this->_parts[$k] = clone $v;
266: }
267: }
268:
269: /**
270: * Set the content-disposition of this part.
271: *
272: * @param string $disposition The content-disposition to set ('inline',
273: * 'attachment', or an empty value).
274: */
275: public function setDisposition($disposition = null)
276: {
277: if (empty($disposition)) {
278: $this->_disposition = '';
279: } else {
280: $disposition = Horde_String::lower($disposition);
281: if (in_array($disposition, array('inline', 'attachment'))) {
282: $this->_disposition = $disposition;
283: }
284: }
285: }
286:
287: /**
288: * Get the content-disposition of this part.
289: *
290: * @return string The part's content-disposition. An empty string means
291: * q no desired disposition has been set for this part.
292: */
293: public function getDisposition()
294: {
295: return $this->_disposition;
296: }
297:
298: /**
299: * Add a disposition parameter to this part.
300: *
301: * @param string $label The disposition parameter label.
302: * @param string $data The disposition parameter data.
303: */
304: public function setDispositionParameter($label, $data)
305: {
306: $this->_dispParams[$label] = $data;
307:
308: switch ($label) {
309: case 'size':
310: // RFC 2183 [2.7] - size parameter
311: $this->_bytes = intval($data);
312: break;
313: }
314: }
315:
316: /**
317: * Get a disposition parameter from this part.
318: *
319: * @param string $label The disposition parameter label.
320: *
321: * @return string The data requested.
322: * Returns null if $label is not set.
323: */
324: public function getDispositionParameter($label)
325: {
326: return (isset($this->_dispParams[$label]))
327: ? $this->_dispParams[$label]
328: : null;
329: }
330:
331: /**
332: * Get all parameters from the Content-Disposition header.
333: *
334: * @return array An array of all the parameters
335: * Returns the empty array if no parameters set.
336: */
337: public function getAllDispositionParameters()
338: {
339: return $this->_dispParams;
340: }
341:
342: /**
343: * Set the name of this part.
344: *
345: * @param string $name The name to set.
346: */
347: public function setName($name)
348: {
349: $this->setDispositionParameter('filename', $name);
350: $this->setContentTypeParameter('name', $name);
351: }
352:
353: /**
354: * Get the name of this part.
355: *
356: * @param boolean $default If the name parameter doesn't exist, should we
357: * use the default name from the description
358: * parameter?
359: *
360: * @return string The name of the part.
361: */
362: public function getName($default = false)
363: {
364: if (!($name = $this->getDispositionParameter('filename')) &&
365: !($name = $this->getContentTypeParameter('name')) &&
366: $default) {
367: $name = preg_replace('|\W|', '_', $this->getDescription(false));
368: }
369:
370: return $name;
371: }
372:
373: /**
374: * Set the body contents of this part.
375: *
376: * @param mixed $contents The part body. Either a string or a stream
377: * resource, or an array containing both.
378: * @param array $options Additional options:
379: * <pre>
380: * 'encoding' - (string) The encoding of $contents.
381: * DEFAULT: Current transfer encoding value.
382: * 'usestream' - (boolean) If $contents is a stream, should we directly
383: * use that stream?
384: * DEFAULT: $contents copied to a new stream.
385: * </pre>
386: */
387: public function setContents($contents, $options = array())
388: {
389: $this->clearContents();
390: if (empty($options['encoding'])) {
391: $options['encoding'] = $this->_transferEncoding;
392: }
393:
394: $fp = (empty($options['usestream']) || !is_resource($contents))
395: ? $this->_writeStream($contents)
396: : $contents;
397:
398: $this->setTransferEncoding($options['encoding']);
399: $this->_contents = $this->_transferDecode($fp, $options['encoding']);
400: }
401:
402: /**
403: * Add to the body contents of this part.
404: *
405: * @param mixed $contents The part body. Either a string or a stream
406: * resource, or an array containing both.
407: * <pre>
408: * 'encoding' - (string) The encoding of $contents.
409: * DEFAULT: Current transfer encoding value.
410: * 'usestream' - (boolean) If $contents is a stream, should we directly
411: * use that stream?
412: * DEFAULT: $contents copied to a new stream.
413: * </pre>
414: */
415: public function appendContents($contents, $options = array())
416: {
417: if (empty($this->_contents)) {
418: $this->setContents($contents, $options);
419: } else {
420: $fp = (empty($options['usestream']) || !is_resource($contents))
421: ? $this->_writeStream($contents)
422: : $contents;
423:
424: $this->_writeStream((empty($options['encoding']) || ($options['encoding'] == $this->_transferEncoding)) ? $fp : $this->_transferDecode($fp, $options['encoding']), array('fp' => $this->_contents));
425: unset($this->_temp['sendTransferEncoding']);
426: }
427: }
428:
429: /**
430: * Clears the body contents of this part.
431: */
432: public function clearContents()
433: {
434: if (!empty($this->_contents)) {
435: fclose($this->_contents);
436: $this->_contents = null;
437: unset($this->_temp['sendTransferEncoding']);
438: }
439: }
440:
441: /**
442: * Return the body of the part.
443: *
444: * @param array $options Additional options:
445: * <pre>
446: * 'canonical' - (boolean) Returns the contents in strict RFC 822 &
447: * 2045 output - namely, all newlines end with the
448: * canonical <CR><LF> sequence.
449: * DEFAULT: No
450: * 'stream' - (boolean) Return the body as a stream resource.
451: * DEFAULT: No
452: * </pre>
453: *
454: * @return mixed The body text of the part, or a stream resource if
455: * 'stream' is true.
456: */
457: public function getContents($options = array())
458: {
459: return empty($options['canonical'])
460: ? (empty($options['stream']) ? $this->_readStream($this->_contents) : $this->_contents)
461: : $this->replaceEOL($this->_contents, self::RFC_EOL, !empty($options['stream']));
462: }
463:
464: /**
465: * Decodes the contents of the part to binary encoding.
466: *
467: * @param resource $fp A stream containing the data to decode.
468: * @param string $encoding The original file encoding.
469: *
470: * @return resource A new file resource with the decoded data.
471: */
472: protected function _transferDecode($fp, $encoding)
473: {
474: /* If the contents are empty, return now. */
475: fseek($fp, 0, SEEK_END);
476: if (ftell($fp)) {
477: switch ($encoding) {
478: case 'base64':
479: try {
480: return $this->_writeStream($fp, array(
481: 'error' => true,
482: 'filter' => array(
483: 'convert.base64-decode' => array()
484: )
485: ));
486: } catch (ErrorException $e) {}
487:
488: rewind($fp);
489: return $this->_writeStream(base64_decode(stream_get_contents($fp)));
490:
491: case 'quoted-printable':
492: try {
493: $stream = $this->_writeStream($fp, array(
494: 'error' => true,
495: 'filter' => array(
496: 'convert.quoted-printable-decode' => array()
497: )
498: ));
499: } catch (ErrorException $e) {
500: // Workaround for Horde Bug #8747
501: rewind($fp);
502: $stream = $this->_writeStream(quoted_printable_decode(stream_get_contents($fp)));
503: }
504: return $stream;
505:
506: case 'uuencode':
507: case 'x-uuencode':
508: case 'x-uue':
509: /* Support for uuencoded encoding - although not required by
510: * RFCs, some mailers may still encode this way. */
511: $res = Horde_Mime::uudecode($this->_readStream($fp));
512: return $this->_writeStream($res[0]['data']);
513: }
514: }
515:
516: return $fp;
517: }
518:
519: /**
520: * Encodes the contents of the part as necessary for transport.
521: *
522: * @param resource $fp A stream containing the data to encode.
523: * @param string $encoding The encoding to use.
524: *
525: * @return resource A new file resource with the encoded data.
526: */
527: protected function _transferEncode($fp, $encoding)
528: {
529: $this->_temp['transferEncodeClose'] = true;
530:
531: switch ($encoding) {
532: case 'base64':
533: /* Base64 Encoding: See RFC 2045, section 6.8 */
534: return $this->_writeStream($fp, array(
535: 'filter' => array(
536: 'convert.base64-encode' => array(
537: 'line-break-chars' => $this->getEOL(),
538: 'line-length' => 76
539: )
540: )
541: ));
542:
543: case 'quoted-printable':
544: /* Quoted-Printable Encoding: See RFC 2045, section 6.7 */
545: return $this->_writeStream($fp, array(
546: 'filter' => array(
547: 'convert.quoted-printable-encode' => array(
548: 'line-break-chars' => $this->getEOL(),
549: 'line-length' => 76
550: )
551: )
552: ));
553:
554: default:
555: $this->_temp['transferEncodeClose'] = false;
556: return $fp;
557: }
558: }
559:
560: /**
561: * Set the MIME type of this part.
562: *
563: * @param string $type The MIME type to set (ex.: text/plain).
564: */
565: public function setType($type)
566: {
567: /* RFC 2045: Any entity with unrecognized encoding must be treated
568: * as if it has a Content-Type of "application/octet-stream"
569: * regardless of what the Content-Type field actually says. */
570: if (($this->_transferEncoding == self::UNKNOWN) ||
571: (strpos($type, '/') === false)) {
572: return;
573: }
574:
575: list($this->_type, $this->_subtype) = explode('/', Horde_String::lower($type));
576:
577: if (in_array($this->_type, self::$mimeTypes)) {
578: /* Set the boundary string for 'multipart/*' parts. */
579: if ($this->_type == 'multipart') {
580: if (!$this->getContentTypeParameter('boundary')) {
581: $this->setContentTypeParameter('boundary', $this->_generateBoundary());
582: }
583: } else {
584: $this->clearContentTypeParameter('boundary');
585: }
586: } else {
587: $this->_type = self::UNKNOWN;
588: $this->clearContentTypeParameter('boundary');
589: }
590: }
591:
592: /**
593: * Get the full MIME Content-Type of this part.
594: *
595: * @param boolean $charset Append character set information to the end
596: * of the content type if this is a text/* part?
597: *
598: * @return string The mimetype of this part (ex.: text/plain;
599: * charset=us-ascii) or false.
600: */
601: public function getType($charset = false)
602: {
603: if (empty($this->_type) || empty($this->_subtype)) {
604: return false;
605: }
606:
607: $ptype = $this->getPrimaryType();
608: $type = $ptype . '/' . $this->getSubType();
609: if ($charset &&
610: ($ptype == 'text') &&
611: ($charset = $this->getCharset())) {
612: $type .= '; charset=' . $charset;
613: }
614:
615: return $type;
616: }
617:
618: /**
619: * If the subtype of a MIME part is unrecognized by an application, the
620: * default type should be used instead (See RFC 2046). This method
621: * returns the default subtype for a particular primary MIME type.
622: *
623: * @return string The default MIME type of this part (ex.: text/plain).
624: */
625: public function getDefaultType()
626: {
627: switch ($this->getPrimaryType()) {
628: case 'text':
629: /* RFC 2046 (4.1.4): text parts default to text/plain. */
630: return 'text/plain';
631:
632: case 'multipart':
633: /* RFC 2046 (5.1.3): multipart parts default to multipart/mixed. */
634: return 'multipart/mixed';
635:
636: default:
637: /* RFC 2046 (4.2, 4.3, 4.4, 4.5.3, 5.2.4): all others default to
638: application/octet-stream. */
639: return 'application/octet-stream';
640: }
641: }
642:
643: /**
644: * Get the primary type of this part.
645: *
646: * @return string The primary MIME type of this part.
647: */
648: public function getPrimaryType()
649: {
650: return $this->_type;
651: }
652:
653: /**
654: * Get the subtype of this part.
655: *
656: * @return string The MIME subtype of this part.
657: */
658: public function getSubType()
659: {
660: return $this->_subtype;
661: }
662:
663: /**
664: * Set the character set of this part.
665: *
666: * @param string $charset The character set of this part.
667: */
668: public function setCharset($charset)
669: {
670: $this->setContentTypeParameter('charset', $charset);
671: }
672:
673: /**
674: * Get the character set to use for this part.
675: *
676: * @return string The character set of this part. Returns null if there
677: * is no character set.
678: */
679: public function getCharset()
680: {
681: $charset = $this->getContentTypeParameter('charset');
682: if (is_null($charset) && $this->getPrimaryType() != 'text') {
683: return null;
684: }
685:
686: $charset = Horde_String::lower($charset);
687:
688: if ($this->getPrimaryType() == 'text') {
689: $d_charset = Horde_String::lower(self::$defaultCharset);
690: if ($d_charset != 'us-ascii' &&
691: (!$charset || $charset == 'us-ascii')) {
692: return $d_charset;
693: }
694: }
695:
696: return $charset;
697: }
698:
699: /**
700: * Set the character set to use when outputting MIME headers.
701: *
702: * @param string $charset The character set.
703: */
704: public function setHeaderCharset($charset)
705: {
706: $this->_hdrCharset = $charset;
707: }
708:
709: /**
710: * Get the character set to use when outputting MIME headers.
711: *
712: * @return string The character set.
713: */
714: public function getHeaderCharset()
715: {
716: return is_null($this->_hdrCharset)
717: ? $this->getCharset()
718: : $this->_hdrCharset;
719: }
720:
721: /**
722: * Set the language(s) of this part.
723: *
724: * @param mixed $lang A language string, or an array of language
725: * strings.
726: */
727: public function setLanguage($lang)
728: {
729: $this->_language = is_array($lang)
730: ? $lang
731: : array($lang);
732: }
733:
734: /**
735: * Get the language(s) of this part.
736: *
737: * @param array The list of languages.
738: */
739: public function getLanguage()
740: {
741: return $this->_language;
742: }
743:
744: /**
745: * Set the content duration of the data contained in this part (see RFC
746: * 3803).
747: *
748: * @param integer $duration The duration of the data, in seconds. If
749: * null, clears the duration information.
750: */
751: public function setDuration($duration)
752: {
753: if (is_null($duration)) {
754: unset($this->_duration);
755: } else {
756: $this->_duration = intval($duration);
757: }
758: }
759:
760: /**
761: * Get the content duration of the data contained in this part (see RFC
762: * 3803).
763: *
764: * @return integer The duration of the data, in seconds. Returns null if
765: * there is no duration information.
766: */
767: public function getDuration()
768: {
769: return isset($this->_duration)
770: ? $this->_duration
771: : null;
772: }
773:
774: /**
775: * Set the description of this part.
776: *
777: * @param string $description The description of this part.
778: */
779: public function setDescription($description)
780: {
781: $this->_description = $description;
782: }
783:
784: /**
785: * Get the description of this part.
786: *
787: * @param boolean $default If the description parameter doesn't exist,
788: * should we use the name of the part?
789: *
790: * @return string The description of this part.
791: */
792: public function getDescription($default = false)
793: {
794: $desc = $this->_description;
795:
796: if ($default && empty($desc)) {
797: $desc = $this->getName();
798: }
799:
800: return $desc;
801: }
802:
803: /**
804: * Set the transfer encoding to use for this part. Only needed in the
805: * following circumstances:
806: * 1.) Indicate what the transfer encoding is if the data has not yet been
807: * set in the object (can only be set if there presently are not
808: * any contents).
809: * 2.) Force the encoding to a certain type on a toString() call (if
810: * 'send' is true).
811: *
812: * @param string $encoding The transfer encoding to use.
813: * @param array $options Additional options:
814: * <pre>
815: * 'send' - (boolean) If true, use $encoding as the sending encoding.
816: * DEFAULT: $encoding is used to change the base encoding.
817: * </pre>
818: */
819: public function setTransferEncoding($encoding, $options = array())
820: {
821: if (empty($options['send']) && !empty($this->_contents)) {
822: return;
823: }
824:
825: $encoding = Horde_String::lower($encoding);
826:
827: if (in_array($encoding, self::$encodingTypes)) {
828: if (empty($options['send'])) {
829: $this->_transferEncoding = $encoding;
830: } else {
831: $this->_temp['sendEncoding'] = $encoding;
832: }
833: } elseif (empty($options['send'])) {
834: /* RFC 2045: Any entity with unrecognized encoding must be treated
835: * as if it has a Content-Type of "application/octet-stream"
836: * regardless of what the Content-Type field actually says. */
837: $this->setType('application/octet-stream');
838: $this->_transferEncoding = self::UNKNOWN;
839: }
840: }
841:
842: /**
843: * Add a MIME subpart.
844: *
845: * @param Horde_Mime_Part $mime_part Add a subpart to the current object.
846: */
847: public function addPart($mime_part)
848: {
849: $this->_parts[] = $mime_part;
850: $this->_reindex = true;
851: }
852:
853: /**
854: * Get a list of all MIME subparts.
855: *
856: * @return array An array of the Horde_Mime_Part subparts.
857: */
858: public function getParts()
859: {
860: return $this->_parts;
861: }
862:
863: /**
864: * Retrieve a specific MIME part.
865: *
866: * @param string $id The MIME ID to get.
867: *
868: * @return Horde_Mime_Part The part requested or null if the part doesn't
869: * exist.
870: */
871: public function getPart($id)
872: {
873: return $this->_partAction($id, 'get');
874: }
875:
876: /**
877: * Remove a subpart.
878: *
879: * @param string $id The MIME ID to delete.
880: *
881: * @param boolean Success status.
882: */
883: public function removePart($id)
884: {
885: return $this->_partAction($id, 'remove');
886: }
887:
888: /**
889: * Alter a current MIME subpart.
890: *
891: * @param string $id The MIME ID to alter.
892: * @param Horde_Mime_Part $mime_part The MIME part to store.
893: *
894: * @param boolean Success status.
895: */
896: public function alterPart($id, $mime_part)
897: {
898: return $this->_partAction($id, 'alter', $mime_part);
899: }
900:
901: /**
902: * Function used to find a specific MIME part by ID and perform an action
903: * on it.
904: *
905: * @param string $id The MIME ID.
906: * @param string $action The action to perform ('get',
907: * 'remove', or 'alter').
908: * @param Horde_Mime_Part $mime_part The object to use for 'alter'.
909: *
910: * @return mixed See calling functions.
911: */
912: protected function _partAction($id, $action, $mime_part = null)
913: {
914: $this_id = $this->getMimeId();
915:
916: /* Need strcmp() because, e.g., '2.0' == '2'. */
917: if (($action == 'get') && (strcmp($id, $this_id) === 0)) {
918: return $this;
919: }
920:
921: if ($this->_reindex) {
922: $this->buildMimeIds(is_null($this_id) ? '1' : $this_id);
923: }
924:
925: foreach (array_keys($this->_parts) as $val) {
926: $partid = $this->_parts[$val]->getMimeId();
927: if (strcmp($id, $partid) === 0) {
928: switch ($action) {
929: case 'alter':
930: $mime_part->setMimeId($this->_parts[$val]->getMimeId());
931: $this->_parts[$val] = $mime_part;
932: return true;
933:
934: case 'get':
935: return $this->_parts[$val];
936:
937: case 'remove':
938: unset($this->_parts[$val]);
939: $this->_reindex = true;
940: return true;
941: }
942: }
943:
944: if ((strpos($id, $partid . '.') === 0) ||
945: (strrchr($partid, '.') === '.0')) {
946: return $this->_parts[$val]->_partAction($id, $action, $mime_part);
947: }
948: }
949:
950: return ($action == 'get') ? null : false;
951: }
952:
953: /**
954: * Add a content type parameter to this part.
955: *
956: * @param string $label The disposition parameter label.
957: * @param string $data The disposition parameter data.
958: */
959: public function setContentTypeParameter($label, $data)
960: {
961: $this->_contentTypeParams[$label] = $data;
962: }
963:
964: /**
965: * Clears a content type parameter from this part.
966: *
967: * @param string $label The disposition parameter label.
968: * @param string $data The disposition parameter data.
969: */
970: public function clearContentTypeParameter($label)
971: {
972: unset($this->_contentTypeParams[$label]);
973: }
974:
975: /**
976: * Get a content type parameter from this part.
977: *
978: * @param string $label The content type parameter label.
979: *
980: * @return string The data requested.
981: * Returns null if $label is not set.
982: */
983: public function getContentTypeParameter($label)
984: {
985: return isset($this->_contentTypeParams[$label])
986: ? $this->_contentTypeParams[$label]
987: : null;
988: }
989:
990: /**
991: * Get all parameters from the Content-Type header.
992: *
993: * @return array An array of all the parameters
994: * Returns the empty array if no parameters set.
995: */
996: public function getAllContentTypeParameters()
997: {
998: return $this->_contentTypeParams;
999: }
1000:
1001: /**
1002: * Sets a new string to use for EOLs.
1003: *
1004: * @param string $eol The string to use for EOLs.
1005: */
1006: public function setEOL($eol)
1007: {
1008: $this->_eol = $eol;
1009: }
1010:
1011: /**
1012: * Get the string to use for EOLs.
1013: *
1014: * @return string The string to use for EOLs.
1015: */
1016: public function getEOL()
1017: {
1018: return $this->_eol;
1019: }
1020:
1021: /**
1022: * Returns a Horde_Mime_Header object containing all MIME headers needed
1023: * for the part.
1024: *
1025: * @param array $options Additional options:
1026: * <pre>
1027: * 'encode' - (integer) A mask of allowable encodings.
1028: * DEFAULT: See self::_getTransferEncoding()
1029: * 'headers' - (Horde_Mime_Headers) The object to add the MIME headers to.
1030: * DEFAULT: Add headers to a new object
1031: * </pre>
1032: *
1033: * @return Horde_Mime_Headers A Horde_Mime_Headers object.
1034: */
1035: public function addMimeHeaders($options = array())
1036: {
1037: $headers = empty($options['headers'])
1038: ? new Horde_Mime_Headers()
1039: : $options['headers'];
1040:
1041: /* Get the Content-Type itself. */
1042: $ptype = $this->getPrimaryType();
1043: $c_params = $this->getAllContentTypeParameters();
1044: if ($ptype != 'text') {
1045: unset($c_params['charset']);
1046: }
1047: $headers->replaceHeader('Content-Type', $this->getType(), array('params' => $c_params));
1048:
1049: /* Add the language(s), if set. (RFC 3282 [2]) */
1050: if ($langs = $this->getLanguage()) {
1051: $headers->replaceHeader('Content-Language', implode(',', $langs));
1052: }
1053:
1054: /* Get the description, if any. */
1055: if (($descrip = $this->getDescription())) {
1056: $headers->replaceHeader('Content-Description', $descrip);
1057: }
1058:
1059: /* Set the duration, if it exists. (RFC 3803) */
1060: if (($duration = $this->getDuration()) !== null) {
1061: $headers->replaceHeader('Content-Duration', $duration);
1062: }
1063:
1064: /* Per RFC 2046 [4], this MUST appear in the base message headers. */
1065: if ($this->_basepart) {
1066: $headers->replaceHeader('MIME-Version', '1.0');
1067: }
1068:
1069: /* message/* parts require no additional header information. */
1070: if ($ptype == 'message') {
1071: return $headers;
1072: }
1073:
1074: /* Don't show Content-Disposition unless a disposition has explicitly
1075: * been set or there are parameters.
1076: * If there is a name, but no disposition, default to 'attachment'.
1077: * RFC 2183 [2] indicates that default is no requested disposition -
1078: * the receiving MUA is responsible for display choice. */
1079: $disposition = $this->getDisposition();
1080: $disp_params = $this->getAllDispositionParameters();
1081: $name = $this->getName();
1082: if ($disposition || !empty($name) || !empty($disp_params)) {
1083: if (!$disposition) {
1084: $disposition = 'attachment';
1085: }
1086: if ($name) {
1087: $disp_params['filename'] = $name;
1088: }
1089: $headers->replaceHeader('Content-Disposition', $disposition, array('params' => $disp_params));
1090: } else {
1091: $headers->removeHeader('Content-Disposition');
1092: }
1093:
1094: /* Add transfer encoding information. RFC 2045 [6.1] indicates that
1095: * default is 7bit. No need to send the header in this case. */
1096: $encoding = $this->_getTransferEncoding(empty($options['encode']) ? null : $options['encode']);
1097: if ($encoding == '7bit') {
1098: $headers->removeHeader('Content-Transfer-Encoding');
1099: } else {
1100: $headers->replaceHeader('Content-Transfer-Encoding', $encoding);
1101: }
1102:
1103: /* Add content ID information. */
1104: if (!is_null($this->_contentid)) {
1105: $headers->replaceHeader('Content-ID', '<' . $this->_contentid . '>');
1106: }
1107:
1108: return $headers;
1109: }
1110:
1111: /**
1112: * Return the entire part in MIME format.
1113: *
1114: * @param array $options Additional options:
1115: * <pre>
1116: * 'canonical' - (boolean) Returns the encoded part in strict RFC 822 &
1117: * 2045 output - namely, all newlines end with the canonical
1118: * <CR><LF> sequence.
1119: * DEFAULT: false
1120: * 'defserver' - (string) The default server to use when creating the
1121: * header string.
1122: * DEFAULT: none
1123: * 'encode' - (integer) A mask of allowable encodings.
1124: * DEFAULT: self::ENCODE_7BIT
1125: * 'headers' - (mixed) Include the MIME headers? If true, create a new
1126: * headers object. If a Horde_Mime_Headers object, add MIME
1127: * headers to this object. If a string, use the string
1128: * verbatim.
1129: * DEFAULT: true
1130: * 'id' - (string) Return only this MIME ID part.
1131: * DEFAULT: Returns the base part.
1132: * 'stream' - (boolean) Return a stream resource.
1133: * DEFAULT: false
1134: * </pre>
1135: *
1136: * @return mixed The MIME string (returned as a resource if $stream is
1137: * true).
1138: */
1139: public function toString($options = array())
1140: {
1141: $eol = $this->getEOL();
1142: $isbase = true;
1143: $oldbaseptr = null;
1144: $parts = $parts_close = array();
1145:
1146: if (isset($options['id'])) {
1147: $id = $options['id'];
1148: if (!($part = $this->getPart($id))) {
1149: return $part;
1150: }
1151: unset($options['id']);
1152: $contents = $part->toString($options);
1153:
1154: $prev_id = Horde_Mime::mimeIdArithmetic($id, 'up', array('norfc822' => true));
1155: $prev_part = ($prev_id == $this->getMimeId())
1156: ? $this
1157: : $this->getPart($prev_id);
1158: if (!$prev_part) {
1159: return $contents;
1160: }
1161:
1162: $boundary = trim($this->getContentTypeParameter('boundary'), '"');
1163: $parts = array(
1164: $eol . '--' . $boundary . $eol,
1165: $contents
1166: );
1167:
1168: if (!$this->getPart(Horde_Mime::mimeIdArithmetic($id, 'next'))) {
1169: $parts[] = $eol . '--' . $boundary . '--' . $eol;
1170: }
1171: } else {
1172: if ($isbase = empty($options['_notbase'])) {
1173: $headers = !empty($options['headers'])
1174: ? $options['headers']
1175: : false;
1176:
1177: if (empty($options['encode'])) {
1178: $options['encode'] = null;
1179: }
1180: if (empty($options['defserver'])) {
1181: $options['defserver'] = null;
1182: }
1183: $options['headers'] = true;
1184: $options['_notbase'] = true;
1185: } else {
1186: $headers = true;
1187: $oldbaseptr = &$options['_baseptr'];
1188: }
1189:
1190: $this->_temp['toString'] = '';
1191: $options['_baseptr'] = &$this->_temp['toString'];
1192:
1193: /* Any information about a message/* is embedded in the message
1194: * contents themself. Simply output the contents of the part
1195: * directly and return. */
1196: $ptype = $this->getPrimaryType();
1197: if ($ptype == 'message') {
1198: $parts[] = $this->_contents;
1199: } else {
1200: if (!empty($this->_contents)) {
1201: $encoding = $this->_getTransferEncoding($options['encode']);
1202: switch ($encoding) {
1203: case '8bit':
1204: if (empty($options['_baseptr'])) {
1205: $options['_baseptr'] = '8bit';
1206: }
1207: break;
1208:
1209: case 'binary':
1210: $options['_baseptr'] = 'binary';
1211: break;
1212: }
1213:
1214: $parts[] = $this->_transferEncode($this->_contents, $encoding);
1215:
1216: /* If not using $this->_contents, we can close the stream
1217: * when finished. */
1218: if ($this->_temp['transferEncodeClose']) {
1219: $parts_close[] = end($parts);
1220: }
1221: }
1222:
1223: /* Deal with multipart messages. */
1224: if ($ptype == 'multipart') {
1225: if (empty($this->_contents)) {
1226: $parts[] = 'This message is in MIME format.' . $eol;
1227: }
1228:
1229: $boundary = trim($this->getContentTypeParameter('boundary'), '"');
1230:
1231: reset($this->_parts);
1232: while (list(,$part) = each($this->_parts)) {
1233: $parts[] = $eol . '--' . $boundary . $eol;
1234: $tmp = $part->toString($options);
1235: if ($part->getEOL() != $eol) {
1236: $tmp = $this->replaceEOL($tmp, $eol, !empty($options['stream']));
1237: }
1238: if (!empty($options['stream'])) {
1239: $parts_close[] = $tmp;
1240: }
1241: $parts[] = $tmp;
1242: }
1243: $parts[] = $eol . '--' . $boundary . '--' . $eol;
1244: }
1245: }
1246:
1247: if (is_string($headers)) {
1248: array_unshift($parts, $headers);
1249: } elseif ($headers) {
1250: $hdr_ob = $this->addMimeHeaders(array('encode' => $options['encode'], 'headers' => ($headers === true) ? null : $headers));
1251: $hdr_ob->setEOL($eol);
1252: if (!empty($this->_temp['toString'])) {
1253: $hdr_ob->replaceHeader('Content-Transfer-Encoding', $this->_temp['toString']);
1254: }
1255: array_unshift($parts, $hdr_ob->toString(array('charset' => $this->getHeaderCharset(), 'defserver' => $options['defserver'])));
1256: }
1257: }
1258:
1259: $newfp = $this->_writeStream($parts);
1260: array_map('fclose', $parts_close);
1261:
1262: if (!is_null($oldbaseptr)) {
1263: switch ($this->_temp['toString']) {
1264: case '8bit':
1265: if (empty($oldbaseptr)) {
1266: $oldbaseptr = '8bit';
1267: }
1268: break;
1269:
1270: case 'binary':
1271: $oldbaseptr = 'binary';
1272: break;
1273: }
1274: }
1275:
1276: if ($isbase && !empty($options['canonical'])) {
1277: return $this->replaceEOL($newfp, self::RFC_EOL, !empty($options['stream']));
1278: }
1279:
1280: return empty($options['stream'])
1281: ? $this->_readStream($newfp)
1282: : $newfp;
1283: }
1284:
1285: /**
1286: * Get the transfer encoding for the part based on the user requested
1287: * transfer encoding and the current contents of the part.
1288: *
1289: * @param integer $encode A mask of allowable encodings.
1290: *
1291: * @return string The transfer-encoding of this part.
1292: */
1293: protected function _getTransferEncoding($encode = self::ENCODE_7BIT)
1294: {
1295: if (!empty($this->_temp['sendEncoding'])) {
1296: return $this->_temp['sendEncoding'];
1297: } elseif (!empty($this->_temp['sendTransferEncoding'][$encode])) {
1298: return $this->_temp['sendTransferEncoding'][$encode];
1299: }
1300:
1301: if (empty($this->_contents)) {
1302: $encoding = '7bit';
1303: } else {
1304: $nobinary = false;
1305:
1306: switch ($this->getPrimaryType()) {
1307: case 'message':
1308: case 'multipart':
1309: /* RFC 2046 [5.2.1] - message/rfc822 messages only allow 7bit,
1310: * 8bit, and binary encodings. If the current encoding is
1311: * either base64 or q-p, switch it to 8bit instead.
1312: * RFC 2046 [5.2.2, 5.2.3, 5.2.4] - All other message/*
1313: * messages only allow 7bit encodings.
1314: *
1315: * TODO: What if message contains 8bit characters and we are
1316: * in strict 7bit mode? Not sure there is anything we can do
1317: * in that situation, especially for message/rfc822 parts.
1318: *
1319: * These encoding will be figured out later (via toString()).
1320: * They are limited to 7bit, 8bit, and binary. Default to
1321: * '7bit' per RFCs. */
1322: $encoding = '7bit';
1323: $nobinary = true;
1324: break;
1325:
1326: case 'text':
1327: $eol = $this->getEOL();
1328:
1329: if ($this->_scanStream($this->_contents, '8bit')) {
1330: $encoding = ($encode & self::ENCODE_8BIT || $encode & self::ENCODE_BINARY)
1331: ? '8bit'
1332: : 'quoted-printable';
1333: } elseif ($this->_scanStream($this->_contents, 'preg', "/(?:" . $eol . "|^)[^" . $eol . "]{999,}(?:" . $eol . "|$)/")) {
1334: /* If the text is longer than 998 characters between
1335: * linebreaks, use quoted-printable encoding to ensure the
1336: * text will not be chopped (i.e. by sendmail if being
1337: * sent as mail text). */
1338: $encoding = 'quoted-printable';
1339: } else {
1340: $encoding = '7bit';
1341: }
1342: break;
1343:
1344: default:
1345: /* If transfer encoding has changed from the default, use that
1346: * value. */
1347: if ($this->_transferEncoding != self::DEFAULT_ENCODING) {
1348: $encoding = $this->_transferEncoding;
1349: } else {
1350: $encoding = ($encode & self::ENCODE_8BIT || $encode & self::ENCODE_BINARY)
1351: ? '8bit'
1352: : 'base64';
1353: }
1354: break;
1355: }
1356:
1357: /* Need to do one last check for binary data if encoding is 7bit
1358: * or 8bit. If the message contains a NULL character at all, the
1359: * message MUST be in binary format. RFC 2046 [2.7, 2.8, 2.9]. Q-P
1360: * and base64 can handle binary data fine so no need to switch
1361: * those encodings. */
1362: if (!$nobinary &&
1363: in_array($encoding, array('8bit', '7bit')) &&
1364: $this->_scanStream($this->_contents, 'binary')) {
1365: $encoding = ($encode & self::ENCODE_BINARY)
1366: ? 'binary'
1367: : 'base64';
1368: }
1369: }
1370:
1371: $this->_temp['sendTransferEncoding'][$encode] = $encoding;
1372:
1373: return $encoding;
1374: }
1375:
1376: /**
1377: * Replace newlines in this part's contents with those specified by either
1378: * the given newline sequence or the part's current EOL setting.
1379: *
1380: * @param mixed $text The text to replace. Either a string or a
1381: * stream resource. If a stream, and returning
1382: * a string, will close the stream when done.
1383: * @param string $eol The EOL sequence to use. If not present, uses
1384: * the part's current EOL setting.
1385: * @param boolean $stream If true, returns a stream resource.
1386: *
1387: * @return string The text with the newlines replaced by the desired
1388: * newline sequence (returned as a stream resource if
1389: * $stream is true).
1390: */
1391: public function replaceEOL($text, $eol = null, $stream = false)
1392: {
1393: if (is_null($eol)) {
1394: $eol = $this->getEOL();
1395: }
1396:
1397: $fp = $this->_writeStream($text);
1398:
1399: stream_filter_register('horde_eol', 'Horde_Stream_Filter_Eol');
1400: stream_filter_append($fp, 'horde_eol', STREAM_FILTER_READ, array('eol' => $eol));
1401:
1402: return $stream ? $fp : $this->_readStream($fp, true);
1403: }
1404:
1405: /**
1406: * Determine the size of this MIME part and its child members.
1407: *
1408: * @param boolean $approx If true, determines an approximate size for
1409: * parts consisting of base64 encoded data (since
1410: * 1.1.0).
1411: *
1412: * @return integer Size of the part, in bytes.
1413: */
1414: public function getBytes($approx = false)
1415: {
1416: $bytes = 0;
1417:
1418: if (isset($this->_bytes)) {
1419: $bytes = $this->_bytes;
1420:
1421: /* Base64 transfer encoding is approx. 33% larger than original
1422: * data size (RFC 2045 [6.8]). */
1423: if ($approx && ($this->_transferEncoding == 'base64')) {
1424: $bytes *= 0.75;
1425: }
1426: } elseif ($this->getPrimaryType() == 'multipart') {
1427: reset($this->_parts);
1428: while (list(,$part) = each($this->_parts)) {
1429: $bytes += $part->getBytes($approx);
1430: }
1431: } elseif ($this->_contents) {
1432: fseek($this->_contents, 0, SEEK_END);
1433: $bytes = ftell($this->_contents);
1434:
1435: /* Base64 transfer encoding is approx. 33% larger than original
1436: * data size (RFC 2045 [6.8]). */
1437: if ($approx && ($this->_transferEncoding == 'base64')) {
1438: $bytes *= 0.75;
1439: }
1440: }
1441:
1442: return $bytes;
1443: }
1444:
1445: /**
1446: * Explicitly set the size (in bytes) of this part. This value will only
1447: * be returned (via getBytes()) if there are no contents currently set.
1448: * This function is useful for setting the size of the part when the
1449: * contents of the part are not fully loaded (i.e. creating a
1450: * Horde_Mime_Part object from IMAP header information without loading the
1451: * data of the part).
1452: *
1453: * @param integer $bytes The size of this part in bytes.
1454: */
1455: public function setBytes($bytes)
1456: {
1457: $this->setDispositionParameter('size', $bytes);
1458: }
1459:
1460: /**
1461: * Output the size of this MIME part in KB.
1462: *
1463: * @param boolean $approx If true, determines an approximate size for
1464: * parts consisting of base64 encoded data (since
1465: * 1.1.0).
1466: *
1467: * @return string Size of the part in KB.
1468: */
1469: public function getSize($approx = false)
1470: {
1471: if (!($bytes = $this->getBytes($approx))) {
1472: return 0;
1473: }
1474:
1475: $kb = $bytes / 1024;
1476: $localeinfo = Horde_Nls::getLocaleInfo();
1477:
1478: /* Reduce need for decimals as part size gets larger. */
1479: if ($kb > 100) {
1480: $decimals = 0;
1481: } elseif ($kb > 10) {
1482: $decimals = 1;
1483: } else {
1484: $decimals = 2;
1485: }
1486:
1487: // TODO: Workaround broken number_format() prior to PHP 5.4.0.
1488: return str_replace(
1489: array('X', 'Y'),
1490: array($localeinfo['decimal_point'], $localeinfo['thousands_sep']),
1491: number_format($kb, $decimals, 'X', 'Y')
1492: );
1493: }
1494:
1495: /**
1496: * Sets the Content-ID header for this part.
1497: *
1498: * @param string $cid Use this CID (if not already set). Else, generate
1499: * a random CID.
1500: *
1501: * @return string The Content-ID for this part.
1502: */
1503: public function setContentId($cid = null)
1504: {
1505: if (is_null($this->_contentid)) {
1506: $this->_contentid = is_null($cid)
1507: ? (strval(new Horde_Support_Randomid()) . '@' . $_SERVER['SERVER_NAME'])
1508: : $cid;
1509: }
1510:
1511: return $this->_contentid;
1512: }
1513:
1514: /**
1515: * Returns the Content-ID for this part.
1516: *
1517: * @return string The Content-ID for this part.
1518: */
1519: public function getContentId()
1520: {
1521: return $this->_contentid;
1522: }
1523:
1524: /**
1525: * Alter the MIME ID of this part.
1526: *
1527: * @param string $mimeid The MIME ID.
1528: */
1529: public function setMimeId($mimeid)
1530: {
1531: $this->_mimeid = $mimeid;
1532: }
1533:
1534: /**
1535: * Returns the MIME ID of this part.
1536: *
1537: * @return string The MIME ID.
1538: */
1539: public function getMimeId()
1540: {
1541: return $this->_mimeid;
1542: }
1543:
1544: /**
1545: * Build the MIME IDs for this part and all subparts.
1546: *
1547: * @param string $id The ID of this part.
1548: * @param boolean $rfc822 Is this a message/rfc822 part?
1549: */
1550: public function buildMimeIds($id = null, $rfc822 = false)
1551: {
1552: if (is_null($id)) {
1553: $rfc822 = true;
1554: $id = '';
1555: }
1556:
1557: if ($rfc822) {
1558: if (empty($this->_parts)) {
1559: $this->setMimeId($id . '1');
1560: } else {
1561: if (empty($id) && ($this->getType() == 'message/rfc822')) {
1562: $this->setMimeId('1');
1563: $id = '1.';
1564: } else {
1565: $this->setMimeId($id . '0');
1566: }
1567: $i = 1;
1568: foreach (array_keys($this->_parts) as $val) {
1569: $this->_parts[$val]->buildMimeIds($id . ($i++));
1570: }
1571: }
1572: } else {
1573: $this->setMimeId($id);
1574: $id = $id
1575: ? $id . '.'
1576: : '';
1577:
1578: if ($this->getType() == 'message/rfc822') {
1579: if (count($this->_parts)) {
1580: reset($this->_parts);
1581: $this->_parts[key($this->_parts)]->buildMimeIds($id, true);
1582: }
1583: } elseif (!empty($this->_parts)) {
1584: $i = 1;
1585: foreach (array_keys($this->_parts) as $val) {
1586: $this->_parts[$val]->buildMimeIds($id . ($i++));
1587: }
1588: }
1589: }
1590:
1591: $this->_reindex = false;
1592: }
1593:
1594: /**
1595: * Generate the unique boundary string (if not already done).
1596: *
1597: * @return string The boundary string.
1598: */
1599: protected function _generateBoundary()
1600: {
1601: if (is_null($this->_boundary)) {
1602: $this->_boundary = '=_' . strval(new Horde_Support_Randomid());
1603: }
1604: return $this->_boundary;
1605: }
1606:
1607: /**
1608: * Returns a mapping of all MIME IDs to their content-types.
1609: *
1610: * @param boolean $sort Sort by MIME ID?
1611: *
1612: * @return array Keys: MIME ID; values: content type.
1613: */
1614: public function contentTypeMap($sort = true)
1615: {
1616: $map = array($this->getMimeId() => $this->getType());
1617: foreach ($this->_parts as $val) {
1618: $map += $val->contentTypeMap(false);
1619: }
1620:
1621: if ($sort) {
1622: uksort($map, 'strnatcmp');
1623: }
1624:
1625: return $map;
1626: }
1627:
1628: /**
1629: * Is this the base MIME part?
1630: *
1631: * @param boolean $base True if this is the base MIME part.
1632: */
1633: public function isBasePart($base)
1634: {
1635: $this->_basepart = $base;
1636: }
1637:
1638: /**
1639: * Set a piece of metadata on this object.
1640: *
1641: * @param string $key The metadata key.
1642: * @param mixed $data The metadata. If null, clears the key.
1643: */
1644: public function setMetadata($key, $data = null)
1645: {
1646: if (is_null($data)) {
1647: unset($this->_metadata[$key]);
1648: } else {
1649: $this->_metadata[$key] = $data;
1650: }
1651: }
1652:
1653: /**
1654: * Retrieves metadata from this object.
1655: *
1656: * @param string $key The metadata key.
1657: *
1658: * @return mixed The metadata, or null if it doesn't exist.
1659: */
1660: public function getMetadata($key)
1661: {
1662: return isset($this->_metadata[$key])
1663: ? $this->_metadata[$key]
1664: : null;
1665: }
1666:
1667: /**
1668: * Sends this message.
1669: *
1670: * @param string $email The address list to send to.
1671: * @param Horde_Mime_Headers $headers The Horde_Mime_Headers object
1672: * holding this message's headers.
1673: * @param Horde_Mail_Transport $mailer A Horde_Mail_Transport object.
1674: * @param array $opts Additional options:
1675: * - encode: (integer) The encoding to use. A mask of self::ENCODE_*
1676: * values.
1677: * DEFAULT: Auto-determined based on transport driver.
1678: *
1679: * @throws Horde_Mime_Exception
1680: * @throws InvalidArgumentException
1681: */
1682: public function send($email, $headers, Horde_Mail_Transport $mailer,
1683: array $opts = array())
1684: {
1685: $old_basepart = $this->_basepart;
1686: $this->_basepart = true;
1687:
1688: /* Does the SMTP backend support 8BITMIME (RFC 1652) or
1689: * BINARYMIME (RFC 3030) extensions? Requires Net_SMTP version
1690: * 1.3+. */
1691: $encode = self::ENCODE_7BIT;
1692: if (isset($opts['encode'])) {
1693: /* Always allow 7bit encoding. */
1694: $encode |= $opts['encode'];
1695: } else {
1696: if ($mailer instanceof Horde_Mail_Transport_Smtp) {
1697: try {
1698: $smtp_ext = $mailer->getSMTPObject()->getServiceExtensions();
1699: if (isset($smtp_ext['8BITMIME'])) {
1700: $encode |= self::ENCODE_8BIT;
1701: }
1702: if (isset($smtp_ext['BINARYMIME'])) {
1703: $encode |= self::ENCODE_BINARY;
1704: }
1705: } catch (Horde_Mail_Exception $e) {}
1706: }
1707: }
1708:
1709: $msg = $this->toString(array(
1710: 'canonical' => true,
1711: 'encode' => $encode,
1712: 'headers' => false,
1713: 'stream' => true
1714: ));
1715:
1716: /* Make sure the message has a trailing newline. */
1717: fseek($msg, -1, SEEK_END);
1718: switch (fgetc($msg)) {
1719: case "\r":
1720: if (fgetc($msg) != "\n") {
1721: fputs($msg, "\n");
1722: }
1723: break;
1724:
1725: default:
1726: fputs($msg, "\r\n");
1727: break;
1728: }
1729: rewind($msg);
1730:
1731: /* Add MIME Headers if they don't already exist. */
1732: if (!$headers->getValue('MIME-Version')) {
1733: $headers = $this->addMimeHeaders(array('encode' => $encode, 'headers' => $headers));
1734: }
1735:
1736: if (!empty($this->_temp['toString'])) {
1737: $headers->replaceHeader('Content-Transfer-Encoding', $this->_temp['toString']);
1738: switch ($this->_temp['toString']) {
1739: case 'binary':
1740: $mailer->addServiceExtensionParameter('BODY', 'BINARYMIME');
1741: break;
1742:
1743: case '8bit':
1744: $mailer->addServiceExtensionParameter('BODY', '8BITMIME');
1745: break;
1746: }
1747: }
1748:
1749: $this->_basepart = $old_basepart;
1750:
1751: try {
1752: $mailer->send(Horde_Mime::encodeAddress($email, $this->getCharset()), $headers->toArray(array(
1753: 'canonical' => true,
1754: 'charset' => $this->getHeaderCharset()
1755: )), $msg);
1756: } catch (Horde_Mail_Exception $e) {
1757: throw new Horde_Mime_Exception($e);
1758: }
1759: }
1760:
1761: /**
1762: * Finds the main "body" text part (if any) in a message.
1763: * "Body" data is the first text part under this part.
1764: *
1765: * @param string $subtype Specifically search for this subtype.
1766: *
1767: * @return mixed The MIME ID of the main body part, or null if a body
1768: part is not found.
1769: */
1770: public function findBody($subtype = null)
1771: {
1772: $initial_id = $this->getMimeId();
1773: $this->buildMimeIds();
1774:
1775: foreach ($this->contentTypeMap() as $mime_id => $mime_type) {
1776: if ((strpos($mime_type, 'text/') === 0) &&
1777: (!$initial_id || (intval($mime_id) == 1)) &&
1778: (is_null($subtype) || (substr($mime_type, 5) == $subtype)) &&
1779: ($part = $this->getPart($mime_id)) &&
1780: ($part->getDisposition() != 'attachment')) {
1781: return $mime_id;
1782: }
1783: }
1784:
1785: return null;
1786: }
1787:
1788: /**
1789: * Write data to a stream.
1790: *
1791: * @param array $data The data to write. Either a stream resource or
1792: * a string.
1793: * @param array $options Additional options:
1794: * <pre>
1795: * error - (boolean) Catch errors when writing to the stream. Throw an
1796: * ErrorException if an error is found.
1797: * DEFAULT: false
1798: * filter - (array) Filter(s) to apply to the string. Keys are the
1799: * filter names, values are filter params.
1800: * fp - (resource) Use this stream instead of creating a new one.
1801: * </pre>
1802: *
1803: * @return resource The stream resource.
1804: * @throws ErrorException
1805: */
1806: protected function _writeStream($data, $options = array())
1807: {
1808: if (empty($options['fp'])) {
1809: $fp = fopen('php://temp/maxmemory:' . self::$memoryLimit, 'r+');
1810: } else {
1811: $fp = $options['fp'];
1812: fseek($fp, 0, SEEK_END);
1813: }
1814:
1815: if (!is_array($data)) {
1816: $data = array($data);
1817: }
1818:
1819: if (!empty($options['filter'])) {
1820: $append_filter = array();
1821: foreach ($options['filter'] as $key => $val) {
1822: $append_filter[] = stream_filter_append($fp, $key, STREAM_FILTER_WRITE, $val);
1823: }
1824: }
1825:
1826: if (!empty($options['error'])) {
1827: set_error_handler(array($this, '_writeStreamErrorHandler'));
1828: $error = null;
1829: }
1830:
1831: try {
1832: reset($data);
1833: while (list(,$d) = each($data)) {
1834: if (is_resource($d)) {
1835: rewind($d);
1836: while (!feof($d)) {
1837: fwrite($fp, fread($d, 8192));
1838: }
1839: } else {
1840: $len = strlen($d);
1841: $i = 0;
1842: while ($i < $len) {
1843: fwrite($fp, substr($d, $i, 8192));
1844: $i += 8192;
1845: }
1846: }
1847: }
1848: } catch (ErrorException $e) {
1849: $error = $e;
1850: }
1851:
1852: if (!empty($options['filter'])) {
1853: foreach ($append_filter as $val) {
1854: stream_filter_remove($val);
1855: }
1856: }
1857:
1858: if (!empty($options['error'])) {
1859: restore_error_handler();
1860: if ($error) {
1861: throw $error;
1862: }
1863: }
1864:
1865: return $fp;
1866: }
1867:
1868: /**
1869: * Error handler for _writeStream().
1870: *
1871: * @param integer $errno Error code.
1872: * @param string $errstr Error text.
1873: *
1874: * @throws ErrorException
1875: */
1876: protected function _writeStreamErrorHandler($errno, $errstr)
1877: {
1878: throw new ErrorException($errstr, $errno);
1879: }
1880:
1881: /**
1882: * Read data from a stream.
1883: *
1884: * @param resource $fp An active stream.
1885: * @param boolean $close Close the stream when done reading?
1886: *
1887: * @return string The data from the stream.
1888: */
1889: protected function _readStream($fp, $close = false)
1890: {
1891: $out = '';
1892:
1893: if (!is_resource($fp)) {
1894: return $out;
1895: }
1896:
1897: rewind($fp);
1898: while (!feof($fp)) {
1899: $out .= fread($fp, 8192);
1900: }
1901:
1902: if ($close) {
1903: fclose($fp);
1904: }
1905:
1906: return $out;
1907: }
1908:
1909: /**
1910: * Scans a stream for the requested data.
1911: *
1912: * @param resource $fp A stream resource.
1913: * @param string $type Either '8bit', 'binary', or 'preg'.
1914: * @param mixed $data Any additional data needed to do the scan.
1915: *
1916: * @param boolean The result of the scan.
1917: */
1918: protected function _scanStream($fp, $type, $data = null)
1919: {
1920: rewind($fp);
1921: while (is_resource($fp) && !feof($fp)) {
1922: $line = fread($fp, 8192);
1923: switch ($type) {
1924: case '8bit':
1925: if (Horde_Mime::is8bit($line)) {
1926: return true;
1927: }
1928: break;
1929:
1930: case 'binary':
1931: if (strpos($line, "\0") !== false) {
1932: return true;
1933: }
1934: break;
1935:
1936: case 'preg':
1937: if (preg_match($data, $line)) {
1938: return true;
1939: }
1940: break;
1941: }
1942: }
1943:
1944: return false;
1945: }
1946:
1947: /**
1948: * Attempts to build a Horde_Mime_Part object from message text.
1949: * This function can be called statically via:
1950: * $mime_part = Horde_Mime_Part::parseMessage();
1951: *
1952: * @param string $text The text of the MIME message.
1953: * @param array $options Additional options:
1954: * <pre>
1955: * 'forcemime' - (boolean) If true, the message data is assumed to be
1956: * MIME data. If not, a MIME-Version header must exist (RFC
1957: * 2045 [4]) to be parsed as a MIME message.
1958: * DEFAULT: false
1959: * </pre>
1960: *
1961: * @return Horde_Mime_Part A MIME Part object.
1962: * @throws Horde_Mime_Exception
1963: */
1964: static public function parseMessage($text, $options = array())
1965: {
1966: /* Find the header. */
1967: list($hdr_pos, $eol) = self::_findHeader($text);
1968:
1969: $ob = self::_getStructure(substr($text, 0, $hdr_pos), substr($text, $hdr_pos + $eol), null, !empty($options['forcemime']));
1970: $ob->buildMimeIds();
1971: return $ob;
1972: }
1973:
1974: /**
1975: * Creates a structure object from the text of one part of a MIME message.
1976: *
1977: * @param string $header The header text.
1978: * @param string $body The body text.
1979: * @param string $ctype The default content-type.
1980: * @param boolean $forcemime If true, the message data is assumed to be
1981: * MIME data. If not, a MIME-Version header
1982: * must exist to be parsed as a MIME message.
1983: *
1984: * @return Horde_Mime_Part TODO
1985: */
1986: static protected function _getStructure($header, $body,
1987: $ctype = 'application/octet-stream',
1988: $forcemime = false)
1989: {
1990: /* Parse headers text into a Horde_Mime_Headers object. */
1991: $hdrs = Horde_Mime_Headers::parseHeaders($header);
1992:
1993: $ob = new Horde_Mime_Part();
1994:
1995: /* This is not a MIME message. */
1996: if (!$forcemime && !$hdrs->getValue('mime-version')) {
1997: $ob->setType('text/plain');
1998:
1999: if (!empty($body)) {
2000: $ob->setContents($body);
2001: $ob->setBytes( strlen(str_replace(array("\r\n", "\n"), array("\n", "\r\n"), $body)));
2002: }
2003:
2004: return $ob;
2005: }
2006:
2007: /* Content type. */
2008: if ($tmp = $hdrs->getValue('content-type', Horde_Mime_Headers::VALUE_BASE)) {
2009: $ob->setType($tmp);
2010:
2011: $ctype_params = $hdrs->getValue('content-type', Horde_Mime_Headers::VALUE_PARAMS);
2012: foreach ($ctype_params as $key => $val) {
2013: $ob->setContentTypeParameter($key, $val);
2014: }
2015: } else {
2016: $ob->setType($ctype);
2017: $ctype_params = array();
2018: }
2019:
2020: /* Content transfer encoding. */
2021: if ($tmp = $hdrs->getValue('content-transfer-encoding')) {
2022: $ob->setTransferEncoding($tmp);
2023: }
2024:
2025: /* Content-Description. */
2026: if ($tmp = $hdrs->getValue('content-description')) {
2027: $ob->setDescription($tmp);
2028: }
2029:
2030: /* Content-Disposition. */
2031: if ($tmp = $hdrs->getValue('content-disposition', Horde_Mime_Headers::VALUE_BASE)) {
2032: $ob->setDisposition($tmp);
2033: foreach ($hdrs->getValue('content-disposition', Horde_Mime_Headers::VALUE_PARAMS) as $key => $val) {
2034: $ob->setDispositionParameter($key, $val);
2035: }
2036: }
2037:
2038: /* Content-Duration */
2039: if ($tmp = $hdrs->getValue('content-duration')) {
2040: $ob->setDuration($tmp);
2041: }
2042:
2043: /* Content-ID. */
2044: if ($tmp = $hdrs->getValue('content-id')) {
2045: $ob->setContentId($tmp);
2046: }
2047:
2048: /* Get file size (if 'body' text is set). */
2049: if (!empty($body)) {
2050: $ob->setContents($body);
2051: if ($ob->getType() != '/message/rfc822') {
2052: $ob->setBytes(strlen(str_replace(array("\r\n", "\n"), array("\n", "\r\n"), $body)));
2053: }
2054: }
2055:
2056: /* Process subparts. */
2057: switch ($ob->getPrimaryType()) {
2058: case 'message':
2059: if ($ob->getSubType() == 'rfc822') {
2060: $ob->addPart(self::parseMessage($body, array('forcemime' => true)));
2061: }
2062: break;
2063:
2064: case 'multipart':
2065: if (isset($ctype_params['boundary'])) {
2066: $b_find = self::_findBoundary($body, 0, $ctype_params['boundary']);
2067: foreach ($b_find as $val) {
2068: $subpart = substr($body, $val['start'], $val['length']);
2069: list($hdr_pos, $eol) = self::_findHeader($subpart);
2070: $ob->addPart(self::_getStructure(substr($subpart, 0, $hdr_pos), substr($subpart, $hdr_pos + $eol), ($ob->getSubType() == 'digest') ? 'message/rfc822' : 'text/plain', true));
2071: }
2072: }
2073: break;
2074: }
2075:
2076: return $ob;
2077: }
2078:
2079: /**
2080: * Attempts to obtain the raw text of a MIME part.
2081: * This function can be called statically via:
2082: * $data = Horde_Mime_Part::getRawPartText();
2083: *
2084: * @param mixed $text The full text of the MIME message. The text is
2085: * assumed to be MIME data (no MIME-Version checking
2086: * is performed). It can be either a stream or a
2087: * string.
2088: * @param string $type Either 'header' or 'body'.
2089: * @param string $id The MIME ID.
2090: *
2091: * @return string The raw text.
2092: * @throws Horde_Mime_Exception
2093: */
2094: static public function getRawPartText($text, $type, $id)
2095: {
2096: /* Mini-hack to get a blank Horde_Mime part so we can call
2097: * replaceEOL(). From an API perspective, getRawPartText() should be
2098: * static since it is not working on MIME part data. */
2099: $part = new Horde_Mime_Part();
2100: $rawtext = $part->replaceEOL($text, self::RFC_EOL);
2101:
2102: /* We need to carry around the trailing "\n" because this is needed
2103: * to correctly find the boundary string. */
2104: list($hdr_pos, $eol) = self::_findHeader($rawtext);
2105: $curr_pos = $hdr_pos + $eol - 1;
2106:
2107: if ($id == 0) {
2108: switch ($type) {
2109: case 'body':
2110: return substr($rawtext, $curr_pos + 1);
2111:
2112: case 'header':
2113: return trim(substr($rawtext, 0, $hdr_pos));
2114: }
2115: }
2116:
2117: $hdr_ob = Horde_Mime_Headers::parseHeaders(trim(substr($rawtext, 0, $hdr_pos)));
2118:
2119: /* If this is a message/rfc822, pass the body into the next loop.
2120: * Don't decrement the ID here. */
2121: if ($hdr_ob->getValue('Content-Type', Horde_Mime_Headers::VALUE_BASE) == 'message/rfc822') {
2122: return self::getRawPartText(substr($rawtext, $curr_pos + 1), $type, $id);
2123: }
2124:
2125: $base_pos = strpos($id, '.');
2126: if ($base_pos !== false) {
2127: $base_pos = substr($id, 0, $base_pos);
2128: $id = substr($id, $base_pos);
2129: } else {
2130: $base_pos = $id;
2131: $id = 0;
2132: }
2133:
2134: $params = $hdr_ob->getValue('Content-Type', Horde_Mime_Headers::VALUE_PARAMS);
2135: if (!isset($params['boundary'])) {
2136: throw new Horde_Mime_Exception('Could not find MIME part.');
2137: }
2138:
2139: $b_find = self::_findBoundary($rawtext, $curr_pos, $params['boundary'], $base_pos);
2140:
2141: if (!isset($b_find[$base_pos])) {
2142: throw new Horde_Mime_Exception('Could not find MIME part.');
2143: }
2144:
2145: return self::getRawPartText(substr($rawtext, $b_find[$base_pos]['start'], $b_find[$base_pos]['length'] - 1), $type, $id);
2146: }
2147:
2148: /**
2149: * Find the location of the end of the header text.
2150: *
2151: * @param string $text The text to search.
2152: *
2153: * @return array 1st element: Header position, 2nd element: Length of
2154: * trailing EOL.
2155: */
2156: static protected function _findHeader($text)
2157: {
2158: $hdr_pos = strpos($text, "\r\n\r\n");
2159: if ($hdr_pos !== false) {
2160: return array($hdr_pos, 4);
2161: }
2162:
2163: $hdr_pos = strpos($text, "\n\n");
2164: return ($hdr_pos === false)
2165: ? array(strlen($text), 0)
2166: : array($hdr_pos, 2);
2167: }
2168:
2169: /**
2170: * Find the location of the next boundary string.
2171: *
2172: * @param string $text The text to search.
2173: * @param integer $pos The current position in $text.
2174: * @param string $boundary The boundary string.
2175: * @param integer $end If set, return after matching this many
2176: * boundaries.
2177: *
2178: * @return array Keys are the boundary number, values are an array with
2179: * two elements: 'start' and 'length'.
2180: */
2181: static protected function _findBoundary($text, $pos, $boundary,
2182: $end = null)
2183: {
2184: $i = 0;
2185: $out = array();
2186:
2187: $search = "--" . $boundary;
2188: $search_len = strlen($search);
2189:
2190: while (($pos = strpos($text, $search, $pos)) !== false) {
2191: /* Boundary needs to appear at beginning of string or right after
2192: * a LF. */
2193: if (($pos != 0) && ($text[$pos - 1] != "\n")) {
2194: continue;
2195: }
2196:
2197: if (isset($out[$i])) {
2198: $out[$i]['length'] = $pos - $out[$i]['start'] - 1;
2199: }
2200:
2201: if (!is_null($end) && ($end == $i)) {
2202: break;
2203: }
2204:
2205: $pos += $search_len;
2206: if (isset($text[$pos])) {
2207: switch ($text[$pos]) {
2208: case "\r":
2209: $pos += 2;
2210: $out[++$i] = array('start' => $pos);
2211: break;
2212:
2213: case "\n":
2214: $out[++$i] = array('start' => ++$pos);
2215: break;
2216:
2217: case '-':
2218: return $out;
2219: }
2220: }
2221: }
2222:
2223: return $out;
2224: }
2225:
2226: /* ArrayAccess methods. */
2227:
2228: public function offsetExists($offset)
2229: {
2230: return ($this->getPart($offset) !== null);
2231: }
2232:
2233: public function offsetGet($offset)
2234: {
2235: return $this->getPart($offset);
2236: }
2237:
2238: public function offsetSet($offset, $value)
2239: {
2240: $this->alterPart($offset, $value);
2241: }
2242:
2243: public function offsetUnset($offset)
2244: {
2245: $this->removePart($offset);
2246: }
2247:
2248: /* Countable methods. */
2249:
2250: /**
2251: * Returns the number of message parts.
2252: *
2253: * @return integer Number of message parts.
2254: */
2255: public function count()
2256: {
2257: return count($this->_parts);
2258: }
2259:
2260: /* Serializable methods. */
2261:
2262: /**
2263: * Serialization.
2264: *
2265: * @return string Serialized data.
2266: */
2267: public function serialize()
2268: {
2269: $data = array(
2270: // Serialized data ID.
2271: self::VERSION
2272: );
2273:
2274: foreach ($this->_serializedVars as $val) {
2275: $data[] = $this->$val;
2276: }
2277:
2278: if (!empty($this->_contents)) {
2279: $data[] = $this->_readStream($this->_contents);
2280: }
2281:
2282: return serialize($data);
2283: }
2284:
2285: /**
2286: * Unserialization.
2287: *
2288: * @param string $data Serialized data.
2289: *
2290: * @throws Exception
2291: */
2292: public function unserialize($data)
2293: {
2294: $data = @unserialize($data);
2295: if (!is_array($data) ||
2296: !isset($data[0]) ||
2297: (array_shift($data) != self::VERSION)) {
2298: throw new Horde_Mime_Exception('Cache version change');
2299: }
2300:
2301: foreach ($this->_serializedVars as $key => $val) {
2302: $this->$val = $data[$key];
2303: }
2304:
2305: // $key now contains the last index of _serializedVars.
2306: if (isset($data[++$key])) {
2307: $this->setContents($data[$key]);
2308: }
2309: }
2310:
2311: }
2312: