5 Python module and command line tool for sending pgp/mime email.
7 Mostly uses subprocess to call gpg and a sendmail-compatible mailer
8 (defaults to msmtp). If you lack gpg, either don't use the encryption
9 functions or adjust the pgp_* commands. If you don't use msmtp,
10 adjust the sendmail command.
13 from cStringIO import StringIO
16 #import GnuPGInterface # Maybe should use this instead of subprocess
23 from email.mime.text import MIMEText
24 from email.mime.multipart import MIMEMultipart
25 from email.mime.application import MIMEApplication
26 from email.generator import Generator
27 from email.encoders import encode_7or8bit
28 from email.utils import getaddress
29 from email import message_from_string
31 # adjust to old python 2.4
32 from email.MIMEText import MIMEText
33 from email.MIMEMultipart import MIMEMultipart
34 from email.MIMENonMultipart import MIMENonMultipart
35 from email.Generator import Generator
36 from email.Encoders import encode_7or8bit
37 from email.Utils import getaddresses
38 from email import message_from_string
40 getaddress = getaddresses
41 class MIMEApplication (MIMENonMultipart):
42 def __init__(self, _data, _subtype, _encoder, **params):
43 MIMENonMultipart.__init__(self, 'application', _subtype, **params)
44 self.set_payload(_data)
47 usage="""usage: %prog [options]
49 Scriptable PGP MIME email using gpg.
51 You can use gpg-agent for passphrase caching if your key requires a
52 passphrase (it better!). Example usage would be to install gpg-agent,
55 eval $(gpg-agent --daemon)
56 in your shell before invoking this script. See gpg-agent(1) for more
57 details. Alternatively, you can send your passphrase in on stdin
58 echo 'passphrase' | %prog [options]
59 or use the --passphrase-file option
60 %prog [options] --passphrase-file FILE [more options]
61 Both of these alternatives are much less secure than gpg-agent. You
69 # The following commands are adapted from my .mutt/pgp configuration
71 # Printf-like sequences:
72 # %a The value of PGP_SIGN_AS.
73 # %f Expands to the name of a file with text to be signed/encrypted.
74 # %p Expands to the passphrase argument.
75 # %R A string with some number (0 on up) of pgp_reciepient_arg
77 # %r One key ID (e.g. recipient email address) to build a
78 # pgp_reciepient_arg string.
80 # The above sequences can be used to optionally print a string if
81 # their length is nonzero. For example, you may only want to pass the
82 # -u/--local-user argument to gpg if PGP_SIGN_AS is defined. To
83 # optionally print a string based upon one of the above sequences, the
84 # following construct is used
85 # %?<sequence_char>?<optional_string>?
86 # where sequence_char is a character from the table above, and
87 # optional_string is the string you would like printed if status_char
88 # is nonzero. optional_string may contain other sequence as well as
89 # normal text, but it may not contain any question marks.
91 # see http://codesorcery.net/old/mutt/mutt-gnupg-howto
92 # http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign
93 # http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html
96 pgp_recipient_arg='-r "%r"'
97 pgp_stdin_passphrase_arg='--passphrase-fd 0'
98 pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f'
99 pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f'
100 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'
101 sendmail='/usr/bin/msmtp -t'
103 def execute(args, stdin=None, expect=(0,)):
105 Execute a command (allows us to drive gpg).
107 if verboseInvoke == True:
108 print >> sys.stderr, '$ '+args
110 p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True)
112 strerror = '%s\nwhile executing %s' % (e.args[1], args)
113 raise Exception, strerror
114 output, error = p.communicate(input=stdin)
116 if verboseInvoke == True:
117 print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error)
118 if status not in expect:
119 strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status)
120 raise Exception, strerror
121 return status, output, error
123 def replace(template, format_char, replacement_text):
125 >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in')
126 '--textmode %?a?-u %a? file.in'
127 >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY')
128 '--textmode -u 0xHEXKEY %f'
129 >>> replace('--textmode %?a?-u %a? %f', 'a', '')
132 if replacement_text == None:
133 replacement_text = ""
134 regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]')
135 if len(replacement_text) > 0:
136 str = regexp.sub('\g<1>', template)
138 str = regexp.sub('', template)
139 regexp = re.compile('%'+format_char)
140 str = regexp.sub(replacement_text, str)
145 Produce flat text output from an email Message instance.
149 g = Generator(fp, mangle_from_=False, maxheaderlen=60)
154 def source_email(msg):
156 Search the header of an email Message instance to find the
157 sender's email address.
159 froms = msg.get_all('from', [])
160 from_tuples = getaddresses(froms) # [(realname, email_address), ...]
161 assert len(from_tuples) == 1
162 return [addr[1] for addr in from_tuples][0]
164 def target_emails(msg):
166 Search the header of an email Message instance to find a
167 list of recipient's email addresses.
169 tos = msg.get_all('to', [])
170 ccs = msg.get_all('cc', [])
171 bccs = msg.get_all('bcc', [])
172 resent_tos = msg.get_all('resent-to', [])
173 resent_ccs = msg.get_all('resent-cc', [])
174 resent_bccs = msg.get_all('resent-bcc', [])
175 all_recipients = getaddresses(tos + ccs + bccs + resent_tos + resent_ccs + resent_bccs)
176 return [addr[1] for addr in all_recipients]
178 def mail(msg, sendmail=None):
180 Send an email Message instance on its merry way.
182 We can shell out to the user specified sendmail in case
183 the local host doesn't have an SMTP server set up
184 for easy smtplib usage.
187 execute(sendmail, stdin=flatten(msg))
191 s.sendmail(from_addr=source_email(msg),
192 to_addrs=target_emails(msg),
198 See http://www.ietf.org/rfc/rfc3156.txt for specification details.
199 >>> m = Mail('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing']), 'check 1 2\\ncheck 1 2\\n')
200 >>> print m.sourceEmail()
202 >>> print m.targetEmails()
204 >>> print flatten(m.clearBodyPart())
205 Content-Type: text/plain; charset="us-ascii"
207 Content-Transfer-Encoding: 7bit
208 Content-Type: text/plain
209 Content-Disposition: inline
214 >>> signed = m.sign()
215 >>> signed.set_boundary('boundsep')
216 >>> print m.stripSig(flatten(signed)).replace('\\t', ' '*4)
217 Content-Type: multipart/signed;
218 protocol="application/pgp-signature";
219 micalg="pgp-sha1"; boundary="boundsep"
224 Content-Disposition: inline
227 Content-Type: text/plain; charset="us-ascii"
229 Content-Transfer-Encoding: 7bit
230 Content-Type: text/plain
231 Content-Disposition: inline
238 Content-Transfer-Encoding: 7bit
239 Content-Description: signature
240 Content-Type: application/pgp-signature; name="signature.asc";
243 -----BEGIN PGP SIGNATURE-----
244 SIGNATURE STRIPPED (depends on current time)
245 -----END PGP SIGNATURE-----
248 >>> encrypted = m.encrypt()
249 >>> encrypted.set_boundary('boundsep')
250 >>> print m.stripPGP(flatten(encrypted)).replace('\\t', ' '*4)
251 Content-Type: multipart/encrypted;
252 protocol="application/pgp-encrypted";
253 micalg="pgp-sha1"; boundary="boundsep"
258 Content-Disposition: inline
261 Content-Type: application/pgp-encrypted
263 Content-Transfer-Encoding: 7bit
269 Content-Transfer-Encoding: 7bit
270 Content-Type: application/octet-stream; charset="us-ascii"
272 -----BEGIN PGP MESSAGE-----
273 MESSAGE STRIPPED (depends on current time)
274 -----END PGP MESSAGE-----
277 >>> signedAndEncrypted = m.signAndEncrypt()
278 >>> signedAndEncrypted.set_boundary('boundsep')
279 >>> print m.stripPGP(flatten(signedAndEncrypted)).replace('\\t', ' '*4)
280 Content-Type: multipart/encrypted;
281 protocol="application/pgp-encrypted";
282 micalg="pgp-sha1"; boundary="boundsep"
287 Content-Disposition: inline
290 Content-Type: application/pgp-encrypted
292 Content-Transfer-Encoding: 7bit
298 Content-Transfer-Encoding: 7bit
299 Content-Type: application/octet-stream; charset="us-ascii"
301 -----BEGIN PGP MESSAGE-----
302 MESSAGE STRIPPED (depends on current time)
303 -----END PGP MESSAGE-----
307 def __init__(self, header, body):
311 self.headermsg = message_from_string(self.header)
312 def sourceEmail(self):
313 return source_email(self.headermsg)
314 def targetEmails(self):
315 return target_emails(self.headermsg)
316 def clearBodyPart(self):
317 body = MIMEText(self.body)
318 body.add_header('Content-Type', 'text/plain')
319 body.add_header('Content-Disposition', 'inline')
320 body.set_charset('us-ascii')
322 def passphrase_arg(self, passphrase=None):
323 if passphrase == None and PASSPHRASE != None:
324 passphrase = PASSPHRASE
325 if passphrase == None:
327 return (passphrase, pgp_stdin_passphrase_arg)
328 def sign(self, passphrase=None):
331 +-> text/plain (body)
332 +-> application/pgp-signature (signature)
334 passphrase,pass_arg = self.passphrase_arg(passphrase)
335 body = self.clearBodyPart()
336 bfile = tempfile.NamedTemporaryFile()
337 bfile.write(flatten(body))
340 args = replace(pgp_sign_command, 'f', bfile.name)
341 if PGP_SIGN_AS == None:
342 pgp_sign_as = '<%s>' % self.sourceEmail()
344 pgp_sign_as = PGP_SIGN_AS
345 args = replace(args, 'a', pgp_sign_as)
346 args = replace(args, 'p', pass_arg)
347 status,output,error = execute(args, stdin=passphrase)
350 sig = MIMEApplication(_data=signature, _subtype='pgp-signature; name="signature.asc"', _encoder=encode_7or8bit)
351 sig['Content-Description'] = 'signature'
352 sig.set_charset('us-ascii')
354 msg = MIMEMultipart('signed', micalg='pgp-sha1', protocol='application/pgp-signature')
358 for k,v in self.headermsg.items():
360 msg['Content-Disposition'] = 'inline'
362 def encrypt(self, passphrase=None):
365 +-> application/pgp-encrypted (control information)
366 +-> application/octet-stream (body)
368 body = self.clearBodyPart()
369 bfile = tempfile.NamedTemporaryFile()
370 bfile.write(flatten(body))
373 recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
374 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
375 args = replace(args, 'f', bfile.name)
376 if PGP_SIGN_AS == None:
377 pgp_sign_as = '<%s>' % self.sourceEmail()
379 pgp_sign_as = PGP_SIGN_AS
380 args = replace(args, 'a', pgp_sign_as)
381 status,output,error = execute(args)
384 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
385 enc.set_charset('us-ascii')
387 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
389 msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
393 for k,v in self.headermsg.items():
395 msg['Content-Disposition'] = 'inline'
397 def signAndEncrypt(self, passphrase=None):
400 +-> application/pgp-encrypted (control information)
401 +-> application/octet-stream (body)
403 passphrase,pass_arg = self.passphrase_arg(passphrase)
405 body.__delitem__('Bcc')
406 bfile = tempfile.NamedTemporaryFile()
407 bfile.write(flatten(body))
410 recipient_string = ' '.join([replace(pgp_recipient_arg, 'r', recipient) for recipient in self.targetEmails()])
411 args = replace(pgp_encrypt_only_command, 'R', recipient_string)
412 args = replace(args, 'f', bfile.name)
413 if PGP_SIGN_AS == None:
414 pgp_sign_as = '<%s>' % self.sourceEmail()
416 pgp_sign_as = PGP_SIGN_AS
417 args = replace(args, 'a', pgp_sign_as)
418 args = replace(args, 'p', pass_arg)
419 status,output,error = execute(args, stdin=passphrase)
422 enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', _encoder=encode_7or8bit)
423 enc.set_charset('us-ascii')
425 control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', _encoder=encode_7or8bit)
427 msg = MIMEMultipart('encrypted', micalg='pgp-sha1', protocol='application/pgp-encrypted')
431 for k,v in self.headermsg.items():
433 msg['Content-Disposition'] = 'inline'
435 def stripChanging(self, text, start, stop, replacement):
438 for line in text.splitlines():
440 if stripping == False:
444 lines.append(replacement)
449 return '\n'.join(lines)
450 def stripSig(self, text):
451 return self.stripChanging(text,
452 '-----BEGIN PGP SIGNATURE-----',
453 '-----END PGP SIGNATURE-----',
454 'SIGNATURE STRIPPED (depends on current time)')
455 def stripPGP(self, text):
456 return self.stripChanging(text,
457 '-----BEGIN PGP MESSAGE-----',
458 '-----END PGP MESSAGE-----',
459 'MESSAGE STRIPPED (depends on current time)')
466 if __name__ == '__main__':
467 from optparse import OptionParser
469 parser = OptionParser(usage=usage)
470 parser.add_option('-t', '--test', dest='test', action='store_true',
471 help='Run doctests and exit')
473 parser.add_option('-H', '--header-file', dest='header_filename',
474 help='file containing email header', metavar='FILE')
475 parser.add_option('-B', '--body-file', dest='body_filename',
476 help='file containing email body', metavar='FILE')
478 parser.add_option('-P', '--passphrase-file', dest='passphrase_file',
479 help='file containing gpg passphrase', metavar='FILE')
480 parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd',
481 help='file descriptor from which to read gpg passphrase (0 for stdin)',
482 type="int", metavar='DESCRIPTOR')
484 parser.add_option('--mode', dest='mode', default='sign',
485 help="One of 'sign', 'encrypt', or 'sign-encrypt'. Defaults to %default.",
488 parser.add_option('-a', '--sign-as', dest='sign_as',
489 help="The gpg key to sign with (gpg's -u/--local-user)",
492 parser.add_option('--output', dest='output', action='store_true',
493 help="Don't mail the generated message, print it to stdout instead.")
495 (options, args) = parser.parse_args()
499 if options.passphrase_file != None:
500 PASSPHRASE = file(options.passphrase_file, 'r').read()
501 elif options.passphrase_fd != None:
502 if options.passphrase_fd == 0:
504 PASSPHRASE = sys.stdin.read()
506 PASSPHRASE = os.read(options.passphrase_fd)
509 PGP_SIGN_AS = options.sign_as
511 if options.test == True:
516 if options.header_filename != None:
517 if options.header_filename == '-':
518 assert stdin_used == False
520 header = sys.stdin.read()
522 header = file(options.header_filename, 'r').read()
524 raise Exception, "missing header"
526 if options.body_filename != None:
527 if options.body_filename == '-':
528 assert stdin_used == False
530 body = sys.stdin.read()
532 body = file(options.body_filename, 'r').read()
534 raise Exception, "missing body"
536 m = Mail(header, body)
537 if options.mode == "sign":
539 elif options.mode == "encrypt":
540 message = m.encrypt()
541 elif options.mode == "sign-encrypt":
542 message = m.signAndEncrypt()
544 print "Unrecognized mode '%s'" % options.mode
546 if options.output == True:
547 message = flatten(message)
550 mail(message, sendmail)