Code

applied unicode patch
authorkedder <kedder@57a73879-2fb5-44c3-a270-3262357dd7e2>
Wed, 15 Jan 2003 22:17:20 +0000 (22:17 +0000)
committerkedder <kedder@57a73879-2fb5-44c3-a270-3262357dd7e2>
Wed, 15 Jan 2003 22:17:20 +0000 (22:17 +0000)
git-svn-id: http://svn.roundup-tracker.org/svnroot/roundup/trunk@1460 57a73879-2fb5-44c3-a270-3262357dd7e2

CHANGES.txt
roundup/backends/back_anydbm.py
roundup/backends/rdbms_common.py
roundup/mailgw.py
roundup/rfc2822.py [new file with mode: 0644]
roundup/roundupdb.py
roundup/templates/classic/html/_generic.help
roundup/templates/classic/html/page
roundup/templates/minimal/html/_generic.help
roundup/templates/minimal/html/page
test/test_mailgw.py

index d52a001d78308a82224a5051929e1e5968bc25c5..cf4737363fd16c4f2ca7e7465aa6214f61b6ccc8 100644 (file)
@@ -15,6 +15,9 @@ are given with the most recent entry first.
 - fix StringHTMLProperty hyperlinking
 - added mysql backend
 - fixes to CGI form handling (NEEDS BACKPORTING TO 0.5)
 - fix StringHTMLProperty hyperlinking
 - added mysql backend
 - fixes to CGI form handling (NEEDS BACKPORTING TO 0.5)
+- applied unicode patch. All data is stored in utf-8. Incoming messages
+  converted from any encoding to utf-8, outgoing messages are encoded 
+  according to rfc2822 (sf bug 568873)
 
 
 2003-??-?? 0.5.5
 
 
 2003-??-?? 0.5.5
index 14b20f94ce14c34b309d69e4e7ff6c41e3df3e0d..2e1bd9b8fa5c399f5d1d8b215e14d89cd0dd59a7 100644 (file)
@@ -15,7 +15,7 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-#$Id: back_anydbm.py,v 1.96 2003-01-08 05:39:40 richard Exp $
+#$Id: back_anydbm.py,v 1.97 2003-01-15 22:17:19 kedder Exp $
 '''
 This module defines a backend that saves the hyperdatabase in a database
 chosen by anydbm. It is guaranteed to always be available in python
 '''
 This module defines a backend that saves the hyperdatabase in a database
 chosen by anydbm. It is guaranteed to always be available in python
@@ -847,7 +847,7 @@ class Class(hyperdb.Class):
                             (self.classname, newid, key))
 
             elif isinstance(prop, String):
                             (self.classname, newid, key))
 
             elif isinstance(prop, String):
-                if type(value) != type(''):
+                if type(value) != type('') and type(value) != type(u''):
                     raise TypeError, 'new property "%s" not a string'%key
 
             elif isinstance(prop, Password):
                     raise TypeError, 'new property "%s" not a string'%key
 
             elif isinstance(prop, Password):
@@ -1244,7 +1244,7 @@ class Class(hyperdb.Class):
                     journalvalues[propname] = tuple(l)
 
             elif isinstance(prop, String):
                     journalvalues[propname] = tuple(l)
 
             elif isinstance(prop, String):
-                if value is not None and type(value) != type(''):
+                if value is not None and type(value) != type('') and type(value) != type(u''):
                     raise TypeError, 'new property "%s" not a string'%propname
 
             elif isinstance(prop, Password):
                     raise TypeError, 'new property "%s" not a string'%propname
 
             elif isinstance(prop, Password):
index 550b51b7024d9b97846274dcce4ce8ac9d44d5ab..30fd3712fe5221a99941bf3c842fbcba1e3d1d20 100644 (file)
@@ -1,4 +1,4 @@
-# $Id: rdbms_common.py,v 1.28 2003-01-12 23:53:20 richard Exp $
+# $Id: rdbms_common.py,v 1.29 2003-01-15 22:17:19 kedder Exp $
 ''' Relational database (SQL) backend common code.
 
 Basics:
 ''' Relational database (SQL) backend common code.
 
 Basics:
@@ -1070,7 +1070,7 @@ class Class(hyperdb.Class):
                             (self.classname, newid, key))
 
             elif isinstance(prop, String):
                             (self.classname, newid, key))
 
             elif isinstance(prop, String):
-                if type(value) != type(''):
+                if type(value) != type('') and type(value) != type(u''):
                     raise TypeError, 'new property "%s" not a string'%key
 
             elif isinstance(prop, Password):
                     raise TypeError, 'new property "%s" not a string'%key
 
             elif isinstance(prop, Password):
@@ -1432,7 +1432,7 @@ class Class(hyperdb.Class):
                     journalvalues[propname] = tuple(l)
 
             elif isinstance(prop, String):
                     journalvalues[propname] = tuple(l)
 
             elif isinstance(prop, String):
-                if value is not None and type(value) != type(''):
+                if value is not None and type(value) != type('') and type(value) != type(u''):
                     raise TypeError, 'new property "%s" not a string'%propname
 
             elif isinstance(prop, Password):
                     raise TypeError, 'new property "%s" not a string'%propname
 
             elif isinstance(prop, Password):
index b9dd886b85dd9e770e59e8ec3ffacefbdf194e9e..a12f909c49c20c4b24bf82a2045b89caea7a1a86 100644 (file)
@@ -73,7 +73,7 @@ are calling the create() method to create a new node). If an auditor raises
 an exception, the original message is bounced back to the sender with the
 explanatory message given in the exception. 
 
 an exception, the original message is bounced back to the sender with the
 explanatory message given in the exception. 
 
-$Id: mailgw.py,v 1.106 2003-01-12 00:03:10 richard Exp $
+$Id: mailgw.py,v 1.107 2003-01-15 22:17:19 kedder Exp $
 '''
 
 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
 '''
 
 import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri
@@ -81,6 +81,8 @@ import time, random, sys
 import traceback, MimeWriter
 import hyperdb, date, password
 
 import traceback, MimeWriter
 import hyperdb, date, password
 
+import rfc2822
+
 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
 
 class MailGWError(ValueError):
 SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
 
 class MailGWError(ValueError):
@@ -134,6 +136,10 @@ class Message(mimetools.Message):
         s.seek(0)
         return Message(s)
 
         s.seek(0)
         return Message(s)
 
+    def getheader(self, name, default=None):
+        hdr = mimetools.Message.getheader(self, name, default)
+        return rfc2822.decode_header(hdr)
 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*'
     r'\s*(?P<quote>")?(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
     r'\s*(?P<title>[^[]+)?"?(\[(?P<args>.+?)\])?', re.I)
 subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fw|fwd|re|aw)\W\s*)*'
     r'\s*(?P<quote>")?(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?'
     r'\s*(?P<title>[^[]+)?"?(\[(?P<args>.+?)\])?', re.I)
@@ -339,7 +345,7 @@ class MailGW:
         writer.addheader('MIME-Version', '1.0')
         part = writer.startmultipartbody('mixed')
         part = writer.nextpart()
         writer.addheader('MIME-Version', '1.0')
         part = writer.startmultipartbody('mixed')
         part = writer.nextpart()
-        body = part.startbody('text/plain')
+        body = part.startbody('text/plain; charset=utf-8')
         body.write('\n'.join(error))
 
         # attach the original message to the returned message
         body.write('\n'.join(error))
 
         # attach the original message to the returned message
@@ -377,7 +383,19 @@ class MailGW:
         else:
             # take it as text
             data = part.fp.read()
         else:
             # take it as text
             data = part.fp.read()
-        return data
+        
+        # Encode message to unicode
+        charset = rfc2822.unaliasCharset(part.getparam("charset"))
+        if charset:
+            # Do conversion only if charset specified
+            edata = unicode(data, charset).encode('utf-8')
+            # Convert from dos eol to unix
+            edata = edata.replace('\r\n', '\n')
+        else:
+            # Leave message content as is
+            edata = data
+                
+        return edata
 
     def handle_message(self, message):
         ''' message - a Message instance
 
     def handle_message(self, message):
         ''' message - a Message instance
diff --git a/roundup/rfc2822.py b/roundup/rfc2822.py
new file mode 100644 (file)
index 0000000..7cee715
--- /dev/null
@@ -0,0 +1,160 @@
+import re
+from binascii import b2a_base64, a2b_base64
+
+ecre = re.compile(r'''
+  =\?                   # literal =?
+  (?P<charset>[^?]*?)   # non-greedy up to the next ? is the charset
+  \?                    # literal ?
+  (?P<encoding>[qb])    # either a "q" or a "b", case insensitive
+  \?                    # literal ?
+  (?P<encoded>.*?)      # non-greedy up to the next ?= is the encoded string
+  \?=                   # literal ?=
+  ''', re.VERBOSE | re.IGNORECASE)
+
+hqre = re.compile(r'^[-a-zA-Z0-9!*+/\[\]., ]+$')
+
+def base64_decode(s, convert_eols=None):
+    """Decode a raw base64 string.
+
+    If convert_eols is set to a string value, all canonical email linefeeds,
+    e.g. "\\r\\n", in the decoded text will be converted to the value of
+    convert_eols.  os.linesep is a good choice for convert_eols if you are
+    decoding a text attachment.
+
+    This function does not parse a full MIME header value encoded with
+    base64 (like =?iso-8895-1?b?bmloISBuaWgh?=) -- please use the high
+    level email.Header class for that functionality.
+
+    Taken from 'email' module
+    """
+    if not s:
+        return s
+    
+    dec = a2b_base64(s)
+    if convert_eols:
+        return dec.replace(CRLF, convert_eols)
+    return dec
+
+def unquote_match(match):
+    """Turn a match in the form =AB to the ASCII character with value 0xab
+
+    Taken from 'email' module
+    """
+    s = match.group(0)
+    return chr(int(s[1:3], 16))
+
+def qp_decode(s):
+    """Decode a string encoded with RFC 2045 MIME header `Q' encoding.
+
+    This function does not parse a full MIME header value encoded with
+    quoted-printable (like =?iso-8895-1?q?Hello_World?=) -- please use
+    the high level email.Header class for that functionality.
+
+    Taken from 'email' module
+    """
+    s = s.replace('_', ' ')
+    return re.sub(r'=\w{2}', unquote_match, s)
+
+def _decode_header(header):
+    """Decode a message header value without converting charset.
+
+    Returns a list of (decoded_string, charset) pairs containing each of the
+    decoded parts of the header.  Charset is None for non-encoded parts of the
+    header, otherwise a lower-case string containing the name of the character
+    set specified in the encoded string.
+
+    Taken from 'email' module
+    """
+    # If no encoding, just return the header
+    header = str(header)
+    if not ecre.search(header):
+        return [(header, None)]
+
+    decoded = []
+    dec = ''
+    for line in header.splitlines():
+        # This line might not have an encoding in it
+        if not ecre.search(line):
+            decoded.append((line, None))
+            continue
+
+        parts = ecre.split(line)
+        while parts:
+            unenc = parts.pop(0)
+            if unenc:
+                if unenc.strip():
+                    decoded.append((unenc, None))
+            if parts:
+                charset, encoding = [s.lower() for s in parts[0:2]]
+                encoded = parts[2]
+                dec = ''
+                if encoding == 'q':
+                    dec = qp_decode(encoded)
+                elif encoding == 'b':
+                    dec = base64_decode(encoded)
+                else:
+                    dec = encoded
+
+                if decoded and decoded[-1][1] == charset:
+                    decoded[-1] = (decoded[-1][0] + dec, decoded[-1][1])
+                else:
+                    decoded.append((dec, charset))
+            del parts[0:3]
+    return decoded
+
+def decode_header(hdr):
+    """ Decodes rfc2822 encoded header and return utf-8 encoded string
+    """
+    if not hdr:
+        return None
+    outs = u""
+    for section in _decode_header(hdr):
+        charset = unaliasCharset(section[1])
+        outs += unicode(section[0], charset or 'iso-8859-1', 'replace')
+    return outs.encode('utf-8')
+
+def encode_header(header):
+    """ Will encode in quoted-printable encoding only if header 
+    contains non latin characters
+    """
+
+    # Return empty headers unchanged
+    if not header:
+        return header
+
+    global hqre
+    # return plain header if it is not contains non-ascii characters
+    if hqre.match(header):
+        return header
+    
+    charset = 'utf-8'
+    quoted = ''
+    #max_encoded = 76 - len(charset) - 7
+    for c in header:
+        # Space may be represented as _ instead of =20 for readability
+        if c == ' ':
+            quoted += '_'
+        # These characters can be included verbatim
+        elif hqre.match(c):
+            quoted += c
+        # Otherwise, replace with hex value like =E2
+        else:
+            quoted += "=%02X" % ord(c)
+            plain = 0
+
+    return '=?%s?q?%s?=' % (charset, quoted)
+
+def unaliasCharset(charset):
+    if charset:
+        return charset.lower().replace("windows-", 'cp')
+        #return charset_table.get(charset.lower(), charset)
+    return None
+
+def test():
+    print encode_header("Contrary, Mary")
+    #print unaliasCharset('Windows-1251')
+
+if __name__ == '__main__':
+    test()
+
+# vim: et
index a45ec16ac1748360388d003881da9ac3d841b85a..4b3761aebd9772f0f18bf56717e1f5205a047237 100644 (file)
@@ -15,7 +15,7 @@
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
 # 
-# $Id: roundupdb.py,v 1.77 2003-01-14 22:19:27 richard Exp $
+# $Id: roundupdb.py,v 1.78 2003-01-15 22:17:19 kedder Exp $
 
 __doc__ = """
 Extending hyperdb with types specific to issue-tracking.
 
 __doc__ = """
 Extending hyperdb with types specific to issue-tracking.
@@ -24,6 +24,9 @@ Extending hyperdb with types specific to issue-tracking.
 import re, os, smtplib, socket, time, random
 import MimeWriter, cStringIO
 import base64, quopri, mimetypes
 import re, os, smtplib, socket, time, random
 import MimeWriter, cStringIO
 import base64, quopri, mimetypes
+
+from rfc2822 import encode_header
+
 # if available, use the 'email' module, otherwise fallback to 'rfc822'
 try :
     from email.Utils import formataddr as straddr
 # if available, use the 'email' module, otherwise fallback to 'rfc822'
 try :
     from email.Utils import formataddr as straddr
@@ -243,9 +246,10 @@ class IssueClass:
         # create the message
         message = cStringIO.StringIO()
         writer = MimeWriter.MimeWriter(message)
         # create the message
         message = cStringIO.StringIO()
         writer = MimeWriter.MimeWriter(message)
-        writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title))
+        writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, encode_header(title)))
         writer.addheader('To', ', '.join(sendto))
         writer.addheader('To', ', '.join(sendto))
-        writer.addheader('From', straddr((authname + from_tag, from_address)))
+        writer.addheader('From', straddr((encode_header(authname) + 
+            from_tag, from_address)))
         writer.addheader('Reply-To', straddr((self.db.config.TRACKER_NAME,
             from_address)))
         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
         writer.addheader('Reply-To', straddr((self.db.config.TRACKER_NAME,
             from_address)))
         writer.addheader('Date', time.strftime("%a, %d %b %Y %H:%M:%S +0000",
@@ -267,7 +271,7 @@ class IssueClass:
             part = writer.startmultipartbody('mixed')
             part = writer.nextpart()
             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
             part = writer.startmultipartbody('mixed')
             part = writer.nextpart()
             part.addheader('Content-Transfer-Encoding', 'quoted-printable')
-            body = part.startbody('text/plain')
+            body = part.startbody('text/plain; charset=utf-8')
             body.write(content_encoded)
             for fileid in message_files:
                 name = files.get(fileid, 'name')
             body.write(content_encoded)
             for fileid in message_files:
                 name = files.get(fileid, 'name')
@@ -295,7 +299,7 @@ class IssueClass:
             writer.lastpart()
         else:
             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
             writer.lastpart()
         else:
             writer.addheader('Content-Transfer-Encoding', 'quoted-printable')
-            body = writer.startbody('text/plain')
+            body = writer.startbody('text/plain; charset=utf-8')
             body.write(content_encoded)
 
         # now try to send the message
             body.write(content_encoded)
 
         # now try to send the message
index 0197597a74ee23816f5d1277592af45737474bba..9cb15354aff5c8f6dfb189eb70a7416ff9b31b28 100644 (file)
@@ -1,6 +1,7 @@
 <html>
 <head>
 <link rel="stylesheet" type="text/css" href="_file/style.css">
 <html>
 <head>
 <link rel="stylesheet" type="text/css" href="_file/style.css">
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8;">
 </head>
 <body class="body" marginwidth="0" marginheight="0">
 
 </head>
 <body class="body" marginwidth="0" marginheight="0">
 
index 8966ce6282d918286375d0fa2298a307af0c070c..033a379f292de3ba1aa9a87136216791e4b0551c 100644 (file)
@@ -1,6 +1,7 @@
 <html metal:define-macro="icing">
 <head>
 <title metal:define-slot="head_title">title goes here</title>
 <html metal:define-macro="icing">
 <head>
 <title metal:define-slot="head_title">title goes here</title>
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8;">
 
 <link rel="stylesheet" type="text/css" href="_file/style.css">
 
 
 <link rel="stylesheet" type="text/css" href="_file/style.css">
 
index bced017b165aeb0c4078754e8269c9ae92283a19..ff4c7a3c0f97fea0e42d15b96fb6d743e2444b97 100644 (file)
@@ -1,5 +1,6 @@
 <html>
 <head>
 <html>
 <head>
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8;">
 <link rel="stylesheet" type="text/css" href="_file/style.css">
 </head>
 <body class="body" marginwidth="0" marginheight="0">
 <link rel="stylesheet" type="text/css" href="_file/style.css">
 </head>
 <body class="body" marginwidth="0" marginheight="0">
index 3c139cfcb165255138430aa4dc4a08026b2d54d6..219a52a90f8a494e01a5f25bc09ef6d7b98b5a88 100644 (file)
@@ -1,6 +1,7 @@
 <html metal:define-macro="icing">
 <head>
 <title metal:define-slot="head_title">title goes here</title>
 <html metal:define-macro="icing">
 <head>
 <title metal:define-slot="head_title">title goes here</title>
+<meta http-equiv="Content-Type" content="text/html; charset=utf-8;">
 
 <link rel="stylesheet" type="text/css" href="_file/style.css">
 
 
 <link rel="stylesheet" type="text/css" href="_file/style.css">
 
index a680dfb8472751c39b933291a002632729b076ef..5fed0029dc83f2217b1a48cad58741b589f7f589 100644 (file)
@@ -8,7 +8,7 @@
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 #
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 #
-# $Id: test_mailgw.py,v 1.37 2002-12-18 00:42:03 richard Exp $
+# $Id: test_mailgw.py,v 1.38 2003-01-15 22:17:20 kedder Exp $
 
 import unittest, cStringIO, tempfile, os, shutil, errno, imp, sys, difflib
 
 
 import unittest, cStringIO, tempfile, os, shutil, errno, imp, sys, difflib
 
@@ -197,7 +197,7 @@ This is a test submission of a new issue.
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, mary@test, richard@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, mary@test, richard@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, mary@test, richard@test
 From: "Bork, Chef" <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, mary@test, richard@test
 From: "Bork, Chef" <issue_tracker@your.tracker.email.domain.example>
@@ -253,7 +253,7 @@ This is a second followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
@@ -302,7 +302,7 @@ This is a followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, john@test, mary@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, john@test, mary@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, john@test, mary@test
 From: richard <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, john@test, mary@test
 From: richard <issue_tracker@your.tracker.email.domain.example>
@@ -349,7 +349,7 @@ This is a followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, john@test, mary@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, john@test, mary@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, john@test, mary@test
 From: richard <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, john@test, mary@test
 From: richard <issue_tracker@your.tracker.email.domain.example>
@@ -397,7 +397,7 @@ This is a followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
@@ -446,7 +446,7 @@ This is a followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork
 From: richard <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork
 From: richard <issue_tracker@your.tracker.email.domain.example>
@@ -495,7 +495,7 @@ This is a followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, john@test, richard@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, john@test, richard@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, john@test, richard@test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, john@test, richard@test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
@@ -543,7 +543,7 @@ This is a followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: John Doe <issue_tracker@your.tracker.email.domain.example>
@@ -591,7 +591,7 @@ This is a followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork
 From: richard <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork
 From: richard <issue_tracker@your.tracker.email.domain.example>
@@ -700,7 +700,7 @@ A message with encoding (encoded oe =F6)
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
@@ -715,7 +715,7 @@ Content-Transfer-Encoding: quoted-printable
 
 Contrary, Mary <mary@test> added the comment:
 
 
 Contrary, Mary <mary@test> added the comment:
 
-A message with encoding (encoded oe =F6)
+A message with encoding (encoded oe =C3=B6)
 
 ----------
 status: unread -> chatting
 
 ----------
 status: unread -> chatting
@@ -755,7 +755,7 @@ A message with first part encoded (encoded oe =F6)
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork, richard@test
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork, richard@test
 From: "Contrary, Mary" <issue_tracker@your.tracker.email.domain.example>
@@ -770,7 +770,7 @@ Content-Transfer-Encoding: quoted-printable
 
 Contrary, Mary <mary@test> added the comment:
 
 
 Contrary, Mary <mary@test> added the comment:
 
-A message with first part encoded (encoded oe =F6)
+A message with first part encoded (encoded oe =C3=B6)
 
 ----------
 status: unread -> chatting
 
 ----------
 status: unread -> chatting
@@ -800,7 +800,7 @@ This is a followup
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork
         self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(),
 '''FROM: roundup-admin@your.tracker.email.domain.example
 TO: chef@bork.bork.bork
-Content-Type: text/plain
+Content-Type: text/plain; charset=utf-8
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork
 From: richard <issue_tracker@your.tracker.email.domain.example>
 Subject: [issue1] Testing...
 To: chef@bork.bork.bork
 From: richard <issue_tracker@your.tracker.email.domain.example>