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