RFC 2231 fixes
authorMichael M Slusarz <slusarz@curecanti.org>
Tue, 2 Dec 2008 08:06:50 +0000 (01:06 -0700)
committerMichael M Slusarz <slusarz@curecanti.org>
Tue, 2 Dec 2008 08:06:50 +0000 (01:06 -0700)
Do a better job of encoding/wrapping MIME parameter values correctly -
in short, we need to keep these parameters separate from the "base"
value of the header so that we can (potentially) encode them differently
than the base value.

framework/Mime/lib/Horde/Mime.php
framework/Mime/lib/Horde/Mime/Address.php
framework/Mime/lib/Horde/Mime/Headers.php
framework/Mime/lib/Horde/Mime/Mail.php
framework/Mime/lib/Horde/Mime/Part.php

index d0a7ea2..55fd4f9 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 /**
- * The Horde_Mime:: class provides methods for dealing with various MIME (see, e.g.,
- * RFC 2045) standards.
+ * The Horde_Mime:: class provides methods for dealing with various MIME (see,
+ * e.g., RFC 2045-2049; 2183; 2231) standards.
  *
  * Copyright 1999-2008 The Horde Project (http://www.horde.org/)
  *
 class Horde_Mime
 {
     /**
+     * Attempt to work around non RFC 2231-compliant MUAs by generating both
+     * a RFC 2047-like parameter name and  also the correct RFC 2231
+     * parameter.  See:
+     * http://lists.horde.org/archives/dev/Week-of-Mon-20040426/014240.html
+     *
+     * @var boolean
+     */
+    static public $brokenRFC2231 = false;
+
+    /**
      * Determines if a string contains 8-bit (non US-ASCII) characters.
      *
      * @param string $string   The string to check.
@@ -312,35 +322,38 @@ class Horde_Mime
     }
 
     /**
-     * Encodes a parameter string pursuant to RFC 2231.
+     * Encodes a MIME parameter string pursuant to RFC 2183 & 2231
+     * (Content-Type and Content-Disposition headers).
      *
      * @param string $name     The parameter name.
-     * @param string $string   The string to encode.
+     * @param string $val      The parameter value.
      * @param string $charset  The charset the text should be encoded with.
      * @param string $lang     The language to use when encoding.
      *
      * @return array  The encoded parameter string.
      */
-    static public function encodeParamString($name, $string, $charset,
-                                             $lang = null)
+    static public function encodeParam($name, $val, $charset, $lang = null)
     {
         $encode = $wrap = false;
+        $i = 0;
         $output = array();
 
-        if (self::is8bit($string, $charset)) {
-            $string = String::lower($charset) . '\'' . (is_null($lang) ? '' : String::lower($lang)) . '\'' . rawurlencode($string);
+        if (self::is8bit($val, $charset)) {
+            $string = String::lower($charset) . '\'' . (is_null($lang) ? '' : String::lower($lang)) . '\'' . rawurlencode($val);
             $encode = true;
+        } else {
+            $string = $val;
         }
 
         // 4 = '*', 2x '"', ';'
         $pre_len = strlen($name) + 4 + (($encode) ? 1 : 0);
-        if (($pre_len + strlen($string)) > 76) {
+        if (($pre_len + strlen($string)) > 75) {
             while ($string) {
-                $chunk = 76 - $pre_len;
+                $chunk = 75 - $pre_len;
                 $pos = min($chunk, strlen($string) - 1);
                 if (($chunk == $pos) && ($pos > 2)) {
-                    for ($i = 0; $i <= 2; $i++) {
-                        if ($string[$pos-$i] == '%') {
+                    for ($i = 0; $i <= 2; ++$i) {
+                        if ($string[$pos - $i] == '%') {
                             $pos -= $i + 1;
                             break;
                         }
@@ -354,27 +367,26 @@ class Horde_Mime
             $lines = array($string);
         }
 
-        $i = 0;
-        foreach ($lines as $val) {
-            $output[] =
-                $name .
-                (($wrap) ? ('*' . $i++) : '') .
-                (($encode) ? '*' : '') .
-                '="' . $val . '"';
+        foreach ($lines as $line) {
+            $output[$name . (($wrap) ? ('*' . $i++) : '') . (($encode) ? '*' : '')] = $line;
         }
 
-        return implode('; ', $output);
+        if (self::$brokenRFC2231 && !isset($output[$name])) {
+            $output[$name] = self::encode($val, $charset);
+        }
+
+        return $output;
     }
 
     /**
-     * Decodes a parameter string encoded pursuant to RFC 2231.
+     * Decodes a header string encoded pursuant to RFC 2231.
      *
-     * @param string $string      The entire string to decode, including the
-     *                            parameter name.
+     * @param string $string      The string to decode.
      * @param string $to_charset  The charset the text should be decoded to.
      *
-     * @return array  The decoded text, or the original string if it was not
-     *                encoded.
+     * @return array  An array with the following entries:
+     * <pre>
+     * </pre>
      */
     static public function decodeParamString($string, $to_charset = null)
     {
@@ -382,11 +394,6 @@ class Horde_Mime
             return false;
         }
 
-        if (!isset($to_charset)) {
-            require_once 'Horde/NLS.php';
-            $to_charset = NLS::getCharset();
-        }
-
         $attribute = substr($string, 0, $pos);
         $charset = $lang = null;
         $output = '';
index fe9a983..fcebaca 100644 (file)
@@ -364,7 +364,7 @@ class Horde_Mime_Address
      * in RFC 2822 [3.2.5].
      *
      * @param string $str   The string to be quoted and escaped.
-     * @param string $type  Either 'address' or 'personal'.
+     * @param string $type  Either 'address', 'personal', or null.
      *
      * @return string  The correctly quoted and escaped string.
      */
index 6869276..ceb521a 100644 (file)
@@ -45,8 +45,8 @@ class Horde_Mime_Headers
      * <pre>
      * 'charset' => (string) Encodes the headers using this charset.
      *              DEFAULT: No encoding.
-     * 'defserver' => (string) TODO
-     *              DEFAULT: NO
+     * 'defserver' => (string) The default domain to append to mailboxes.
+     *              DEFAULT: No default name.
      * 'nowrap' => (integer) Don't wrap the headers.
      *             DEFAULT: Headers are wrapped.
      * </pre>
@@ -55,30 +55,43 @@ class Horde_Mime_Headers
      */
     public function toArray($options = array())
     {
+        $charset = empty($options['charset']) ? null : $options['charset'];
+        $address_keys = $charset ? array() : $this->addressFields();
         $ret = array();
-        $address_keys = empty($options['charset'])
-            ? array()
-            : $this->addressFields();
 
         foreach ($this->_headers as $header => $ob) {
             $val = is_array($ob['value']) ? $ob['value'] : array($ob['value']);
 
             foreach (array_keys($val) as $key) {
-                if (!empty($address_keys)) {
-                    if (in_array($header, $address_keys)) {
-                        $text = Horde_Mime::encodeAddress($val[$key], empty($options['charset']) ? null : $options['charset'], empty($options['defserver']) ? null : $options['defserver']);
-                                                                                                        if (is_a($text, 'PEAR_Error')) {
-                            $text = $val[$key];
-                        }
-                    } else {
-                        $text = Horde_Mime::encode($val[$key], $options['charset']);
+                if (in_array($header, $address_keys) ) {
+                    /* Address encoded headers. */
+                    $text = Horde_Mime::encodeAddress($val[$key], $charset, empty($options['defserver']) ? null : $options['defserver']);
+                    if (is_a($text, 'PEAR_Error')) {
+                        $text = $val[$key];
                     }
                 } else {
-                    $text = $val[$key];
+                    $text = $charset
+                        ? Horde_Mime::encode($val[$key], $charset)
+                        : $val[$key];
+
+                    /* MIME encoded headers (RFC 2231). */
+                    if (in_array($header, array('content-type', 'content-disposition')) &&
+                        !empty($ob['params'])) {
+                        foreach ($ob['params'] as $name => $param) {
+                            foreach (Horde_Mime::encodeParam($name, $param, $charset) as $name2 => $param2) {
+                                /* MIME parameter quoting is identical to RFC
+                                 * 822 quoted-string encoding. See RFC 2045
+                                 * [Appendix A]. */
+                                $text .= '; ' . $name2 . '=' . Horde_Mime_Address::encode($param2, null);
+                            }
+                        }
+                    }
                 }
 
                 if (empty($options['nowrap'])) {
-                    $text = $this->wrapHeaders($header, $text);
+                    /* Remove any existing linebreaks and wrap the line. */
+                    $header_text = $ob['header'] . ': ';
+                    $text = substr(wordwrap($header_text . strtr(trim($text), array("\r" => '', "\n" => '')), 76, $this->_eol . ' '), strlen($header_text));
                 }
 
                 $val[$key] = $text;
@@ -97,8 +110,8 @@ class Horde_Mime_Headers
      * <pre>
      * 'charset' => (string) Encodes the headers using this charset.
      *              DEFAULT: No encoding.
-     * 'defserver' => (string) TODO
-     *              DEFAULT: NO
+     * 'defserver' => (string) The default domain to append to mailboxes.
+     *              DEFAULT: No default name.
      * 'nowrap' => (integer) Don't wrap the headers.
      *             DEFAULT: Headers are wrapped.
      * </pre>
@@ -225,11 +238,16 @@ class Horde_Mime_Headers
     /**
      * Add a header to the header array.
      *
-     * @param string $header   The header name.
-     * @param string $value    The header value.
-     * @param boolean $decode  MIME decode the value?
+     * @param string $header  The header name.
+     * @param string $value   The header value.
+     * @param array $options  Additional options:
+     * <pre>
+     * 'decode' - (boolean) MIME decode the value?
+     * 'params' - (array) MIME parameters for Content-Type or
+     *            Content-Disposition
+     * </pre>
      */
-    public function addHeader($header, $value, $decode = false)
+    public function addHeader($header, $value, $options = array())
     {
         require_once 'Horde/String.php';
 
@@ -242,7 +260,7 @@ class Horde_Mime_Headers
         }
         $ptr = &$this->_headers[$lcHeader];
 
-        if ($decode) {
+        if (!empty($options['decode'])) {
             // Fields defined in RFC 2822 that contain address information
             if (in_array($lcHeader, $this->addressFields())) {
                 $value = Horde_Mime::decodeAddrString($value);
@@ -259,6 +277,10 @@ class Horde_Mime_Headers
         } else {
             $ptr['value'] = $value;
         }
+
+        if (!empty($options['params'])) {
+            $ptr['params'] = $options['params'];
+        }
     }
 
     /**
@@ -275,26 +297,36 @@ class Horde_Mime_Headers
     /**
      * Replace a value of a header.
      *
-     * @param string $header   The header name.
-     * @param string $value    The header value.
-     * @param boolean $decode  MIME decode the value?
+     * @param string $header  The header name.
+     * @param string $value   The header value.
+     * @param array $options  Additional options:
+     * <pre>
+     * 'decode' - (boolean) MIME decode the value?
+     * 'params' - (array) MIME parameters for Content-Type or
+     *            Content-Disposition
+     * </pre>
      */
-    public function replaceHeader($header, $value, $decode = false)
+    public function replaceHeader($header, $value, $options = array())
     {
         $this->removeHeader($header);
-        $this->addHeader($header, $value, $decode);
+        $this->addHeader($header, $value, $options);
     }
 
     /**
      * Set a value for a particular header ONLY if that header is set.
      *
-     * @param string $header   The header name.
-     * @param string $value    The header value.
-     * @param boolean $decode  MIME decode the value?
+     * @param string $header  The header name.
+     * @param string $value   The header value.
+     * @param array $options  Additional options:
+     * <pre>
+     * 'decode' - (boolean) MIME decode the value?
+     * 'params' - (array) MIME parameters for Content-Type or
+     *            Content-Disposition
+     * </pre>
      *
      * @return boolean  True if value was set.
      */
-    public function setValue($header, $value, $decode = false)
+    public function setValue($header, $value, $options = array())
     {
         require_once 'Horde/String.php';
 
@@ -454,66 +486,6 @@ class Horde_Mime_Headers
     }
 
     /**
-     * Adds proper linebreaks to a header string.
-     * RFC 2822 says headers SHOULD only be 78 characters a line, but also
-     * says that a header line MUST not be more than 998 characters.
-     *
-     * @param string $header  The header name.
-     * @param string $text    The text of the header field.
-     *
-     * @return string  The header value, with linebreaks inserted.
-     */
-    public function wrapHeaders($header, $text)
-    {
-        $eol = $this->_eol;
-        $header_text = rtrim($header) . ': ';
-
-        /* Remove any existing linebreaks. */
-        $text = $header_text . preg_replace("/\r?\n\s?/", ' ', rtrim($text));
-
-        if (!in_array(strtolower($header), array('content-type', 'content-disposition'))) {
-            /* Wrap the line. */
-            $line = wordwrap($text, 75, $eol . ' ');
-
-            /* Make sure there are no empty lines. */
-            $line = preg_replace('/' . $eol . ' ' . $eol . ' /', '/' . $eol . ' /', $line);
-
-            return substr($line, strlen($header_text));
-        }
-
-        /* Split the line by the RFC parameter separator ';'. */
-        $params = preg_split("/\s*;\s*/", $text);
-
-        $line = '';
-        $eollength = strlen($eol);
-        $length = 1000 - $eollength;
-        $paramcount = count($params) - 1;
-
-        reset($params);
-        while (list($count, $val) = each($params)) {
-            /* If longer than RFC allows, then simply chop off the excess. */
-            $moreparams = ($count != $paramcount);
-            $maxlength = $length - (!empty($line) ? 1 : 0) - (($moreparams) ? 1 : 0);
-            if (strlen($val) > $maxlength) {
-                $val = substr($val, 0, $maxlength);
-
-                /* If we have an opening quote, add a closing quote after
-                 * chopping the rest of the text. */
-                if (strpos($val, '"') !== false) {
-                    $val = substr($val, 0, -1) . '"';
-                }
-            }
-
-            if (!empty($line)) {
-                $line .= ' ';
-            }
-            $line .= $val . (($moreparams) ? ';' : '') . $eol;
-        }
-
-        return substr($line, strlen($header_text), ($eollength * -1));
-    }
-
-    /**
      * Builds a Horde_Mime_Headers object from header text.
      * This function can be called statically:
      *   $headers = Horde_Mime_Headers::parseHeaders().
@@ -537,14 +509,15 @@ class Horde_Mime_Headers
                 $currtext .= ' ' . ltrim($val);
             } else {
                 if (!is_null($currheader)) {
-                    $headers->addHeader($currheader, $currtext, true);
+                    // TODO: RFC 2231
+                    $headers->addHeader($currheader, $currtext, array('decode' => true));
                 }
                 $pos = strpos($val, ':');
                 $currheader = substr($val, 0, $pos);
                 $currtext = ltrim(substr($val, $pos + 1));
             }
         }
-        $headers->addHeader($currheader, $currtext, true);
+        $headers->addHeader($currheader, $currtext, array('decode' => true));
 
         return $headers;
     }
index 7d8ae00..0991e19 100644 (file)
@@ -60,6 +60,13 @@ class Horde_Mime_Mail
     protected $_mailer_driver = 'smtp';
 
     /**
+     * The charset to use for the message.
+     *
+     * @var string
+     */
+    protected $_charset;
+
+    /**
      * The Mail driver parameters.
      *
      * @link http://pear.php.net/Mail
@@ -85,15 +92,16 @@ class Horde_Mime_Mail
         }
 
         $this->_headers = new Horde_Mime_Headers();
+        $this->_charset = $charset;
 
         if ($subject) {
-            $this->addHeader('Subject', $subject, $charset);
+            $this->addHeader('Subject', $subject);
         }
         if ($to) {
-            $this->addHeader('To', $to, $charset);
+            $this->addHeader('To', $to);
         }
         if ($from) {
-            $this->addHeader('From', $from, $charset);
+            $this->addHeader('From', $from);
         }
         if ($body) {
             $this->setBody($body, $charset);
index 29676b2..9a22b94 100644 (file)
@@ -740,72 +740,26 @@ class Horde_Mime_Part
             $headers = new Horde_Mime_Headers();
         }
 
-        foreach ($this->getHeaderArray() as $key => $val) {
-            $headers->replaceHeader($key, $val);
-        }
-
-        return $headers;
-    }
-
-    /**
-     * Get the list of MIME headers for this part in an array.
-     *
-     * @return array  The full set of MIME headers.
-     */
-    public function getHeaderArray()
-    {
-        $headers = array();
-
-        if ($this->_basepart) {
-            /* Per RFC 2046 [4], this MUST appear in the message headers. */
-            $headers['MIME-Version'] = '1.0';
-        }
-
         $ptype = $this->getPrimaryType();
         $stype = $this->getSubType();
 
-        /* Get the character set for this part. */
-        $charset = $this->getCharset();
-
-        /* Get the Content-Type - this is ALWAYS required. */
-        $ctype = $this->getType(true);
-
-        /* Manually encode Content-Type and Disposition parameters in here,
-         * rather than in Horde_Mime_Headers, since it is easier to do when
-         * the paramters are broken down. Encoding in the headers object will
-         * ignore these headers Since they will already be in 7bit. */
-        foreach ($this->getAllContentTypeParameters() as $key => $value) {
-            /* Skip the charset key since that would have already been
-             * added to $ctype by getType(). */
-            if ($key == 'charset') {
-                continue;
-            }
-
-            $encode_2231 = Horde_Mime::encodeParamString($key, $value, $charset);
-            /* Try to work around non RFC 2231-compliant MUAs by sending both
-             * a RFC 2047-like parameter name and then the correct RFC 2231
-             * parameter.  See:
-             * http://lists.horde.org/archives/dev/Week-of-Mon-20040426/014240.html */
-            if (!empty($GLOBALS['conf']['mailformat']['brokenrfc2231']) &&
-                (strpos($encode_2231, '*=') !== false)) {
-                $ctype .= '; ' . $key . '="' . Horde_Mime::encode($value, $charset) . '"';
-            }
-            $ctype .= '; ' . $encode_2231;
-        }
-        $headers['Content-Type'] = $ctype;
+        /* Get the Content-Type itself. */
+        $headers->replaceHeader('Content-Type', $this->getType(), array('params' => $this->getAllContentTypeParameters()));
 
         /* Get the description, if any. */
         if (($descrip = $this->getDescription())) {
-            $headers['Content-Description'] = $descrip;
+            $headers->replaceHeader('Content-Description', $descrip);
         }
 
         /* RFC 2045 [4] - message/rfc822 and message/partial require the
-           MIME-Version header only if they themselves claim to be MIME
-           compliant. */
-        if (($ptype == 'message') &&
-            (($stype == 'rfc822') || ($stype == 'partial'))) {
-            // TODO - Check for "MIME-Version" in message/rfc822 part.
-            $headers['MIME-Version'] = '1.0';
+         * MIME-Version header only if they themselves claim to be MIME
+         * compliant.
+         * @TODO - Check for "MIME-Version" in message/rfc822 part.
+         * Per RFC 2046 [4], this MUST appear in the base message headers. */
+        if ($this->_basepart ||
+            (($ptype == 'message') &&
+             (($stype == 'rfc822') || ($stype == 'partial')))) {
+            $headers->replaceHeader('MIME-Version', '1.0');
         }
 
         /* message/* parts require no additional header information. */
@@ -817,28 +771,15 @@ class Horde_Mime_Part
            there is a name parameter. */
         $name = $this->getName();
         if (($ptype != 'multipart') || !empty($name)) {
-            $disp = $this->getDisposition();
-
-            /* Add any disposition parameter information, if available. */
-            if (!empty($name)) {
-                $encode_2231 = Horde_Mime::encodeParamString('filename', $name, $charset);
-                /* Same broken RFC 2231 workaround as above. */
-                if (!empty($GLOBALS['conf']['mailformat']['brokenrfc2231']) &&
-                    (strpos($encode_2231, '*=') !== false)) {
-                    $disp .= '; filename="' . Horde_Mime::encode($name, $charset) . '"';
-                }
-                $disp .= '; ' . $encode_2231;
-            }
-
-            $headers['Content-Disposition'] = $disp;
+            $headers->replaceHeader('Content-Disposition', $this->getDisposition(), array('params' => (!empty($name) ? array('filename' => $name) : array())));
         }
 
         /* Add transfer encoding information. */
-        $headers['Content-Transfer-Encoding'] = $this->getTransferEncoding();
+        $headers->replaceHeader('Content-Transfer-Encoding', $this->getTransferEncoding());
 
         /* Add content ID information. */
         if (!is_null($this->_contentid)) {
-            $headers['Content-ID'] = $this->_contentid;
+            $headers->replaceHeader('Content-ID', $this->_contentid);
         }
 
         return $headers;