Fixed email.Parser typo in send_pgp_mime.py imports for Python 2.4.
[pgp-mime.git] / interfaces / email / interactive / send_pgp_mime.py
1 #!/usr/bin/python
2 #
3 # Copyright (C) 2009 W. Trevor King <wking@drexel.edu>
4 #
5 # This program is free software; you can redistribute it and/or modify
6 # it under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 2 of the License, or
8 # (at your option) any later version.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License along
16 # with this program; if not, write to the Free Software Foundation, Inc.,
17 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18 """
19 Python module and command line tool for sending pgp/mime email.
20
21 Mostly uses subprocess to call gpg and a sendmail-compatible mailer.
22 If you lack gpg, either don't use the encryption functions or adjust
23 the pgp_* commands.  You may need to adjust the sendmail command to
24 point to whichever sendmail-compatible mailer you have on your system.
25 """
26
27 from cStringIO import StringIO
28 import os
29 import re
30 #import GnuPGInterface # Maybe should use this instead of subprocess
31 import smtplib
32 import subprocess
33 import sys
34 import tempfile
35 import types
36
37 try:
38     from email import Message
39     from email.mime.text import MIMEText
40     from email.mime.multipart import MIMEMultipart
41     from email.mime.application import MIMEApplication
42     from email.encoders import encode_7or8bit
43     from email.generator import Generator
44     from email.parser import Parser
45     from email.utils import getaddress
46 except ImportError:
47     # adjust to old python 2.4
48     from email import Message
49     from email.MIMEText import MIMEText
50     from email.MIMEMultipart import MIMEMultipart
51     from email.MIMENonMultipart import MIMENonMultipart
52     from email.Encoders import encode_7or8bit
53     from email.Generator import Generator
54     from email.Parser import Parser
55     from email.Utils import getaddresses
56
57     getaddress = getaddresses
58     class MIMEApplication (MIMENonMultipart):
59         def __init__(self, _data, _subtype, _encoder, **params):
60             MIMENonMultipart.__init__(self, 'application', _subtype, **params)
61             self.set_payload(_data)
62             _encoder(self)
63
64 usage="""usage: %prog [options]
65
66 Scriptable PGP MIME email using gpg.
67
68 You can use gpg-agent for passphrase caching if your key requires a
69 passphrase (it better!).  Example usage would be to install gpg-agent,
70 and then run
71   export GPG_TTY=`tty`
72   eval $(gpg-agent --daemon)
73 in your shell before invoking this script.  See gpg-agent(1) for more
74 details.  Alternatively, you can send your passphrase in on stdin
75   echo 'passphrase' | %prog [options]
76 or use the --passphrase-file option
77   %prog [options] --passphrase-file FILE [more options]
78 Both of these alternatives are much less secure than gpg-agent.  You
79 have been warned.
80 """
81
82 verboseInvoke = False
83 PGP_SIGN_AS = None
84 PASSPHRASE = None
85
86 # The following commands are adapted from my .mutt/pgp configuration
87 #
88 # Printf-like sequences:
89 #   %a The value of PGP_SIGN_AS.
90 #   %f Expands to the name of a file with text to be signed/encrypted.
91 #   %p Expands to the passphrase argument.
92 #   %R A string with some number (0 on up) of pgp_reciepient_arg
93 #      strings.
94 #   %r One key ID (e.g. recipient email address) to build a
95 #      pgp_reciepient_arg string.
96 #
97 # The above sequences can be used to optionally print a string if
98 # their length is nonzero. For example, you may only want to pass the
99 # -u/--local-user argument to gpg if PGP_SIGN_AS is defined.  To
100 # optionally print a string based upon one of the above sequences, the
101 # following construct is used
102 #   %?<sequence_char>?<optional_string>?
103 # where sequence_char is a character from the table above, and
104 # optional_string is the string you would like printed if status_char
105 # is nonzero. optional_string may contain other sequence as well as
106 # normal text, but it may not contain any question marks.
107 #
108 # see http://codesorcery.net/old/mutt/mutt-gnupg-howto
109 #     http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign
110 #     http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html
111 # for more details
112
113 pgp_recipient_arg='-r "%r"'
114 pgp_stdin_passphrase_arg='--passphrase-fd 0'
115 pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f'
116 pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
117 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'
118 sendmail='/usr/sbin/sendmail -t'
119
120 def mail(msg, sendmail=None):
121     """
122     Send an email Message instance on its merry way.
123
124     We can shell out to the user specified sendmail in case
125     the local host doesn't have an SMTP server set up
126     for easy smtplib usage.
127     """
128     if sendmail != None:
129         execute(sendmail, stdin=flatten(msg))
130         return None
131     s = smtplib.SMTP()
132     s.connect()
133     s.sendmail(from_addr=source_email(msg),
134                to_addrs=target_emails(msg),
135                msg=flatten(msg))
136     s.close()
137
138 def header_from_text(text, encoding="us-ascii"):
139     """
140     Simple wrapper for instantiating an email.Message from text.
141     >>> header = header_from_text('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']))
142     >>> print flatten(header)
143     From: me@big.edu
144     To: you@big.edu
145     Subject: testing
146     <BLANKLINE>
147     <BLANKLINE>
148     """
149     text = text.strip()
150     if type(text) == types.UnicodeType:
151         text = text.encode(encoding)
152     # assume StringType arguments are already encoded
153     p = Parser()
154     return p.parsestr(text, headersonly=True)
155
156 def guess_encoding(text):
157     if type(text) == types.StringType:
158         encoding = "us-ascii"
159     elif type(text) == types.UnicodeType:
160         for encoding in ["us-ascii", "iso-8859-1", "utf-8"]:
161             try:
162                 text.encode(encoding)
163             except UnicodeError:
164                 pass
165             else:
166                 break
167         assert encoding != None
168     return encoding
169
170 def encodedMIMEText(body, encoding=None):
171     if encoding == None:
172         encoding = guess_encoding(body)
173     if encoding == "us-ascii":
174         return MIMEText(body)
175     else:
176         # Create the message ('plain' stands for Content-Type: text/plain)
177         return MIMEText(body.encode(encoding), 'plain', encoding)
178
179 def append_text(text_part, new_text):
180     original_payload = text_part.get_payload(decode=True)
181     new_payload = u"%s%s" % (original_payload, new_text)
182     new_encoding = guess_encoding(new_payload)
183     text_part.set_payload(new_payload.encode(new_encoding), new_encoding)
184
185 def attach_root(header, root_part):
186     """
187     Attach the email.Message root_part to the email.Message header
188     without generating a multi-part message.
189     """
190     for k,v in header.items():
191         root_part[k] = v
192     return root_part    
193
194 def execute(args, stdin=None, expect=(0,)):
195     """
196     Execute a command (allows us to drive gpg).
197     """
198     if verboseInvoke == True:
199         print >> sys.stderr, '$ '+args
200     try:
201         p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
202     except OSError, e:
203         strerror = '%s\nwhile executing %s' % (e.args[1], args)
204         raise Exception, strerror
205     output, error = p.communicate(input=stdin)
206     status = p.wait()
207     if verboseInvoke == True:
208         print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
209     if status not in expect:
210         strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
211         raise Exception, strerror
212     return status, output, error
213
214 def replace(template, format_char, replacement_text):
215     """
216     >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
217     '--textmode %?a?-u %a? file.in'
218     >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
219     '--textmode -u 0xHEXKEY %f'
220     >>> replace('--textmode %?a?-u %a? %f', 'a', '')
221     '--textmode  %f'
222     """
223     if replacement_text == None:
224         replacement_text = ""
225     regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
226     if len(replacement_text) > 0:
227         str = regexp.sub('\g<1>', template)
228     else:
229         str = regexp.sub('', template)
230     regexp = re.compile('%'+format_char)
231     str = regexp.sub(replacement_text, str)
232     return str
233
234 def flatten(msg, to_unicode=False):
235     """
236     Produce flat text output from an email Message instance.
237     """
238     assert msg != None
239     fp = StringIO()
240     g = Generator(fp, mangle_from_=False)
241     g.flatten(msg)
242     text = fp.getvalue()
243     if to_unicode == True:
244         encoding = msg.get_content_charset() or "utf-8"
245         text = unicode(text, encoding=encoding)
246     return text
247
248 def source_email(msg, return_realname=False):
249     """
250     Search the header of an email Message instance to find the
251     sender's email address.
252     """
253     froms = msg.get_all('from', [])
254     from_tuples = getaddresses(froms) # [(realname, email_address), ...]
255     assert len(from_tuples) == 1
256     if return_realname == True:
257         return from_tuples[0] # (realname, email_address)
258     return from_tuples[0][1]  # email_address
259
260 def target_emails(msg):
261     """
262     Search the header of an email Message instance to find a
263     list of recipient's email addresses.
264     """
265     tos = msg.get_all('to', [])
266     ccs = msg.get_all('cc', [])
267     bccs = msg.get_all('bcc', [])
268     resent_tos = msg.get_all('resent-to', [])
269     resent_ccs = msg.get_all('resent-cc', [])
270     resent_bccs = msg.get_all('resent-bcc', [])
271     all_recipients = getaddresses(tos + ccs + bccs + resent_tos
272                                   + resent_ccs + resent_bccs)
273     return [addr[1] for addr in all_recipients]
274
275 class PGPMimeMessageFactory (object):
276     """
277     See http://www.ietf.org/rfc/rfc3156.txt for specification details.
278     >>> from_addr = "me@big.edu"
279     >>> to_addr = "you@you.edu"
280     >>> header = header_from_text('\\n'.join(['From: %s'%from_addr,'To: %s'%to_addr,'Subject: testing']))
281     >>> source_email(header) == from_addr
282     True
283     >>> target_emails(header) == [to_addr]
284     True
285     >>> m = PGPMimeMessageFactory('check 1 2\\ncheck 1 2\\n')
286     >>> print flatten(m.clearBodyPart())
287     Content-Type: text/plain; charset="us-ascii"
288     MIME-Version: 1.0
289     Content-Transfer-Encoding: 7bit
290     Content-Disposition: inline
291     <BLANKLINE>
292     check 1 2
293     check 1 2
294     <BLANKLINE>
295     >>> print flatten(m.plain())
296     Content-Type: text/plain; charset="us-ascii"
297     MIME-Version: 1.0
298     Content-Transfer-Encoding: 7bit
299     <BLANKLINE>
300     check 1 2
301     check 1 2
302     <BLANKLINE>
303     >>> signed = m.sign(header)
304     >>> signed.set_boundary('boundsep')
305     >>> print flatten(signed).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
306     Content-Type: multipart/signed; protocol="application/pgp-signature";
307         micalg="pgp-sha1"; boundary="boundsep"
308     MIME-Version: 1.0
309     Content-Disposition: inline
310     <BLANKLINE>
311     --boundsep
312     Content-Type: text/plain; charset="us-ascii"
313     MIME-Version: 1.0
314     Content-Transfer-Encoding: 7bit
315     Content-Disposition: inline
316     <BLANKLINE>
317     check 1 2
318     check 1 2
319     <BLANKLINE>
320     --boundsep
321     MIME-Version: 1.0
322     Content-Transfer-Encoding: 7bit
323     Content-Description: signature
324     Content-Type: application/pgp-signature; name="signature.asc";
325         charset="us-ascii"
326     <BLANKLINE>
327     -----BEGIN PGP SIGNATURE-----
328     ...
329     -----END PGP SIGNATURE-----
330     <BLANKLINE>
331     --boundsep--
332     >>> encrypted = m.encrypt(header)
333     >>> encrypted.set_boundary('boundsep')
334     >>> print flatten(encrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
335     Content-Type: multipart/encrypted;
336         protocol="application/pgp-encrypted";
337         micalg="pgp-sha1"; boundary="boundsep"
338     MIME-Version: 1.0
339     Content-Disposition: inline
340     <BLANKLINE>
341     --boundsep
342     Content-Type: application/pgp-encrypted
343     MIME-Version: 1.0
344     Content-Transfer-Encoding: 7bit
345     <BLANKLINE>
346     Version: 1
347     <BLANKLINE>
348     --boundsep
349     MIME-Version: 1.0
350     Content-Transfer-Encoding: 7bit
351     Content-Type: application/octet-stream; charset="us-ascii"
352     <BLANKLINE>
353     -----BEGIN PGP MESSAGE-----
354     ...
355     -----END PGP MESSAGE-----
356     <BLANKLINE>
357     --boundsep--
358     >>> signedAndEncrypted = m.signAndEncrypt(header)
359     >>> signedAndEncrypted.set_boundary('boundsep')
360     >>> print flatten(signedAndEncrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE
361     Content-Type: multipart/encrypted;
362         protocol="application/pgp-encrypted";
363         micalg="pgp-sha1"; boundary="boundsep"
364     MIME-Version: 1.0
365     Content-Disposition: inline
366     <BLANKLINE>
367     --boundsep
368     Content-Type: application/pgp-encrypted
369     MIME-Version: 1.0
370     Content-Transfer-Encoding: 7bit
371     <BLANKLINE>
372     Version: 1
373     <BLANKLINE>
374     --boundsep
375     MIME-Version: 1.0
376     Content-Transfer-Encoding: 7bit
377     Content-Type: application/octet-stream; charset="us-ascii"
378     <BLANKLINE>
379     -----BEGIN PGP MESSAGE-----
380     ...
381     -----END PGP MESSAGE-----
382     <BLANKLINE>
383     --boundsep--
384     """
385     def __init__(self, body):
386         self.body = body
387     def clearBodyPart(self):
388         body = encodedMIMEText(self.body)
389         body.add_header('Content-Disposition', 'inline')
390         return body
391     def passphrase_arg(self, passphrase=None):
392         if passphrase == None and PASSPHRASE != None:
393             passphrase = PASSPHRASE
394         if passphrase == None:
395             return (None,'')
396         return (passphrase, pgp_stdin_passphrase_arg)
397     def plain(self):
398         """
399         text/plain
400         """
401         return encodedMIMEText(self.body)
402     def sign(self, header, passphrase=None):
403         """
404         multipart/signed
405           +-> text/plain                 (body)
406           +-> application/pgp-signature  (signature)
407         """
408         passphrase,pass_arg = self.passphrase_arg(passphrase)
409         body = self.clearBodyPart()
410         bfile = tempfile.NamedTemporaryFile()
411         bfile.write(flatten(body))
412         bfile.flush()
413
414         args = replace(pgp_sign_command, 'f', bfile.name)
415         if PGP_SIGN_AS == None:
416             pgp_sign_as = '<%s>' % source_email(header)
417         else:
418             pgp_sign_as = PGP_SIGN_AS
419         args = replace(args, 'a', pgp_sign_as)
420         args = replace(args, 'p', pass_arg)
421         status,output,error = execute(args, stdin=passphrase)
422         signature = output
423
424         sig = MIMEApplication(_data=signature,
425                               _subtype='pgp-signature; name="signature.asc"',
426                               _encoder=encode_7or8bit)
427         sig['Content-Description'] = 'signature'
428         sig.set_charset('us-ascii')
429
430         msg = MIMEMultipart('signed', micalg='pgp-sha1',
431                             protocol='application/pgp-signature')
432         msg.attach(body)
433         msg.attach(sig)
434
435         msg['Content-Disposition'] = 'inline'
436         return msg
437     def encrypt(self, header, passphrase=None):
438         """
439         multipart/encrypted
440          +-> application/pgp-encrypted  (control information)
441          +-> application/octet-stream   (body)
442         """
443         body = self.clearBodyPart()
444         bfile = tempfile.NamedTemporaryFile()
445         bfile.write(flatten(body))
446         bfile.flush()
447
448         recipients = [replace(pgp_recipient_arg, 'r', recipient)
449                       for recipient in target_emails(header)]
450         recipient_string = ' '.join(recipients)
451         args = replace(pgp_encrypt_only_command, 'R', recipient_string)
452         args = replace(args, 'f', bfile.name)
453         if PGP_SIGN_AS == None:
454             pgp_sign_as = '<%s>' % source_email(header)
455         else:
456             pgp_sign_as = PGP_SIGN_AS
457         args = replace(args, 'a', pgp_sign_as)
458         status,output,error = execute(args)
459         encrypted = output
460
461         enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
462                               _encoder=encode_7or8bit)
463         enc.set_charset('us-ascii')
464
465         control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted',
466                                   _encoder=encode_7or8bit)
467
468         msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
469                             protocol='application/pgp-encrypted')
470         msg.attach(control)
471         msg.attach(enc)
472
473         msg['Content-Disposition'] = 'inline'
474         return msg
475     def signAndEncrypt(self, header, passphrase=None):
476         """
477         multipart/encrypted
478          +-> application/pgp-encrypted  (control information)
479          +-> application/octet-stream   (body)
480         """
481         passphrase,pass_arg = self.passphrase_arg(passphrase)
482         body = self.sign(header, passphrase)
483         body.__delitem__('Bcc')
484         bfile = tempfile.NamedTemporaryFile()
485         bfile.write(flatten(body))
486         bfile.flush()
487
488         recipients = [replace(pgp_recipient_arg, 'r', recipient)
489                       for recipient in target_emails(header)]
490         recipient_string = ' '.join(recipients)
491         args = replace(pgp_encrypt_only_command, 'R', recipient_string)
492         args = replace(args, 'f', bfile.name)
493         if PGP_SIGN_AS == None:
494             pgp_sign_as = '<%s>' % source_email(header)
495         else:
496             pgp_sign_as = PGP_SIGN_AS
497         args = replace(args, 'a', pgp_sign_as)
498         args = replace(args, 'p', pass_arg)
499         status,output,error = execute(args, stdin=passphrase)
500         encrypted = output
501
502         enc = MIMEApplication(_data=encrypted, _subtype='octet-stream',
503                               _encoder=encode_7or8bit)
504         enc.set_charset('us-ascii')
505
506         control = MIMEApplication(_data='Version: 1\n',
507                                   _subtype='pgp-encrypted',
508                                   _encoder=encode_7or8bit)
509
510         msg = MIMEMultipart('encrypted', micalg='pgp-sha1',
511                             protocol='application/pgp-encrypted')
512         msg.attach(control)
513         msg.attach(enc)
514
515         msg['Content-Disposition'] = 'inline'
516         return msg
517
518 def test():
519     import doctest
520     doctest.testmod()
521
522
523 if __name__ == '__main__':
524     from optparse import OptionParser
525
526     parser = OptionParser(usage=usage)
527     parser.add_option('-t', '--test', dest='test', action='store_true',
528                       help='Run doctests and exit')
529
530     parser.add_option('-H', '--header-file', dest='header_filename',
531                       help='file containing email header', metavar='FILE')
532     parser.add_option('-B', '--body-file', dest='body_filename',
533                       help='file containing email body', metavar='FILE')
534
535     parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
536                       help='file containing gpg passphrase', metavar='FILE')
537     parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
538                       help='file descriptor from which to read gpg passphrase (0 for stdin)',
539                       type="int", metavar='DESCRIPTOR')
540
541     parser.add_option('--mode', dest='mode', default='sign',
542                       help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'.  Defaults to %default.",
543                       metavar='MODE')
544
545     parser.add_option('-a', '--sign-as', dest='sign_as',
546                       help="The gpg key to sign with (gpg's -u/--local-user)",
547                       metavar='KEY')
548
549     parser.add_option('--output', dest='output', action='store_true',
550                       help="Don't mail the generated message, print it to stdout instead.")
551
552     (options, args) = parser.parse_args()
553
554     stdin_used = False
555
556     if options.passphrase_file != None:
557         PASSPHRASE = file(options.passphrase_file, 'r').read()
558     elif options.passphrase_fd != None:
559         if options.passphrase_fd == 0:
560             stdin_used = True
561             PASSPHRASE = sys.stdin.read()
562         else:
563             PASSPHRASE = os.read(options.passphrase_fd)
564
565     if options.sign_as:
566         PGP_SIGN_AS = options.sign_as
567
568     if options.test == True:
569         test()
570         sys.exit(0)
571
572     header = None
573     if options.header_filename != None:
574         if options.header_filename == '-':
575             assert stdin_used == False
576             stdin_used = True
577             header = sys.stdin.read()
578         else:
579             header = file(options.header_filename, 'r').read()
580     if header == None:
581         raise Exception, "missing header"
582     headermsg = header_from_text(header)
583     body = None
584     if options.body_filename != None:
585         if options.body_filename == '-':
586             assert stdin_used == False
587             stdin_used = True
588             body = sys.stdin.read()
589         else:
590             body = file(options.body_filename, 'r').read()
591     if body == None:
592         raise Exception, "missing body"
593
594     m = PGPMimeMessageFactory(body)
595     if options.mode == "sign":
596         bodymsg = m.sign(header)
597     elif options.mode == "encrypt":
598         bodymsg = m.encrypt(header)
599     elif options.mode == "sign-encrypt":
600         bodymsg = m.signAndEncrypt(header)
601     elif options.mode == "plain":
602         bodymsg = m.plain()
603     else:
604         print "Unrecognized mode '%s'" % options.mode
605
606     message = attach_root(headermsg, bodymsg)
607     if options.output == True:
608         message = flatten(message)
609         print message
610     else:
611         mail(message, sendmail)