Typo EncryptedMessageFactory -> PGPMimeMessageFactory in send-pgp-mime.py
[pgp-mime.git] / interfaces / email / interactive / send_pgp_mime.py
index 38a2437b0c7d5eb421334f85aeef6acf2ebbe433..939648c8a01904c6e42337779f4610483e8e4a1c 100644 (file)
@@ -35,6 +35,7 @@ import tempfile
 import types
 
 try:
+    from email import Message
     from email.mime.text import MIMEText
     from email.mime.multipart import MIMEMultipart
     from email.mime.application import MIMEApplication
@@ -44,6 +45,7 @@ try:
     from email.utils import getaddress
 except ImportError:
     # adjust to old python 2.4
+    from email import Message
     from email.MIMEText import MIMEText
     from email.MIMEMultipart import MIMEMultipart
     from email.MIMENonMultipart import MIMENonMultipart
@@ -51,7 +53,7 @@ except ImportError:
     from email.Generator import Generator
     from email.parser import Parser
     from email.Utils import getaddresses
-    
+
     getaddress = getaddresses
     class MIMEApplication (MIMENonMultipart):
         def __init__(self, _data, _subtype, _encoder, **params):
@@ -72,7 +74,7 @@ in your shell before invoking this script.  See gpg-agent(1) for more
 details.  Alternatively, you can send your passphrase in on stdin
   echo 'passphrase' | %prog [options]
 or use the --passphrase-file option
-  %prog [options] --passphrase-file FILE [more options]  
+  %prog [options] --passphrase-file FILE [more options]
 Both of these alternatives are much less secure than gpg-agent.  You
 have been warned.
 """
@@ -82,7 +84,7 @@ PGP_SIGN_AS = None
 PASSPHRASE = None
 
 # The following commands are adapted from my .mutt/pgp configuration
-# 
+#
 # Printf-like sequences:
 #   %a The value of PGP_SIGN_AS.
 #   %f Expands to the name of a file with text to be signed/encrypted.
@@ -91,7 +93,7 @@ PASSPHRASE = None
 #      strings.
 #   %r One key ID (e.g. recipient email address) to build a
 #      pgp_reciepient_arg string.
-# 
+#
 # The above sequences can be used to optionally print a string if
 # their length is nonzero. For example, you may only want to pass the
 # -u/--local-user argument to gpg if PGP_SIGN_AS is defined.  To
@@ -115,6 +117,80 @@ pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - -
 pgp_encrypt_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --encrypt --sign %?a?-u "%a"? --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
 sendmail='/usr/sbin/sendmail -t'
 
+def mail(msg, sendmail=None):
+    """
+    Send an email Message instance on its merry way.
+
+    We can shell out to the user specified sendmail in case
+    the local host doesn't have an SMTP server set up
+    for easy smtplib usage.
+    """
+    if sendmail != None:
+        execute(sendmail, stdin=flatten(msg))
+        return None
+    s = smtplib.SMTP()
+    s.connect()
+    s.sendmail(from_addr=source_email(msg),
+               to_addrs=target_emails(msg),
+               msg=flatten(msg))
+    s.close()
+
+def header_from_text(text, encoding="us-ascii"):
+    """
+    Simple wrapper for instantiating an email.Message from text.
+    >>> header = header_from_text('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']))
+    >>> print flatten(header)
+    From: me@big.edu
+    To: you@big.edu
+    Subject: testing
+    <BLANKLINE>
+    <BLANKLINE>
+    """
+    text = text.strip()
+    if type(text) == types.UnicodeType:
+        text = text.encode(encoding)
+    # assume StringType arguments are already encoded
+    p = Parser()
+    return p.parsestr(text, headersonly=True)
+
+def guess_encoding(text):
+    if type(text) == types.StringType:
+        encoding = "us-ascii"
+    elif type(text) == types.UnicodeType:
+        for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
+            try:
+                text.encode(encoding)
+            except UnicodeError:
+                pass
+            else:
+                break
+        assert encoding != None
+    return encoding
+
+def encodedMIMEText(body, encoding=None):
+    if encoding == None:
+        encoding = guess_encoding(body)
+    if encoding == "us-ascii":
+        return MIMEText(body)
+    else:
+        # Create the message ('plain' stands for Content-Type: text/plain)
+        return MIMEText(body.encode(encoding), 'plain', encoding)
+
+def append_text(text_part, new_text):
+    original_payload = text_part.get_payload(decode=True)
+    new_payload = u"%s%s" % (original_payload, new_text)
+    new_encoding = guess_encoding(new_payload)
+    text_part.set_payload(new_payload.encode(new_encoding), new_encoding)
+
+def attach_root(header, root_part):
+    """
+    Attach the email.Message root_part to the email.Message header
+    without generating a multi-part message.
+    """
+    for k,v in header.items():
+        root_part[k] = v
+    return root_part    
+
 def execute(args, stdin=None, expect=(0,)):
     """
     Execute a command (allows us to drive gpg).
@@ -146,7 +222,7 @@ def replace(template, format_char, replacement_text):
     """
     if replacement_text == None:
         replacement_text = ""
-    regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]') 
+    regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
     if len(replacement_text) > 0:
         str = regexp.sub('\g<1>', template)
     else:
@@ -155,7 +231,7 @@ def replace(template, format_char, replacement_text):
     str = regexp.sub(replacement_text, str)
     return str
 
-def flatten(msg):
+def flatten(msg, to_unicode=False):
     """
     Produce flat text output from an email Message instance.
     """
@@ -164,8 +240,10 @@ def flatten(msg):
     g = Generator(fp, mangle_from_=False)
     g.flatten(msg)
     text = fp.getvalue()
-    encoding = msg.get_content_charset()
-    return unicode(text, encoding=encoding)
+    if to_unicode == True:
+        encoding = msg.get_content_charset() or "utf-8"
+        text = unicode(text, encoding=encoding)
+    return text
 
 def source_email(msg, return_realname=False):
     """
@@ -190,35 +268,21 @@ def target_emails(msg):
     resent_tos = msg.get_all('resent-to', [])
     resent_ccs = msg.get_all('resent-cc', [])
     resent_bccs = msg.get_all('resent-bcc', [])
-    all_recipients = getaddresses(tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs)
+    all_recipients = getaddresses(tos + ccs + bccs + resent_tos
+                                  + resent_ccs + resent_bccs)
     return [addr[1] for addr in all_recipients]
 
-def mail(msg, sendmail=None):
-    """
-    Send an email Message instance on its merry way.
-    
-    We can shell out to the user specified sendmail in case
-    the local host doesn't have an SMTP server set up
-    for easy smtplib usage.
-    """
-    if sendmail != None:
-        execute(sendmail, stdin=flatten(msg))
-        return None
-    s = smtplib.SMTP()
-    s.connect()
-    s.sendmail(from_addr=source_email(msg),
-               to_addrs=target_emails(msg),
-               msg=flatten(msg))
-    s.close()
-
-class Mail (object):
+class PGPMimeMessageFactory (object):
     """
     See http://www.ietf.org/rfc/rfc3156.txt for specification details.
-    >>> m = Mail('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']), 'check 1 2\\ncheck 1 2\\n')
-    >>> print m.sourceEmail()
-    me@big.edu
-    >>> print m.targetEmails()
-    ['you@big.edu']
+    >>> from_addr = "me@big.edu"
+    >>> to_addr = "you@you.edu"
+    >>> header = header_from_text('\\n'.join(['From: %s'%from_addr,'To: %s'%to_addr,'Subject: testing']))
+    >>> source_email(header) == from_addr
+    True
+    >>> target_emails(header) == [to_addr]
+    True
+    >>> m = PGPMimeMessageFactory('check 1 2\\ncheck 1 2\\n')
     >>> print flatten(m.clearBodyPart())
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
@@ -232,30 +296,22 @@ class Mail (object):
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
     Content-Transfer-Encoding: 7bit
-    From: me@big.edu
-    To: you@big.edu
-    Subject: testing
     <BLANKLINE>
     check 1 2
     check 1 2
     <BLANKLINE>
-    >>> m.sign()
+    >>> signed = m.sign(header)
     >>> signed.set_boundary('boundsep')
-    >>> print m.stripSig(flatten(signed)).replace('\\t', ' '*4)
-    Content-Type: multipart/signed;
-        protocol="application/pgp-signature";
+    >>> print flatten(signed).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
+    Content-Type: multipart/signed; protocol="application/pgp-signature";
         micalg="pgp-sha1"; boundary="boundsep"
     MIME-Version: 1.0
-    From: me@big.edu
-    To: you@big.edu
-    Subject: testing
     Content-Disposition: inline
     <BLANKLINE>
     --boundsep
     Content-Type: text/plain; charset="us-ascii"
     MIME-Version: 1.0
     Content-Transfer-Encoding: 7bit
-    Content-Type: text/plain
     Content-Disposition: inline
     <BLANKLINE>
     check 1 2
@@ -269,20 +325,17 @@ class Mail (object):
         charset="us-ascii"
     <BLANKLINE>
     -----BEGIN PGP SIGNATURE-----
-    SIGNATURE STRIPPED (depends on current time)
+    ...
     -----END PGP SIGNATURE-----
     <BLANKLINE>
     --boundsep--
-    >>> encrypted = m.encrypt()
+    >>> encrypted = m.encrypt(header)
     >>> encrypted.set_boundary('boundsep')
-    >>> print m.stripPGP(flatten(encrypted)).replace('\\t', ' '*4)
+    >>> print flatten(encrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
     Content-Type: multipart/encrypted;
         protocol="application/pgp-encrypted";
         micalg="pgp-sha1"; boundary="boundsep"
     MIME-Version: 1.0
-    From: me@big.edu
-    To: you@big.edu
-    Subject: testing
     Content-Disposition: inline
     <BLANKLINE>
     --boundsep
@@ -298,20 +351,17 @@ class Mail (object):
     Content-Type: application/octet-stream; charset="us-ascii"
     <BLANKLINE>
     -----BEGIN PGP MESSAGE-----
-    MESSAGE STRIPPED (depends on current time)
+    ...
     -----END PGP MESSAGE-----
     <BLANKLINE>
     --boundsep--
-    >>> signedAndEncrypted = m.signAndEncrypt()
+    >>> signedAndEncrypted = m.signAndEncrypt(header)
     >>> signedAndEncrypted.set_boundary('boundsep')
-    >>> print m.stripPGP(flatten(signedAndEncrypted)).replace('\\t', ' '*4)
+    >>> print flatten(signedAndEncrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
     Content-Type: multipart/encrypted;
         protocol="application/pgp-encrypted";
         micalg="pgp-sha1"; boundary="boundsep"
     MIME-Version: 1.0
-    From: me@big.edu
-    To: you@big.edu
-    Subject: testing
     Content-Disposition: inline
     <BLANKLINE>
     --boundsep
@@ -327,42 +377,15 @@ class Mail (object):
     Content-Type: application/octet-stream; charset="us-ascii"
     <BLANKLINE>
     -----BEGIN PGP MESSAGE-----
-    MESSAGE STRIPPED (depends on current time)
+    ...
     -----END PGP MESSAGE-----
     <BLANKLINE>
     --boundsep--
     """
-    def __init__(self, header, body):
-        self.header = header.strip()
+    def __init__(self, body):
         self.body = body
-        if type(self.header) == types.UnicodeType:
-            self.header = self.header.encode("ascii")
-        p = Parser()
-        self.headermsg = p.parsestr(self.header, headersonly=True)
-    def sourceEmail(self):
-        return source_email(self.headermsg)
-    def targetEmails(self):
-        return target_emails(self.headermsg)
-    def encodedMIMEText(self, body, encoding=None):
-        if encoding == None:
-            if type(body) == types.StringType:
-                encoding = "US-ASCII"
-            elif type(body) == types.UnicodeType:
-                for encoding in ["US-ASCII", "ISO-8859-1", "UTF-8"]:
-                    try:
-                        body.encode(encoding)
-                    except UnicodeError:
-                        pass
-                    else:
-                        break
-                assert encoding != None
-        # Create the message ('plain' stands for Content-Type: text/plain)
-        if encoding == "US-ASCII":
-            return MIMEText(body)
-        else:
-            return MIMEText(body.encode(encoding), 'plain', encoding)            
     def clearBodyPart(self):
-        body = self.encodedMIMEText(self.body)
+        body = encodedMIMEText(self.body)
         body.add_header('Content-Disposition', 'inline')
         return body
     def passphrase_arg(self, passphrase=None):
@@ -374,17 +397,14 @@ class Mail (object):
     def plain(self):
         """
         text/plain
-        """        
-        msg = self.encodedMIMEText(self.body)
-        for k,v in self.headermsg.items():
-            msg[k] = v
-        return msg
-    def sign(self, passphrase=None):
+        """
+        return encodedMIMEText(self.body)
+    def sign(self, header, passphrase=None):
         """
         multipart/signed
           +-> text/plain                 (body)
           +-> application/pgp-signature  (signature)
-        """        
+        """
         passphrase,pass_arg = self.passphrase_arg(passphrase)
         body = self.clearBodyPart()
         bfile = tempfile.NamedTemporaryFile()
@@ -393,27 +413,28 @@ class Mail (object):
 
         args = replace(pgp_sign_command, 'f', bfile.name)
         if PGP_SIGN_AS == None:
-            pgp_sign_as = '<%s>' % self.sourceEmail()
+            pgp_sign_as = '<%s>' % source_email(header)
         else:
             pgp_sign_as = PGP_SIGN_AS
         args = replace(args, 'a', pgp_sign_as)
         args = replace(args, 'p', pass_arg)
         status,output,error = execute(args, stdin=passphrase)
         signature = output
-        
-        sig = MIMEApplication(_data=signature, _subtype='pgp-signature; name="signature.asc"', _encoder=encode_7or8bit)
+
+        sig = MIMEApplication(_data=signature,
+                              _subtype='pgp-signature; name="signature.asc"',
+                              _encoder=encode_7or8bit)
         sig['Content-Description'] = 'signature'
         sig.set_charset('us-ascii')
-        
-        msg = MIMEMultipart('signed', micalg='pgp-sha1', protocol='application/pgp-signature')
+
+        msg = MIMEMultipart('signed', micalg='pgp-sha1',
+                            protocol='application/pgp-signature')
         msg.attach(body)
         msg.attach(sig)
-        
-        for k,v in self.headermsg.items():
-            msg[k] = v
+
         msg['Content-Disposition'] = 'inline'
         return msg
-    def encrypt(self, passphrase=None):
+    def encrypt(self, header, passphrase=None):
         """
         multipart/encrypted
          +-> application/pgp-encrypted  (control information)
@@ -423,94 +444,76 @@ class Mail (object):
         bfile = tempfile.NamedTemporaryFile()
         bfile.write(flatten(body))
         bfile.flush()
-        
-        recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
+
+        recipients = [replace(pgp_recipient_arg, 'r', recipient)
+                      for recipient in target_emails(header)]
+        recipient_string = ' '.join(recipients)
         args = replace(pgp_encrypt_only_command, 'R', recipient_string)
         args = replace(args, 'f', bfile.name)
         if PGP_SIGN_AS == None:
-            pgp_sign_as = '<%s>' % self.sourceEmail()
+            pgp_sign_as = '<%s>' % source_email(header)
         else:
             pgp_sign_as = PGP_SIGN_AS
         args = replace(args, 'a', pgp_sign_as)
         status,output,error = execute(args)
         encrypted = output
-        
-        enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
+
+        enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
+                              _encoder=encode_7or8bit)
         enc.set_charset('us-ascii')
-        
-        control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
-        
-        msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
+
+        control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted',
+                                  _encoder=encode_7or8bit)
+
+        msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
+                            protocol='application/pgp-encrypted')
         msg.attach(control)
         msg.attach(enc)
-        
-        for k,v in self.headermsg.items():
-            msg[k] = v
+
         msg['Content-Disposition'] = 'inline'
         return msg
-    def signAndEncrypt(self, passphrase=None):
+    def signAndEncrypt(self, header, passphrase=None):
         """
         multipart/encrypted
          +-> application/pgp-encrypted  (control information)
          +-> application/octet-stream   (body)
         """
         passphrase,pass_arg = self.passphrase_arg(passphrase)
-        body = self.sign()
+        body = self.sign(header, passphrase)
         body.__delitem__('Bcc')
         bfile = tempfile.NamedTemporaryFile()
         bfile.write(flatten(body))
         bfile.flush()
-        
-        recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
+
+        recipients = [replace(pgp_recipient_arg, 'r', recipient)
+                      for recipient in target_emails(header)]
+        recipient_string = ' '.join(recipients)
         args = replace(pgp_encrypt_only_command, 'R', recipient_string)
         args = replace(args, 'f', bfile.name)
         if PGP_SIGN_AS == None:
-            pgp_sign_as = '<%s>' % self.sourceEmail()
+            pgp_sign_as = '<%s>' % source_email(header)
         else:
             pgp_sign_as = PGP_SIGN_AS
         args = replace(args, 'a', pgp_sign_as)
         args = replace(args, 'p', pass_arg)
         status,output,error = execute(args, stdin=passphrase)
         encrypted = output
-        
-        enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
+
+        enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
+                              _encoder=encode_7or8bit)
         enc.set_charset('us-ascii')
-        
-        control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
-        
-        msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
+
+        control = MIMEApplication(_data='Version: 1\n',
+                                  _subtype='pgp-encrypted',
+                                  _encoder=encode_7or8bit)
+
+        msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
+                            protocol='application/pgp-encrypted')
         msg.attach(control)
         msg.attach(enc)
-        
-        for k,v in self.headermsg.items():
-            msg[k] = v
+
         msg['Content-Disposition'] = 'inline'
         return msg
-    def stripChanging(self, text, start, stop, replacement):
-        stripping = False
-        lines = []
-        for line in text.splitlines():
-            line.strip()
-            if stripping == False:
-                lines.append(line)
-                if line == start:
-                    stripping = True
-                    lines.append(replacement)
-            else:
-                if line == stop:
-                    stripping = False
-                    lines.append(line)
-        return '\n'.join(lines)
-    def stripSig(self, text):
-        return self.stripChanging(text,
-                                  '-----BEGIN PGP SIGNATURE-----',
-                                  '-----END PGP SIGNATURE-----',
-                                  'SIGNATURE STRIPPED (depends on current time)')
-    def stripPGP(self, text):
-        return self.stripChanging(text,
-                                  '-----BEGIN PGP MESSAGE-----',
-                                  '-----END PGP MESSAGE-----',
-                                  'MESSAGE STRIPPED (depends on current time)')
 
 def test():
     import doctest
@@ -519,22 +522,22 @@ def test():
 
 if __name__ == '__main__':
     from optparse import OptionParser
-    
+
     parser = OptionParser(usage=usage)
     parser.add_option('-t', '--test', dest='test', action='store_true',
                       help='Run doctests and exit')
-    
+
     parser.add_option('-H', '--header-file', dest='header_filename',
                       help='file containing email header', metavar='FILE')
     parser.add_option('-B', '--body-file', dest='body_filename',
                       help='file containing email body', metavar='FILE')
-    
+
     parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
                       help='file containing gpg passphrase', metavar='FILE')
     parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
                       help='file descriptor from which to read gpg passphrase (0 for stdin)',
                       type="int", metavar='DESCRIPTOR')
-    
+
     parser.add_option('--mode', dest='mode', default='sign',
                       help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'.  Defaults to %default.",
                       metavar='MODE')
@@ -542,14 +545,14 @@ if __name__ == '__main__':
     parser.add_option('-a', '--sign-as', dest='sign_as',
                       help="The gpg key to sign with (gpg's -u/--local-user)",
                       metavar='KEY')
-    
+
     parser.add_option('--output', dest='output', action='store_true',
                       help="Don't mail the generated message, print it to stdout instead.")
-    
+
     (options, args) = parser.parse_args()
-    
+
     stdin_used = False
-    
+
     if options.passphrase_file != None:
         PASSPHRASE = file(options.passphrase_file, 'r').read()
     elif options.passphrase_fd != None:
@@ -558,28 +561,29 @@ if __name__ == '__main__':
             PASSPHRASE = sys.stdin.read()
         else:
             PASSPHRASE = os.read(options.passphrase_fd)
-    
+
     if options.sign_as:
         PGP_SIGN_AS = options.sign_as
 
     if options.test == True:
         test()
         sys.exit(0)
-    
+
     header = None
     if options.header_filename != None:
         if options.header_filename == '-':
-            assert stdin_used == False 
+            assert stdin_used == False
             stdin_used = True
             header = sys.stdin.read()
         else:
             header = file(options.header_filename, 'r').read()
     if header == None:
         raise Exception, "missing header"
+    headermsg = header_from_text(header)
     body = None
     if options.body_filename != None:
         if options.body_filename == '-':
-            assert stdin_used == False 
+            assert stdin_used == False
             stdin_used = True
             body = sys.stdin.read()
         else:
@@ -587,18 +591,19 @@ if __name__ == '__main__':
     if body == None:
         raise Exception, "missing body"
 
-    m = Mail(header, body)
+    m = PGPMimeMessageFactory(body)
     if options.mode == "sign":
-        message = m.sign()
+        bodymsg = m.sign(header)
     elif options.mode == "encrypt":
-        message = m.encrypt()
+        bodymsg = m.encrypt(header)
     elif options.mode == "sign-encrypt":
-        message = m.signAndEncrypt()
+        bodymsg = m.signAndEncrypt(header)
     elif options.mode == "plain":
-        message = m.plain()
+        bodymsg = m.plain()
     else:
         print "Unrecognized mode '%s'" % options.mode
-    
+
+    message = attach_root(headermsg, bodymsg)
     if options.output == True:
         message = flatten(message)
         print message